Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test/fixtures/commands/fix/e2e-test-py-temp-*
# The stub repos and caches are rebuilt on every run; smoke-test-workspaces.sh
# still keeps its Gradle user home inside the fixture directory.
/src/commands/manifest/scripts/test/gradle-compat/.gradle-home/
/src/commands/manifest/scripts/test/gradle-compat/project/.gradle/
/src/commands/manifest/scripts/test/gradle-compat/**/.gradle/
/src/commands/manifest/scripts/test/gradle-compat/project/build/
/src/commands/manifest/scripts/test/gradle-compat/project/localrepo/
/src/commands/manifest/scripts/test/gradle-compat/project/records.tsv
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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]

### Changed
- 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.
- `--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.

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

### Fixed
Expand Down
18 changes: 9 additions & 9 deletions src/commands/fix/generated-socket-facts.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'

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

export type GeneratedSocketFacts = {
Expand Down Expand Up @@ -59,14 +59,14 @@ export async function generateSocketFactsForFix({
let paths: string[]
let sidecarFile: string | undefined
try {
const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({
cwd,
excludePaths,
sbtTmpDir: undefined,
sidecar: true,
withFiles: false,
})
artifactsFlags = useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar)
const { factsOnlyTypes, factsPaths, resolvedPathsSidecar } =
await runDynamicSbomInference({
consumer: 'fix',
cwd,
excludePaths,
sbtTmpDir: undefined,
})
artifactsFlags = useOnlySocketFactsArtifactsFlags(factsOnlyTypes)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the sidecar and facts-only flag assembly from performReachabilityAnalysis, and the two copies already differ. Reach writes a sidecar whenever factsOnlyTypes is non-empty, but this writes one only when it has entries. Coana's fix commands don't require the sidecar today, so nothing breaks yet. Still, one helper returning the flags and the sidecar together would stop the two from drifting further.

paths = factsPaths.map(p => path.resolve(cwd, p))
if (resolvedPathsSidecar) {
sidecarFile = path.join(tmpDir, 'sidecar.json')
Expand Down
12 changes: 8 additions & 4 deletions src/commands/fix/generated-socket-facts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('generateSocketFactsForFix', () => {
mockRunDynamicSbomInference.mockImplementation(async () => {
await fs.writeFile(existing, 'regenerated')
await fs.writeFile(fresh, 'generated')
return { factsPaths: [existing, fresh] }
return { factsOnlyTypes: ['maven'], factsPaths: [existing, fresh] }
})

const generated = await generate()
Expand All @@ -66,6 +66,7 @@ describe('generateSocketFactsForFix', () => {
mockRunDynamicSbomInference.mockImplementation(async () => {
await fs.writeFile(fresh, 'generated')
return {
factsOnlyTypes: ['maven', 'nuget'],
factsPaths: [fresh],
resolvedPathsSidecar: { [fresh]: { components: [], projects: [] } },
}
Expand All @@ -74,9 +75,12 @@ describe('generateSocketFactsForFix', () => {
const { artifactsFlags, sidecarFile } = await generate()

expect(mockRunDynamicSbomInference).toHaveBeenCalledWith(
expect.objectContaining({ sidecar: true }),
expect.objectContaining({ consumer: 'fix' }),
)
expect(artifactsFlags).toEqual(['--maven-use-only-socket-facts'])
expect(artifactsFlags).toEqual([
'--use-only-socket-facts-artifacts',
'maven,nuget',
])
expect(path.dirname(sidecarFile!)).toBe(tmpDir)
expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({
[fresh]: { components: [], projects: [] },
Expand All @@ -86,7 +90,7 @@ describe('generateSocketFactsForFix', () => {
it('restores an existing facts file that generation does not rewrite', async () => {
mockRunDynamicSbomInference.mockImplementation(async () => {
await fs.writeFile(fresh, 'generated')
return { factsPaths: [fresh] }
return { factsOnlyTypes: ['maven'], factsPaths: [fresh] }
})

const generated = await generate()
Expand Down
25 changes: 16 additions & 9 deletions src/commands/manifest/generate-recursive-manifests.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import {
DSI_SETUP_COMMAND,
REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG,
} from './scripts/resolution-report-render.mts'
import { skipWithoutDotnetSdk } from './scripts/run.mts'
import { noDotnetSdkReason } from './scripts/run.mts'
import { SOCKET_JSON } from '../../constants.mts'
import { loadSocketConfig } from '../../utils/config.mts'
import { InputError } from '../../utils/errors.mts'
import { withTmpDir } from '../../utils/fs.mts'
import {
readOrDefaultSocketJson,
Expand All @@ -44,7 +45,6 @@ export type RecursiveManifestOutcomeStatus =
| 'generated'
| 'skippedCovered'
| 'skippedDisabled'
| 'skippedNoSdk'

export type RecursiveManifestOutcome = BuildRoot & {
ecosystem: BuildTool
Expand Down Expand Up @@ -212,13 +212,20 @@ async function runEcosystemCandidates({
outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' })
continue
}
if (
ecosystem === 'dotnet' &&
const socketJson = displayPath(path.join(dir, SOCKET_JSON))
if (ecosystem === 'dotnet') {
// eslint-disable-next-line no-await-in-loop
(await skipWithoutDotnetSdk(dirConfig.bin, dir))
) {
outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' })
continue
const reason = await noDotnetSdkReason(dirConfig.bin, dir)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this check runs per root inside the build loop, a later .NET root whose global.json pins a missing SDK aborts the run after earlier .NET roots were already built and wrote their facts files. fix resets those files, but scan create and scan reach leave them in the working tree, and the user waited for a build that can't be used. Checking every .NET root before building anything would make the "fails before building anything" test hold in general. Right now it holds only because the test has a single .NET root and dotnet comes first in BUILD_TOOLS.

if (reason !== undefined) {
throw new InputError(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

socket manifest dynamic-sbom-inference --json used to report a root without an SDK as a skippedNoSdk outcome in its JSON. Now this InputError escapes handleManifestDynamicSbomInference before its CResult is built, so a script parsing the JSON gets no outcome list.

[
`No .NET SDK runs for ${displayPath(buildRootPath(root))} (${reason}), so its Socket facts cannot be generated.`,
'',
`- Install the .NET SDK, or point defaults.manifest.dotnet.bin in ${socketJson} at one (\`${DSI_SETUP_COMMAND}\` walks you through it).`,
`- Or leave it out with --exclude-paths, or with defaults.manifest.dotnet.disabled in ${socketJson}.`,
].join('\n'),
)
}
}
// eslint-disable-next-line no-await-in-loop
const result = await buildUncoveredRoot({
Expand All @@ -240,7 +247,7 @@ async function runEcosystemCandidates({
: undefined,
flags: optionFlags,
setupCommand: DSI_SETUP_COMMAND,
socketJson: displayPath(path.join(dir, SOCKET_JSON)),
socketJson,
},
root,
sidecarAcc,
Expand Down
38 changes: 14 additions & 24 deletions src/commands/manifest/generate-recursive-manifests.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,13 @@ vi.mock('./run-manifest-facts.mts', () => ({
vi.mock('./scripts/run.mts', async importOriginal => ({
...(await importOriginal<typeof import('./scripts/run.mts')>()),
listDotnetEntryProjects: vi.fn(async () => undefined),
skipWithoutDotnetSdk: vi.fn(async () => false),
noDotnetSdkReason: vi.fn(async () => undefined),
}))

import { buildRootPath } from './discover-manifest-roots.mts'
import { generateRecursiveManifests } from './generate-recursive-manifests.mts'
import { runManifestFacts } from './run-manifest-facts.mts'
import {
listDotnetEntryProjects,
skipWithoutDotnetSdk,
} from './scripts/run.mts'
import { listDotnetEntryProjects, noDotnetSdkReason } from './scripts/run.mts'
import { testPath } from '../../../test/utils.mts'
import {
readOrDefaultSocketJson,
Expand Down Expand Up @@ -527,28 +524,21 @@ describe('generateRecursiveManifests', () => {
})
})

it('skips .NET builds, without failing, where no .NET SDK runs', async () => {
const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-sdk-'))
it('fails before building anything when no .NET SDK runs for a .NET build root', async () => {
const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-require-sdk-'))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: withTmpDir in src/utils/fs.mts does this mkdtemp plus try/finally cleanup. It's only worth switching for the whole file, since the rest of the file uses the same pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Not changing this here: as you say it's only worth it for the whole file, and the rest of the file is outside this PR.

try {
await fs.writeFile(path.join(dir, 'pom.xml'), '<project/>')
await fs.mkdir(path.join(dir, 'samples'))
await fs.writeFile(path.join(dir, 'samples', 'Stray.csproj'), '')
vi.mocked(skipWithoutDotnetSdk).mockResolvedValueOnce(true)
vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({
factsPath: path.join(cwd, 'pom.xml.socket.facts.json'),
projects: [],
}))
const outcomes = await generateRecursiveManifests({
cwd: dir,
verbose: false,
})
await fs.writeFile(path.join(dir, 'App.csproj'), '')
vi.mocked(noDotnetSdkReason).mockResolvedValueOnce('dotnet not found')
vi.mocked(runManifestFacts).mockClear()

expect(
outcomes.map(o => `${o.ecosystem}:${o.entryFile}:${o.status}`).sort(),
).toEqual(['dotnet:Stray.csproj:skippedNoSdk', 'maven:pom.xml:generated'])
expect(
vi.mocked(runManifestFacts).mock.calls.map(([o]) => o.ecosystem),
).toEqual(['maven'])
await expect(
generateRecursiveManifests({
cwd: dir,
verbose: false,
}),
).rejects.toThrow(/No .NET SDK runs for .* \(dotnet not found\)/)
expect(runManifestFacts).not.toHaveBeenCalled()
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
Expand Down
40 changes: 25 additions & 15 deletions src/commands/manifest/scripts/run.mts
Original file line number Diff line number Diff line change
Expand Up @@ -528,27 +528,37 @@ async function readDotnetSdkVersion(

const noSdkWarnings = new Set<string>()

// True, after a warning, when no .NET SDK runs in `dir` (none installed, or
// none matching its global.json), so its .NET builds are skipped.
export async function skipWithoutDotnetSdk(
// Why no .NET SDK runs in `dir` (none installed, or none matching its
// global.json), or undefined when one does.
export async function noDotnetSdkReason(
bin: string,
dir: string,
): Promise<boolean> {
): Promise<string | undefined> {
try {
await dotnetSdkVersion(bin, { projectDir: dir })
return false
return undefined
} catch (e) {
const reason = getErrorMessageOr(e, `\`${bin} --version\` failed`).split(
'\n',
)[0]!
if (!noSdkWarnings.has(reason)) {
noSdkWarnings.add(reason)
logger.warn(
`Skipping .NET builds where no .NET SDK runs (${reason}); their project files are scanned as they are.`,
)
}
return true
return getErrorMessageOr(e, `\`${bin} --version\` failed`).split('\n')[0]!
}
}

// True, after a warning, when no .NET SDK runs in `dir`, so its .NET builds
// are skipped.
export async function skipWithoutDotnetSdk(
bin: string,
dir: string,
): Promise<boolean> {
const reason = await noDotnetSdkReason(bin, dir)
if (reason === undefined) {
return false
}
if (!noSdkWarnings.has(reason)) {
noSdkWarnings.add(reason)
logger.warn(
`Skipping .NET builds where no .NET SDK runs (${reason}); their project files are scanned as they are.`,
)
}
return true
}

// Runs the bundled tool against the build's own dotnet, in `mode` (none: a
Expand Down
17 changes: 0 additions & 17 deletions src/commands/manifest/scripts/sidecar.mts
Original file line number Diff line number Diff line change
Expand Up @@ -157,20 +157,3 @@ export function serializeSidecar(
}
return result
}

// Tells Coana which ecosystems' artifacts only these facts files attribute.
// Maven's always are (the dynamic-SBOM-inference contract), under its own flag
// every Coana release knows unless another ecosystem's facts are present too.
export function useOnlySocketFactsArtifactsFlags(
sidecar: ResolvedPathsSidecar | undefined,
): string[] {
const types = new Set(['maven'])
for (const entry of Object.values(sidecar ?? {})) {
for (const item of [...entry.projects, ...entry.components]) {
types.add(item.type)
}
}
return types.size === 1
? ['--maven-use-only-socket-facts']
: ['--use-only-socket-facts-artifacts', [...types].sort().join(',')]
}
26 changes: 0 additions & 26 deletions src/commands/manifest/scripts/sidecar.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
hasSidecarEntries,
mergeResolvedPathsSidecars,
serializeSidecar,
useOnlySocketFactsArtifactsFlags,
} from './sidecar.mts'

import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts'
Expand Down Expand Up @@ -422,28 +421,3 @@ describe('compute-artifacts sidecar', () => {
expect(hasResolvedPathsSidecarEntries({})).toBe(false)
})
})

function entryOfType(type: string) {
return { projects: [], components: [{ type, name: 'x', id: 'x' }] }
}

describe('useOnlySocketFactsArtifactsFlags', () => {
it('keeps the Maven-only flag every Coana release knows for JVM facts', () => {
expect(useOnlySocketFactsArtifactsFlags(undefined)).toEqual([
'--maven-use-only-socket-facts',
])
expect(
useOnlySocketFactsArtifactsFlags({
'/a/pom.xml.socket.facts.json': entryOfType('maven'),
}),
).toEqual(['--maven-use-only-socket-facts'])
})

it('names every ecosystem once another one has facts', () => {
expect(
useOnlySocketFactsArtifactsFlags({
'/a/App.sln.socket.facts.json': entryOfType('nuget'),
}),
).toEqual(['--use-only-socket-facts-artifacts', 'maven,nuget'])
})
})
14 changes: 11 additions & 3 deletions src/commands/manifest/setup-recursive-manifest-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from './discover-manifest-roots.mts'
import { enumerateWorkspaces } from './enumerate-workspaces.mts'
import { resolveEcosystemConfig } from './generate-recursive-manifests.mts'
import { skipWithoutDotnetSdk } from './scripts/run.mts'
import { noDotnetSdkReason } from './scripts/run.mts'
import {
setupDotnet,
setupGradle,
Expand Down Expand Up @@ -241,8 +241,16 @@ export async function markWorkspaceCoverage({
if (skipReason) {
return { ok: true, data: undefined }
}
if (ecosystem === 'dotnet' && (await skipWithoutDotnetSdk(bin, dir))) {
return { ok: true, data: undefined }
if (ecosystem === 'dotnet') {
const reason = await noDotnetSdkReason(bin, dir)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wizard checks for an SDK only on the path where nested roots are configured one by one. If the user answers no to "Configure other build roots found beneath this one, individually?" (line 853), it reports "Recursive setup complete." without checking any nested .NET root, and the next --dynamic-sbom-inference run fails. On the individual path, returning ok: false here looks like it aborts after earlier candidates' socket.json changes were written, leaving the setup half-applied.

if (reason !== undefined) {
return {
ok: false,
message: `No .NET SDK runs in ${path.relative(cwd, dir) || '.'} (${reason})`,
cause:
'Install the .NET SDK, or point defaults.manifest.dotnet.bin at one; or leave the directory out with --exclude-paths.',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This remedy differs from the one in generate-recursive-manifests.mts: it omits the socket.json path and the defaults.manifest.dotnet.disabled option. A shared message builder next to noDotnetSdkReason would keep the two in sync.

}
}
}

for (const root of roots) {
Expand Down
24 changes: 24 additions & 0 deletions src/commands/manifest/setup-recursive-manifest-config.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ vi.mock('./discover-manifest-roots.mts', async importOriginal => {
vi.mock('./enumerate-workspaces.mts', () => ({
enumerateWorkspaces: vi.fn(),
}))
vi.mock('./scripts/run.mts', async importOriginal => ({
...(await importOriginal<typeof import('./scripts/run.mts')>()),
noDotnetSdkReason: vi.fn(async () => undefined),
}))
vi.mock('@socketsecurity/registry/lib/prompts', () => ({
select: vi.fn(),
}))
Expand All @@ -48,6 +52,7 @@ import {
findBuildToolCandidates,
} from './discover-manifest-roots.mts'
import { enumerateWorkspaces } from './enumerate-workspaces.mts'
import { noDotnetSdkReason } from './scripts/run.mts'
import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts'
import {
configureCandidate,
Expand Down Expand Up @@ -455,6 +460,25 @@ describe('markWorkspaceCoverage', () => {
expect(covered.size).toBe(0)
})

it('fails closed when no .NET SDK runs for a .NET root', async () => {
vi.mocked(noDotnetSdkReason).mockResolvedValueOnce('dotnet not found')

const result = await markWorkspaceCoverage({
covered: new Set<string>(),
cwd,
dir: reactor,
ecosystem: 'dotnet',
rootSockJson: emptySockJson(),
roots: [{ dir: reactor, entryFile: 'App.csproj' }],
})

expect(result).toMatchObject({
ok: false,
message: 'No .NET SDK runs in reactor (dotnet not found)',
})
expect(enumerateWorkspaces).not.toHaveBeenCalled()
})

it('resolves settings once per directory and skips a POM an earlier one covers', async () => {
vi.mocked(enumerateWorkspaces).mockResolvedValue({
buildRoot: reactor,
Expand Down
Loading
Loading