Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ reports/
.idea/

coverage/
AGENTS.md
AGENTS.md
CLAUDE.md
114 changes: 110 additions & 4 deletions src/parsers/pnpm-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { uniquePathArrays } from "../utils/array.js";
export function loadFromPnpmLock(filePath: string, prodOnly: boolean): PackageRef[] {
const content = fs.readFileSync(filePath, "utf8");
const parsed = YAML.parse(content) as any;
const majorVersion = parseInt(String(parsed?.lockfileVersion ?? "0"), 10);
return majorVersion >= 9 ? loadV9(parsed, prodOnly) : loadLegacy(parsed, prodOnly);
}

function loadLegacy(parsed: any, prodOnly: boolean): PackageRef[] {
const packagesSection = parsed?.packages ?? {};
const importers = parsed?.importers ?? {};
const graph = new Map<string, string[]>();
Expand Down Expand Up @@ -58,18 +63,94 @@ export function loadFromPnpmLock(filePath: string, prodOnly: boolean): PackageRe
}

const queue = rootDeps.map(dep => ({ key: dep, path: ["project"] as string[] }));
const seenPaths = new Set<string>();
const seenKeys = new Set<string>();

while (queue.length > 0) {
const current = queue.shift()!;
if (seenKeys.has(current.key)) continue;
seenKeys.add(current.key);

const ref = parsePnpmPackageKey(current.key);
if (!ref) continue;

const nextPath = [...current.path, ref.name];
const pathFingerprint = `${current.key}|${nextPath.join(">")}`;
if (seenPaths.has(pathFingerprint)) continue;
seenPaths.add(pathFingerprint);
const pkgKey = `${ref.name}@${ref.version}`;
const pkg = map.get(pkgKey);
if (pkg) {
pkg.paths = uniquePathArrays([...(pkg.paths ?? []), nextPath]).slice(0, 5);
}

const children = graph.get(current.key) ?? [];
for (const child of children) {
queue.push({ key: child, path: nextPath });
}
}

return [...map.values()];
}

