From 14d6c9b6c502af20f9d54a20fc86259a70e51841 Mon Sep 17 00:00:00 2001 From: Bonobo Date: Wed, 2 Sep 2026 13:37:37 +0200 Subject: [PATCH] fix(neo4j): preserve dependency lock provenance --- src/build/neo4j/project.ts | 27 ++++++++++++++------ test/artifacts.test.ts | 50 +++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 6e16c78..1ba3493 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -93,10 +93,21 @@ export function project(app: TSAnalysis, _appName?: string): GraphRows { } } { - const lockIds = Object.values(root.artifacts ?? {}) - .filter((a) => /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|bun\.lock|yarn\.lock|pnpm-lock\.yaml)$/.test(a.path)) - .map((a) => a.id) - .sort(); + const parsedLocks: Record = { + "package-lock.json": true, + "npm-shrinkwrap.json": true, + "bun.lock": true, + }; + const lockByManifest = new Map(); + for (const art of Object.values(root.artifacts ?? {}).sort((a, b) => a.path.localeCompare(b.path))) { + const parts = art.path.split("/"); + const base = parts[parts.length - 1]; + if (!base || parsedLocks[base] !== true) continue; + parts[parts.length - 1] = "package.json"; + const manifest = root.artifacts?.[parts.join("/")]; + if (manifest) lockByManifest.set(manifest.id, art.id); + } + const seen = new Set(); for (const d of root.dependencies ?? []) { const pkgId = purlNpm(d.name); @@ -105,10 +116,10 @@ export function project(app: TSAnalysis, _appName?: string): GraphRows { spec: d.spec || null, kind: d.kind, direct: d.direct, extras: d.extras.length ? d.extras : null, prov: d.prov.length ? d.prov : null, }), d.kind); - if (d.locked_version) { - for (const lockId of lockIds) { - const k = `LOCKS\0${lockId}\0${pkgId}`; - if (seen.has(k)) continue; + const lockId = d.direct ? lockByManifest.get(d.declared_in) : d.declared_in; + if (d.locked_version && lockId) { + const k = `LOCKS\0${lockId}\0${pkgId}`; + if (!seen.has(k)) { seen.add(k); b.edge("LOCKS", { label: "Artifact", keyProp: "id", value: lockId }, pkgRef, prune({ version: d.locked_version })); } diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index b408624..dd2e0e0 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -236,14 +236,62 @@ describe("Neo4j projection — neutral :Artifact/:Package (#101)", () => { expect(decl?.props["direct"]).toBe(true); const transitive = rows.edges.find((e) => e.type === "DECLARES_DEPENDENCY" && e.to.value === "pkg:npm/lockonly-transitive"); expect(transitive?.props["direct"]).toBe(false); - expect(rows.edges.some((e) => e.type === "LOCKS" && e.to.value === "pkg:npm/express")).toBe(true); + const lockSources = (packageId: string): string[] => + rows.edges + .filter((e) => e.type === "LOCKS" && e.to.value === packageId) + .map((e) => String(e.from.value)); + expect(lockSources("pkg:npm/express")).toEqual(["can://artifact/artifacts-app/package-lock.json"]); + expect(lockSources("pkg:npm/lockonly-transitive")).toEqual(["can://artifact/artifacts-app/package-lock.json"]); expect( rows.edges.some( (e) => e.type === "TS_PROVIDES" && e.from.value === "pkg:npm/express" && String(e.to.value).endsWith("/@external/express"), ), ).toBe(true); + expect( rows.edges.some((e) => e.type === "TS_UNRESOLVED_IMPORT" && String(e.to.value).endsWith("/@external/left-pad")), ).toBe(true); }); + test("each locked dependency is attributed only to its owning lockfile", () => { + const analysis = structuredClone(r1.application); + const application = analysis.application; + const rootManifest = application.artifacts["package.json"]; + const rootLock = application.artifacts["package-lock.json"]; + if (!rootManifest || !rootLock) throw new Error("fixture manifests are missing"); + + const manifestId = "can://artifact/artifacts-app/packages/api/package.json"; + const lockId = "can://artifact/artifacts-app/packages/api/package-lock.json"; + application.artifacts["packages/api/package.json"] = { + ...rootManifest, + id: manifestId, + path: "packages/api/package.json", + config_keys: [], + }; + application.artifacts["packages/api/package-lock.json"] = { + ...rootLock, + id: lockId, + path: "packages/api/package-lock.json", + config_keys: [], + }; + application.dependencies.push({ + name: "nested-only", + spec: "^1.0.0", + kind: "runtime", + extras: [], + declared_in: manifestId, + direct: true, + locked_version: "1.0.1", + provides_imports: ["nested-only"], + prov: ["declared", "lockfile"], + }); + + const nestedRows = project(analysis); + const sources = (packageId: string): string[] => + nestedRows.edges + .filter((e) => e.type === "LOCKS" && e.to.value === packageId) + .map((e) => String(e.from.value)) + .sort(); + expect(sources("pkg:npm/express")).toEqual(["can://artifact/artifacts-app/package-lock.json"]); + expect(sources("pkg:npm/nested-only")).toEqual([lockId]); + }); });