diff --git a/CHANGELOG.md b/CHANGELOG.md
index daa85e021..d54ddd1ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [Unreleased]
+
+### 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).