diff --git a/CHANGELOG.md b/CHANGELOG.md index 430931be4..c80a8f627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ 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. +- `socket fix` now accepts existing Socket facts files instead of asking you to delete them first. - Updated the Coana CLI to v `15.12.5`. +### 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.7.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.7.0) - 2026-10-09 ### Changed 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..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 @@ -153,6 +153,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).toContain('--compute-artifacts-sidecar') expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe( generated.sidecarFile, ) @@ -172,20 +173,48 @@ 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('still refuses 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(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { + pathsRelativeTo: '/test/cwd', + }) + }) + + 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 upload a leftover reachability report', async () => { mockGetPackageFilesForScan.mockResolvedValue([ - '/test/cwd/app/.socket.facts.json', + '/test/cwd/.socket.facts.json', + FACTS, ]) - const result = await coanaFix(baseConfig) + await coanaFix({ ...baseConfig, dynamicSbomInference: false }) - expect(result.ok).toBe(false) - expect(mockGenerateSocketFactsForFix).not.toHaveBeenCalled() + expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { + pathsRelativeTo: '/test/cwd', + }) }) it('does not pass the facts restriction without the flag', async () => { @@ -268,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 e27284cc2..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,27 +372,21 @@ 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() - // Facts files are analysis artifacts and must be removed before re-running fix. - const factsFiles = scanFilepaths.filter(isSocketFactsFile) - if (factsFiles.length) { - if (!silence) { - spinner?.stop() - } - return { - ok: false, - message: 'Found Socket facts files 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() @@ -393,26 +394,23 @@ 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, + 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() } } 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. + // 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, diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index 93cff3035..e2e3f658e 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,4 +1,4 @@ -import { copyFile, rm, writeFile } 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' @@ -11,47 +11,76 @@ 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, + findFactsPaths, tmpDir, }: { cwd: string excludePaths: string[] + findFactsPaths: () => Promise tmpDir: string }): Promise { - const { factsPaths, resolvedPathsSidecar } = 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 existingPaths = await findFactsPaths() + 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) } - 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 } - }), - ) + let paths: string[] + let sidecarFile: string | undefined + try { + 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()) + throw e + } + const generated = new Set(paths) + const backups = [ + ...originals.filter(({ source }) => !generated.has(source)), + ...(await backUp(paths, path.join(tmpDir, 'generated'))), + ] return { paths, sidecarFile, - 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..2d5a4a9cd --- /dev/null +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -0,0 +1,107 @@ +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: [], + 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('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') + 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) + }) +}) 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 fa90eb4cc..cae73d216 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 ee07ec9d8..e3aadb200 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -30,6 +30,7 @@ function okResult(buildRoot: string): ManifestRunResult { artifactPaths: { pathsById: new Map(), classpathByProject: new Map(), + directDependenciesByProject: new Map(), }, stderr: '', stdout: '', @@ -122,7 +123,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 eb40be43b..60daa21df 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -2,9 +2,9 @@ import { existsSync } from 'node:fs' import { type ResolvedArtifactPaths, - type SocketFactsManifestReference, type SocketFactsSbom, type SocketFactsSbomComponent, + type SocketFactsSbomDependency, type SocketFactsSbomMetadata, type SocketFactsSbomProject, } from './facts.mts' @@ -21,10 +21,6 @@ export type AssembleResult = { } export type AssembleOptions = { - emitProjects?: boolean | undefined - // Basename the facts file is written under; direct dependencies reference - // it. Undefined only when it cannot be named, and so will not be written. - factsFileName: string | undefined // Injectable for tests; an uncompiled module's output dir is dropped (module // stays resolvable via its sources). fileExists?: ((path: string) => boolean) | undefined @@ -32,7 +28,6 @@ export type AssembleOptions = { type MergedNode = { coord: RawCoord - children: Set prod: boolean direct: boolean // projectKey when this node is a build project, else empty. @@ -40,6 +35,11 @@ type MergedNode = { targets: Set } +type SubprojectGraph = { + children: Map> + direct: Set +} + type PerRoot = { projectKey: string prod: boolean @@ -56,21 +56,20 @@ type RootNode = { export function assembleFacts( parsed: ParsedRecords, - opts: AssembleOptions, + opts: AssembleOptions = {}, ): 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, - buildManifestFilesById(parsed, directByRoot, perRoot, opts.factsFileName), + const components = buildComponents(finalNodes) + const { dependencies, projects } = buildDependencyGraph( + parsed, + graphs, + components, ) - const projects = - opts.emitProjects === false - ? [] - : buildProjects(parsed, directByRoot, perRoot) const metadata: SocketFactsSbomMetadata = { format: 'socket-facts-sbom', @@ -80,7 +79,7 @@ export function assembleFacts( } const facts: SocketFactsSbom = projects.length - ? { metadata, projects, components } + ? { metadata, projects, components, dependencies } : { metadata, components } return { @@ -89,7 +88,7 @@ export function assembleFacts( artifactPaths: buildArtifactPaths( finalNodes, [...parsed.projects.values()], - perRoot, + graphs, fileExists, ), } @@ -142,19 +141,14 @@ 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) { fn = { coord: node.coord, - children: new Set(), prod: false, direct: false, project: node.project, @@ -168,69 +162,16 @@ function mergeById(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) } - if (node.direct) { - let d = directByRoot.get(rootId) - if (!d) { - d = new Set() - directByRoot.set(rootId, d) - } - d.add(coordId) - } } } - return { finalNodes, directByRoot } -} - -function buildManifestFilesById( - parsed: ParsedRecords, - directByRoot: Map>, - perRoot: Map, - factsFileName: string | undefined, -): Map { - const buildFilesByCoord = new Map>() - for (const [rootId, ids] of directByRoot) { - const root = perRoot.get(rootId) - const project = parsed.projects.get(root?.projectKey ?? '') - for (const id of ids) { - let set = buildFilesByCoord.get(id) - if (!set) { - set = new Set() - buildFilesByCoord.set(id, set) - } - const coord = root?.nodes.get(id)?.coord - // Without a known declaring script, the project's build file, even one - // absent on disk: its presence marks the dependency direct here. - const declared = - coord && project?.declaredIn.get(`${coord.group}:${coord.name}`) - const files = declared ?? [ - ...(project?.buildFiles ?? []), - ...(project?.missingBuildFiles ?? []), - ] - for (const f of files) { - set.add(f) - } - } - } - return new Map( - [...buildFilesByCoord].map(({ 0: id, 1: buildFiles }) => [ - id, - [ - ...(factsFileName ? [factsFileName] : []), - ...[...buildFiles].sort(), - ].map(file => ({ file })), - ]), - ) + return finalNodes } function buildComponents( finalNodes: Map, - manifestFilesById: Map, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -261,102 +202,207 @@ function buildComponents( if (fn.project) { comp.firstParty = true } - if (fn.children.size) { - comp.dependencies = [...fn.children].sort() - } - const manifestFiles = manifestFilesById.get(id) - if (manifestFiles) { - comp.manifestFiles = manifestFiles - } return comp }) } -function buildProjects( - parsed: ParsedRecords, - directByRoot: Map>, +// One graph per subproject, its configurations merged. +function buildSubprojectGraphs( 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) +): Map { + 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) + } } } + return graphs +} - const projects = [...parsed.projects.values()].map(p => { - const entry: SocketFactsSbomProject = { - id: p.projectKey, - type: PURL_TYPE_MAVEN, - namespace: p.group, - name: p.name, - ...(p.version ? { version: p.version } : {}), - subprojectDir: p.dir, - dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), +// 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[] = [] + 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([]) } - // 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 = [...files].sort().map(file => ({ file })) + 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)) + } } - 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 -} + } -function buildClasspathByProject( - 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) + // 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(p.projectKey, 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 } - for (const coordId of nodes.keys()) { - set.add(coordId) + 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 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) + 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 } - for (const id of idsByProjectKey.get(p.projectKey) ?? []) { - set.add(id) + } + + return { + dependencies, + projects: projects.map(({ p, roots }) => { + const entry: SocketFactsSbomProject = { + id: p.projectKey, + type: PURL_TYPE_MAVEN, + namespace: p.group, + name: p.name, + ...(p.version ? { version: p.version } : {}), + subprojectDir: p.dir, + children: roots + .map(coordId => indexOf(vertex(p.projectKey, coordId))) + .sort((x, y) => x - y), + } + if (p.buildFiles.length) { + entry.manifestFiles = [...new Set(p.buildFiles)] + .sort() + .map(file => ({ file })) + } + return entry + }), + } +} + +function treeRoots( + projectKey: string, + graph: SubprojectGraph | undefined, + byComponent: (a: string, b: string) => number, +): string[] { + if (!graph) { + return [] + } + const roots = [...graph.direct].sort(byComponent) + const reached = new Set() + const stack = [...roots] + while (stack.length) { + const id = stack.pop()! + if (!reached.has(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 +} + +function byProject( + projects: RawProject[], + graphs: Map, + ids: (graph: SubprojectGraph) => Iterable, +): Map { return new Map( - [...classpathByProject].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[], - perRoot: Map, + graphs: Map, fileExists: (path: string) => boolean, ): ResolvedArtifactPaths { const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() @@ -378,7 +424,8 @@ function buildArtifactPaths( } return { pathsById, - classpathByProject: buildClasspathByProject(projects, perRoot), + classpathByProject: byProject(projects, graphs, g => g.children.keys()), + directDependenciesByProject: byProject(projects, graphs, g => g.direct), } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 23b821762..2167321a9 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -4,8 +4,26 @@ 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.id, 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,11 +41,37 @@ 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. const { artifactPaths, facts } = assembleFacts(parseRecords(RECORDS), { - factsFileName: 'gradle.socket.facts.json', fileExists: () => true, }) @@ -71,7 +115,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 = [ @@ -90,7 +134,6 @@ describe('records → assemble → sidecar', () => { 'node\tr3\tg:junit:jar:4\tg\tjunit\t4\tjar\t\t1', ].join('\n') const { artifactPaths, facts } = assembleFacts(parseRecords(records), { - factsFileName: 'gradle.socket.facts.json', fileExists: () => true, }) @@ -100,24 +143,136 @@ 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('rejects 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') + + 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("gives a dependency on a build project that project's id, and marks only it firstParty", () => { const records = [ @@ -133,25 +288,19 @@ describe('records → assemble → sidecar', () => { // 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), { - factsFileName: 'gradle.socket.facts.json', - }) + const { artifactPaths, facts } = assembleFacts(parseRecords(records)) - 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', - ]) + expect(facts.components.map(c => [c.id, c.firstParty ?? 'absent'])).toEqual( + [ + [':a', true], + ['g:b:jar:0.9', 'absent'], + ['g:ext:jar:2', 'absent'], + ], + ) + expect(trees(facts)).toEqual({ + ':a': ['g:ext:jar:2'], + ':b': [':a(g:ext:jar:2)', 'g:b:jar:0.9'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar( @@ -166,7 +315,6 @@ describe('records → assemble → sidecar', () => { 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', @@ -185,17 +333,19 @@ describe('records → assemble → sidecar', () => { 'node\tr2\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:a:util', ].join('\n') const { artifactPaths, facts } = assembleFacts(parseRecords(records), { - factsFileName: 'gradle.socket.facts.json', 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(facts.components.map(c => [c.id, c.qualifiers])).toEqual([ + [':a:util', undefined], + [':b:util', undefined], + ['g:ext:jar:2', { ext: 'jar' }], ]) + expect(trees(facts)).toEqual({ + ':a:util': [], + ':app': [':b:util(g:ext:jar:2)'], + ':b:util': [':a:util'], + }) expect(artifactPaths.classpathByProject.get(':app')).toEqual([ ':b:util', 'g:ext:jar:2', @@ -209,7 +359,7 @@ describe('records → assemble → sidecar', () => { ]) }) - 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', @@ -229,27 +379,20 @@ describe('records → assemble → sidecar', () => { 'root\tr3\tc\truntimeClasspath\t1', 'node\tr3\tg:solo:jar:1\tg\tsolo\t1\tjar\t\t1', ].join('\n') - const { facts } = assembleFacts(parseRecords(records), { - factsFileName: 'pom.xml.socket.facts.json', - }) + const { facts } = assembleFacts(parseRecords(records)) 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: 'pom.xml.socket.facts.json' }, - { file: 'b/pom.xml' }, - ], - 'g:dep:jar:3': 'absent', - 'g:ext:jar:2': [ - { file: 'pom.xml.socket.facts.json' }, - { file: 'a/pom.xml' }, - { file: 'b/pom.xml' }, - ], - 'g:solo:jar:1': [{ file: 'pom.xml.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') + } }) it("records each project's own build files, relative to the build root", () => { const records = [ @@ -264,9 +407,7 @@ describe('records → assemble → sidecar', () => { 'project\tg:bare:1\tg\tbare\t1\tbare', ].join('\n') const parsed = parseRecords(records) - const { facts } = assembleFacts(parsed, { - factsFileName: 'other-pom.xml.socket.facts.json', - }) + const { facts } = assembleFacts(parsed) expect(parsed.buildRoot).toBe('/repo/sub') expect( @@ -280,85 +421,28 @@ describe('records → assemble → sidecar', () => { 'g:mod-b:1': [{ file: 'mod/b.xml' }], }) }) - it('omits the facts file reference when the facts file cannot be named', () => { - const records = [ - 'meta\tmaven\t3.9.6\t17', - 'project\ta\tg\ta\t1\t.', - 'projectBuild\ta\tpom.xml', - 'root\tr1\ta\truntimeClasspath\t1', - 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1', - ].join('\n') - const { facts } = assembleFacts(parseRecords(records), { - factsFileName: undefined, - }) - - expect(facts.components[0]?.manifestFiles).toEqual([{ file: 'pom.xml' }]) - }) - it('attributes Gradle direct dependencies to the script declaring them', () => { + 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), { - factsFileName: 'gradle.socket.facts.json', - }) + const { facts } = assembleFacts(parseRecords(records)) - expect( - Object.fromEntries( - facts.components.map(c => [ - c.id, - c.manifestFiles?.map(m => m.file).slice(1), - ]), - ), - ).toEqual({ - 'x:by-plugin:jar:1': ['b/build.gradle.kts'], - 'x:from-root:jar:1': ['build.gradle.kts'], - 'x:own:jar:1': ['b/build.gradle.kts'], - }) expect( Object.fromEntries( facts.projects!.map(p => [p.id, p.manifestFiles?.map(m => m.file)]), ), ).toEqual({ ':': ['build.gradle.kts'], - ':a': ['build.gradle.kts'], - ':b': ['b/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), { - factsFileName: 'gradle.socket.facts.json', + ':a': ['a/build.gradle.kts'], }) - - expect(facts.components[0]?.manifestFiles).toEqual([ - { file: 'gradle.socket.facts.json' }, - { file: 'a/build.gradle.kts' }, - ]) - expect(facts.projects![0]?.manifestFiles).toEqual([ - { file: 'a/build.gradle.kts' }, - ]) }) }) diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 54825bf65..ca13ac151 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,11 +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, per subproject pulling it in - // directly, the Gradle script declaring it, else the subproject's build file - // (which need not declare it, e.g. a parent POM does, nor exist on disk). - manifestFiles?: SocketFactsManifestReference[] | undefined } // Relative to the facts file's directory. @@ -41,16 +37,25 @@ export type SocketFactsManifestReference = { file: string } +// A component with one exact subtree, shared by every position whose subtree +// is identical. +export type SocketFactsSbomDependency = { + // Index into components[]. + component: number + // Indices into dependencies[]. + children?: number[] | undefined +} + 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`; - // for a Gradle project without one, the scripts declaring its dependencies, - // else its configured build file, which may not exist on disk. + // 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[] } // Resolved on-disk paths for a --with-files run, keyed by component or project @@ -61,4 +66,6 @@ export type ResolvedArtifactPaths = { // Component ids on each project's resolved classpath (union over its // configurations), keyed by project id. classpathByProject: Map + // Component ids of each project's direct dependencies, keyed by project id. + directDependenciesByProject: Map } 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/run.mts b/src/commands/manifest/scripts/run.mts index 49b0a9475..66a59ed0f 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -146,9 +146,7 @@ async function assembleFromRecords( : '' const parsed = parseRecords(text) const factsFileName = socketFactsFileName(tool, parsed.entry || undefined) - const { artifactPaths, facts, report } = assembleFacts(parsed, { - factsFileName, - }) + const { artifactPaths, facts, report } = assembleFacts(parsed) return { buildRoot: parsed.buildRoot || undefined, code: out.code, diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index 9f2bbf919..fb821d8c1 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -16,9 +16,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[] @@ -93,10 +95,15 @@ 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(proj.id) ?? [])], - })), + projects: (facts.projects ?? []).map( + ({ children: _children, ...proj }) => ({ + ...paths(proj), + dependencies: [ + ...(artifactPaths.directDependenciesByProject.get(proj.id) ?? []), + ], + 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 35236aca4..412819d19 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -15,6 +15,7 @@ function emptyArtifactPaths(): ResolvedArtifactPaths { return { pathsById: new Map(), classpathByProject: new Map(), + directDependenciesByProject: new Map(), } } @@ -51,7 +52,7 @@ describe('compute-artifacts sidecar', () => { namespace: 'g', name: 'app', subprojectDir: 'app', - dependencies: ['g:a:jar:1'], + children: [0], }, ], components: [ @@ -67,6 +68,7 @@ describe('compute-artifacts sidecar', () => { } const artifactPaths = emptyArtifactPaths() artifactPaths.classpathByProject.set(':app', ['g:a:jar:1']) + artifactPaths.directDependenciesByProject.set(':app', ['g:a:jar:1']) const acc: SidecarAccumulator = new Map() accumulateSidecar( @@ -79,7 +81,12 @@ describe('compute-artifacts sidecar', () => { const entry = serializeSidecar(acc)['/root/.socket.facts.json']! expect(entry.projects[0]).toEqual({ - ...facts.projects![0], + id: ':app', + 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]) @@ -160,7 +167,7 @@ describe('compute-artifacts sidecar', () => { namespace: 'ex', name: 'util', version: '1', - dependencies: [], + children: [], } const facts: SocketFactsSbom = { components: [ @@ -210,7 +217,6 @@ describe('compute-artifacts sidecar', () => { qualifiers: { ext: 'jar', classifier: 'sources' }, id: 'g:a:jar:sources:1', direct: true, - dependencies: ['x'], }, ], } @@ -226,7 +232,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', () => { @@ -242,7 +247,7 @@ describe('compute-artifacts sidecar', () => { name: 'app', version: '1.0', subprojectDir: 'app', - dependencies: [], + children: [], }, ], } @@ -278,7 +283,7 @@ describe('compute-artifacts sidecar', () => { type: 'maven', namespace: 'com.example', version: '1.0', - dependencies: [], + children: [], subprojectDir: 'x', } const facts: SocketFactsSbom = { @@ -329,7 +334,7 @@ describe('compute-artifacts sidecar', () => { name: 'shared', version: '1.0', subprojectDir: '.', - dependencies: [], + children: [], }, ], } 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]) } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 09668c217..129d014dd 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -62,18 +62,11 @@ 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, - projectsOf, failures).foreach { + foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { case (rootKey, tree) => perSub(rootKey) = tree } } @@ -159,16 +152,6 @@ 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)) @@ -294,14 +277,49 @@ object SocketFactsPlugin extends AutoPlugin { scannedConfigs: mutable.LinkedHashSet[String], withFiles: Boolean, populateScope: Option[Set[String]], - moduleExts: Map[String, String], - projectsOf: String => Seq[String], - failures: mutable.LinkedHashSet[Failure] + 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 @@ -314,18 +332,32 @@ 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 => + // Keyed by project id, as projects need not have distinct coordinates. + def buildModuleNode(r: ProjectRef): String = { + midToIds.getOrElseUpdate(moduleKey(rootIdOf(extracted, r)), mutable.LinkedHashSet.empty[String]) += r.project + nodes.getOrElseUpdate(r.project, { + val node = new Node(buildModuleCoord(r)) + node.project = r.project + node + }) + r.project + } + interProjectEdges(conf).foreach { case (p, d) => + val child = buildModuleNode(d) + if (p == ref) nodes(child).direct = true + else nodes(buildModuleNode(p)).children += child + } + } + 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 ids = midToIds.getOrElseUpdate(moduleKey(m.module), mutable.LinkedHashSet.empty[String]) + variantsOf(m).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) } @@ -335,10 +367,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 => + // 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 = 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. @@ -355,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) => @@ -503,10 +536,11 @@ object SocketFactsPlugin extends AutoPlugin { } } - 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 orEmpty(v: String): String = if (v == null) "" else v + + 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 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: [], }, ], }