From 3e2edefe3c30e84cbd40fec9746050bd9d980fe7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:53:34 +0200 Subject: [PATCH] fix(manifest): write JVM Socket facts into the build's own root mvn -f sub/x.xml and gradle -p dir wrote the facts file to cwd, where its build-root-relative paths do not resolve. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + .../manifest/generate-recursive-manifests.mts | 12 +++- .../generate-recursive-manifests.test.mts | 53 ++++++++++++++++++ src/commands/manifest/run-manifest-facts.mts | 16 +++++- .../manifest/run-manifest-facts.test.mts | 55 ++++++++++++++++--- .../manifest/scripts/assemble.test.mts | 10 ++++ .../socket/SocketFactsRecordsEngine.java | 1 + src/commands/manifest/scripts/records.mts | 7 +++ src/commands/manifest/scripts/run.mts | 6 +- .../manifest/scripts/socket-facts.init.gradle | 2 + .../scripts/socket-facts.plugin.scala | 1 + 11 files changed, 151 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce645096..4173cf31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. +### 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. + ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08 ### Changed diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index fd329f20c..70f76047e 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -185,10 +185,13 @@ async function runEcosystemCandidates({ } covered.add(dir) + // `-f`/`-p` can root the reactor away from `dir`. + // eslint-disable-next-line no-await-in-loop + const buildRoot = await realpathOrResolved(path.dirname(result.factsPath)) // eslint-disable-next-line no-await-in-loop const resolvedSubprojectDirs = await Promise.all( result.projects.map(project => - realpathOrResolved(path.resolve(dir, project.subprojectDir)), + realpathOrResolved(path.resolve(buildRoot, project.subprojectDir)), ), ) for (const subprojectDir of resolvedSubprojectDirs) { @@ -203,7 +206,10 @@ async function runEcosystemCandidates({ // meaningful data point, not a redundant one. Never suppress its own // build-root invocation, regardless of which reactor(s) also // incorporate it or the order candidates happen to be discovered in. - if (subprojectDir.startsWith(`${dir}${path.sep}`)) { + if ( + subprojectDir === buildRoot || + subprojectDir.startsWith(`${buildRoot}${path.sep}`) + ) { covered.add(subprojectDir) } } @@ -218,7 +224,7 @@ async function runEcosystemCandidates({ return outcomes } -// Generates one .socket.facts.json per independent gradle/sbt/maven build +// Generates one Socket facts file per independent gradle/sbt/maven build // root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's // own projects[].subprojectDir, not by pruning the whole discovered subtree, // so an unrelated nested project a reactor doesn't declare still gets its diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 041a223aa..fa90eb4cc 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -164,6 +164,59 @@ describe('generateRecursiveManifests', () => { }, ) + it('judges coverage against the reported build root, not the discovery directory', async () => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'relocated-build-root-')), + ) + const buildRoot = path.join(outer, 'build') + const member = path.join(buildRoot, 'member') + const escaped = path.join(outer, 'escaped') + try { + for (const dir of [outer, member, escaped]) { + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(dir, { recursive: true }) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(path.join(dir, 'pom.xml'), '') + } + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => { + if (cwd === outer) { + return { + factsPath: path.join(buildRoot, 'x.xml.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'member', + subprojectDir: 'member', + dependencies: [], + }, + { + type: 'maven', + name: 'escaped', + subprojectDir: '../escaped', + dependencies: [], + }, + ], + } + } + return { + factsPath: path.join(cwd, 'pom.xml.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(member)).toBe('skippedCovered') + expect(byDir.get(escaped)).toBe('generated') + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => { vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ factsPath: path.join(cwd, '.socket.facts.json'), diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index cecf4f9d3..520bf68a4 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -79,8 +79,6 @@ export async function runManifestFacts({ verbose: boolean withFiles?: boolean | undefined }): Promise { - const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) - let resolvedJavaHome: string | undefined if (javaHome) { const expanded = expandEnvVarRefs(javaHome) @@ -160,7 +158,8 @@ export async function runManifestFacts({ ) return null } - const { artifactPaths, code, facts, report, stderr, stdout } = result + const { artifactPaths, buildRoot, code, facts, report, stderr, stdout } = + result const rendered = renderResolutionErrorReport( report.failures, @@ -230,6 +229,17 @@ export async function runManifestFacts({ return } + if (!buildRoot) { + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build did not report its root directory, so its Socket facts file cannot be placed.`, + ) + return null + } + // Every path in the facts is relative to the build root, which `-f`/`-p` + // can move away from cwd. + const factsPath = path.join(buildRoot, constants.DOT_SOCKET_DOT_FACTS_JSON) + const socketCliVersion = constants.ENV.INLINED_SOCKET_CLI_VERSION if (facts.metadata && socketCliVersion) { facts.metadata.socketCliVersion = socketCliVersion diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 9c735cfc0..492e64d2a 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -17,8 +17,9 @@ import type { SidecarAccumulator } from './scripts/sidecar.mts' const ENV_VAR = 'SOCKET_TEST_JAVA_HOME' -function okResult(): ManifestRunResult { +function okResult(buildRoot: string): ManifestRunResult { return { + buildRoot, code: 0, facts: { components: [{ id: 'a', type: 'maven', name: 'a' }], @@ -63,7 +64,7 @@ describe('runManifestFacts - javaHome', () => { }) it('passes a literal javaHome straight through as JAVA_HOME', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, javaHome: '/opt/jdk-17' }) const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17') @@ -71,7 +72,7 @@ describe('runManifestFacts - javaHome', () => { it('expands $VAR and ${VAR} references against the CLI process env', async () => { process.env[ENV_VAR] = '/opt/jdk-11' - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, @@ -82,7 +83,7 @@ describe('runManifestFacts - javaHome', () => { }) it('fails closed without invoking the build tool when the referenced var is unset', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) const result = await runManifestFacts({ ...baseArgs, cwd, @@ -94,7 +95,7 @@ describe('runManifestFacts - javaHome', () => { }) it('leaves the environment untouched when javaHome is unset', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd }) const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] expect(opts?.env).toBeUndefined() @@ -115,7 +116,7 @@ describe('runManifestFacts - sidecar', () => { }) it('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => { - const result = okResult() + const result = okResult(cwd) result.facts.projects = [ { type: 'maven', @@ -139,7 +140,7 @@ describe('runManifestFacts - sidecar', () => { expect(bucket?.projects.find(m => m.name === 'app')).toBeDefined() }) it('stamps the inlined socket-cli version into the written facts metadata', async () => { - const result = okResult() + const result = okResult(cwd) result.facts.metadata = { format: 'socket-facts-sbom', tool: 'maven', @@ -160,6 +161,44 @@ describe('runManifestFacts - sidecar', () => { }) }) +describe('runManifestFacts - build root', () => { + let cwd = '' + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) + vi.mocked(runManifestScript).mockReset() + process.exitCode = undefined + }) + afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + process.exitCode = undefined + }) + + it('writes the facts file into the build root the tool reports', async () => { + const buildRoot = path.join(cwd, 'sub') + await fs.mkdir(buildRoot) + vi.mocked(runManifestScript).mockResolvedValue(okResult(buildRoot)) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome?.factsPath).toBe(path.join(buildRoot, '.socket.facts.json')) + expect(await fs.readdir(buildRoot)).toEqual(['.socket.facts.json']) + }) + + it('fails without writing when the build did not report its root', async () => { + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(cwd), + buildRoot: undefined, + }) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome).toBeNull() + expect(process.exitCode).toBe(1) + expect(await fs.readdir(cwd)).toEqual([]) + }) +}) + describe('runManifestFacts - sbt build detection', () => { let cwd = '' @@ -196,7 +235,7 @@ describe('runManifestFacts - sbt build detection', () => { } else { await fs.writeFile(path.join(cwd, marker), '') } - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, ecosystem: 'sbt' }) diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 40fc827e7..284cba72e 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -187,3 +187,13 @@ describe('records → assemble → sidecar', () => { }) }) }) + +describe('parseRecords', () => { + it('reads the build root the build reports', () => { + expect( + parseRecords( + ['meta\tmaven\t3.9.6\t17', 'buildRoot\t/repo/sub'].join('\n'), + ).buildRoot, + ).toBe('/repo/sub') + }) +}) 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 f5074848b..8facd1502 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 @@ -93,6 +93,7 @@ public void run(MavenSession session, List reactor, File rootDir, List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + rec(lines, "buildRoot", rootDir.getAbsolutePath()); for (MavenProject module : reactor) { // No basedir: Maven's stand-in project for a directory without a POM. Skipping it lets Maven's diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 14a776e84..a5207d907 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -10,6 +10,7 @@ import type { // \t\t... // // meta tool toolVersion javaVersion +// buildRoot path (absolute; the facts file's directory) // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) @@ -67,6 +68,8 @@ export type ParsedRecords = { tool: string toolVersion: string javaVersion: string + // Absolute directory the build is rooted at; the facts file is written there. + buildRoot: string projects: Map roots: Map scannedConfigs: string[] @@ -100,6 +103,7 @@ export function parseRecords(text: string): ParsedRecords { tool: '', toolVersion: '', javaVersion: '', + buildRoot: '', projects: new Map(), roots: new Map(), scannedConfigs: [], @@ -152,6 +156,9 @@ export function parseRecords(text: string): ParsedRecords { result.toolVersion = f[2] ?? '' result.javaVersion = f[3] ?? '' break + case 'buildRoot': + result.buildRoot = f[1] ?? '' + break case 'project': { const p = project(f[1] ?? '') p.group = f[2] ?? '' diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index fda242661..993945e0c 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -49,6 +49,8 @@ export type ManifestScriptOptions = { export type ManifestRunResult = { code: number facts: SocketFactsSbom + // Undefined when the build did not report it. + buildRoot: string | undefined report: ResolutionReport artifactPaths: ResolvedArtifactPaths // Captured build-tool output (empty when stdio is 'inherit'). @@ -139,8 +141,10 @@ async function assembleFromRecords( const text = existsSync(recordsFile) ? await fs.readFile(recordsFile, 'utf8') : '' - const { artifactPaths, facts, report } = assembleFacts(parseRecords(text)) + const parsed = parseRecords(text) + const { artifactPaths, facts, report } = assembleFacts(parsed) return { + buildRoot: parsed.buildRoot || undefined, code: out.code, facts, report, diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 184fcfece..299f1fd56 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -473,6 +473,7 @@ rootProject { rp -> // task actions. The Socket CLI disables the cache for this run, but hoisting is cheap insurance. def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() def defaultRecordsFile = new File(rp.projectDir, '.socket.facts.records.tsv').absolutePath + def buildRootPath = rp.projectDir.absolutePath // `sources`/`targets` are --with-files-only; a plain run emits only the graph fields. def withFilesProjects = gradle.socketProp.call(rp, 'socket.withFiles')?.toString()?.toLowerCase() == 'true' @@ -493,6 +494,7 @@ rootProject { rp -> def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + rec(['buildRoot', buildRootPath]) // One `project` record per build module (sources/targets only with --with-files). def projectsInfo diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index e14edfe3a..993aa8ddd 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -98,6 +98,7 @@ object SocketFactsPlugin extends AutoPlugin { } rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) + rec("buildRoot", rootCanonPath.toString) // One `project` record per build module (sources/targets only with --with-files). Excluded // subprojects are omitted (they were also skipped during resolution above).