Skip to content

Commit d283907

Browse files
committed
refactor(scan): fail closed without a .NET SDK, simplify facts-only wiring
- --dynamic-sbom-inference and its setup wizard fail closed when no .NET SDK runs for a .NET build root, for every consumer, since no facts can be generated without it. --auto-manifest still warns and skips. - Name socket.json's path and the setup command in the next steps. - Write the reachability sidecar whenever facts-only types are set, so the flag and the sidecar `coana run` needs come from the same input. - Name the upload-only inference mode instead of an undefined consumer. - Move useOnlySocketFactsArtifactsFlags next to the other Coana helpers. - Table-driven tests with shared helpers.
1 parent c81b1ac commit d283907

17 files changed

Lines changed: 302 additions & 377 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Changed
1010
- With `--dynamic-sbom-inference`, reachability analysis and `socket fix` now take the dependencies of every Gradle, sbt, Maven and .NET build they find from Socket facts only, so results no longer depend on which other builds produced facts.
11-
- With `--dynamic-sbom-inference`, reachability analysis and `socket fix` now stop with clear next steps when a .NET build cannot be read because no .NET SDK is installed, and warn that builds skipped in `socket.json` are left out. Reachability analysis also stops with next steps when no build produced Socket facts.
11+
- `--dynamic-sbom-inference` and its setup now stop with clear next steps when a .NET build cannot be read because no .NET SDK is installed, instead of skipping it. With it, reachability analysis and `socket fix` warn that builds skipped in `socket.json` are left out, and reachability analysis stops with next steps when no build produced Socket facts.
1212

1313
## [1.13.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.13.1) - 2026-11-03
1414

‎src/commands/fix/generated-socket-facts.mts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'
22
import path from 'node:path'
33

4-
import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts'
4+
import { useOnlySocketFactsArtifactsFlags } from '../../utils/coana.mts'
55
import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts'
66

77
export type GeneratedSocketFacts = {

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

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
DSI_SETUP_COMMAND,
1919
REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG,
2020
} from './scripts/resolution-report-render.mts'
21-
import { noDotnetSdkReason, skipWithoutDotnetSdk } from './scripts/run.mts'
21+
import { noDotnetSdkReason } from './scripts/run.mts'
2222
import { SOCKET_JSON } from '../../constants.mts'
2323
import { loadSocketConfig } from '../../utils/config.mts'
2424
import { InputError } from '../../utils/errors.mts'
@@ -45,7 +45,6 @@ export type RecursiveManifestOutcomeStatus =
4545
| 'generated'
4646
| 'skippedCovered'
4747
| 'skippedDisabled'
48-
| 'skippedNoSdk'
4948

