From 444896d9706a048c4598d0b85e99129112065a44 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 7 Oct 2026 13:33:00 +0200 Subject: [PATCH 01/12] feat(manifest): record each subproject's dependency tree in JVM facts Facts merged every resolution root's edges into one graph keyed by coordinate, so a package's dependencies in one subproject leaked into every other subproject that also uses it. Edges move out of components into a top-level dependencies list: each entry is a component with one exact subtree, shared by every position whose subtree is identical, and each project lists the entries at the roots of its own tree (its configurations merged). Identical subtrees are found by partition refinement, so cycles merge exactly too. The compute-artifacts sidecar keeps its contract: projects still carry their direct dependency ids. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + .../manifest/enumerate-workspaces.test.mts | 2 +- .../generate-recursive-manifests.test.mts | 6 +- .../manifest/run-manifest-facts.test.mts | 3 +- src/commands/manifest/scripts/assemble.mts | 259 +++++++++++++++--- .../manifest/scripts/assemble.test.mts | 178 +++++++++++- src/commands/manifest/scripts/facts.mts | 20 +- src/commands/manifest/scripts/sidecar.mts | 25 +- .../manifest/scripts/sidecar.test.mts | 18 +- .../setup-recursive-manifest-config.test.mts | 12 +- 10 files changed, 456 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1d9252b..f8381af07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed +- Socket facts for Maven, Gradle and sbt builds now record each subproject's own dependency tree, so a package is attributed only to the subprojects that actually pull it in. + ## [1.5.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.5.0) - 2026-10-06 ### Added diff --git a/src/commands/manifest/enumerate-workspaces.test.mts b/src/commands/manifest/enumerate-workspaces.test.mts index b8c0ec2c4..bf2621578 100644 --- a/src/commands/manifest/enumerate-workspaces.test.mts +++ b/src/commands/manifest/enumerate-workspaces.test.mts @@ -19,7 +19,7 @@ function okResult(): WorkspaceEnumerationResult { type: 'maven', name: 'root', subprojectDir: '.', - dependencies: [], + children: [], }, ], stderr: '', diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 041a223aa..deda754c1 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -60,13 +60,13 @@ describe('generateRecursiveManifests', () => { type: 'maven', name: 'moduleA', subprojectDir: 'moduleA', - dependencies: [], + children: [], }, { type: 'maven', name: 'moduleB', subprojectDir: 'moduleB', - dependencies: [], + children: [], }, ], } @@ -134,7 +134,7 @@ describe('generateRecursiveManifests', () => { type: 'maven', name: 'shared-lib', subprojectDir: `../${sharedLibName}`, - dependencies: [], + children: [], }, ], } diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 9c735cfc0..b895b70c2 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -31,6 +31,7 @@ function okResult(): ManifestRunResult { sourcesByCoord: new Map(), coords: new Set(), classpathByProject: new Map(), + directDependenciesByProject: new Map(), }, stderr: '', stdout: '', @@ -123,7 +124,7 @@ describe('runManifestFacts - sidecar', () => { name: 'app', version: '1.0', subprojectDir: '.', - dependencies: [], + children: [], }, ] vi.mocked(runManifestScript).mockResolvedValue(result) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index e95fd6b9c..a048397ca 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -5,6 +5,7 @@ import { type SocketFactsManifestReference, type SocketFactsSbom, type SocketFactsSbomComponent, + type SocketFactsSbomDependency, type SocketFactsSbomMetadata, type SocketFactsSbomProject, mavenCoordinateKey, @@ -24,7 +25,6 @@ export type AssembleResult = { } export type AssembleOptions = { - emitProjects?: boolean | undefined // Injectable for tests; an uncompiled module's output dir is dropped (module // stays resolvable via its sources). fileExists?: ((path: string) => boolean) | undefined @@ -32,12 +32,16 @@ export type AssembleOptions = { type MergedNode = { coord: RawCoord - children: Set prod: boolean direct: boolean targets: Set } +type SubprojectGraph = { + children: Map> + direct: Set +} + type PerRoot = { projectKey: string prod: boolean @@ -65,10 +69,11 @@ export function assembleFacts( projectsByGav, buildManifestFilesByCoord(parsed, directByRoot, perRoot), ) - const projects = - opts.emitProjects === false - ? [] - : buildProjects(parsed, directByRoot, perRoot) + const { dependencies, projects } = buildDependencyGraph( + parsed, + perRoot, + components, + ) const metadata: SocketFactsSbomMetadata = { format: 'socket-facts-sbom', @@ -78,7 +83,7 @@ export function assembleFacts( } const facts: SocketFactsSbom = projects.length - ? { metadata, projects, components } + ? { metadata, projects, components, dependencies } : { metadata, components } return { @@ -87,6 +92,7 @@ export function assembleFacts( artifactPaths: buildArtifactPaths( finalNodes, [...parsed.projects.values()], + directByRoot, projectsByGav, perRoot, fileExists, @@ -150,7 +156,6 @@ function mergeByCoordinate(perRoot: Map): { if (!fn) { fn = { coord: node.coord, - children: new Set(), prod: false, direct: false, targets: new Set(), @@ -163,9 +168,6 @@ function mergeByCoordinate(perRoot: Map): { if (node.direct) { fn.direct = true } - for (const c of node.children) { - fn.children.add(c) - } for (const t of node.targets) { fn.targets.add(t) } @@ -246,9 +248,6 @@ function buildComponents( if (projectsByGav.has(gav(c.group, c.name, c.version ?? ''))) { comp.firstParty = true } - if (fn.children.size) { - comp.dependencies = [...fn.children].sort() - } const manifestFiles = manifestFilesByCoord.get(id) if (manifestFiles) { comp.manifestFiles = manifestFiles @@ -257,41 +256,183 @@ function buildComponents( }) } -function buildProjects( +// One dependency tree per subproject, the union of its configurations. Tree +// positions merge when their full subtrees are equal: the coarsest bisimulation +// over the subproject graphs labelled by component, found by partition +// refinement so that cycles merge exactly too. +function buildDependencyGraph( parsed: ParsedRecords, - directByRoot: Map>, perRoot: Map, -): SocketFactsSbomProject[] { - const directByProject = new Map>() - for (const [rootId, ids] of directByRoot) { - const pk = perRoot.get(rootId)?.projectKey ?? '' - let set = directByProject.get(pk) - if (!set) { - set = new Set() - directByProject.set(pk, set) + components: SocketFactsSbomComponent[], +): { + dependencies: SocketFactsSbomDependency[] + projects: SocketFactsSbomProject[] +} { + const componentIndex = new Map(components.map((c, i) => [c.id, i])) + const graphs = new Map() + for (const { nodes, projectKey } of perRoot.values()) { + let g = graphs.get(projectKey) + if (!g) { + g = { children: new Map(), direct: new Set() } + graphs.set(projectKey, g) } - for (const id of ids) { - set.add(id) + for (const [coordId, node] of nodes) { + let set = g.children.get(coordId) + if (!set) { + set = new Set() + g.children.set(coordId, set) + } + for (const c of node.children) { + set.add(c) + } + if (node.direct) { + g.direct.add(coordId) + } } } - const projects = [...parsed.projects.values()].map(p => { - const entry: SocketFactsSbomProject = { + // Vertices are (subproject, coordinate) pairs. + const vertexIds = new Map() + const labels: number[] = [] + const edges: number[][] = [] + const vertex = (projectKey: string, coordId: string): number => { + const key = `${projectKey}\t${coordId}` + let v = vertexIds.get(key) + if (v === undefined) { + v = labels.length + vertexIds.set(key, v) + labels.push(componentIndex.get(coordId)!) + edges.push([]) + } + return v + } + for (const [projectKey, { children }] of graphs) { + for (const [coordId, kids] of children) { + const v = vertex(projectKey, coordId) + for (const c of kids) { + edges[v]!.push(vertex(projectKey, c)) + } + } + } + + // Each round splits blocks by their children's blocks; a round that splits + // nothing is stable. + let block = [...labels] + let blockCount = new Set(block).size + for (;;) { + const ids = new Map() + block = block.map((b, v) => { + const childBlocks = [...new Set(edges[v]!.map(c => block[c]!))].sort( + (x, y) => x - y, + ) + const sig = `${b}:${childBlocks.join(',')}` + let id = ids.get(sig) + if (id === undefined) { + id = ids.size + ids.set(sig, id) + } + return id + }) + if (ids.size === blockCount) { + break + } + blockCount = ids.size + } + + const byComponent = (a: string, b: string) => + componentIndex.get(a)! - componentIndex.get(b)! + const projects = [...parsed.projects.values()] + .map(p => ({ + p, + roots: treeRoots(graphs.get(p.projectKey), byComponent), + })) + .sort((a, b) => { + const ka = `${a.p.dir} ${a.p.group}:${a.p.name}` + const kb = `${b.p.dir} ${b.p.group}:${b.p.name}` + return ka < kb ? -1 : ka > kb ? 1 : 0 + }) + + // Post-order over the sorted projects, so the numbering is stable. + const indexOfBlock = new Map() + const vertexOfBlock = new Map() + const dependencies: SocketFactsSbomDependency[] = [] + const visit = (v: number): void => { + const b = block[v]! + if (vertexOfBlock.has(b)) { + return + } + vertexOfBlock.set(b, v) + for (const c of [...edges[v]!].sort((x, y) => labels[x]! - labels[y]!)) { + visit(c) + } + indexOfBlock.set(b, dependencies.length) + dependencies.push({ component: labels[v]! }) + } + const indexOf = (v: number) => indexOfBlock.get(block[v]!)! + for (const { p, roots } of projects) { + for (const coordId of roots) { + visit(vertex(p.projectKey, coordId)) + } + } + for (const [b, v] of vertexOfBlock) { + const children = [...new Set(edges[v]!.map(indexOf))].sort((x, y) => x - y) + if (children.length) { + dependencies[indexOfBlock.get(b)!]!.children = children + } + } + + return { + dependencies, + projects: projects.map(({ p, roots }) => ({ type: PURL_TYPE_MAVEN, namespace: p.group, name: p.name, ...(p.version ? { version: p.version } : {}), subprojectDir: p.dir, - dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), + children: roots + .map(coordId => indexOf(vertex(p.projectKey, coordId))) + .sort((x, y) => x - y), + })), + } +} + +// Direct dependencies first, then what they leave unreached: nodes nothing +// depends on, then anything left on an unreached cycle. Every resolved +// dependency of the subproject is then in its tree. +function treeRoots( + graph: SubprojectGraph | undefined, + byComponent: (a: string, b: string) => number, +): string[] { + if (!graph) { + return [] + } + const roots: string[] = [] + const reached = new Set() + const add = (root: string) => { + roots.push(root) + const stack = [root] + while (stack.length) { + const id = stack.pop()! + if (!reached.has(id)) { + reached.add(id) + stack.push(...graph.children.get(id)!) + } } - return entry - }) - projects.sort((a, b) => { - const ka = `${a.subprojectDir} ${a.namespace}:${a.name}` - const kb = `${b.subprojectDir} ${b.namespace}:${b.name}` - return ka < kb ? -1 : ka > kb ? 1 : 0 - }) - return projects + } + for (const id of [...graph.direct].sort(byComponent)) { + add(id) + } + const hasParent = new Set([...graph.children.values()].flatMap(c => [...c])) + const rest = [...graph.children.keys()].sort(byComponent) + for (const id of [ + ...rest.filter(id => !hasParent.has(id)), + ...rest.filter(id => hasParent.has(id)), + ]) { + if (!reached.has(id)) { + add(id) + } + } + return roots } function unionInto( @@ -350,9 +491,48 @@ function buildClasspathByProject( ) } +function buildDirectDependenciesByProject( + projects: RawProject[], + directByRoot: Map>, + perRoot: Map, +): Map { + const idsByProjectKey = new Map>() + for (const [rootId, ids] of directByRoot) { + const projectKey = perRoot.get(rootId)?.projectKey ?? '' + let set = idsByProjectKey.get(projectKey) + if (!set) { + set = new Set() + idsByProjectKey.set(projectKey, set) + } + for (const id of ids) { + set.add(id) + } + } + const directByProject = new Map>() + for (const p of projects) { + const key = projectClasspathKey({ + name: p.name, + namespace: p.group, + subprojectDir: p.dir, + }) + let set = directByProject.get(key) + if (!set) { + set = new Set() + directByProject.set(key, set) + } + for (const id of idsByProjectKey.get(p.projectKey) ?? []) { + set.add(id) + } + } + return new Map( + [...directByProject].map(({ 0: key, 1: ids }) => [key, [...ids].sort()]), + ) +} + function buildArtifactPaths( finalNodes: Map, projects: RawProject[], + directByRoot: Map>, projectsByGav: Map, perRoot: Map, fileExists: (path: string) => boolean, @@ -432,6 +612,11 @@ function buildArtifactPaths( sourcesByCoord, coords, classpathByProject: buildClasspathByProject(projects, perRoot), + directDependenciesByProject: buildDirectDependenciesByProject( + projects, + directByRoot, + perRoot, + ), } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 40fc827e7..40f168c7d 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -4,8 +4,29 @@ import { assembleFacts } from './assemble.mts' import { parseRecords } from './records.mts' import { accumulateSidecar, serializeSidecar } from './sidecar.mts' +import type { SocketFactsSbom } from './facts.mts' import type { SidecarAccumulator } from './sidecar.mts' +// Each project's tree, written out with component ids; a repeat on the current +// path (a cycle) is written without its children. +function trees(facts: SocketFactsSbom): Record { + const deps = facts.dependencies ?? [] + const render = (i: number, path: number[]): string => { + const id = facts.components[deps[i]!.component]!.id + const children = deps[i]!.children ?? [] + if (path.includes(i) || !children.length) { + return id + } + return `${id}(${children.map(c => render(c, [...path, i])).join(', ')})` + } + return Object.fromEntries( + (facts.projects ?? []).map(p => [ + p.name, + p.children.map(i => render(i, [])), + ]), + ) +} + // Minimal line-protocol records for a one-module Gradle build (--with-files): // - first-party module `:app` (a project, NOT a dependency node) with source + // output roots, @@ -23,6 +44,33 @@ const RECORDS = [ 'scanned\truntimeClasspath', ].join('\n') +function chain(root: string, project: string, version: string): string[] { + return [ + `root\t${root}\t${project}\tcompile\t1`, + `node\t${root}\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1`, + `node\t${root}\tg:mid:jar:1\tg\tmid\t1\tjar\t\t0`, + `node\t${root}\tg:dep:jar:${version}\tg\tdep\t${version}\tjar\t\t0`, + `edge\t${root}\tg:lib:jar:1\tg:mid:jar:1`, + `edge\t${root}\tg:mid:jar:1\tg:dep:jar:${version}`, + ] +} + +function cycle(root: string, project: string, extra: boolean): string[] { + return [ + `root\t${root}\t${project}\tcompile\t1`, + `node\t${root}\tg:x:jar:1\tg\tx\t1\tjar\t\t1`, + `node\t${root}\tg:y:jar:1\tg\ty\t1\tjar\t\t0`, + `edge\t${root}\tg:x:jar:1\tg:y:jar:1`, + `edge\t${root}\tg:y:jar:1\tg:x:jar:1`, + ...(extra + ? [ + `node\t${root}\tg:z:jar:1\tg\tz\t1\tjar\t\t0`, + `edge\t${root}\tg:y:jar:1\tg:z:jar:1`, + ] + : []), + ] +} + describe('records → assemble → sidecar', () => { it('carries first-party project paths, external jars, and artifactless BOMs', () => { // Inject fileExists so the synthetic absolute paths aren't filtered out. @@ -69,7 +117,7 @@ describe('records → assemble → sidecar', () => { expect(bom?.targets).toEqual([]) expect(bom?.sources).toEqual([]) }) - it('merges a coordinate with divergent subtrees into one component and scopes classpaths per project', () => { + it('keeps a coordinate with divergent subtrees as one component with a dependency entry per subtree', () => { // :a and :b both depend on `lib`, which pulls a different `dep` version in // each subproject. const records = [ @@ -97,24 +145,140 @@ describe('records → assemble → sidecar', () => { 'g:junit:jar:4', 'g:lib:jar:1', ]) - expect( - facts.components.find(c => c.id === 'g:lib:jar:1')?.dependencies, - ).toEqual(['g:dep:jar:1', 'g:dep:jar:2']) + expect(facts.components[3]).not.toHaveProperty('dependencies') + expect(facts.dependencies).toEqual([ + { component: 0 }, + { component: 3, children: [0] }, + { component: 2 }, + { component: 1 }, + { component: 3, children: [3] }, + ]) + expect(trees(facts)).toEqual({ + a: ['g:lib:jar:1(g:dep:jar:1)'], + b: ['g:junit:jar:4', 'g:lib:jar:1(g:dep:jar:2)'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') const byName = new Map( serializeSidecar(acc)['/abs/.socket.facts.json']!.projects.map(p => [ p.name, - p.classpath, + p, ]), ) - expect(byName.get('a')).toEqual(['g:dep:jar:1', 'g:lib:jar:1']) - expect(byName.get('b')).toEqual([ + expect(byName.get('a')?.classpath).toEqual(['g:dep:jar:1', 'g:lib:jar:1']) + expect(byName.get('b')?.classpath).toEqual([ 'g:dep:jar:2', 'g:junit:jar:4', 'g:lib:jar:1', ]) + expect(byName.get('b')?.dependencies).toEqual([ + 'g:junit:jar:4', + 'g:lib:jar:1', + ]) + expect(byName.get('b')).not.toHaveProperty('children') + }) + it('shares one dependency entry between identical subtrees across subprojects', () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'project\ta\tg\ta\t1\ta', + 'project\tb\tg\tb\t1\tb', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', + 'edge\tr1\tg:lib:jar:1\tg:dep:jar:1', + 'root\tr2\tb\tcompile\t1', + 'node\tr2\tg:other:jar:1\tg\tother\t1\tjar\t\t1', + 'node\tr2\tg:lib:jar:1\tg\tlib\t1\tjar\t\t0', + 'node\tr2\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', + 'edge\tr2\tg:other:jar:1\tg:lib:jar:1', + 'edge\tr2\tg:lib:jar:1\tg:dep:jar:1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect(facts.dependencies).toHaveLength(3) + expect(trees(facts)).toEqual({ + a: ['g:lib:jar:1(g:dep:jar:1)'], + b: ['g:other:jar:1(g:lib:jar:1(g:dep:jar:1))'], + }) + }) + it('tells apart subtrees that differ only further down', () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'project\ta\tg\ta\t1\ta', + 'project\tb\tg\tb\t1\tb', + ...chain('r1', 'a', '1'), + ...chain('r2', 'b', '2'), + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect(trees(facts)).toEqual({ + a: ['g:lib:jar:1(g:mid:jar:1(g:dep:jar:1))'], + b: ['g:lib:jar:1(g:mid:jar:1(g:dep:jar:2))'], + }) + }) + it('merges the configurations of a subproject into one tree', () => { + const records = [ + 'meta\tgradle\t8.0\t17', + 'project\t:a\tg\ta\t1\ta', + 'root\tr1\t:a\tcompileClasspath\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr1\tg:api:jar:1\tg\tapi\t1\tjar\t\t0', + 'edge\tr1\tg:lib:jar:1\tg:api:jar:1', + 'root\tr2\t:a\truntimeClasspath\t1', + 'node\tr2\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr2\tg:api:jar:1\tg\tapi\t1\tjar\t\t0', + 'node\tr2\tg:impl:jar:1\tg\timpl\t1\tjar\t\t0', + 'edge\tr2\tg:lib:jar:1\tg:api:jar:1', + 'edge\tr2\tg:lib:jar:1\tg:impl:jar:1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect(trees(facts)).toEqual({ + a: ['g:lib:jar:1(g:api:jar:1, g:impl:jar:1)'], + }) + }) + it('merges equal cycles and keeps different ones apart', () => { + const records = [ + 'meta\tsbt\t1.10.7\t17', + 'project\ta\tg\ta\t1\ta', + 'project\tb\tg\tb\t1\tb', + 'project\tc\tg\tc\t1\tc', + ...cycle('r1', 'a', false), + ...cycle('r2', 'b', false), + ...cycle('r3', 'c', true), + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + // x and y once for a and b together, and once more for c, plus z. + expect(facts.dependencies).toHaveLength(5) + const [a, b, c] = facts.projects! + expect(a!.children).toEqual(b!.children) + expect(c!.children).not.toEqual(a!.children) + expect(trees(facts)).toEqual({ + a: ['g:x:jar:1(g:y:jar:1(g:x:jar:1))'], + b: ['g:x:jar:1(g:y:jar:1(g:x:jar:1))'], + c: ['g:x:jar:1(g:y:jar:1(g:z:jar:1, g:x:jar:1))'], + }) + }) + it('roots a resolved dependency that no direct dependency reaches', () => { + const records = [ + 'meta\tsbt\t1.10.7\t17', + 'project\ta\tg\ta\t1\ta', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr1\tg:loose:jar:1\tg\tloose\t1\tjar\t\t0', + 'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', + 'edge\tr1\tg:loose:jar:1\tg:dep:jar:1', + ].join('\n') + const { artifactPaths, facts } = assembleFacts(parseRecords(records)) + + expect(trees(facts)).toEqual({ + a: ['g:lib:jar:1', 'g:loose:jar:1(g:dep:jar:1)'], + }) + expect(artifactPaths.directDependenciesByProject.get('a g:a')).toEqual([ + 'g:lib:jar:1', + ]) }) it('marks only components with the exact coordinate of a build module as firstParty', () => { const records = [ diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index f3f41010e..c1084e083 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -12,6 +12,7 @@ export type SocketFactsSbom = { metadata?: SocketFactsSbomMetadata | undefined projects?: SocketFactsSbomProject[] | undefined components: SocketFactsSbomComponent[] + dependencies?: SocketFactsSbomDependency[] | undefined } export type SocketFactsSbomMetadata = { @@ -29,7 +30,6 @@ export type SocketFactsSbomComponent = AnyPURL & { dev?: boolean | undefined // A module of the scanned build itself (same GAV as a projects[] entry). firstParty?: true | undefined - dependencies?: string[] | undefined // Direct dependencies only: the facts file plus the build files of the subprojects // pulling it in directly, which need not declare it (e.g. a parent POM does). manifestFiles?: SocketFactsManifestReference[] | undefined @@ -40,9 +40,22 @@ export type SocketFactsManifestReference = { file: string } +// A component together with its exact dependency subtree. Entries are shared +// by every position whose subtree is identical, so one component has an entry +// per distinct subtree. +export type SocketFactsSbomDependency = { + // Index into components[]. + component: number + // Indices into dependencies[]. + children?: number[] | undefined +} + export type SocketFactsSbomProject = AnyPURL & { subprojectDir: string - dependencies: string[] + // Indices into dependencies[]: the roots of this subproject's dependency + // tree, i.e. its direct dependencies plus any resolved dependency they don't + // reach. + children: number[] } // Resolved on-disk paths for a --with-files run, keyed by coordinate. `targets` @@ -58,6 +71,9 @@ export type ResolvedArtifactPaths = { // Component ids on each project's resolved classpath (union over its // configurations), keyed by projectClasspathKey. classpathByProject: Map + // Component ids of each project's direct dependencies, keyed by + // projectClasspathKey. + directDependenciesByProject: Map } export function projectClasspathKey( diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index f0fd5fddf..68a8cd270 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -19,9 +19,11 @@ export type SidecarComponentEntry = SocketFactsSbomComponent & { sources?: string[] | undefined } -export type SidecarProjectEntry = SocketFactsSbomProject & { +export type SidecarProjectEntry = Omit & { targets?: string[] | undefined sources?: string[] | undefined + // Ids of this facts file's components[] the project depends on directly. + dependencies: string[] // Ids of this facts file's components[] forming the project's full // transitive classpath across all its configurations. classpath: string[] @@ -112,13 +114,20 @@ export function accumulateSidecar( withPaths ? attachPaths(entry, artifactPaths) : { ...entry } acc.set(factsFile, { components: facts.components.map(paths), - projects: (facts.projects ?? []).map(proj => ({ - ...paths(proj), - classpath: [ - ...(artifactPaths.classpathByProject.get(projectClasspathKey(proj)) ?? - []), - ], - })), + projects: (facts.projects ?? []).map( + ({ children: _children, ...proj }) => ({ + ...paths(proj), + dependencies: [ + ...(artifactPaths.directDependenciesByProject.get( + projectClasspathKey(proj), + ) ?? []), + ], + classpath: [ + ...(artifactPaths.classpathByProject.get(projectClasspathKey(proj)) ?? + []), + ], + }), + ), }) } diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index e818972e7..bb7136615 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -18,6 +18,7 @@ function emptyArtifactPaths(): ResolvedArtifactPaths { sourcesByCoord: new Map(), coords: new Set(), classpathByProject: new Map(), + directDependenciesByProject: new Map(), } } @@ -53,7 +54,7 @@ describe('compute-artifacts sidecar', () => { namespace: 'g', name: 'app', subprojectDir: 'app', - dependencies: ['g:a:jar:1'], + children: [0], }, ], components: [ @@ -69,6 +70,7 @@ describe('compute-artifacts sidecar', () => { } const artifactPaths = emptyArtifactPaths() artifactPaths.classpathByProject.set('app g:app', ['g:a:jar:1']) + artifactPaths.directDependenciesByProject.set('app g:app', ['g:a:jar:1']) const acc: SidecarAccumulator = new Map() accumulateSidecar( @@ -81,7 +83,11 @@ describe('compute-artifacts sidecar', () => { const entry = serializeSidecar(acc)['/root/.socket.facts.json']! expect(entry.projects[0]).toEqual({ - ...facts.projects![0], + type: 'maven', + namespace: 'g', + name: 'app', + subprojectDir: 'app', + dependencies: ['g:a:jar:1'], classpath: ['g:a:jar:1'], }) expect(entry.components[0]).toEqual(facts.components[0]) @@ -188,7 +194,6 @@ describe('compute-artifacts sidecar', () => { qualifiers: { ext: 'jar', classifier: 'sources' }, id: 'g:a:jar:sources:1', direct: true, - dependencies: ['x'], }, ], } @@ -204,7 +209,6 @@ describe('compute-artifacts sidecar', () => { expect(entry.qualifiers?.['classifier']).toBe('sources') expect(entry.id).toBe('g:a:jar:sources:1') expect(entry.direct).toBe(true) - expect(entry.dependencies).toEqual(['x']) }) it('carries a first-party module (project, not a component) source/target roots, keyed by its own facts file', () => { @@ -219,7 +223,7 @@ describe('compute-artifacts sidecar', () => { name: 'app', version: '1.0', subprojectDir: 'app', - dependencies: [], + children: [], }, ], } @@ -256,7 +260,7 @@ describe('compute-artifacts sidecar', () => { type: 'maven', namespace: 'com.example', version: '1.0', - dependencies: [], + children: [], } const facts: SocketFactsSbom = { components: [], @@ -305,7 +309,7 @@ describe('compute-artifacts sidecar', () => { name: 'shared', version: '1.0', subprojectDir: '.', - dependencies: [], + children: [], }, ], } diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 237e664ea..d96b3d759 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -239,13 +239,13 @@ describe('markWorkspaceCoverage', () => { type: 'maven', name: 'moduleA', subprojectDir: 'moduleA', - dependencies: [], + children: [], }, { type: 'maven', name: 'moduleB', subprojectDir: 'moduleB', - dependencies: [], + children: [], }, ], }) @@ -271,13 +271,13 @@ describe('markWorkspaceCoverage', () => { type: 'maven', name: 'moduleA', subprojectDir: 'moduleA', - dependencies: [], + children: [], }, { type: 'maven', name: 'shared-lib', subprojectDir: '../shared-lib', - dependencies: [], + children: [], }, ], }) @@ -1166,13 +1166,13 @@ describe('setupRecursiveManifestConfig', () => { type: 'maven', name: 'module-a', subprojectDir: 'module-a', - dependencies: [], + children: [], }, { type: 'maven', name: 'module-b', subprojectDir: 'module-b', - dependencies: [], + children: [], }, ], } From b45d03d09b389b801392e116fd98ec7715690487 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 7 Oct 2026 13:33:49 +0200 Subject: [PATCH 02/12] refactor(manifest): trim facts graph comments Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 11 +++-------- src/commands/manifest/scripts/facts.mts | 5 ++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index a048397ca..b351d2d52 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -256,10 +256,8 @@ function buildComponents( }) } -// One dependency tree per subproject, the union of its configurations. Tree -// positions merge when their full subtrees are equal: the coarsest bisimulation -// over the subproject graphs labelled by component, found by partition -// refinement so that cycles merge exactly too. +// Equal subtrees share an entry; partition refinement (coarsest bisimulation) +// keeps that exact on cycles too. function buildDependencyGraph( parsed: ParsedRecords, perRoot: Map, @@ -291,7 +289,6 @@ function buildDependencyGraph( } } - // Vertices are (subproject, coordinate) pairs. const vertexIds = new Map() const labels: number[] = [] const edges: number[][] = [] @@ -396,9 +393,7 @@ function buildDependencyGraph( } } -// Direct dependencies first, then what they leave unreached: nodes nothing -// depends on, then anything left on an unreached cycle. Every resolved -// dependency of the subproject is then in its tree. +// Unreached nodes become roots too, so every resolved dependency is in the tree. function treeRoots( graph: SubprojectGraph | undefined, byComponent: (a: string, b: string) => number, diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index c1084e083..7892ce7ea 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -40,9 +40,8 @@ export type SocketFactsManifestReference = { file: string } -// A component together with its exact dependency subtree. Entries are shared -// by every position whose subtree is identical, so one component has an entry -// per distinct subtree. +// A component with one exact subtree, shared by every position whose subtree +// is identical. export type SocketFactsSbomDependency = { // Index into components[]. component: number From 777dbfd59b0f11826c583ace8e85f01d7d24c757 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 7 Oct 2026 14:53:36 +0200 Subject: [PATCH 03/12] feat(manifest): attach build files to projects instead of components In the dependency-tree shape, a subproject's own build files go on projects[].manifestFiles, and components no longer carry manifestFiles. Consumers attribute a direct component to the build files of the projects whose children include it, and every component to the facts file itself, so neither is written per component. Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 50 +++---------------- .../manifest/scripts/assemble.test.mts | 18 +++---- src/commands/manifest/scripts/facts.mts | 6 +-- 3 files changed, 19 insertions(+), 55 deletions(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index b351d2d52..5531ff8b9 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -2,7 +2,6 @@ import { existsSync } from 'node:fs' import { type ResolvedArtifactPaths, - type SocketFactsManifestReference, type SocketFactsSbom, type SocketFactsSbomComponent, type SocketFactsSbomDependency, @@ -11,7 +10,6 @@ import { mavenCoordinateKey, projectClasspathKey, } from './facts.mts' -import constants from '../../../constants.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' @@ -64,11 +62,7 @@ export function assembleFacts( for (const p of parsed.projects.values()) { projectsByGav.set(gav(p.group, p.name, p.version), p) } - const components = buildComponents( - finalNodes, - projectsByGav, - buildManifestFilesByCoord(parsed, directByRoot, perRoot), - ) + const components = buildComponents(finalNodes, projectsByGav) const { dependencies, projects } = buildDependencyGraph( parsed, perRoot, @@ -184,40 +178,9 @@ function mergeByCoordinate(perRoot: Map): { return { finalNodes, directByRoot } } -function buildManifestFilesByCoord( - parsed: ParsedRecords, - directByRoot: Map>, - perRoot: Map, -): Map { - const buildFilesByCoord = new Map>() - for (const [rootId, ids] of directByRoot) { - const projectKey = perRoot.get(rootId)?.projectKey ?? '' - const buildFiles = parsed.projects.get(projectKey)?.buildFiles ?? [] - for (const id of ids) { - let set = buildFilesByCoord.get(id) - if (!set) { - set = new Set() - buildFilesByCoord.set(id, set) - } - for (const f of buildFiles) { - set.add(f) - } - } - } - return new Map( - [...buildFilesByCoord].map(({ 0: id, 1: buildFiles }) => [ - id, - [constants.DOT_SOCKET_DOT_FACTS_JSON, ...[...buildFiles].sort()].map( - file => ({ file }), - ), - ]), - ) -} - function buildComponents( finalNodes: Map, projectsByGav: Map, - manifestFilesByCoord: Map, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -248,10 +211,6 @@ function buildComponents( if (projectsByGav.has(gav(c.group, c.name, c.version ?? ''))) { comp.firstParty = true } - const manifestFiles = manifestFilesByCoord.get(id) - if (manifestFiles) { - comp.manifestFiles = manifestFiles - } return comp }) } @@ -386,6 +345,13 @@ function buildDependencyGraph( name: p.name, ...(p.version ? { version: p.version } : {}), subprojectDir: p.dir, + ...(p.buildFiles.length + ? { + manifestFiles: [...new Set(p.buildFiles)] + .sort() + .map(file => ({ file })), + } + : {}), children: roots .map(coordId => indexOf(vertex(p.projectKey, coordId))) .sort((x, y) => x - y), diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 40f168c7d..72a8f083b 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -313,7 +313,7 @@ describe('records → assemble → sidecar', () => { expect(project).not.toHaveProperty('firstParty') } }) - it('marks direct dependencies with the facts file and the build files of the subprojects they are direct in', () => { + it('marks each subproject with its own build files and no component with any', () => { const records = [ 'meta\tmaven\t3.9.6\t17', 'project\ta\tg\ta\t1\ta', @@ -337,17 +337,15 @@ describe('records → assemble → sidecar', () => { expect( Object.fromEntries( - facts.components.map(c => [c.id, c.manifestFiles ?? 'absent']), + facts.projects!.map(p => [p.name, p.manifestFiles ?? 'absent']), ), ).toEqual({ - 'g:a:jar:1': [{ file: '.socket.facts.json' }, { file: 'b/pom.xml' }], - 'g:dep:jar:3': 'absent', - 'g:ext:jar:2': [ - { file: '.socket.facts.json' }, - { file: 'a/pom.xml' }, - { file: 'b/pom.xml' }, - ], - 'g:solo:jar:1': [{ file: '.socket.facts.json' }], + a: [{ file: 'a/pom.xml' }], + b: [{ file: 'b/pom.xml' }], + c: 'absent', }) + for (const c of facts.components) { + expect(c).not.toHaveProperty('manifestFiles') + } }) }) diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 7892ce7ea..f9216031f 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -30,9 +30,6 @@ export type SocketFactsSbomComponent = AnyPURL & { dev?: boolean | undefined // A module of the scanned build itself (same GAV as a projects[] entry). firstParty?: true | undefined - // Direct dependencies only: the facts file plus the build files of the subprojects - // pulling it in directly, which need not declare it (e.g. a parent POM does). - manifestFiles?: SocketFactsManifestReference[] | undefined } // Relative to the facts file's directory. @@ -51,6 +48,9 @@ export type SocketFactsSbomDependency = { export type SocketFactsSbomProject = AnyPURL & { subprojectDir: string + // The subproject's own build files. They need not declare its direct + // dependencies (e.g. a parent POM does); the facts file itself is implied. + manifestFiles?: SocketFactsManifestReference[] | undefined // Indices into dependencies[]: the roots of this subproject's dependency // tree, i.e. its direct dependencies plus any resolved dependency they don't // reach. From 7da788ef45570de0f336f3f96b3cff68ef9bc093 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 10:21:06 +0200 Subject: [PATCH 04/12] fix(manifest): capture sbt dependency trees exactly The sbt facts plugin read lm-coursier's update report too literally, which left resolved nodes unreachable from a project's direct dependencies. The assembler then rooted those nodes in projects[].children, mixing them with real direct dependencies. - Match callers by org:name. lm-coursier stamps a caller with its declared version rather than the resolved one, so exact-GAV matching dropped edges from conflict winners. - Take sibling subprojects from the build structure, following sbt's own Classpaths.interSort. The update report lists every inter-project dependency in every configuration, and cross-versioning can merge distinct siblings into one module. - Fail on unreached nodes instead of rooting them, so projects[].children is exactly the direct dependencies. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + src/commands/manifest/scripts/assemble.mts | 38 ++++------ .../manifest/scripts/assemble.test.mts | 12 ++-- .../scripts/socket-facts.plugin.scala | 71 +++++++++++++++++-- 4 files changed, 85 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8381af07..5f2972978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Socket facts for Maven, Gradle and sbt builds now record each subproject's own dependency tree, so a package is attributed only to the subprojects that actually pull it in. +### Fixed +- Socket facts for sbt builds now match sbt's own classpaths: dependencies pulled in by a version-conflict winner are no longer dropped, and sibling subprojects appear only in the configurations that actually depend on them. + ## [1.5.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.5.0) - 2026-10-06 ### Added diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 5531ff8b9..4f461696c 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -300,7 +300,7 @@ function buildDependencyGraph( const projects = [...parsed.projects.values()] .map(p => ({ p, - roots: treeRoots(graphs.get(p.projectKey), byComponent), + roots: treeRoots(p.projectKey, graphs.get(p.projectKey), byComponent), })) .sort((a, b) => { const ka = `${a.p.dir} ${a.p.group}:${a.p.name}` @@ -359,40 +359,30 @@ function buildDependencyGraph( } } -// Unreached nodes become roots too, so every resolved dependency is in the tree. function treeRoots( + projectKey: string, graph: SubprojectGraph | undefined, byComponent: (a: string, b: string) => number, ): string[] { if (!graph) { return [] } - const roots: string[] = [] + const roots = [...graph.direct].sort(byComponent) const reached = new Set() - const add = (root: string) => { - roots.push(root) - const stack = [root] - while (stack.length) { - const id = stack.pop()! - if (!reached.has(id)) { - reached.add(id) - stack.push(...graph.children.get(id)!) - } - } - } - for (const id of [...graph.direct].sort(byComponent)) { - add(id) - } - const hasParent = new Set([...graph.children.values()].flatMap(c => [...c])) - const rest = [...graph.children.keys()].sort(byComponent) - for (const id of [ - ...rest.filter(id => !hasParent.has(id)), - ...rest.filter(id => hasParent.has(id)), - ]) { + const stack = [...roots] + while (stack.length) { + const id = stack.pop()! if (!reached.has(id)) { - add(id) + reached.add(id) + stack.push(...graph.children.get(id)!) } } + const unreached = [...graph.children.keys()].filter(id => !reached.has(id)) + if (unreached.length) { + throw new Error( + `Resolved dependencies of ${projectKey} are not reachable from its direct dependencies: ${unreached.sort(byComponent).join(', ')}`, + ) + } return roots } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 72a8f083b..99dd7f2c8 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -261,7 +261,7 @@ describe('records → assemble → sidecar', () => { c: ['g:x:jar:1(g:y:jar:1(g:z:jar:1, g:x:jar:1))'], }) }) - it('roots a resolved dependency that no direct dependency reaches', () => { + it('rejects a resolved dependency that no direct dependency reaches', () => { const records = [ 'meta\tsbt\t1.10.7\t17', 'project\ta\tg\ta\t1\ta', @@ -271,14 +271,10 @@ describe('records → assemble → sidecar', () => { 'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', 'edge\tr1\tg:loose:jar:1\tg:dep:jar:1', ].join('\n') - const { artifactPaths, facts } = assembleFacts(parseRecords(records)) - expect(trees(facts)).toEqual({ - a: ['g:lib:jar:1', 'g:loose:jar:1(g:dep:jar:1)'], - }) - expect(artifactPaths.directDependenciesByProject.get('a g:a')).toEqual([ - 'g:lib:jar:1', - ]) + expect(() => assembleFacts(parseRecords(records))).toThrow( + 'Resolved dependencies of a are not reachable from its direct dependencies: g:dep:jar:1, g:loose:jar:1', + ) }) it('marks only components with the exact coordinate of a build module as firstParty', () => { const records = [ diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index e14edfe3a..b67bdbae9 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -278,9 +278,46 @@ object SocketFactsPlugin extends AutoPlugin { moduleExts: Map[String, String] ): mutable.LinkedHashMap[String, RootTree] = { val perRoot = mutable.LinkedHashMap.empty[String, RootTree] - val rootGav = gavKey(rootIdOf(extracted, ref)) + val rootKey = moduleKey(rootIdOf(extracted, ref)) + + // Build modules come from the build structure instead: lm-coursier lists every inter-project + // dependency in every configuration, and cross-versioning can merge distinct ones. + def emittable(m: ModuleReport): Boolean = + !m.evicted && !moduleExts.contains(gavKey(m.module)) + + def confsOf(r: ProjectRef): Seq[Configuration] = extracted.getOpt(ivyConfigurations.in(r)).getOrElse(Nil) + val buildDeps = extracted.get(buildDependencies) + + // Classpaths.interSort, recording the (project -> dependency project) edges it follows. + def interProjectEdges(conf: Configuration): mutable.LinkedHashSet[(ProjectRef, ProjectRef)] = { + val masterConfs = confsOf(ref).map(_.name) + val visited = mutable.HashSet.empty[(ProjectRef, String)] + val edges = mutable.LinkedHashSet.empty[(ProjectRef, ProjectRef)] + def visit(p: ProjectRef, c: Configuration): Unit = { + val applicable = Classpaths.allConfigs(c) + applicable.foreach(ac => visited += (p -> ac.name)) + buildDeps.classpath.getOrElse(p, Nil).foreach { d => + val depConfs = confsOf(d.project) + val mapping = Classpaths.mapped(d.configuration, masterConfs, depConfs.map(_.name), "compile", "*->compile") + for { + ac <- applicable + depConfName <- mapping(ac.name) + depConf <- depConfs.find(_.name == depConfName) + } { + edges += (p -> d.project) + if (!visited((d.project, depConfName))) visit(d.project, depConf) + } + } + } + visit(ref, conf) + edges + } - def emittable(m: ModuleReport): Boolean = !m.evicted + def buildModuleCoord(r: ProjectRef): Coord = { + val mid = rootIdOf(extracted, r) + val ver = if (mid.revision == null) "" else mid.revision + Coord(mid.organization, mid.name, ver, moduleExts.getOrElse(gavKey(mid), ""), "") + } def inScope(m: ModuleID): Boolean = populateScope match { case None => true @@ -293,12 +330,25 @@ object SocketFactsPlugin extends AutoPlugin { scannedConfigs += cfg val prod = isProdConf(cfg) && !isTestConf(cfg) val nodes = mutable.LinkedHashMap.empty[String, Node] - // module GAV -> component ids (caller edges are module-level). + // org:name -> component ids (caller edges are module-level). val midToIds = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]] + confsOf(ref).find(_.name == cfg).foreach { conf => + def buildModuleNode(r: ProjectRef): Node = { + val coord = buildModuleCoord(r) + midToIds.getOrElseUpdate(moduleKey(rootIdOf(extracted, r)), mutable.LinkedHashSet.empty[String]) += coord.id + nodes.getOrElseUpdate(coord.id, new Node(coord)) + } + interProjectEdges(conf).foreach { case (p, d) => + val child = buildModuleNode(d) + if (p == ref) child.direct = true + else buildModuleNode(p).children += child.coord.id + } + } + cr.modules.foreach { m => if (emittable(m)) { - val ids = midToIds.getOrElseUpdate(gavKey(m.module), mutable.LinkedHashSet.empty[String]) + val ids = midToIds.getOrElseUpdate(moduleKey(m.module), mutable.LinkedHashSet.empty[String]) variantsOf(m, moduleExts).foreach { case (coord, fileOpt) => val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) ids += coord.id @@ -310,10 +360,12 @@ object SocketFactsPlugin extends AutoPlugin { // Caller edges within this config: a root caller marks the child direct, any other becomes its parent. cr.modules.foreach { m => if (emittable(m)) { - midToIds.get(gavKey(m.module)).foreach { childIds => + midToIds.get(moduleKey(m.module)).foreach { childIds => + // A caller's revision is lm-coursier's declared version, not the resolved one, so + // callers match by org:name (a configuration resolves one version per module). m.callers.foreach { c => - val callerKey = gavKey(c.caller) - if (callerKey == rootGav) childIds.foreach(cid => nodes(cid).direct = true) + val callerKey = moduleKey(c.caller) + if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true) else midToIds.get(callerKey).foreach { parentIds => // Drop self-edges (test → main resolving to the same coordinate), matching gradle. @@ -478,6 +530,11 @@ object SocketFactsPlugin extends AutoPlugin { } } + private def moduleKey(m: ModuleID): String = { + def s(v: String): String = if (v == null) "" else v + s(m.organization) + ":" + s(m.name) + } + private def gavKey(m: ModuleID): String = { def s(v: String): String = if (v == null) "" else v s(m.organization) + ":" + s(m.name) + ":" + s(m.revision) From 13324e14db9329a967200914a1fb600cd5f107e7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 10:21:24 +0200 Subject: [PATCH 05/12] feat(fix): use existing facts files and drop the fix sidecar With per-subproject trees in .socket.facts.json, Coana's socket fix reads each project's direct dependencies from the facts file itself. - Stop refusing an existing .socket.facts.json; upload it like any other manifest. --dynamic-sbom-inference regenerates it. - Stop writing the resolved-paths sidecar for socket fix and drop --compute-artifacts-sidecar, along with runDynamicSbomInference's now unused sidecar option. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../coana-fix-dynamic-sbom-inference.test.mts | 29 ++++++++++++------- src/commands/fix/coana-fix.mts | 28 ++---------------- src/commands/fix/generated-socket-facts.mts | 14 ++------- .../scan/run-dynamic-sbom-inference.mts | 9 ++---- 5 files changed, 28 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2972978..4f10cb8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Socket facts for Maven, Gradle and sbt builds now record each subproject's own dependency tree, so a package is attributed only to the subprojects that actually pull it in. +- `socket fix` now accepts an existing `.socket.facts.json` instead of asking you to delete it first. ### Fixed - Socket facts for sbt builds now match sbt's own classpaths: dependencies pulled in by a version-conflict winner are no longer dropped, and sibling subprojects appear only in the configurations that actually depend on them. diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 19f001d0a..87954d464 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -124,7 +124,6 @@ describe('socket fix --dynamic-sbom-inference', () => { const uploadManifestFiles = vi.fn() const generated = { paths: [FACTS], - sidecarFile: '/tmp/socket-fix-facts/sidecar.json', remove: vi.fn(), restore: vi.fn(), } @@ -153,9 +152,7 @@ describe('socket fix --dynamic-sbom-inference', () => { ) const args = coanaCalls('compute-fixes-and-upgrade-purls')[0]! expect(args).toContain('--maven-use-only-socket-facts') - expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe( - generated.sidecarFile, - ) + expect(args).not.toContain('--compute-artifacts-sidecar') expect(generated.remove).toHaveBeenCalledTimes(1) }) @@ -177,15 +174,27 @@ describe('socket fix --dynamic-sbom-inference', () => { ) }) - it('still refuses facts files that were already present', async () => { - mockGetPackageFilesForScan.mockResolvedValue([ - '/test/cwd/app/.socket.facts.json', - ]) + it('regenerates facts files that were already present', async () => { + mockGetPackageFilesForScan.mockResolvedValue([FACTS]) const result = await coanaFix(baseConfig) - expect(result.ok).toBe(false) - expect(mockGenerateSocketFactsForFix).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + expect(mockGenerateSocketFactsForFix).toHaveBeenCalledTimes(1) + }) + + it('uploads facts files that were already present without the flag', async () => { + mockGetPackageFilesForScan.mockResolvedValue([FACTS]) + + const result = await coanaFix({ + ...baseConfig, + dynamicSbomInference: false, + }) + + expect(result.ok).toBe(true) + expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { + pathsRelativeTo: '/test/cwd', + }) }) it('does not pass the facts restriction without the flag', async () => { diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 6b9191490..1aa57e3fc 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -350,21 +350,6 @@ async function coanaFixWithFacts( cwd, }) const scanFilepaths = await findScanFilepaths() - // Fail if any .socket.facts.json files are present in the scan folder. - // These are analysis artifacts and must be removed before re-running fix. - const factsFiles = scanFilepaths.filter(isFactsFile) - if (factsFiles.length) { - if (!silence) { - spinner?.stop() - } - return { - ok: false, - message: `Found ${DOT_SOCKET_DOT_FACTS_JSON} in manifest files`, - cause: - `Delete the following ${pluralize('file', factsFiles.length)} before running socket fix again:\n` + - factsFiles.map(p => ` - ${p}`).join('\n'), - } - } if (factsSlot) { if (!silence) { spinner?.stop() @@ -389,14 +374,7 @@ async function coanaFixWithFacts( spinner?.start() } } - const sidecarFile = factsSlot?.generated?.sidecarFile - // Discovery only needs which artifacts the facts files resolve; applying - // fixes also needs each project's exact classpath from the sidecar. - const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : [] - const factsFlags = [ - ...discoveryFlags, - ...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []), - ] + const factsFlags = factsSlot ? ['--maven-use-only-socket-facts'] : [] const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, scanFilepaths, { pathsRelativeTo: cwd, @@ -464,7 +442,7 @@ async function coanaFixWithFacts( coanaVersion, cwd, ecosystems, - factsFlags: discoveryFlags, + factsFlags, packageManagers, silence, spinner, @@ -612,7 +590,7 @@ async function coanaFixWithFacts( coanaVersion, cwd, ecosystems, - factsFlags: discoveryFlags, + factsFlags, packageManagers, silence, spinner, diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index 93cff3035..b8907c105 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,12 +1,10 @@ -import { copyFile, rm, writeFile } from 'node:fs/promises' +import { copyFile, rm } from 'node:fs/promises' import path from 'node:path' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' export type GeneratedSocketFacts = { paths: string[] - // The facts files' per-project classpaths, outside the repository. - sidecarFile: string | undefined remove: () => Promise restore: () => Promise } @@ -21,19 +19,12 @@ export async function generateSocketFactsForFix({ excludePaths: string[] tmpDir: string }): Promise { - const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ + const { factsPaths } = await runDynamicSbomInference({ cwd, excludePaths, sbtTmpDir: undefined, - sidecar: true, withFiles: false, }) - const sidecarFile = resolvedPathsSidecar - ? path.join(tmpDir, 'sidecar.json') - : undefined - if (sidecarFile) { - await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) - } const paths = factsPaths.map(p => path.resolve(cwd, p)) const backups = await Promise.all( paths.map(async (source, index) => { @@ -44,7 +35,6 @@ export async function generateSocketFactsForFix({ ) return { paths, - sidecarFile, async remove() { await Promise.all(paths.map(p => rm(p, { force: true }))) }, diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts index e51fb0b41..9336c5dc5 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -22,22 +22,19 @@ export async function runDynamicSbomInference({ cwd, excludePaths, sbtTmpDir, - sidecar, withFiles, }: { cwd: string excludePaths: string[] - // Collects the sidecar's per-project classpaths even without resolving - // artifact paths. - sidecar?: boolean | undefined // sbt provisions its Scala toolchain under this directory and withFiles' // artifactPaths point into it, so it must outlive whoever consumes them. // Only meaningful alongside `withFiles`. sbtTmpDir: string | undefined withFiles: boolean }): Promise { - const sidecarAcc: SidecarAccumulator | undefined = - (sidecar ?? withFiles) ? new Map() : undefined + const sidecarAcc: SidecarAccumulator | undefined = withFiles + ? new Map() + : undefined const outcomes = await generateRecursiveManifests({ cwd, excludePaths, From e5985a555f6952c58644aea558e123692326ed1c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 14:00:10 +0200 Subject: [PATCH 06/12] docs(manifest): correct projects[].children comment children holds exactly the subproject's direct dependencies; the assembler fails on unreached nodes instead of rooting them. Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/facts.mts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index f9216031f..237022f2e 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -51,9 +51,7 @@ export type SocketFactsSbomProject = AnyPURL & { // The subproject's own build files. They need not declare its direct // dependencies (e.g. a parent POM does); the facts file itself is implied. manifestFiles?: SocketFactsManifestReference[] | undefined - // Indices into dependencies[]: the roots of this subproject's dependency - // tree, i.e. its direct dependencies plus any resolved dependency they don't - // reach. + // Indices into dependencies[]: this subproject's direct dependencies. children: number[] } From 9ead5696ebb870ab938556c41334be004ef0abae Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 12 Oct 2026 10:58:26 +0200 Subject: [PATCH 07/12] fix(fix): keep existing facts files intact across --dynamic-sbom-inference Facts files present before generation were overwritten before any backup, and removed by the cleanup after a fix or a failed generation. Back them up first and restore them afterwards; upload a regenerated file only once. --- .../coana-fix-dynamic-sbom-inference.test.mts | 9 +- src/commands/fix/coana-fix.mts | 23 ++--- src/commands/fix/generated-socket-facts.mts | 79 +++++++++++----- .../fix/generated-socket-facts.test.mts | 91 +++++++++++++++++++ 4 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 src/commands/fix/generated-socket-facts.test.mts diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 87954d464..3865fc89a 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -174,13 +174,18 @@ describe('socket fix --dynamic-sbom-inference', () => { ) }) - it('regenerates facts files that were already present', async () => { + it('regenerates facts files that were already present, uploading each once', async () => { mockGetPackageFilesForScan.mockResolvedValue([FACTS]) const result = await coanaFix(baseConfig) expect(result.ok).toBe(true) - expect(mockGenerateSocketFactsForFix).toHaveBeenCalledTimes(1) + expect(mockGenerateSocketFactsForFix).toHaveBeenCalledWith( + expect.objectContaining({ existingPaths: [FACTS] }), + ) + expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { + pathsRelativeTo: '/test/cwd', + }) }) it('uploads facts files that were already present without the flag', async () => { diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index e5a5aed72..2e351a55d 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -379,19 +379,16 @@ async function coanaFixWithFacts( 'Generating Socket facts for Gradle, sbt and Maven builds ...', ) } - try { - factsSlot.generated = await generateSocketFactsForFix({ - cwd, - excludePaths, - tmpDir: factsSlot.tmpDir, - }) - } catch (e) { - // A failed build root aborts inference after others wrote their facts. - const partial = (await findScanFilepaths()).filter(isSocketFactsFile) - await Promise.all(partial.map(p => fs.rm(p, { force: true }))) - throw e - } - scanFilepaths.push(...factsSlot.generated.paths) + factsSlot.generated = await generateSocketFactsForFix({ + cwd, + excludePaths, + existingPaths: scanFilepaths.filter(isSocketFactsFile), + findFactsPaths: async () => + (await findScanFilepaths()).filter(isSocketFactsFile), + tmpDir: factsSlot.tmpDir, + }) + const known = new Set(scanFilepaths) + scanFilepaths.push(...factsSlot.generated.paths.filter(p => !known.has(p))) if (!silence) { spinner?.start() } diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index b8907c105..ff98cb6aa 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,4 +1,4 @@ -import { copyFile, rm } from 'node:fs/promises' +import { copyFile, mkdir, rm } from 'node:fs/promises' import path from 'node:path' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' @@ -9,39 +9,70 @@ export type GeneratedSocketFacts = { restore: () => Promise } -// Backed up so each fix attempt sees the pre-fix build after `git clean`. +type Backup = { backup: string; source: string } + +async function backUp(sources: string[], dir: string): Promise { + await mkdir(dir, { recursive: true }) + return await Promise.all( + sources.map(async (source, index) => { + const backup = path.join(dir, `${index}.json`) + await copyFile(source, backup) + return { backup, source } + }), + ) +} + +async function restoreAll(backups: Backup[]): Promise { + await Promise.all( + backups.map(({ backup, source }) => copyFile(backup, source)), + ) +} + +// Backed up so each fix attempt sees the pre-fix build after `git clean`, and +// facts files already present are left as they were found. export async function generateSocketFactsForFix({ cwd, excludePaths, + existingPaths, + findFactsPaths, tmpDir, }: { cwd: string excludePaths: string[] + existingPaths: string[] + findFactsPaths: () => Promise tmpDir: string }): Promise { - const { factsPaths } = await runDynamicSbomInference({ - cwd, - excludePaths, - sbtTmpDir: undefined, - withFiles: false, - }) - const paths = factsPaths.map(p => path.resolve(cwd, p)) - const backups = await Promise.all( - paths.map(async (source, index) => { - const backup = path.join(tmpDir, `${index}.json`) - await copyFile(source, backup) - return { backup, source } - }), - ) + const originals = await backUp(existingPaths, path.join(tmpDir, 'existing')) + const existing = new Set(existingPaths) + const reset = async (written: string[]) => { + await Promise.all( + written.filter(p => !existing.has(p)).map(p => rm(p, { force: true })), + ) + await restoreAll(originals) + } + let paths: string[] + try { + const { factsPaths } = await runDynamicSbomInference({ + cwd, + excludePaths, + sbtTmpDir: undefined, + withFiles: false, + }) + paths = factsPaths.map(p => path.resolve(cwd, p)) + } catch (e) { + // A failed build root aborts inference after others wrote their facts. + await reset(await findFactsPaths()) + throw e + } + const generated = new Set(paths) + const backups = [ + ...originals.filter(({ source }) => !generated.has(source)), + ...(await backUp(paths, path.join(tmpDir, 'generated'))), + ] return { paths, - async remove() { - await Promise.all(paths.map(p => rm(p, { force: true }))) - }, - async restore() { - await Promise.all( - backups.map(({ backup, source }) => copyFile(backup, source)), - ) - }, + remove: () => reset(paths), + restore: () => restoreAll(backups), } } diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts new file mode 100644 index 000000000..05a63777c --- /dev/null +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -0,0 +1,91 @@ +import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { generateSocketFactsForFix } from './generated-socket-facts.mts' + +const mockRunDynamicSbomInference = vi.hoisted(() => vi.fn()) + +vi.mock('../scan/run-dynamic-sbom-inference.mts', () => ({ + runDynamicSbomInference: mockRunDynamicSbomInference, +})) + +describe('generateSocketFactsForFix', () => { + let cwd: string + let tmpDir: string + let existing: string + let fresh: string + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-fix-facts-cwd-')) + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-fix-facts-tmp-')) + existing = path.join(cwd, 'pom.xml.socket.facts.json') + fresh = path.join(cwd, 'sub', 'gradle.socket.facts.json') + await fs.mkdir(path.dirname(fresh)) + await fs.writeFile(existing, 'original') + }) + + afterEach(async () => { + await fs.rm(cwd, { force: true, recursive: true }) + await fs.rm(tmpDir, { force: true, recursive: true }) + }) + + async function generate() { + return await generateSocketFactsForFix({ + cwd, + excludePaths: [], + existingPaths: [existing], + findFactsPaths: async () => [existing, fresh].filter(p => existsSync(p)), + tmpDir, + }) + } + + it('restores the generated facts per attempt and the original facts afterwards', async () => { + mockRunDynamicSbomInference.mockImplementation(async () => { + await fs.writeFile(existing, 'regenerated') + await fs.writeFile(fresh, 'generated') + return { factsPaths: [existing, fresh] } + }) + + const generated = await generate() + await fs.rm(existing) + await fs.rm(fresh) + await generated.restore() + + expect(await fs.readFile(existing, 'utf8')).toBe('regenerated') + expect(await fs.readFile(fresh, 'utf8')).toBe('generated') + + await generated.remove() + + expect(await fs.readFile(existing, 'utf8')).toBe('original') + expect(existsSync(fresh)).toBe(false) + }) + + it('restores an existing facts file that generation does not rewrite', async () => { + mockRunDynamicSbomInference.mockImplementation(async () => { + await fs.writeFile(fresh, 'generated') + return { factsPaths: [fresh] } + }) + + const generated = await generate() + await fs.rm(existing) + await generated.restore() + + expect(await fs.readFile(existing, 'utf8')).toBe('original') + }) + + it('leaves the facts files as they were when generation fails', async () => { + mockRunDynamicSbomInference.mockImplementation(async () => { + await fs.writeFile(existing, 'partial') + await fs.writeFile(fresh, 'partial') + throw new Error('build root failed') + }) + + await expect(generate()).rejects.toThrow('build root failed') + + expect(await fs.readFile(existing, 'utf8')).toBe('original') + expect(existsSync(fresh)).toBe(false) + }) +}) From c501497a7e31b91a97943e2adcce1cebf4546e80 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 12 Oct 2026 11:05:33 +0200 Subject: [PATCH 08/12] refactor(manifest): derive facts trees and sidecar maps from one subproject graph Build each subproject's merged graph once and read projects[].children, the sidecar's direct dependencies and its classpath from it. Also drop a dead build-module ext lookup in the sbt plugin and pass socket fix's facts generation a single facts-file finder. --- .../coana-fix-dynamic-sbom-inference.test.mts | 4 +- src/commands/fix/coana-fix.mts | 1 - src/commands/fix/generated-socket-facts.mts | 3 +- .../fix/generated-socket-facts.test.mts | 1 - src/commands/manifest/scripts/assemble.mts | 131 +++++------------- .../scripts/socket-facts.plugin.scala | 21 ++- 6 files changed, 44 insertions(+), 117 deletions(-) diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 3865fc89a..a4dd1edf2 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -180,9 +180,7 @@ describe('socket fix --dynamic-sbom-inference', () => { const result = await coanaFix(baseConfig) expect(result.ok).toBe(true) - expect(mockGenerateSocketFactsForFix).toHaveBeenCalledWith( - expect.objectContaining({ existingPaths: [FACTS] }), - ) + expect(mockGenerateSocketFactsForFix).toHaveBeenCalledTimes(1) expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { pathsRelativeTo: '/test/cwd', }) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 2e351a55d..973589e8f 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -382,7 +382,6 @@ async function coanaFixWithFacts( factsSlot.generated = await generateSocketFactsForFix({ cwd, excludePaths, - existingPaths: scanFilepaths.filter(isSocketFactsFile), findFactsPaths: async () => (await findScanFilepaths()).filter(isSocketFactsFile), tmpDir: factsSlot.tmpDir, diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index ff98cb6aa..2d4275605 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -33,16 +33,15 @@ async function restoreAll(backups: Backup[]): Promise { export async function generateSocketFactsForFix({ cwd, excludePaths, - existingPaths, findFactsPaths, tmpDir, }: { cwd: string excludePaths: string[] - existingPaths: string[] findFactsPaths: () => Promise tmpDir: string }): Promise { + const existingPaths = await findFactsPaths() const originals = await backUp(existingPaths, path.join(tmpDir, 'existing')) const existing = new Set(existingPaths) const reset = async (written: string[]) => { diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 05a63777c..1e0548bcc 100644 --- a/src/commands/fix/generated-socket-facts.test.mts +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -36,7 +36,6 @@ describe('generateSocketFactsForFix', () => { return await generateSocketFactsForFix({ cwd, excludePaths: [], - existingPaths: [existing], findFactsPaths: async () => [existing, fresh].filter(p => existsSync(p)), tmpDir, }) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 415204d17..8bf2609f4 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -60,13 +60,14 @@ export function assembleFacts( ): AssembleResult { const fileExists = opts.fileExists ?? existsSync const perRoot = buildPerRoot(parsed) - const { directByRoot, finalNodes } = mergeById(perRoot) + const finalNodes = mergeById(perRoot) + const graphs = buildSubprojectGraphs(perRoot) const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] const components = buildComponents(finalNodes) const { dependencies, projects } = buildDependencyGraph( parsed, - perRoot, + graphs, components, ) @@ -87,8 +88,7 @@ export function assembleFacts( artifactPaths: buildArtifactPaths( finalNodes, [...parsed.projects.values()], - directByRoot, - perRoot, + graphs, fileExists, ), } @@ -141,13 +141,9 @@ function buildPerRoot(parsed: ParsedRecords): Map { // Components are merged by id across every resolution root; which ids belong // to which subproject is kept separately (classpathByProject) for // reachability, which needs each subproject's exact classpath. -function mergeById(perRoot: Map): { - finalNodes: Map - directByRoot: Map> -} { +function mergeById(perRoot: Map): Map { const finalNodes = new Map() - const directByRoot = new Map>() - for (const [rootId, { nodes, prod }] of perRoot) { + for (const { nodes, prod } of perRoot.values()) { for (const [coordId, node] of nodes) { let fn = finalNodes.get(coordId) if (!fn) { @@ -169,17 +165,9 @@ function mergeById(perRoot: Map): { for (const t of node.targets) { fn.targets.add(t) } - if (node.direct) { - let d = directByRoot.get(rootId) - if (!d) { - d = new Set() - directByRoot.set(rootId, d) - } - d.add(coordId) - } } } - return { finalNodes, directByRoot } + return finalNodes } function buildComponents( @@ -218,17 +206,10 @@ function buildComponents( }) } -// Equal subtrees share an entry; partition refinement (coarsest bisimulation) -// keeps that exact on cycles too. -function buildDependencyGraph( - parsed: ParsedRecords, +// One graph per subproject, its configurations merged. +function buildSubprojectGraphs( perRoot: Map, - components: SocketFactsSbomComponent[], -): { - dependencies: SocketFactsSbomDependency[] - projects: SocketFactsSbomProject[] -} { - const componentIndex = new Map(components.map((c, i) => [c.id, i])) +): Map { const graphs = new Map() for (const { nodes, projectKey } of perRoot.values()) { let g = graphs.get(projectKey) @@ -250,6 +231,20 @@ function buildDependencyGraph( } } } + return graphs +} + +// Equal subtrees share an entry; partition refinement (coarsest bisimulation) +// keeps that exact on cycles too. +function buildDependencyGraph( + parsed: ParsedRecords, + graphs: Map, + components: SocketFactsSbomComponent[], +): { + dependencies: SocketFactsSbomDependency[] + projects: SocketFactsSbomProject[] +} { + const componentIndex = new Map(components.map((c, i) => [c.id, i])) const vertexIds = new Map() const labels: number[] = [] @@ -397,77 +392,23 @@ function treeRoots( return roots } -function buildClasspathByProject( +function byProject( projects: RawProject[], - perRoot: Map, -): Map { - const idsByProjectKey = new Map>() - for (const { nodes, projectKey } of perRoot.values()) { - let set = idsByProjectKey.get(projectKey) - if (!set) { - set = new Set() - idsByProjectKey.set(projectKey, set) - } - for (const coordId of nodes.keys()) { - set.add(coordId) - } - } - const classpathByProject = new Map>() - for (const p of projects) { - const key = p.projectKey - let set = classpathByProject.get(key) - if (!set) { - set = new Set() - classpathByProject.set(key, set) - } - for (const id of idsByProjectKey.get(p.projectKey) ?? []) { - set.add(id) - } - } - return new Map( - [...classpathByProject].map(({ 0: key, 1: ids }) => [key, [...ids].sort()]), - ) -} - -function buildDirectDependenciesByProject( - projects: RawProject[], - directByRoot: Map>, - perRoot: Map, + graphs: Map, + ids: (graph: SubprojectGraph) => Iterable, ): Map { - const idsByProjectKey = new Map>() - for (const [rootId, ids] of directByRoot) { - const projectKey = perRoot.get(rootId)?.projectKey ?? '' - let set = idsByProjectKey.get(projectKey) - if (!set) { - set = new Set() - idsByProjectKey.set(projectKey, set) - } - for (const id of ids) { - set.add(id) - } - } - const directByProject = new Map>() - for (const p of projects) { - const key = p.projectKey - let set = directByProject.get(key) - if (!set) { - set = new Set() - directByProject.set(key, set) - } - for (const id of idsByProjectKey.get(p.projectKey) ?? []) { - set.add(id) - } - } return new Map( - [...directByProject].map(({ 0: key, 1: ids }) => [key, [...ids].sort()]), + projects.map(p => { + const g = graphs.get(p.projectKey) + return [p.projectKey, g ? [...ids(g)].sort() : []] + }), ) } function buildArtifactPaths( finalNodes: Map, projects: RawProject[], - directByRoot: Map>, - perRoot: Map, + graphs: Map, fileExists: (path: string) => boolean, ): ResolvedArtifactPaths { const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() @@ -489,12 +430,8 @@ function buildArtifactPaths( } return { pathsById, - classpathByProject: buildClasspathByProject(projects, perRoot), - directDependenciesByProject: buildDirectDependenciesByProject( - projects, - directByRoot, - perRoot, - ), + classpathByProject: byProject(projects, graphs, g => g.children.keys()), + directDependenciesByProject: byProject(projects, graphs, g => g.direct), } } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index c69c13391..54e59a6c1 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -356,7 +356,7 @@ object SocketFactsPlugin extends AutoPlugin { cr.modules.foreach { m => if (emittable(m)) { val ids = midToIds.getOrElseUpdate(moduleKey(m.module), mutable.LinkedHashSet.empty[String]) - variantsOf(m, moduleExts).foreach { case (coord, fileOpt) => + variantsOf(m).foreach { case (coord, fileOpt) => val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) ids += coord.id if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath) @@ -389,16 +389,15 @@ object SocketFactsPlugin extends AutoPlugin { perRoot } - private def variantsOf(m: ModuleReport, moduleExts: Map[String, String]): Seq[(Coord, Option[File])] = { + private def variantsOf(m: ModuleReport): Seq[(Coord, Option[File])] = { val mid = m.module // `mid.revision` is normally set for a resolved module, but normalize null (Coord.id calls // `.nonEmpty`, which NPEs on null) to match the other call sites (gavKey, module records). val ver = if (mid.revision == null) "" else mid.revision val arts = m.artifacts if (arts == null || arts.isEmpty) - // No resolved artifact (e.g. an inter-project dep): stamp the module's real ext if it's a build - // module (matches depscan's ext=jar), else leave ext-less. - Seq((Coord(mid.organization, mid.name, ver, moduleExts.getOrElse(gavKey(mid), ""), ""), None)) + // No resolved artifact (e.g. a pom-only module): leave it ext-less. + Seq((Coord(mid.organization, mid.name, ver, "", ""), None)) else arts.toList.map { case (art, file) => @@ -537,15 +536,11 @@ object SocketFactsPlugin extends AutoPlugin { } } - private def moduleKey(m: ModuleID): String = { - def s(v: String): String = if (v == null) "" else v - s(m.organization) + ":" + s(m.name) - } + private def orEmpty(v: String): String = if (v == null) "" else v - private def gavKey(m: ModuleID): String = { - def s(v: String): String = if (v == null) "" else v - s(m.organization) + ":" + s(m.name) + ":" + s(m.revision) - } + private def moduleKey(m: ModuleID): String = orEmpty(m.organization) + ":" + orEmpty(m.name) + + private def gavKey(m: ModuleID): String = moduleKey(m) + ":" + orEmpty(m.revision) private def coordOf(m: ModuleID): String = { val rev = m.revision From bc9f62ccd43a7c4e501de4e057cd3acb8b7919ef Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 12 Oct 2026 12:18:51 +0200 Subject: [PATCH 09/12] refactor(manifest): mark each Gradle project with its configured build file A facts tree names each project's direct dependencies, so the build file on projects[].manifestFiles only has to mark the subproject. Gradle's API does not say which script declared a dependency, so use the project's configured build file, present or not, and drop the declaring-script records recovered from stack frames. --- CHANGELOG.md | 2 +- src/commands/manifest/scripts/assemble.mts | 14 ++-- .../manifest/scripts/assemble.test.mts | 31 ++------- src/commands/manifest/scripts/records.mts | 21 +----- .../manifest/scripts/socket-facts.init.gradle | 67 +------------------ 5 files changed, 14 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e952aa8..9086be9ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Socket facts for Maven, Gradle and sbt builds now record each subproject's own dependency tree, so a package is attributed only to the subprojects that actually pull it in. -- `socket fix` now accepts existing Socket facts files instead of asking you to delete it first. +- `socket fix` now accepts existing Socket facts files instead of asking you to delete them first. ### Fixed - Socket facts for sbt builds now match sbt's own classpaths: dependencies pulled in by a version-conflict winner are no longer dropped, and sibling subprojects appear only in the configurations that actually depend on them. diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 8bf2609f4..60daa21df 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -349,16 +349,10 @@ function buildDependencyGraph( .map(coordId => indexOf(vertex(p.projectKey, coordId))) .sort((x, y) => x - y), } - // A project without a build file of its own is defined by the scripts - // declaring its dependencies, e.g. the root build script. - const declared = [...new Set([...p.declaredIn.values()].flat())] - const files = p.buildFiles.length - ? p.buildFiles - : declared.length - ? declared - : p.missingBuildFiles - if (files.length) { - entry.manifestFiles = [...new Set(files)].sort().map(file => ({ file })) + if (p.buildFiles.length) { + entry.manifestFiles = [...new Set(p.buildFiles)] + .sort() + .map(file => ({ file })) } return entry }), diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 9cb9a630c..2167321a9 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -421,24 +421,17 @@ describe('records → assemble → sidecar', () => { 'g:mod-b:1': [{ file: 'mod/b.xml' }], }) }) - it('attributes a Gradle project without a build file of its own to the scripts declaring its dependencies', () => { + it('marks each Gradle project with its configured build file, even when absent', () => { const records = [ 'meta\tgradle\t9.2.1\t21', 'buildRoot\t/repo', 'project\t:\tg\troot\t1\t.', 'projectBuild\t:\tbuild.gradle.kts', + // Configured from the root build, no script of its own. 'project\t:a\tg\ta\t1\ta', - 'declared\t:a\tx\tfrom-root\tbuild.gradle.kts', - 'project\t:b\tg\tb\t1\tb', - 'projectBuild\t:b\tb/build.gradle.kts', - 'declared\t:b\tx\tfrom-root\tbuild.gradle.kts', - 'declared\t:b\tx\town\tb/build.gradle.kts', + 'projectBuild\t:a\ta/build.gradle.kts', 'root\tr1\t:a\truntimeClasspath\t1', 'node\tr1\tx:from-root:jar:1\tx\tfrom-root\t1\tjar\t\t1', - 'root\tr2\t:b\truntimeClasspath\t1', - 'node\tr2\tx:from-root:jar:1\tx\tfrom-root\t1\tjar\t\t1', - 'node\tr2\tx:own:jar:1\tx\town\t1\tjar\t\t1', - 'node\tr2\tx:by-plugin:jar:1\tx\tby-plugin\t1\tjar\t\t1', ].join('\n') const { facts } = assembleFacts(parseRecords(records)) @@ -448,25 +441,9 @@ describe('records → assemble → sidecar', () => { ), ).toEqual({ ':': ['build.gradle.kts'], - ':a': ['build.gradle.kts'], - ':b': ['b/build.gradle.kts'], + ':a': ['a/build.gradle.kts'], }) }) - it('falls back to the configured Gradle build file even when absent', () => { - const records = [ - 'meta\tgradle\t9.2.1\t21', - 'buildRoot\t/repo', - 'project\t:a\tg\ta\t1\ta', - 'projectBuild\t:a\ta/build.gradle.kts\tmissing', - 'root\tr1\t:a\truntimeClasspath\t1', - 'node\tr1\tx:undeclared:jar:1\tx\tundeclared\t1\tjar\t\t1', - ].join('\n') - const { facts } = assembleFacts(parseRecords(records)) - - expect(facts.projects![0]?.manifestFiles).toEqual([ - { file: 'a/build.gradle.kts' }, - ]) - }) }) describe('parseRecords', () => { diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index f043c1dbd..5d43f14c1 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -15,8 +15,7 @@ import type { // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) -// projectBuild projectKey path [missing] (build-root-relative; `missing`: configured, absent on disk) -// declared projectKey group name path (script declaring the dependency; build-root-relative) +// projectBuild projectKey path (build-root-relative; may be absent on disk) // root rootId projectKey config prod(0|1) // node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId @@ -68,12 +67,8 @@ export type RawProject = { dir: string sources: string[] targets: string[] - // The project's own build files, build-root-relative. + // The project's build files, build-root-relative. buildFiles: string[] - // Build files the tool configures for the project but absent on disk. - missingBuildFiles: string[] - // "group:name" -> the scripts that declared that dependency on this project. - declaredIn: Map } export type ParsedRecords = { @@ -155,8 +150,6 @@ export function parseRecords(text: string): ParsedRecords { sources: [], targets: [], buildFiles: [], - missingBuildFiles: [], - declaredIn: new Map(), } result.projects.set(key, p) } @@ -200,15 +193,7 @@ export function parseRecords(text: string): ParsedRecords { break case 'projectBuild': if (f[2]) { - const p = project(f[1] ?? '') - ;(f[3] === 'missing' ? p.missingBuildFiles : p.buildFiles).push(f[2]) - } - break - case 'declared': - if (f[4]) { - const { declaredIn } = project(f[1] ?? '') - const ga = `${f[2] ?? ''}:${f[3] ?? ''}` - declaredIn.set(ga, [...(declaredIn.get(ga) ?? []), f[4]]) + project(f[1] ?? '').buildFiles.push(f[2]) } break case 'root': { diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 90b3437d0..bf3621a34 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -63,65 +63,8 @@ gradle.ext.socketFactsState = [ projectArtifactExt : Collections.synchronizedMap([:]), // Project path -> "group:name", to tell this build's projects from an included build's. projectGaByPath : Collections.synchronizedMap([:]), - // Project path -> "group:name" -> build-root-relative scripts that declared that dependency. - declaredIn : Collections.synchronizedMap([:]), ] -// Kotlin DSL frames carry only the script's base name; its path is read from the script class -// loader's internal scope id (`kotlin-dsl::`), which needs StackWalker (Java 9+). -gradle.ext.socketInitScript = initscript.sourceFile?.canonicalFile -gradle.ext.socketDeclaringScript = { File rootDir -> - def rootPath = rootDir.canonicalFile.toPath() - def frames = [] - try { - StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).forEach { f -> - frames << [f.fileName, f.fileName?.endsWith('.kts') ? f.declaringClass.classLoader?.toString() : null] - } - } catch (Throwable ignore) { - frames = new Throwable().stackTrace.collect { f -> [f.fileName, null] } - } - for (fr in frames) { - def path = fr[0] - if (path == null) continue - if (fr[1] != null) { - def m = (fr[1] =~ ('kotlin-dsl:(.+?[\\\\/]' + java.util.regex.Pattern.quote(path) + '):')) - path = m.find() ? m.group(1) : null - if (path == null) continue - } - def f = new File(path) - if (!f.isAbsolute() || !f.isFile()) continue - def c = f.canonicalFile - if (c == gradle.ext.socketInitScript || !c.toPath().startsWith(rootPath)) continue - return rootPath.relativize(c.toPath()).toString().replace(File.separator, '/') - } - null -} - -allprojects { p -> - p.configurations.all { c -> - c.dependencies.whenObjectAdded { d -> - if (d.group == null || d.name == null) return - def script = gradle.ext.socketDeclaringScript.call(p.rootDir) - if (script == null) return - def byGa - synchronized (gradle.socketFactsState.declaredIn) { - byGa = gradle.socketFactsState.declaredIn.get(p.path) - if (byGa == null) { - byGa = [:] - gradle.socketFactsState.declaredIn.put(p.path, byGa) - } - def ga = "${d.group}:${d.name}".toString() - def set = byGa.get(ga) - if (set == null) { - set = [] as Set - byGa.put(ga, set) - } - set << script - } - } - } -} - // Capture every project's (group:name) before collectors run so they can filter intra-project // deps without an ordering dependency on other subprojects. gradle.projectsEvaluated { g -> @@ -179,7 +122,7 @@ gradle.projectsEvaluated { g -> def buildFiles = [] try { def bf = p.buildFile - if (bf != null) buildFiles << [rel(bf), bf.isFile()] + if (bf != null) buildFiles << rel(bf) } catch (Exception ignore) {} g.socketFactsState.projectsInfo.add([ path : p.path, @@ -575,13 +518,7 @@ rootProject { rp -> synchronized (state.projectsInfo) { projectsInfo = new ArrayList(state.projectsInfo) } projectsInfo.each { pi -> rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir]) - pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f[0]] + (f[1] ? [] : ['missing'])) } - def byGa - synchronized (state.declaredIn) { byGa = state.declaredIn.get(pi.path)?.collectEntries { k, v -> [k, new ArrayList(v)] } } - byGa?.keySet()?.sort()?.each { ga -> - def parts = ga.split(':', 2) - byGa[ga].sort().each { f -> rec(['declared', pi.path, parts[0], parts[1], f]) } - } + pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f]) } if (withFilesProjects) { pi.sources.each { s -> rec(['projectSrc', pi.path, s]) } pi.targets.each { t -> rec(['projectTgt', pi.path, t]) } From 0361123ea3e0af76db74490b7d49c1ab1e281a1c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 12 Oct 2026 12:29:22 +0200 Subject: [PATCH 10/12] docs(manifest): match facts comments to the reported build files and lm-coursier callers --- src/commands/manifest/scripts/facts.mts | 6 ++---- src/commands/manifest/scripts/socket-facts.plugin.scala | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 06ac83528..ca13ac151 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -51,10 +51,8 @@ export type SocketFactsSbomProject = AnyPURL & { // Maven's GAV, Gradle's project path, sbt's project id. id: string subprojectDir: string - // The module's own build files, e.g. a POM other than `/pom.xml`, - // which need not declare its direct dependencies; for a Gradle project - // without one, the scripts declaring its dependencies, else its configured - // build file, which may not exist on disk. The facts file itself is implied. + // The build files the tool reports for the project, e.g. a POM other than + // `/pom.xml`, or a Gradle build file absent on disk. manifestFiles?: SocketFactsManifestReference[] | undefined // Indices into dependencies[]: this subproject's direct dependencies. children: number[] diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 54e59a6c1..129d014dd 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -368,7 +368,7 @@ object SocketFactsPlugin extends AutoPlugin { cr.modules.foreach { m => if (emittable(m)) { midToIds.get(moduleKey(m.module)).foreach { childIds => - // A caller's revision is lm-coursier's declared version, not the resolved one, so + // lm-coursier gives a declared caller its declared revision, not the resolved one, so // callers match by org:name (a configuration resolves one version per module). m.callers.foreach { c => val callerKey = moduleKey(c.caller) From 57d97b5a200ed9f1fd16c8b9bcf9c5103a7963d8 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 12 Oct 2026 12:59:15 +0200 Subject: [PATCH 11/12] fix(fix): pass the compute-artifacts sidecar to Coana again socket fix can run an older Coana (--fix-version) that reads each project's classpath from the sidecar rather than from facts trees. A Coana that reads facts trees ignores the sidecar for them. --- .../coana-fix-dynamic-sbom-inference.test.mts | 12 ++++++++---- src/commands/fix/coana-fix.mts | 14 +++++++++++--- src/commands/fix/generated-socket-facts.mts | 13 +++++++++++-- .../fix/generated-socket-facts.test.mts | 17 +++++++++++++++++ .../scan/run-dynamic-sbom-inference.mts | 9 ++++++--- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index a4dd1edf2..89fb7faea 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -124,6 +124,7 @@ describe('socket fix --dynamic-sbom-inference', () => { const uploadManifestFiles = vi.fn() const generated = { paths: [FACTS], + sidecarFile: '/tmp/socket-fix-facts/sidecar.json', remove: vi.fn(), restore: vi.fn(), } @@ -152,7 +153,10 @@ describe('socket fix --dynamic-sbom-inference', () => { ) const args = coanaCalls('compute-fixes-and-upgrade-purls')[0]! expect(args).toContain('--maven-use-only-socket-facts') - expect(args).not.toContain('--compute-artifacts-sidecar') + expect(args).toContain('--compute-artifacts-sidecar') + expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe( + generated.sidecarFile, + ) expect(generated.remove).toHaveBeenCalledTimes(1) }) @@ -169,9 +173,9 @@ describe('socket fix --dynamic-sbom-inference', () => { await coanaFix({ ...baseConfig, all: true, ghsas: [] }) - expect(coanaCalls('find-vulnerabilities')[0]).toContain( - '--maven-use-only-socket-facts', - ) + const args = coanaCalls('find-vulnerabilities')[0] + expect(args).toContain('--maven-use-only-socket-facts') + expect(args).not.toContain('--compute-artifacts-sidecar') }) it('regenerates facts files that were already present, uploading each once', async () => { diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 973589e8f..2c75390a5 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -392,7 +392,15 @@ async function coanaFixWithFacts( spinner?.start() } } - const factsFlags = factsSlot ? ['--maven-use-only-socket-facts'] : [] + const sidecarFile = factsSlot?.generated?.sidecarFile + // Discovery only needs which artifacts the facts files resolve. Applying + // fixes reads each project's tree; a Coana predating facts trees can only + // tell from the sidecar's classpaths which projects resolve an artifact. + const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : [] + const factsFlags = [ + ...discoveryFlags, + ...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []), + ] const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, scanFilepaths, { pathsRelativeTo: cwd, @@ -460,7 +468,7 @@ async function coanaFixWithFacts( coanaVersion, cwd, ecosystems, - factsFlags, + factsFlags: discoveryFlags, packageManagers, silence, spinner, @@ -619,7 +627,7 @@ async function coanaFixWithFacts( coanaVersion, cwd, ecosystems, - factsFlags, + factsFlags: discoveryFlags, packageManagers, silence, spinner, diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index 2d4275605..e2e3f658e 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,10 +1,12 @@ -import { copyFile, mkdir, rm } from 'node:fs/promises' +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' export type GeneratedSocketFacts = { paths: string[] + // The facts files' per-project classpaths, outside the repository. + sidecarFile: string | undefined remove: () => Promise restore: () => Promise } @@ -51,14 +53,20 @@ export async function generateSocketFactsForFix({ await restoreAll(originals) } let paths: string[] + let sidecarFile: string | undefined try { - const { factsPaths } = await runDynamicSbomInference({ + const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ cwd, excludePaths, sbtTmpDir: undefined, + sidecar: true, withFiles: false, }) paths = factsPaths.map(p => path.resolve(cwd, p)) + if (resolvedPathsSidecar) { + sidecarFile = path.join(tmpDir, 'sidecar.json') + await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) + } } catch (e) { // A failed build root aborts inference after others wrote their facts. await reset(await findFactsPaths()) @@ -71,6 +79,7 @@ export async function generateSocketFactsForFix({ ] return { paths, + sidecarFile, remove: () => reset(paths), restore: () => restoreAll(backups), } diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 1e0548bcc..2d5a4a9cd 100644 --- a/src/commands/fix/generated-socket-facts.test.mts +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -62,6 +62,23 @@ describe('generateSocketFactsForFix', () => { expect(existsSync(fresh)).toBe(false) }) + it('writes the sidecar outside the repository', async () => { + mockRunDynamicSbomInference.mockImplementation(async () => { + await fs.writeFile(fresh, 'generated') + return { factsPaths: [fresh], resolvedPathsSidecar: { [fresh]: {} } } + }) + + const { sidecarFile } = await generate() + + expect(mockRunDynamicSbomInference).toHaveBeenCalledWith( + expect.objectContaining({ sidecar: true }), + ) + expect(path.dirname(sidecarFile!)).toBe(tmpDir) + expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({ + [fresh]: {}, + }) + }) + it('restores an existing facts file that generation does not rewrite', async () => { mockRunDynamicSbomInference.mockImplementation(async () => { await fs.writeFile(fresh, 'generated') diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts index 9336c5dc5..e51fb0b41 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -22,19 +22,22 @@ export async function runDynamicSbomInference({ cwd, excludePaths, sbtTmpDir, + sidecar, withFiles, }: { cwd: string excludePaths: string[] + // Collects the sidecar's per-project classpaths even without resolving + // artifact paths. + sidecar?: boolean | undefined // sbt provisions its Scala toolchain under this directory and withFiles' // artifactPaths point into it, so it must outlive whoever consumes them. // Only meaningful alongside `withFiles`. sbtTmpDir: string | undefined withFiles: boolean }): Promise { - const sidecarAcc: SidecarAccumulator | undefined = withFiles - ? new Map() - : undefined + const sidecarAcc: SidecarAccumulator | undefined = + (sidecar ?? withFiles) ? new Map() : undefined const outcomes = await generateRecursiveManifests({ cwd, excludePaths, From ba5611e3fa969ecc5dd8575873695fb6395da872 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 12 Oct 2026 14:43:25 +0200 Subject: [PATCH 12/12] fix(fix): leave leftover reachability reports out of the fix upload The bare .socket.facts.json is Coana's report, not producer output, so socket fix skips it as reachability scans already do. --- .../coana-fix-dynamic-sbom-inference.test.mts | 17 ++++++++-- src/commands/fix/coana-fix.mts | 31 ++++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 89fb7faea..4a6f29049 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -83,7 +83,7 @@ vi.mock('./branch-cleanup.mts', () => ({ cleanupSuccessfulPrLocalBranch: vi.fn(), })) -const FACTS = '/test/cwd/app/.socket.facts.json' +const FACTS = '/test/cwd/app/gradle.socket.facts.json' function coanaCalls(command: string): string[][] { return mockSpawnCoanaDlx.mock.calls @@ -204,6 +204,19 @@ describe('socket fix --dynamic-sbom-inference', () => { }) }) + it('does not upload a leftover reachability report', async () => { + mockGetPackageFilesForScan.mockResolvedValue([ + '/test/cwd/.socket.facts.json', + FACTS, + ]) + + await coanaFix({ ...baseConfig, dynamicSbomInference: false }) + + expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { + pathsRelativeTo: '/test/cwd', + }) + }) + it('does not pass the facts restriction without the flag', async () => { await coanaFix({ ...baseConfig, dynamicSbomInference: false }) @@ -284,7 +297,7 @@ describe('socket fix --dynamic-sbom-inference', () => { ok: true, data: [ 'project/SocketDependencyOverrides.scala', - 'app/.socket.facts.json', + 'app/gradle.socket.facts.json', ], }) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 2c75390a5..411cafbf5 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -22,9 +22,16 @@ import { import { generateSocketFactsForFix } from './generated-socket-facts.mts' import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts' import { getSocketFixPrs, openSocketFixPr } from './pull-request.mts' -import { FLAG_DRY_RUN, GQL_PR_STATE_OPEN } from '../../constants.mts' +import { + DOT_SOCKET_DOT_FACTS_JSON, + FLAG_DRY_RUN, + GQL_PR_STATE_OPEN, +} from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { isSocketFactsFile } from '../../utils/coana.mts' +import { + isReachabilityReportPath, + isSocketFactsFile, +} from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { getErrorCause } from '../../utils/errors.mts' @@ -365,12 +372,20 @@ async function coanaFixWithFacts( // sibling manifest's references). --exclude stays separate as a hidden // legacy escape hatch for the narrower "fix-application only" semantic. const coanaExcludePatterns = [...exclude, ...excludePaths] - const findScanFilepaths = () => - getPackageFilesForScan(['.'], supportedFiles, { - additionalIgnores, - config: socketConfig, - cwd, - }) + const findScanFilepaths = async () => + ( + await getPackageFilesForScan(['.'], supportedFiles, { + additionalIgnores, + config: socketConfig, + cwd, + }) + ).filter( + p => + !isReachabilityReportPath(p, { + cwd, + outputPath: DOT_SOCKET_DOT_FACTS_JSON, + }), + ) const scanFilepaths = await findScanFilepaths() if (factsSlot) { if (!silence) {