function loadV9(parsed: any, prodOnly: boolean): PackageRef[] {
const snapshotsSection = parsed?.snapshots ?? {};
const importers = parsed?.importers ?? {};
const graph = new Map<string, string[]>();
const map = new Map<string, PackageRef>();

for (const [key, meta] of Object.entries<any>(snapshotsSection)) {
const ref = parsePnpmPackageKeyV9(String(key));
if (!ref) continue;

const depKeys = new Set<string>();
for (const depMap of [meta?.dependencies, meta?.optionalDependencies]) {
if (!depMap || typeof depMap !== "object") continue;
for (const [depName, depRef] of Object.entries<any>(depMap)) {
const resolved = normalizePnpmDepRefV9(String(depName), depRef);
if (resolved) depKeys.add(resolved);
}
}

graph.set(ref.key, [...depKeys]);
const dev = !!meta?.dev;
if (prodOnly && dev) continue;
upsertPackage(map, { name: ref.name, version: ref.version, ecosystem: "npm", dev, paths: [] });
}

const rootDeps: string[] = [];
for (const importer of Object.values<any>(importers)) {
for (const depSectionName of ["dependencies", "optionalDependencies", "devDependencies"]) {
if (prodOnly && depSectionName === "devDependencies") continue;
const depSection = importer?.[depSectionName];
if (!depSection || typeof depSection !== "object") continue;
for (const [depName, depRef] of Object.entries<any>(depSection)) {
const resolved = normalizePnpmDepRefV9(String(depName), depRef);
if (resolved) {
rootDeps.push(resolved);
} else {
const fallbackVersion = normalizeRawVersion(depRef);
if (fallbackVersion) {
upsertPackage(map, {
name: String(depName),
version: fallbackVersion,
ecosystem: "npm",
paths: [["project", String(depName)]]
});
}
}
}
}
}

const queue = rootDeps.map(dep => ({ key: dep, path: ["project"] as string[] }));
const seenKeys = new Set<string>();

while (queue.length > 0) {
const current = queue.shift()!;
if (seenKeys.has(current.key)) continue;
seenKeys.add(current.key);

const ref = parsePnpmPackageKeyV9(current.key);
if (!ref) continue;

const nextPath = [...current.path, ref.name];
const pkgKey = `${ref.name}@${ref.version}`;
const pkg = map.get(pkgKey);
if (pkg) {
Expand All @@ -93,6 +174,16 @@ function parsePnpmPackageKey(key: string): { key: string; name: string; version:
return { key, name, version };
}

function parsePnpmPackageKeyV9(key: string): { key: string; name: string; version: string } | null {
const cleaned = key.split("(")[0]; // strip peer-dep suffix e.g. handlebars@4.7.8(foo@1.0.0)
const idx = cleaned.lastIndexOf("@");
if (idx <= 0) return null; // no @ or @ is the first char
const name = cleaned.slice(0, idx);
const version = cleaned.slice(idx + 1);
if (!name || !version) return null;
return { key: cleaned, name, version };
}

function normalizePnpmDepRef(depName: string, depRef: unknown): string | null {
if (typeof depRef === "string") {
const cleaned = depRef.replace(/^link:/, "").replace(/^workspace:/, "").split("(")[0];
Expand All @@ -109,3 +200,18 @@ function normalizePnpmDepRef(depName: string, depRef: unknown): string | null {

return null;
}

function normalizePnpmDepRefV9(depName: string, depRef: unknown): string | null {
if (typeof depRef === "string") {
const cleaned = depRef.replace(/^link:/, "").replace(/^workspace:/, "").split("(")[0];
if (!cleaned || cleaned.startsWith(".") || cleaned.startsWith("..")) return null;
if (looksLikeVersion(cleaned)) return `${depName}@${cleaned}`;
}

if (depRef && typeof depRef === "object") {
const version = (depRef as any).version ?? (depRef as any).specifier;
return normalizePnpmDepRefV9(depName, version);
}

return null;
}
62 changes: 61 additions & 1 deletion tests/parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ describe("pnpm-lock parser", () => {
fs.writeFileSync(
lockPath,
`
lockfileVersion: '9.0'
lockfileVersion: '6.0'
importers:
.:
dependencies:
Expand Down Expand Up @@ -223,6 +223,66 @@ packages:
removeDir(projectDir);
}
});

it("parses v9 lockfiles using snapshots section and name@version keys", () => {
const projectDir = createTempProjectDir();
const lockPath = path.join(projectDir, "pnpm-lock.yaml");

fs.writeFileSync(
lockPath,
`
lockfileVersion: '9.0'
importers:
.:
dependencies:
react:
specifier: ^18.0.0
version: 18.2.0
'@scope/lib':
specifier: ^1.0.0
version: 1.0.0
devDependencies:
jest:
specifier: ^30.0.0
version: 30.3.0
snapshots:
react@18.2.0:
dependencies:
loose-envify: 1.4.0
handlebars: 4.7.8(foo@1.0.0)
loose-envify@1.4.0: {}
'handlebars@4.7.8(foo@1.0.0)': {}
'@scope/lib@1.0.0': {}
jest@30.3.0:
dev: true
`,
"utf8",
);

try {
const allPackages = loadFromPnpmLock(lockPath, false);
const prodPackages = loadFromPnpmLock(lockPath, true);

expect(allPackages).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "react", version: "18.2.0", paths: [["project", "react"]] }),
expect.objectContaining({
name: "loose-envify",
version: "1.4.0",
paths: [["project", "react", "loose-envify"]],
}),
expect.objectContaining({ name: "handlebars", version: "4.7.8" }),
expect.objectContaining({ name: "@scope/lib", version: "1.0.0", paths: [["project", "@scope/lib"]] }),
expect.objectContaining({ name: "jest", version: "30.3.0", dev: true }),
]),
);
expect(prodPackages).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: "jest" })]),
);
} finally {
removeDir(projectDir);
}
});
});

describe("yarn.lock parser", () => {
Expand Down
Loading