5049
export type RecursiveManifestOutcome = BuildRoot & {
5150
ecosystem: BuildTool
@@ -153,7 +152,6 @@ async function runEcosystemCandidates({
153152
ignoreUnresolved: ignoreUnresolvedOverride,
154153
optionFlags,
155154
realCwd,
156-
requireDotnetSdk,
157155
rootSockJson,
158156
sbtTmpDir,
159157
sidecarAcc,
@@ -165,7 +163,6 @@ async function runEcosystemCandidates({
165163
ignoreUnresolved: boolean | undefined
166164
optionFlags: ReadonlySet<BuildOption>
167165
realCwd: string
168-
requireDotnetSdk: boolean | undefined
169166
rootSockJson: SocketJson
170167
// sbt only: a shared global base reused across every sbt root in this run,
171168
// so sbt's own Scala-toolchain cache under <base>/boot survives between
@@ -215,26 +212,20 @@ async function runEcosystemCandidates({
215212
outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' })
216213
continue
217214
}
218-
if (ecosystem === 'dotnet' && requireDotnetSdk) {
215+
const socketJson = displayPath(path.join(dir, SOCKET_JSON))
216+
if (ecosystem === 'dotnet') {
219217
// eslint-disable-next-line no-await-in-loop
220218
const reason = await noDotnetSdkReason(dirConfig.bin, dir)
221219
if (reason !== undefined) {
222220
throw new InputError(
223221
[
224-
`No .NET SDK runs for ${displayPath(buildRootPath(root))} (${reason}), so it has no Socket facts, and the analysis takes .NET dependencies only from them.`,
222+
`No .NET SDK runs for ${displayPath(buildRootPath(root))} (${reason}), so its Socket facts cannot be generated.`,
225223
'',
226-
'- Install the .NET SDK, or point defaults.manifest.dotnet.bin in socket.json at one.',
227-
'- Or leave it out with --exclude-paths, or with defaults.manifest.dotnet.disabled in socket.json.',
224+
`- Install the .NET SDK, or point defaults.manifest.dotnet.bin in ${socketJson} at one (\`${DSI_SETUP_COMMAND}\` walks you through it).`,
225+
`- Or leave it out with --exclude-paths, or with defaults.manifest.dotnet.disabled in ${socketJson}.`,
228226
].join('\n'),
229227
)
230228
}
231-
} else if (
232-
ecosystem === 'dotnet' &&
233-
// eslint-disable-next-line no-await-in-loop
234-
(await skipWithoutDotnetSdk(dirConfig.bin, dir))
235-
) {
236-
outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' })
237-
continue
238229
}
239230
// eslint-disable-next-line no-await-in-loop
240231
const result = await buildUncoveredRoot({
@@ -256,7 +247,7 @@ async function runEcosystemCandidates({
256247
: undefined,
257248
flags: optionFlags,
258249
setupCommand: DSI_SETUP_COMMAND,
259-
socketJson: displayPath(path.join(dir, SOCKET_JSON)),
250+
socketJson,
260251
},
261252
root,
262253
sidecarAcc,
@@ -371,7 +362,6 @@ export async function generateRecursiveManifests({
371362
excludePaths,
372363
ignoreUnresolved,
373364
optionFlags = new Set(),
374-
requireDotnetSdk,
375365
sbtTmpDir,
376366
sidecarAcc,
377367
verbose,
@@ -384,8 +374,6 @@ export async function generateRecursiveManifests({
384374
// Options the invoking command takes as flags; hints send the rest to each
385375
// build root's socket.json.
386376
optionFlags?: ReadonlySet<BuildOption> | undefined
387-
// Fail instead of skipping a .NET build root no .NET SDK can build.
388-
requireDotnetSdk?: boolean | undefined
389377
// Reachability path only: caller-supplied directory to use as sbt's shared
390378
// global base across every sbt root in this run. sbt provisions the Scala
391379
// toolchain under `<base>/boot`, which withFiles' artifactPaths point into,
@@ -424,7 +412,6 @@ export async function generateRecursiveManifests({
424412
ignoreUnresolved,
425413
optionFlags,
426414
realCwd,
427-
requireDotnetSdk,
428415
rootSockJson,
429416
sbtTmpDir: resolvedSbtTmpDir,
430417
sidecarAcc,

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

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,12 @@ vi.mock('./scripts/run.mts', async importOriginal => ({
1919
...(await importOriginal<typeof import('./scripts/run.mts')>()),
2020
listDotnetEntryProjects: vi.fn(async () => undefined),
2121
noDotnetSdkReason: vi.fn(async () => undefined),
22-
skipWithoutDotnetSdk: vi.fn(async () => false),
2322
}))
2423

2524
import { buildRootPath } from './discover-manifest-roots.mts'
2625
import { generateRecursiveManifests } from './generate-recursive-manifests.mts'
2726
import { runManifestFacts } from './run-manifest-facts.mts'
28-
import {
29-
listDotnetEntryProjects,
30-
noDotnetSdkReason,
31-
skipWithoutDotnetSdk,
32-
} from './scripts/run.mts'
27+
import { listDotnetEntryProjects, noDotnetSdkReason } from './scripts/run.mts'
3328
import { testPath } from '../../../test/utils.mts'
3429
import {
3530
readOrDefaultSocketJson,
@@ -529,7 +524,7 @@ describe('generateRecursiveManifests', () => {
529524
})
530525
})
531526

532-
it('fails before building anything when a .NET build root needs an SDK that does not run', async () => {
527+
it('fails before building anything when no .NET SDK runs for a .NET build root', async () => {
533528
const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-require-sdk-'))
534529
try {
535530
await fs.writeFile(path.join(dir, 'pom.xml'), '<project/>')
@@ -540,44 +535,15 @@ describe('generateRecursiveManifests', () => {
540535
await expect(
541536
generateRecursiveManifests({
542537
cwd: dir,
543-
requireDotnetSdk: true,
544538
verbose: false,
545539
}),
546540
).rejects.toThrow(/No .NET SDK runs for .* \(dotnet not found\)/)
547-
expect(skipWithoutDotnetSdk).not.toHaveBeenCalled()
548541
expect(runManifestFacts).not.toHaveBeenCalled()
549542
} finally {
550543
await fs.rm(dir, { recursive: true, force: true })
551544
}
552545
})
553546

554-
it('skips .NET builds, without failing, where no .NET SDK runs', async () => {
555-
const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-sdk-'))
556-
try {
557-
await fs.writeFile(path.join(dir, 'pom.xml'), '<project/>')
558-
await fs.mkdir(path.join(dir, 'samples'))
559-
await fs.writeFile(path.join(dir, 'samples', 'Stray.csproj'), '')
560-
vi.mocked(skipWithoutDotnetSdk).mockResolvedValueOnce(true)
561-
vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({
562-
factsPath: path.join(cwd, 'pom.xml.socket.facts.json'),
563-
projects: [],
564-
}))
565-
const outcomes = await generateRecursiveManifests({
566-
cwd: dir,
567-
verbose: false,
568-
})
569-
570-
expect(
571-
outcomes.map(o => `${o.ecosystem}:${o.entryFile}:${o.status}`).sort(),
572-
).toEqual(['dotnet:Stray.csproj:skippedNoSdk', 'maven:pom.xml:generated'])
573-
expect(
574-
vi.mocked(runManifestFacts).mock.calls.map(([o]) => o.ecosystem),
575-
).toEqual(['maven'])
576-
} finally {
577-
await fs.rm(dir, { recursive: true, force: true })
578-
}
579-
})
580-
581547
it("does not list a solution's members for coverage another tool's builds recorded", async () => {
582548
const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-per-tool-'))
583549
try {

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,3 @@ export function serializeSidecar(
157157
}
158158
return result
159159
}
160-
161-
// Facts-only whether or not any facts were generated. Maven alone keeps the
162-
// older flag, which Coana releases before 15.14.1 also know.
163-
export function useOnlySocketFactsArtifactsFlags(
164-
types: readonly string[],
165-
): string[] {
166-
return types.length === 1 && types[0] === 'maven'
167-
? ['--maven-use-only-socket-facts']
168-
: ['--use-only-socket-facts-artifacts', [...types].sort().join(',')]
169-
}

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

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
hasSidecarEntries,
77
mergeResolvedPathsSidecars,
88
serializeSidecar,
9-
useOnlySocketFactsArtifactsFlags,
109
} from './sidecar.mts'
1110

1211
import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts'
@@ -422,22 +421,3 @@ describe('compute-artifacts sidecar', () => {
422421
expect(hasResolvedPathsSidecarEntries({})).toBe(false)
423422
})
424423
})
425-
426-
describe('useOnlySocketFactsArtifactsFlags', () => {
427-
it('keeps the Maven-only flag every Coana release knows for Maven alone', () => {
428-
expect(useOnlySocketFactsArtifactsFlags(['maven'])).toEqual([
429-
'--maven-use-only-socket-facts',
430-
])
431-
})
432-
433-
it('names every other selection, sorted', () => {
434-
expect(useOnlySocketFactsArtifactsFlags(['nuget', 'maven'])).toEqual([
435-
'--use-only-socket-facts-artifacts',
436-
'maven,nuget',
437-
])
438-
expect(useOnlySocketFactsArtifactsFlags(['nuget'])).toEqual([
439-
'--use-only-socket-facts-artifacts',
440-
'nuget',
441-
])
442-
})
443-
})

‎src/commands/manifest/setup-recursive-manifest-config.mts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from './discover-manifest-roots.mts'
1919
import { enumerateWorkspaces } from './enumerate-workspaces.mts'
2020
import { resolveEcosystemConfig } from './generate-recursive-manifests.mts'
21-
import { skipWithoutDotnetSdk } from './scripts/run.mts'
21+
import { noDotnetSdkReason } from './scripts/run.mts'
2222
import {
2323
setupDotnet,
2424
setupGradle,
@@ -241,8 +241,16 @@ export async function markWorkspaceCoverage({
241241
if (skipReason) {
242242
return { ok: true, data: undefined }
243243
}
244-
if (ecosystem === 'dotnet' && (await skipWithoutDotnetSdk(bin, dir))) {
245-
return { ok: true, data: undefined }
244+
if (ecosystem === 'dotnet') {
245+
const reason = await noDotnetSdkReason(bin, dir)
246+
if (reason !== undefined) {
247+
return {
248+
ok: false,
249+
message: `No .NET SDK runs in ${path.relative(cwd, dir) || '.'} (${reason})`,
250+
cause:
251+
'Install the .NET SDK, or point defaults.manifest.dotnet.bin at one; or leave the directory out with --exclude-paths.',
252+
}
253+
}
246254
}
247255

248256
for (const root of roots) {

‎src/commands/manifest/setup-recursive-manifest-config.test.mts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ vi.mock('./discover-manifest-roots.mts', async importOriginal => {
2525
vi.mock('./enumerate-workspaces.mts', () => ({
2626
enumerateWorkspaces: vi.fn(),
2727
}))
28+
vi.mock('./scripts/run.mts', async importOriginal => ({
29+
...(await importOriginal<typeof import('./scripts/run.mts')>()),
30+
noDotnetSdkReason: vi.fn(async () => undefined),
31+
}))
2832
vi.mock('@socketsecurity/registry/lib/prompts', () => ({
2933
select: vi.fn(),
3034
}))
@@ -48,6 +52,7 @@ import {
4852
findBuildToolCandidates,
4953
} from './discover-manifest-roots.mts'
5054
import { enumerateWorkspaces } from './enumerate-workspaces.mts'
55+
import { noDotnetSdkReason } from './scripts/run.mts'
5156
import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts'
5257
import {
5358
configureCandidate,
@@ -455,6 +460,25 @@ describe('markWorkspaceCoverage', () => {
455460
expect(covered.size).toBe(0)
456461
})
457462

463+
it('fails closed when no .NET SDK runs for a .NET root', async () => {
464+
vi.mocked(noDotnetSdkReason).mockResolvedValueOnce('dotnet not found')
465+
466+
const result = await markWorkspaceCoverage({
467+
covered: new Set<string>(),
468+
cwd,
469+
dir: reactor,
470+
ecosystem: 'dotnet',
471+
rootSockJson: emptySockJson(),
472+
roots: [{ dir: reactor, entryFile: 'App.csproj' }],
473+
})
474+
475+
expect(result).toMatchObject({
476+
ok: false,
477+
message: 'No .NET SDK runs in reactor (dotnet not found)',
478+
})
479+
expect(enumerateWorkspaces).not.toHaveBeenCalled()
480+
})
481+
458482
it('resolves settings once per directory and skips a POM an earlier one covers', async () => {
459483
vi.mocked(enumerateWorkspaces).mockResolvedValue({
460484
buildRoot: reactor,

‎src/commands/scan/handle-create-new-scan.mts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ async function createNewScan(
162162
let factsOnlyTypes: PURL_Type[] | undefined
163163

164164
// sbt provisions its Scala toolchain under the directory passed as its
165-
// isolated global base; withFiles' artifactPaths point into it, so it must
165+
// isolated global base; reach's artifactPaths point into it, so it must
166166
// stay on disk until the reachability analysis below has consumed those
167167
// paths. Always allocated (even when unused) to keep this uniform rather
168168
// than conditional on autoManifest/reach.
@@ -177,11 +177,11 @@ async function createNewScan(
177177
// one at cwd. This runs on its own, so nothing outside those
178178
// ecosystems is generated unless --auto-manifest also asked for it.
179179
const dynamicResult = await runDynamicSbomInference({
180-
consumer: reach.runReachabilityAnalysis ? 'reach' : undefined,
180+
consumer: reach.runReachabilityAnalysis ? 'reach' : 'upload',
181181
cwd,
182182
excludePaths: reach.excludePaths,
183183
// sbt's Scala toolchain lives under its shared global base;
184-
// withFiles' resolved paths point into it, so when reachability
184+
// reach's artifactPaths point into it, so when reachability
185185
// will consume them afterward, reuse manifestTmpDir (kept alive
186186
// until reach finishes below) instead of letting the call clean
187187
// its own ephemeral base up before reach ever reads those paths.

‎src/commands/scan/handle-create-new-scan.test.mts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,6 @@ describe('handleCreateNewScan excludePaths', () => {
391391
expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith(
392392
expect.objectContaining({
393393
cwd: '/repo',
394-
requireDotnetSdk: false,
395394
sbtTmpDir: undefined,
396395
withFiles: false,
397396
}),
@@ -505,7 +504,6 @@ describe('handleCreateNewScan excludePaths', () => {
505504

506505
expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith(
507506
expect.objectContaining({
508-
requireDotnetSdk: true,
509507
sbtTmpDir: expect.any(String),
510508
withFiles: true,
511509
}),

0 commit comments

Comments
 (0)