diff --git a/src/services/index.ts b/src/services/index.ts index 34ac8d80..471b53bf 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -94,11 +94,18 @@ export type { ChangelogEntryDetail, ChangelogPackageInfo, ChangelogReport, + CircularDependencyCycle, DependencyBundle, + DependencyConflict, + DependencyConflictEdge, + DependencyGraph, + DependencyGraphEdge, + DependencyGraphNode, DependencyGroup, DependencyGroupsInfo, DependencyReport, DirectDependency, + EnvironmentMarker, GithubRepository, GroupDependency, PackageChangelogParams, diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index 8e0f5362..327db766 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -1110,7 +1110,7 @@ describe("PackageIntelligenceServiceImpl — packageDependencies", () => { }, dependencyGroups: { primaryGroup: null, - environmentConstraints: null, + environmentMarkers: null, groups: [ { name: "runtime", diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 15653a21..cc543c49 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -160,33 +160,95 @@ export interface DirectDependency { } /** - * Opaque GenericJSON passthrough from the backend. Shape is not yet - * typed — see {@link TransitiveDependencySummary} and - * {@link DependencyGroupsInfo} for the fields that carry this type. - * Consumers should treat entries as `unknown` until the backend - * surfaces a typed contract. - * - * TODO(pkgseer-backend): replace with a concrete typed union once the - * backend documents `dag`, `conflicts`, `circularDependencies`, and - * `environmentConstraints` schemas. Parity tests for the current - * passthrough behaviour will fail loudly when the types tighten, - * prompting a deliberate migration. + * Opaque GenericJSON passthrough. The `package_dependencies` tool + * migrated to typed fields (`dependencyGraph`, `dependencyConflicts`, + * `circularDependencyCycles`, `environmentMarkers`); this type is + * retained for the one remaining passthrough — `ChangelogEntryDetail.metadata`. */ export type UntypedGenericJSON = unknown; +/** + * Node in a typed dependency graph. Mirrors `DependencyGraphNode`. + * `registry` is a lowercase string: `"npm"`, `"pypi"`, …, or + * `"synthetic"` for manifest / project root nodes. `version` is null + * for synthetic roots. + */ +export interface DependencyGraphNode { + registry: string; + name: string; + version?: string; +} + +/** + * Edge in a typed dependency graph. `fromIndex` / `toIndex` index + * into the companion `nodes` list. `fromIndex` is null for + * synthetic-root edges emitted by the resolver. + */ +export interface DependencyGraphEdge { + fromIndex?: number; + toIndex: number; + /** Caller's declared constraint (e.g. `^4.2.3`). Null for implicit edges. */ + constraint?: string; + /** `runtime` / `dev` / `peer` / `optional` / `circular` or registry-specific. */ + dependencyType?: string; +} + +export interface DependencyGraph { + formatVersion: number; + nodes: DependencyGraphNode[]; + edges: DependencyGraphEdge[]; +} + +export interface DependencyConflictEdge { + /** Index into `DependencyGraph.nodes`. Null for synthetic-root edges. */ + fromIndex?: number; + /** Index into `DependencyGraph.nodes`. Never null. */ + toIndex: number; + versionConstraint: string; + dependencyType: string; +} + +export interface DependencyConflict { + packageName: string; + requiredVersions: string[]; + conflictingEdges: DependencyConflictEdge[]; +} + +export interface CircularDependencyCycle { + cycleStart: string; + /** Ordered node names, with the starting node repeated at the end. */ + circularPath: string[]; + /** Pre-joined human-readable display, e.g. `"a → b → c → a"`. */ + displayChain: string; +} + +export interface EnvironmentMarker { + /** `extra` / `python_version` / `sys_platform` / `cfg` / …; null when unclassified. */ + type?: string; + /** Marker value (e.g. `async`, `>= 3.8`). Null when raw-only. */ + value?: string; + /** Original unparsed marker text as it appeared in the package manifest. */ + raw?: string; +} + export interface TransitiveDependencySummary { totalEdges?: number; uniquePackagesCount?: number; uniqueDependencies?: string[]; - /** TODO(pkgseer-backend): type once real shapes are observed. */ - conflicts?: UntypedGenericJSON[]; - /** TODO(pkgseer-backend): type once real shapes are observed. */ - circularDependencies?: UntypedGenericJSON[]; /** - * Raw DAG blob; backend-defined `GenericJSON` passthrough. - * TODO(pkgseer-backend): type once real shapes are observed. + * Typed conflict list. Each `conflictingEdges[].fromIndex` / + * `toIndex` indexes into `dependencyGraph.nodes`. */ - dag?: UntypedGenericJSON; + dependencyConflicts?: DependencyConflict[]; + /** Typed circular-dependency cycles. */ + circularDependencyCycles?: CircularDependencyCycle[]; + /** + * Typed directed-acyclic-graph of transitive dependencies. Kept on + * the service-level result so the future `pkg deps-dag` command can + * consume it without re-querying; deliberately not surfaced on the + * `package_dependencies` envelope. + */ + dependencyGraph?: DependencyGraph; } export interface DependencyBundle { @@ -214,8 +276,7 @@ export interface DependencyGroup { export interface DependencyGroupsInfo { primaryGroup?: string; - /** TODO(pkgseer-backend): type once real shapes are observed. */ - environmentConstraints?: UntypedGenericJSON[]; + environmentMarkers?: EnvironmentMarker[]; groups: DependencyGroup[]; } @@ -648,14 +709,64 @@ const directDependencySchema = z.object({ type: z.string().nullable().optional(), }); +const dependencyGraphNodeSchema = z.object({ + registry: z.string(), + name: z.string(), + version: z.string().nullable().optional(), +}); + +const dependencyGraphEdgeSchema = z.object({ + fromIndex: z.number().int().nullable().optional(), + toIndex: z.number().int(), + constraint: z.string().nullable().optional(), + dependencyType: z.string().nullable().optional(), +}); + +const dependencyGraphSchema = z.object({ + formatVersion: z.number().int(), + nodes: z.array(dependencyGraphNodeSchema), + edges: z.array(dependencyGraphEdgeSchema), +}); + +const dependencyConflictEdgeSchema = z.object({ + fromIndex: z.number().int().nullable().optional(), + toIndex: z.number().int(), + versionConstraint: z.string(), + dependencyType: z.string(), +}); + +const dependencyConflictSchema = z.object({ + packageName: z.string(), + requiredVersions: z.array(z.string()), + conflictingEdges: z.array(dependencyConflictEdgeSchema), +}); + +const circularDependencyCycleSchema = z.object({ + cycleStart: z.string(), + circularPath: z.array(z.string()), + displayChain: z.string(), +}); + +const environmentMarkerSchema = z.object({ + type: z.string().nullable().optional(), + value: z.string().nullable().optional(), + raw: z.string().nullable().optional(), +}); + const transitiveDependencySchema = z .object({ totalEdges: z.number().int().nullable().optional(), uniquePackagesCount: z.number().int().nullable().optional(), uniqueDependencies: z.array(z.string()).nullable().optional(), - conflicts: z.array(z.unknown()).nullable().optional(), - circularDependencies: z.array(z.unknown()).nullable().optional(), - dag: z.unknown().nullable().optional(), + dependencyConflicts: z + .array(dependencyConflictSchema) + .nullable() + .optional(), + circularDependencyCycles: z + .array(circularDependencyCycleSchema) + .nullable() + .optional(), + dependencyGraph: dependencyGraphSchema.nullable().optional(), }) .nullable() .optional(); @@ -689,7 +800,7 @@ const dependencyGroupSchema = z.object({ const dependencyGroupsInfoSchema = z .object({ primaryGroup: z.string().nullable().optional(), - environmentConstraints: z.array(z.unknown()).nullable().optional(), + environmentMarkers: z.array(environmentMarkerSchema).nullable().optional(), groups: z.array(dependencyGroupSchema), }) .nullable() @@ -747,14 +858,44 @@ query PackageDependencies( totalEdges uniquePackagesCount uniqueDependencies - conflicts - circularDependencies - dag + dependencyConflicts { + packageName + requiredVersions + conflictingEdges { + fromIndex + toIndex + versionConstraint + dependencyType + } + } + circularDependencyCycles { + cycleStart + circularPath + displayChain + } + dependencyGraph { + formatVersion + nodes { + registry + name + version + } + edges { + fromIndex + toIndex + constraint + dependencyType + } + } } } dependencyGroups { primaryGroup - environmentConstraints + environmentMarkers { + type + value + raw + } groups { name lifecycle @@ -1344,10 +1485,44 @@ export class PackageIntelligenceServiceImpl bundle.transitive.uniquePackagesCount ?? undefined, uniqueDependencies: bundle.transitive.uniqueDependencies ?? undefined, - conflicts: bundle.transitive.conflicts ?? undefined, - circularDependencies: - bundle.transitive.circularDependencies ?? undefined, - dag: bundle.transitive.dag ?? undefined, + dependencyConflicts: + bundle.transitive.dependencyConflicts?.map((c) => ({ + packageName: c.packageName, + requiredVersions: c.requiredVersions, + conflictingEdges: c.conflictingEdges.map((edge) => ({ + fromIndex: edge.fromIndex ?? undefined, + toIndex: edge.toIndex, + versionConstraint: edge.versionConstraint, + dependencyType: edge.dependencyType, + })), + })) ?? undefined, + circularDependencyCycles: + bundle.transitive.circularDependencyCycles?.map((cycle) => ({ + cycleStart: cycle.cycleStart, + circularPath: cycle.circularPath, + displayChain: cycle.displayChain, + })) ?? undefined, + dependencyGraph: bundle.transitive.dependencyGraph + ? { + formatVersion: + bundle.transitive.dependencyGraph.formatVersion, + nodes: bundle.transitive.dependencyGraph.nodes.map( + (n) => ({ + registry: n.registry, + name: n.name, + version: n.version ?? undefined, + }), + ), + edges: bundle.transitive.dependencyGraph.edges.map( + (e) => ({ + fromIndex: e.fromIndex ?? undefined, + toIndex: e.toIndex, + constraint: e.constraint ?? undefined, + dependencyType: e.dependencyType ?? undefined, + }), + ), + } + : undefined, } : undefined, } @@ -1357,8 +1532,12 @@ export class PackageIntelligenceServiceImpl data.dependencyGroups ? { primaryGroup: data.dependencyGroups.primaryGroup ?? undefined, - environmentConstraints: - data.dependencyGroups.environmentConstraints ?? undefined, + environmentMarkers: + data.dependencyGroups.environmentMarkers?.map((m) => ({ + type: m.type ?? undefined, + value: m.value ?? undefined, + raw: m.raw ?? undefined, + })) ?? undefined, groups: data.dependencyGroups.groups.map((group) => ({ name: group.name, lifecycle: group.lifecycle, diff --git a/src/shared/package-dependencies-response.test.ts b/src/shared/package-dependencies-response.test.ts index 9444d48f..f38d263b 100644 --- a/src/shared/package-dependencies-response.test.ts +++ b/src/shared/package-dependencies-response.test.ts @@ -149,7 +149,7 @@ describe("buildPackageDependenciesSuccessPayload — transitive block", () => { expect(payload.transitive).toBeUndefined(); }); - it("emits transitive block with preprocessed `packages[]` (drops raw dag + uniqueDependencies)", () => { + it("emits transitive block with preprocessed `packages[]` (drops graph + uniqueDependencies)", () => { const fixture = clone(defaultDependencyReport); fixture.dependencies = { direct: fixture.dependencies?.direct, @@ -157,17 +157,27 @@ describe("buildPackageDependenciesSuccessPayload — transitive block", () => { totalEdges: 80, uniquePackagesCount: 45, uniqueDependencies: ["accepts@2.0.0", "body-parser@2.2.2"], - dag: { - n: [ - ["npm", "express", "5.2.1"], - ["npm", "accepts", "2.0.0"], - ["npm", "body-parser", "2.2.2"], + dependencyGraph: { + formatVersion: 4, + nodes: [ + { registry: "npm", name: "express", version: "5.2.1" }, + { registry: "npm", name: "accepts", version: "2.0.0" }, + { registry: "npm", name: "body-parser", version: "2.2.2" }, ], - e: [ - [0, 1, "^2.0.0", "runtime"], - [0, 2, "^2.2.1", "runtime"], + edges: [ + { + fromIndex: 0, + toIndex: 1, + constraint: "^2.0.0", + dependencyType: "runtime", + }, + { + fromIndex: 0, + toIndex: 2, + constraint: "^2.2.1", + dependencyType: "runtime", + }, ], - v: 4, }, }, }; @@ -193,11 +203,12 @@ describe("buildPackageDependenciesSuccessPayload — transitive block", () => { ], }, ]); - // `dag` is no longer in the envelope (deferred to a future typed - // `pkg deps-dag` command); `uniqueDependencies` is subsumed by - // `packages[]`. + // `dependencyGraph` stays on the service-level result for a + // future `pkg deps-dag` command; this tool's envelope doesn't + // surface it. `uniqueDependencies` is subsumed by `packages[]`. expect( - (payload.transitive as unknown as Record).dag, + (payload.transitive as unknown as Record) + .dependencyGraph, ).toBeUndefined(); expect( (payload.transitive as unknown as Record) @@ -205,15 +216,15 @@ describe("buildPackageDependenciesSuccessPayload — transitive block", () => { ).toBeUndefined(); }); - it("omits empty conflicts / circularDependencies arrays", () => { + it("omits empty dependencyConflicts / circularDependencyCycles arrays", () => { const fixture = clone(defaultDependencyReport); fixture.dependencies = { direct: fixture.dependencies?.direct, transitive: { totalEdges: 1, uniquePackagesCount: 1, - conflicts: [], - circularDependencies: [], + dependencyConflicts: [], + circularDependencyCycles: [], }, }; const payload = buildPackageDependenciesSuccessPayload(fixture, { @@ -223,22 +234,34 @@ describe("buildPackageDependenciesSuccessPayload — transitive block", () => { expect(payload.transitive?.circularDependencies).toBeUndefined(); }); - it("preserves GenericJSON conflicts + cycles as opaque passthrough", () => { + it("maps typed conflicts / cycles straight to envelope (no raw passthrough)", () => { const fixture = clone(defaultDependencyReport); fixture.dependencies = { direct: [], transitive: { totalEdges: 0, uniquePackagesCount: 0, - conflicts: [{ package: "lodash", versions: ["4", "5"] }], - circularDependencies: [{ cycle: ["a", "b", "a"] }], + dependencyConflicts: [ + { + packageName: "lodash", + requiredVersions: ["^4", "^5"], + conflictingEdges: [], + }, + ], + circularDependencyCycles: [ + { + cycleStart: "a", + circularPath: ["a", "b", "a"], + displayChain: "a → b → a", + }, + ], }, }; const payload = buildPackageDependenciesSuccessPayload(fixture, { includeTransitive: true, }); expect(payload.transitive?.conflicts).toEqual([ - { package: "lodash", versions: ["4", "5"] }, + { name: "lodash", requiredVersions: ["^4", "^5"] }, ]); expect(payload.transitive?.circularDependencies).toEqual([ { cycle: ["a", "b", "a"] }, @@ -408,11 +431,18 @@ describe("formatPackageDependenciesTerminal — groups view", () => { expect(output).toContain("defaultEnabled:"); }); - it("renders environmentConstraints block under --verbose when backend provides them", () => { + it("renders environmentMarkers block under --verbose when backend provides them", () => { const fixture: DependencyReport = { package: { name: "x", version: "1.0.0", registry: "NPM" }, dependencyGroups: { - environmentConstraints: [{ platform: "linux" }, { platform: "macos" }], + environmentMarkers: [ + { type: "extra", value: "async", raw: 'extra == "async"' }, + { + type: "python_version", + value: ">= 3.8", + raw: 'python_version >= "3.8"', + }, + ], groups: [ { name: "runtime", @@ -429,15 +459,18 @@ describe("formatPackageDependenciesTerminal — groups view", () => { showGroups: true, verbose: true, }); - expect(verbose).toContain("environmentConstraints (2):"); - expect(verbose).toContain('{"platform":"linux"}'); + expect(verbose).toContain("environmentMarkers (2):"); + // Typed rendering — type: value per line, no JSON blob. + expect(verbose).toContain("extra: async"); + expect(verbose).toContain("python_version: >= 3.8"); + expect(verbose).not.toContain('{"type":'); const nonVerbose = formatPackageDependenciesTerminal(fixture, { useColors: false, showGroups: true, verbose: false, }); - expect(nonVerbose).not.toContain("environmentConstraints"); + expect(nonVerbose).not.toContain("environmentMarkers"); }); it("renders the filter-matched-nothing case with a helpful message", () => { @@ -552,20 +585,40 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { totalEdges: 3, uniquePackagesCount: 3, uniqueDependencies: ["accepts@2.0.0", "bytes@3.1.2"], - dag: { - n: [ - ["npm", "express", "5.2.1"], - ["npm", "accepts", "2.0.0"], - ["npm", "bytes", "3.1.2"], - ["npm", "body-parser", "2.2.2"], + dependencyGraph: { + formatVersion: 4, + nodes: [ + { registry: "npm", name: "express", version: "5.2.1" }, + { registry: "npm", name: "accepts", version: "2.0.0" }, + { registry: "npm", name: "bytes", version: "3.1.2" }, + { registry: "npm", name: "body-parser", version: "2.2.2" }, ], - e: [ - [0, 1, "^2.0.0", "runtime"], - [0, 3, "^2.2.1", "runtime"], - [3, 2, "^3.0.0", "runtime"], - [0, 2, "^3.1.0", "runtime"], + edges: [ + { + fromIndex: 0, + toIndex: 1, + constraint: "^2.0.0", + dependencyType: "runtime", + }, + { + fromIndex: 0, + toIndex: 3, + constraint: "^2.2.1", + dependencyType: "runtime", + }, + { + fromIndex: 3, + toIndex: 2, + constraint: "^3.0.0", + dependencyType: "runtime", + }, + { + fromIndex: 0, + toIndex: 2, + constraint: "^3.1.0", + dependencyType: "runtime", + }, ], - v: 4, }, }, }; @@ -591,24 +644,54 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { totalEdges: 5, uniquePackagesCount: 5, uniqueDependencies: ["leaf@1.0.0"], - dag: { - n: [ - ["npm", "root", "1.0.0"], - ["npm", "a", "1.0.0"], - ["npm", "b", "1.0.0"], - ["npm", "c", "1.0.0"], - ["npm", "leaf", "1.0.0"], + dependencyGraph: { + formatVersion: 4, + nodes: [ + { registry: "npm", name: "root", version: "1.0.0" }, + { registry: "npm", name: "a", version: "1.0.0" }, + { registry: "npm", name: "b", version: "1.0.0" }, + { registry: "npm", name: "c", version: "1.0.0" }, + { registry: "npm", name: "leaf", version: "1.0.0" }, ], - e: [ - [0, 1, "^1", "runtime"], - [0, 2, "^1", "runtime"], - [0, 3, "^1", "runtime"], + edges: [ + { + fromIndex: 0, + toIndex: 1, + constraint: "^1", + dependencyType: "runtime", + }, + { + fromIndex: 0, + toIndex: 2, + constraint: "^1", + dependencyType: "runtime", + }, + { + fromIndex: 0, + toIndex: 3, + constraint: "^1", + dependencyType: "runtime", + }, // Three importers all expressing ^1 for leaf — group them. - [1, 4, "^1", "runtime"], - [2, 4, "^1", "runtime"], - [3, 4, "^1", "runtime"], + { + fromIndex: 1, + toIndex: 4, + constraint: "^1", + dependencyType: "runtime", + }, + { + fromIndex: 2, + toIndex: 4, + constraint: "^1", + dependencyType: "runtime", + }, + { + fromIndex: 3, + toIndex: 4, + constraint: "^1", + dependencyType: "runtime", + }, ], - v: 4, }, }, }; @@ -686,7 +769,7 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { expect(groupsHeadingIdx).toBeGreaterThan(transitiveIdx); }); - it("silently omits provenance when DAG shape is undecodable", () => { + it("silently omits provenance when the dependency graph isn't fetched", () => { const fixture = clone(defaultDependencyReport); fixture.dependencies = { direct: fixture.dependencies?.direct, @@ -694,7 +777,7 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { totalEdges: 1, uniquePackagesCount: 1, uniqueDependencies: ["accepts@2.0.0"], - dag: { garbage: "shape" }, + // No dependencyGraph — importers block degrades cleanly. }, }; const output = formatPackageDependenciesTerminal(fixture, { @@ -733,13 +816,20 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { transitive: { totalEdges: 2, uniquePackagesCount: 2, - conflicts: [ + dependencyConflicts: [ { - package_name: "lodash", - required_versions: ["^4", "^5"], + packageName: "lodash", + requiredVersions: ["^4", "^5"], + conflictingEdges: [], + }, + ], + circularDependencyCycles: [ + { + cycleStart: "a", + circularPath: ["a", "b", "a"], + displayChain: "a → b → a", }, ], - circularDependencies: [{ cycle: ["a", "b", "a"] }], }, }; const output = formatPackageDependenciesTerminal(fixture, { @@ -761,12 +851,27 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { transitive: { totalEdges: 5, uniquePackagesCount: 5, - conflicts: [ - { package_name: "a", required_versions: ["1", "2"] }, - { package_name: "b", required_versions: ["1", "2"] }, - { package_name: "c", required_versions: ["1", "2"] }, + dependencyConflicts: [ + { + packageName: "a", + requiredVersions: ["1", "2"], + conflictingEdges: [], + }, + { + packageName: "b", + requiredVersions: ["1", "2"], + conflictingEdges: [], + }, + { + packageName: "c", + requiredVersions: ["1", "2"], + conflictingEdges: [], + }, + ], + circularDependencyCycles: [ + { cycleStart: "x", circularPath: ["x"], displayChain: "x" }, + { cycleStart: "y", circularPath: ["y"], displayChain: "y" }, ], - circularDependencies: [{ cycle: ["x"] }, { cycle: ["y"] }], }, }; const output = formatPackageDependenciesTerminal(fixture, { @@ -784,22 +889,22 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { transitive: { totalEdges: 2, uniquePackagesCount: 2, - conflicts: [ + dependencyConflicts: [ { - package_name: "string-width", - required_versions: [ + packageName: "string-width", + requiredVersions: [ "^4.2.3", "^4.2.0", "^4.1.0", "^5.1.2", "^5.0.1", ], - conflicting_edges: [], + conflictingEdges: [], }, { - package_name: "emoji-regex", - required_versions: ["^8.0.0", "^9.2.2"], - conflicting_edges: [], + packageName: "emoji-regex", + requiredVersions: ["^8.0.0", "^9.2.2"], + conflictingEdges: [], }, ], }, @@ -815,8 +920,6 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { expect(output).toMatch( /string-width:\s+\^4\.1\.0, \^4\.2\.0, \^4\.2\.3, \^5\.0\.1, \^5\.1\.2/, ); - // No raw JSON blob for a recognised shape. - expect(output).not.toContain('{"package_name":'); }); it("renders typed circular dependencies as `a → b → a` arrow chain under --verbose", () => { @@ -826,7 +929,13 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { transitive: { totalEdges: 3, uniquePackagesCount: 3, - circularDependencies: [{ cycle: ["a", "b", "a"] }], + circularDependencyCycles: [ + { + cycleStart: "a", + circularPath: ["a", "b", "a"], + displayChain: "a → b → a", + }, + ], }, }; const output = formatPackageDependenciesTerminal(fixture, { @@ -836,29 +945,6 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { }); expect(output).toContain("Circular dependencies (1):"); expect(output).toContain("a → b → a"); - expect(output).not.toContain('{"cycle":'); - }); - - it("falls back to raw JSON under --verbose when conflict / cycle shape is unknown", () => { - const fixture = clone(defaultDependencyReport); - fixture.dependencies = { - direct: fixture.dependencies?.direct, - transitive: { - totalEdges: 2, - uniquePackagesCount: 2, - conflicts: [{ package: "lodash", versions: ["4", "5"] }], - circularDependencies: [{ unknown: "shape" }], - }, - }; - const output = formatPackageDependenciesTerminal(fixture, { - useColors: false, - includeTransitive: true, - verbose: true, - }); - expect(output).toContain("Conflicts (1):"); - expect(output).toContain("Circular dependencies (1):"); - expect(output).toContain('{"package":"lodash"'); - expect(output).toContain('{"unknown":"shape"}'); }); it("keeps the zero-ack line when neither conflicts nor cycles are present", () => { diff --git a/src/shared/package-dependencies-response.ts b/src/shared/package-dependencies-response.ts index 4b3462ec..cfb9f426 100644 --- a/src/shared/package-dependencies-response.ts +++ b/src/shared/package-dependencies-response.ts @@ -18,25 +18,24 @@ * `includeImporters` populates per-package `importers[]` with the * upstream node name, its own version, and the constraint it * declared — the same signal the terminal `--verbose` view - * renders, but derived client-side so agents don't have to decode - * the backend's `GenericJSON` DAG themselves. - * - **Typed conflicts / cycles with raw fallback.** `transitive.conflicts` - * and `transitive.circularDependencies` ship as typed arrays - * (`{name, requiredVersions}` / `{cycle: string[]}`) when every - * entry decodes against the observed backend shape. If any entry - * fails we fall back to raw `GenericJSON[]` passthrough for that - * field so no data is silently lost — agents discriminate by - * checking for the typed fields on the first element. + * renders, derived client-side from the typed dependency graph + * so agents never see the graph directly. + * - **Typed conflicts / cycles.** `transitive.conflicts` is + * `{name, requiredVersions}[]`; `transitive.circularDependencies` + * is `{cycle: string[]}[]`. Both map from the backend's typed + * `DependencyConflict` / `CircularDependencyCycle` shapes — no + * raw-fallback path. * - **Null vs empty matters.** `dependencyGroups: null` → omit * `groups` entirely ("backend has no groups concept"). Non-null * with zero members after filtering → `groups: { items: [] }` * ("filter matched nothing"). - * - **No raw DAG, no `uniqueDependencies`.** The backend DAG is - * deliberately not exposed from this tool's envelope — a future - * `pkg deps-dag` command will surface it under a typed contract - * for graph visualisation. `uniqueDependencies` is subsumed by - * `packages[]`. `groups.environmentConstraints` remains raw - * `GenericJSON[]` pending a live observation to type it against. + * - **No DAG in the envelope.** The typed `dependencyGraph` sits on + * the service-layer result for internal lookups (direct-version + * resolution, importer provenance) and for a future `pkg deps-dag` + * command; this tool's envelope deliberately doesn't surface it. + * - **Typed environment markers.** `groups.environmentMarkers` maps + * the backend's typed `{type, value, raw}` marker list. No raw-JSON + * passthrough. * - **No v-prefix normalisation.** Tag-style inputs are rejected in * the request builder before we get here. * - **Terminal-only dedup.** JSON preserves every tuple the backend @@ -45,9 +44,10 @@ */ import type { + DependencyGraph, DependencyGroup, DependencyReport, - UntypedGenericJSON, + EnvironmentMarker, } from "../services/index.js"; import { colorize, dim } from "./colors.js"; import type { DependencyLifecycle } from "./package-dependencies-request.js"; @@ -76,6 +76,12 @@ export interface LeanGroupDependency { constraint?: string; } +export interface LeanEnvironmentMarker { + type?: string; + value?: string; + raw?: string; +} + export interface LeanGroup { name: string; lifecycle: string; @@ -91,7 +97,7 @@ export interface LeanGroup { export interface LeanGroupsBlock { primaryGroup?: string; - environmentConstraints?: UntypedGenericJSON[]; + environmentMarkers?: LeanEnvironmentMarker[]; items: LeanGroup[]; } @@ -107,9 +113,9 @@ export interface LeanTransitivePackage { name: string; version?: string; /** - * Importers for this package. Present when the DAG was decodable. - * Empty when the package is the root (no incoming edges) or when - * decoding failed for this node. + * Importers for this package. Present when the DAG was available + * and the node resolved. Empty when the package is the root (no + * incoming edges). */ importers?: LeanTransitiveImporter[]; } @@ -134,23 +140,12 @@ export interface LeanTransitiveBlock { depth?: number; /** * Per-transitive-package records with resolved version + importer - * provenance. Preprocessed from the backend's DAG so agents - * consume the same signal the CLI `--verbose` view renders without - * having to decode `GenericJSON` themselves. + * provenance. Preprocessed from the backend's typed dependency graph + * so agents consume the same signal the CLI `--verbose` view renders. */ packages?: LeanTransitivePackage[]; - /** - * Typed when every entry decoded against the observed backend - * shape (`{ package_name, required_versions }`). Raw passthrough - * otherwise — agents can discriminate by checking for `name` / - * `requiredVersions` fields. - */ - conflicts?: LeanTypedConflict[] | UntypedGenericJSON[]; - /** - * Typed when every entry decoded (observed: `{ cycle: string[] }`). - * Raw passthrough otherwise. - */ - circularDependencies?: LeanTypedCycle[] | UntypedGenericJSON[]; + conflicts?: LeanTypedConflict[]; + circularDependencies?: LeanTypedCycle[]; } export interface LeanFilterBlock { @@ -215,14 +210,10 @@ export function buildPackageDependenciesSuccessPayload( } const bundle = report.dependencies; - // Decode the DAG up front; used both for direct-dep version lookup - // (always, when the DAG was fetched) and for verbose-mode importer - // provenance later. Falls back to null on unknown shapes; each - // consumer handles the absence gracefully. - const decodedDagForResolution = decodeDag(bundle?.transitive?.dag); - const directVersionByName = decodedDagForResolution - ? buildDirectVersionLookup(decodedDagForResolution) - : null; + // Direct-version lookup uses the typed dependency graph's + // root-outgoing edges. Null when the graph wasn't fetched. + const graph = bundle?.transitive?.dependencyGraph ?? null; + const directVersionByName = graph ? buildDirectVersionLookup(graph) : null; const directArray = bundle?.direct; if (directArray !== undefined) { @@ -240,11 +231,12 @@ export function buildPackageDependenciesSuccessPayload( groupsBlock.primaryGroup = groupsInfo.primaryGroup; } if ( - groupsInfo.environmentConstraints && - groupsInfo.environmentConstraints.length > 0 + groupsInfo.environmentMarkers && + groupsInfo.environmentMarkers.length > 0 ) { - groupsBlock.environmentConstraints = - groupsInfo.environmentConstraints.slice(); + groupsBlock.environmentMarkers = groupsInfo.environmentMarkers.map((m) => + projectEnvironmentMarker(m), + ); } payload.groups = groupsBlock; } @@ -264,21 +256,27 @@ export function buildPackageDependenciesSuccessPayload( } const packages = buildTransitivePackages( transitive.uniqueDependencies, - decodedDagForResolution, + graph, options.includeImporters ?? false, ); if (packages && packages.length > 0) { block.packages = packages; } - if (transitive.conflicts && transitive.conflicts.length > 0) { - block.conflicts = buildTypedConflicts(transitive.conflicts); + if ( + transitive.dependencyConflicts && + transitive.dependencyConflicts.length > 0 + ) { + block.conflicts = transitive.dependencyConflicts.map((c) => ({ + name: c.packageName, + requiredVersions: c.requiredVersions.slice().sort(), + })); } if ( - transitive.circularDependencies && - transitive.circularDependencies.length > 0 + transitive.circularDependencyCycles && + transitive.circularDependencyCycles.length > 0 ) { - block.circularDependencies = buildTypedCycles( - transitive.circularDependencies, + block.circularDependencies = transitive.circularDependencyCycles.map( + (c) => ({ cycle: c.circularPath.slice() }), ); } payload.transitive = block; @@ -292,6 +290,16 @@ export function buildPackageDependenciesSuccessPayload( return payload; } +function projectEnvironmentMarker( + marker: EnvironmentMarker, +): LeanEnvironmentMarker { + const out: LeanEnvironmentMarker = {}; + if (marker.type !== undefined) out.type = marker.type; + if (marker.value !== undefined) out.value = marker.value; + if (marker.raw !== undefined) out.raw = marker.raw; + return out; +} + function buildDirect( entry: { name: string; versionConstraint?: string; type?: string }, directVersionByName: Map | null, @@ -304,18 +312,24 @@ function buildDirect( } /** - * Build a `name → resolved version` lookup for direct deps by scanning - * the DAG's outgoing edges from the root node. Used during envelope - * construction to annotate `runtime.items[].version` whenever the DAG - * is available. + * Build a `name → resolved version` lookup for direct deps by walking + * edges whose `fromIndex` points at the graph's root (the node with + * no incoming edges). Returns null when the graph has no single root + * — synthetic-root edges (`fromIndex: null`) are treated as + * originating from the root as well. */ -function buildDirectVersionLookup(dag: DecodedDag): Map | null { - const rootIdx = findRootNodeIdx(dag); - if (rootIdx === null) return null; +function buildDirectVersionLookup( + graph: DependencyGraph, +): Map | null { + const rootIdx = findRootNodeIdx(graph); const out = new Map(); - for (const edge of dag.edges) { - if (edge.fromIdx !== rootIdx) continue; - const node = dag.nodes[edge.toIdx]; + for (const edge of graph.edges) { + const fromRoot = + edge.fromIndex === undefined || + edge.fromIndex === null || + edge.fromIndex === rootIdx; + if (!fromRoot) continue; + const node = graph.nodes[edge.toIndex]; if (!node || !node.version) continue; if (!out.has(node.name)) { out.set(node.name, node.version); @@ -324,11 +338,11 @@ function buildDirectVersionLookup(dag: DecodedDag): Map | null { return out.size > 0 ? out : null; } -function findRootNodeIdx(dag: DecodedDag): number | null { +function findRootNodeIdx(graph: DependencyGraph): number | null { const incoming = new Set(); - for (const e of dag.edges) incoming.add(e.toIdx); + for (const e of graph.edges) incoming.add(e.toIndex); let root: number | null = null; - for (let i = 0; i < dag.nodes.length; i++) { + for (let i = 0; i < graph.nodes.length; i++) { if (!incoming.has(i)) { if (root !== null) return null; // ambiguous — multiple roots root = i; @@ -340,21 +354,21 @@ function findRootNodeIdx(dag: DecodedDag): number | null { /** * Build the preprocessed `transitive.packages[]` array. Each entry * carries the name + resolved version (from the backend's - * `uniqueDependencies` list) plus importer provenance when the DAG - * decoded successfully. Agents consume this directly rather than - * reverse-engineering the raw DAG — same source of truth the - * terminal `--verbose` renderer reads from. + * `uniqueDependencies` list) plus importer provenance when the graph + * was fetched. Agents consume this directly rather than the raw + * dependency graph. */ function buildTransitivePackages( uniqueDependencies: string[] | undefined, - dag: DecodedDag | null, + graph: DependencyGraph | null, includeImporters: boolean, ): LeanTransitivePackage[] | null { if (!uniqueDependencies || uniqueDependencies.length === 0) return null; // Build a name→importers lookup once — only needed when we're // actually emitting importers. - const incoming = includeImporters && dag ? buildIncomingEdgeMap(dag) : null; + const incoming = + includeImporters && graph ? buildIncomingEdgeMap(graph) : null; const out: LeanTransitivePackage[] = []; for (const entry of uniqueDependencies) { @@ -363,11 +377,11 @@ function buildTransitivePackages( const record: LeanTransitivePackage = { name }; if (version) record.version = version; - if (includeImporters && dag && incoming) { - const nodeIdx = findNodeIdx(dag, name, version); + if (includeImporters && graph && incoming) { + const nodeIdx = findNodeIdx(graph, name, version); if (nodeIdx !== null) { const edges = incoming.get(nodeIdx) ?? []; - const importers = buildImportersFromEdges(dag, edges); + const importers = buildImportersFromEdges(graph, edges); if (importers.length > 0) record.importers = importers; } } @@ -385,26 +399,28 @@ function parseNameAtVersion(raw: string): [string | null, string | undefined] { return [trimmed.slice(0, atIdx), trimmed.slice(atIdx + 1)]; } -function buildIncomingEdgeMap(dag: DecodedDag): Map { - const map = new Map(); - for (const edge of dag.edges) { - const list = map.get(edge.toIdx); +function buildIncomingEdgeMap( + graph: DependencyGraph, +): Map { + const map = new Map(); + for (const edge of graph.edges) { + const list = map.get(edge.toIndex); if (list) list.push(edge); - else map.set(edge.toIdx, [edge]); + else map.set(edge.toIndex, [edge]); } return map; } function findNodeIdx( - dag: DecodedDag, + graph: DependencyGraph, name: string, version: string | undefined, ): number | null { // Prefer exact name+version match. Fall back to name-only when - // version is absent or the DAG's node carries no version. + // version is absent or the graph's node carries no version. let fallback: number | null = null; - for (let i = 0; i < dag.nodes.length; i++) { - const n = dag.nodes[i]; + for (let i = 0; i < graph.nodes.length; i++) { + const n = graph.nodes[i]; if (!n) continue; if (n.name !== name) continue; if (version && n.version === version) return i; @@ -415,13 +431,17 @@ function findNodeIdx( } function buildImportersFromEdges( - dag: DecodedDag, - edges: DagEdge[], + graph: DependencyGraph, + edges: DependencyGraph["edges"], ): LeanTransitiveImporter[] { const seen = new Set(); const out: LeanTransitiveImporter[] = []; for (const edge of edges) { - const from = dag.nodes[edge.fromIdx]; + // Synthetic-root edges have `fromIndex: null` — skip; the root + // is the package itself and doesn't need to show up as an + // importer of its direct deps. + if (edge.fromIndex === undefined || edge.fromIndex === null) continue; + const from = graph.nodes[edge.fromIndex]; if (!from) continue; const key = `${from.name}\u0000${from.version ?? ""}\u0000${edge.constraint ?? ""}`; if (seen.has(key)) continue; @@ -443,79 +463,6 @@ function buildImportersFromEdges( return out; } -/** - * Promote `transitive.conflicts[]` to typed objects when every entry - * matches the observed backend shape (`{ package_name, - * required_versions }`). Falls back to the raw array when any entry - * fails to decode — agents can discriminate by checking for `name` / - * `requiredVersions` keys on the first element. - */ -function buildTypedConflicts( - raw: UntypedGenericJSON[], -): LeanTypedConflict[] | UntypedGenericJSON[] { - const typed: LeanTypedConflict[] = []; - for (const entry of raw) { - const decoded = decodeConflictEntryForEnvelope(entry); - if (!decoded) return raw.slice(); // fall back to raw passthrough - typed.push(decoded); - } - return typed; -} - -function decodeConflictEntryForEnvelope( - raw: unknown, -): LeanTypedConflict | null { - if (!raw || typeof raw !== "object") return null; - const obj = raw as Record; - const name = - typeof obj.package_name === "string" - ? obj.package_name - : typeof obj.packageName === "string" - ? obj.packageName - : null; - if (!name) return null; - const rangesRaw = obj.required_versions ?? obj.requiredVersions; - if (!Array.isArray(rangesRaw)) return null; - const ranges: string[] = []; - for (const r of rangesRaw) { - if (typeof r === "string" && r.length > 0 && !ranges.includes(r)) { - ranges.push(r); - } - } - if (ranges.length === 0) return null; - ranges.sort(); - return { name, requiredVersions: ranges }; -} - -/** - * Promote `transitive.circularDependencies[]` to typed objects when - * every entry matches the expected `{ cycle: string[] }` shape. - * Raw-passthrough fallback otherwise. - */ -function buildTypedCycles( - raw: UntypedGenericJSON[], -): LeanTypedCycle[] | UntypedGenericJSON[] { - const typed: LeanTypedCycle[] = []; - for (const entry of raw) { - const decoded = decodeCycleEntryForEnvelope(entry); - if (!decoded) return raw.slice(); - typed.push({ cycle: decoded }); - } - return typed; -} - -function decodeCycleEntryForEnvelope(raw: unknown): string[] | null { - if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) { - return raw as string[]; - } - if (!raw || typeof raw !== "object") return null; - const obj = raw as Record; - const source = obj.cycle ?? obj.packages ?? obj.path; - if (!Array.isArray(source)) return null; - const names = source.filter((x): x is string => typeof x === "string"); - return names.length > 0 ? names : null; -} - function buildGroup(group: DependencyGroup): LeanGroup { const lean: LeanGroup = { name: group.name, @@ -603,8 +550,7 @@ function lowerRegistry(value: string | undefined): string { * if you asked for transitive, you get it all. * - **`--verbose` with `--transitive` adds provenance.** Each * transitive entry gets `(required by @, …)` - * derived from the DAG edges. Best-effort decoding; if the DAG - * shape drifts, provenance silently degrades (list still renders). + * derived from the typed dependency graph. * - **Groups is a separate block below the deps list.** Shown when * `--groups` or `--lifecycle` is set, composes cleanly with either * the direct or transitive deps list above. @@ -899,18 +845,13 @@ function formatConflictsAndCycles( lines.push( colorize(`Conflicts (${conflicts.length}):`, "yellow", useColors), ); - const typed = isTypedConflictArray(conflicts) ? conflicts : null; - if (typed) { - const nameWidth = Math.max(...typed.map((c) => c.name.length)); - const sorted = [...typed].sort((a, b) => - a.name < b.name ? -1 : a.name > b.name ? 1 : 0, - ); - for (const c of sorted) { - const padded = `${c.name}:`.padEnd(nameWidth + 2); - lines.push(` ${padded} ${c.requiredVersions.join(", ")}`); - } - } else { - for (const c of conflicts) lines.push(` ${JSON.stringify(c)}`); + const nameWidth = Math.max(...conflicts.map((c) => c.name.length)); + const sorted = [...conflicts].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + for (const c of sorted) { + const padded = `${c.name}:`.padEnd(nameWidth + 2); + lines.push(` ${padded} ${c.requiredVersions.join(", ")}`); } } if (cycles.length > 0) { @@ -918,35 +859,12 @@ function formatConflictsAndCycles( lines.push( colorize(`Circular dependencies (${cycles.length}):`, "red", useColors), ); - const typed = isTypedCycleArray(cycles) ? cycles : null; - if (typed) { - for (const c of typed) lines.push(` ${c.cycle.join(" → ")}`); - } else { - for (const c of cycles) lines.push(` ${JSON.stringify(c)}`); - } + for (const c of cycles) lines.push(` ${c.cycle.join(" → ")}`); } return lines.join("\n"); } -function isTypedConflictArray( - arr: LeanTypedConflict[] | UntypedGenericJSON[], -): arr is LeanTypedConflict[] { - const first = arr[0]; - if (!first || typeof first !== "object") return false; - const obj = first as Record; - return typeof obj.name === "string" && Array.isArray(obj.requiredVersions); -} - -function isTypedCycleArray( - arr: LeanTypedCycle[] | UntypedGenericJSON[], -): arr is LeanTypedCycle[] { - const first = arr[0]; - if (!first || typeof first !== "object") return false; - const obj = first as Record; - return Array.isArray(obj.cycle); -} - // -------------------------------------------------------------------- // Groups block (separate; shown when --groups or --lifecycle) // -------------------------------------------------------------------- @@ -981,17 +899,17 @@ function formatGroupsBlock( if ( verbose && - groups.environmentConstraints && - groups.environmentConstraints.length > 0 + groups.environmentMarkers && + groups.environmentMarkers.length > 0 ) { lines.push( dim( - `environmentConstraints (${groups.environmentConstraints.length}):`, + `environmentMarkers (${groups.environmentMarkers.length}):`, useColors, ), ); - for (const entry of groups.environmentConstraints) { - lines.push(dim(` ${JSON.stringify(entry)}`, useColors)); + for (const marker of groups.environmentMarkers) { + lines.push(dim(` ${formatEnvironmentMarker(marker)}`, useColors)); } lines.push(""); } @@ -1025,6 +943,18 @@ function formatGroupsBlock( return lines.join("\n").trimEnd(); } +/** + * Render a typed environment marker as `type: value` (falling back + * to the raw text when either field is missing). Keeps verbose + * output compact — one line per marker. + */ +function formatEnvironmentMarker(marker: LeanEnvironmentMarker): string { + if (marker.type && marker.value) return `${marker.type}: ${marker.value}`; + if (marker.value) return marker.value; + if (marker.type) return marker.type; + return marker.raw ?? "(empty marker)"; +} + function formatGroupHeading(group: LeanGroup, registry: string): string { if (group.conditionType === "always") { return group.name; @@ -1113,140 +1043,3 @@ function sortAlphabetically( return ka < kb ? -1 : ka > kb ? 1 : 0; }); } - -// -------------------------------------------------------------------- -// Best-effort DAG decoder + provenance lookup -// -// Backend declares `transitive.dag` as `GenericJSON`. The shape we've -// observed live (npm, PyPI, Crates) is: -// -// { -// n: Array<[registry, name, version]> // node list, indexed by position -// e: Array<[fromIdx, toIdx, constraint?, lifecycle?]> -// v: number // format version marker -// } -// -// The decoder also tolerates an alternative object-shape -// variant (`n: { id: { n, v?, l? } }`) so that if backend -// formats diverge we don't break the terminal — provenance just -// silently stops rendering. -// -------------------------------------------------------------------- - -interface DagNode { - name: string; - version?: string; - registry?: string; -} - -interface DagEdge { - fromIdx: number; - toIdx: number; - constraint?: string; - lifecycle?: string; -} - -interface DecodedDag { - nodes: DagNode[]; - edges: DagEdge[]; -} - -function decodeDag(raw: unknown): DecodedDag | null { - if (!raw || typeof raw !== "object") return null; - const obj = raw as Record; - const rawNodes = obj.n ?? obj.nodes; - const rawEdges = obj.e ?? obj.edges; - - const nodes = decodeNodes(rawNodes); - if (!nodes) return null; - const edges = decodeEdges(rawEdges); - if (!edges) return null; - return { nodes, edges }; -} - -function decodeNodes(raw: unknown): DagNode[] | null { - if (Array.isArray(raw)) { - // Tuple form: [registry, name, version] - const result: DagNode[] = []; - for (const entry of raw) { - if (!Array.isArray(entry)) { - if (typeof entry === "object" && entry !== null) { - const n = decodeObjectNode(entry as Record); - if (!n) return null; - result.push(n); - continue; - } - return null; - } - const [registry, name, version] = entry as unknown[]; - if (typeof name !== "string") return null; - result.push({ - name, - version: typeof version === "string" ? version : undefined, - registry: typeof registry === "string" ? registry : undefined, - }); - } - return result; - } - if (raw && typeof raw === "object") { - // Object form: { "": { n: name, v?: version, l?: label } } - const result: DagNode[] = []; - for (const entry of Object.values(raw as Record)) { - if (!entry || typeof entry !== "object") return null; - const n = decodeObjectNode(entry as Record); - if (!n) return null; - result.push(n); - } - return result; - } - return null; -} - -function decodeObjectNode(entry: Record): DagNode | null { - const name = - typeof entry.n === "string" - ? entry.n - : typeof entry.name === "string" - ? entry.name - : null; - if (!name) return null; - const version = - typeof entry.v === "string" - ? entry.v - : typeof entry.version === "string" - ? entry.version - : undefined; - return { name, version }; -} - -function decodeEdges(raw: unknown): DagEdge[] | null { - if (!Array.isArray(raw)) return null; - const out: DagEdge[] = []; - for (const entry of raw) { - if (Array.isArray(entry)) { - const [from, to, constraint, lifecycle] = entry as unknown[]; - if (typeof from !== "number" || typeof to !== "number") return null; - out.push({ - fromIdx: from, - toIdx: to, - constraint: typeof constraint === "string" ? constraint : undefined, - lifecycle: typeof lifecycle === "string" ? lifecycle : undefined, - }); - continue; - } - if (entry && typeof entry === "object") { - const obj = entry as Record; - const from = obj.f ?? obj.from; - const to = obj.t ?? obj.to; - if (typeof from !== "number" || typeof to !== "number") return null; - out.push({ - fromIdx: from, - toIdx: to, - constraint: typeof obj.c === "string" ? obj.c : undefined, - lifecycle: typeof obj.l === "string" ? obj.l : undefined, - }); - continue; - } - return null; - } - return out; -} diff --git a/src/tools/package-dependencies-parity.test.ts b/src/tools/package-dependencies-parity.test.ts index c13e2221..038076fd 100644 --- a/src/tools/package-dependencies-parity.test.ts +++ b/src/tools/package-dependencies-parity.test.ts @@ -262,15 +262,22 @@ describe("package_dependencies parity", () => { totalEdges: 80, uniquePackagesCount: 45, uniqueDependencies: ["accepts@2.0.0"], - conflicts: [], - circularDependencies: [], - dag: { - n: [ - ["npm", "express", "5.2.1"], - ["npm", "accepts", "2.0.0"], + dependencyConflicts: [], + circularDependencyCycles: [], + dependencyGraph: { + formatVersion: 4, + nodes: [ + { registry: "npm", name: "express", version: "5.2.1" }, + { registry: "npm", name: "accepts", version: "2.0.0" }, + ], + edges: [ + { + fromIndex: 0, + toIndex: 1, + constraint: "^2.0.0", + dependencyType: "runtime", + }, ], - e: [[0, 1, "^2.0.0", "runtime"]], - v: 4, }, }, }, @@ -300,7 +307,11 @@ describe("package_dependencies parity", () => { expect(cli).toEqual(json); const transitiveEnvelope = ( cli as { - transitive?: { packages?: unknown[]; dag?: unknown }; + transitive?: { + packages?: unknown[]; + dependencyGraph?: unknown; + dag?: unknown; + }; } ).transitive; expect(transitiveEnvelope?.packages).toEqual([ @@ -312,6 +323,10 @@ describe("package_dependencies parity", () => { ], }, ]); + // Neither the typed `dependencyGraph` nor the legacy `dag` is + // surfaced on this tool's envelope — both remain internal to + // the service-level result. + expect(transitiveEnvelope?.dependencyGraph).toBeUndefined(); expect(transitiveEnvelope?.dag).toBeUndefined(); }); @@ -324,15 +339,22 @@ describe("package_dependencies parity", () => { totalEdges: 1, uniquePackagesCount: 1, uniqueDependencies: ["accepts@2.0.0"], - conflicts: [], - circularDependencies: [], - dag: { - n: [ - ["npm", "express", "5.2.1"], - ["npm", "accepts", "2.0.0"], + dependencyConflicts: [], + circularDependencyCycles: [], + dependencyGraph: { + formatVersion: 4, + nodes: [ + { registry: "npm", name: "express", version: "5.2.1" }, + { registry: "npm", name: "accepts", version: "2.0.0" }, + ], + edges: [ + { + fromIndex: 0, + toIndex: 1, + constraint: "^2.0.0", + dependencyType: "runtime", + }, ], - e: [[0, 1, "^2.0.0", "runtime"]], - v: 4, }, }, }, diff --git a/src/tools/package-dependencies.test.ts b/src/tools/package-dependencies.test.ts index f48f1710..70dbb0a4 100644 --- a/src/tools/package-dependencies.test.ts +++ b/src/tools/package-dependencies.test.ts @@ -158,15 +158,22 @@ describe("createPackageDependenciesTool — happy path", () => { totalEdges: 1, uniquePackagesCount: 1, uniqueDependencies: ["accepts@2.0.0"], - conflicts: [], - circularDependencies: [], - dag: { - n: [ - ["npm", "express", "5.2.1"], - ["npm", "accepts", "2.0.0"], + dependencyConflicts: [], + circularDependencyCycles: [], + dependencyGraph: { + formatVersion: 4, + nodes: [ + { registry: "npm", name: "express", version: "5.2.1" }, + { registry: "npm", name: "accepts", version: "2.0.0" }, + ], + edges: [ + { + fromIndex: 0, + toIndex: 1, + constraint: "^2.0.0", + dependencyType: "runtime", + }, ], - e: [[0, 1, "^2.0.0", "runtime"]], - v: 4, }, }, },