Skip to content

Instantly share code, notes, and snippets.

@aweber1
Created February 11, 2026 14:12
Show Gist options
  • Select an option

  • Save aweber1/83c76cc60cef33fc4602a1b100ec7fde to your computer and use it in GitHub Desktop.

Select an option

Save aweber1/83c76cc60cef33fc4602a1b100ec7fde to your computer and use it in GitHub Desktop.
package-lock.json version compare

These scripts compare package versions prescribed in a package-lock.json file with the package versions defined in a package.json file.

The output lists any difference in semantic version ranges. This was particularly useful when migrating from npm to pnpm.

Our package.json had many dependencies that used ^ caret ranges for packages. After initially running pnpm install with our existing package.json, pnpm rightfully installed the latest packages that satisfied the caret range. Unfortunately, some of those packages introduced bugs or issues that we didn't have before.

So I wanted a script that would compare the package versions in the "old" package-lock.json with what was declared in package.json, then I could adjust package.json to either pin to a specific version of packages or use the ~ patch range specifier.

import { rangeSatisfies } from './range-satisfies';
import type { PackageJsonLike, PackageLockV2 } from './types';
export type ComparisonResult =
| {
kind: 'ok';
name: string;
dependencyType: 'dependencies' | 'devDependencies';
range: string;
lockedVersion: string;
}
| {
kind: 'missing-in-lock';
name: string;
dependencyType: 'dependencies' | 'devDependencies';
range: string;
}
| {
kind: 'range-mismatch';
name: string;
dependencyType: 'dependencies' | 'devDependencies';
range: string;
lockedVersion: string;
};
function getLockedVersion(lock: PackageLockV2, packageName: string): string | null {
const packages = lock.packages;
if (packages) {
const key = `node_modules/${packageName}`;
const fromPackages = packages[key]?.version;
if (fromPackages) return fromPackages;
}
const fromDependencies = lock.dependencies?.[packageName]?.version;
if (fromDependencies) return fromDependencies;
return null;
}
function compareDependencyGroup(
dependencyType: 'dependencies' | 'devDependencies',
group: Record<string, string> | undefined,
lock: PackageLockV2
): ComparisonResult[] {
if (!group) return [];
const results: ComparisonResult[] = [];
for (const [name, range] of Object.entries(group)) {
const lockedVersion = getLockedVersion(lock, name);
if (!lockedVersion) {
results.push({ kind: 'missing-in-lock', name, dependencyType, range });
continue;
}
if (!rangeSatisfies(range, lockedVersion)) {
results.push({
kind: 'range-mismatch',
name,
dependencyType,
range,
lockedVersion,
});
continue;
}
results.push({ kind: 'ok', name, dependencyType, range, lockedVersion });
}
return results;
}
/**
* Compares direct dependencies (dependencies + devDependencies) in package.json
* against the resolved versions in package-lock.json.
*/
export function compareDirectDependencies(input: {
packageJson: PackageJsonLike;
lock: PackageLockV2;
}): ComparisonResult[] {
const dependencyResults = compareDependencyGroup(
'dependencies',
input.packageJson.dependencies,
input.lock
);
const devDependencyResults = compareDependencyGroup(
'devDependencies',
input.packageJson.devDependencies,
input.lock
);
return [...dependencyResults, ...devDependencyResults].sort((a, b) =>
a.name.localeCompare(b.name)
);
}
import { bumpMajor, bumpMinor, bumpPatch, compareSemVer, parseSemVer } from './semver';
type ComparatorOperator = '<' | '<=' | '>' | '>=';
function compareWithOperator(operator: ComparatorOperator, left: number, right: number): boolean {
if (operator === '<') return left < right;
if (operator === '<=') return left <= right;
if (operator === '>') return left > right;
return left >= right;
}
function satisfiesComparator(version: string, comparator: string): boolean {
const trimmed = comparator.trim();
if (!trimmed) return true;
const match = /^(>=|<=|>|<)\s*(.+)$/.exec(trimmed);
if (!match) {
// If it doesn't look like a comparator, treat it as an exact version.
return rangeSatisfies(trimmed, version);
}
const operator = match[1] as ComparatorOperator;
const targetRaw = match[2].trim();
const parsedVersion = parseSemVer(version);
const parsedTarget = parseSemVer(targetRaw);
if (!parsedVersion || !parsedTarget) return false;
const cmp = compareSemVer(parsedVersion, parsedTarget);
return compareWithOperator(operator, cmp, 0);
}
function satisfiesTildeRange(range: string, version: string): boolean {
const base = parseSemVer(range.slice(1));
const parsedVersion = parseSemVer(version);
if (!base || !parsedVersion) return false;
const lowerOk = compareSemVer(parsedVersion, base) >= 0;
const upperBound = bumpMinor(base);
const upperOk = compareSemVer(parsedVersion, upperBound) < 0;
return lowerOk && upperOk;
}
function satisfiesCaretRange(range: string, version: string): boolean {
const base = parseSemVer(range.slice(1));
const parsedVersion = parseSemVer(version);
if (!base || !parsedVersion) return false;
const lowerOk = compareSemVer(parsedVersion, base) >= 0;
let upperBound;
if (base.major > 0) {
upperBound = bumpMajor(base);
} else if (base.minor > 0) {
upperBound = bumpMinor(base);
} else {
upperBound = bumpPatch(base);
}
const upperOk = compareSemVer(parsedVersion, upperBound) < 0;
return lowerOk && upperOk;
}
/**
* Returns true if the given lockfile-resolved version is compatible with the
* version range specified in package.json.
*
* Supported range syntax (pragmatic subset):
* - Exact: `1.2.3`
* - Tilde: `~1.2.3`
* - Caret: `^1.2.3`
* - Comparator sets: `>=22 <23` (space-separated)
* - OR sets: `>=1 <2 || >=3 <4`
*/
export function rangeSatisfies(range: string, version: string): boolean {
const trimmedRange = range.trim();
const trimmedVersion = version.trim();
if (!trimmedRange) return false;
if (!trimmedVersion) return false;
// Non-semver specs: fall back to string equality.
if (
trimmedRange.startsWith('workspace:') ||
trimmedRange.startsWith('file:') ||
trimmedRange.startsWith('link:')
) {
return trimmedRange === trimmedVersion;
}
// Support OR sets.
const orParts = trimmedRange.split('||').map((part) => part.trim());
if (orParts.length > 1) {
return orParts.some((part) => rangeSatisfies(part, trimmedVersion));
}
if (trimmedRange.startsWith('~')) return satisfiesTildeRange(trimmedRange, trimmedVersion);
if (trimmedRange.startsWith('^')) return satisfiesCaretRange(trimmedRange, trimmedVersion);
// Comparator sets.
if (/^(>=|<=|>|<)/.test(trimmedRange)) {
const comparators = trimmedRange.split(/\s+/).filter(Boolean);
return comparators.every((comparator) => satisfiesComparator(trimmedVersion, comparator));
}
// Exact version.
const parsedVersion = parseSemVer(trimmedVersion);
const parsedTarget = parseSemVer(trimmedRange);
if (!parsedVersion || !parsedTarget) return false;
return compareSemVer(parsedVersion, parsedTarget) === 0;
}
export interface SemVer {
major: number;
minor: number;
patch: number;
prerelease: Array<string | number>;
}
const semverCoreRegex = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?/;
function parsePrerelease(prerelease: string | undefined): Array<string | number> {
if (!prerelease) return [];
return prerelease.split('.').map((segment) => {
if (/^\d+$/.test(segment)) return Number(segment);
return segment;
});
}
/**
* Parses a SemVer-ish string into a comparable structure.
*
* This is intentionally pragmatic (not a full npm `semver` implementation) because
* our package.json uses a small subset of range syntax and we only need consistent
* comparisons against `package-lock.json` resolved versions.
*/
export function parseSemVer(input: string): SemVer | null {
const trimmed = input.trim();
const match = semverCoreRegex.exec(trimmed);
if (!match) return null;
const major = Number(match[1]);
const minor = match[2] === undefined ? 0 : Number(match[2]);
const patch = match[3] === undefined ? 0 : Number(match[3]);
if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return null;
return {
major,
minor,
patch,
prerelease: parsePrerelease(match[4]),
};
}
function comparePrerelease(a: Array<string | number>, b: Array<string | number>): number {
if (a.length === 0 && b.length === 0) return 0;
if (a.length === 0) return 1;
if (b.length === 0) return -1;
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) {
const left = a[i];
const right = b[i];
if (left === undefined) return -1;
if (right === undefined) return 1;
if (left === right) continue;
const leftIsNumber = typeof left === 'number';
const rightIsNumber = typeof right === 'number';
if (leftIsNumber && rightIsNumber) return left < right ? -1 : 1;
if (leftIsNumber && !rightIsNumber) return -1;
if (!leftIsNumber && rightIsNumber) return 1;
return String(left) < String(right) ? -1 : 1;
}
return 0;
}
/**
* Compares two parsed versions.
*
* Returns:
* - `-1` if a < b
* - `0` if a == b
* - `1` if a > b
*/
export function compareSemVer(a: SemVer, b: SemVer): number {
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
return comparePrerelease(a.prerelease, b.prerelease);
}
export function bumpMajor(version: SemVer): SemVer {
return { major: version.major + 1, minor: 0, patch: 0, prerelease: [] };
}
export function bumpMinor(version: SemVer): SemVer {
return { major: version.major, minor: version.minor + 1, patch: 0, prerelease: [] };
}
export function bumpPatch(version: SemVer): SemVer {
return { major: version.major, minor: version.minor, patch: version.patch + 1, prerelease: [] };
}
export interface PackageLockV2 {
name?: string;
lockfileVersion: 2;
requires?: boolean;
packages?: Record<string, PackageLockPackageEntry | undefined>;
dependencies?: Record<string, PackageLockDependencyEntry | undefined>;
}
export interface PackageLockPackageEntry {
version?: string;
resolved?: string;
integrity?: string;
dev?: boolean;
optional?: boolean;
}
export interface PackageLockDependencyEntry {
version?: string;
resolved?: string;
integrity?: string;
dev?: boolean;
optional?: boolean;
requires?: Record<string, string>;
dependencies?: Record<string, PackageLockDependencyEntry | undefined>;
}
export interface PackageJsonLike {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment