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
49 changes: 29 additions & 20 deletions src/parsers/npm-lock-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,22 @@ export function loadNpmLockGraph(
const packages = raw?.packages;
const rawPackages = isRecord(packages) ? (packages as Record<string, RawLockPackage>) : {};
const nodesById = new Map<string, NpmLockNode>();
const nodeIdsByPackageKey = new Map<string, string[]>();
const nodeIdsByPackageKey = new Map<string, Set<string>>();
const nodeIdByPackagePath = new Map<string, string>();
const dependencyRangesByNodeId = new Map<string, Record<string, string>>();
const resolutionBasePathsByNodeId = new Map<string, string[]>();
const parentNodeIdsByChildNodeId = new Map<string, string[]>();
const childNodeIdsByParentNodeId = new Map<string, string[]>();
const parentNodeIdsByChildNodeId = new Map<string, Set<string>>();
const childNodeIdsByParentNodeId = new Map<string, Set<string>>();
const rangeByParentNodeId = new Map<string, Map<string, string>>();
const pathSetByNodeId = new Map<string, Set<string>>();

if (!packages || typeof packages !== "object") {
return createGraph({
entryPackages: [],
nodesById,
nodeIdsByPackageKey,
parentNodeIdsByChildNodeId,
childNodeIdsByParentNodeId,
nodeIdsByPackageKey: setsToArrays(nodeIdsByPackageKey),
parentNodeIdsByChildNodeId: setsToArrays(parentNodeIdsByChildNodeId),
childNodeIdsByParentNodeId: setsToArrays(childNodeIdsByParentNodeId),
rangeByParentNodeId,
pathSetByNodeId,
});
Expand Down Expand Up @@ -65,7 +65,9 @@ export function loadNpmLockGraph(
dev: !!meta?.dev,
});
nodeIdByPackagePath.set(packagePath, id);
nodeIdsByPackageKey.set(packageKey, [...(nodeIdsByPackageKey.get(packageKey) ?? []), id]);
const keySet = nodeIdsByPackageKey.get(packageKey) ?? new Set<string>();
keySet.add(id);
nodeIdsByPackageKey.set(packageKey, keySet);
dependencyRangesByNodeId.set(id, collectDependencyRanges(resolveDependencySource(meta, rawPackages)));
resolutionBasePathsByNodeId.set(id, resolveBasePaths(packagePath, meta));
}
Expand Down Expand Up @@ -130,9 +132,9 @@ export function loadNpmLockGraph(
return createGraph({
entryPackages,
nodesById,
nodeIdsByPackageKey,
parentNodeIdsByChildNodeId,
childNodeIdsByParentNodeId,
nodeIdsByPackageKey: setsToArrays(nodeIdsByPackageKey),
parentNodeIdsByChildNodeId: setsToArrays(parentNodeIdsByChildNodeId),
childNodeIdsByParentNodeId: setsToArrays(childNodeIdsByParentNodeId),
rangeByParentNodeId,
pathSetByNodeId,
});
Expand Down Expand Up @@ -180,8 +182,8 @@ function resolveEdgesForParent(
dependencyRanges: Record<string, string>,
resolutionBasePaths: string[],
nodeIdByPackagePath: Map<string, string>,
childNodeIdsByParentNodeId: Map<string, string[]>,
parentNodeIdsByChildNodeId: Map<string, string[]>,
childNodeIdsByParentNodeId: Map<string, Set<string>>,
parentNodeIdsByChildNodeId: Map<string, Set<string>>,
rangeByParentNodeId: Map<string, Map<string, string>>,
): string[] {
const resolvedChildNodeIds: string[] = [];
Expand All @@ -198,14 +200,13 @@ function resolveEdgesForParent(
const parentNodeId = parentPackagePath ? nodeIdByPackagePath.get(parentPackagePath) : null;
if (!parentNodeId) continue;

childNodeIdsByParentNodeId.set(
parentNodeId,
unique([...(childNodeIdsByParentNodeId.get(parentNodeId) ?? []), childNodeId]),
);
parentNodeIdsByChildNodeId.set(
childNodeId,
unique([...(parentNodeIdsByChildNodeId.get(childNodeId) ?? []), parentNodeId]),
);
const childSet = childNodeIdsByParentNodeId.get(parentNodeId) ?? new Set<string>();
childSet.add(childNodeId);
childNodeIdsByParentNodeId.set(parentNodeId, childSet);

const parentSet = parentNodeIdsByChildNodeId.get(childNodeId) ?? new Set<string>();
parentSet.add(parentNodeId);
parentNodeIdsByChildNodeId.set(childNodeId, parentSet);

const rangesForParent = rangeByParentNodeId.get(parentNodeId) ?? new Map<string, string>();
rangesForParent.set(dependencyName, range);
Expand Down Expand Up @@ -301,6 +302,14 @@ function rememberPath(pathSetByNodeId: Map<string, Set<string>>, nodeId: string,
pathSetByNodeId.set(nodeId, existing);
}

function setsToArrays<K>(map: Map<K, Set<string>>): Map<K, string[]> {
const result = new Map<K, string[]>();
for (const [key, set] of map) {
result.set(key, [...set]);
}
return result;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object";
}
Expand Down
49 changes: 49 additions & 0 deletions tests/parsers/npm-lock-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,55 @@ describe("npm lock graph extraction", () => {
}
});

it("produces no duplicate entries in childrenFor/parentsFor when the same edge appears multiple times in the lockfile", () => {
// Regression test for the Set accumulator refactor (PR #652).
// The old unique([...spread]) pattern deduplicated on every insert.
// The new Set.add() approach must produce the same deduplicated result
// even if the same child-parent pair is encountered more than once
// during graph construction.
const projectDir = createTempProjectDir();
const lockPath = path.join(projectDir, "package-lock.json");

fs.writeFileSync(
lockPath,
JSON.stringify({
name: "fixture",
lockfileVersion: 3,
packages: {
"": {
name: "fixture",
version: "1.0.0",
dependencies: { parent: "^1.0.0" },
},
"node_modules/parent": {
version: "1.0.0",
// child appears in both dependencies and optionalDependencies -
// two declarations of the same edge that should collapse to one.
dependencies: { child: "^1.0.0" },
optionalDependencies: { child: "^1.0.0" },
},
"node_modules/child": { version: "1.0.0" },
},
}),
"utf8",
);

try {
const graph = loadNpmLockGraph(lockPath, { includePaths: true });
const parentId = graph.nodeIdsFor("parent", "1.0.0")[0]!;
const childId = graph.nodeIdsFor("child", "1.0.0")[0]!;

const children = graph.childrenFor(parentId);
const parents = graph.parentsFor(childId);

// Each should appear exactly once despite two declarations of the same edge
expect(children.filter(id => id === childId)).toHaveLength(1);
expect(parents.filter(id => id === parentId)).toHaveLength(1);
} finally {
removeDir(projectDir);
}
});

it("bounds path depth and terminates on graphs with dependency cycles", () => {
// pkg-a → pkg-b → pkg-c → pkg-a (cycle)
const projectDir = createTempProjectDir();
Expand Down