Skip to content

Commit fc9277b

Browse files
committed
fix(manifest): report a facts build that fails before writing its records
A facts generator signals failure by returning null, which the auto-manifest abort missed when the exit code was already set. sbt roots share one global base, so a build that died before writing could be read as the previous root's records; records.tsv is now removed before each invocation.
1 parent d01da4e commit fc9277b

9 files changed

Lines changed: 95 additions & 26 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
99
### Changed
1010
- Updated the Coana CLI to v `15.12.12`.
1111

12+
### Fixed
13+
- With `--dynamic-sbom-inference`, an sbt build that fails before writing its results is reported as failed instead of reusing another build's results.
14+
1215
## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22
1316

1417
### Added

‎src/commands/manifest/convert-gradle-to-facts.mts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { runManifestFacts } from './run-manifest-facts.mts'
22

3+
import type { RunManifestFactsOutcome } from './run-manifest-facts.mts'
34
import type { BuildOptionHints } from './scripts/resolution-report-render.mts'
45
import type { SidecarAccumulator } from './scripts/sidecar.mts'
56

@@ -31,8 +32,8 @@ export async function convertGradleToFacts({
3132
sidecarAcc?: SidecarAccumulator | undefined
3233
verbose: boolean
3334
withFiles?: boolean | undefined
34-
}): Promise<void> {
35-
await runManifestFacts({
35+
}): Promise<RunManifestFactsOutcome> {
36+
return await runManifestFacts({
3637
bin,
3738
buildOpts: gradleOpts,
3839
cwd,

‎src/commands/manifest/convert-maven-to-facts.mts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { runManifestFacts } from './run-manifest-facts.mts'
22

3+
import type { RunManifestFactsOutcome } from './run-manifest-facts.mts'
34
import type { BuildOptionHints } from './scripts/resolution-report-render.mts'
45
import type { SidecarAccumulator } from './scripts/sidecar.mts'
56

@@ -31,8 +32,8 @@ export async function convertMavenToFacts({
3132
sidecarAcc?: SidecarAccumulator | undefined
3233
verbose: boolean
3334
withFiles?: boolean | undefined
34-
}): Promise<void> {
35-
await runManifestFacts({
35+
}): Promise<RunManifestFactsOutcome> {
36+
return await runManifestFacts({
3637
bin,
3738
buildOpts: mavenOpts,
3839
cwd,

‎src/commands/manifest/convert-sbt-to-facts.mts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { runManifestFacts } from './run-manifest-facts.mts'
22

3+
import type { RunManifestFactsOutcome } from './run-manifest-facts.mts'
34
import type { BuildOptionHints } from './scripts/resolution-report-render.mts'
45
import type { SidecarAccumulator } from './scripts/sidecar.mts'
56

@@ -35,8 +36,8 @@ export async function convertSbtToFacts({
3536
tmpDir: string
3637
verbose: boolean
3738
withFiles?: boolean | undefined
38-
}): Promise<void> {
39-
await runManifestFacts({
39+
}): Promise<RunManifestFactsOutcome> {
40+
return await runManifestFacts({
4041
bin,
4142
buildOpts: sbtOpts,
4243
cwd,

‎src/commands/manifest/generate-recursive-manifests.mts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,10 @@ export async function generateRecursiveManifests({
294294
// A shared global base across every sbt root in this run lets sbt's own
295295
// Scala-toolchain cache under <base>/boot survive between invocations
296296
// instead of being reprovisioned per root (the plugin file is rewritten and
297-
// records.tsv is fully overwritten - not appended - on every invocation, so
298-
// reuse is safe). A caller-supplied sbtTmpDir is reused as-is (the caller
299-
// owns its lifetime); otherwise one is allocated and cleaned up here, but
300-
// only when there's an sbt root to benefit from sharing it at all.
297+
// records.tsv is removed before every invocation, so reuse is safe). A
298+
// caller-supplied sbtTmpDir is reused as-is (the caller owns its lifetime);
299+
// otherwise one is allocated and cleaned up here, but only when there's an
300+
// sbt root to benefit from sharing it at all.
301301
const outcomes = sbtTmpDir
302302
? await runAll(sbtTmpDir)
303303
: candidatesByTool.get('sbt')?.length

‎src/commands/manifest/generate_auto_manifest.mts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { InputError } from '../../utils/errors.mts'
2121
import { readOrDefaultSocketJson } from '../../utils/socket-json.mts'
2222

2323
import type { GeneratableManifests } from './detect-manifest-actions.mts'
24+
import type { RunManifestFactsOutcome } from './run-manifest-facts.mts'
2425
import type { BuildOptionHints } from './scripts/resolution-report-render.mts'
2526
import type {
2627
ResolvedPathsSidecar,
@@ -41,12 +42,20 @@ export type GenerateAutoManifestResult = {
4142
resolvedPathsSidecar?: ResolvedPathsSidecar | undefined
4243
}
4344

44-
// Under --auto-manifest, a manifest generator that failed — raising the exit
45-
// code above the value captured before it ran — aborts the whole run: a partial
46-
// or empty SBOM silently under-reports dependencies. The generator has already
47-
// logged the specifics. A tolerated resolution failure (ignoreUnresolved) warns
48-
// without touching the exit code, so it passes through here and the run
49-
// continues.
45+
// A failed generator aborts --auto-manifest: a partial SBOM silently
46+
// under-reports dependencies. Facts generators return null on failure; the
47+
// others only change the exit code.
48+
function abortFactsRunIfFailed(
49+
ecosystem: string,
50+
outcome: RunManifestFactsOutcome,
51+
): void {
52+
if (outcome === null) {
53+
throw new InputError(
54+
`Auto-manifest generation failed for the ${ecosystem} project; aborting (see the errors above).`,
55+
)
56+
}
57+
}
58+
5059
function abortManifestRunIfFailed(
5160
ecosystem: string,
5261
beforeExitCode: string | number | undefined,
@@ -109,8 +118,7 @@ export async function generateAutoManifest({
109118
// `defaults.manifest.sbt.facts: false` in socket.json.
110119
if (sockJson.defaults?.manifest?.sbt?.facts !== false) {
111120
logger.log('Detected a Scala sbt build, generating Socket facts...')
112-
const beforeExitCode = process.exitCode
113-
await convertSbtToFacts({
121+
const outcome = await convertSbtToFacts({
114122
...sbtArgs,
115123
excludeConfigs: sockJson.defaults?.manifest?.sbt?.excludeConfigs ?? '',
116124
excludePaths,
@@ -124,7 +132,7 @@ export async function generateAutoManifest({
124132
tmpDir,
125133
withFiles: computeArtifactsSidecar,
126134
})
127-
abortManifestRunIfFailed('sbt', beforeExitCode)
135+
abortFactsRunIfFailed('sbt', outcome)
128136
} else {
129137
logger.log('Detected a Scala sbt build, generating pom files with sbt...')
130138
const beforeExitCode = process.exitCode
@@ -154,8 +162,7 @@ export async function generateAutoManifest({
154162
logger.log(
155163
'Detected a gradle build (Gradle, Kotlin, Scala), generating Socket facts...',
156164
)
157-
const beforeExitCode = process.exitCode
158-
await convertGradleToFacts({
165+
const outcome = await convertGradleToFacts({
159166
...gradleArgs,
160167
excludeConfigs:
161168
sockJson.defaults?.manifest?.gradle?.excludeConfigs ?? '',
@@ -170,7 +177,7 @@ export async function generateAutoManifest({
170177
sidecarAcc,
171178
withFiles: computeArtifactsSidecar,
172179
})
173-
abortManifestRunIfFailed('gradle', beforeExitCode)
180+
abortFactsRunIfFailed('gradle', outcome)
174181
} else {
175182
logger.log(
176183
'Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...',
@@ -183,8 +190,7 @@ export async function generateAutoManifest({
183190

184191
if (!sockJson?.defaults?.manifest?.maven?.disabled && detected.maven) {
185192
logger.log('Detected a Maven pom.xml build, generating Socket facts...')
186-
const beforeExitCode = process.exitCode
187-
await convertMavenToFacts({
193+
const outcome = await convertMavenToFacts({
188194
// Configured bin wins; else prefer ./mvnw, else mvn on PATH.
189195
bin:
190196
sockJson.defaults?.manifest?.maven?.bin ??
@@ -205,7 +211,7 @@ export async function generateAutoManifest({
205211
verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose),
206212
withFiles: computeArtifactsSidecar,
207213
})
208-
abortManifestRunIfFailed('maven', beforeExitCode)
214+
abortFactsRunIfFailed('maven', outcome)
209215
}
210216

211217
if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) {

‎src/commands/manifest/generate_auto_manifest.test.mts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,4 +386,18 @@ describe('generateAutoManifest — conda branch', () => {
386386
).rejects.toThrow(/Auto-manifest generation failed for the conda project/)
387387
process.exitCode = undefined
388388
})
389+
390+
it('aborts on a failed facts generator even when the exit code was already set', async () => {
391+
process.exitCode = 1
392+
vi.mocked(convertGradleToFacts).mockResolvedValueOnce(null)
393+
await expect(
394+
generateAutoManifest({
395+
cwd: '/tmp/repo',
396+
detected: { ...baseDetected, gradle: true, count: 1 },
397+
outputKind: 'text',
398+
verbose: false,
399+
}),
400+
).rejects.toThrow(/Auto-manifest generation failed for the gradle project/)
401+
process.exitCode = undefined
402+
})
389403
})

‎src/commands/manifest/scripts/run.mts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ async function invokeSbtIn(
299299
): Promise<ManifestRunResult> {
300300
await writeSbtPlugin(globalBase, pluginSourceFilename, pluginDestFilename)
301301
const recordsFile = path.join(globalBase, 'records.tsv')
302+
// The global base is shared across sbt roots: a build that dies before
303+
// writing must not be read as the previous root's records.
304+
await fs.rm(recordsFile, { force: true })
302305
const bin = resolveBuildToolBin('sbt', opts.projectDir, opts.bin)
303306
// Fresh per-run global base (not ~/.sbt): sbt executes everything under
304307
// plugins/, so a shared path is a code-injection surface. BSP off for this

‎src/commands/manifest/scripts/run.test.mts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { promises as fs } from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
15
import { describe, expect, it, vi } from 'vitest'
26

37
import { spawn } from '@socketsecurity/registry/lib/spawn'
48

5-
import { enumerateWorkspaces } from './run.mts'
9+
import { enumerateWorkspaces, runManifestScript } from './run.mts'
610

711
vi.mock('@socketsecurity/registry/lib/spawn', () => ({
812
spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })),
@@ -25,3 +29,39 @@ describe('build tool output', () => {
2529
},
2630
)
2731
})
32+
33+
describe('shared sbt global base', () => {
34+
it("does not read the previous root's records when a build dies before writing", async () => {
35+
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'sbt-shared-base-'))
36+
try {
37+
const recordsArg = (args: readonly string[]) =>
38+
args
39+
.find(a => a.startsWith('-Dsocket.recordsFile='))!
40+
.slice('-Dsocket.recordsFile='.length)
41+
vi.mocked(spawn).mockImplementationOnce((async (
42+
_bin: string,
43+
args: readonly string[],
44+
) => {
45+
await fs.writeFile(
46+
recordsArg(args),
47+
'buildRoot\t/a\nroot\t0\troot\tcompile\t1\nnode\t0\tx:y:jar:1\tx\ty\t1\tjar\t\t1\t\n',
48+
)
49+
return { code: 0, stderr: '', stdout: '' }
50+
}) as any)
51+
vi.mocked(spawn).mockImplementationOnce((async () => ({
52+
code: 1,
53+
stderr: 'error: not found: value broken',
54+
stdout: '',
55+
})) as any)
56+
const opts = { bin: 'sbt', stdio: 'pipe' as const, tmpDir: base }
57+
const a = await runManifestScript('sbt', { ...opts, projectDir: base })
58+
const b = await runManifestScript('sbt', { ...opts, projectDir: base })
59+
expect(a.facts.components).toHaveLength(1)
60+
expect(b.code).toBe(1)
61+
expect(b.buildRoot).toBeUndefined()
62+
expect(b.facts.components).toHaveLength(0)
63+
} finally {
64+
await fs.rm(base, { force: true, recursive: true })
65+
}
66+
})
67+
})

0 commit comments

Comments
 (0)