From 3cbc05cc7983cc767c86b55b8028ba98e1610564 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:57:06 +0200 Subject: [PATCH 1/2] feat(manifest): give each JVM build project its own id and facts component projects[].id is the build tool's unique project identifier (Maven GAV, Gradle project path, sbt project id) and projects[].manifestFiles lists the project's build files. A dependency on one of the build's own projects is that project's component, sharing its id, so projects sharing a coordinate or a directory stay apart. The compute-artifacts sidecar keys paths and classpaths by id. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../manifest/run-manifest-facts.test.mts | 5 +- src/commands/manifest/scripts/assemble.mts | 213 ++++++------------ .../manifest/scripts/assemble.test.mts | 109 +++++++-- src/commands/manifest/scripts/facts.mts | 40 +--- .../socket/SocketFactsRecordsEngine.java | 33 ++- src/commands/manifest/scripts/records.mts | 9 +- src/commands/manifest/scripts/sidecar.mts | 50 ++-- .../manifest/scripts/sidecar.test.mts | 103 +++++---- .../manifest/scripts/socket-facts.init.gradle | 45 ++-- .../scripts/socket-facts.plugin.scala | 32 ++- 11 files changed, 341 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4173cf31e..92818a320 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 - Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. +- Socket facts for Maven, Gradle and sbt builds keep projects that share a coordinate or a directory apart, giving each its own id and build files. ### Fixed - Socket facts for a Maven or Gradle build pointed elsewhere with `-f` or `-p` are now written into that build's own directory, where their paths resolve. diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 492e64d2a..2d7aa9c8d 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -27,10 +27,7 @@ function okResult(buildRoot: string): ManifestRunResult { }, report: { failures: [], scannedConfigs: [], unscannable: [] }, artifactPaths: { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), + pathsById: new Map(), classpathByProject: new Map(), }, stderr: '', diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index e95fd6b9c..c9e1dc47a 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -7,9 +7,8 @@ import { type SocketFactsSbomComponent, type SocketFactsSbomMetadata, type SocketFactsSbomProject, - mavenCoordinateKey, - projectClasspathKey, } from './facts.mts' + import constants from '../../../constants.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' @@ -35,16 +34,23 @@ type MergedNode = { children: Set prod: boolean direct: boolean + // projectKey when this node is a build project, else empty. + project: string targets: Set } type PerRoot = { projectKey: string prod: boolean - nodes: Map< - string, - { coord: RawCoord; children: string[]; direct: boolean; targets: string[] } - > + nodes: Map +} + +type RootNode = { + coord: RawCoord + children: string[] + direct: boolean + project: string + targets: string[] } export function assembleFacts( @@ -53,17 +59,12 @@ export function assembleFacts( ): AssembleResult { const fileExists = opts.fileExists ?? existsSync const perRoot = buildPerRoot(parsed) - const { directByRoot, finalNodes } = mergeByCoordinate(perRoot) + const { directByRoot, finalNodes } = mergeById(perRoot) const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] - const projectsByGav = new Map() - 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), + buildManifestFilesById(parsed, directByRoot, perRoot), ) const projects = opts.emitProjects === false @@ -87,58 +88,60 @@ export function assembleFacts( artifactPaths: buildArtifactPaths( finalNodes, [...parsed.projects.values()], - projectsByGav, perRoot, fileExists, ), } } -function gav(group: string, name: string, version: string): string { - return `${group}:${name}:${version}` +// A node resolving to a build project takes that project's id, so projects +// sharing a coordinate stay apart and the project's variants collapse into it. +function componentId(coordId: string, project: string): string { + return project || coordId } function buildPerRoot(parsed: ParsedRecords): Map { const out = new Map() for (const [rootId, r] of parsed.roots) { - const childrenByParent = new Map>() + const idOf = (coordId: string) => + componentId(coordId, r.nodes.get(coordId)?.project ?? '') + const nodes = new Map() + for (const [coordId, n] of r.nodes) { + const id = idOf(coordId) + let node = nodes.get(id) + if (!node) { + node = { + coord: n.project ? { ...n.coord, classifier: '', ext: '' } : n.coord, + children: [], + direct: false, + project: n.project, + targets: [], + } + nodes.set(id, node) + } + node.direct ||= n.direct + node.targets.push(...n.targets) + } for (const [p, c] of r.edges) { if (!r.nodes.has(p) || !r.nodes.has(c)) { continue } - let set = childrenByParent.get(p) - if (!set) { - set = new Set() - childrenByParent.set(p, set) + const parentId = idOf(p) + const childId = idOf(c) + const parent = nodes.get(parentId)! + if (childId !== parentId && !parent.children.includes(childId)) { + parent.children.push(childId) } - set.add(c) - } - const nodes = new Map< - string, - { - coord: RawCoord - children: string[] - direct: boolean - targets: string[] - } - >() - for (const [coordId, n] of r.nodes) { - nodes.set(coordId, { - coord: n.coord, - children: [...(childrenByParent.get(coordId) ?? [])], - direct: n.direct, - targets: n.targets, - }) } out.set(rootId, { projectKey: r.projectKey, prod: r.prod, nodes }) } return out } -// Components are merged by coordinate across every resolution root; which -// coordinates belong to which subproject is kept separately (classpathByProject) -// for reachability, which needs each subproject's exact classpath. -function mergeByCoordinate(perRoot: 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> } { @@ -153,6 +156,7 @@ function mergeByCoordinate(perRoot: Map): { children: new Set(), prod: false, direct: false, + project: node.project, targets: new Set(), } finalNodes.set(coordId, fn) @@ -182,7 +186,7 @@ function mergeByCoordinate(perRoot: Map): { return { finalNodes, directByRoot } } -function buildManifestFilesByCoord( +function buildManifestFilesById( parsed: ParsedRecords, directByRoot: Map>, perRoot: Map, @@ -214,8 +218,7 @@ function buildManifestFilesByCoord( function buildComponents( finalNodes: Map, - projectsByGav: Map, - manifestFilesByCoord: Map, + manifestFilesById: Map, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -243,13 +246,13 @@ function buildComponents( if (!fn.prod) { comp.dev = true } - if (projectsByGav.has(gav(c.group, c.name, c.version ?? ''))) { + if (fn.project) { comp.firstParty = true } if (fn.children.size) { comp.dependencies = [...fn.children].sort() } - const manifestFiles = manifestFilesByCoord.get(id) + const manifestFiles = manifestFilesById.get(id) if (manifestFiles) { comp.manifestFiles = manifestFiles } @@ -277,6 +280,7 @@ function buildProjects( const projects = [...parsed.projects.values()].map(p => { const entry: SocketFactsSbomProject = { + id: p.projectKey, type: PURL_TYPE_MAVEN, namespace: p.group, name: p.name, @@ -284,6 +288,9 @@ function buildProjects( subprojectDir: p.dir, dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), } + if (p.buildFiles.length) { + entry.manifestFiles = [...p.buildFiles].sort().map(file => ({ file })) + } return entry }) projects.sort((a, b) => { @@ -294,26 +301,6 @@ function buildProjects( return projects } -function unionInto( - map: Map, - key: string, - add: string[], -): void { - if (!add.length) { - return - } - const acc = map.get(key) - if (acc) { - for (const f of add) { - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - map.set(key, [...add]) - } -} - function buildClasspathByProject( projects: RawProject[], perRoot: Map, @@ -331,11 +318,7 @@ function buildClasspathByProject( } const classpathByProject = new Map>() for (const p of projects) { - const key = projectClasspathKey({ - name: p.name, - namespace: p.group, - subprojectDir: p.dir, - }) + const key = p.projectKey let set = classpathByProject.get(key) if (!set) { set = new Set() @@ -353,84 +336,28 @@ function buildClasspathByProject( function buildArtifactPaths( finalNodes: Map, projects: RawProject[], - projectsByGav: Map, perRoot: Map, fileExists: (path: string) => boolean, ): ResolvedArtifactPaths { - const targetsByCoord = new Map() - const targetsByGav = new Map() - const sourcesByCoord = new Map() - const coords = new Set() - for (const fn of finalNodes.values()) { - const c = fn.coord - const coordKey = mavenCoordinateKey( - c.group, - c.name, - c.ext, - c.classifier, - c.version, - ) - if (!coordKey) { - continue - } - coords.add(coordKey) - const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) - const sources = (pi?.sources ?? []).filter(fileExists).sort() - const targets = [...new Set(pi ? pi.targets : fn.targets)] - .filter(fileExists) - .sort() - if (sources.length) { - sourcesByCoord.set(coordKey, sources) - } - if (!targets.length) { - continue - } - targetsByCoord.set(coordKey, targets) - const gavKey = mavenCoordinateKey( - c.group, - c.name, - undefined, - undefined, - c.version, - ) - if (gavKey) { - const acc = targetsByGav.get(gavKey) - if (acc) { - for (const f of targets) { - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - targetsByGav.set(gavKey, [...targets]) - } + const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() + for (const [id, fn] of finalNodes) { + if (!fn.project) { + pathsById.set(id, { + sources: [], + targets: [...fn.targets].filter(fileExists).sort(), + }) } } - // A top-level module is a `project` but usually not a dependency node, so its - // source roots (where reachability starts) are missed by the node loop above; - // emit first-party module paths here. + // A project's own component shares its id, so this also covers dependency + // edges onto a sibling project. for (const p of projects) { - const coordKey = mavenCoordinateKey( - p.group, - p.name, - undefined, - undefined, - p.version, - ) - if (!coordKey) { - continue - } - coords.add(coordKey) - unionInto(sourcesByCoord, coordKey, p.sources.filter(fileExists)) - const targets = p.targets.filter(fileExists) - unionInto(targetsByCoord, coordKey, targets) - unionInto(targetsByGav, coordKey, targets) + pathsById.set(p.projectKey, { + sources: [...new Set(p.sources)].filter(fileExists).sort(), + targets: [...new Set(p.targets)].filter(fileExists).sort(), + }) } return { - targetsByCoord, - targetsByGav, - sourcesByCoord, - coords, + pathsById, classpathByProject: buildClasspathByProject(projects, perRoot), } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 284cba72e..f00630685 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -46,6 +46,7 @@ describe('records → assemble → sidecar', () => { // roots reach the sidecar, keyed by its own facts file. expect(bucket.projects).toEqual([ { + id: ':app', type: 'maven', namespace: 'com.example', name: 'app', @@ -116,39 +117,88 @@ describe('records → assemble → sidecar', () => { 'g:lib:jar:1', ]) }) - it('marks only components with the exact coordinate of a build module as firstParty', () => { + it("gives a dependency on a build project that project's id, and marks only it firstParty", () => { const records = [ - 'meta\tmaven\t3.9.6\t17', + 'meta\tgradle\t8.0\t17', 'project\t:a\tg\ta\t1.0-SNAPSHOT\ta', 'project\t:b\tg\tb\t1.0-SNAPSHOT\tb', 'root\tr1\t:a\truntimeClasspath\t1', - 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1\t', 'root\tr2\t:b\truntimeClasspath\t1', - 'node\tr2\tg:a:jar:1.0-SNAPSHOT\tg\ta\t1.0-SNAPSHOT\tjar\t\t1', - 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t0', + 'node\tr2\tg:a:jar:1.0-SNAPSHOT\tg\ta\t1.0-SNAPSHOT\tjar\t\t1\t:a', + 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t0\t', 'edge\tr2\tg:a:jar:1.0-SNAPSHOT\tg:ext:jar:2', - 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1', + // Same name as a build project, but a published artifact, not the project. + 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1\t', ].join('\n') const { artifactPaths, facts } = assembleFacts(parseRecords(records)) - expect(facts.components.map(c => [c.id, c.firstParty ?? 'absent'])).toEqual( - [ - ['g:a:jar:1.0-SNAPSHOT', true], - ['g:b:jar:0.9', 'absent'], - ['g:ext:jar:2', 'absent'], - ], - ) + expect( + facts.components.map(c => [ + c.id, + c.firstParty ?? 'absent', + c.dependencies, + ]), + ).toEqual([ + [':a', true, ['g:ext:jar:2']], + ['g:b:jar:0.9', 'absent', undefined], + ['g:ext:jar:2', 'absent', undefined], + ]) + expect(facts.projects!.find(p => p.id === ':b')?.dependencies).toEqual([ + ':a', + 'g:b:jar:0.9', + ]) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') const bucket = serializeSidecar(acc)['/abs/.socket.facts.json']! - expect( - bucket.components.find(c => c.id === 'g:a:jar:1.0-SNAPSHOT')?.firstParty, - ).toBe(true) + expect(bucket.components.find(c => c.id === ':a')?.firstParty).toBe(true) for (const project of bucket.projects) { expect(project).not.toHaveProperty('firstParty') } }) + + it("keeps projects that share a coordinate apart, collapsing each project's variants into it", () => { + const records = [ + 'meta\tgradle\t8.0\t17', + 'project\t:a:util\tex\tutil\t1\ta/util', + 'projectSrc\t:a:util\t/abs/a/util/src', + 'project\t:b:util\tex\tutil\t1\tb/util', + 'projectSrc\t:b:util\t/abs/b/util/src', + 'project\t:app\tex\tapp\t1\tapp', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:b:util', + 'node\tr1\tex:util:jar:test-fixtures:1\tex\tutil\t1\tjar\ttest-fixtures\t1\t:b:util', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t0\t', + 'edge\tr1\tex:util:jar:test-fixtures:1\tex:util:jar:1', + 'edge\tr1\tex:util:jar:1\tg:ext:jar:2', + 'root\tr2\t:b:util\truntimeClasspath\t1', + 'node\tr2\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:a:util', + ].join('\n') + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + fileExists: () => true, + }) + + expect( + facts.components.map(c => [c.id, c.qualifiers, c.dependencies]), + ).toEqual([ + [':a:util', undefined, undefined], + [':b:util', undefined, ['g:ext:jar:2']], + ['g:ext:jar:2', { ext: 'jar' }, undefined], + ]) + expect(artifactPaths.classpathByProject.get(':app')).toEqual([ + ':b:util', + 'g:ext:jar:2', + ]) + expect(artifactPaths.classpathByProject.get(':b:util')).toEqual([':a:util']) + expect(artifactPaths.pathsById.get(':a:util')?.sources).toEqual([ + '/abs/a/util/src', + ]) + expect(artifactPaths.pathsById.get(':b:util')?.sources).toEqual([ + '/abs/b/util/src', + ]) + }) + it('marks direct dependencies with the facts file and the build files of the subprojects they are direct in', () => { const records = [ 'meta\tmaven\t3.9.6\t17', @@ -186,6 +236,33 @@ describe('records → assemble → sidecar', () => { 'g:solo:jar:1': [{ file: '.socket.facts.json' }], }) }) + it("records each project's own build files, relative to the build root", () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'buildRoot\t/repo/sub', + 'project\tg:agg:1\tg\tagg\t1\t.', + 'projectBuild\tg:agg:1\tother-pom.xml', + 'project\tg:mod-a:1\tg\tmod-a\t1\tmod', + 'projectBuild\tg:mod-a:1\tmod/a.xml', + 'project\tg:mod-b:1\tg\tmod-b\t1\tmod', + 'projectBuild\tg:mod-b:1\tmod/b.xml', + 'project\tg:bare:1\tg\tbare\t1\tbare', + ].join('\n') + const parsed = parseRecords(records) + const { facts } = assembleFacts(parsed) + + expect(parsed.buildRoot).toBe('/repo/sub') + expect( + Object.fromEntries( + facts.projects!.map(p => [p.id, p.manifestFiles ?? 'absent']), + ), + ).toEqual({ + 'g:agg:1': [{ file: 'other-pom.xml' }], + 'g:bare:1': 'absent', + 'g:mod-a:1': [{ file: 'mod/a.xml' }], + 'g:mod-b:1': [{ file: 'mod/b.xml' }], + }) + }) }) describe('parseRecords', () => { diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index f3f41010e..011ee4c43 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -41,41 +41,21 @@ export type SocketFactsManifestReference = { } export type SocketFactsSbomProject = AnyPURL & { + // The build tool's own project identity, unique within the facts file: + // Maven's GAV, Gradle's project path, sbt's project id. + id: string subprojectDir: string dependencies: string[] + // The module's own build files, e.g. a POM other than `/pom.xml`. + manifestFiles?: SocketFactsManifestReference[] | undefined } -// Resolved on-disk paths for a --with-files run, keyed by coordinate. `targets` -// = classpath entries (jars / module output dirs); `sources` = module source -// roots. +// Resolved on-disk paths for a --with-files run, keyed by component or project +// id (a project and its own component share one). `targets` = classpath +// entries (jars / module output dirs); `sources` = module source roots. export type ResolvedArtifactPaths = { - targetsByCoord: Map - // ext/classifier-agnostic, to recover the variant when an ingested ext is - // untrustworthy (Gradle lockfile / version-catalog hardcode ext=jar). - targetsByGav: Map - sourcesByCoord: Map - coords: Set + pathsById: Map // Component ids on each project's resolved classpath (union over its - // configurations), keyed by projectClasspathKey. + // configurations), keyed by project id. classpathByProject: Map } - -export function projectClasspathKey( - project: Pick, -): string { - return `${project.subprojectDir} ${project.namespace ?? ''}:${project.name}` -} - -// Coordinate-based (not `id`-based) so it also matches foreign SBOMs like -// CycloneDX. Empty segments dropped. -export function mavenCoordinateKey( - groupId: string | undefined, - artifactId: string | undefined, - type: string | undefined, - classifier: string | undefined, - version: string | undefined, -): string { - return [groupId, artifactId, type, classifier, version] - .filter(Boolean) - .join(':') -} diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java index 8facd1502..f7768c26e 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java @@ -101,12 +101,13 @@ public void run(MavenSession session, List reactor, File rootDir, if (module.getBasedir() == null) continue; String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); if (SocketSupport.isExcludedPath(ws, excludes)) continue; - rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); + String key = projectKey(module); + rec(lines, "project", key, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); File pom = module.getFile(); - if (pom != null && pom.isFile()) rec(lines, "projectBuild", ws, SocketSupport.relativePath(rootDir.toPath(), pom.toPath())); + if (pom != null && pom.isFile()) rec(lines, "projectBuild", key, SocketSupport.relativePath(rootDir.toPath(), pom.toPath())); if (opts.withFiles) { - for (String s : collectSources(module)) rec(lines, "projectSrc", ws, s); - for (String t : collectTargets(module)) rec(lines, "projectTgt", ws, t); + for (String s : collectSources(module)) rec(lines, "projectSrc", key, s); + for (String t : collectTargets(module)) rec(lines, "projectTgt", key, t); } } @@ -124,7 +125,7 @@ public void run(MavenSession session, List reactor, File rootDir, // Which direct dependencies are prod-scoped: seeds the prod/dev root split (see emitModuleRoots). Set directProdIds = new HashSet<>(); collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, directProdIds, failures); - rootIdx = emitModuleRoots(lines, rootIdx, ws, nodes, directIds, directProdIds); + rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds, directProdIds); } for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config); @@ -132,6 +133,12 @@ public void run(MavenSession session, List reactor, File rootDir, write(opts.recordsFile, lines); } + // Not the directory, which `x/a.xml` and `x/b.xml` share; Maven + // rejects a reactor with a duplicate GAV. Surfaces as the facts project id. + private static String projectKey(MavenProject module) { + return module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); + } + // ---- resolution ---- private void collectModule( @@ -278,9 +285,9 @@ private String visit( if (!visited.add(id)) return id; Node node = internal - ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version) + ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version, gav) : upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), - type == null ? "" : type, classifier == null ? "" : classifier, version); + type == null ? "" : type, classifier == null ? "" : classifier, version, ""); // Maven wrote each accepted node's resolved file back onto the node; a reactor module reports its // own dirs through its `project` record instead of a `file` record. if (!internal && opts.withFiles) { @@ -309,10 +316,11 @@ private static boolean isProd(String scope) { } private static Node upsert( - Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version) { + Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version, + String project) { Node node = nodes.get(id); if (node == null) { - node = new Node(id, groupId, artifactId, type, classifier, version); + node = new Node(id, groupId, artifactId, type, classifier, version, project); nodes.put(id, node); } return node; @@ -365,7 +373,7 @@ private int emitRoot( rec(lines, "root", rootId, projectKey, config, prod ? "1" : "0"); for (Node n : nodeMap.values()) { rec(lines, "node", rootId, n.id, n.groupId, n.artifactId, n.version, n.type, n.classifier, - directIds.contains(n.id) ? "1" : "0"); + directIds.contains(n.id) ? "1" : "0", n.project); for (String child : n.children) { if (nodeMap.containsKey(child)) rec(lines, "edge", rootId, n.id, child); } @@ -521,16 +529,19 @@ private static final class Node { final String type; final String classifier; final String version; + // The reactor module's project key when this is a sibling module, else empty. + final String project; final TreeSet children = new TreeSet<>(); final TreeSet files = new TreeSet<>(); - Node(String id, String groupId, String artifactId, String type, String classifier, String version) { + Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) { this.id = id; this.groupId = groupId; this.artifactId = artifactId; this.type = type; this.classifier = classifier; this.version = version; + this.project = project; } } } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index a5207d907..f757d4bc6 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -16,13 +16,16 @@ import type { // projectTgt projectKey path (--with-files only) // projectBuild projectKey path (build-root-relative) // root rootId projectKey config prod(0|1) -// node rootId coordId group name version ext classifier direct(0|1) +// node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) // scanned config // failure coord detail config // unscannable config detail // +// `projectKey` is the tool's unique project identity (Maven: GAV; Gradle: +// project path; sbt: project id), emitted as the project id; a `node` whose +// `project` names one resolves to that build project. // A `root` is one (subproject, configuration) resolution root; `coordId` is the // coordinate key (`group:name:ext:classifier:version`, empty segments dropped), // used opaquely as the per-root node key. Unknown tags are ignored. @@ -39,6 +42,9 @@ export type RawNode = { coordId: string coord: RawCoord direct: boolean + // projectKey of the build project this node resolves to; empty for an + // external artifact. + project: string // --with-files only. targets: string[] } @@ -202,6 +208,7 @@ export function parseRecords(text: string): ParsedRecords { classifier: f[7] ?? '', }, direct: bool(f[8]), + project: f[9] ?? '', targets: [], }) break diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index f0fd5fddf..9f2bbf919 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,5 +1,3 @@ -import { mavenCoordinateKey, projectClasspathKey } from './facts.mts' - import type { AnyPURL, ResolvedArtifactPaths, @@ -9,10 +7,9 @@ import type { } from './facts.mts' export type SidecarComponentEntry = SocketFactsSbomComponent & { - // Classpath entries (jars, or a sibling first-party project's own build - // output dirs when this dependency edge resolves to one). `[]` - // means resolution was attempted and found nothing (e.g. a pom/BOM); - // undefined means resolution couldn't be attempted at all (see attachPaths). + // Classpath entries (jars, or a sibling project's own build output dirs + // when this component is that project). `[]` means resolved and found + // nothing (e.g. a pom/BOM); undefined means paths were not resolved. targets?: string[] | undefined // First-party source roots; `[]` for a genuinely external dependency (still // attempted, nothing to find), not undefined. @@ -29,7 +26,7 @@ export type SidecarProjectEntry = SocketFactsSbomProject & { // Frozen contract with `coana run --compute-artifacts-sidecar`; change only // in sync with the coana consumer. Keyed by the absolute path of the -// `.socket.facts.json` file whose own projects[]/components[] these entries +// `*.socket.facts.json` file whose own projects[]/components[] these entries // describe - the key IS the scope, so two independent reactors that happen to // emit the same purl identity (e.g. a shared internal module name) can never // collide: each is only ever looked up within its own key. No cross-reactor @@ -54,31 +51,16 @@ export type SidecarAccumulator = Map< { projects: SidecarProjectEntry[]; components: SidecarComponentEntry[] } > -// `targets`/`sources` present (possibly `[]`) means resolution was attempted -// for this coordinate - an empty array is a successful resolve that found -// nothing (e.g. a pom/BOM with no artifact), not a failure. Both fields -// omitted (undefined) means resolution couldn't even be attempted - the only -// case here is a degenerate entry with no computable coordinate at all, since -// every entry reaching this function already came from a resolved graph node -// (an unresolved dependency lives in the resolution report, not here). -function attachPaths( +// `[]` means resolved and found nothing (e.g. a pom/BOM with no artifact). +function attachPaths( entry: T, artifactPaths: ResolvedArtifactPaths, -): T & { targets?: string[] | undefined; sources?: string[] | undefined } { - const coordKey = mavenCoordinateKey( - entry.namespace, - entry.name, - entry.qualifiers?.['ext'], - entry.qualifiers?.['classifier'], - entry.version, - ) - if (!coordKey) { - return { ...entry } - } +): T & { targets: string[]; sources: string[] } { + const paths = artifactPaths.pathsById.get(entry.id) return { ...entry, - targets: [...(artifactPaths.targetsByCoord.get(coordKey) ?? [])].sort(), - sources: [...(artifactPaths.sourcesByCoord.get(coordKey) ?? [])].sort(), + targets: [...(paths?.targets ?? [])], + sources: [...(paths?.sources ?? [])], } } @@ -97,9 +79,8 @@ function sortByPurl(entries: T[]): T[] { // Emit an entry for every SBOM component AND every first-party project: a // top-level module is a project, not a dependency component, yet its source // roots are where reachability starts, so the sidecar must carry them. -// A second call for the same factsFile (a dual-marker directory where two -// build tools both target it) overwrites rather than merges, matching the -// existing last-writer-wins convention for that case. +// Every build writes its own facts file, so a key is accumulated once; a +// repeated call for the same factsFile (the same build run again) overwrites. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, @@ -108,16 +89,13 @@ export function accumulateSidecar( // Off when artifact paths were not resolved; entries then omit `targets` and `sources`. withPaths = true, ): void { - const paths = (entry: T) => + const paths = (entry: T) => 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)) ?? - []), - ], + classpath: [...(artifactPaths.classpathByProject.get(proj.id) ?? [])], })), }) } diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index e818972e7..dd34c8c91 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -13,10 +13,7 @@ import type { SidecarAccumulator } from './sidecar.mts' function emptyArtifactPaths(): ResolvedArtifactPaths { return { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), + pathsById: new Map(), classpathByProject: new Map(), } } @@ -26,7 +23,7 @@ function mkComponentFixture(target: string): { paths: ResolvedArtifactPaths } { const paths = emptyArtifactPaths() - paths.targetsByCoord.set('g:a:jar:1', [target]) + paths.pathsById.set('g:a:jar:1', { sources: [], targets: [target] }) return { facts: { components: [ @@ -49,6 +46,7 @@ describe('compute-artifacts sidecar', () => { const facts: SocketFactsSbom = { projects: [ { + id: ':app', type: 'maven', namespace: 'g', name: 'app', @@ -68,7 +66,7 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.classpathByProject.set('app g:app', ['g:a:jar:1']) + artifactPaths.classpathByProject.set(':app', ['g:a:jar:1']) const acc: SidecarAccumulator = new Map() accumulateSidecar( @@ -101,12 +99,10 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.targetsByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib.jar', - ]) - artifactPaths.sourcesByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib/src/main/java', - ]) + artifactPaths.pathsById.set('com.example:lib:jar:da517db', { + sources: ['/abs/lib/src/main/java'], + targets: ['/abs/lib.jar'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') @@ -158,23 +154,44 @@ describe('compute-artifacts sidecar', () => { expect(entry.sources).toEqual([]) }) - it('leaves targets/sources undefined (not []) when the entry has no computable coordinate at all', () => { + it("gives each sibling project's component that project's own paths, even when they share a coordinate", () => { + const util = { + type: 'maven', + namespace: 'ex', + name: 'util', + version: '1', + dependencies: [], + } const facts: SocketFactsSbom = { components: [ - { type: 'maven', namespace: '', name: '', id: 'degenerate' }, + { ...util, id: ':a:util', firstParty: true }, + { ...util, id: ':b:util', firstParty: true }, + ], + projects: [ + { ...util, id: ':a:util', subprojectDir: 'a/util' }, + { ...util, id: ':b:util', subprojectDir: 'b/util' }, ], } + const artifactPaths = emptyArtifactPaths() + artifactPaths.pathsById.set(':a:util', { + sources: ['/abs/a/util/src'], + targets: ['/abs/a/util/classes'], + }) + artifactPaths.pathsById.set(':b:util', { + sources: ['/abs/b/util/src'], + targets: ['/abs/b/util/classes'], + }) + const acc: SidecarAccumulator = new Map() - accumulateSidecar( - acc, - facts, - emptyArtifactPaths(), - '/root/.socket.facts.json', - ) - const entry = - serializeSidecar(acc)['/root/.socket.facts.json']!.components[0]! - expect(entry.targets).toBeUndefined() - expect(entry.sources).toBeUndefined() + accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') + const entry = serializeSidecar(acc)['/root/.socket.facts.json']! + + for (const entries of [entry.components, entry.projects]) { + expect(Object.fromEntries(entries.map(e => [e.id, e.sources]))).toEqual({ + ':a:util': ['/abs/a/util/src'], + ':b:util': ['/abs/b/util/src'], + }) + } }) it('preserves the original component fields (id, qualifiers) untouched', () => { @@ -214,6 +231,7 @@ describe('compute-artifacts sidecar', () => { components: [], projects: [ { + id: 'com.example:app:1.0', type: 'maven', namespace: 'com.example', name: 'app', @@ -224,12 +242,10 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.sourcesByCoord.set('com.example:app:1.0', [ - '/abs/app/src/main/java', - ]) - artifactPaths.targetsByCoord.set('com.example:app:1.0', [ - '/abs/app/build/classes', - ]) + artifactPaths.pathsById.set('com.example:app:1.0', { + sources: ['/abs/app/src/main/java'], + targets: ['/abs/app/build/classes'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/app/.socket.facts.json') @@ -238,6 +254,7 @@ describe('compute-artifacts sidecar', () => { expect(resolved['/root/app/.socket.facts.json']!.components).toEqual([]) expect(resolved['/root/app/.socket.facts.json']!.projects).toEqual([ { + id: 'com.example:app:1.0', type: 'maven', namespace: 'com.example', name: 'app', @@ -251,23 +268,24 @@ describe('compute-artifacts sidecar', () => { ]) }) - it('attaches each project its own classpath ids, keyed by subprojectDir and name', () => { + it('attaches each project its own classpath ids, keyed by project id even within one directory', () => { const project = { type: 'maven', namespace: 'com.example', version: '1.0', dependencies: [], + subprojectDir: 'x', } const facts: SocketFactsSbom = { components: [], projects: [ - { ...project, name: 'a', subprojectDir: 'a' }, - { ...project, name: 'b', subprojectDir: 'b' }, + { ...project, id: 'x/a.xml', name: 'a' }, + { ...project, id: 'x/b.xml', name: 'b' }, ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.classpathByProject.set('a com.example:a', ['g:x:jar:1']) - artifactPaths.classpathByProject.set('b com.example:b', ['g:x:jar:2']) + artifactPaths.classpathByProject.set('x/a.xml', ['g:x:jar:1']) + artifactPaths.classpathByProject.set('x/b.xml', ['g:x:jar:2']) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') @@ -300,6 +318,7 @@ describe('compute-artifacts sidecar', () => { components: [], projects: [ { + id: 'com.example:shared:1.0', type: 'maven', namespace: 'com.example', name: 'shared', @@ -310,13 +329,15 @@ describe('compute-artifacts sidecar', () => { ], } const pathsA = emptyArtifactPaths() - pathsA.sourcesByCoord.set('com.example:shared:1.0', [ - '/root-a/src/main/java', - ]) + pathsA.pathsById.set('com.example:shared:1.0', { + sources: ['/root-a/src/main/java'], + targets: [], + }) const pathsB = emptyArtifactPaths() - pathsB.sourcesByCoord.set('com.example:shared:1.0', [ - '/root-b/src/main/java', - ]) + pathsB.pathsById.set('com.example:shared:1.0', { + sources: ['/root-b/src/main/java'], + targets: [], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar( diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 299f1fd56..1d2e86ed0 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -58,9 +58,11 @@ gradle.ext.socketFactsState = [ paths : Collections.synchronizedMap([:]), perSub : Collections.synchronizedMap([:]), projectsInfo : Collections.synchronizedList([]), - // "group:name" -> the module's real artifact extension (e.g. jar), so an intra-project dep that + // Project path -> the module's real artifact extension (e.g. jar), so an intra-project dep that // resolves without selecting a published artifact still gets its true coordinate, not ext-less. projectArtifactExt : Collections.synchronizedMap([:]), + // Project path -> "group:name", to tell this build's projects from an included build's. + projectGaByPath : Collections.synchronizedMap([:]), ] // Capture every project's (group:name) before collectors run so they can filter intra-project @@ -86,7 +88,8 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} - g.socketFactsState.projectArtifactExt["${p.group ?: ''}:${p.name}".toString()] = artExt + g.socketFactsState.projectArtifactExt[p.path] = artExt + g.socketFactsState.projectGaByPath[p.path] = "${p.group ?: ''}:${p.name}".toString() // A wholly excluded subproject emits no project record and its configs are never resolved (see the // collector). projectKeys/projectArtifactExt above stay populated so a KEPT project depending on it // still recognizes it as a first-party module. @@ -154,6 +157,8 @@ allprojects { project -> // visit/upsertNode closures capture them); `failures`/`scannedConfigs` stay shared. def nodes = [:] def directIds = [] as Set + // "group:name:version" -> path of the build project this config resolved it to. + def projectByGav = [:] def failures = state.failures def scannedConfigs = state.scannedConfigs def projectKeys = state.projectKeys @@ -205,21 +210,21 @@ allprojects { project -> // A first-party module dep can resolve with no published artifact (classes-dir variant, or // variant-ambiguous moduleArtifacts), leaving it ext-less. Stamp the module's real artifact // extension so it keeps a full, stable coordinate instead of an ext-less one. - def effExt = { String group, String name, String ext -> - if ((ext == null || ext.isEmpty()) && isIntraProject(group, name)) { - projectArtifactExt["${group ?: ''}:${name}".toString()] ?: '' + def effExt = { String projPath, String ext -> + if ((ext == null || ext.isEmpty()) && projPath != null) { + projectArtifactExt[projPath] ?: '' } else { ext ?: '' } } // A node is created once; its prod flag accumulates (OR) across the configs that reach it. - def upsertNode = { Map coord, boolean isProd -> + def upsertNode = { Map coord, boolean isProd, String projPath -> def id = coordId(coord) synchronized (nodes) { def node = nodes[id] if (node == null) { - node = [coord: coord, children: [] as Set, prod: false] + node = [coord: coord, children: [] as Set, prod: false, project: projPath] nodes[id] = node } if (isProd) { @@ -253,18 +258,19 @@ allprojects { project -> } catch (Exception e) { artifacts = [] as Set } + def projPath = projectByGav["${dep.moduleGroup ?: ''}:${dep.moduleName}:${dep.moduleVersion ?: ''}".toString()] if (artifacts.isEmpty()) { - def ext = effExt(dep.moduleGroup, dep.moduleName, '') + def ext = effExt(projPath, '') // Skip a no-artifact first-party module (aggregator / build root): it builds no archive, so // it's fully described by `projects` and is never a real artifact dependency. - if (!(ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName))) { + if (!(ext.isEmpty() && projPath != null)) { producedIds << upsertNode([ groupId : dep.moduleGroup ?: '', artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: '', ext : ext, - ], isProd) + ], isProd, projPath) } } else { // Build the GAV scope key only when a scope is set: the no-scope path never reads it and @@ -281,16 +287,16 @@ allprojects { project -> artifacts.each { a -> // Directory variants (java-classes-directory etc.) carry no extension; for a first-party // module fall back to its real artifact ext (never artifact.type, a Gradle variant attr). - def ext = effExt(dep.moduleGroup, dep.moduleName, a.extension) + def ext = effExt(projPath, a.extension) // Skip a no-artifact first-party module (see the empty-artifacts branch above). - if (ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName)) return + if (ext.isEmpty() && projPath != null) return def aid = upsertNode([ groupId : dep.moduleGroup ?: '', artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: a.classifier ?: '', ext : ext, - ], isProd) + ], isProd, projPath) producedIds << aid // `a.file` downloads the artifact if not already cached, so scoping avoids fetching // artifacts the SBOM doesn't reference. Per-artifact try/catch: a single download can @@ -421,10 +427,21 @@ allprojects { project -> nodes = [:] directIds = [] as Set paths = [:] + projectByGav = [:] // Per-config try/catch: AGP-style configs can fail variant ambiguity from an init-script // context lacking the consumer attributes AGP sets internally. try { def lenient = cfg.resolvedConfiguration.lenientConfiguration + // The resolution graph names a project dependency by project path; within one graph a + // module resolves to a single component, so its GAV identifies that project here. + cfg.incoming.resolutionResult.allComponents.each { comp -> + def cid = comp.id + def mv = comp.moduleVersion + if (cid instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier && mv != null && + state.projectGaByPath[cid.projectPath] == "${mv.group ?: ''}:${mv.name}".toString()) { + projectByGav["${mv.group ?: ''}:${mv.name}:${mv.version ?: ''}".toString()] = cid.projectPath + } + } def cache = [:] // No-arg getter only since Gradle 3.3; older Gradle has just the Spec-taking overload. def firstLevel @@ -519,7 +536,7 @@ rootProject { rp -> tree.nodes.each { coordId, node -> def c = node.coord rec(['node', rootId, coordId, c.groupId ?: '', c.artifactId ?: '', c.version ?: '', c.ext ?: '', - c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0']) + c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0', node.project ?: '']) node.children.each { childId -> rec(['edge', rootId, coordId, childId]) } def fs = tree.paths[coordId] if (fs) { (fs as List).sort().each { p -> rec(['file', rootId, coordId, p]) } } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 993aa8ddd..09668c217 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -62,11 +62,18 @@ object SocketFactsPlugin extends AutoPlugin { // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate. val moduleExts = buildModuleExts(allRefs, extracted) + val projectIdsByGav: Map[String, Seq[String]] = + allRefs.groupBy(r => gavKey(rootIdOf(extracted, r))).map { case (k, rs) => k -> rs.map(_.project) } allRefs.foreach { ref => if (!isExcludedRef(ref)) { + // The update report names an inter-project dependency only by module ID; the projects this + // one reaches through `dependsOn` say which build project that ID is. + val reachable = dependsOnClosure(ref, extracted) + val projectsOf = (gav: String) => projectIdsByGav.getOrElse(gav, Nil).filter(reachable) runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => - foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts, + projectsOf, failures).foreach { case (rootKey, tree) => perSub(rootKey) = tree } } @@ -129,7 +136,8 @@ object SocketFactsPlugin extends AutoPlugin { tree.nodes.foreach { case (coordId, node) => val c = node.coord - rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0") + rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0", + node.project) node.children.foreach(ch => rec("edge", rootId, coordId, ch)) node.targets.foreach(p => rec("file", rootId, coordId, p)) } @@ -151,6 +159,16 @@ object SocketFactsPlugin extends AutoPlugin { // ---- resolution --------------------------------------------------------- + private def dependsOnClosure(ref: ProjectRef, extracted: Extracted): Set[String] = { + val seen = mutable.LinkedHashSet.empty[ProjectRef] + def walk(r: ProjectRef): Unit = + extracted.getOpt(thisProject.in(r)).toList.flatMap(_.dependencies).map(_.project).foreach { d => + if (seen.add(d)) walk(d) + } + walk(ref) + seen.map(_.project).toSet + } + private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = { val sv = extracted.get(scalaVersion.in(ref)) val sbv = extracted.get(scalaBinaryVersion.in(ref)) @@ -276,7 +294,9 @@ object SocketFactsPlugin extends AutoPlugin { scannedConfigs: mutable.LinkedHashSet[String], withFiles: Boolean, populateScope: Option[Set[String]], - moduleExts: Map[String, String] + moduleExts: Map[String, String], + projectsOf: String => Seq[String], + failures: mutable.LinkedHashSet[Failure] ): mutable.LinkedHashMap[String, RootTree] = { val perRoot = mutable.LinkedHashMap.empty[String, RootTree] val rootGav = gavKey(rootIdOf(extracted, ref)) @@ -300,8 +320,12 @@ object SocketFactsPlugin extends AutoPlugin { cr.modules.foreach { m => if (emittable(m)) { val ids = midToIds.getOrElseUpdate(gavKey(m.module), mutable.LinkedHashSet.empty[String]) + val projects = projectsOf(gavKey(m.module)) + if (projects.size > 1) + failures += Failure(coordOf(m.module), "ambiguous inter-project dependency: " + projects.mkString(", "), cfg) variantsOf(m, moduleExts).foreach { case (coord, fileOpt) => val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) + if (projects.size == 1) node.project = projects.head ids += coord.id if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath) } @@ -518,6 +542,8 @@ object SocketFactsPlugin extends AutoPlugin { private final class Node(val coord: Coord) { val children = mutable.TreeSet.empty[String] var direct = false + // Id of the build project this node is, when it is one. + var project = "" // External artifact's resolved jar(s); --with-files only. val targets = mutable.TreeSet.empty[String] } From 67767fe6bfc6c784858ef8aa781c333cd4808723 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 14:31:01 +0200 Subject: [PATCH 2/2] docs(changelog): describe the project-merging problem the facts ids fix Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3629bb51d..fa024f8e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,8 @@ 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 keep projects that share a coordinate or a directory apart, giving each its own id and build files. - ### Fixed +- Socket facts no longer merge build projects into one: Maven modules sharing a directory and Gradle or sbt projects sharing a coordinate each keep their own dependencies and build files. - Socket facts for a Maven or Gradle build pointed elsewhere with `-f` or `-p` are now written into that build's own directory, where their paths resolve. ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08