From 32ebd1c1c058c4ad41faf79bd5543a8b565fd593 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 30 Jul 2026 10:58:52 +0200 Subject: [PATCH 01/27] Add socket manifest dynamic-sbom-inference Recursively discovers independent gradle/sbt/maven build roots and generates a Socket facts SBOM for each, without re-invoking the manifest script on subproject/reactor-module directories a parent build root already covers. Maven reactors in particular have a pom.xml per module, so naive marker-file discovery would over-invoke; coverage is tracked per ecosystem using the facts SBOM's own projects[].subprojectDir, which every build-tool producer already reports. This is a standalone command rather than an --auto-manifest extension, so it can force stricter behavior later without touching auto's existing contract. Scoped narrowly for this first PR: discovery plus generation only, one global socket.json config applied to every discovered root (no per-build-root cascade), no new CLI flags beyond --exclude-paths/--verbose. Refs REA-685, REA-553 --- .../cmd-manifest-dynamic-sbom-inference.mts | 134 ++++++++++++++ ...d-manifest-dynamic-sbom-inference.test.mts | 76 ++++++++ src/commands/manifest/cmd-manifest.mts | 2 + src/commands/manifest/cmd-manifest.test.mts | 1 + .../manifest/discover-manifest-roots.mts | 115 ++++++++++++ .../manifest/discover-manifest-roots.test.mts | 104 +++++++++++ .../manifest/generate-recursive-manifests.mts | 169 +++++++++++++++++ .../generate-recursive-manifests.test.mts | 173 ++++++++++++++++++ ...handle-manifest-dynamic-sbom-inference.mts | 36 ++++ ...output-manifest-dynamic-sbom-inference.mts | 68 +++++++ src/commands/manifest/run-manifest-facts.mts | 9 +- .../monorepo/dual-marker-dir/build.gradle | 3 + .../monorepo/dual-marker-dir/pom.xml | 6 + .../moduleA/nested-gradle/build.gradle | 3 + .../monorepo/reactor/moduleA/pom.xml | 9 + .../moduleB/independent-submodule/pom.xml | 6 + .../monorepo/reactor/moduleB/pom.xml | 9 + .../monorepo/reactor/pom.xml | 11 ++ .../standalone-gradle/build.gradle.kts | 3 + .../standalone-gradle/settings.gradle.kts | 1 + 20 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts create mode 100644 src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts create mode 100644 src/commands/manifest/discover-manifest-roots.mts create mode 100644 src/commands/manifest/discover-manifest-roots.test.mts create mode 100644 src/commands/manifest/generate-recursive-manifests.mts create mode 100644 src/commands/manifest/generate-recursive-manifests.test.mts create mode 100644 src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts create mode 100644 src/commands/manifest/output-manifest-dynamic-sbom-inference.mts create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts create mode 100644 test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..8fcace4cfe --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -0,0 +1,134 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleManifestDynamicSbomInference } from './handle-manifest-dynamic-sbom-inference.mts' +import constants, { FLAG_JSON, FLAG_MARKDOWN } from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { excludePathsFlag } from '../scan/reachability-flags.mts' + +import type { + CliCommandConfig, + CliCommandContext, +} from '../../utils/meow-with-subcommands.mts' + +const config: CliCommandConfig = { + commandName: 'dynamic-sbom-inference', + description: + 'Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + ...excludePathsFlag, + verbose: { + type: 'boolean', + default: false, + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Recursively walks CWD, discovers independent gradle, sbt, and maven build + roots, and generates a Socket facts SBOM (.socket.facts.json) for each, + skipping subproject/reactor-module directories a parent build root already + covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. + + Options + ${getFlagListOutput(config.flags)} + + Examples + + $ ${command} + $ ${command} ./monorepo + `, +} + +export const cmdManifestDynamicSbomInference = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: CliCommandContext, +): Promise { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun, + json, + markdown, + verbose: verboseFlag, + } = cli.flags as { + dryRun: boolean + json: boolean + markdown: boolean + verbose: boolean | undefined + } + const verbose = !!verboseFlag + + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- target:', cwd) + logger.groupEnd() + } + + const outputKind = getOutputKind(json, markdown) + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + fail: `received ${cli.input.length}`, + }, + { + nook: true, + test: !json || !markdown, + message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + + if (dryRun) { + logger.log(constants.DRY_RUN_BAILING_NOW) + return + } + + await handleManifestDynamicSbomInference({ + cwd, + excludePaths, + outputKind, + verbose, + }) +} diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts new file mode 100644 index 0000000000..26b4cdd224 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -0,0 +1,76 @@ +import { describe, expect } from 'vitest' + +import constants, { + FLAG_CONFIG, + FLAG_DRY_RUN, + FLAG_HELP, +} from '../../../src/constants.mts' +import { cmdit, spawnSocketCli, testPath } from '../../../test/utils.mts' + +describe('socket manifest dynamic-sbom-inference', async () => { + const { binCliPath } = constants + + cmdit( + ['manifest', 'dynamic-sbom-inference', FLAG_HELP, FLAG_CONFIG, '{}'], + `should support ${FLAG_HELP}`, + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { + cwd: testPath, + }) + expect(stdout).toMatchInlineSnapshot(` + "Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each + + Usage + $ socket manifest dynamic-sbom-inference [options] [CWD=.] + + Recursively walks CWD, discovers independent gradle, sbt, and maven build + roots, and generates a Socket facts SBOM (.socket.facts.json) for each, + skipping subproject/reactor-module directories a parent build root already + covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. + + Options + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --json Output as JSON + --markdown Output as Markdown + --verbose Print debug messages + + Examples + + $ socket manifest dynamic-sbom-inference + $ socket manifest dynamic-sbom-inference ./monorepo" + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest dynamic-sbom-inference`', + ) + }, + ) + + cmdit( + ['manifest', 'dynamic-sbom-inference', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], + 'should exit with dry-run message before touching disk', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { + cwd: testPath, + }) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest.mts b/src/commands/manifest/cmd-manifest.mts index 518cdfddd7..fa94fe1b0d 100644 --- a/src/commands/manifest/cmd-manifest.mts +++ b/src/commands/manifest/cmd-manifest.mts @@ -2,6 +2,7 @@ import { cmdManifestBazel } from './bazel/cmd-manifest-bazel.mts' import { cmdManifestAuto } from './cmd-manifest-auto.mts' import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts' import { cmdManifestConda } from './cmd-manifest-conda.mts' +import { cmdManifestDynamicSbomInference } from './cmd-manifest-dynamic-sbom-inference.mts' import { cmdManifestGradle } from './cmd-manifest-gradle.mts' import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts' import { cmdManifestMaven } from './cmd-manifest-maven.mts' @@ -73,6 +74,7 @@ async function run( bazel: cmdManifestBazel, cdxgen: cmdManifestCdxgen, conda: cmdManifestConda, + 'dynamic-sbom-inference': cmdManifestDynamicSbomInference, gradle: cmdManifestGradle, kotlin: cmdManifestKotlin, maven: cmdManifestMaven, diff --git a/src/commands/manifest/cmd-manifest.test.mts b/src/commands/manifest/cmd-manifest.test.mts index 93c264770a..124258616b 100644 --- a/src/commands/manifest/cmd-manifest.test.mts +++ b/src/commands/manifest/cmd-manifest.test.mts @@ -27,6 +27,7 @@ describe('socket manifest', async () => { bazel [beta] Bazel SBOM support \\u2014 generate manifest files for a Bazel project (Maven, PyPI) cdxgen Run cdxgen for SBOM generation conda [beta] Convert a Conda environment.yml file to a python requirements.txt + dynamic-sbom-inference Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each gradle [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Gradle/Java/Kotlin/etc project kotlin [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Kotlin project maven [beta] Generate a Socket facts file from a Maven \`pom.xml\` project diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts new file mode 100644 index 0000000000..262712aa14 --- /dev/null +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -0,0 +1,115 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import { globWithGitIgnore } from '../../utils/glob.mts' +import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +const BUILD_TOOLS: BuildTool[] = ['gradle', 'maven', 'sbt'] + +// One glob-suffix set per ecosystem's build-descriptor marker(s); `settings.gradle(.kts)` +// covers Kotlin-DSL roots that have no root `build.gradle`. +const MARKERS_BY_TOOL: Record = { + __proto__: null, + gradle: [ + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts', + ], + maven: ['pom.xml'], + sbt: ['build.sbt'], +} as unknown as Record + +const TOOL_BY_MARKER: Record = { + __proto__: null, +} as unknown as Record +for (const tool of BUILD_TOOLS) { + for (const marker of MARKERS_BY_TOOL[tool]) { + TOOL_BY_MARKER[marker] = tool + } +} + +async function realpathOrResolved(dir: string): Promise { + try { + return await fs.realpath(dir) + } catch { + return path.resolve(dir) + } +} + +function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] { + return [...dirs].sort((a, b) => { + const relA = path.relative(cwd, a) + const relB = path.relative(cwd, b) + const depthA = relA === '' ? 0 : relA.split(path.sep).length + const depthB = relB === '' ? 0 : relB.split(path.sep).length + if (depthA !== depthB) { + return depthA - depthB + } + return relA < relB ? -1 : relA > relB ? 1 : 0 + }) +} + +// Recursively discovers gradle/sbt/maven build-tool roots under `cwd`, one +// filesystem pass for all three ecosystems' marker files (fast-glob unions the +// patterns internally). Each ecosystem's candidate list is depth-sorted +// (root-most first) so a reactor/multi-project root is always visited before +// its own members; the caller uses that ordering plus the resulting facts +// SBOM's `projects[].subprojectDir` to avoid re-invoking the manifest script on +// directories a parent build root already covers. +export async function findBuildToolCandidates({ + cwd, + excludePaths, + sockJson, +}: { + cwd: string + excludePaths?: string[] | undefined + sockJson: SocketJson +}): Promise> { + const enabledTools = BUILD_TOOLS.filter( + tool => !sockJson.defaults?.manifest?.[tool]?.disabled, + ) + const result = new Map() + if (!enabledTools.length) { + return result + } + + const patterns = enabledTools.flatMap(tool => + MARKERS_BY_TOOL[tool].map(marker => `**/${marker}`), + ) + const additionalIgnores = (excludePaths ?? []).flatMap( + excludePathToScanIgnores, + ) + const hits = await globWithGitIgnore(patterns, { + absolute: true, + additionalIgnores, + cwd, + }) + + const dirSetsByTool = new Map>( + enabledTools.map(tool => [tool, new Set()]), + ) + const resolvedHits = await Promise.all( + hits.map(async hit => ({ + dir: await realpathOrResolved(path.dirname(hit)), + tool: TOOL_BY_MARKER[path.basename(hit)], + })), + ) + for (const { dir, tool } of resolvedHits) { + if (tool) { + dirSetsByTool.get(tool)?.add(dir) + } + } + + const realCwd = await realpathOrResolved(cwd) + for (const tool of enabledTools) { + result.set( + tool, + sortByDepthThenPath([...(dirSetsByTool.get(tool) ?? [])], realCwd), + ) + } + return result +} diff --git a/src/commands/manifest/discover-manifest-roots.test.mts b/src/commands/manifest/discover-manifest-roots.test.mts new file mode 100644 index 0000000000..206e988d24 --- /dev/null +++ b/src/commands/manifest/discover-manifest-roots.test.mts @@ -0,0 +1,104 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { testPath } from '../../../test/utils.mts' + +import type { SocketJson } from '../../utils/socket-json.mts' + +const monorepo = path.join( + testPath, + 'fixtures/commands/manifest/dynamic-sbom-inference/monorepo', +) + +function relDirs(dirs: string[]): string[] { + return dirs.map(d => path.relative(monorepo, d).replaceAll('\\', '/')).sort() +} + +describe('findBuildToolCandidates', () => { + it('discovers maven, gradle, and sbt candidates in depth order', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + + expect(relDirs(candidates.get('maven') ?? [])).toEqual( + [ + 'dual-marker-dir', + 'reactor', + 'reactor/moduleA', + 'reactor/moduleB', + 'reactor/moduleB/independent-submodule', + ].sort(), + ) + expect(relDirs(candidates.get('gradle') ?? [])).toEqual( + [ + 'dual-marker-dir', + 'reactor/moduleA/nested-gradle', + 'standalone-gradle', + ].sort(), + ) + expect(candidates.get('sbt') ?? []).toEqual([]) + }) + + it('lists a reactor root before its own members (depth-ascending)', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + const maven = candidates.get('maven') ?? [] + const rootIndex = maven.findIndex(d => d === path.join(monorepo, 'reactor')) + const moduleAIndex = maven.findIndex( + d => d === path.join(monorepo, 'reactor/moduleA'), + ) + expect(rootIndex).toBeGreaterThanOrEqual(0) + expect(moduleAIndex).toBeGreaterThan(rootIndex) + }) + + it('includes a gradle project nested inside a maven candidate directory tree', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + expect(candidates.get('gradle')).toContain( + path.join(monorepo, 'reactor/moduleA/nested-gradle'), + ) + }) + + it('lists a dual-marker directory on both ecosystems', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + const dualDir = path.join(monorepo, 'dual-marker-dir') + expect(candidates.get('maven')).toContain(dualDir) + expect(candidates.get('gradle')).toContain(dualDir) + }) + + it('drops a disabled ecosystem entirely', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: { + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson, + }) + expect(candidates.has('maven')).toBe(false) + expect(candidates.get('gradle')?.length).toBeGreaterThan(0) + }) + + it('respects --exclude-paths', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + excludePaths: ['reactor'], + sockJson: {} as SocketJson, + }) + expect(candidates.get('maven')).toEqual([ + path.join(monorepo, 'dual-marker-dir'), + ]) + expect(candidates.get('gradle')).toEqual([ + path.join(monorepo, 'dual-marker-dir'), + path.join(monorepo, 'standalone-gradle'), + ]) + }) +}) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts new file mode 100644 index 0000000000..2ed2c9e635 --- /dev/null +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -0,0 +1,169 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { runManifestFacts } from './run-manifest-facts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +export type RecursiveManifestOutcomeStatus = + | 'empty' + | 'failed' + | 'generated' + | 'skippedCovered' + +export type RecursiveManifestOutcome = { + dir: string + ecosystem: BuildTool + factsPath?: string | undefined + status: RecursiveManifestOutcomeStatus +} + +type EcosystemBuildConfig = { + bin: string + buildOpts: string[] + excludeConfigs: string + ignoreUnresolved: boolean + includeConfigs: string +} + +// Resolves the single, global per-ecosystem build-tool config (socket.json +// `defaults.manifest.`) applied uniformly to every discovered root +// of that ecosystem. There is no per-build-root cascade yet (tracked +// separately under REA-553); a wrapper-preferred `bin` default is still +// resolved per-root (`dir`, not `cwd`) since a wrapper script only exists at +// the actual build root, not necessarily at the overall recursion root. +function resolveEcosystemConfig( + ecosystem: BuildTool, + dir: string, + sockJson: SocketJson, +): EcosystemBuildConfig { + if (ecosystem === 'sbt') { + const config = sockJson.defaults?.manifest?.sbt + return { + bin: config?.bin ?? 'sbt', + buildOpts: parseBuildToolOpts(config?.sbtOpts), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + } + } + if (ecosystem === 'gradle') { + const config = sockJson.defaults?.manifest?.gradle + return { + bin: config?.bin + ? path.resolve(dir, config.bin) + : resolveBuildToolBin('gradle', dir), + buildOpts: parseBuildToolOpts(config?.gradleOpts), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + } + } + const config = sockJson.defaults?.manifest?.maven + return { + bin: config?.bin ?? resolveBuildToolBin('maven', dir), + buildOpts: parseBuildToolOpts(config?.mavenOpts), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + } +} + +// Recursively discovers gradle/sbt/maven build roots under `cwd` and +// generates one `.socket.facts.json` per independent build root. Coverage is +// tracked per ecosystem (not globally) using the facts SBOM's own +// `projects[].subprojectDir` — never by pruning an entire discovered +// subtree — so a reactor/multi-project member is skipped on re-encounter +// while an unrelated nested project the reactor doesn't declare (e.g. a +// stray git-submodule pom, or a different-ecosystem project nested inside a +// covered directory tree) still gets its own invocation. A failure at one +// root does not stop discovery/generation at sibling roots. +export async function generateRecursiveManifests({ + cwd, + excludePaths, + verbose, +}: { + cwd: string + excludePaths?: string[] | undefined + verbose: boolean +}): Promise { + const sockJson = readOrDefaultSocketJson(cwd) + const candidatesByTool = await findBuildToolCandidates({ + cwd, + excludePaths, + sockJson, + }) + + const outcomes: RecursiveManifestOutcome[] = [] + for (const [ecosystem, dirs] of candidatesByTool) { + const covered = new Set() + for (const dir of dirs) { + if (covered.has(dir)) { + outcomes.push({ dir, ecosystem, status: 'skippedCovered' }) + continue + } + + const { + bin, + buildOpts, + excludeConfigs, + ignoreUnresolved, + includeConfigs, + } = resolveEcosystemConfig(ecosystem, dir, sockJson) + const excludePathsForRoot = projectIgnorePathsToReachExcludePaths( + excludePaths, + { cwd, target: dir }, + ) + + const beforeExitCode = process.exitCode + // eslint-disable-next-line no-await-in-loop + const result = await runManifestFacts({ + bin, + buildOpts, + cwd: dir, + ecosystem, + excludeConfigs, + excludePaths: excludePathsForRoot, + ignoreUnresolved, + includeConfigs, + verbose, + }) + + if (!result) { + const failed = Boolean( + process.exitCode && process.exitCode !== beforeExitCode, + ) + outcomes.push({ + dir, + ecosystem, + status: failed ? 'failed' : 'empty', + }) + continue + } + + covered.add(dir) + for (const project of result.projects) { + covered.add(path.resolve(dir, project.subprojectDir)) + } + outcomes.push({ + dir, + ecosystem, + factsPath: result.factsPath, + status: 'generated', + }) + } + } + + if (verbose) { + logger.info(`Discovered ${outcomes.length} build-tool candidate(s).`) + } + + return outcomes +} diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts new file mode 100644 index 0000000000..dadd894b17 --- /dev/null +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -0,0 +1,173 @@ +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../utils/socket-json.mts', () => ({ + readOrDefaultSocketJson: vi.fn(() => ({})), +})) +vi.mock('./run-manifest-facts.mts', () => ({ + runManifestFacts: vi.fn(), +})) + +import { generateRecursiveManifests } from './generate-recursive-manifests.mts' +import { runManifestFacts } from './run-manifest-facts.mts' +import { testPath } from '../../../test/utils.mts' + +const monorepo = path.join( + testPath, + 'fixtures/commands/manifest/dynamic-sbom-inference/monorepo', +) +const reactor = path.join(monorepo, 'reactor') +const dualMarkerDir = path.join(monorepo, 'dual-marker-dir') + +function relOf(dir: string): string { + return path.relative(monorepo, dir).replaceAll('\\', '/') +} + +describe('generateRecursiveManifests', () => { + beforeEach(() => { + vi.mocked(runManifestFacts).mockReset() + }) + afterEach(() => { + process.exitCode = undefined + }) + + it('invokes the reactor root once and skips its declared members, but still visits an undeclared nested submodule and a differently-tooled nested project', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (cwd === reactor && ecosystem === 'maven') { + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'moduleA', + subprojectDir: 'moduleA', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'moduleB', + subprojectDir: 'moduleB', + dependencies: [], + resolvedAs: [], + }, + ], + } + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const calledDirs = vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => `${opts.ecosystem}:${relOf(opts.cwd)}`) + .sort() + expect(calledDirs).toEqual( + [ + 'gradle:dual-marker-dir', + 'gradle:reactor/moduleA/nested-gradle', + 'gradle:standalone-gradle', + 'maven:dual-marker-dir', + 'maven:reactor', + 'maven:reactor/moduleB/independent-submodule', + ].sort(), + ) + + const skipped = outcomes + .filter(o => o.status === 'skippedCovered') + .map(o => `${o.ecosystem}:${relOf(o.dir)}`) + .sort() + expect(skipped).toEqual( + ['maven:reactor/moduleA', 'maven:reactor/moduleB'].sort(), + ) + }) + + 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'), + projects: [], + })) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const atDualMarkerDir = outcomes.filter(o => o.dir === dualMarkerDir) + expect(atDualMarkerDir.map(o => o.ecosystem).sort()).toEqual([ + 'gradle', + 'maven', + ]) + expect(atDualMarkerDir.every(o => o.status === 'generated')).toBe(true) + }) + + it('continues to sibling roots in the same ecosystem after one root fails', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (ecosystem === 'maven' && cwd === dualMarkerDir) { + process.exitCode = 1 + return undefined + } + if (cwd === reactor && ecosystem === 'maven') { + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'moduleA', + subprojectDir: 'moduleA', + dependencies: [], + resolvedAs: [], + }, + ], + } + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('failed') + // A failure at one maven root must not stop later maven roots from being attempted. + expect(byKey.get('maven:reactor')).toBe('generated') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'generated', + ) + }) + + it('reports a non-fatal empty result distinctly from a failure', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (ecosystem === 'maven' && cwd === dualMarkerDir) { + // No resolvable dependencies; runManifestFacts warns but does not fail. + return undefined + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('empty') + }) +}) diff --git a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..7e46495c3e --- /dev/null +++ b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts @@ -0,0 +1,36 @@ +import { generateRecursiveManifests } from './generate-recursive-manifests.mts' +import { outputManifestDynamicSbomInference } from './output-manifest-dynamic-sbom-inference.mts' + +import type { RecursiveManifestOutcome } from './generate-recursive-manifests.mts' +import type { CResult, OutputKind } from '../../types.mts' + +export async function handleManifestDynamicSbomInference({ + cwd, + excludePaths, + outputKind, + verbose, +}: { + cwd: string + excludePaths: string[] + outputKind: OutputKind + verbose: boolean +}): Promise { + const outcomes = await generateRecursiveManifests({ + cwd, + excludePaths, + verbose, + }) + + const result: CResult = outcomes.some( + o => o.status === 'failed', + ) + ? { + ok: false, + code: 1, + message: 'One or more build roots failed to generate Socket facts.', + data: outcomes, + } + : { ok: true, data: outcomes } + + await outputManifestDynamicSbomInference(result, outputKind) +} diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..c10a385607 --- /dev/null +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -0,0 +1,68 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { RecursiveManifestOutcome } from './generate-recursive-manifests.mts' +import type { CResult, OutputKind } from '../../types.mts' + +function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { + return outcomes + .map( + o => + `- ${o.dir} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`, + ) + .join('\n') +} + +function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { + const generated = outcomes.filter(o => o.status === 'generated').length + const failed = outcomes.filter(o => o.status === 'failed').length + const skipped = outcomes.filter(o => o.status === 'skippedCovered').length + const empty = outcomes.filter(o => o.status === 'empty').length + const roots = new Set(outcomes.map(o => o.dir)).size + return ( + `Generated ${generated} Socket facts file(s) across ${roots} build root(s); ` + + `${failed} failed, ${skipped} skipped (already covered), ${empty} empty.` + ) +} + +export async function outputManifestDynamicSbomInference( + result: CResult, + outputKind: OutputKind, +): Promise { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + const data = result.data as RecursiveManifestOutcome[] | undefined + if (Array.isArray(data)) { + logger.log(renderTable(data)) + logger.log(summarize(data)) + } + return + } + + if (outputKind === 'markdown') { + logger.log( + [ + '# Dynamic SBOM inference', + '', + renderTable(result.data), + '', + summarize(result.data), + ].join('\n'), + ) + return + } + + logger.log(renderTable(result.data)) + logger.log(summarize(result.data)) +} diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 42bcc416a2..e4a4c098cd 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -9,11 +9,17 @@ import { accumulateSidecar } from './scripts/sidecar.mts' import constants from '../../constants.mts' import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketFactsSbomProject } from './scripts/facts.mts' import type { ManifestRunResult } from './scripts/run.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' const MAX_FAILURE_OUTPUT_LINES = 40 +export type RunManifestFactsResult = { + factsPath: string + projects: SocketFactsSbomProject[] +} + // Last N non-empty lines of the captured build output, for diagnosing a crash // without forcing a --verbose rebuild. function tailBuildOutput(stdout: string, stderr: string): string { @@ -57,7 +63,7 @@ export async function runManifestFacts({ tmpDir?: string | undefined verbose: boolean withFiles?: boolean | undefined -}): Promise { +}): Promise { const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) logger.log( @@ -189,4 +195,5 @@ export async function runManifestFacts({ } logger.success('Generated Socket facts') + return { factsPath, projects: facts.projects ?? [] } } diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle new file mode 100644 index 0000000000..075ba3d563 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java' +} diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml new file mode 100644 index 0000000000..a2405658c6 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + dual-marker-dir + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle new file mode 100644 index 0000000000..075ba3d563 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java' +} diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml new file mode 100644 index 0000000000..64a4821efe --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + reactor + 1.0.0 + + moduleA + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml new file mode 100644 index 0000000000..d3cdbf46ae --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + independent-submodule + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml new file mode 100644 index 0000000000..a4b77f171b --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + reactor + 1.0.0 + + moduleB + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml new file mode 100644 index 0000000000..98b6e4f97f --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + com.example + reactor + 1.0.0 + pom + + moduleA + moduleB + + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts new file mode 100644 index 0000000000..3b371f30bb --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + java +} diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts new file mode 100644 index 0000000000..5aff8a1e56 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "standalone-gradle" From ed803ad0c1938c4362fdd72b7247030e25748793 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 10:50:40 +0200 Subject: [PATCH 02/27] Fix misleading --exclude-paths description on manifest commands --exclude-paths on socket manifest auto/gradle/kotlin/maven/scala/ dynamic-sbom-inference reused the flag description from the scan/reach flag definitions verbatim, which talks about "the scan" and full application reachability analysis. None of these commands scan or run reachability analysis themselves, so the wording was confusing when viewed via --help on any of them standalone. Adds a manifest-scoped excludePathsFlag with wording specific to manifest/facts generation and switches all six commands to it. --- src/commands/manifest/cmd-manifest-auto.mts | 2 +- src/commands/manifest/cmd-manifest-auto.test.mts | 6 +++--- .../cmd-manifest-dynamic-sbom-inference.mts | 2 +- .../cmd-manifest-dynamic-sbom-inference.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-gradle.mts | 2 +- src/commands/manifest/cmd-manifest-gradle.test.mts | 8 ++++---- src/commands/manifest/cmd-manifest-kotlin.mts | 2 +- src/commands/manifest/cmd-manifest-kotlin.test.mts | 8 ++++---- src/commands/manifest/cmd-manifest-maven.mts | 2 +- src/commands/manifest/cmd-manifest-maven.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-scala.mts | 2 +- src/commands/manifest/cmd-manifest-scala.test.mts | 8 ++++---- src/commands/manifest/manifest-flags.mts | 14 ++++++++++++++ 13 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 src/commands/manifest/manifest-flags.mts diff --git a/src/commands/manifest/cmd-manifest-auto.mts b/src/commands/manifest/cmd-manifest-auto.mts index 1289265531..e8451ba575 100644 --- a/src/commands/manifest/cmd-manifest-auto.mts +++ b/src/commands/manifest/cmd-manifest-auto.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { detectManifestActions } from './detect-manifest-actions.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' +import { excludePathsFlag } from './manifest-flags.mts' import constants from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' @@ -14,7 +15,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index a6410f7e03..3403ee69f1 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -23,7 +23,7 @@ describe('socket manifest auto', async () => { $ socket manifest auto [options] [CWD=.] Options - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --verbose Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors Tries to figure out what language your target repo uses. If it finds a @@ -42,7 +42,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) @@ -63,7 +63,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index 8fcace4cfe..f80fe072fd 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { handleManifestDynamicSbomInference } from './handle-manifest-dynamic-sbom-inference.mts' +import { excludePathsFlag } from './manifest-flags.mts' import constants, { FLAG_JSON, FLAG_MARKDOWN } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -11,7 +12,6 @@ import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 26b4cdd224..28baf2e573 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -29,7 +29,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. Options - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --json Output as JSON --markdown Output as Markdown --verbose Print debug messages @@ -42,7 +42,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) @@ -65,7 +65,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 650a8f5cdc..3ed3f95727 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' @@ -16,7 +17,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index a0efb72a69..1eb8c8bb62 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -25,7 +25,7 @@ describe('socket manifest gradle', async () => { Options --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) @@ -65,7 +65,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 1b5bf6b650..460af5e54c 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' @@ -16,7 +17,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 4b55415550..47c4d7d295 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -25,7 +25,7 @@ describe('socket manifest kotlin', async () => { Options --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) @@ -65,7 +65,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index f82a7ed86a..1b80f44658 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -4,6 +4,7 @@ import { debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { convertMavenToFacts } from './convert-maven-to-facts.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { SOCKET_JSON } from '../../constants.mts' @@ -15,7 +16,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 57412e4263..54c48c19b5 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -24,7 +24,7 @@ describe('socket manifest maven', async () => { Options --bin Location of the maven binary to use, default: ./mvnw if present, else mvn on PATH --exclude-configs Comma-separated glob patterns; Maven scopes matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs Comma-separated glob patterns matched against Maven dependency scopes (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only scopes matching at least one pattern are resolved. e.g. \`compile,runtime\`. Default: every scope --maven-opts Additional options to pass on to maven, e.g. \`-P -s \` @@ -53,7 +53,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) @@ -74,7 +74,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 3ab01345e4..12635f1f3c 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertSbtToFacts } from './convert-sbt-to-facts.mts' import { convertSbtToMaven } from './convert_sbt_to_maven.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' @@ -16,7 +17,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 38131d70ee..2acf7bd31a 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -25,7 +25,7 @@ describe('socket manifest scala', async () => { Options --bin Location of sbt binary to use --exclude-configs When generating facts: comma-separated glob patterns; sbt configurations matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs When generating facts: comma-separated glob patterns matched against sbt configuration names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`compile,test\`. Default: compile,optional,provided,runtime,test @@ -79,7 +79,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -100,7 +100,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -118,7 +118,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) diff --git a/src/commands/manifest/manifest-flags.mts b/src/commands/manifest/manifest-flags.mts new file mode 100644 index 0000000000..dfb656ee54 --- /dev/null +++ b/src/commands/manifest/manifest-flags.mts @@ -0,0 +1,14 @@ +import type { MeowFlags } from '../../flags.mts' + +// A manifest-scoped variant of `../scan/reachability-flags.mts`'s +// `excludePathsFlag`: these commands only ever generate a manifest/facts +// file, so the description must not reference "the scan" or reachability +// analysis, which don't apply when a manifest command is run standalone. +export const excludePathsFlag: MeowFlags = { + excludePaths: { + type: 'string', + isMultiple: true, + description: + 'List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (`--cwd` if set): `tests` matches only `/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + }, +} From 7072d24ea9b547a64f52e6f517e5612711ebef41 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 11:10:48 +0200 Subject: [PATCH 03/27] Add per-ecosystem javaHome config for gradle/maven/sbt manifest generation Different projects in a repo may need different JDKs for their build tool. Adds a javaHome field to defaults.manifest.{gradle,maven,sbt} in socket.json, configurable via the socket manifest setup wizard, and threads it through to the actual build-tool invocation (overrides JAVA_HOME for that spawn only, everything else about the environment is left untouched). Applies to socket manifest gradle/kotlin/maven/scala, socket manifest auto, and socket manifest dynamic-sbom-inference. Scoped to the Socket facts generation path only, not the legacy --pom conversion path. No new CLI flag - socket.json/the setup wizard is the only configuration surface for now. --- src/commands/manifest/cmd-manifest-gradle.mts | 4 ++ src/commands/manifest/cmd-manifest-kotlin.mts | 4 ++ src/commands/manifest/cmd-manifest-maven.mts | 4 ++ src/commands/manifest/cmd-manifest-scala.mts | 4 ++ .../manifest/convert-gradle-to-facts.mts | 3 ++ .../manifest/convert-maven-to-facts.mts | 3 ++ .../manifest/convert-sbt-to-facts.mts | 3 ++ .../manifest/generate-recursive-manifests.mts | 6 +++ .../manifest/generate_auto_manifest.mts | 3 ++ src/commands/manifest/run-manifest-facts.mts | 4 ++ .../manifest/setup-manifest-config.mts | 37 +++++++++++++++++++ src/utils/socket-json.mts | 6 +++ 12 files changed, 81 insertions(+) diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 3ed3f95727..dc7aada77d 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -276,10 +276,13 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome + if (verbose) { logger.group() logger.info('- cwd:', cwd) logger.info('- gradle bin:', bin) + logger.info('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -302,6 +305,7 @@ async function run( gradleOpts: parsedGradleOpts, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, verbose: Boolean(verbose), }) return diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 460af5e54c..fd47f6508d 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -279,10 +279,13 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome + if (verbose) { logger.group() logger.info('- cwd:', cwd) logger.info('- gradle bin:', bin) + logger.info('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -305,6 +308,7 @@ async function run( gradleOpts: parsedGradleOpts, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, verbose: Boolean(verbose), }) return diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index 1b80f44658..8a2465cadb 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -214,10 +214,13 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.maven?.javaHome + if (verbose) { logger.group() logger.info('- cwd:', cwd) logger.info('- maven bin:', bin) + logger.info('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -238,6 +241,7 @@ async function run( excludePaths, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, mavenOpts: parsedMavenOpts, verbose: Boolean(verbose), }) diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 12635f1f3c..01188c782c 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -330,11 +330,14 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.sbt?.javaHome + if (verbose) { logger.group() logger.log('- target:', cwd) logger.log('- sbt bin:', bin) logger.log('- out:', out) + logger.log('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -357,6 +360,7 @@ async function run( excludePaths, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, sbtOpts: parsedSbtOpts, tmpDir, verbose: Boolean(verbose), diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 011073fadf..7f8f852ab1 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -11,6 +11,7 @@ export async function convertGradleToFacts({ gradleOpts, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, verbose, withFiles, @@ -22,6 +23,7 @@ export async function convertGradleToFacts({ gradleOpts: string[] ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined sidecarAcc?: SidecarAccumulator | undefined verbose: boolean withFiles?: boolean | undefined @@ -35,6 +37,7 @@ export async function convertGradleToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, verbose, withFiles, diff --git a/src/commands/manifest/convert-maven-to-facts.mts b/src/commands/manifest/convert-maven-to-facts.mts index a7f9c24720..a41769fcfe 100644 --- a/src/commands/manifest/convert-maven-to-facts.mts +++ b/src/commands/manifest/convert-maven-to-facts.mts @@ -10,6 +10,7 @@ export async function convertMavenToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, mavenOpts, sidecarAcc, verbose, @@ -21,6 +22,7 @@ export async function convertMavenToFacts({ excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined mavenOpts: string[] sidecarAcc?: SidecarAccumulator | undefined verbose: boolean @@ -35,6 +37,7 @@ export async function convertMavenToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, verbose, withFiles, diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index a6f4f77acc..649b684441 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -12,6 +12,7 @@ export async function convertSbtToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sbtOpts, sidecarAcc, tmpDir, @@ -24,6 +25,7 @@ export async function convertSbtToFacts({ excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined sbtOpts: string[] sidecarAcc?: SidecarAccumulator | undefined // Caller-owned; see ManifestScriptOptions.tmpDir. @@ -40,6 +42,7 @@ export async function convertSbtToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, tmpDir, verbose, diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 2ed2c9e635..bf36f98f89 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -31,6 +31,7 @@ type EcosystemBuildConfig = { excludeConfigs: string ignoreUnresolved: boolean includeConfigs: string + javaHome: string | undefined } // Resolves the single, global per-ecosystem build-tool config (socket.json @@ -52,6 +53,7 @@ function resolveEcosystemConfig( excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome, } } if (ecosystem === 'gradle') { @@ -64,6 +66,7 @@ function resolveEcosystemConfig( excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome, } } const config = sockJson.defaults?.manifest?.maven @@ -73,6 +76,7 @@ function resolveEcosystemConfig( excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome, } } @@ -116,6 +120,7 @@ export async function generateRecursiveManifests({ excludeConfigs, ignoreUnresolved, includeConfigs, + javaHome, } = resolveEcosystemConfig(ecosystem, dir, sockJson) const excludePathsForRoot = projectIgnorePathsToReachExcludePaths( excludePaths, @@ -133,6 +138,7 @@ export async function generateRecursiveManifests({ excludePaths: excludePathsForRoot, ignoreUnresolved, includeConfigs, + javaHome, verbose, }) diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 96fc74bdd6..648b363696 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -104,6 +104,7 @@ export async function generateAutoManifest({ sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, ), includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '', + javaHome: sockJson.defaults?.manifest?.sbt?.javaHome, sidecarAcc, tmpDir, withFiles: computeArtifactsSidecar, @@ -149,6 +150,7 @@ export async function generateAutoManifest({ ), includeConfigs: sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '', + javaHome: sockJson.defaults?.manifest?.gradle?.javaHome, sidecarAcc, withFiles: computeArtifactsSidecar, }) @@ -178,6 +180,7 @@ export async function generateAutoManifest({ sockJson.defaults?.manifest?.maven?.ignoreUnresolved, ), includeConfigs: sockJson.defaults?.manifest?.maven?.includeConfigs ?? '', + javaHome: sockJson.defaults?.manifest?.maven?.javaHome, mavenOpts: parseBuildToolOpts( sockJson.defaults?.manifest?.maven?.mavenOpts, ), diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index e4a4c098cd..ab29f3ff5f 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -45,6 +45,7 @@ export async function runManifestFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, tmpDir, verbose, @@ -58,6 +59,7 @@ export async function runManifestFacts({ excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined sidecarAcc?: SidecarAccumulator | undefined // sbt only; see ManifestScriptOptions.tmpDir. tmpDir?: string | undefined @@ -74,6 +76,8 @@ export async function runManifestFacts({ bin: bin || undefined, excludeConfigs: excludeConfigs || undefined, excludePaths: excludePaths?.length ? excludePaths : undefined, + // `env` replaces the spawned process's whole environment, not just JAVA_HOME. + env: javaHome ? { ...process.env, JAVA_HOME: javaHome } : undefined, includeConfigs: includeConfigs || undefined, projectDir: cwd, // Stream the build tool's output only when asked; otherwise capture it and diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index 1e13a0f90a..a3ec92a6f2 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -283,6 +283,15 @@ async function setupGradle( delete config.bin } + const javaHome = await askForJavaHome(config.javaHome || '') + if (javaHome === undefined) { + return canceledByUser() + } else if (javaHome) { + config.javaHome = javaHome + } else { + delete config.javaHome + } + const opts = await input({ message: '(--gradle-opts) Enter gradle options to pass through', default: config.gradleOpts || '', @@ -341,6 +350,15 @@ async function setupMaven( delete config.bin } + const javaHome = await askForJavaHome(config.javaHome || '') + if (javaHome === undefined) { + return canceledByUser() + } else if (javaHome) { + config.javaHome = javaHome + } else { + delete config.javaHome + } + const opts = await input({ message: '(--maven-opts) Enter maven options to pass through', default: config.mavenOpts || '', @@ -387,6 +405,15 @@ async function setupSbt( delete config.bin } + const javaHome = await askForJavaHome(config.javaHome || '') + if (javaHome === undefined) { + return canceledByUser() + } else if (javaHome) { + config.javaHome = javaHome + } else { + delete config.javaHome + } + const opts = await input({ message: '(--sbt-opts) Enter sbt options to pass through', default: config.sbtOpts || '', @@ -551,6 +578,16 @@ async function askForBin(defaultName = ''): Promise { }) } +async function askForJavaHome(defaultName = ''): Promise { + return await input({ + message: + 'What JDK should this build tool use? Leave blank to use the JDK already on PATH/JAVA_HOME.' + + (defaultName ? ' (Backspace to leave default)' : ''), + default: defaultName, + required: false, + }) +} + async function askForVerboseFlag( current: boolean | undefined, ): Promise { diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 3cbbfee23f..32dae98179 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -67,6 +67,8 @@ export interface SocketJson { facts?: boolean | undefined gradleOpts?: string | undefined ignoreUnresolved?: boolean | undefined + // Absolute JDK path; sets JAVA_HOME for this ecosystem's build tool. + javaHome?: string | undefined verbose?: boolean | undefined } maven?: { @@ -75,6 +77,8 @@ export interface SocketJson { excludeConfigs?: string | undefined includeConfigs?: string | undefined ignoreUnresolved?: boolean | undefined + // Absolute JDK path; sets JAVA_HOME for this ecosystem's build tool. + javaHome?: string | undefined mavenOpts?: string | undefined verbose?: boolean | undefined } @@ -87,6 +91,8 @@ export interface SocketJson { includeConfigs?: string | undefined facts?: boolean | undefined ignoreUnresolved?: boolean | undefined + // Absolute JDK path; sets JAVA_HOME for this ecosystem's build tool. + javaHome?: string | undefined outfile?: string | undefined sbtOpts?: string | undefined stdout?: boolean | undefined From 3f5bdefb0b453b45eb7bad8668690bfc3217304a Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 11:25:04 +0200 Subject: [PATCH 04/27] Resolve socket.json per build root, and support env var refs in javaHome socket manifest dynamic-sbom-inference previously read socket.json once at the overall recursion root and applied that single config to every discovered build root. It now resolves each build root's own nearest socket.json (walking up from that root, bounded at the recursion root, nearest wins - no merging), so different projects in the same repo can carry their own settings instead of being forced onto one shared config. Also adds $VAR/${VAR} expansion for javaHome, resolved against the CLI process's own environment. A hardcoded absolute JDK path only works on whoever's machine wrote it; referencing an env var each developer sets themselves (e.g. $JAVA11_HOME) makes a shared socket.json portable across machines. Fails closed with a clear message if the referenced variable isn't set, rather than silently passing a broken path to the build tool. --- .../manifest/generate-recursive-manifests.mts | 10 +- .../generate-recursive-manifests.test.mts | 36 +++++++ src/commands/manifest/run-manifest-facts.mts | 34 ++++++- .../manifest/run-manifest-facts.test.mts | 99 +++++++++++++++++++ .../manifest/setup-manifest-config.mts | 2 +- src/utils/socket-json.mts | 32 +++++- src/utils/socket-json.test.mts | 76 ++++++++++++++ 7 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 src/commands/manifest/run-manifest-facts.test.mts create mode 100644 src/utils/socket-json.test.mts diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index bf36f98f89..83ed43e7cb 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -6,7 +6,10 @@ import { findBuildToolCandidates } from './discover-manifest-roots.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' -import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { + readOrDefaultSocketJson, + readOrDefaultSocketJsonUpTo, +} from '../../utils/socket-json.mts' import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' @@ -98,11 +101,11 @@ export async function generateRecursiveManifests({ excludePaths?: string[] | undefined verbose: boolean }): Promise { - const sockJson = readOrDefaultSocketJson(cwd) + const rootSockJson = readOrDefaultSocketJson(cwd) const candidatesByTool = await findBuildToolCandidates({ cwd, excludePaths, - sockJson, + sockJson: rootSockJson, }) const outcomes: RecursiveManifestOutcome[] = [] @@ -114,6 +117,7 @@ export async function generateRecursiveManifests({ continue } + const sockJson = readOrDefaultSocketJsonUpTo(dir, cwd, rootSockJson) const { bin, buildOpts, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index dadd894b17..772661889b 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -4,6 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../utils/socket-json.mts', () => ({ readOrDefaultSocketJson: vi.fn(() => ({})), + // Default: no per-root override found, fall back to the root config - matches + // there being no nested socket.json anywhere in the fixture tree. + readOrDefaultSocketJsonUpTo: vi.fn( + (_dir, _boundaryDir, fallback) => fallback, + ), })) vi.mock('./run-manifest-facts.mts', () => ({ runManifestFacts: vi.fn(), @@ -12,6 +17,7 @@ vi.mock('./run-manifest-facts.mts', () => ({ import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { testPath } from '../../../test/utils.mts' +import { readOrDefaultSocketJsonUpTo } from '../../utils/socket-json.mts' const monorepo = path.join( testPath, @@ -27,6 +33,9 @@ function relOf(dir: string): string { describe('generateRecursiveManifests', () => { beforeEach(() => { vi.mocked(runManifestFacts).mockReset() + vi.mocked(readOrDefaultSocketJsonUpTo).mockImplementation( + (_dir, _boundaryDir, fallback) => fallback, + ) }) afterEach(() => { process.exitCode = undefined @@ -170,4 +179,31 @@ describe('generateRecursiveManifests', () => { ) expect(byKey.get('maven:dual-marker-dir')).toBe('empty') }) + + it('resolves each build root its own nearest socket.json instead of only the root config', async () => { + vi.mocked(readOrDefaultSocketJsonUpTo).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + + const javaHomeByCall = new Map( + vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => [ + `${opts.ecosystem}:${relOf(opts.cwd)}`, + opts.javaHome, + ]), + ) + expect(javaHomeByCall.get('maven:dual-marker-dir')).toBe('/opt/jdk-11') + expect(javaHomeByCall.get('gradle:dual-marker-dir')).toBeUndefined() + expect(javaHomeByCall.get('maven:reactor')).toBeUndefined() + }) }) diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index ab29f3ff5f..e7527bfee7 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -14,12 +14,29 @@ import type { ManifestRunResult } from './scripts/run.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' const MAX_FAILURE_OUTPUT_LINES = 40 +const ENV_VAR_REF = /\$\{(\w+)\}|\$(\w+)/g export type RunManifestFactsResult = { factsPath: string projects: SocketFactsSbomProject[] } +// Expands `$VAR`/`${VAR}` references (e.g. a team-shared `javaHome: +// "$JAVA11_HOME"`) against the CLI process's own environment, so a socket.json +// value works across machines instead of hardcoding one developer's path. +function expandEnvVarRefs(value: string): { missing?: string; value: string } { + let missing: string | undefined + const expanded = value.replace(ENV_VAR_REF, (_match, braced, bare) => { + const name = braced ?? bare + const resolved = process.env[name] + if (resolved === undefined) { + missing ??= name + } + return resolved ?? '' + }) + return missing ? { missing, value: expanded } : { value: expanded } +} + // Last N non-empty lines of the captured build output, for diagnosing a crash // without forcing a --verbose rebuild. function tailBuildOutput(stdout: string, stderr: string): string { @@ -68,6 +85,19 @@ export async function runManifestFacts({ }): Promise { const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) + let resolvedJavaHome: string | undefined + if (javaHome) { + const expanded = expandEnvVarRefs(javaHome) + if (expanded.missing) { + process.exitCode = 1 + logger.fail( + `javaHome (\`${javaHome}\`) references \`${expanded.missing}\`, which is not set in this environment.`, + ) + return + } + resolvedJavaHome = expanded.value + } + logger.log( `Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`, ) @@ -77,7 +107,9 @@ export async function runManifestFacts({ excludeConfigs: excludeConfigs || undefined, excludePaths: excludePaths?.length ? excludePaths : undefined, // `env` replaces the spawned process's whole environment, not just JAVA_HOME. - env: javaHome ? { ...process.env, JAVA_HOME: javaHome } : undefined, + env: resolvedJavaHome + ? { ...process.env, JAVA_HOME: resolvedJavaHome } + : undefined, includeConfigs: includeConfigs || undefined, projectDir: cwd, // Stream the build tool's output only when asked; otherwise capture it and diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts new file mode 100644 index 0000000000..52e0ddf440 --- /dev/null +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -0,0 +1,99 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./scripts/run.mts', () => ({ + runManifestScript: vi.fn(), +})) + +import { runManifestFacts } from './run-manifest-facts.mts' +import { runManifestScript } from './scripts/run.mts' + +import type { ManifestRunResult } from './scripts/run.mts' + +const ENV_VAR = 'SOCKET_TEST_JAVA_HOME' + +function okResult(): ManifestRunResult { + return { + code: 0, + facts: { + components: [{ id: 'a', type: 'maven', name: 'a' }], + projects: [], + }, + report: { failures: [], scannedConfigs: [], unscannable: [] }, + artifactPaths: { + targetsByCoord: new Map(), + targetsByGav: new Map(), + sourcesByCoord: new Map(), + coords: new Set(), + }, + stderr: '', + stdout: '', + } +} + +const baseArgs = { + bin: 'mvn', + buildOpts: [], + ecosystem: 'maven' as const, + excludeConfigs: '', + ignoreUnresolved: false, + includeConfigs: '', + verbose: false, +} + +describe('runManifestFacts - javaHome', () => { + let cwd = '' + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) + vi.mocked(runManifestScript).mockReset() + delete process.env[ENV_VAR] + process.exitCode = undefined + }) + afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + delete process.env[ENV_VAR] + process.exitCode = undefined + }) + + it('passes a literal javaHome straight through as JAVA_HOME', async () => { + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + 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') + }) + + it('expands $VAR and ${VAR} references against the CLI process env', async () => { + process.env[ENV_VAR] = '/opt/jdk-11' + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + await runManifestFacts({ + ...baseArgs, + cwd, + javaHome: `\${${ENV_VAR}}`, + }) + const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-11') + }) + + it('fails closed without invoking the build tool when the referenced var is unset', async () => { + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + const result = await runManifestFacts({ + ...baseArgs, + cwd, + javaHome: `$${ENV_VAR}`, + }) + expect(result).toBeUndefined() + expect(runManifestScript).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('leaves the environment untouched when javaHome is unset', async () => { + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + await runManifestFacts({ ...baseArgs, cwd }) + const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] + expect(opts?.env).toBeUndefined() + }) +}) diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index a3ec92a6f2..748eb8629f 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -581,7 +581,7 @@ async function askForBin(defaultName = ''): Promise { async function askForJavaHome(defaultName = ''): Promise { return await input({ message: - 'What JDK should this build tool use? Leave blank to use the JDK already on PATH/JAVA_HOME.' + + 'What JDK should this build tool use? Leave blank to use the JDK already on PATH/JAVA_HOME. Supports $VAR/${VAR} (e.g. $JAVA11_HOME) so this works across machines.' + (defaultName ? ' (Backspace to leave default)' : ''), default: defaultName, required: false, diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 32dae98179..0bacad021e 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -67,7 +67,8 @@ export interface SocketJson { facts?: boolean | undefined gradleOpts?: string | undefined ignoreUnresolved?: boolean | undefined - // Absolute JDK path; sets JAVA_HOME for this ecosystem's build tool. + // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports + // $VAR/${VAR} expansion against the CLI's own environment. javaHome?: string | undefined verbose?: boolean | undefined } @@ -77,7 +78,8 @@ export interface SocketJson { excludeConfigs?: string | undefined includeConfigs?: string | undefined ignoreUnresolved?: boolean | undefined - // Absolute JDK path; sets JAVA_HOME for this ecosystem's build tool. + // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports + // $VAR/${VAR} expansion against the CLI's own environment. javaHome?: string | undefined mavenOpts?: string | undefined verbose?: boolean | undefined @@ -91,7 +93,8 @@ export interface SocketJson { includeConfigs?: string | undefined facts?: boolean | undefined ignoreUnresolved?: boolean | undefined - // Absolute JDK path; sets JAVA_HOME for this ecosystem's build tool. + // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports + // $VAR/${VAR} expansion against the CLI's own environment. javaHome?: string | undefined outfile?: string | undefined sbtOpts?: string | undefined @@ -143,6 +146,29 @@ export async function readOrDefaultSocketJsonUp( return getDefaultSocketJson() } +// Nearest socket.json walking up from `dir`, stopping at (and falling back to) +// `fallback` once `boundaryDir` is reached rather than continuing past it. +export function readOrDefaultSocketJsonUpTo( + dir: string, + boundaryDir: string, + fallback: SocketJson, +): SocketJson { + const boundary = path.resolve(boundaryDir) + let current = path.resolve(dir) + while (current !== boundary) { + if (existsSync(path.join(current, SOCKET_JSON))) { + const jsonCResult = readSocketJsonSync(current, true) + return jsonCResult.ok ? jsonCResult.data : fallback + } + const parent = path.dirname(current) + if (parent === current) { + return fallback + } + current = parent + } + return fallback +} + export function getDefaultSocketJson(): SocketJson { return { ' _____ _ _ ': `Local config file for Socket CLI tool ( ${SOCKET_WEBSITE_URL}/npm/package/${SOCKET_JSON.replace('.json', '')} ), to work with ${SOCKET_WEBSITE_URL}`, diff --git a/src/utils/socket-json.test.mts b/src/utils/socket-json.test.mts new file mode 100644 index 0000000000..bf687823d7 --- /dev/null +++ b/src/utils/socket-json.test.mts @@ -0,0 +1,76 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { readOrDefaultSocketJsonUpTo } from './socket-json.mts' + +import type { SocketJson } from './socket-json.mts' + +const fallback = { version: 1 } as SocketJson + +async function writeSocketJson(dir: string, data: unknown): Promise { + await fs.writeFile( + path.join(dir, 'socket.json'), + JSON.stringify(data), + 'utf8', + ) +} + +describe('readOrDefaultSocketJsonUpTo', () => { + let root = '' + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(tmpdir(), 'socket-json-up-to-')) + }) + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) + }) + + it('returns the fallback when dir is the boundary itself', () => { + expect(readOrDefaultSocketJsonUpTo(root, root, fallback)).toBe(fallback) + }) + + it("returns the build root's own socket.json when present", async () => { + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(buildRoot, { version: 1, marker: 'own' }) + + const result = readOrDefaultSocketJsonUpTo(buildRoot, root, fallback) + expect((result as { marker?: string }).marker).toBe('own') + }) + + it('walks up to an intermediate ancestor between dir and the boundary', async () => { + const middle = path.join(root, 'workspace') + const buildRoot = path.join(middle, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(middle, { version: 1, marker: 'workspace' }) + + const result = readOrDefaultSocketJsonUpTo(buildRoot, root, fallback) + expect((result as { marker?: string }).marker).toBe('workspace') + }) + + it('falls back when nothing is found between dir and the boundary', async () => { + const buildRoot = path.join(root, 'workspace', 'project') + await fs.mkdir(buildRoot, { recursive: true }) + + expect(readOrDefaultSocketJsonUpTo(buildRoot, root, fallback)).toBe( + fallback, + ) + }) + + it('does not walk past the boundary even if an ancestor above it has one', async () => { + await writeSocketJson(tmpdir(), { version: 1, marker: 'outside-scope' }) + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + + try { + expect(readOrDefaultSocketJsonUpTo(buildRoot, root, fallback)).toBe( + fallback, + ) + } finally { + await fs.rm(path.join(tmpdir(), 'socket.json'), { force: true }) + } + }) +}) From 836d9fb4affef393094db7f88393e47db3ac47a9 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 11:39:48 +0200 Subject: [PATCH 05/27] Deep-merge the socket.json cascade instead of nearest-wins readSocketJsonCascade (renamed from readOrDefaultSocketJsonUpTo) now merges defaults.manifest. field-by-field across every ancestor between a build root and the recursion root, nearest winning per field, instead of one file replacing the root config wholesale. A subproject can now override just javaHome while still inheriting the root's excludeConfigs/bin/etc., rather than having to restate the whole config. Ecosystems the override doesn't mention are left untouched. Verified against the sandbox tree: a root socket.json setting excludeConfigs plus a nested one setting only javaHome both applied together for that build root. --- .../manifest/generate-recursive-manifests.mts | 4 +- .../generate-recursive-manifests.test.mts | 10 +- src/utils/socket-json.mts | 58 +++++++-- src/utils/socket-json.test.mts | 119 +++++++++++++----- 4 files changed, 148 insertions(+), 43 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 83ed43e7cb..d62edcc239 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -8,7 +8,7 @@ import { runManifestFacts } from './run-manifest-facts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import { readOrDefaultSocketJson, - readOrDefaultSocketJsonUpTo, + readSocketJsonCascade, } from '../../utils/socket-json.mts' import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' @@ -117,7 +117,7 @@ export async function generateRecursiveManifests({ continue } - const sockJson = readOrDefaultSocketJsonUpTo(dir, cwd, rootSockJson) + const sockJson = readSocketJsonCascade(dir, cwd, rootSockJson) const { bin, buildOpts, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 772661889b..569d1b871e 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -6,9 +6,7 @@ vi.mock('../../utils/socket-json.mts', () => ({ readOrDefaultSocketJson: vi.fn(() => ({})), // Default: no per-root override found, fall back to the root config - matches // there being no nested socket.json anywhere in the fixture tree. - readOrDefaultSocketJsonUpTo: vi.fn( - (_dir, _boundaryDir, fallback) => fallback, - ), + readSocketJsonCascade: vi.fn((_dir, _boundaryDir, fallback) => fallback), })) vi.mock('./run-manifest-facts.mts', () => ({ runManifestFacts: vi.fn(), @@ -17,7 +15,7 @@ vi.mock('./run-manifest-facts.mts', () => ({ import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { testPath } from '../../../test/utils.mts' -import { readOrDefaultSocketJsonUpTo } from '../../utils/socket-json.mts' +import { readSocketJsonCascade } from '../../utils/socket-json.mts' const monorepo = path.join( testPath, @@ -33,7 +31,7 @@ function relOf(dir: string): string { describe('generateRecursiveManifests', () => { beforeEach(() => { vi.mocked(runManifestFacts).mockReset() - vi.mocked(readOrDefaultSocketJsonUpTo).mockImplementation( + vi.mocked(readSocketJsonCascade).mockImplementation( (_dir, _boundaryDir, fallback) => fallback, ) }) @@ -181,7 +179,7 @@ describe('generateRecursiveManifests', () => { }) it('resolves each build root its own nearest socket.json instead of only the root config', async () => { - vi.mocked(readOrDefaultSocketJsonUpTo).mockImplementation( + vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => dir === dualMarkerDir ? { defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } } } diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 0bacad021e..b6712d6855 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -146,27 +146,71 @@ export async function readOrDefaultSocketJsonUp( return getDefaultSocketJson() } -// Nearest socket.json walking up from `dir`, stopping at (and falling back to) -// `fallback` once `boundaryDir` is reached rather than continuing past it. -export function readOrDefaultSocketJsonUpTo( +const MANIFEST_ECOSYSTEMS = [ + 'bazel', + 'conda', + 'gradle', + 'maven', + 'sbt', +] as const + +// Shallow-merges `defaults.manifest.` per ecosystem: fields present +// in `override` win, fields it doesn't set fall through to `base`. Everything +// outside `defaults.manifest` (scan-level defaults, etc.) comes from `base` +// only - only the manifest/build-tool section cascades. +function mergeManifestDefaults( + base: SocketJson, + override: SocketJson, +): SocketJson { + const overrideManifest = override.defaults?.manifest + if (!overrideManifest) { + return base + } + const baseManifest = base.defaults?.manifest + const mergedManifest: NonNullable< + NonNullable['manifest'] + > = { ...baseManifest } + for (const eco of MANIFEST_ECOSYSTEMS) { + if (overrideManifest[eco]) { + mergedManifest[eco] = { ...baseManifest?.[eco], ...overrideManifest[eco] } + } + } + return { + ...base, + defaults: { ...base.defaults, manifest: mergedManifest }, + } +} + +// Cascades socket.json's `defaults.manifest.*` section from `rootSockJson` +// down to `dir`: every ancestor between `dir` and `boundaryDir` (inclusive of +// `dir`, exclusive of `boundaryDir` - that's already `rootSockJson`) that has +// its own socket.json is merged in, nearest-to-`dir` taking precedence field +// by field. A build root with no socket.json of its own simply inherits +// `rootSockJson` unchanged. +export function readSocketJsonCascade( dir: string, boundaryDir: string, - fallback: SocketJson, + rootSockJson: SocketJson, ): SocketJson { const boundary = path.resolve(boundaryDir) let current = path.resolve(dir) + // Farthest-from-dir first, so the merge loop below applies overrides in + // increasing precedence and the nearest-to-dir file wins last. + const ancestorsFarToNear: SocketJson[] = [] while (current !== boundary) { if (existsSync(path.join(current, SOCKET_JSON))) { const jsonCResult = readSocketJsonSync(current, true) - return jsonCResult.ok ? jsonCResult.data : fallback + if (jsonCResult.ok) { + ancestorsFarToNear.unshift(jsonCResult.data) + } } const parent = path.dirname(current) if (parent === current) { - return fallback + break } current = parent } - return fallback + return ancestorsFarToNear.reduce(mergeManifestDefaults, rootSockJson) } export function getDefaultSocketJson(): SocketJson { diff --git a/src/utils/socket-json.test.mts b/src/utils/socket-json.test.mts index bf687823d7..c8e2f8cd1c 100644 --- a/src/utils/socket-json.test.mts +++ b/src/utils/socket-json.test.mts @@ -4,12 +4,10 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { readOrDefaultSocketJsonUpTo } from './socket-json.mts' +import { readSocketJsonCascade } from './socket-json.mts' import type { SocketJson } from './socket-json.mts' -const fallback = { version: 1 } as SocketJson - async function writeSocketJson(dir: string, data: unknown): Promise { await fs.writeFile( path.join(dir, 'socket.json'), @@ -18,57 +16,122 @@ async function writeSocketJson(dir: string, data: unknown): Promise { ) } -describe('readOrDefaultSocketJsonUpTo', () => { +function mavenConfig(sockJson: SocketJson) { + return sockJson.defaults?.manifest?.maven +} + +describe('readSocketJsonCascade', () => { let root = '' + let rootSockJson: SocketJson beforeEach(async () => { - root = await fs.mkdtemp(path.join(tmpdir(), 'socket-json-up-to-')) + root = await fs.mkdtemp(path.join(tmpdir(), 'socket-json-cascade-')) + rootSockJson = { + version: 1, + defaults: { + manifest: { + maven: { bin: 'mvn', excludeConfigs: 'root-exclude' }, + }, + }, + } as SocketJson }) afterEach(async () => { await fs.rm(root, { recursive: true, force: true }) }) - it('returns the fallback when dir is the boundary itself', () => { - expect(readOrDefaultSocketJsonUpTo(root, root, fallback)).toBe(fallback) + it('returns rootSockJson unchanged when dir is the boundary itself', () => { + expect(readSocketJsonCascade(root, root, rootSockJson)).toBe(rootSockJson) }) - it("returns the build root's own socket.json when present", async () => { - const buildRoot = path.join(root, 'project') + it('returns rootSockJson unchanged when nothing is found between dir and the boundary', async () => { + const buildRoot = path.join(root, 'workspace', 'project') await fs.mkdir(buildRoot, { recursive: true }) - await writeSocketJson(buildRoot, { version: 1, marker: 'own' }) - const result = readOrDefaultSocketJsonUpTo(buildRoot, root, fallback) - expect((result as { marker?: string }).marker).toBe('own') + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(result).toBe(rootSockJson) + }) + + it("merges the build root's own socket.json over the root config instead of replacing it", async () => { + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(mavenConfig(result)).toEqual({ + bin: 'mvn', + excludeConfigs: 'root-exclude', + javaHome: '/opt/jdk-11', + }) }) - it('walks up to an intermediate ancestor between dir and the boundary', async () => { - const middle = path.join(root, 'workspace') - const buildRoot = path.join(middle, 'project') + it('does not touch an ecosystem the override never mentions', async () => { + rootSockJson = { + version: 1, + defaults: { + manifest: { + gradle: { bin: './gradlew' }, + maven: { bin: 'mvn' }, + }, + }, + } as SocketJson + const buildRoot = path.join(root, 'project') await fs.mkdir(buildRoot, { recursive: true }) - await writeSocketJson(middle, { version: 1, marker: 'workspace' }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } }, + }) - const result = readOrDefaultSocketJsonUpTo(buildRoot, root, fallback) - expect((result as { marker?: string }).marker).toBe('workspace') + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(result.defaults?.manifest?.gradle).toEqual({ bin: './gradlew' }) }) - it('falls back when nothing is found between dir and the boundary', async () => { - const buildRoot = path.join(root, 'workspace', 'project') + it('cascades multiple levels, nearest-to-dir winning per field', async () => { + const workspace = path.join(root, 'workspace') + const buildRoot = path.join(workspace, 'project') await fs.mkdir(buildRoot, { recursive: true }) - - expect(readOrDefaultSocketJsonUpTo(buildRoot, root, fallback)).toBe( - fallback, - ) + await writeSocketJson(workspace, { + version: 1, + defaults: { + manifest: { + maven: { + excludeConfigs: 'workspace-exclude', + includeConfigs: 'workspace-include', + }, + }, + }, + }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(mavenConfig(result)).toEqual({ + // From root, untouched by either override. + bin: 'mvn', + // Workspace overrides root; project doesn't mention it. + excludeConfigs: 'workspace-exclude', + // From workspace only. + includeConfigs: 'workspace-include', + // From the nearest file only. + javaHome: '/opt/jdk-11', + }) }) it('does not walk past the boundary even if an ancestor above it has one', async () => { - await writeSocketJson(tmpdir(), { version: 1, marker: 'outside-scope' }) + await writeSocketJson(tmpdir(), { + version: 1, + defaults: { manifest: { maven: { javaHome: '/outside-scope' } } }, + }) const buildRoot = path.join(root, 'project') await fs.mkdir(buildRoot, { recursive: true }) try { - expect(readOrDefaultSocketJsonUpTo(buildRoot, root, fallback)).toBe( - fallback, - ) + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(result).toBe(rootSockJson) } finally { await fs.rm(path.join(tmpdir(), 'socket.json'), { force: true }) } From 20e6206e005d85553fe3250a9c2ec8253fbf4fa0 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 11:52:17 +0200 Subject: [PATCH 06/27] Warn (but proceed) when a resolved config sets facts: false dynamic-sbom-inference always generates Socket facts SBOMs, never pom.xml. If a build root's cascaded socket.json sets facts: false (the pom-mode opt-out other manifest commands honor), that's ignored here rather than skipping the project or silently doing nothing - but it's a real, deliberate setting the user made, so it's surfaced as a warning rather than silently overridden. --- .../manifest/generate-recursive-manifests.mts | 26 +++++++++++---- .../generate-recursive-manifests.test.mts | 33 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index d62edcc239..7149a3a185 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -37,12 +37,12 @@ type EcosystemBuildConfig = { javaHome: string | undefined } -// Resolves the single, global per-ecosystem build-tool config (socket.json -// `defaults.manifest.`) applied uniformly to every discovered root -// of that ecosystem. There is no per-build-root cascade yet (tracked -// separately under REA-553); a wrapper-preferred `bin` default is still -// resolved per-root (`dir`, not `cwd`) since a wrapper script only exists at -// the actual build root, not necessarily at the overall recursion root. +// Resolves this build root's effective per-ecosystem build-tool config from +// its cascaded socket.json; a wrapper-preferred `bin` default is resolved +// per-root (`dir`, not `cwd`) since a wrapper script only exists at the +// actual build root. gradle/sbt's `facts: false` (pom mode) is ignored here - +// this command always generates Socket facts - but warned about, since it's +// an explicit setting the user made for other commands. function resolveEcosystemConfig( ecosystem: BuildTool, dir: string, @@ -50,6 +50,7 @@ function resolveEcosystemConfig( ): EcosystemBuildConfig { if (ecosystem === 'sbt') { const config = sockJson.defaults?.manifest?.sbt + warnIfFactsDisabled(ecosystem, dir, config?.facts) return { bin: config?.bin ?? 'sbt', buildOpts: parseBuildToolOpts(config?.sbtOpts), @@ -61,6 +62,7 @@ function resolveEcosystemConfig( } if (ecosystem === 'gradle') { const config = sockJson.defaults?.manifest?.gradle + warnIfFactsDisabled(ecosystem, dir, config?.facts) return { bin: config?.bin ? path.resolve(dir, config.bin) @@ -83,6 +85,18 @@ function resolveEcosystemConfig( } } +function warnIfFactsDisabled( + ecosystem: BuildTool, + dir: string, + facts: boolean | undefined, +): void { + if (facts === false) { + logger.warn( + `${dir} sets defaults.manifest.${ecosystem}.facts: false (pom mode), but dynamic-sbom-inference always generates Socket facts; ignoring that setting.`, + ) + } +} + // Recursively discovers gradle/sbt/maven build roots under `cwd` and // generates one `.socket.facts.json` per independent build root. Coverage is // tracked per ecosystem (not globally) using the facts SBOM's own diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 569d1b871e..d32b1bfa82 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -2,6 +2,8 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { logger } from '@socketsecurity/registry/lib/logger' + vi.mock('../../utils/socket-json.mts', () => ({ readOrDefaultSocketJson: vi.fn(() => ({})), // Default: no per-root override found, fall back to the root config - matches @@ -204,4 +206,35 @@ describe('generateRecursiveManifests', () => { expect(javaHomeByCall.get('gradle:dual-marker-dir')).toBeUndefined() expect(javaHomeByCall.get('maven:reactor')).toBeUndefined() }) + + it('warns but still generates facts when a resolved config sets facts: false', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { defaults: { manifest: { gradle: { facts: false } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(warned).toMatch(/facts: false/) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('gradle:dual-marker-dir')).toBe('generated') + } finally { + warnSpy.mockRestore() + } + }) }) From 09a585e4c4583e723d1162be4510c6f5065d8df9 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 13:23:38 +0200 Subject: [PATCH 07/27] Revert facts:false to skip, add a dedicated ignored config dynamic-sbom-inference previously forced Socket facts generation over an explicit defaults.manifest..facts: false (pom mode), just warning about it. Reverted: facts: false now skips the project again, same as before that change, since this command has no pom-mode equivalent to fall back to. Also adds a separate ignored: true field (gradle/maven/sbt) scoped specifically to dynamic-sbom-inference, for projects that should be skipped during recursive generation regardless of their facts/pom preference for other commands. Kept as its own boolean rather than folding into facts, since facts already means something specific to other manifest commands and overloading it would require touching every consumer for a value only this command understands. New skippedIgnored outcome status covers both reasons, distinguished via the warning message. --- .../manifest/generate-recursive-manifests.mts | 46 ++++++++++++------- .../generate-recursive-manifests.test.mts | 45 ++++++++++++++++-- ...output-manifest-dynamic-sbom-inference.mts | 10 +++- src/utils/socket-json.mts | 6 +++ 4 files changed, 85 insertions(+), 22 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 7149a3a185..4dc76d11d0 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -20,6 +20,7 @@ export type RecursiveManifestOutcomeStatus = | 'failed' | 'generated' | 'skippedCovered' + | 'skippedIgnored' export type RecursiveManifestOutcome = { dir: string @@ -35,14 +36,28 @@ type EcosystemBuildConfig = { ignoreUnresolved: boolean includeConfigs: string javaHome: string | undefined + // Set when this build root should be skipped entirely (never invoked). + skipReason: string | undefined +} + +// facts:false has no pom-mode equivalent here (facts-only), so it skips too. +function getSkipReason( + ignored: boolean | undefined, + facts?: boolean | undefined, +): string | undefined { + if (ignored) { + return 'defaults.manifest..ignored is true' + } + if (facts === false) { + return 'defaults.manifest..facts is false (pom mode)' + } + return undefined } // Resolves this build root's effective per-ecosystem build-tool config from // its cascaded socket.json; a wrapper-preferred `bin` default is resolved // per-root (`dir`, not `cwd`) since a wrapper script only exists at the -// actual build root. gradle/sbt's `facts: false` (pom mode) is ignored here - -// this command always generates Socket facts - but warned about, since it's -// an explicit setting the user made for other commands. +// actual build root. function resolveEcosystemConfig( ecosystem: BuildTool, dir: string, @@ -50,7 +65,6 @@ function resolveEcosystemConfig( ): EcosystemBuildConfig { if (ecosystem === 'sbt') { const config = sockJson.defaults?.manifest?.sbt - warnIfFactsDisabled(ecosystem, dir, config?.facts) return { bin: config?.bin ?? 'sbt', buildOpts: parseBuildToolOpts(config?.sbtOpts), @@ -58,11 +72,11 @@ function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome, + skipReason: getSkipReason(config?.ignored, config?.facts), } } if (ecosystem === 'gradle') { const config = sockJson.defaults?.manifest?.gradle - warnIfFactsDisabled(ecosystem, dir, config?.facts) return { bin: config?.bin ? path.resolve(dir, config.bin) @@ -72,6 +86,7 @@ function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome, + skipReason: getSkipReason(config?.ignored, config?.facts), } } const config = sockJson.defaults?.manifest?.maven @@ -82,18 +97,7 @@ function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome, - } -} - -function warnIfFactsDisabled( - ecosystem: BuildTool, - dir: string, - facts: boolean | undefined, -): void { - if (facts === false) { - logger.warn( - `${dir} sets defaults.manifest.${ecosystem}.facts: false (pom mode), but dynamic-sbom-inference always generates Socket facts; ignoring that setting.`, - ) + skipReason: getSkipReason(config?.ignored), } } @@ -139,7 +143,15 @@ export async function generateRecursiveManifests({ ignoreUnresolved, includeConfigs, javaHome, + skipReason, } = resolveEcosystemConfig(ecosystem, dir, sockJson) + + if (skipReason) { + logger.warn(`Skipping ${dir} (${ecosystem}): ${skipReason}.`) + outcomes.push({ dir, ecosystem, status: 'skippedIgnored' }) + continue + } + const excludePathsForRoot = projectIgnorePathsToReachExcludePaths( excludePaths, { cwd, target: dir }, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index d32b1bfa82..3727c30ba5 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -207,7 +207,7 @@ describe('generateRecursiveManifests', () => { expect(javaHomeByCall.get('maven:reactor')).toBeUndefined() }) - it('warns but still generates facts when a resolved config sets facts: false', async () => { + it('skips (with a warning) a resolved config that sets facts: false, never invoking the build tool', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => dir === dualMarkerDir @@ -227,12 +227,51 @@ describe('generateRecursiveManifests', () => { }) const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(warned).toMatch(/facts: false/) + expect(warned).toMatch(/facts is false/) const byKey = new Map( outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), ) - expect(byKey.get('gradle:dual-marker-dir')).toBe('generated') + expect(byKey.get('gradle:dual-marker-dir')).toBe('skippedIgnored') + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some( + ([opts]) => + opts.cwd === dualMarkerDir && opts.ecosystem === 'gradle', + ), + ).toBe(false) + } finally { + warnSpy.mockRestore() + } + }) + + it('skips (with a warning) a resolved config that sets ignored: true', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === reactor + ? { defaults: { manifest: { maven: { ignored: true } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(warned).toMatch(/ignored is true/) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:reactor')).toBe('skippedIgnored') } finally { warnSpy.mockRestore() } diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts index c10a385607..fd08869ce8 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -18,12 +18,18 @@ function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { const generated = outcomes.filter(o => o.status === 'generated').length const failed = outcomes.filter(o => o.status === 'failed').length - const skipped = outcomes.filter(o => o.status === 'skippedCovered').length + const skippedCovered = outcomes.filter( + o => o.status === 'skippedCovered', + ).length + const skippedIgnored = outcomes.filter( + o => o.status === 'skippedIgnored', + ).length const empty = outcomes.filter(o => o.status === 'empty').length const roots = new Set(outcomes.map(o => o.dir)).size return ( `Generated ${generated} Socket facts file(s) across ${roots} build root(s); ` + - `${failed} failed, ${skipped} skipped (already covered), ${empty} empty.` + `${failed} failed, ${skippedCovered} skipped (already covered), ` + + `${skippedIgnored} skipped (ignored/pom), ${empty} empty.` ) } diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index b6712d6855..04142b71e8 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -66,6 +66,8 @@ export interface SocketJson { includeConfigs?: string | undefined facts?: boolean | undefined gradleOpts?: string | undefined + // Skips this project in dynamic-sbom-inference only. + ignored?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. @@ -77,6 +79,8 @@ export interface SocketJson { bin?: string | undefined excludeConfigs?: string | undefined includeConfigs?: string | undefined + // Skips this project in dynamic-sbom-inference only. + ignored?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. @@ -92,6 +96,8 @@ export interface SocketJson { excludeConfigs?: string | undefined includeConfigs?: string | undefined facts?: boolean | undefined + // Skips this project in dynamic-sbom-inference only. + ignored?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. From 4116c117e813d3da6281637efc287fa4b1a9b727 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 13:38:53 +0200 Subject: [PATCH 08/27] Consolidate ignored into disabled instead of a separate field A cascaded (not just root-level) disabled: true produces identical per-root behavior to the ignored field just added, so keeping both was redundant. Removed ignored; disabled now does double duty: root-only ecosystem-wide gating for auto/gradle/etc. (unchanged), plus a cascaded per-build-root skip specifically for dynamic-sbom-inference. Renamed the skippedIgnored outcome status to skippedDisabled to match. --- .../manifest/generate-recursive-manifests.mts | 21 +++++++++++-------- .../generate-recursive-manifests.test.mts | 10 ++++----- ...output-manifest-dynamic-sbom-inference.mts | 6 +++--- src/utils/socket-json.mts | 15 +++++++------ 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 4dc76d11d0..06dfd1d2ad 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -20,7 +20,7 @@ export type RecursiveManifestOutcomeStatus = | 'failed' | 'generated' | 'skippedCovered' - | 'skippedIgnored' + | 'skippedDisabled' export type RecursiveManifestOutcome = { dir: string @@ -40,13 +40,16 @@ type EcosystemBuildConfig = { skipReason: string | undefined } -// facts:false has no pom-mode equivalent here (facts-only), so it skips too. +// A cascaded (not just root-level) disabled additionally skips this one build +// root here, on top of its existing root-only ecosystem-wide meaning for +// auto/gradle/etc. facts:false has no pom-mode equivalent here (facts-only), +// so it skips too. function getSkipReason( - ignored: boolean | undefined, + disabled: boolean | undefined, facts?: boolean | undefined, ): string | undefined { - if (ignored) { - return 'defaults.manifest..ignored is true' + if (disabled) { + return 'defaults.manifest..disabled is true' } if (facts === false) { return 'defaults.manifest..facts is false (pom mode)' @@ -72,7 +75,7 @@ function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome, - skipReason: getSkipReason(config?.ignored, config?.facts), + skipReason: getSkipReason(config?.disabled, config?.facts), } } if (ecosystem === 'gradle') { @@ -86,7 +89,7 @@ function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome, - skipReason: getSkipReason(config?.ignored, config?.facts), + skipReason: getSkipReason(config?.disabled, config?.facts), } } const config = sockJson.defaults?.manifest?.maven @@ -97,7 +100,7 @@ function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome, - skipReason: getSkipReason(config?.ignored), + skipReason: getSkipReason(config?.disabled), } } @@ -148,7 +151,7 @@ export async function generateRecursiveManifests({ if (skipReason) { logger.warn(`Skipping ${dir} (${ecosystem}): ${skipReason}.`) - outcomes.push({ dir, ecosystem, status: 'skippedIgnored' }) + outcomes.push({ dir, ecosystem, status: 'skippedDisabled' }) continue } diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 3727c30ba5..fefb3465cf 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -232,7 +232,7 @@ describe('generateRecursiveManifests', () => { const byKey = new Map( outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), ) - expect(byKey.get('gradle:dual-marker-dir')).toBe('skippedIgnored') + expect(byKey.get('gradle:dual-marker-dir')).toBe('skippedDisabled') expect( vi .mocked(runManifestFacts) @@ -246,11 +246,11 @@ describe('generateRecursiveManifests', () => { } }) - it('skips (with a warning) a resolved config that sets ignored: true', async () => { + it('skips (with a warning) a resolved config that sets a cascaded disabled: true', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => dir === reactor - ? { defaults: { manifest: { maven: { ignored: true } } } } + ? { defaults: { manifest: { maven: { disabled: true } } } } : fallback, ) vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ @@ -266,12 +266,12 @@ describe('generateRecursiveManifests', () => { }) const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(warned).toMatch(/ignored is true/) + expect(warned).toMatch(/disabled is true/) const byKey = new Map( outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), ) - expect(byKey.get('maven:reactor')).toBe('skippedIgnored') + expect(byKey.get('maven:reactor')).toBe('skippedDisabled') } finally { warnSpy.mockRestore() } diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts index fd08869ce8..c7aca3a9f3 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -21,15 +21,15 @@ function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { const skippedCovered = outcomes.filter( o => o.status === 'skippedCovered', ).length - const skippedIgnored = outcomes.filter( - o => o.status === 'skippedIgnored', + const skippedDisabled = outcomes.filter( + o => o.status === 'skippedDisabled', ).length const empty = outcomes.filter(o => o.status === 'empty').length const roots = new Set(outcomes.map(o => o.dir)).size return ( `Generated ${generated} Socket facts file(s) across ${roots} build root(s); ` + `${failed} failed, ${skippedCovered} skipped (already covered), ` + - `${skippedIgnored} skipped (ignored/pom), ${empty} empty.` + `${skippedDisabled} skipped (disabled/pom), ${empty} empty.` ) } diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 04142b71e8..1e95a8c4c7 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -60,14 +60,15 @@ export interface SocketJson { verbose?: boolean | undefined } gradle?: { + // Root-only: gates auto-detection for socket manifest auto/gradle. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. disabled?: boolean | undefined bin?: string | undefined excludeConfigs?: string | undefined includeConfigs?: string | undefined facts?: boolean | undefined gradleOpts?: string | undefined - // Skips this project in dynamic-sbom-inference only. - ignored?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. @@ -75,12 +76,13 @@ export interface SocketJson { verbose?: boolean | undefined } maven?: { + // Root-only: gates auto-detection for socket manifest auto/maven. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. disabled?: boolean | undefined bin?: string | undefined excludeConfigs?: string | undefined includeConfigs?: string | undefined - // Skips this project in dynamic-sbom-inference only. - ignored?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. @@ -89,6 +91,9 @@ export interface SocketJson { verbose?: boolean | undefined } sbt?: { + // Root-only: gates auto-detection for socket manifest auto/scala. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. disabled?: boolean | undefined infile?: string | undefined stdin?: boolean | undefined @@ -96,8 +101,6 @@ export interface SocketJson { excludeConfigs?: string | undefined includeConfigs?: string | undefined facts?: boolean | undefined - // Skips this project in dynamic-sbom-inference only. - ignored?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. From 1ec6566d1cb489f9578b239294dc4a954554cbed Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 31 Jul 2026 22:18:08 +0200 Subject: [PATCH 09/27] Add explicit clear sentinel, lightweight workspace discovery, and recursive setup for dynamic-sbom-inference - socket.json manifest fields now accept `null` as an explicit "clear the inherited value" sentinel, distinct from leaving a field unset; the setup wizard writes it when a previously-set value is cleared instead of just deleting the key. - Add a lightweight per-ecosystem workspace enumeration path for gradle/sbt/maven (new standalone scripts, kept fully separate from the existing facts-generation scripts) that discovers a build's subprojects without running dependency resolution. - Add a hidden `socket manifest setup --dynamic-sbom-inference` mode: configures root-level defaults per ecosystem, then recursively marks `disabled: true` on build roots matching `--exclude-paths`, leaving everything else untouched. - Speed up `dynamic-sbom-inference`'s handling of a disabled build root with many nested candidates by reusing the nearest already-resolved disabled ancestor instead of re-walking the whole config cascade for each one, and only logging the root cause instead of once per nested candidate. --- .config/rollup.dist.config.mjs | 8 + src/commands/manifest/cmd-manifest-gradle.mts | 10 +- src/commands/manifest/cmd-manifest-kotlin.mts | 10 +- src/commands/manifest/cmd-manifest-maven.mts | 10 +- src/commands/manifest/cmd-manifest-scala.mts | 10 +- src/commands/manifest/cmd-manifest-setup.mts | 26 +- .../manifest/discover-manifest-roots.mts | 2 +- .../manifest/enumerate-workspaces.mts | 81 ++++ .../manifest/enumerate-workspaces.test.mts | 97 +++++ src/commands/manifest/expand-env-var-refs.mts | 20 + .../manifest/generate-recursive-manifests.mts | 66 ++- .../generate-recursive-manifests.test.mts | 138 +++++++ .../manifest/generate_auto_manifest.mts | 14 +- .../manifest/handle-manifest-setup.mts | 7 +- src/commands/manifest/run-manifest-facts.mts | 18 +- .../CoanaWorkspacesLifecycleParticipant.java | 60 +++ .../socket/SocketWorkspacesRecordsEngine.java | 63 +++ src/commands/manifest/scripts/run.mts | 155 +++++-- .../scripts/socket-workspaces.init.gradle | 133 ++++++ .../scripts/socket-workspaces.plugin.scala | 149 +++++++ .../manifest/setup-manifest-config.mts | 45 +- .../setup-recursive-manifest-config.mts | 362 +++++++++++++++++ .../setup-recursive-manifest-config.test.mts | 383 ++++++++++++++++++ src/utils/socket-json.mts | 63 +-- src/utils/socket-json.test.mts | 15 + 25 files changed, 1828 insertions(+), 117 deletions(-) create mode 100644 src/commands/manifest/enumerate-workspaces.mts create mode 100644 src/commands/manifest/enumerate-workspaces.test.mts create mode 100644 src/commands/manifest/expand-env-var-refs.mts create mode 100644 src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java create mode 100644 src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java create mode 100644 src/commands/manifest/scripts/socket-workspaces.init.gradle create mode 100644 src/commands/manifest/scripts/socket-workspaces.plugin.scala create mode 100644 src/commands/manifest/setup-recursive-manifest-config.mts create mode 100644 src/commands/manifest/setup-recursive-manifest-config.test.mts diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index e9341c3e72..52eab0bec4 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -98,6 +98,14 @@ async function copyManifestScripts() { path.join(srcDir, 'socket-facts.plugin.scala'), path.join(destDir, 'socket-facts.plugin.scala'), ), + fs.copyFile( + path.join(srcDir, 'socket-workspaces.init.gradle'), + path.join(destDir, 'socket-workspaces.init.gradle'), + ), + fs.copyFile( + path.join(srcDir, 'socket-workspaces.plugin.scala'), + path.join(destDir, 'socket-workspaces.plugin.scala'), + ), ]) const jarPath = path.join( srcDir, diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index dc7aada77d..876790efca 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -206,8 +206,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.gradle?.includeConfigs + if (sockJson.defaults?.manifest?.gradle?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.gradle.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -217,8 +217,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.gradle?.excludeConfigs + if (sockJson.defaults?.manifest?.gradle?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.gradle.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -276,7 +276,7 @@ async function run( return } - const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome + const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined if (verbose) { logger.group() diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index fd47f6508d..3f3c5df4c0 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -211,8 +211,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.gradle?.includeConfigs + if (sockJson.defaults?.manifest?.gradle?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.gradle.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -222,8 +222,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.gradle?.excludeConfigs + if (sockJson.defaults?.manifest?.gradle?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.gradle.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -279,7 +279,7 @@ async function run( return } - const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome + const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined if (verbose) { logger.group() diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index 8a2465cadb..99f62029b3 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -155,8 +155,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.maven?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.maven?.includeConfigs + if (sockJson.defaults?.manifest?.maven?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.maven.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -166,8 +166,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.maven?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.maven?.excludeConfigs + if (sockJson.defaults?.manifest?.maven?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.maven.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -214,7 +214,7 @@ async function run( return } - const javaHome = sockJson.defaults?.manifest?.maven?.javaHome + const javaHome = sockJson.defaults?.manifest?.maven?.javaHome ?? undefined if (verbose) { logger.group() diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 01188c782c..d2e4f5695a 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -208,8 +208,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.sbt?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.sbt?.includeConfigs + if (sockJson.defaults?.manifest?.sbt?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.sbt.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -219,8 +219,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.sbt?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.sbt?.excludeConfigs + if (sockJson.defaults?.manifest?.sbt?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.sbt.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -330,7 +330,7 @@ async function run( return } - const javaHome = sockJson.defaults?.manifest?.sbt?.javaHome + const javaHome = sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined if (verbose) { logger.group() diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 9294d508cc..164a5e8cca 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { handleManifestSetup } from './handle-manifest-setup.mts' import constants, { SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' @@ -20,10 +21,24 @@ const config: CliCommandConfig = { hidden: false, flags: { ...commonFlags, + // Only meaningful alongside the hidden --dynamic-sbom-inference below; kept hidden too. + excludePaths: { + type: 'string', + isMultiple: true, + hidden: true, + description: + 'Build roots matching these glob patterns (and everything beneath them) are marked disabled. Patterns are anchored micromatch globs matched relative to CWD: `legacy` matches only `/legacy`; use `**/legacy` to match at any depth. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + }, defaultOnReadError: { type: 'boolean', description: `If reading the ${SOCKET_JSON} fails, just use a default config? Warning: This might override the existing json file!`, }, + dynamicSbomInference: { + type: 'boolean', + hidden: true, + description: + 'After configuring CWD, recursively discover every gradle/sbt/maven build root beneath it and mark `disabled: true` on whatever matches --exclude-paths; everything else is left untouched', + }, }, help: (command, config) => ` Usage @@ -75,7 +90,7 @@ async function run( parentName, }) - const { defaultOnReadError = false } = cli.flags + const { defaultOnReadError = false, dynamicSbomInference = false } = cli.flags const dryRun = !!cli.flags['dryRun'] @@ -89,5 +104,12 @@ async function run( return } - await handleManifestSetup(cwd, Boolean(defaultOnReadError)) + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + + await handleManifestSetup( + cwd, + Boolean(defaultOnReadError), + Boolean(dynamicSbomInference), + excludePaths, + ) } diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index 262712aa14..d79991fc02 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -32,7 +32,7 @@ for (const tool of BUILD_TOOLS) { } } -async function realpathOrResolved(dir: string): Promise { +export async function realpathOrResolved(dir: string): Promise { try { return await fs.realpath(dir) } catch { diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts new file mode 100644 index 0000000000..270d72d774 --- /dev/null +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -0,0 +1,81 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { expandEnvVarRefs } from './expand-env-var-refs.mts' +import { enumerateWorkspaces as enumerateWorkspacesScript } from './scripts/run.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketFactsSbomProject } from './scripts/facts.mts' + +export type EnumerateWorkspacesResult = { + projects: SocketFactsSbomProject[] +} + +// Cheaply discovers a build root's subprojects (no dependency resolution): used +// for `socket manifest setup --recursive` discovery. Distinct from +// dynamic-sbom-inference's own coverage tracking, which gets the same +// subproject list for free as a side effect of the full facts run it already +// has to do. +export async function enumerateWorkspaces({ + bin, + buildOpts, + cwd, + ecosystem, + excludePaths, + javaHome, + verbose, +}: { + bin: string + buildOpts: string[] + cwd: string + ecosystem: BuildTool + excludePaths?: string[] | undefined + javaHome?: string | undefined + verbose: boolean +}): Promise { + let resolvedJavaHome: string | undefined + if (javaHome) { + const expanded = expandEnvVarRefs(javaHome) + if (expanded.missing) { + process.exitCode = 1 + logger.fail( + `javaHome (\`${javaHome}\`) references \`${expanded.missing}\`, which is not set in this environment.`, + ) + return + } + resolvedJavaHome = expanded.value + } + + const scriptOpts = { + bin: bin || undefined, + excludePaths: excludePaths?.length ? excludePaths : undefined, + // `env` replaces the spawned process's whole environment, not just JAVA_HOME. + env: resolvedJavaHome + ? { ...process.env, JAVA_HOME: resolvedJavaHome } + : undefined, + projectDir: cwd, + stdio: verbose ? ('inherit' as const) : ('pipe' as const), + toolOpts: buildOpts, + } + + let result + try { + result = await enumerateWorkspacesScript(ecosystem, scriptOpts) + } catch (e) { + process.exitCode = 1 + logger.fail( + `Could not run the ${ecosystem} build tool` + + (verbose ? `: ${e}` : ' (run with --verbose for details).'), + ) + return + } + + if (result.code !== 0 && !result.projects.length) { + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build failed (exit code ${result.code}) before producing any workspace records.`, + ) + return + } + + return { projects: result.projects } +} diff --git a/src/commands/manifest/enumerate-workspaces.test.mts b/src/commands/manifest/enumerate-workspaces.test.mts new file mode 100644 index 0000000000..e97abd95f7 --- /dev/null +++ b/src/commands/manifest/enumerate-workspaces.test.mts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./scripts/run.mts', () => ({ + enumerateWorkspaces: vi.fn(), +})) + +import { enumerateWorkspaces } from './enumerate-workspaces.mts' +import { enumerateWorkspaces as enumerateWorkspacesScript } from './scripts/run.mts' + +import type { WorkspaceEnumerationResult } from './scripts/run.mts' + +const ENV_VAR = 'SOCKET_TEST_ENUMERATE_JAVA_HOME' + +function okResult(): WorkspaceEnumerationResult { + return { + code: 0, + projects: [ + { + type: 'maven', + name: 'root', + subprojectDir: '.', + dependencies: [], + resolvedAs: [], + }, + ], + stderr: '', + stdout: '', + } +} + +const baseArgs = { + bin: 'gradle', + buildOpts: [], + cwd: '/tmp/some-project', + ecosystem: 'gradle' as const, + verbose: false, +} + +describe('enumerateWorkspaces', () => { + beforeEach(() => { + vi.mocked(enumerateWorkspacesScript).mockReset() + delete process.env[ENV_VAR] + process.exitCode = undefined + }) + + it('returns the projects from a successful run', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + const result = await enumerateWorkspaces(baseArgs) + expect(result?.projects).toEqual(okResult().projects) + }) + + it('passes a literal javaHome straight through as JAVA_HOME', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + await enumerateWorkspaces({ ...baseArgs, javaHome: '/opt/jdk-17' }) + const opts = vi.mocked(enumerateWorkspacesScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17') + }) + + it('expands $VAR and ${VAR} references against the CLI process env', async () => { + process.env[ENV_VAR] = '/opt/jdk-11' + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + await enumerateWorkspaces({ ...baseArgs, javaHome: `\${${ENV_VAR}}` }) + const opts = vi.mocked(enumerateWorkspacesScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-11') + delete process.env[ENV_VAR] + }) + + it('fails closed without invoking the build tool when the referenced var is unset', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + const result = await enumerateWorkspaces({ + ...baseArgs, + javaHome: `$${ENV_VAR}`, + }) + expect(result).toBeUndefined() + expect(enumerateWorkspacesScript).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('leaves the environment untouched when javaHome is unset', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + await enumerateWorkspaces(baseArgs) + const opts = vi.mocked(enumerateWorkspacesScript).mock.calls[0]?.[1] + expect(opts?.env).toBeUndefined() + }) + + it('fails when the build crashed before producing any workspace records', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue({ + code: 1, + projects: [], + stderr: '', + stdout: '', + }) + const result = await enumerateWorkspaces(baseArgs) + expect(result).toBeUndefined() + expect(process.exitCode).toBe(1) + }) +}) diff --git a/src/commands/manifest/expand-env-var-refs.mts b/src/commands/manifest/expand-env-var-refs.mts new file mode 100644 index 0000000000..c8d65ebc2a --- /dev/null +++ b/src/commands/manifest/expand-env-var-refs.mts @@ -0,0 +1,20 @@ +const ENV_VAR_REF = /\$\{(\w+)\}|\$(\w+)/g + +// Expands `$VAR`/`${VAR}` references (e.g. a team-shared `javaHome: +// "$JAVA11_HOME"`) against the CLI process's own environment, so a socket.json +// value works across machines instead of hardcoding one developer's path. +export function expandEnvVarRefs(value: string): { + missing?: string + value: string +} { + let missing: string | undefined + const expanded = value.replace(ENV_VAR_REF, (_match, braced, bare) => { + const name = braced ?? bare + const resolved = process.env[name] + if (resolved === undefined) { + missing ??= name + } + return resolved ?? '' + }) + return missing ? { missing, value: expanded } : { value: expanded } +} diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 06dfd1d2ad..8bd0d18c5f 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -57,6 +57,33 @@ function getSkipReason( return undefined } +type DisabledRoot = { dir: string; sockJson: SocketJson } + +// Nearest already-confirmed-disabled ancestor of `dir` (if any): lets the +// caller shorten `readSocketJsonCascade`'s walk to start there instead of +// all the way back at `cwd`. A build root with hundreds of nested candidates +// (a big disabled legacy reactor, say) would otherwise re-walk the same long +// ancestor chain from `cwd` for every single one. Correctness is unaffected +// - the shortened walk still checks every directory between `dir` and the +// chosen boundary, so a nested override (re-enabling a specific subproject) +// is still honored - it's just cheaper when nothing overrides it, which is +// the common case. Picks the deepest (nearest) match if several qualify. +function nearestDisabledRoot( + dir: string, + disabledRoots: readonly DisabledRoot[], +): DisabledRoot | undefined { + let nearest: DisabledRoot | undefined + for (const root of disabledRoots) { + if ( + dir.startsWith(`${root.dir}${path.sep}`) && + (!nearest || root.dir.length > nearest.dir.length) + ) { + nearest = root + } + } + return nearest +} + // Resolves this build root's effective per-ecosystem build-tool config from // its cascaded socket.json; a wrapper-preferred `bin` default is resolved // per-root (`dir`, not `cwd`) since a wrapper script only exists at the @@ -68,38 +95,39 @@ function resolveEcosystemConfig( ): EcosystemBuildConfig { if (ecosystem === 'sbt') { const config = sockJson.defaults?.manifest?.sbt + const bin = config?.bin ?? undefined return { - bin: config?.bin ?? 'sbt', - buildOpts: parseBuildToolOpts(config?.sbtOpts), + bin: bin ?? 'sbt', + buildOpts: parseBuildToolOpts(config?.sbtOpts ?? undefined), excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', - javaHome: config?.javaHome, + javaHome: config?.javaHome ?? undefined, skipReason: getSkipReason(config?.disabled, config?.facts), } } if (ecosystem === 'gradle') { const config = sockJson.defaults?.manifest?.gradle + const bin = config?.bin ?? undefined return { - bin: config?.bin - ? path.resolve(dir, config.bin) - : resolveBuildToolBin('gradle', dir), - buildOpts: parseBuildToolOpts(config?.gradleOpts), + bin: bin ? path.resolve(dir, bin) : resolveBuildToolBin('gradle', dir), + buildOpts: parseBuildToolOpts(config?.gradleOpts ?? undefined), excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', - javaHome: config?.javaHome, + javaHome: config?.javaHome ?? undefined, skipReason: getSkipReason(config?.disabled, config?.facts), } } const config = sockJson.defaults?.manifest?.maven + const bin = config?.bin ?? undefined return { - bin: config?.bin ?? resolveBuildToolBin('maven', dir), - buildOpts: parseBuildToolOpts(config?.mavenOpts), + bin: bin ?? resolveBuildToolBin('maven', dir), + buildOpts: parseBuildToolOpts(config?.mavenOpts ?? undefined), excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', - javaHome: config?.javaHome, + javaHome: config?.javaHome ?? undefined, skipReason: getSkipReason(config?.disabled), } } @@ -132,13 +160,17 @@ export async function generateRecursiveManifests({ const outcomes: RecursiveManifestOutcome[] = [] for (const [ecosystem, dirs] of candidatesByTool) { const covered = new Set() + const disabledRoots: DisabledRoot[] = [] for (const dir of dirs) { if (covered.has(dir)) { outcomes.push({ dir, ecosystem, status: 'skippedCovered' }) continue } - const sockJson = readSocketJsonCascade(dir, cwd, rootSockJson) + const nearestRoot = nearestDisabledRoot(dir, disabledRoots) + const sockJson = nearestRoot + ? readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) + : readSocketJsonCascade(dir, cwd, rootSockJson) const { bin, buildOpts, @@ -150,8 +182,16 @@ export async function generateRecursiveManifests({ } = resolveEcosystemConfig(ecosystem, dir, sockJson) if (skipReason) { - logger.warn(`Skipping ${dir} (${ecosystem}): ${skipReason}.`) + // Only warn for a genuinely new disabled root, not one already + // covered by an ancestor's warning above - otherwise a big disabled + // reactor with hundreds of nested poms would spam one warning line + // per pom for what's really a single root cause. The aggregate + // count still shows up in the final summary either way. + if (!nearestRoot) { + logger.warn(`Skipping ${dir} (${ecosystem}): ${skipReason}.`) + } outcomes.push({ dir, ecosystem, status: 'skippedDisabled' }) + disabledRoots.push({ dir, sockJson }) continue } diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index fefb3465cf..620013492f 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -207,6 +207,33 @@ describe('generateRecursiveManifests', () => { expect(javaHomeByCall.get('maven:reactor')).toBeUndefined() }) + it('resolves an explicit null override back to the no-restriction default, not a literal null', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { + defaults: { + manifest: { maven: { excludeConfigs: null, javaHome: null } }, + }, + } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + + const call = vi + .mocked(runManifestFacts) + .mock.calls.find( + ([opts]) => opts.cwd === dualMarkerDir && opts.ecosystem === 'maven', + ) + expect(call?.[0].excludeConfigs).toBe('') + expect(call?.[0].javaHome).toBeUndefined() + }) + it('skips (with a warning) a resolved config that sets facts: false, never invoking the build tool', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => @@ -246,6 +273,117 @@ describe('generateRecursiveManifests', () => { } }) + it('shortens the cascade walk for a nested candidate under an already-disabled root, instead of re-walking from cwd', async () => { + const independentSubmodule = path.join( + reactor, + 'moduleB', + 'independent-submodule', + ) + // Maven-only candidates nested under reactor; excludes the differently- + // tooled reactor/moduleA/nested-gradle, which is its own gradle-ecosystem + // candidate never marked disabled and correctly still walks from cwd. + const nestedMavenDirs = new Set([ + path.join(reactor, 'moduleA'), + path.join(reactor, 'moduleB'), + independentSubmodule, + ]) + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, boundaryDir, fallback) => { + if (dir === reactor && boundaryDir === monorepo) { + // The one full walk: reactor's own socket.json disables maven. + return { defaults: { manifest: { maven: { disabled: true } } } } + } + // Every other maven candidate nested under reactor must use a + // boundary nearer than the overall recursion root - never re-walk + // all the way back to monorepo/rootSockJson once an ancestor is + // already confirmed disabled. + if (nestedMavenDirs.has(dir)) { + expect(boundaryDir).not.toBe(monorepo) + } + return fallback + }, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:reactor')).toBe('skippedDisabled') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'skippedDisabled', + ) + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some( + ([opts]) => + opts.ecosystem === 'maven' && + opts.cwd.startsWith(reactor) && + opts.cwd !== independentSubmodule, + ), + ).toBe(false) + // Four maven candidates end up skippedDisabled (reactor + moduleA + + // moduleB + independent-submodule), but only the root cause should + // warn - otherwise a big disabled reactor spams one line per pom. + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(String(warnSpy.mock.calls[0]?.[0])).toMatch(/disabled is true/) + } finally { + warnSpy.mockRestore() + } + }) + + it('still honors a nested override that re-enables a build root under an otherwise-disabled ancestor', async () => { + const independentSubmodule = path.join( + reactor, + 'moduleB', + 'independent-submodule', + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => { + if (dir === reactor) { + return { defaults: { manifest: { maven: { disabled: true } } } } + } + if (dir === independentSubmodule) { + // Its own socket.json explicitly clears the inherited disable. + return { defaults: { manifest: { maven: { disabled: false } } } } + } + return fallback + }, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:reactor')).toBe('skippedDisabled') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'generated', + ) + } finally { + warnSpy.mockRestore() + } + }) + it('skips (with a warning) a resolved config that sets a cascaded disabled: true', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 648b363696..947df2983b 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -88,7 +88,9 @@ export async function generateAutoManifest({ // Note: `sbt` is more likely to be resolved against PATH env. bin: sockJson.defaults?.manifest?.sbt?.bin ?? 'sbt', cwd, - sbtOpts: parseBuildToolOpts(sockJson.defaults?.manifest?.sbt?.sbtOpts), + sbtOpts: parseBuildToolOpts( + sockJson.defaults?.manifest?.sbt?.sbtOpts ?? undefined, + ), verbose: Boolean(sockJson.defaults?.manifest?.sbt?.verbose), } // Socket facts is the default; opt into pom generation with @@ -104,7 +106,7 @@ export async function generateAutoManifest({ sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, ), includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '', - javaHome: sockJson.defaults?.manifest?.sbt?.javaHome, + javaHome: sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined, sidecarAcc, tmpDir, withFiles: computeArtifactsSidecar, @@ -130,7 +132,7 @@ export async function generateAutoManifest({ cwd, verbose: Boolean(sockJson.defaults?.manifest?.gradle?.verbose), gradleOpts: parseBuildToolOpts( - sockJson.defaults?.manifest?.gradle?.gradleOpts, + sockJson.defaults?.manifest?.gradle?.gradleOpts ?? undefined, ), } // Socket facts is the default; opt into pom generation with @@ -150,7 +152,7 @@ export async function generateAutoManifest({ ), includeConfigs: sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '', - javaHome: sockJson.defaults?.manifest?.gradle?.javaHome, + javaHome: sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined, sidecarAcc, withFiles: computeArtifactsSidecar, }) @@ -180,9 +182,9 @@ export async function generateAutoManifest({ sockJson.defaults?.manifest?.maven?.ignoreUnresolved, ), includeConfigs: sockJson.defaults?.manifest?.maven?.includeConfigs ?? '', - javaHome: sockJson.defaults?.manifest?.maven?.javaHome, + javaHome: sockJson.defaults?.manifest?.maven?.javaHome ?? undefined, mavenOpts: parseBuildToolOpts( - sockJson.defaults?.manifest?.maven?.mavenOpts, + sockJson.defaults?.manifest?.maven?.mavenOpts ?? undefined, ), sidecarAcc, verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose), diff --git a/src/commands/manifest/handle-manifest-setup.mts b/src/commands/manifest/handle-manifest-setup.mts index f4697e67ce..eba05a21da 100644 --- a/src/commands/manifest/handle-manifest-setup.mts +++ b/src/commands/manifest/handle-manifest-setup.mts @@ -1,11 +1,16 @@ import { outputManifestSetup } from './output-manifest-setup.mts' import { setupManifestConfig } from './setup-manifest-config.mts' +import { setupRecursiveManifestConfig } from './setup-recursive-manifest-config.mts' export async function handleManifestSetup( cwd: string, defaultOnReadError: boolean, + dynamicSbomInference = false, + excludePaths?: string[] | undefined, ): Promise { - const result = await setupManifestConfig(cwd, defaultOnReadError) + const result = dynamicSbomInference + ? await setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePaths) + : await setupManifestConfig(cwd, defaultOnReadError) await outputManifestSetup(result) } diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index e7527bfee7..da1de532ee 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' +import { expandEnvVarRefs } from './expand-env-var-refs.mts' import { renderResolutionErrorReport } from './scripts/resolution-report-render.mts' import { runManifestScript } from './scripts/run.mts' import { accumulateSidecar } from './scripts/sidecar.mts' @@ -14,29 +15,12 @@ import type { ManifestRunResult } from './scripts/run.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' const MAX_FAILURE_OUTPUT_LINES = 40 -const ENV_VAR_REF = /\$\{(\w+)\}|\$(\w+)/g export type RunManifestFactsResult = { factsPath: string projects: SocketFactsSbomProject[] } -// Expands `$VAR`/`${VAR}` references (e.g. a team-shared `javaHome: -// "$JAVA11_HOME"`) against the CLI process's own environment, so a socket.json -// value works across machines instead of hardcoding one developer's path. -function expandEnvVarRefs(value: string): { missing?: string; value: string } { - let missing: string | undefined - const expanded = value.replace(ENV_VAR_REF, (_match, braced, bare) => { - const name = braced ?? bare - const resolved = process.env[name] - if (resolved === undefined) { - missing ??= name - } - return resolved ?? '' - }) - return missing ? { missing, value: expanded } : { value: expanded } -} - // Last N non-empty lines of the captured build output, for diagnosing a crash // without forcing a --verbose rebuild. function tailBuildOutput(stdout: string, stderr: string): string { diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java new file mode 100644 index 0000000000..994535c49b --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java @@ -0,0 +1,60 @@ +package tech.coana.ext; + +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.MavenExecutionException; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.rtinfo.RuntimeInformation; +import tech.coana.socket.SocketWorkspacesRecordsEngine; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.util.Properties; + +/** + * Lightweight sibling of {@link CoanaFactsLifecycleParticipant}: loaded from the same extension + * jar, gated by {@code -Dcoana.task=socket-workspaces}. Hooks {@code afterProjectsRead} instead of + * {@code afterSessionEnd} - it fires as soon as Maven determines the reactor project list, before + * any lifecycle phase runs - and needs no {@code RepositorySystem}/{@code DependencyGraphBuilder} + * since it never builds a dependency graph. + */ +@Named("coana-workspaces") +@Singleton +public class CoanaWorkspacesLifecycleParticipant extends AbstractMavenLifecycleParticipant { + + private final RuntimeInformation runtimeInformation; + + @Inject + public CoanaWorkspacesLifecycleParticipant(RuntimeInformation runtimeInformation) { + this.runtimeInformation = runtimeInformation; + } + + @Override + public void afterProjectsRead(MavenSession session) throws MavenExecutionException { + if (!"socket-workspaces".equals(opt(session, "coana.task"))) { + return; + } + String recordsFile = opt(session, "socket.recordsFile"); + if (recordsFile == null || recordsFile.isEmpty()) { + throw new MavenExecutionException("socket-workspaces requires -Dsocket.recordsFile", new IllegalStateException()); + } + SocketWorkspacesRecordsEngine.Options opts = new SocketWorkspacesRecordsEngine.Options(); + opts.recordsFile = recordsFile; + opts.excludePaths = opt(session, "socket.excludePaths"); + File rootDir = new File(session.getExecutionRootDirectory()); + try { + SocketWorkspacesRecordsEngine.run(session.getProjects(), rootDir, opts, runtimeInformation.getMavenVersion()); + } catch (IOException exception) { + throw new MavenExecutionException("Cannot write socket workspace records", exception); + } + } + + // -D values arrive as both session user-properties and JVM system properties; prefer the former. + private static String opt(MavenSession session, String key) { + Properties user = session.getUserProperties(); + if (user != null && user.getProperty(key) != null) return user.getProperty(key); + return System.getProperty(key); + } +} diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java new file mode 100644 index 0000000000..0e70498dbb --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java @@ -0,0 +1,63 @@ +package tech.coana.socket; + +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; + +/** + * Lightweight sibling of {@link SocketFactsRecordsEngine}: emits only `meta`/`project` records + * from {@code session.getProjects()} (Maven's own reactor list, already populated before any + * lifecycle phase runs) - no dependency graph is ever built, so no {@code RepositorySystem} or + * {@code DependencyGraphBuilder} is needed. Used for cheap workspace discovery (e.g. + * `socket manifest setup --recursive`) without paying for a full facts-generation build. + */ +public final class SocketWorkspacesRecordsEngine { + + public static final class Options { + // Scan-root-relative `--exclude-paths` (CSV): a wholly excluded reactor module is skipped. + public String excludePaths; + public String recordsFile; + } + + private SocketWorkspacesRecordsEngine() {} + + public static void run(List reactor, File rootDir, Options opts, String mavenVersion) + throws IOException { + List excludes = SocketSupport.parseExcludeMatchers(opts.excludePaths); + + List lines = new ArrayList<>(); + rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + + for (MavenProject module : reactor) { + String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); + if (SocketSupport.isExcludedPath(ws, excludes)) continue; + rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); + } + + write(opts.recordsFile, lines); + } + + private static void rec(List lines, String... fields) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) sb.append('\t'); + sb.append(SocketSupport.escapeField(fields[i])); + } + lines.add(sb.toString()); + } + + private static void write(String recordsFile, List lines) throws IOException { + File out = new File(recordsFile); + if (out.getParentFile() != null) Files.createDirectories(out.getParentFile().toPath()); + try (PrintWriter writer = new PrintWriter(out, StandardCharsets.UTF_8.name())) { + for (String line : lines) writer.print(line + "\n"); + } + } +} diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index d738cd2e65..d6ee79bb86 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -10,7 +10,11 @@ import constants from '../../../constants.mts' import { withTmpDir } from '../../../utils/fs.mts' import type { BuildTool } from './build-tool.mts' -import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' +import type { + ResolvedArtifactPaths, + SocketFactsSbom, + SocketFactsSbomProject, +} from './facts.mts' import type { ResolutionReport } from './resolution-report.mts' export type ManifestScriptOptions = { @@ -54,7 +58,18 @@ export type ManifestRunResult = { type RunOutput = { code: number; stdout: string; stderr: string } const FACTS_TASK = 'socketFacts' -const SBT_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' +const WORKSPACES_TASK = 'socketWorkspaces' +// Destination filename inside sbt's isolated plugins/ dir; unrelated to the source filename, which +// is whatever socket-facts.plugin.scala / socket-workspaces.plugin.scala are called on disk. +const SBT_FACTS_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' +const SBT_WORKSPACES_PLUGIN_FILENAME = 'SocketWorkspacesPlugin.scala' + +export type WorkspaceEnumerationResult = { + code: number + projects: SocketFactsSbomProject[] + stderr: string + stdout: string +} // Bundled emitter assets, copied into dist by the rollup build. function manifestScriptsPath(...parts: string[]): string { @@ -105,14 +120,15 @@ async function runNeverThrow( } } -async function writeSbtPlugin(globalBase: string): Promise { - const src = await fs.readFile( - manifestScriptsPath('socket-facts.plugin.scala'), - 'utf8', - ) +async function writeSbtPlugin( + globalBase: string, + sourceFilename: string, + destFilename: string, +): Promise { + const src = await fs.readFile(manifestScriptsPath(sourceFilename), 'utf8') const pluginsDir = path.join(globalBase, 'plugins') await fs.mkdir(pluginsDir, { recursive: true }) - await fs.writeFile(path.join(pluginsDir, SBT_PLUGIN_FILENAME), src) + await fs.writeFile(path.join(pluginsDir, destFilename), src) } async function assembleFromRecords( @@ -160,6 +176,28 @@ export async function runManifestScript( } } +// Cheap subproject discovery: emits only `project` records, never resolving +// dependencies. Distinct from a full `runManifestScript` run, which gets the +// same subproject list for free as a side effect of the resolution it already +// has to do; this path exists for discovery BEFORE committing to that cost +// (e.g. `socket manifest setup --recursive`). +export async function enumerateWorkspaces( + tool: BuildTool, + opts: ManifestScriptOptions, +): Promise { + const result = await (tool === 'gradle' + ? enumerateGradleWorkspaces(opts) + : tool === 'sbt' + ? enumerateSbtWorkspaces(opts) + : enumerateMavenWorkspaces(opts)) + return { + code: result.code, + projects: result.facts.projects ?? [], + stderr: result.stderr, + stdout: result.stdout, + } +} + function commonProps( opts: ManifestScriptOptions, prefix: '-D' | '-P', @@ -185,11 +223,14 @@ function commonProps( return props } -async function runGradle( +async function invokeGradle( opts: ManifestScriptOptions, + initScriptFilename: string, + task: string, + tmpDirPrefix: string, ): Promise { - const initScript = manifestScriptsPath('socket-facts.init.gradle') - return await withTmpDir('socket-gradle-facts-', async tmp => { + const initScript = manifestScriptsPath(initScriptFilename) + return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') const bin = resolveBuildToolBin('gradle', opts.projectDir, opts.bin) // Disable the configuration cache: the init script's legacy @@ -201,7 +242,7 @@ async function runGradle( `-Psocket.recordsFile=${recordsFile}`, ...commonProps(opts, '-P'), ...(opts.toolOpts ?? []), - FACTS_TASK, + task, '--no-daemon', '--console=plain', ] @@ -210,11 +251,36 @@ async function runGradle( }) } -async function runSbtIn( +async function runGradle( + opts: ManifestScriptOptions, +): Promise { + return await invokeGradle( + opts, + 'socket-facts.init.gradle', + FACTS_TASK, + 'socket-gradle-facts-', + ) +} + +async function enumerateGradleWorkspaces( + opts: ManifestScriptOptions, +): Promise { + return await invokeGradle( + opts, + 'socket-workspaces.init.gradle', + WORKSPACES_TASK, + 'socket-gradle-workspaces-', + ) +} + +async function invokeSbtIn( globalBase: string, opts: ManifestScriptOptions, + pluginSourceFilename: string, + pluginDestFilename: string, + task: string, ): Promise { - await writeSbtPlugin(globalBase) + await writeSbtPlugin(globalBase, pluginSourceFilename, pluginDestFilename) const recordsFile = path.join(globalBase, 'records.tsv') const bin = resolveBuildToolBin('sbt', opts.projectDir, opts.bin) // Fresh per-run global base (not ~/.sbt): sbt executes everything under @@ -241,37 +307,62 @@ async function runSbtIn( ...props, ...(opts.toolOpts ?? []), '--batch', - FACTS_TASK, + task, ] const out = await runNeverThrow(bin, args, opts) return await assembleFromRecords(out, recordsFile) } async function runSbt(opts: ManifestScriptOptions): Promise { + const run = (globalBase: string) => + invokeSbtIn( + globalBase, + opts, + 'socket-facts.plugin.scala', + SBT_FACTS_PLUGIN_FILENAME, + FACTS_TASK, + ) if (opts.tmpDir) { - return await runSbtIn(opts.tmpDir, opts) + return await run(opts.tmpDir) } - return await withTmpDir('socket-sbt-facts-', globalBase => - runSbtIn(globalBase, opts), - ) + return await withTmpDir('socket-sbt-facts-', run) } -async function runMaven( +async function enumerateSbtWorkspaces( opts: ManifestScriptOptions, +): Promise { + const run = (globalBase: string) => + invokeSbtIn( + globalBase, + opts, + 'socket-workspaces.plugin.scala', + SBT_WORKSPACES_PLUGIN_FILENAME, + WORKSPACES_TASK, + ) + if (opts.tmpDir) { + return await run(opts.tmpDir) + } + return await withTmpDir('socket-sbt-workspaces-', run) +} + +async function invokeMaven( + opts: ManifestScriptOptions, + coanaTask: string, + tmpDirPrefix: string, ): Promise { const jarPath = manifestScriptsPath( 'maven-extension', 'coana-maven-extension.jar', ) assertMavenExtensionBuilt(jarPath) - return await withTmpDir('socket-maven-facts-', async tmp => { + return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') const bin = resolveBuildToolBin('maven', opts.projectDir, opts.bin) - // `validate` is the cheapest phase that triggers the afterSessionEnd - // extension; no compile needed (analysis uses configured paths, not classes). + // `validate` is the cheapest phase that triggers the extension; no compile + // needed (analysis uses configured paths, not classes). const props = [ `-Dmaven.ext.class.path=${jarPath}`, - '-Dcoana.task=socket-facts', + `-Dcoana.task=${coanaTask}`, `-Dsocket.recordsFile=${recordsFile}`, ...commonProps(opts, '-D'), ] @@ -285,3 +376,19 @@ async function runMaven( return await assembleFromRecords(out, recordsFile) }) } + +async function runMaven( + opts: ManifestScriptOptions, +): Promise { + return await invokeMaven(opts, 'socket-facts', 'socket-maven-facts-') +} + +async function enumerateMavenWorkspaces( + opts: ManifestScriptOptions, +): Promise { + return await invokeMaven( + opts, + 'socket-workspaces', + 'socket-maven-workspaces-', + ) +} diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle new file mode 100644 index 0000000000..a41b6ef1f3 --- /dev/null +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -0,0 +1,133 @@ +// Invoke via: +// ./gradlew --init-script socket-workspaces.init.gradle socketWorkspaces + +// Lightweight sibling of socket-facts.init.gradle: emits only `meta`/`project` records (a build's +// subproject list) with NO dependency resolution at all - no socketFactsCollect-equivalent task, no +// configurations ever touched. Kept as a wholly separate script (not an added task in the facts init +// script) so it can never affect that file's already-verified wide Gradle-version compatibility +// (1.0+). Used for cheap workspace discovery, e.g. `socket manifest setup --dynamic-sbom-inference`, +// without paying for a full facts-generation build. + +// `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. +gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } + +// `-Psocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects. Each +// entry variant yields the entry itself and `entry/**` so it matches the dir and its subtree (same +// expansion as the SCA ignore path). A trailing `/**` is stripped first, so a user-written `dir/**` +// still excludes the `dir` directory itself, not only its contents. Standard glob semantics +// (anchored to the scan root, matching the CLI flag): `x` is root-level, `**/x` matches at any +// depth. Mirrors socket-facts.init.gradle / the sbt / maven producers. +// NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's +// micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` +// occurrences dropped so both semantics hold. +gradle.ext.socketZeroDepthVariants = { String glob -> + def out = new LinkedHashSet() + def work = new ArrayDeque() + work.add(glob) + while (!work.isEmpty()) { + def cur = work.poll() + if (!out.add(cur)) { continue } + int idx = cur.indexOf('**/') + while (idx >= 0) { + if (idx == 0 || cur[idx - 1] == '/') { + def collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (!collapsed.isEmpty()) { work.add(collapsed) } + } + idx = cur.indexOf('**/', idx + 1) + } + } + out +} +gradle.ext.socketExcludeMatchersCache = null +gradle.ext.socketExcludeMatchers = { + if (gradle.ext.socketExcludeMatchersCache != null) return gradle.ext.socketExcludeMatchersCache + def raw = gradle.socketProp.call(gradle.rootProject, 'socket.excludePaths')?.toString() + def matchers = [] + if (raw != null && !raw.trim().isEmpty()) { + def fs = java.nio.file.FileSystems.getDefault() + raw.split(',').each { r -> + def g = r.trim().replace('\\', '/') + while (g.startsWith('/')) { g = g.substring(1) } + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + while (g.endsWith('/**')) { + g = g.substring(0, g.length() - 3) + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + } + if (!g.isEmpty()) { + gradle.ext.socketZeroDepthVariants.call(g).each { v -> + matchers << fs.getPathMatcher('glob:' + v) + matchers << fs.getPathMatcher('glob:' + v + '/**') + } + } + } + } + gradle.ext.socketExcludeMatchersCache = matchers + matchers +} +gradle.ext.socketIsExcluded = { String rel -> + def c = (rel == null ? '' : rel).replace('\\', '/') + while (c.startsWith('./')) { c = c.substring(2) } + while (c.startsWith('/')) { c = c.substring(1) } + while (c.endsWith('/')) { c = c.substring(0, c.length() - 1) } + if (c.isEmpty()) { return false } + def p = java.nio.file.Paths.get(c) + gradle.ext.socketExcludeMatchers.call().any { m -> m.matches(p) } +} + +gradle.ext.socketWorkspacesInfo = Collections.synchronizedList([]) + +// Collect every non-excluded project's identity at configuration time - cheap, no dependency +// resolution, no sourceSets/jar introspection (unlike the full facts script, which also stamps +// sources/targets/artifact-ext for resolution-time use). +gradle.projectsEvaluated { g -> + def rootPath = g.rootProject.projectDir.toPath() + def rel = { java.io.File f -> + def r = rootPath.relativize(f.toPath()).toString().replace(File.separator, '/') + r.isEmpty() ? '.' : r + } + g.rootProject.allprojects.each { p -> + if (g.ext.socketIsExcluded.call(rel(p.projectDir))) { + return + } + g.socketWorkspacesInfo.add([ + path : p.path, + group : (p.group ?: '').toString(), + name : p.name, + version: (p.version ?: '').toString(), + dir : rel(p.projectDir), + ]) + } +} + +rootProject { rp -> + def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() + def defaultRecordsFile = new File(rp.projectDir, '.socket.workspaces.records.tsv').absolutePath + + rp.tasks.create('socketWorkspaces') { + group = 'socket' + description = 'Emits Socket workspace records (project list only; no dependency resolution)' + outputs.upToDateWhen { false } + + doLast { + def esc = { v -> + (v == null ? '' : v.toString()) + .replace('\\', '\\\\').replace('\t', '\\t').replace('\n', '\\n').replace('\r', '\\r') + } + def lines = [] + def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } + + rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + + def info + synchronized (gradle.socketWorkspacesInfo) { info = new ArrayList(gradle.socketWorkspacesInfo) } + info.each { pi -> + rec(['project', pi.path, pi.group, pi.name, pi.version, pi.dir]) + } + + def outFile = new File(recordsFileOverride ?: defaultRecordsFile) + outFile.parentFile?.mkdirs() + outFile.withWriter('UTF-8') { it.write(lines.join('\n') + '\n') } + println "Socket workspace records written to: ${outFile.absolutePath}" + } + } +} diff --git a/src/commands/manifest/scripts/socket-workspaces.plugin.scala b/src/commands/manifest/scripts/socket-workspaces.plugin.scala new file mode 100644 index 0000000000..8cc9c36feb --- /dev/null +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -0,0 +1,149 @@ +package socket + +import sbt._ +import sbt.Keys._ + +import scala.collection.mutable + +/** + * Lightweight sibling of SocketFactsPlugin (socket-facts.plugin.scala): emits only + * `meta`/`project` records (a build's subproject list) with NO dependency resolution at all - no + * `update`/`updateFull` is ever run. Kept as a wholly separate plugin (not a flag on the facts + * task) so it can never affect that file's already-verified wide sbt-version compatibility + * (0.13.x+). Used for cheap workspace discovery, e.g. + * `socket manifest setup --dynamic-sbom-inference`, without paying for a full facts-generation build. + * + * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x, same constraint as the facts plugin. + */ +object SocketWorkspacesPlugin extends AutoPlugin { + override def trigger = allRequirements + + object autoImport { + val socketWorkspaces = + taskKey[Unit]("Emit Socket workspace records (project list only; no dependency resolution)") + } + import autoImport._ + + override def projectSettings: Seq[Setting[_]] = Seq( + aggregate in socketWorkspaces := false, + socketWorkspaces := { + val st = state.value + val buildRoot = (baseDirectory in ThisBuild).value + + val extracted = Project.extract(st) + val allRefs = extracted.structure.allProjectRefs + + // `-Dsocket.excludePaths` (scan-root-relative globs): a subproject whose dir is wholly excluded + // emits no project record. Mirrors socket-facts.plugin.scala / the gradle / maven producers. + val excludeMatchers = parseExcludeMatchers() + val rootCanonPath = buildRoot.getCanonicalFile.toPath + def relOf(f: File): String = { + val r = rootCanonPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/") + if (r.isEmpty) "." else r + } + def isExcludedRef(ref: ProjectRef): Boolean = + isExcludedPath(relOf(extracted.get(baseDirectory.in(ref))), excludeMatchers) + + def rootIdOf(ref: ProjectRef): ModuleID = { + val sv = extracted.get(scalaVersion.in(ref)) + val sbv = extracted.get(scalaBinaryVersion.in(ref)) + CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref))) + } + + val sb = new StringBuilder + def rec(fields: String*): Unit = { + sb.append(fields.map(esc).mkString("\t")); sb.append('\n') + } + + rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) + + allRefs.foreach { ref => + if (!isExcludedRef(ref)) { + val mid = rootIdOf(ref) + val ver = if (mid.revision == null) "" else mid.revision + rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref)))) + } + } + + val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match { + case Some(p) => new File(p) + case None => new File(buildRoot, ".socket.workspaces.records.tsv") + } + Option(recordsFile.getParentFile).foreach(_.mkdirs()) + IO.write(recordsFile, sb.toString) + println("Socket workspace records written to: " + recordsFile.getAbsolutePath) + } + ) + + // ---- config selection / path exclusion (mirrors socket-facts.plugin.scala) ---------------- + + // `-Dsocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects. Each + // entry variant yields the entry itself and `entry/**` so it matches the dir and its subtree (same + // expansion as the SCA ignore path). A trailing `/**` is stripped first, so a user-written `dir/**` + // still excludes the `dir` directory itself, not only its contents. Standard glob semantics + // (anchored to the scan root, matching the CLI flag): `x` is root-level; `**`/`x` matches at any + // depth. Mirrors the gradle/maven producers. + private def parseExcludeMatchers(): Seq[java.nio.file.PathMatcher] = { + sys.props.get("socket.excludePaths").map(_.trim).filter(_.nonEmpty) match { + case None => Nil + case Some(raw) => + val fs = java.nio.file.FileSystems.getDefault + raw.split(",").toSeq.flatMap { r => + var g = r.trim.replace("\\", "/") + while (g.startsWith("/")) g = g.substring(1) + while (g.endsWith("/")) g = g.substring(0, g.length - 1) + while (g.endsWith("/**")) { + g = g.substring(0, g.length - 3) + while (g.endsWith("/")) g = g.substring(0, g.length - 1) + } + if (g.isEmpty) Nil + else zeroDepthVariants(g).flatMap { v => + Seq(fs.getPathMatcher("glob:" + v), fs.getPathMatcher("glob:" + v + "/**")) + } + } + } + } + + // NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's + // micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` + // occurrences dropped so both semantics hold. + private def zeroDepthVariants(glob: String): Seq[String] = { + val out = mutable.LinkedHashSet[String]() + val work = mutable.Queue(glob) + while (work.nonEmpty) { + val cur = work.dequeue() + if (out.add(cur)) { + var idx = cur.indexOf("**/") + while (idx >= 0) { + if (idx == 0 || cur.charAt(idx - 1) == '/') { + val collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (collapsed.nonEmpty) work.enqueue(collapsed) + } + idx = cur.indexOf("**/", idx + 1) + } + } + } + out.toSeq + } + + private def isExcludedPath(rel: String, matchers: Seq[java.nio.file.PathMatcher]): Boolean = { + if (matchers.isEmpty) false + else { + var c = (if (rel == null) "" else rel).replace("\\", "/") + while (c.startsWith("./")) c = c.substring(2) + while (c.startsWith("/")) c = c.substring(1) + while (c.endsWith("/")) c = c.substring(0, c.length - 1) + if (c.isEmpty) false + else { + val p = java.nio.file.Paths.get(c) + matchers.exists(_.matches(p)) + } + } + } + + // Backslash-escape so a value can never break line/field framing (see records.ts unescape). + private def esc(v: String): String = { + if (v == null) "" + else v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r") + } +} diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index 748eb8629f..a3b37b9393 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -269,29 +269,36 @@ async function setupConda( return notCanceled() } -async function setupGradle( +export async function setupGradle( config: NonNullable< NonNullable['manifest']>['gradle'] >, ): Promise> { + const priorBin = config.bin const bin = await askForBin(config.bin || './gradlew') if (bin === undefined) { return canceledByUser() } else if (bin) { config.bin = bin + } else if (priorBin) { + config.bin = null } else { delete config.bin } + const priorJavaHome = config.javaHome const javaHome = await askForJavaHome(config.javaHome || '') if (javaHome === undefined) { return canceledByUser() } else if (javaHome) { config.javaHome = javaHome + } else if (priorJavaHome) { + config.javaHome = null } else { delete config.javaHome } + const priorGradleOpts = config.gradleOpts const opts = await input({ message: '(--gradle-opts) Enter gradle options to pass through', default: config.gradleOpts || '', @@ -302,6 +309,8 @@ async function setupGradle( return canceledByUser() } else if (opts) { config.gradleOpts = opts + } else if (priorGradleOpts) { + config.gradleOpts = null } else { delete config.gradleOpts } @@ -336,29 +345,36 @@ async function setupGradle( return notCanceled() } -async function setupMaven( +export async function setupMaven( config: NonNullable< NonNullable['manifest']>['maven'] >, ): Promise> { + const priorBin = config.bin const bin = await askForBin(config.bin || 'mvn') if (bin === undefined) { return canceledByUser() } else if (bin) { config.bin = bin + } else if (priorBin) { + config.bin = null } else { delete config.bin } + const priorJavaHome = config.javaHome const javaHome = await askForJavaHome(config.javaHome || '') if (javaHome === undefined) { return canceledByUser() } else if (javaHome) { config.javaHome = javaHome + } else if (priorJavaHome) { + config.javaHome = null } else { delete config.javaHome } + const priorMavenOpts = config.mavenOpts const opts = await input({ message: '(--maven-opts) Enter maven options to pass through', default: config.mavenOpts || '', @@ -368,6 +384,8 @@ async function setupMaven( return canceledByUser() } else if (opts) { config.mavenOpts = opts + } else if (priorMavenOpts) { + config.mavenOpts = null } else { delete config.mavenOpts } @@ -391,29 +409,36 @@ async function setupMaven( return notCanceled() } -async function setupSbt( +export async function setupSbt( config: NonNullable< NonNullable['manifest']>['sbt'] >, ): Promise> { + const priorBin = config.bin const bin = await askForBin(config.bin || 'sbt') if (bin === undefined) { return canceledByUser() } else if (bin) { config.bin = bin + } else if (priorBin) { + config.bin = null } else { delete config.bin } + const priorJavaHome = config.javaHome const javaHome = await askForJavaHome(config.javaHome || '') if (javaHome === undefined) { return canceledByUser() } else if (javaHome) { config.javaHome = javaHome + } else if (priorJavaHome) { + config.javaHome = null } else { delete config.javaHome } + const priorSbtOpts = config.sbtOpts const opts = await input({ message: '(--sbt-opts) Enter sbt options to pass through', default: config.sbtOpts || '', @@ -424,6 +449,8 @@ async function setupSbt( return canceledByUser() } else if (opts) { config.sbtOpts = opts + } else if (priorSbtOpts) { + config.sbtOpts = null } else { delete config.sbtOpts } @@ -672,10 +699,11 @@ async function askForIgnoreUnresolvedFlag( // Prompts for the facts-only options shared by gradle and sbt: the config // include/exclude filters and --ignore-unresolved. Mutates `config` in place. async function setupFactsOptions(config: { - excludeConfigs?: string | undefined + excludeConfigs?: string | undefined | null ignoreUnresolved?: boolean | undefined - includeConfigs?: string | undefined + includeConfigs?: string | undefined | null }): Promise> { + const priorIncludeConfigs = config.includeConfigs const includeConfigs = await input({ message: '(--include-configs) Comma-separated config-name globs to resolve (blank = all configurations)', @@ -686,10 +714,15 @@ async function setupFactsOptions(config: { return canceledByUser() } else if (includeConfigs) { config.includeConfigs = includeConfigs + } else if (priorIncludeConfigs) { + // Was previously set; clear it explicitly instead of just deleting the + // key, so it doesn't silently start inheriting an ancestor's value again. + config.includeConfigs = null } else { delete config.includeConfigs } + const priorExcludeConfigs = config.excludeConfigs const excludeConfigs = await input({ message: '(--exclude-configs) Comma-separated config-name globs to skip (blank = none)', @@ -700,6 +733,8 @@ async function setupFactsOptions(config: { return canceledByUser() } else if (excludeConfigs) { config.excludeConfigs = excludeConfigs + } else if (priorExcludeConfigs) { + config.excludeConfigs = null } else { delete config.excludeConfigs } diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts new file mode 100644 index 0000000000..8efc6fb283 --- /dev/null +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -0,0 +1,362 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' + +import { + findBuildToolCandidates, + realpathOrResolved, +} from './discover-manifest-roots.mts' +import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' +import { SOCKET_JSON } from '../../constants.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, + readSocketJsonSync, + writeSocketJson, +} from '../../utils/socket-json.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { CResult } from '../../types.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +type Candidate = { dir: string; ecosystem: BuildTool } + +function canceledByUser(): CResult<{ canceled: boolean }> { + logger.log('') + logger.info('User canceled') + logger.log('') + return { ok: true, data: { canceled: true } } +} + +function notCanceled(): CResult<{ canceled: boolean }> { + return { ok: true, data: { canceled: false } } +} + +function getEcosystemSection( + sockJson: SocketJson, + ecosystem: BuildTool, +): Record { + return ( + (sockJson.defaults?.manifest?.[ecosystem] as + | Record + | undefined) ?? {} + ) +} + +// Depth-then-path sort so a disabled ancestor is always written before its +// descendants - required for the cascade no-op check in disableCandidate to +// see an ancestor's just-written `disabled: true`. +export function sortCandidatesForDisplay( + candidates: readonly Candidate[], + cwd: string, +): Candidate[] { + return [...candidates].sort((a, b) => { + const relA = path.relative(cwd, a.dir) + const relB = path.relative(cwd, b.dir) + const depthA = relA.split(path.sep).length + const depthB = relB.split(path.sep).length + if (depthA !== depthB) { + return depthA - depthB + } + if (relA !== relB) { + return relA < relB ? -1 : 1 + } + return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0 + }) +} + +// Discovers every gradle/sbt/maven build root beneath `cwd` (a plain +// filesystem walk, no dependency resolution and no build-tool invocation - +// so no bin/javaHome is ever needed) and returns the ones that should end up +// disabled: anything matching `--exclude-paths`. `cwd` itself is excluded - +// it already got its own wizard pass. Comparing an unfiltered walk against an +// excludePaths-filtered walk (both via the same findBuildToolCandidates +// fast-glob machinery, which already treats --exclude-paths as anchored +// ignores that prevent descending into a matched subtree at all) avoids +// re-implementing that matching logic. `cwd` is realpath-resolved before +// comparing: the discovered dirs findBuildToolCandidates returns already are +// (it resolves symlinks so results are stable), and on macOS /tmp -> +// /private/tmp alone is enough to otherwise break the comparison. +export async function discoverExcludedCandidates({ + cwd, + excludePaths, + rootSockJson, +}: { + cwd: string + excludePaths?: string[] | undefined + rootSockJson: SocketJson +}): Promise { + const realCwd = await realpathOrResolved(cwd) + const [fullByTool, includedByTool] = await Promise.all([ + findBuildToolCandidates({ cwd, sockJson: rootSockJson }), + findBuildToolCandidates({ cwd, excludePaths, sockJson: rootSockJson }), + ]) + + const result: Candidate[] = [] + for (const [ecosystem, fullDirs] of fullByTool) { + const includedDirs = new Set(includedByTool.get(ecosystem) ?? []) + for (const dir of fullDirs) { + if (dir === realCwd || includedDirs.has(dir)) { + continue + } + result.push({ dir, ecosystem }) + } + } + return result +} + +// Marks one excluded build root's own socket.json `disabled: true` - a no-op +// if its cascade (an already-disabled ancestor, processed earlier in the same +// depth-ordered pass) already covers it, so only the topmost excluded +// directory in a subtree gets an explicit write. +export async function disableCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + rootSockJson: SocketJson +}): Promise> { + const relDir = path.relative(cwd, dir) || '.' + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + const cascadeSection = getEcosystemSection(cascade, ecosystem) + if (cascadeSection['disabled'] === true) { + return notCanceled() + } + + const ownSockJson = readOrDefaultSocketJson(dir) + if (!ownSockJson.defaults) { + ownSockJson.defaults = {} + } + if (!ownSockJson.defaults.manifest) { + ownSockJson.defaults.manifest = {} + } + const ownSection = getEcosystemSection(ownSockJson, ecosystem) + ;(ownSockJson.defaults.manifest as Record)[ecosystem] = { + ...ownSection, + disabled: true, + } + + const writeResult = await writeSocketJson(dir, ownSockJson) + if (!writeResult.ok) { + return writeResult + } + logger.success(`Disabled ${relDir} (${ecosystem})`) + return notCanceled() +} + +async function askYesNo(message: string): Promise { + return (await select({ + message, + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + })) as boolean | null +} + +// The recursive flow's root step: unlike the plain single-project wizard +// (`setupManifestConfig`, which assumes `cwd` IS a specific ecosystem's +// project and only lets you configure one before finishing), the recursion +// root is often just a common ancestor with no project of its own - so walk +// all three JVM ecosystems in a fixed order, asking yes/no whether to set +// baseline defaults for each, instead of picking one from a menu. Declining +// all three is a normal (non-canceled) outcome, not an abort - the +// exclude-paths-driven part of the recursive setup still proceeds. +async function setupRecursiveRootDefaults( + cwd: string, + defaultOnReadError: boolean, +): Promise> { + const jsonPath = path.join(cwd, SOCKET_JSON) + if (existsSync(jsonPath)) { + logger.info(`Found ${SOCKET_JSON} at ${jsonPath}`) + } else { + logger.info(`No ${SOCKET_JSON} found at ${cwd}, will generate a new one`) + } + + logger.log('') + logger.log( + 'Note: This tool will set up flag and argument defaults for certain', + ) + logger.log(' CLI commands. You can still override them by explicitly') + logger.log(' setting the flag. It is meant to be a convenience tool.') + logger.log('') + logger.log( + `This command will generate a ${SOCKET_JSON} file in the target cwd,`, + ) + logger.log( + 'used as the fallback for every build root beneath it that inherits', + ) + logger.log("(rather than overrides) a given field, instead of the CLI's") + logger.log('own hardcoded defaults.') + logger.log('') + + const sockJsonCResult = readSocketJsonSync(cwd, defaultOnReadError) + if (!sockJsonCResult.ok) { + return sockJsonCResult + } + const sockJson = sockJsonCResult.data + if (!sockJson.defaults) { + sockJson.defaults = {} + } + if (!sockJson.defaults.manifest) { + sockJson.defaults.manifest = {} + } + + let configuredAny = false + + const wantsMaven = await askYesNo('Configure Maven defaults?') + if (wantsMaven === undefined || wantsMaven === null) { + return canceledByUser() + } + if (wantsMaven) { + if (!sockJson.defaults.manifest.maven) { + sockJson.defaults.manifest.maven = {} + } + const result = await setupMaven(sockJson.defaults.manifest.maven) + if (!result.ok || result.data.canceled) { + return result + } + configuredAny = true + } + + const wantsGradle = await askYesNo('Configure Gradle defaults?') + if (wantsGradle === undefined || wantsGradle === null) { + return canceledByUser() + } + if (wantsGradle) { + if (!sockJson.defaults.manifest.gradle) { + sockJson.defaults.manifest.gradle = {} + } + const result = await setupGradle(sockJson.defaults.manifest.gradle) + if (!result.ok || result.data.canceled) { + return result + } + configuredAny = true + } + + const wantsSbt = await askYesNo('Configure sbt defaults?') + if (wantsSbt === undefined || wantsSbt === null) { + return canceledByUser() + } + if (wantsSbt) { + if (!sockJson.defaults.manifest.sbt) { + sockJson.defaults.manifest.sbt = {} + } + const result = await setupSbt(sockJson.defaults.manifest.sbt) + if (!result.ok || result.data.canceled) { + return result + } + configuredAny = true + } + + if (!configuredAny) { + logger.log('') + logger.log('No root-level defaults configured.') + return notCanceled() + } + + logger.log('') + logger.log(`Setup complete. Writing ${SOCKET_JSON}`) + logger.log('') + + if ( + await select({ + message: `Do you want to write the new config to ${jsonPath} ?`, + choices: [ + { name: 'yes', value: true, description: 'Update config' }, + { name: 'no', value: false, description: 'Do not update the config' }, + ], + }) + ) { + const writeResult = await writeSocketJson(cwd, sockJson) + if (!writeResult.ok) { + return writeResult + } + return notCanceled() + } + return canceledByUser() +} + +// `socket manifest setup --dynamic-sbom-inference`: configures `cwd` via +// `setupRecursiveRootDefaults` first, then walks every gradle/sbt/maven +// build root beneath it and marks `disabled: true` on whatever matches +// `--exclude-paths` - a project not covered by it is assumed to be one the +// user wants included and is left completely untouched. To customize +// bin/JDK/config filters for a *specific* project, run the plain +// `socket manifest setup ` on it directly; this recursive mode only +// ever writes `disabled: true`, never per-field overrides. This sidesteps a +// real circularity the earlier "discover via enumeration, then prompt" +// design had: enumerating a nested project's own subprojects needs a +// resolved bin/javaHome for that specific project, which isn't known until +// after prompting for it - but prompting-before-discovery doesn't work when +// the discovery itself is what surfaces the project to prompt about. Pure +// path-based exclusion needs neither: it never invokes a build tool at all. +export async function setupRecursiveManifestConfig( + cwd: string, + defaultOnReadError: boolean, + excludePaths?: string[] | undefined, +): Promise> { + logger.log('') + logger.log(`Configuring the root project at ${cwd} ...`) + const rootResult = await setupRecursiveRootDefaults(cwd, defaultOnReadError) + if (!rootResult.ok) { + return rootResult + } + if (rootResult.data.canceled) { + return canceledByUser() + } + + logger.log('') + const wantsDiscovery = await askYesNo('Recursively discover build roots?') + if (wantsDiscovery === undefined || wantsDiscovery === null) { + return canceledByUser() + } + if (!wantsDiscovery) { + logger.log('') + logger.success('Recursive setup complete.') + return notCanceled() + } + + // Re-read: the root wizard may have just written a new socket.json. + const rootSockJson = readOrDefaultSocketJson(cwd) + // Resolved once here for sortCandidatesForDisplay/disableCandidate's + // relative-path math, consistent with discoverExcludedCandidates' own + // internal resolution (see its comment for why this matters). + const realCwd = await realpathOrResolved(cwd) + + logger.log('') + logger.log('Discovering build roots to exclude ...') + const toDisable = await discoverExcludedCandidates({ + cwd, + excludePaths, + rootSockJson, + }) + if (!toDisable.length) { + logger.log('No excluded build roots found.') + return notCanceled() + } + + const ordered = sortCandidatesForDisplay(toDisable, realCwd) + for (const candidate of ordered) { + // eslint-disable-next-line no-await-in-loop + const result = await disableCandidate({ + cwd: realCwd, + dir: candidate.dir, + ecosystem: candidate.ecosystem, + rootSockJson, + }) + if (!result.ok) { + return result + } + } + + logger.log('') + logger.success('Recursive setup complete.') + return notCanceled() +} diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts new file mode 100644 index 0000000000..17ee75b505 --- /dev/null +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -0,0 +1,383 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./discover-manifest-roots.mts', () => ({ + findBuildToolCandidates: vi.fn(), + // Identity: test dirs are already-absolute plain strings, no symlinks involved. + realpathOrResolved: vi.fn(async (dir: string) => dir), +})) +vi.mock('@socketsecurity/registry/lib/prompts', () => ({ + select: vi.fn(), +})) +vi.mock('./setup-manifest-config.mts', () => ({ + setupGradle: vi.fn(), + setupMaven: vi.fn(), + setupSbt: vi.fn(), +})) +vi.mock('../../utils/socket-json.mts', () => ({ + readOrDefaultSocketJson: vi.fn(), + readSocketJsonCascade: vi.fn(), + readSocketJsonSync: vi.fn(), + writeSocketJson: vi.fn(), +})) + +import { select } from '@socketsecurity/registry/lib/prompts' + +import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' +import { + disableCandidate, + discoverExcludedCandidates, + setupRecursiveManifestConfig, + sortCandidatesForDisplay, +} from './setup-recursive-manifest-config.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, + readSocketJsonSync, + writeSocketJson, +} from '../../utils/socket-json.mts' + +import type { SocketJson } from '../../utils/socket-json.mts' + +function emptySockJson(): SocketJson { + return { version: 1 } as SocketJson +} + +// In-memory `dir -> own gradle section` store backing readSocketJsonCascade/ +// readOrDefaultSocketJson/writeSocketJson, so a write made while processing +// one candidate is visible (via cascade) to a later candidate nested under +// it - the exact ordering behavior the "topmost only" tests exercise. +function makeFakeGradleDisk(cwd: string) { + const store = new Map>() + const ancestorsFarToNear = (dir: string): string[] => { + const chain: string[] = [] + let current = dir + while (current !== cwd) { + chain.unshift(current) + const parent = current.slice(0, current.lastIndexOf('/')) + if (!parent || parent === current) { + break + } + current = parent + } + return chain + } + return { + readOrDefaultSocketJson: vi.fn((dir: string) => { + const own = store.get(dir) + return own + ? ({ + version: 1, + defaults: { manifest: { gradle: own } }, + } as SocketJson) + : emptySockJson() + }), + readSocketJsonCascade: vi.fn((dir: string) => { + let merged: Record = {} + for (const ancestor of ancestorsFarToNear(dir)) { + const own = store.get(ancestor) + if (own) { + merged = { ...merged, ...own } + } + } + return { + version: 1, + defaults: { manifest: { gradle: merged } }, + } as SocketJson + }), + writeSocketJson: vi.fn(async (dir: string, sockJson: SocketJson) => { + store.set( + dir, + (sockJson.defaults?.manifest?.gradle as Record) ?? {}, + ) + return { ok: true, data: undefined } + }), + } +} + +describe('sortCandidatesForDisplay', () => { + const cwd = '/repo' + + it('sorts shallower dirs before deeper ones, parent before child', () => { + const sorted = sortCandidatesForDisplay( + [ + { dir: '/repo/module-b/standalone-gradle-lib', ecosystem: 'gradle' }, + { dir: '/repo/independent-service', ecosystem: 'maven' }, + ], + cwd, + ) + expect(sorted).toEqual([ + { dir: '/repo/independent-service', ecosystem: 'maven' }, + { dir: '/repo/module-b/standalone-gradle-lib', ecosystem: 'gradle' }, + ]) + }) +}) + +describe('discoverExcludedCandidates', () => { + const cwd = '/repo' + + beforeEach(() => { + vi.mocked(findBuildToolCandidates).mockReset() + }) + + it('excludes cwd itself', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['gradle', [cwd]]]), + ) + + const result = await discoverExcludedCandidates({ + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual([]) + }) + + it('marks a dir excluded when it is present in the full walk but absent from the excludePaths-filtered walk', async () => { + const legacy = '/repo/legacy' + const active = '/repo/active' + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map([['gradle', [active]]]) + : new Map([['gradle', [legacy, active]]]), + ) + + const result = await discoverExcludedCandidates({ + cwd, + excludePaths: ['legacy'], + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual([{ dir: legacy, ecosystem: 'gradle' }]) + }) +}) + +describe('disableCandidate', () => { + const cwd = '/repo' + const dir = '/repo/legacy' + + beforeEach(() => { + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + }) + + it('no-ops when the cascade already shows disabled', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + + await disableCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('writes disabled:true, preserving other own-file content', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { gradle: { bin: './gradlew' } } }, + }) as SocketJson, + ) + + await disableCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { + manifest: { gradle: { bin: './gradlew', disabled: true } }, + }, + }), + ) + }) +}) + +describe('setupRecursiveManifestConfig', () => { + const cwd = '/repo' + + beforeEach(() => { + vi.mocked(select).mockReset() + // Default: decline all three root ecosystem questions ("No" x3), then + // never reach the write-confirmation select at all (configuredAny stays + // false) - matches the common case of a root with no baseline defaults. + vi.mocked(select).mockResolvedValue(false) + vi.mocked(setupGradle).mockReset() + vi.mocked(setupMaven).mockReset() + vi.mocked(setupSbt).mockReset() + vi.mocked(readSocketJsonSync).mockReset() + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: true, + data: emptySockJson(), + })) + vi.mocked(findBuildToolCandidates).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + }) + + it('proceeds to discovery when all three root ecosystem questions are declined', async () => { + vi.mocked(select) + // Configure Maven/Gradle/sbt? -> no, no, no. + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + // Recursively discover...? -> yes. + .mockResolvedValue(true) + vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupGradle).not.toHaveBeenCalled() + expect(setupMaven).not.toHaveBeenCalled() + expect(setupSbt).not.toHaveBeenCalled() + expect(writeSocketJson).not.toHaveBeenCalled() + expect(findBuildToolCandidates).toHaveBeenCalled() + }) + + it('skips discovery entirely when the user declines the recursive-discovery gate', async () => { + vi.mocked(select) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + // Recursively discover...? -> no. + .mockResolvedValue(false) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(findBuildToolCandidates).not.toHaveBeenCalled() + }) + + it('configures maven at the root and writes it, then still proceeds to discovery', async () => { + vi.mocked(select) + // Configure Maven? -> yes. + .mockResolvedValueOnce(true) + // Configure Gradle? -> no. + .mockResolvedValueOnce(false) + // Configure sbt? -> no. + .mockResolvedValueOnce(false) + // Write the config? -> yes. + .mockResolvedValueOnce(true) + vi.mocked(setupMaven).mockResolvedValue({ + ok: true, + data: { canceled: false }, + }) + vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith(cwd, expect.any(Object)) + }) + + it('stops when canceling one of the root ecosystem questions', async () => { + vi.mocked(select).mockResolvedValueOnce(null) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok && result.data.canceled).toBe(true) + expect(findBuildToolCandidates).not.toHaveBeenCalled() + }) + + it('stops when a root ecosystem sub-wizard is canceled', async () => { + vi.mocked(select).mockResolvedValueOnce(true) + vi.mocked(setupMaven).mockResolvedValue({ + ok: true, + data: { canceled: true }, + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok && result.data.canceled).toBe(true) + expect(findBuildToolCandidates).not.toHaveBeenCalled() + }) + + it('propagates a hard failure reading the root socket.json', async () => { + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: false, + message: 'boom', + })) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok).toBe(false) + expect(findBuildToolCandidates).not.toHaveBeenCalled() + }) + + it('reports nothing excluded without writing anything', async () => { + vi.mocked(select) + // Configure Maven/Gradle/sbt? -> no, no, no. + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + // Recursively discover...? -> yes. + .mockResolvedValue(true) + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['gradle', [`${cwd}/active`]]]), + ) + + const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(findBuildToolCandidates).toHaveBeenCalled() + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('only writes disabled:true to the topmost of an excluded subtree', async () => { + vi.mocked(select) + // Configure Maven/Gradle/sbt? -> no, no, no. + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + // Recursively discover...? -> yes. + .mockResolvedValue(true) + const legacy = `${cwd}/legacy` + const nested = `${legacy}/nested` + const fake = makeFakeGradleDisk(cwd) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + fake.readOrDefaultSocketJson, + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + fake.readSocketJsonCascade, + ) + vi.mocked(writeSocketJson).mockImplementation(fake.writeSocketJson) + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map([['gradle', []]]) + : new Map([['gradle', [legacy, nested]]]), + ) + + const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith( + legacy, + expect.objectContaining({ + defaults: { manifest: { gradle: { disabled: true } } }, + }), + ) + }) +}) diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 1e95a8c4c7..f6196eab79 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -64,15 +64,17 @@ export interface SocketJson { // Cascaded (any level): also skips that build root in // dynamic-sbom-inference specifically. disabled?: boolean | undefined - bin?: string | undefined - excludeConfigs?: string | undefined - includeConfigs?: string | undefined + // A field set to null explicitly clears an inherited cascade value + // (widens back to the tool default) instead of restating it. + bin?: string | undefined | null + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null facts?: boolean | undefined - gradleOpts?: string | undefined + gradleOpts?: string | undefined | null ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. - javaHome?: string | undefined + javaHome?: string | undefined | null verbose?: boolean | undefined } maven?: { @@ -80,14 +82,14 @@ export interface SocketJson { // Cascaded (any level): also skips that build root in // dynamic-sbom-inference specifically. disabled?: boolean | undefined - bin?: string | undefined - excludeConfigs?: string | undefined - includeConfigs?: string | undefined + bin?: string | undefined | null + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. - javaHome?: string | undefined - mavenOpts?: string | undefined + javaHome?: string | undefined | null + mavenOpts?: string | undefined | null verbose?: boolean | undefined } sbt?: { @@ -95,18 +97,18 @@ export interface SocketJson { // Cascaded (any level): also skips that build root in // dynamic-sbom-inference specifically. disabled?: boolean | undefined - infile?: string | undefined + infile?: string | undefined | null stdin?: boolean | undefined - bin?: string | undefined - excludeConfigs?: string | undefined - includeConfigs?: string | undefined + bin?: string | undefined | null + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null facts?: boolean | undefined ignoreUnresolved?: boolean | undefined // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports // $VAR/${VAR} expansion against the CLI's own environment. - javaHome?: string | undefined - outfile?: string | undefined - sbtOpts?: string | undefined + javaHome?: string | undefined | null + outfile?: string | undefined | null + sbtOpts?: string | undefined | null stdout?: boolean | undefined verbose?: boolean | undefined } @@ -155,14 +157,6 @@ export async function readOrDefaultSocketJsonUp( return getDefaultSocketJson() } -const MANIFEST_ECOSYSTEMS = [ - 'bazel', - 'conda', - 'gradle', - 'maven', - 'sbt', -] as const - // Shallow-merges `defaults.manifest.` per ecosystem: fields present // in `override` win, fields it doesn't set fall through to `base`. Everything // outside `defaults.manifest` (scan-level defaults, etc.) comes from `base` @@ -179,11 +173,24 @@ function mergeManifestDefaults( const mergedManifest: NonNullable< NonNullable['manifest'] > = { ...baseManifest } - for (const eco of MANIFEST_ECOSYSTEMS) { - if (overrideManifest[eco]) { - mergedManifest[eco] = { ...baseManifest?.[eco], ...overrideManifest[eco] } + if (overrideManifest.bazel) { + mergedManifest.bazel = { ...baseManifest?.bazel, ...overrideManifest.bazel } + } + if (overrideManifest.conda) { + mergedManifest.conda = { ...baseManifest?.conda, ...overrideManifest.conda } + } + if (overrideManifest.gradle) { + mergedManifest.gradle = { + ...baseManifest?.gradle, + ...overrideManifest.gradle, } } + if (overrideManifest.maven) { + mergedManifest.maven = { ...baseManifest?.maven, ...overrideManifest.maven } + } + if (overrideManifest.sbt) { + mergedManifest.sbt = { ...baseManifest?.sbt, ...overrideManifest.sbt } + } return { ...base, defaults: { ...base.defaults, manifest: mergedManifest }, diff --git a/src/utils/socket-json.test.mts b/src/utils/socket-json.test.mts index c8e2f8cd1c..f7b61e92f3 100644 --- a/src/utils/socket-json.test.mts +++ b/src/utils/socket-json.test.mts @@ -121,6 +121,21 @@ describe('readSocketJsonCascade', () => { }) }) + it('treats an explicit null as clearing an inherited value, not restating it', async () => { + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { excludeConfigs: null } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(mavenConfig(result)).toEqual({ + bin: 'mvn', + excludeConfigs: null, + }) + }) + it('does not walk past the boundary even if an ancestor above it has one', async () => { await writeSocketJson(tmpdir(), { version: 1, From b8e20bbefc01dc54a33cace0e480c58af3896f0c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Sun, 2 Aug 2026 10:06:37 +0200 Subject: [PATCH 10/27] Write --exclude-paths disable at the exclusion root, not per project Previously each excluded build root got its own disabled:true write, relying on cascade to skip descendants already covered by an ancestor write. That only worked when the excluded ancestor happened to be a build root itself; a non-project directory containing multiple sibling projects would leave later siblings enabled. Instead, group excluded projects by the shallowest directory that actually matches --exclude-paths and write disabled:true there once, covering every ecosystem and sibling/nested project beneath it regardless of whether that directory is a build root of its own. --- .../setup-recursive-manifest-config.mts | 124 ++++++++++++------ .../setup-recursive-manifest-config.test.mts | 77 +++++++++-- 2 files changed, 150 insertions(+), 51 deletions(-) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 8efc6fb283..1d2c847b09 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -1,6 +1,8 @@ import { existsSync } from 'node:fs' import path from 'node:path' +import micromatch from 'micromatch' + import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' @@ -16,12 +18,19 @@ import { readSocketJsonSync, writeSocketJson, } from '../../utils/socket-json.mts' +import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { CResult } from '../../types.mts' import type { SocketJson } from '../../utils/socket-json.mts' -type Candidate = { dir: string; ecosystem: BuildTool } +// One directory to mark `disabled: true` in, covering every ecosystem found +// excluded beneath it. `dir` is the shallowest directory that itself matches +// `--exclude-paths` - not necessarily a project dir of its own - so a single +// write covers every sibling/nested project underneath, instead of one write +// per matched project (which would miss sibling projects that don't happen to +// be descendants of whichever matched project was written first). +type ExclusionRoot = { dir: string; ecosystems: BuildTool[] } function canceledByUser(): CResult<{ canceled: boolean }> { logger.log('') @@ -45,13 +54,13 @@ function getEcosystemSection( ) } -// Depth-then-path sort so a disabled ancestor is always written before its -// descendants - required for the cascade no-op check in disableCandidate to -// see an ancestor's just-written `disabled: true`. +// Depth-then-path sort, purely for stable/predictable log ordering - separate +// exclusion roots never nest inside one another (see findExclusionRoot), so +// there's no cascade-correctness dependency between the writes. export function sortCandidatesForDisplay( - candidates: readonly Candidate[], + candidates: readonly ExclusionRoot[], cwd: string, -): Candidate[] { +): ExclusionRoot[] { return [...candidates].sort((a, b) => { const relA = path.relative(cwd, a.dir) const relB = path.relative(cwd, b.dir) @@ -60,25 +69,48 @@ export function sortCandidatesForDisplay( if (depthA !== depthB) { return depthA - depthB } - if (relA !== relB) { - return relA < relB ? -1 : 1 - } - return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0 + return relA < relB ? -1 : relA > relB ? 1 : 0 }) } +function toPosixRelative(cwd: string, dir: string): string { + return path.relative(cwd, dir).split(path.sep).join('/') +} + +// Walks a matched candidate's path from shallowest to deepest and returns the +// first (shallowest) prefix that itself matches one of the --exclude-paths +// ignore patterns - i.e. the directory the exclusion should actually be +// written to. Writing there instead of at the matched candidate itself means +// one write covers every sibling/nested project beneath it, even when that +// directory isn't a build root of its own. +function findExclusionRoot( + relDir: string, + ignorePatterns: readonly string[], +): string { + const segments = relDir.split('/') + for (let depth = 1; depth <= segments.length; depth += 1) { + const prefix = segments.slice(0, depth).join('/') + if (micromatch.isMatch(prefix, ignorePatterns, { dot: true })) { + return prefix + } + } + return relDir +} + // Discovers every gradle/sbt/maven build root beneath `cwd` (a plain // filesystem walk, no dependency resolution and no build-tool invocation - -// so no bin/javaHome is ever needed) and returns the ones that should end up -// disabled: anything matching `--exclude-paths`. `cwd` itself is excluded - -// it already got its own wizard pass. Comparing an unfiltered walk against an +// so no bin/javaHome is ever needed), diffs an unfiltered walk against an // excludePaths-filtered walk (both via the same findBuildToolCandidates // fast-glob machinery, which already treats --exclude-paths as anchored -// ignores that prevent descending into a matched subtree at all) avoids -// re-implementing that matching logic. `cwd` is realpath-resolved before -// comparing: the discovered dirs findBuildToolCandidates returns already are -// (it resolves symlinks so results are stable), and on macOS /tmp -> -// /private/tmp alone is enough to otherwise break the comparison. +// ignores that prevent descending into a matched subtree at all) to find +// every excluded project, then groups them by the shallowest directory that +// actually matched --exclude-paths (see findExclusionRoot) so a whole +// excluded subtree gets exactly one write, regardless of how many build roots +// or ecosystems it contains. `cwd` itself is excluded - it already got its +// own wizard pass. `cwd` is realpath-resolved before comparing: the +// discovered dirs findBuildToolCandidates returns already are (it resolves +// symlinks so results are stable), and on macOS /tmp -> /private/tmp alone is +// enough to otherwise break the comparison. export async function discoverExcludedCandidates({ cwd, excludePaths, @@ -87,45 +119,57 @@ export async function discoverExcludedCandidates({ cwd: string excludePaths?: string[] | undefined rootSockJson: SocketJson -}): Promise { +}): Promise { const realCwd = await realpathOrResolved(cwd) const [fullByTool, includedByTool] = await Promise.all([ findBuildToolCandidates({ cwd, sockJson: rootSockJson }), findBuildToolCandidates({ cwd, excludePaths, sockJson: rootSockJson }), ]) - const result: Candidate[] = [] + const ignorePatterns = (excludePaths ?? []).flatMap(excludePathToScanIgnores) + const ecosystemsByRoot = new Map>() for (const [ecosystem, fullDirs] of fullByTool) { const includedDirs = new Set(includedByTool.get(ecosystem) ?? []) for (const dir of fullDirs) { if (dir === realCwd || includedDirs.has(dir)) { continue } - result.push({ dir, ecosystem }) + const relDir = toPosixRelative(realCwd, dir) + const rootRelDir = findExclusionRoot(relDir, ignorePatterns) + const rootDir = path.join(realCwd, rootRelDir) + const ecosystems = ecosystemsByRoot.get(rootDir) ?? new Set() + ecosystems.add(ecosystem) + ecosystemsByRoot.set(rootDir, ecosystems) } } - return result + + return [...ecosystemsByRoot].map(([dir, ecosystems]) => ({ + dir, + ecosystems: [...ecosystems].sort(), + })) } -// Marks one excluded build root's own socket.json `disabled: true` - a no-op -// if its cascade (an already-disabled ancestor, processed earlier in the same -// depth-ordered pass) already covers it, so only the topmost excluded -// directory in a subtree gets an explicit write. -export async function disableCandidate({ +// Marks one exclusion root's own socket.json `disabled: true` for whichever +// of its ecosystems aren't already disabled via cascade (an already-disabled +// ancestor from a prior run) - a no-op write is skipped entirely so re-running +// the wizard doesn't keep rewriting already-disabled roots. +export async function disableExclusionRoot({ cwd, dir, - ecosystem, + ecosystems, rootSockJson, }: { cwd: string dir: string - ecosystem: BuildTool + ecosystems: readonly BuildTool[] rootSockJson: SocketJson }): Promise> { const relDir = path.relative(cwd, dir) || '.' const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) - const cascadeSection = getEcosystemSection(cascade, ecosystem) - if (cascadeSection['disabled'] === true) { + const needsWrite = ecosystems.filter( + ecosystem => getEcosystemSection(cascade, ecosystem)['disabled'] !== true, + ) + if (!needsWrite.length) { return notCanceled() } @@ -136,17 +180,19 @@ export async function disableCandidate({ if (!ownSockJson.defaults.manifest) { ownSockJson.defaults.manifest = {} } - const ownSection = getEcosystemSection(ownSockJson, ecosystem) - ;(ownSockJson.defaults.manifest as Record)[ecosystem] = { - ...ownSection, - disabled: true, + const manifest = ownSockJson.defaults.manifest as Record + for (const ecosystem of needsWrite) { + manifest[ecosystem] = { + ...getEcosystemSection(ownSockJson, ecosystem), + disabled: true, + } } const writeResult = await writeSocketJson(dir, ownSockJson) if (!writeResult.ok) { return writeResult } - logger.success(`Disabled ${relDir} (${ecosystem})`) + logger.success(`Disabled ${relDir} (${needsWrite.join(', ')})`) return notCanceled() } @@ -325,7 +371,7 @@ export async function setupRecursiveManifestConfig( // Re-read: the root wizard may have just written a new socket.json. const rootSockJson = readOrDefaultSocketJson(cwd) - // Resolved once here for sortCandidatesForDisplay/disableCandidate's + // Resolved once here for sortCandidatesForDisplay/disableExclusionRoot's // relative-path math, consistent with discoverExcludedCandidates' own // internal resolution (see its comment for why this matters). const realCwd = await realpathOrResolved(cwd) @@ -345,10 +391,10 @@ export async function setupRecursiveManifestConfig( const ordered = sortCandidatesForDisplay(toDisable, realCwd) for (const candidate of ordered) { // eslint-disable-next-line no-await-in-loop - const result = await disableCandidate({ + const result = await disableExclusionRoot({ cwd: realCwd, dir: candidate.dir, - ecosystem: candidate.ecosystem, + ecosystems: candidate.ecosystems, rootSockJson, }) if (!result.ok) { diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 17ee75b505..2665f7d9b5 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -25,7 +25,7 @@ import { select } from '@socketsecurity/registry/lib/prompts' import { findBuildToolCandidates } from './discover-manifest-roots.mts' import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' import { - disableCandidate, + disableExclusionRoot, discoverExcludedCandidates, setupRecursiveManifestConfig, sortCandidatesForDisplay, @@ -101,14 +101,14 @@ describe('sortCandidatesForDisplay', () => { it('sorts shallower dirs before deeper ones, parent before child', () => { const sorted = sortCandidatesForDisplay( [ - { dir: '/repo/module-b/standalone-gradle-lib', ecosystem: 'gradle' }, - { dir: '/repo/independent-service', ecosystem: 'maven' }, + { dir: '/repo/module-b/standalone-gradle-lib', ecosystems: ['gradle'] }, + { dir: '/repo/independent-service', ecosystems: ['maven'] }, ], cwd, ) expect(sorted).toEqual([ - { dir: '/repo/independent-service', ecosystem: 'maven' }, - { dir: '/repo/module-b/standalone-gradle-lib', ecosystem: 'gradle' }, + { dir: '/repo/independent-service', ecosystems: ['maven'] }, + { dir: '/repo/module-b/standalone-gradle-lib', ecosystems: ['gradle'] }, ]) }) }) @@ -149,11 +149,42 @@ describe('discoverExcludedCandidates', () => { rootSockJson: emptySockJson(), }) - expect(result).toEqual([{ dir: legacy, ecosystem: 'gradle' }]) + expect(result).toEqual([{ dir: legacy, ecosystems: ['gradle'] }]) + }) + + it('groups sibling projects under a non-project excluded ancestor into a single exclusion root', async () => { + // legacy/ itself has no build file of its own - only its two children do - + // so it never appears as a candidate, but --exclude-paths=legacy should + // still collapse both into one write at legacy, not two writes at + // legacy/a and legacy/b. + const a = '/repo/legacy/a' + const b = '/repo/legacy/b' + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map([ + ['maven', []], + ['gradle', []], + ]) + : new Map([ + ['maven', [a]], + ['gradle', [b]], + ]), + ) + + const result = await discoverExcludedCandidates({ + cwd, + excludePaths: ['legacy'], + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual([ + { dir: '/repo/legacy', ecosystems: ['gradle', 'maven'] }, + ]) }) }) -describe('disableCandidate', () => { +describe('disableExclusionRoot', () => { const cwd = '/repo' const dir = '/repo/legacy' @@ -164,16 +195,16 @@ describe('disableCandidate', () => { vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) }) - it('no-ops when the cascade already shows disabled', async () => { + it('no-ops when the cascade already shows every ecosystem disabled', async () => { vi.mocked(readSocketJsonCascade).mockReturnValue({ version: 1, defaults: { manifest: { gradle: { disabled: true } } }, } as SocketJson) - await disableCandidate({ + await disableExclusionRoot({ cwd, dir, - ecosystem: 'gradle', + ecosystems: ['gradle'], rootSockJson: emptySockJson(), }) @@ -190,10 +221,10 @@ describe('disableCandidate', () => { }) as SocketJson, ) - await disableCandidate({ + await disableExclusionRoot({ cwd, dir, - ecosystem: 'gradle', + ecosystems: ['gradle'], rootSockJson: emptySockJson(), }) @@ -206,6 +237,28 @@ describe('disableCandidate', () => { }), ) }) + + it('writes only the ecosystems not already covered by cascade', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + + await disableExclusionRoot({ + cwd, + dir, + ecosystems: ['gradle', 'maven'], + rootSockJson: emptySockJson(), + }) + + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { manifest: { maven: { disabled: true } } }, + }), + ) + }) }) describe('setupRecursiveManifestConfig', () => { From 9c16b4103a1a72954c3fd29c89a2f7fe5696b5f4 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Sun, 2 Aug 2026 10:10:48 +0200 Subject: [PATCH 11/27] Abort recursive facts generation when a build root's workspace layout is unknown A build root whose facts generation fails (a build-tool crash or a blocking resolution failure) never produces its projects[] list, so there's no way to tell whether a later candidate underneath it is already covered by that root or a genuinely independent project. Continuing to process further candidates in that state risked misclassifying subprojects and piling on doomed attempts against a build already known to be broken. Fail closed instead: abort the entire recursive walk as soon as one build root's workspace layout can't be determined, rather than continuing to sibling and nested candidates. --- .../manifest/generate-recursive-manifests.mts | 19 +++++- .../generate-recursive-manifests.test.mts | 61 ++++++++++--------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 8bd0d18c5f..b99b3d03e2 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -139,8 +139,15 @@ function resolveEcosystemConfig( // subtree — so a reactor/multi-project member is skipped on re-encounter // while an unrelated nested project the reactor doesn't declare (e.g. a // stray git-submodule pom, or a different-ecosystem project nested inside a -// covered directory tree) still gets its own invocation. A failure at one -// root does not stop discovery/generation at sibling roots. +// covered directory tree) still gets its own invocation. Fail-closed: a build +// root whose workspace layout couldn't be determined (the build tool crashed +// or a blocking resolution failure prevented `projects[]` from being read) +// aborts the entire walk instead of continuing to its still-undiscovered +// descendants - without that root's `projects[]`, there's no way to tell +// whether a later candidate is one of its own already-covered members or a +// genuinely independent project, and guessing risks silently mis-scanning a +// subproject as standalone (or vice versa) plus a cascade of doomed attempts +// against a build that's already known to be broken. export async function generateRecursiveManifests({ cwd, excludePaths, @@ -158,7 +165,7 @@ export async function generateRecursiveManifests({ }) const outcomes: RecursiveManifestOutcome[] = [] - for (const [ecosystem, dirs] of candidatesByTool) { + ecosystems: for (const [ecosystem, dirs] of candidatesByTool) { const covered = new Set() const disabledRoots: DisabledRoot[] = [] for (const dir of dirs) { @@ -224,6 +231,12 @@ export async function generateRecursiveManifests({ ecosystem, status: failed ? 'failed' : 'empty', }) + if (failed) { + logger.warn( + `Aborting recursive discovery: ${dir}'s (${ecosystem}) workspace layout could not be determined, so remaining build roots cannot be safely classified as covered or independent.`, + ) + break ecosystems + } continue } diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 620013492f..dab251d635 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -117,45 +117,50 @@ describe('generateRecursiveManifests', () => { expect(atDualMarkerDir.every(o => o.status === 'generated')).toBe(true) }) - it('continues to sibling roots in the same ecosystem after one root fails', async () => { + it('aborts the entire walk (fail-closed) once a build root fails, instead of continuing to further candidates', async () => { vi.mocked(runManifestFacts).mockImplementation( async ({ cwd, ecosystem }) => { if (ecosystem === 'maven' && cwd === dualMarkerDir) { process.exitCode = 1 return undefined } - if (cwd === reactor && ecosystem === 'maven') { - return { - factsPath: path.join(cwd, '.socket.facts.json'), - projects: [ - { - type: 'maven', - name: 'moduleA', - subprojectDir: 'moduleA', - dependencies: [], - resolvedAs: [], - }, - ], - } - } return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } }, ) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) - const outcomes = await generateRecursiveManifests({ - cwd: monorepo, - verbose: false, - }) + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) - const byKey = new Map( - outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), - ) - expect(byKey.get('maven:dual-marker-dir')).toBe('failed') - // A failure at one maven root must not stop later maven roots from being attempted. - expect(byKey.get('maven:reactor')).toBe('generated') - expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( - 'generated', - ) + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('failed') + // Without dual-marker-dir's own projects[], reactor's still-undiscovered + // members can't be safely told apart from independent projects - so + // nothing else in the maven ecosystem gets attempted, or reported at all. + expect(byKey.has('maven:reactor')).toBe(false) + expect(byKey.has('maven:reactor/moduleB/independent-submodule')).toBe( + false, + ) + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some( + ([opts]) => opts.ecosystem === 'maven' && opts.cwd === reactor, + ), + ).toBe(false) + expect( + warnSpy.mock.calls.some(c => + /Aborting recursive discovery/.test(String(c[0])), + ), + ).toBe(true) + } finally { + warnSpy.mockRestore() + } }) it('reports a non-fatal empty result distinctly from a failure', async () => { From 3c8758b93ca6f89021886c8279270d09423ba2f7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 10:23:53 +0200 Subject: [PATCH 12/27] Make recursive setup actually configure discovered build roots The recursive wizard (`socket manifest setup --dynamic-sbom-inference`) previously only ever disabled build roots matching --exclude-paths; every other discovered root was left completely untouched, with no way to set its bin/JDK/opts short of running the plain single-project wizard on it directly. Every non-excluded candidate now gets an interactive configure-or-inherit-defaults prompt (in discovery order, parent before child), seeded with its cascaded effective value so accepting every prompt unchanged preserves whatever it already inherits. Disabling a specific candidate is intentionally not offered here - that stays --exclude-paths' job, so a whole excluded subtree still collapses into a single write. Also fixes a real bug surfaced along the way: askForBin pre-filled the hardcoded tool fallback (mvn/./gradlew/sbt) as the prompt's shown value, so accepting the default was indistinguishable from explicitly typing it and got written to socket.json for no reason. The shown default is now only ever a prior explicit value; the fallback is mentioned as a hint instead. A matching guard drops any ecosystem section that ends up empty so it doesn't count as configured or trigger a write. Finally, the root step now detects which ecosystems are actually present at cwd (reusing the same check the plain wizard uses) and asks about detected ecosystems first, phrased accordingly, before offering to configure undetected ones "anyway" for subprojects that might need them. --- src/commands/manifest/cmd-manifest-setup.mts | 2 +- .../manifest/setup-manifest-config.mts | 26 +- .../manifest/setup-manifest-config.test.mts | 39 ++ .../setup-recursive-manifest-config.mts | 481 ++++++++++++++---- .../setup-recursive-manifest-config.test.mts | 404 ++++++++++++++- 5 files changed, 832 insertions(+), 120 deletions(-) create mode 100644 src/commands/manifest/setup-manifest-config.test.mts diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 164a5e8cca..4173fbfa3b 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -37,7 +37,7 @@ const config: CliCommandConfig = { type: 'boolean', hidden: true, description: - 'After configuring CWD, recursively discover every gradle/sbt/maven build root beneath it and mark `disabled: true` on whatever matches --exclude-paths; everything else is left untouched', + 'After configuring CWD, recursively discover every gradle/sbt/maven build root beneath it. A build root matching --exclude-paths is bulk-disabled with no prompt; every other one gets an interactive configure-or-inherit-defaults prompt', }, }, help: (command, config) => ` diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index a3b37b9393..27fb8541a1 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -275,7 +275,7 @@ export async function setupGradle( >, ): Promise> { const priorBin = config.bin - const bin = await askForBin(config.bin || './gradlew') + const bin = await askForBin(config.bin || '', './gradlew') if (bin === undefined) { return canceledByUser() } else if (bin) { @@ -351,7 +351,7 @@ export async function setupMaven( >, ): Promise> { const priorBin = config.bin - const bin = await askForBin(config.bin || 'mvn') + const bin = await askForBin(config.bin || '', 'mvn') if (bin === undefined) { return canceledByUser() } else if (bin) { @@ -415,7 +415,7 @@ export async function setupSbt( >, ): Promise> { const priorBin = config.bin - const bin = await askForBin(config.bin || 'sbt') + const bin = await askForBin(config.bin || '', 'sbt') if (bin === undefined) { return canceledByUser() } else if (bin) { @@ -594,11 +594,25 @@ async function askForOutputFile(defaultName = ''): Promise { }) } -async function askForBin(defaultName = ''): Promise { +// `defaultName` is only ever a *prior* explicit value (own file or, for the +// recursive per-project wizard, the cascaded effective value) - never the +// tool's own hardcoded fallback (e.g. `mvn`). `input()` returns whatever's +// shown when the user just presses Enter, so pre-filling a fabricated +// fallback there would be indistinguishable from the user actually typing +// it, freezing it into socket.json for no reason. The fallback is mentioned +// in `fallbackHint` purely as informational text. +async function askForBin( + defaultName = '', + fallbackHint = '', +): Promise { return await input({ message: '(--bin) What should be the command to execute? Usually your build binary.' + - (defaultName ? ' (Backspace to leave default)' : ''), + (defaultName + ? ' (Backspace to leave default)' + : fallbackHint + ? ` (blank = ${fallbackHint})` + : ''), default: defaultName, required: false, // validate: async string => bool @@ -608,7 +622,7 @@ async function askForBin(defaultName = ''): Promise { async function askForJavaHome(defaultName = ''): Promise { return await input({ message: - 'What JDK should this build tool use? Leave blank to use the JDK already on PATH/JAVA_HOME. Supports $VAR/${VAR} (e.g. $JAVA11_HOME) so this works across machines.' + + 'What JDK should this build tool use? Leave blank to use the JDK already on PATH/JAVA_HOME. Supports $VAR/${VAR}.' + (defaultName ? ' (Backspace to leave default)' : ''), default: defaultName, required: false, diff --git a/src/commands/manifest/setup-manifest-config.test.mts b/src/commands/manifest/setup-manifest-config.test.mts new file mode 100644 index 0000000000..bcb088d000 --- /dev/null +++ b/src/commands/manifest/setup-manifest-config.test.mts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' + +// Simulates a real prompt: pressing Enter with nothing typed returns +// whatever `default` was shown - the same contract inquirer honors. This is +// exactly the behavior that made `askForBin` freeze a hardcoded tool +// fallback (e.g. 'mvn') into socket.json when the user never typed anything. +vi.mock('@socketsecurity/registry/lib/prompts', () => ({ + input: vi.fn(async ({ default: def }: { default?: string }) => def ?? ''), + select: vi.fn(async ({ default: def }: { default?: string }) => def ?? ''), +})) + +import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' + +describe('setupGradle/setupMaven/setupSbt', () => { + it('leaves bin unset on a fresh config when every prompt is left blank, instead of freezing the hardcoded fallback', async () => { + const gradleConfig: Record = {} + const mavenConfig: Record = {} + const sbtConfig: Record = {} + + const gradleResult = await setupGradle(gradleConfig) + const mavenResult = await setupMaven(mavenConfig) + const sbtResult = await setupSbt(sbtConfig) + + expect(gradleResult).toEqual({ ok: true, data: { canceled: false } }) + expect(mavenResult).toEqual({ ok: true, data: { canceled: false } }) + expect(sbtResult).toEqual({ ok: true, data: { canceled: false } }) + expect(gradleConfig['bin']).toBeUndefined() + expect(mavenConfig['bin']).toBeUndefined() + expect(sbtConfig['bin']).toBeUndefined() + }) + + it('still shows and accepts a prior explicit bin value unchanged', async () => { + const config: Record = { bin: './custom-gradlew' } + + await setupGradle(config) + + expect(config['bin']).toBe('./custom-gradlew') + }) +}) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 1d2c847b09..12a0dc916e 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -6,6 +6,7 @@ import micromatch from 'micromatch' import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' +import { detectManifestActions } from './detect-manifest-actions.mts' import { findBuildToolCandidates, realpathOrResolved, @@ -24,6 +25,19 @@ import type { BuildTool } from './scripts/build-tool.mts' import type { CResult } from '../../types.mts' import type { SocketJson } from '../../utils/socket-json.mts' +// A single discovered build root - a directory with its own gradle/sbt/maven +// marker file. +type Candidate = { dir: string; ecosystem: BuildTool } + +const ROOT_ECOSYSTEMS: BuildTool[] = ['maven', 'gradle', 'sbt'] + +const ECOSYSTEM_LABELS: Record = { + __proto__: null, + gradle: 'Gradle', + maven: 'Maven', + sbt: 'sbt', +} as unknown as Record + // One directory to mark `disabled: true` in, covering every ecosystem found // excluded beneath it. `dir` is the shallowest directory that itself matches // `--exclude-paths` - not necessarily a project dir of its own - so a single @@ -54,13 +68,18 @@ function getEcosystemSection( ) } -// Depth-then-path sort, purely for stable/predictable log ordering - separate -// exclusion roots never nest inside one another (see findExclusionRoot), so -// there's no cascade-correctness dependency between the writes. -export function sortCandidatesForDisplay( - candidates: readonly ExclusionRoot[], +// Depth-then-path sort. For exclusion roots this is purely for stable, +// predictable log ordering - separate exclusion roots never nest inside one +// another (see findExclusionRoot), so there's no cascade-correctness +// dependency between those writes. For plain candidates (the per-project +// configure-or-inherit walk) the order matters for a different reason: a +// parent must be processed before its children so that if the parent gets +// configured, a child's shown "inherited" default already reflects that +// change (via cascade) instead of the parent's pre-run value. +export function sortCandidatesForDisplay( + candidates: readonly T[], cwd: string, -): ExclusionRoot[] { +): T[] { return [...candidates].sort((a, b) => { const relA = path.relative(cwd, a.dir) const relB = path.relative(cwd, b.dir) @@ -97,21 +116,34 @@ function findExclusionRoot( return relDir } +export type DiscoveredBuildRoots = { + excluded: ExclusionRoot[] + // Every candidate --exclude-paths didn't rule out - these get the + // interactive configure-or-inherit walk instead of a bulk write. + included: Candidate[] + // Every gradle/sbt/maven candidate found beneath `cwd`, excluded or not - + // reported so the wizard can show discovery actually walked the tree even + // when nothing ends up excluded (no --exclude-paths given, or none of it + // matched), instead of a bare "found nothing" that reads the same either way. + totalCandidateCount: number +} + // Discovers every gradle/sbt/maven build root beneath `cwd` (a plain // filesystem walk, no dependency resolution and no build-tool invocation - // so no bin/javaHome is ever needed), diffs an unfiltered walk against an // excludePaths-filtered walk (both via the same findBuildToolCandidates // fast-glob machinery, which already treats --exclude-paths as anchored -// ignores that prevent descending into a matched subtree at all) to find -// every excluded project, then groups them by the shallowest directory that -// actually matched --exclude-paths (see findExclusionRoot) so a whole -// excluded subtree gets exactly one write, regardless of how many build roots -// or ecosystems it contains. `cwd` itself is excluded - it already got its -// own wizard pass. `cwd` is realpath-resolved before comparing: the -// discovered dirs findBuildToolCandidates returns already are (it resolves -// symlinks so results are stable), and on macOS /tmp -> /private/tmp alone is -// enough to otherwise break the comparison. -export async function discoverExcludedCandidates({ +// ignores that prevent descending into a matched subtree at all) to split +// candidates into `included` (the interactive per-project walk) and +// `excluded`, grouped by the shallowest directory that actually matched +// --exclude-paths (see findExclusionRoot) so a whole excluded subtree gets +// exactly one write, regardless of how many build roots or ecosystems it +// contains. `cwd` itself is excluded from both - it already got its own +// wizard pass. `cwd` is realpath-resolved before comparing: the discovered +// dirs findBuildToolCandidates returns already are (it resolves symlinks so +// results are stable), and on macOS /tmp -> /private/tmp alone is enough to +// otherwise break the comparison. +export async function discoverBuildRoots({ cwd, excludePaths, rootSockJson, @@ -119,19 +151,26 @@ export async function discoverExcludedCandidates({ cwd: string excludePaths?: string[] | undefined rootSockJson: SocketJson -}): Promise { +}): Promise { const realCwd = await realpathOrResolved(cwd) const [fullByTool, includedByTool] = await Promise.all([ findBuildToolCandidates({ cwd, sockJson: rootSockJson }), findBuildToolCandidates({ cwd, excludePaths, sockJson: rootSockJson }), ]) + let totalCandidateCount = 0 + const included: Candidate[] = [] const ignorePatterns = (excludePaths ?? []).flatMap(excludePathToScanIgnores) const ecosystemsByRoot = new Map>() for (const [ecosystem, fullDirs] of fullByTool) { const includedDirs = new Set(includedByTool.get(ecosystem) ?? []) for (const dir of fullDirs) { - if (dir === realCwd || includedDirs.has(dir)) { + if (dir === realCwd) { + continue + } + totalCandidateCount += 1 + if (includedDirs.has(dir)) { + included.push({ dir, ecosystem }) continue } const relDir = toPosixRelative(realCwd, dir) @@ -143,10 +182,14 @@ export async function discoverExcludedCandidates({ } } - return [...ecosystemsByRoot].map(([dir, ecosystems]) => ({ - dir, - ecosystems: [...ecosystems].sort(), - })) + return { + excluded: [...ecosystemsByRoot].map(([dir, ecosystems]) => ({ + dir, + ecosystems: [...ecosystems].sort(), + })), + included, + totalCandidateCount, + } } // Marks one exclusion root's own socket.json `disabled: true` for whichever @@ -196,6 +239,187 @@ export async function disableExclusionRoot({ return notCanceled() } +// Dispatches to the right ecosystem-specific wizard - the three have +// different config shapes, but this is the only place that needs to know +// that; every caller just deals with `BuildTool` generically. +async function runEcosystemWizard( + ecosystem: BuildTool, + config: Record, +): Promise> { + if (ecosystem === 'gradle') { + return await setupGradle( + config as NonNullable< + NonNullable['manifest']>['gradle'] + >, + ) + } + if (ecosystem === 'maven') { + return await setupMaven( + config as NonNullable< + NonNullable['manifest']>['maven'] + >, + ) + } + return await setupSbt( + config as NonNullable< + NonNullable['manifest']>['sbt'] + >, + ) +} + +// Runs the same per-ecosystem wizard used for the root, seeded with this +// candidate's *effective* (cascaded) value for any field its own socket.json +// doesn't already set - so accepting every prompt unchanged preserves +// whatever it currently inherits, while an actual change writes an explicit +// override. Own-file values win over the cascaded seed, so re-running this +// against an already-configured candidate shows its own prior answers. +export async function configureCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + rootSockJson: SocketJson +}): Promise> { + const relDir = path.relative(cwd, dir) || '.' + const ownSockJson = readOrDefaultSocketJson(dir) + if (!ownSockJson.defaults) { + ownSockJson.defaults = {} + } + if (!ownSockJson.defaults.manifest) { + ownSockJson.defaults.manifest = {} + } + + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + const seed: Record = { + ...getEcosystemSection(cascade, ecosystem), + ...getEcosystemSection(ownSockJson, ecosystem), + } + + const result = await runEcosystemWizard(ecosystem, seed) + if (!result.ok || result.data.canceled) { + return result + } + // Nothing inherited and nothing set - writing an empty section would just + // be noise (own file unaffected, `dir` keeps inheriting exactly as before). + if (!Object.keys(seed).length) { + logger.log(`No changes for ${relDir} (${ecosystem}); nothing written.`) + return notCanceled() + } + + const manifest = ownSockJson.defaults.manifest as Record + manifest[ecosystem] = seed + + const writeResult = await writeSocketJson(dir, ownSockJson) + if (!writeResult.ok) { + return writeResult + } + logger.success(`Configured ${relDir} (${ecosystem})`) + return notCanceled() +} + +type CandidateAction = 'configure' | 'inherit' + +// Disabling a candidate is deliberately not offered here - that's +// --exclude-paths' job (a bulk, path-based write covering a whole subtree in +// one go, see findExclusionRoot). Offering it per-candidate too would +// undermine that: an interactive disable here only ever touches this one +// directory's own file, none of the "shallowest excluded ancestor" grouping +// that keeps the tree's disabled state coherent and cheap to re-derive. +async function askCandidateAction( + relDir: string, + ecosystem: BuildTool, +): Promise { + return (await select({ + message: `${relDir} (${ecosystem})`, + choices: [ + { + name: 'Use inherited defaults', + value: 'inherit', + description: + "Leave this project inheriting whatever cascades down from its ancestors' socket.json", + }, + { + name: 'Configure', + value: 'configure', + description: 'Set bin/JDK/opts/etc. for this project specifically', + }, + ], + default: 'inherit', + })) as CandidateAction | null +} + +type CandidateOutcome = + | 'configured' + | 'inherited' + // Already disabled via cascade (an ancestor disabled through + // --exclude-paths, or a pre-existing config) - not re-prompted, since + // asking about a project an --exclude-paths write already covers would be + // noise. + | 'skipped' + +// Decides and applies one discovered, non-excluded candidate's fate: prompt +// for configure/inherit, unless its cascade already shows it disabled (in +// which case it's silently skipped - see the CandidateOutcome.skipped note). +export async function processCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + rootSockJson: SocketJson +}): Promise> { + const relDir = path.relative(cwd, dir) || '.' + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + if (getEcosystemSection(cascade, ecosystem)['disabled'] === true) { + return { ok: true, data: { canceled: false, outcome: 'skipped' } } + } + + const action = await askCandidateAction(relDir, ecosystem) + if (action === undefined || action === null) { + canceledByUser() + return { ok: true, data: { canceled: true, outcome: 'inherited' } } + } + if (action === 'configure') { + const result = await configureCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, + }) + if (!result.ok) { + return result + } + return { ok: true, data: { ...result.data, outcome: 'configured' } } + } + // 'inherit', or any unexpected value - the safe no-op default. + return { ok: true, data: { canceled: false, outcome: 'inherited' } } +} + +// Accepting every prompt's shown default (now that askForBin no longer +// pre-fills a fabricated tool fallback, see setup-manifest-config.mts) +// leaves an ecosystem's section genuinely empty - no field actually differs +// from "inherit/use the tool default". Drop it so `configuredAny` and the +// write-confirmation prompt reflect what was actually configured, not just +// which ecosystems the user said "yes" to walking through. +function dropIfEmpty( + manifest: Record, + ecosystem: BuildTool, +): boolean { + const section = manifest[ecosystem] as Record | undefined + if (section && Object.keys(section).length) { + return true + } + delete manifest[ecosystem] + return false +} + async function askYesNo(message: string): Promise { return (await select({ message, @@ -209,10 +433,13 @@ async function askYesNo(message: string): Promise { // The recursive flow's root step: unlike the plain single-project wizard // (`setupManifestConfig`, which assumes `cwd` IS a specific ecosystem's // project and only lets you configure one before finishing), the recursion -// root is often just a common ancestor with no project of its own - so walk -// all three JVM ecosystems in a fixed order, asking yes/no whether to set -// baseline defaults for each, instead of picking one from a menu. Declining -// all three is a normal (non-canceled) outcome, not an abort - the +// root is often just a common ancestor with no project of its own. Detecting +// what's actually here (the same marker-file check the plain wizard's +// `detectManifestActions` uses) lets the questions reflect that: a detected +// ecosystem is asked about first and phrased as "configure it", while an +// undetected one is asked afterward and phrased as "anyway" (for the case +// where a subproject further down needs it even though the root doesn't). +// Declining all three is a normal (non-canceled) outcome, not an abort - the // exclude-paths-driven part of the recursive setup still proceeds. async function setupRecursiveRootDefaults( cwd: string, @@ -230,18 +457,27 @@ async function setupRecursiveRootDefaults( 'Note: This tool will set up flag and argument defaults for certain', ) logger.log(' CLI commands. You can still override them by explicitly') - logger.log(' setting the flag. It is meant to be a convenience tool.') + logger.log(' setting the flag.') logger.log('') - logger.log( - `This command will generate a ${SOCKET_JSON} file in the target cwd,`, - ) - logger.log( - 'used as the fallback for every build root beneath it that inherits', - ) - logger.log("(rather than overrides) a given field, instead of the CLI's") - logger.log('own hardcoded defaults.') + logger.log(`This command will generate a ${SOCKET_JSON} file in ${cwd}.`) + logger.log('socket.json properties are inherited by nested paths.') logger.log('') + const detected = await detectManifestActions(null, cwd) + const detectedEcosystems = ROOT_ECOSYSTEMS.filter( + ecosystem => detected[ecosystem], + ) + if (detectedEcosystems.length) { + logger.log( + `Detected at this root: ${detectedEcosystems.map(ecosystem => ECOSYSTEM_LABELS[ecosystem]).join(', ')}.`, + ) + logger.log('') + } + const orderedEcosystems = [ + ...detectedEcosystems, + ...ROOT_ECOSYSTEMS.filter(ecosystem => !detected[ecosystem]), + ] + const sockJsonCResult = readSocketJsonSync(cwd, defaultOnReadError) if (!sockJsonCResult.ok) { return sockJsonCResult @@ -253,52 +489,37 @@ async function setupRecursiveRootDefaults( if (!sockJson.defaults.manifest) { sockJson.defaults.manifest = {} } + const manifest = sockJson.defaults.manifest as Record let configuredAny = false - const wantsMaven = await askYesNo('Configure Maven defaults?') - if (wantsMaven === undefined || wantsMaven === null) { - return canceledByUser() - } - if (wantsMaven) { - if (!sockJson.defaults.manifest.maven) { - sockJson.defaults.manifest.maven = {} + for (const ecosystem of orderedEcosystems) { + const label = ECOSYSTEM_LABELS[ecosystem] + const message = detected[ecosystem] + ? `${label} was detected at this root - configure ${label} defaults?` + : `${label} wasn't detected here - configure defaults for it anyway?` + // eslint-disable-next-line no-await-in-loop + const wants = await askYesNo(message) + if (wants === undefined || wants === null) { + return canceledByUser() } - const result = await setupMaven(sockJson.defaults.manifest.maven) - if (!result.ok || result.data.canceled) { - return result + if (!wants) { + continue } - configuredAny = true - } - - const wantsGradle = await askYesNo('Configure Gradle defaults?') - if (wantsGradle === undefined || wantsGradle === null) { - return canceledByUser() - } - if (wantsGradle) { - if (!sockJson.defaults.manifest.gradle) { - sockJson.defaults.manifest.gradle = {} + if (!manifest[ecosystem]) { + manifest[ecosystem] = {} } - const result = await setupGradle(sockJson.defaults.manifest.gradle) + // eslint-disable-next-line no-await-in-loop + const result = await runEcosystemWizard( + ecosystem, + manifest[ecosystem] as Record, + ) if (!result.ok || result.data.canceled) { return result } - configuredAny = true - } - - const wantsSbt = await askYesNo('Configure sbt defaults?') - if (wantsSbt === undefined || wantsSbt === null) { - return canceledByUser() - } - if (wantsSbt) { - if (!sockJson.defaults.manifest.sbt) { - sockJson.defaults.manifest.sbt = {} - } - const result = await setupSbt(sockJson.defaults.manifest.sbt) - if (!result.ok || result.data.canceled) { - return result + if (dropIfEmpty(manifest, ecosystem)) { + configuredAny = true } - configuredAny = true } if (!configuredAny) { @@ -330,19 +551,24 @@ async function setupRecursiveRootDefaults( } // `socket manifest setup --dynamic-sbom-inference`: configures `cwd` via -// `setupRecursiveRootDefaults` first, then walks every gradle/sbt/maven -// build root beneath it and marks `disabled: true` on whatever matches -// `--exclude-paths` - a project not covered by it is assumed to be one the -// user wants included and is left completely untouched. To customize -// bin/JDK/config filters for a *specific* project, run the plain -// `socket manifest setup ` on it directly; this recursive mode only -// ever writes `disabled: true`, never per-field overrides. This sidesteps a -// real circularity the earlier "discover via enumeration, then prompt" -// design had: enumerating a nested project's own subprojects needs a -// resolved bin/javaHome for that specific project, which isn't known until -// after prompting for it - but prompting-before-discovery doesn't work when -// the discovery itself is what surfaces the project to prompt about. Pure -// path-based exclusion needs neither: it never invokes a build tool at all. +// `setupRecursiveRootDefaults` first, then walks every gradle/sbt/maven build +// root beneath it. A candidate matching `--exclude-paths` is bulk-disabled by +// pure path matching (no build-tool invocation, no prompt - see +// findExclusionRoot); everything else gets an interactive per-project +// configure-or-inherit prompt (see processCandidate; disabling one +// individually isn't offered there - that stays --exclude-paths' job so a +// whole subtree keeps collapsing to one write instead of one per candidate). +// This sidesteps the +// circularity an earlier "discover via enumeration, then prompt" design hit +// (enumerating a nested project's own subprojects to prune already-covered +// reactor members needs a resolved bin/javaHome for that project, which isn't +// known until after prompting for it): this walk never tries to prune reactor +// members ahead of time, so it never needs to invoke a build tool to decide +// what to prompt about. The cost is that a reactor member (e.g. a Maven +// module) may get its own prompt and its own socket.json, which then goes +// unused once dynamic-sbom-inference's own coverage-tracking (driven by the +// parent build's actual output, not by socket.json) determines it's already +// covered - harmless, just a wasted prompt/file for that one candidate. export async function setupRecursiveManifestConfig( cwd: string, defaultOnReadError: boolean, @@ -372,34 +598,87 @@ export async function setupRecursiveManifestConfig( // Re-read: the root wizard may have just written a new socket.json. const rootSockJson = readOrDefaultSocketJson(cwd) // Resolved once here for sortCandidatesForDisplay/disableExclusionRoot's - // relative-path math, consistent with discoverExcludedCandidates' own - // internal resolution (see its comment for why this matters). + // relative-path math, consistent with discoverBuildRoots' own internal + // resolution (see its comment for why this matters). const realCwd = await realpathOrResolved(cwd) logger.log('') - logger.log('Discovering build roots to exclude ...') - const toDisable = await discoverExcludedCandidates({ + logger.log('Discovering build roots ...') + const { excluded, included, totalCandidateCount } = await discoverBuildRoots({ cwd, excludePaths, rootSockJson, }) - if (!toDisable.length) { - logger.log('No excluded build roots found.') + + if (!totalCandidateCount) { + logger.log(`No build roots found beneath ${cwd}.`) + logger.log('') + logger.success('Recursive setup complete.') return notCanceled() } + logger.log(`Found ${totalCandidateCount} build root(s) beneath ${cwd}.`) + + if (excludePaths?.length) { + if (!excluded.length) { + logger.log('None matched --exclude-paths; nothing disabled.') + } else { + const orderedExcluded = sortCandidatesForDisplay(excluded, realCwd) + for (const candidate of orderedExcluded) { + // eslint-disable-next-line no-await-in-loop + const result = await disableExclusionRoot({ + cwd: realCwd, + dir: candidate.dir, + ecosystems: candidate.ecosystems, + rootSockJson, + }) + if (!result.ok) { + return result + } + } + } + } - const ordered = sortCandidatesForDisplay(toDisable, realCwd) - for (const candidate of ordered) { - // eslint-disable-next-line no-await-in-loop - const result = await disableExclusionRoot({ - cwd: realCwd, - dir: candidate.dir, - ecosystems: candidate.ecosystems, - rootSockJson, - }) - if (!result.ok) { - return result + if (included.length) { + logger.log('') + logger.log( + 'For each remaining build root, choose to configure it or leave it', + ) + logger.log( + "inheriting its ancestors' defaults. To disable one, re-run with", + ) + logger.log('--exclude-paths instead.') + logger.log( + 'Note: a project that turns out to be a module of a parent multi-module', + ) + logger.log('build is already covered there - configuring it is safe, but') + logger.log('may end up unused.') + logger.log('') + + const counts = { configured: 0, inherited: 0, skipped: 0 } + const orderedIncluded = sortCandidatesForDisplay(included, realCwd) + for (const candidate of orderedIncluded) { + // eslint-disable-next-line no-await-in-loop + const result = await processCandidate({ + cwd: realCwd, + dir: candidate.dir, + ecosystem: candidate.ecosystem, + rootSockJson, + }) + if (!result.ok) { + return result + } + if (result.data.canceled) { + // The cancellation itself (select Esc/Ctrl+C, or a sub-wizard's own + // cancel) already logged "User canceled" - don't log it twice. + return { ok: true, data: { canceled: true } } + } + counts[result.data.outcome] += 1 } + + logger.log('') + logger.log( + `${counts.configured} configured, ${counts.inherited} left inheriting.`, + ) } logger.log('') diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 2665f7d9b5..1b5af84943 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -5,6 +5,17 @@ vi.mock('./discover-manifest-roots.mts', () => ({ // Identity: test dirs are already-absolute plain strings, no symlinks involved. realpathOrResolved: vi.fn(async (dir: string) => dir), })) +vi.mock('./detect-manifest-actions.mts', () => ({ + detectManifestActions: vi.fn(async () => ({ + bazel: false, + cdxgen: false, + count: 0, + conda: false, + gradle: false, + maven: false, + sbt: false, + })), +})) vi.mock('@socketsecurity/registry/lib/prompts', () => ({ select: vi.fn(), })) @@ -20,13 +31,17 @@ vi.mock('../../utils/socket-json.mts', () => ({ writeSocketJson: vi.fn(), })) +import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' +import { detectManifestActions } from './detect-manifest-actions.mts' import { findBuildToolCandidates } from './discover-manifest-roots.mts' import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' import { + configureCandidate, disableExclusionRoot, - discoverExcludedCandidates, + discoverBuildRoots, + processCandidate, setupRecursiveManifestConfig, sortCandidatesForDisplay, } from './setup-recursive-manifest-config.mts' @@ -113,7 +128,7 @@ describe('sortCandidatesForDisplay', () => { }) }) -describe('discoverExcludedCandidates', () => { +describe('discoverBuildRoots', () => { const cwd = '/repo' beforeEach(() => { @@ -125,12 +140,16 @@ describe('discoverExcludedCandidates', () => { new Map([['gradle', [cwd]]]), ) - const result = await discoverExcludedCandidates({ + const result = await discoverBuildRoots({ cwd, rootSockJson: emptySockJson(), }) - expect(result).toEqual([]) + expect(result).toEqual({ + excluded: [], + included: [], + totalCandidateCount: 0, + }) }) it('marks a dir excluded when it is present in the full walk but absent from the excludePaths-filtered walk', async () => { @@ -143,13 +162,17 @@ describe('discoverExcludedCandidates', () => { : new Map([['gradle', [legacy, active]]]), ) - const result = await discoverExcludedCandidates({ + const result = await discoverBuildRoots({ cwd, excludePaths: ['legacy'], rootSockJson: emptySockJson(), }) - expect(result).toEqual([{ dir: legacy, ecosystems: ['gradle'] }]) + expect(result).toEqual({ + excluded: [{ dir: legacy, ecosystems: ['gradle'] }], + included: [{ dir: active, ecosystem: 'gradle' }], + totalCandidateCount: 2, + }) }) it('groups sibling projects under a non-project excluded ancestor into a single exclusion root', async () => { @@ -172,15 +195,17 @@ describe('discoverExcludedCandidates', () => { ]), ) - const result = await discoverExcludedCandidates({ + const result = await discoverBuildRoots({ cwd, excludePaths: ['legacy'], rootSockJson: emptySockJson(), }) - expect(result).toEqual([ - { dir: '/repo/legacy', ecosystems: ['gradle', 'maven'] }, - ]) + expect(result).toEqual({ + excluded: [{ dir: '/repo/legacy', ecosystems: ['gradle', 'maven'] }], + included: [], + totalCandidateCount: 2, + }) }) }) @@ -261,6 +286,220 @@ describe('disableExclusionRoot', () => { }) }) +describe('configureCandidate', () => { + const cwd = '/repo' + const dir = '/repo/nested-gradle' + + beforeEach(() => { + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + vi.mocked(setupGradle).mockReset() + }) + + it('seeds the sub-wizard with the cascaded value, own-file value winning, and writes the mutated result', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { gradle: { javaHome: '/opt/jdk-17' } } }, + }) as SocketJson, + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + () => + ({ + version: 1, + defaults: { + manifest: { gradle: { bin: './gradlew', javaHome: '/opt/jdk-11' } }, + }, + }) as SocketJson, + ) + vi.mocked(setupGradle).mockImplementation(async config => { + expect(config).toEqual({ bin: './gradlew', javaHome: '/opt/jdk-17' }) + ;(config as Record)['gradleOpts'] = '--offline' + return { ok: true, data: { canceled: false } } + }) + + const result = await configureCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { + manifest: { + gradle: { + bin: './gradlew', + javaHome: '/opt/jdk-17', + gradleOpts: '--offline', + }, + }, + }, + }), + ) + }) + + it('propagates a cancellation from the sub-wizard without writing', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(setupGradle).mockResolvedValue({ + ok: true, + data: { canceled: true }, + }) + + const result = await configureCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: { canceled: true } }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('writes nothing when nothing is inherited and every prompt is left blank', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + // A no-op wizard: doesn't set a single field on the seed it's handed. + vi.mocked(setupGradle).mockResolvedValue({ + ok: true, + data: { canceled: false }, + }) + + const result = await configureCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) +}) + +describe('processCandidate', () => { + const cwd = '/repo' + const dir = '/repo/legacy/nested' + + beforeEach(() => { + vi.mocked(select).mockReset() + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + vi.mocked(setupGradle).mockReset() + }) + + it('skips silently, without prompting, when the cascade already shows it disabled', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'skipped' }, + }) + expect(select).not.toHaveBeenCalled() + }) + + it('does not offer a disable choice - that stays --exclude-paths-only', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockImplementation(async ({ choices }) => { + expect(choices.map((c: { value: unknown }) => c.value)).toEqual([ + 'inherit', + 'configure', + ]) + return 'inherit' + }) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'inherited' }, + }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('configures the candidate when the user picks configure', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockResolvedValue('configure') + vi.mocked(setupGradle).mockImplementation(async config => { + ;(config as Record)['bin'] = './gradlew' + return { ok: true, data: { canceled: false } } + }) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'configured' }, + }) + expect(setupGradle).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalled() + }) + + it('leaves the candidate untouched when the user picks inherit', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockResolvedValue('inherit') + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'inherited' }, + }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('reports a cancellation from the action prompt itself', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockResolvedValue(null) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result.ok && result.data.canceled).toBe(true) + expect(writeSocketJson).not.toHaveBeenCalled() + }) +}) + describe('setupRecursiveManifestConfig', () => { const cwd = '/repo' @@ -285,6 +524,16 @@ describe('setupRecursiveManifestConfig', () => { vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) vi.mocked(writeSocketJson).mockReset() vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + vi.mocked(detectManifestActions).mockReset() + vi.mocked(detectManifestActions).mockResolvedValue({ + bazel: false, + cdxgen: false, + count: 0, + conda: false, + gradle: false, + maven: false, + sbt: false, + }) }) it('proceeds to discovery when all three root ecosystem questions are declined', async () => { @@ -307,6 +556,32 @@ describe('setupRecursiveManifestConfig', () => { expect(findBuildToolCandidates).toHaveBeenCalled() }) + it('asks about a detected ecosystem first, phrased as detected, before undetected ones', async () => { + vi.mocked(detectManifestActions).mockResolvedValue({ + bazel: false, + cdxgen: false, + count: 1, + conda: false, + gradle: false, + maven: true, + sbt: false, + }) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + return false + }) + vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) + + await setupRecursiveManifestConfig(cwd, false) + + // Maven (detected) is asked about first, phrased as detected; Gradle and + // sbt (undetected) follow, phrased as "anyway". + expect(messages[0]).toMatch(/Maven was detected at this root/) + expect(messages[1]).toMatch(/Gradle wasn't detected here.*anyway/) + expect(messages[2]).toMatch(/sbt wasn't detected here.*anyway/) + }) + it('skips discovery entirely when the user declines the recursive-discovery gate', async () => { vi.mocked(select) .mockResolvedValueOnce(false) @@ -331,17 +606,41 @@ describe('setupRecursiveManifestConfig', () => { .mockResolvedValueOnce(false) // Write the config? -> yes. .mockResolvedValueOnce(true) + vi.mocked(setupMaven).mockImplementation(async config => { + ;(config as Record)['bin'] = './mvnw' + return { ok: true, data: { canceled: false } } + }) + vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith(cwd, expect.any(Object)) + }) + + it('does not write when the user says yes to configure Maven but leaves every prompt blank', async () => { + vi.mocked(select) + // Configure Maven? -> yes. + .mockResolvedValueOnce(true) + // Configure Gradle? -> no. + .mockResolvedValueOnce(false) + // Configure sbt? -> no. + .mockResolvedValueOnce(false) + // Recursively discover...? -> no (never reaches the write-confirmation + // select at all since nothing ended up configured). + .mockResolvedValue(false) + // A no-op wizard: doesn't set a single field. vi.mocked(setupMaven).mockResolvedValue({ ok: true, data: { canceled: false }, }) - vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) const result = await setupRecursiveManifestConfig(cwd, false) expect(result).toEqual({ ok: true, data: { canceled: false } }) expect(setupMaven).toHaveBeenCalledTimes(1) - expect(writeSocketJson).toHaveBeenCalledWith(cwd, expect.any(Object)) + expect(writeSocketJson).not.toHaveBeenCalled() }) it('stops when canceling one of the root ecosystem questions', async () => { @@ -378,6 +677,36 @@ describe('setupRecursiveManifestConfig', () => { expect(findBuildToolCandidates).not.toHaveBeenCalled() }) + it('reports the discovered count and walks included candidates even when no --exclude-paths is given, instead of doing nothing', async () => { + vi.mocked(select) + // Configure Maven/Gradle/sbt? -> no, no, no. + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + // Recursively discover...? -> yes. + .mockResolvedValueOnce(true) + // Candidate action prompt -> anything but 'disable'/'configure' is a + // safe no-op (inherit), so this never touches writeSocketJson. + .mockResolvedValue('inherit') + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['gradle', [`${cwd}/active`]]]), + ) + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + const logSpy = vi.spyOn(logger, 'log') + + try { + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).not.toHaveBeenCalled() + const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(logged).toMatch(/Found 1 build root\(s\) beneath/) + expect(logged).toMatch(/0 configured, 1 left inheriting/) + } finally { + logSpy.mockRestore() + } + }) + it('reports nothing excluded without writing anything', async () => { vi.mocked(select) // Configure Maven/Gradle/sbt? -> no, no, no. @@ -433,4 +762,55 @@ describe('setupRecursiveManifestConfig', () => { }), ) }) + + it('configures and leaves candidates inheriting in a single pass, skipping candidates already disabled via cascade', async () => { + const serviceA = `${cwd}/serviceA` + const serviceB = `${cwd}/serviceB` + const serviceBSubmodule = `${serviceB}/submodule` + const serviceC = `${cwd}/serviceC` + + vi.mocked(select) + // Configure Maven/Gradle/sbt? -> no, no, no. + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + // Recursively discover...? -> yes. + .mockResolvedValueOnce(true) + // serviceA (depth 1) -> configure. + .mockResolvedValueOnce('configure') + // serviceC (depth 1) -> inherit. + .mockResolvedValueOnce('inherit') + // serviceB and serviceB/submodule are already disabled - via a prior + // --exclude-paths run, say - so neither is ever prompted. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([ + ['maven', [serviceA, serviceB, serviceBSubmodule]], + ['gradle', [serviceC]], + ]), + ) + vi.mocked(readSocketJsonCascade).mockImplementation(dir => + dir === serviceB || dir === serviceBSubmodule + ? ({ + version: 1, + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson) + : emptySockJson(), + ) + vi.mocked(setupMaven).mockImplementation(async config => { + ;(config as Record)['bin'] = './mvnw' + return { ok: true, data: { canceled: false } } + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(select).toHaveBeenCalledTimes(6) + expect(writeSocketJson).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith( + serviceA, + expect.objectContaining({ + defaults: { manifest: { maven: { bin: './mvnw' } } }, + }), + ) + }) }) From 565b284c7d1f9ce9ebc55a577fafc5942f238ff5 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 14:42:48 +0200 Subject: [PATCH 13/27] Fix null-clear and exclude-paths bugs in the recursive setup wizard Fixes two correctness bugs found in an audit pass: leaving a config prompt blank when the field was already explicitly cleared (null) deleted the key instead of preserving the clear, silently reverting it to inheriting an ancestor's value; and --exclude-paths never reached the wizard's workspace enumeration, so excluding a broken reactor member didn't stop the wizard from still trying to resolve it and aborting the whole walk. Also tightens several UX rough edges in the recursive wizard: drops a redundant write confirmation inconsistent with the rest of the flow, fixes a tally line that double-counted re-enabled candidates and never reported disabled ones, and softens wording that overclaimed knowledge the wizard doesn't actually have yet (a build root at cwd, a fixed --exclude-paths prompt count). Trims the surrounding comments down to non-obvious rationale only, per repo comment-style guidelines. --- src/commands/manifest/cmd-manifest-setup.mts | 2 +- .../manifest/generate-recursive-manifests.mts | 44 +- .../manifest/setup-manifest-config.mts | 73 +- .../manifest/setup-manifest-config.test.mts | 45 ++ .../setup-recursive-manifest-config.mts | 665 ++++++++++------ .../setup-recursive-manifest-config.test.mts | 710 +++++++++++++----- 6 files changed, 1077 insertions(+), 462 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 4173fbfa3b..64536bdec8 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -37,7 +37,7 @@ const config: CliCommandConfig = { type: 'boolean', hidden: true, description: - 'After configuring CWD, recursively discover every gradle/sbt/maven build root beneath it. A build root matching --exclude-paths is bulk-disabled with no prompt; every other one gets an interactive configure-or-inherit-defaults prompt', + 'Recursively scans for every gradle/sbt/maven build root beneath CWD first, so the CWD config step only asks about ecosystems actually found somewhere in the tree. A build root matching --exclude-paths is bulk-disabled with no prompt, applied unconditionally; eligible build roots found afterward can be configured individually', }, }, help: (command, config) => ` diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index b99b3d03e2..f1af732d5a 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -29,7 +29,7 @@ export type RecursiveManifestOutcome = { status: RecursiveManifestOutcomeStatus } -type EcosystemBuildConfig = { +export type EcosystemBuildConfig = { bin: string buildOpts: string[] excludeConfigs: string @@ -59,15 +59,9 @@ function getSkipReason( type DisabledRoot = { dir: string; sockJson: SocketJson } -// Nearest already-confirmed-disabled ancestor of `dir` (if any): lets the +// Nearest already-confirmed-disabled ancestor of `dir`, if any - lets the // caller shorten `readSocketJsonCascade`'s walk to start there instead of -// all the way back at `cwd`. A build root with hundreds of nested candidates -// (a big disabled legacy reactor, say) would otherwise re-walk the same long -// ancestor chain from `cwd` for every single one. Correctness is unaffected -// - the shortened walk still checks every directory between `dir` and the -// chosen boundary, so a nested override (re-enabling a specific subproject) -// is still honored - it's just cheaper when nothing overrides it, which is -// the common case. Picks the deepest (nearest) match if several qualify. +// all the way back at `cwd`, without skipping any nested override. function nearestDisabledRoot( dir: string, disabledRoots: readonly DisabledRoot[], @@ -84,11 +78,10 @@ function nearestDisabledRoot( return nearest } -// Resolves this build root's effective per-ecosystem build-tool config from -// its cascaded socket.json; a wrapper-preferred `bin` default is resolved -// per-root (`dir`, not `cwd`) since a wrapper script only exists at the -// actual build root. -function resolveEcosystemConfig( +// A wrapper-preferred `bin` default is resolved per-root (`dir`, not `cwd`) +// since a wrapper script only exists at the actual build root. Exported for +// reuse by the recursive setup wizard's reactor-coverage pruning. +export function resolveEcosystemConfig( ecosystem: BuildTool, dir: string, sockJson: SocketJson, @@ -132,22 +125,13 @@ function resolveEcosystemConfig( } } -// Recursively discovers gradle/sbt/maven build roots under `cwd` and -// generates one `.socket.facts.json` per independent build root. Coverage is -// tracked per ecosystem (not globally) using the facts SBOM's own -// `projects[].subprojectDir` — never by pruning an entire discovered -// subtree — so a reactor/multi-project member is skipped on re-encounter -// while an unrelated nested project the reactor doesn't declare (e.g. a -// stray git-submodule pom, or a different-ecosystem project nested inside a -// covered directory tree) still gets its own invocation. Fail-closed: a build -// root whose workspace layout couldn't be determined (the build tool crashed -// or a blocking resolution failure prevented `projects[]` from being read) -// aborts the entire walk instead of continuing to its still-undiscovered -// descendants - without that root's `projects[]`, there's no way to tell -// whether a later candidate is one of its own already-covered members or a -// genuinely independent project, and guessing risks silently mis-scanning a -// subproject as standalone (or vice versa) plus a cascade of doomed attempts -// against a build that's already known to be broken. +// Generates one .socket.facts.json 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 +// own invocation. Fail-closed: a root whose workspace layout can't be +// determined aborts the whole walk, since without its projects[] a later +// candidate can't safely be classified as covered vs. independent. export async function generateRecursiveManifests({ cwd, excludePaths, diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index 27fb8541a1..ee68a0fdc6 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -273,6 +273,9 @@ export async function setupGradle( config: NonNullable< NonNullable['manifest']>['gradle'] >, + // Set by the recursive dynamic-sbom-inference wizard, which has no + // pom-mode equivalent - skips the facts/pom question entirely. + { factsOnly = false }: { factsOnly?: boolean } = {}, ): Promise> { const priorBin = config.bin const bin = await askForBin(config.bin || '', './gradlew') @@ -280,7 +283,7 @@ export async function setupGradle( return canceledByUser() } else if (bin) { config.bin = bin - } else if (priorBin) { + } else if (priorBin !== undefined) { config.bin = null } else { delete config.bin @@ -292,7 +295,7 @@ export async function setupGradle( return canceledByUser() } else if (javaHome) { config.javaHome = javaHome - } else if (priorJavaHome) { + } else if (priorJavaHome !== undefined) { config.javaHome = null } else { delete config.javaHome @@ -309,24 +312,26 @@ export async function setupGradle( return canceledByUser() } else if (opts) { config.gradleOpts = opts - } else if (priorGradleOpts) { + } else if (priorGradleOpts !== undefined) { config.gradleOpts = null } else { delete config.gradleOpts } - const facts = await askForFactsFlag(config.facts) - if (facts === undefined) { - return canceledByUser() - } else if (facts === 'yes' || facts === 'no') { - config.facts = facts === 'yes' - } else { - delete config.facts + if (!factsOnly) { + const facts = await askForFactsFlag(config.facts) + if (facts === undefined) { + return canceledByUser() + } else if (facts === 'yes' || facts === 'no') { + config.facts = facts === 'yes' + } else { + delete config.facts + } } // The config filters and --ignore-unresolved only apply to facts generation // (the default); skip them when pom generation (--pom) is selected. - if (config.facts !== false) { + if (factsOnly || config.facts !== false) { const factsOptions = await setupFactsOptions(config) if (!factsOptions.ok || factsOptions.data.canceled) { return factsOptions @@ -356,7 +361,7 @@ export async function setupMaven( return canceledByUser() } else if (bin) { config.bin = bin - } else if (priorBin) { + } else if (priorBin !== undefined) { config.bin = null } else { delete config.bin @@ -368,7 +373,7 @@ export async function setupMaven( return canceledByUser() } else if (javaHome) { config.javaHome = javaHome - } else if (priorJavaHome) { + } else if (priorJavaHome !== undefined) { config.javaHome = null } else { delete config.javaHome @@ -384,7 +389,7 @@ export async function setupMaven( return canceledByUser() } else if (opts) { config.mavenOpts = opts - } else if (priorMavenOpts) { + } else if (priorMavenOpts !== undefined) { config.mavenOpts = null } else { delete config.mavenOpts @@ -413,6 +418,8 @@ export async function setupSbt( config: NonNullable< NonNullable['manifest']>['sbt'] >, + // See setupGradle's matching parameter for why. + { factsOnly = false }: { factsOnly?: boolean } = {}, ): Promise> { const priorBin = config.bin const bin = await askForBin(config.bin || '', 'sbt') @@ -420,7 +427,7 @@ export async function setupSbt( return canceledByUser() } else if (bin) { config.bin = bin - } else if (priorBin) { + } else if (priorBin !== undefined) { config.bin = null } else { delete config.bin @@ -432,7 +439,7 @@ export async function setupSbt( return canceledByUser() } else if (javaHome) { config.javaHome = javaHome - } else if (priorJavaHome) { + } else if (priorJavaHome !== undefined) { config.javaHome = null } else { delete config.javaHome @@ -449,25 +456,27 @@ export async function setupSbt( return canceledByUser() } else if (opts) { config.sbtOpts = opts - } else if (priorSbtOpts) { + } else if (priorSbtOpts !== undefined) { config.sbtOpts = null } else { delete config.sbtOpts } - const facts = await askForFactsFlag(config.facts) - if (facts === undefined) { - return canceledByUser() - } else if (facts === 'yes' || facts === 'no') { - config.facts = facts === 'yes' - } else { - delete config.facts + if (!factsOnly) { + const facts = await askForFactsFlag(config.facts) + if (facts === undefined) { + return canceledByUser() + } else if (facts === 'yes' || facts === 'no') { + config.facts = facts === 'yes' + } else { + delete config.facts + } } // Socket facts is the default. The pom output questions (stdout/outfile) // only apply when pom generation (--pom) is explicitly selected; otherwise // ask the facts-only options. - if (config.facts === false) { + if (!factsOnly && config.facts === false) { const stdout = await askForStdout(config.stdout) if (stdout === undefined) { return canceledByUser() @@ -594,13 +603,9 @@ async function askForOutputFile(defaultName = ''): Promise { }) } -// `defaultName` is only ever a *prior* explicit value (own file or, for the -// recursive per-project wizard, the cascaded effective value) - never the -// tool's own hardcoded fallback (e.g. `mvn`). `input()` returns whatever's -// shown when the user just presses Enter, so pre-filling a fabricated -// fallback there would be indistinguishable from the user actually typing -// it, freezing it into socket.json for no reason. The fallback is mentioned -// in `fallbackHint` purely as informational text. +// `defaultName` must be a prior explicit/cascaded value, never the tool's +// hardcoded fallback (e.g. `mvn`) - pre-filling that would freeze it into +// socket.json on a bare Enter. `fallbackHint` is informational text only. async function askForBin( defaultName = '', fallbackHint = '', @@ -728,7 +733,7 @@ async function setupFactsOptions(config: { return canceledByUser() } else if (includeConfigs) { config.includeConfigs = includeConfigs - } else if (priorIncludeConfigs) { + } else if (priorIncludeConfigs !== undefined) { // Was previously set; clear it explicitly instead of just deleting the // key, so it doesn't silently start inheriting an ancestor's value again. config.includeConfigs = null @@ -747,7 +752,7 @@ async function setupFactsOptions(config: { return canceledByUser() } else if (excludeConfigs) { config.excludeConfigs = excludeConfigs - } else if (priorExcludeConfigs) { + } else if (priorExcludeConfigs !== undefined) { config.excludeConfigs = null } else { delete config.excludeConfigs diff --git a/src/commands/manifest/setup-manifest-config.test.mts b/src/commands/manifest/setup-manifest-config.test.mts index bcb088d000..ed47c2c09a 100644 --- a/src/commands/manifest/setup-manifest-config.test.mts +++ b/src/commands/manifest/setup-manifest-config.test.mts @@ -9,6 +9,8 @@ vi.mock('@socketsecurity/registry/lib/prompts', () => ({ select: vi.fn(async ({ default: def }: { default?: string }) => def ?? ''), })) +import { select } from '@socketsecurity/registry/lib/prompts' + import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' describe('setupGradle/setupMaven/setupSbt', () => { @@ -36,4 +38,47 @@ describe('setupGradle/setupMaven/setupSbt', () => { expect(config['bin']).toBe('./custom-gradlew') }) + + it('preserves an explicit null (already-cleared) field on a blank re-prompt, instead of deleting it back into inheriting', async () => { + const config: Record = { + bin: null, + gradleOpts: null, + javaHome: null, + } + + await setupGradle(config) + + expect(config['bin']).toBeNull() + expect(config['javaHome']).toBeNull() + expect(config['gradleOpts']).toBeNull() + }) + + it('asks the facts/pom question by default', async () => { + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ default: def, message }) => { + messages.push(message) + return def ?? '' + }) + + await setupGradle({}) + await setupSbt({}) + + expect(messages.some(m => m.includes('--facts / --pom'))).toBe(true) + }) + + it('skips the facts/pom question entirely when factsOnly is set, going straight to facts-only options', async () => { + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ default: def, message }) => { + messages.push(message) + return def ?? '' + }) + + await setupGradle({}, { factsOnly: true }) + await setupSbt({}, { factsOnly: true }) + + expect(messages.some(m => m.includes('--facts / --pom'))).toBe(false) + // The facts-only options (config filters) still ask, just without the + // facts/pom choice gating them. + expect(messages.some(m => m.includes('--ignore-unresolved'))).toBe(true) + }) }) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 12a0dc916e..06235c9d24 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -6,11 +6,12 @@ import micromatch from 'micromatch' import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' -import { detectManifestActions } from './detect-manifest-actions.mts' import { findBuildToolCandidates, realpathOrResolved, } from './discover-manifest-roots.mts' +import { enumerateWorkspaces } from './enumerate-workspaces.mts' +import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' import { SOCKET_JSON } from '../../constants.mts' import { @@ -19,7 +20,10 @@ import { readSocketJsonSync, writeSocketJson, } from '../../utils/socket-json.mts' -import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' +import { + excludePathToScanIgnores, + projectIgnorePathsToReachExcludePaths, +} from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { CResult } from '../../types.mts' @@ -38,12 +42,30 @@ const ECOSYSTEM_LABELS: Record = { sbt: 'sbt', } as unknown as Record -// One directory to mark `disabled: true` in, covering every ecosystem found -// excluded beneath it. `dir` is the shallowest directory that itself matches -// `--exclude-paths` - not necessarily a project dir of its own - so a single -// write covers every sibling/nested project underneath, instead of one write -// per matched project (which would miss sibling projects that don't happen to -// be descendants of whichever matched project was written first). +// findBuildToolCandidates skips scanning for a root-disabled tool entirely +// (correct for generation) but that would hide it from this wizard with no +// way to re-enable it. Strip disabled before the scan only; every prompt and +// write still reads the real sockJson. +function withoutDisabledFlags(sockJson: SocketJson): SocketJson { + const manifest = sockJson.defaults?.manifest + if (!manifest) { + return sockJson + } + const stripped: Record = { ...manifest } + for (const ecosystem of ROOT_ECOSYSTEMS) { + const section = manifest[ecosystem] + if (section?.disabled) { + stripped[ecosystem] = { ...section, disabled: false } + } + } + return { + ...sockJson, + defaults: { ...sockJson.defaults, manifest: stripped }, + } as SocketJson +} + +// The shallowest directory matching `--exclude-paths`, not necessarily a +// project dir itself - one write here covers every project beneath it. type ExclusionRoot = { dir: string; ecosystems: BuildTool[] } function canceledByUser(): CResult<{ canceled: boolean }> { @@ -68,14 +90,10 @@ function getEcosystemSection( ) } -// Depth-then-path sort. For exclusion roots this is purely for stable, -// predictable log ordering - separate exclusion roots never nest inside one -// another (see findExclusionRoot), so there's no cascade-correctness -// dependency between those writes. For plain candidates (the per-project -// configure-or-inherit walk) the order matters for a different reason: a -// parent must be processed before its children so that if the parent gets -// configured, a child's shown "inherited" default already reflects that -// change (via cascade) instead of the parent's pre-run value. +// Depth-then-path sort: for exclusion roots it's just stable log ordering; +// for candidates it's load-bearing, since a parent must be prompted before +// its children so a child's shown default reflects a parent's just-written +// cascade instead of its pre-run value. export function sortCandidatesForDisplay( candidates: readonly T[], cwd: string, @@ -96,12 +114,9 @@ function toPosixRelative(cwd: string, dir: string): string { return path.relative(cwd, dir).split(path.sep).join('/') } -// Walks a matched candidate's path from shallowest to deepest and returns the -// first (shallowest) prefix that itself matches one of the --exclude-paths -// ignore patterns - i.e. the directory the exclusion should actually be -// written to. Writing there instead of at the matched candidate itself means -// one write covers every sibling/nested project beneath it, even when that -// directory isn't a build root of its own. +// Shallowest prefix of `relDir` that matches an --exclude-paths pattern - +// the directory to write `disabled` to, so one write covers everything +// beneath it, even if that directory isn't itself a build root. function findExclusionRoot( relDir: string, ignorePatterns: readonly string[], @@ -116,49 +131,54 @@ function findExclusionRoot( return relDir } +export type ScannedBuildRoots = { + fullByTool: Map + includedByTool: Map +} + +// Both the unfiltered and --exclude-paths-filtered scans, once, so ecosystem +// detection and later include/exclude grouping (see discoverBuildRoots) +// never re-walk the same tree. +export async function scanBuildRoots({ + cwd, + excludePaths, + sockJson, +}: { + cwd: string + excludePaths?: string[] | undefined + sockJson: SocketJson +}): Promise { + const [fullByTool, includedByTool] = await Promise.all([ + findBuildToolCandidates({ cwd, sockJson }), + findBuildToolCandidates({ cwd, excludePaths, sockJson }), + ]) + return { fullByTool, includedByTool } +} + export type DiscoveredBuildRoots = { excluded: ExclusionRoot[] // Every candidate --exclude-paths didn't rule out - these get the // interactive configure-or-inherit walk instead of a bulk write. included: Candidate[] - // Every gradle/sbt/maven candidate found beneath `cwd`, excluded or not - - // reported so the wizard can show discovery actually walked the tree even - // when nothing ends up excluded (no --exclude-paths given, or none of it - // matched), instead of a bare "found nothing" that reads the same either way. - totalCandidateCount: number } -// Discovers every gradle/sbt/maven build root beneath `cwd` (a plain -// filesystem walk, no dependency resolution and no build-tool invocation - -// so no bin/javaHome is ever needed), diffs an unfiltered walk against an -// excludePaths-filtered walk (both via the same findBuildToolCandidates -// fast-glob machinery, which already treats --exclude-paths as anchored -// ignores that prevent descending into a matched subtree at all) to split -// candidates into `included` (the interactive per-project walk) and -// `excluded`, grouped by the shallowest directory that actually matched -// --exclude-paths (see findExclusionRoot) so a whole excluded subtree gets -// exactly one write, regardless of how many build roots or ecosystems it -// contains. `cwd` itself is excluded from both - it already got its own -// wizard pass. `cwd` is realpath-resolved before comparing: the discovered -// dirs findBuildToolCandidates returns already are (it resolves symlinks so -// results are stable), and on macOS /tmp -> /private/tmp alone is enough to -// otherwise break the comparison. +// Splits an already-scanned candidate set into `included` (the interactive +// walk) and `excluded`, grouped by the shallowest --exclude-paths match (see +// findExclusionRoot). `cwd` is excluded from both (it got its own wizard +// pass) and realpath-resolved to match findBuildToolCandidates' own +// resolution - otherwise macOS's /tmp -> /private/tmp breaks the comparison. export async function discoverBuildRoots({ cwd, excludePaths, - rootSockJson, + fullByTool, + includedByTool, }: { cwd: string excludePaths?: string[] | undefined - rootSockJson: SocketJson + fullByTool: Map + includedByTool: Map }): Promise { const realCwd = await realpathOrResolved(cwd) - const [fullByTool, includedByTool] = await Promise.all([ - findBuildToolCandidates({ cwd, sockJson: rootSockJson }), - findBuildToolCandidates({ cwd, excludePaths, sockJson: rootSockJson }), - ]) - - let totalCandidateCount = 0 const included: Candidate[] = [] const ignorePatterns = (excludePaths ?? []).flatMap(excludePathToScanIgnores) const ecosystemsByRoot = new Map>() @@ -168,7 +188,6 @@ export async function discoverBuildRoots({ if (dir === realCwd) { continue } - totalCandidateCount += 1 if (includedDirs.has(dir)) { included.push({ dir, ecosystem }) continue @@ -188,10 +207,72 @@ export async function discoverBuildRoots({ ecosystems: [...ecosystems].sort(), })), included, - totalCandidateCount, } } +// Enumerates one build root's declared workspace members (see +// enumerate-workspaces.mts) and folds them into `coveredByEcosystem`, so a +// later candidate matching one is recognized as a reactor member rather than +// independent. Called per-candidate right after its own prompt, not as a +// bulk pass, so the build invocation never blocks candidates that don't need +// it. A disabled candidate is skipped (no point invoking an off build tool); +// otherwise this fails closed, same reasoning as generateRecursiveManifests - +// if enumeration fails there's no way to tell covered from independent, so +// the caller aborts rather than guess. +export async function markWorkspaceCoverage({ + candidate, + coveredByEcosystem, + cwd, + excludePaths, + rootSockJson, +}: { + candidate: Candidate + coveredByEcosystem: Map> + cwd: string + excludePaths?: string[] | undefined + rootSockJson: SocketJson +}): Promise> { + const cascade = readSocketJsonCascade(candidate.dir, cwd, rootSockJson) + const { bin, buildOpts, javaHome, skipReason } = resolveEcosystemConfig( + candidate.ecosystem, + candidate.dir, + cascade, + ) + if (skipReason) { + return { ok: true, data: undefined } + } + + const excludePathsForCandidate = projectIgnorePathsToReachExcludePaths( + excludePaths, + { cwd, target: candidate.dir }, + ) + + const enumResult = await enumerateWorkspaces({ + bin, + buildOpts, + cwd: candidate.dir, + ecosystem: candidate.ecosystem, + excludePaths: excludePathsForCandidate, + javaHome, + verbose: false, + }) + if (!enumResult) { + const relDir = path.relative(cwd, candidate.dir) || '.' + return { + ok: false, + message: `Could not determine ${candidate.ecosystem} workspace layout for ${relDir}; aborting rather than risk misclassifying its members.`, + } + } + + const set = coveredByEcosystem.get(candidate.ecosystem) ?? new Set() + set.add(candidate.dir) + for (const project of enumResult.projects) { + set.add(path.resolve(candidate.dir, project.subprojectDir)) + } + coveredByEcosystem.set(candidate.ecosystem, set) + return { ok: true, data: undefined } +} + // Marks one exclusion root's own socket.json `disabled: true` for whichever // of its ecosystems aren't already disabled via cascade (an already-disabled // ancestor from a prior run) - a no-op write is skipped entirely so re-running @@ -239,9 +320,11 @@ export async function disableExclusionRoot({ return notCanceled() } -// Dispatches to the right ecosystem-specific wizard - the three have -// different config shapes, but this is the only place that needs to know -// that; every caller just deals with `BuildTool` generically. +// Dispatches to the right ecosystem-specific wizard. `factsOnly` skips the +// facts/pom question - dynamic-sbom-inference never generates a pom.xml, it +// just skips a project resolved to `facts: false` (see +// generateRecursiveManifests' skipReason), so offering that choice here +// would be misleading. async function runEcosystemWizard( ecosystem: BuildTool, config: Record, @@ -251,6 +334,7 @@ async function runEcosystemWizard( config as NonNullable< NonNullable['manifest']>['gradle'] >, + { factsOnly: true }, ) } if (ecosystem === 'maven') { @@ -264,15 +348,14 @@ async function runEcosystemWizard( config as NonNullable< NonNullable['manifest']>['sbt'] >, + { factsOnly: true }, ) } -// Runs the same per-ecosystem wizard used for the root, seeded with this -// candidate's *effective* (cascaded) value for any field its own socket.json -// doesn't already set - so accepting every prompt unchanged preserves -// whatever it currently inherits, while an actual change writes an explicit -// override. Own-file values win over the cascaded seed, so re-running this -// against an already-configured candidate shows its own prior answers. +// Seeds the wizard with this candidate's effective (cascaded) value for any +// field its own file doesn't already set, so accepting every prompt +// unchanged preserves what it currently inherits, while an actual change +// writes an explicit override. export async function configureCandidate({ cwd, dir, @@ -323,12 +406,9 @@ export async function configureCandidate({ type CandidateAction = 'configure' | 'inherit' -// Disabling a candidate is deliberately not offered here - that's -// --exclude-paths' job (a bulk, path-based write covering a whole subtree in -// one go, see findExclusionRoot). Offering it per-candidate too would -// undermine that: an interactive disable here only ever touches this one -// directory's own file, none of the "shallowest excluded ancestor" grouping -// that keeps the tree's disabled state coherent and cheap to re-derive. +// No disable choice here - that's --exclude-paths' job (see +// findExclusionRoot), so a whole subtree still collapses to one write +// instead of one per candidate. async function askCandidateAction( relDir: string, ecosystem: BuildTool, @@ -337,10 +417,10 @@ async function askCandidateAction( message: `${relDir} (${ecosystem})`, choices: [ { - name: 'Use inherited defaults', + name: 'Leave as-is', value: 'inherit', description: - "Leave this project inheriting whatever cascades down from its ancestors' socket.json", + "Make no change - keep this project's current effective configuration", }, { name: 'Configure', @@ -352,35 +432,27 @@ async function askCandidateAction( })) as CandidateAction | null } -type CandidateOutcome = - | 'configured' - | 'inherited' - // Already disabled via cascade (an ancestor disabled through - // --exclude-paths, or a pre-existing config) - not re-prompted, since - // asking about a project an --exclude-paths write already covers would be - // noise. - | 'skipped' - -// Decides and applies one discovered, non-excluded candidate's fate: prompt -// for configure/inherit, unless its cascade already shows it disabled (in -// which case it's silently skipped - see the CandidateOutcome.skipped note). -export async function processCandidate({ +type CandidateOutcome = 'configured' | 'inherited' | 'skipped' + +// Asks the standard configure-or-leave-as-is question and applies the +// answer. Shared by the normal path and the post-re-enable path below, so +// re-enabling a candidate isn't a dead end that skips straight past its own +// bin/JDK/opts configuration. +async function askAndApplyAction({ cwd, dir, ecosystem, + relDir, rootSockJson, }: { cwd: string dir: string ecosystem: BuildTool + relDir: string rootSockJson: SocketJson -}): Promise> { - const relDir = path.relative(cwd, dir) || '.' - const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) - if (getEcosystemSection(cascade, ecosystem)['disabled'] === true) { - return { ok: true, data: { canceled: false, outcome: 'skipped' } } - } - +}): Promise< + CResult<{ canceled: boolean; outcome: 'configured' | 'inherited' }> +> { const action = await askCandidateAction(relDir, ecosystem) if (action === undefined || action === null) { canceledByUser() @@ -402,12 +474,98 @@ export async function processCandidate({ return { ok: true, data: { canceled: false, outcome: 'inherited' } } } -// Accepting every prompt's shown default (now that askForBin no longer -// pre-fills a fabricated tool fallback, see setup-manifest-config.mts) -// leaves an ecosystem's section genuinely empty - no field actually differs -// from "inherit/use the tool default". Drop it so `configuredAny` and the -// write-confirmation prompt reflect what was actually configured, not just -// which ecosystems the user said "yes" to walking through. +// Prompts for configure/inherit, unless the cascade shows the candidate +// disabled: offer to re-enable if that's its own file, then continue into +// the normal prompt; stay silent if it's only inherited from an ancestor's +// bulk write (that's noise for a large excluded subtree). +export async function processCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + rootSockJson: SocketJson +}): Promise< + CResult<{ canceled: boolean; outcome: CandidateOutcome; reenabled: boolean }> +> { + const relDir = path.relative(cwd, dir) || '.' + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + if (getEcosystemSection(cascade, ecosystem)['disabled'] === true) { + const ownSockJson = readOrDefaultSocketJson(dir) + if (getEcosystemSection(ownSockJson, ecosystem)['disabled'] !== true) { + // Inherited from an ancestor, not this candidate's own file - stay silent. + return { + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + } + } + + const wantsReenable = await askYesNo( + `${relDir} (${ecosystem}) is disabled - re-enable it?`, + ) + if (wantsReenable === undefined || wantsReenable === null) { + canceledByUser() + return { + ok: true, + data: { canceled: true, outcome: 'skipped', reenabled: false }, + } + } + if (!wantsReenable) { + return { + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + } + } + if (!ownSockJson.defaults) { + ownSockJson.defaults = {} + } + if (!ownSockJson.defaults.manifest) { + ownSockJson.defaults.manifest = {} + } + const manifest = ownSockJson.defaults.manifest as Record + manifest[ecosystem] = { + ...getEcosystemSection(ownSockJson, ecosystem), + disabled: false, + } + const writeResult = await writeSocketJson(dir, ownSockJson) + if (!writeResult.ok) { + return writeResult + } + logger.success(`Re-enabled ${relDir} (${ecosystem})`) + + const result = await askAndApplyAction({ + cwd, + dir, + ecosystem, + relDir, + rootSockJson, + }) + if (!result.ok) { + return result + } + return { ok: true, data: { ...result.data, reenabled: true } } + } + + const result = await askAndApplyAction({ + cwd, + dir, + ecosystem, + relDir, + rootSockJson, + }) + if (!result.ok) { + return result + } + return { ok: true, data: { ...result.data, reenabled: false } } +} + +// Accepting every prompt's default leaves an ecosystem's section genuinely +// empty (see askForBin in setup-manifest-config.mts) - drop it so +// `configuredAny` reflects what was actually configured, not just which +// ecosystems the user said "yes" to. function dropIfEmpty( manifest: Record, ecosystem: BuildTool, @@ -430,20 +588,18 @@ async function askYesNo(message: string): Promise { })) as boolean | null } -// The recursive flow's root step: unlike the plain single-project wizard -// (`setupManifestConfig`, which assumes `cwd` IS a specific ecosystem's -// project and only lets you configure one before finishing), the recursion -// root is often just a common ancestor with no project of its own. Detecting -// what's actually here (the same marker-file check the plain wizard's -// `detectManifestActions` uses) lets the questions reflect that: a detected -// ecosystem is asked about first and phrased as "configure it", while an -// undetected one is asked afterward and phrased as "anyway" (for the case -// where a subproject further down needs it even though the root doesn't). -// Declining all three is a normal (non-canceled) outcome, not an abort - the -// exclude-paths-driven part of the recursive setup still proceeds. +// Unlike the plain single-project wizard, cwd here is often just a common +// ancestor - so `ecosystemsAtCwd` (a subset of `ecosystems`) picks the +// wording: cwd's own build root gets "configure this project's settings", +// everything else gets "configure defaults for nested projects". A +// pre-existing `disabled: true` gets a re-enable question first, otherwise +// there'd be no way back from a prior disable. Declining everything is +// normal, not an abort. async function setupRecursiveRootDefaults( cwd: string, defaultOnReadError: boolean, + ecosystems: readonly BuildTool[], + ecosystemsAtCwd: readonly BuildTool[], ): Promise> { const jsonPath = path.join(cwd, SOCKET_JSON) if (existsSync(jsonPath)) { @@ -463,21 +619,6 @@ async function setupRecursiveRootDefaults( logger.log('socket.json properties are inherited by nested paths.') logger.log('') - const detected = await detectManifestActions(null, cwd) - const detectedEcosystems = ROOT_ECOSYSTEMS.filter( - ecosystem => detected[ecosystem], - ) - if (detectedEcosystems.length) { - logger.log( - `Detected at this root: ${detectedEcosystems.map(ecosystem => ECOSYSTEM_LABELS[ecosystem]).join(', ')}.`, - ) - logger.log('') - } - const orderedEcosystems = [ - ...detectedEcosystems, - ...ROOT_ECOSYSTEMS.filter(ecosystem => !detected[ecosystem]), - ] - const sockJsonCResult = readSocketJsonSync(cwd, defaultOnReadError) if (!sockJsonCResult.ok) { return sockJsonCResult @@ -493,11 +634,29 @@ async function setupRecursiveRootDefaults( let configuredAny = false - for (const ecosystem of orderedEcosystems) { + for (const ecosystem of ecosystems) { const label = ECOSYSTEM_LABELS[ecosystem] - const message = detected[ecosystem] - ? `${label} was detected at this root - configure ${label} defaults?` - : `${label} wasn't detected here - configure defaults for it anyway?` + const existingSection = manifest[ecosystem] as + | Record + | undefined + if (existingSection?.['disabled'] === true) { + // eslint-disable-next-line no-await-in-loop + const wantsReenable = await askYesNo( + `${label} is currently disabled here - re-enable it?`, + ) + if (wantsReenable === undefined || wantsReenable === null) { + return canceledByUser() + } + if (!wantsReenable) { + continue + } + existingSection['disabled'] = false + configuredAny = true + } + + const message = ecosystemsAtCwd.includes(ecosystem) + ? `Configure ${label} settings for this project?` + : `Configure ${label} defaults for any nested projects?` // eslint-disable-next-line no-await-in-loop const wants = await askYesNo(message) if (wants === undefined || wants === null) { @@ -529,95 +688,91 @@ async function setupRecursiveRootDefaults( } logger.log('') - logger.log(`Setup complete. Writing ${SOCKET_JSON}`) + logger.log(`Writing ${SOCKET_JSON} to ${jsonPath}`) logger.log('') - if ( - await select({ - message: `Do you want to write the new config to ${jsonPath} ?`, - choices: [ - { name: 'yes', value: true, description: 'Update config' }, - { name: 'no', value: false, description: 'Do not update the config' }, - ], - }) - ) { - const writeResult = await writeSocketJson(cwd, sockJson) - if (!writeResult.ok) { - return writeResult - } - return notCanceled() + const writeResult = await writeSocketJson(cwd, sockJson) + if (!writeResult.ok) { + return writeResult } - return canceledByUser() + return notCanceled() } -// `socket manifest setup --dynamic-sbom-inference`: configures `cwd` via -// `setupRecursiveRootDefaults` first, then walks every gradle/sbt/maven build -// root beneath it. A candidate matching `--exclude-paths` is bulk-disabled by -// pure path matching (no build-tool invocation, no prompt - see -// findExclusionRoot); everything else gets an interactive per-project -// configure-or-inherit prompt (see processCandidate; disabling one -// individually isn't offered there - that stays --exclude-paths' job so a -// whole subtree keeps collapsing to one write instead of one per candidate). -// This sidesteps the -// circularity an earlier "discover via enumeration, then prompt" design hit -// (enumerating a nested project's own subprojects to prune already-covered -// reactor members needs a resolved bin/javaHome for that project, which isn't -// known until after prompting for it): this walk never tries to prune reactor -// members ahead of time, so it never needs to invoke a build tool to decide -// what to prompt about. The cost is that a reactor member (e.g. a Maven -// module) may get its own prompt and its own socket.json, which then goes -// unused once dynamic-sbom-inference's own coverage-tracking (driven by the -// parent build's actual output, not by socket.json) determines it's already -// covered - harmless, just a wasted prompt/file for that one candidate. +// `socket manifest setup --dynamic-sbom-inference`: the up-front scan (see +// scanBuildRoots) only ever answers "which build tools exist", never "which +// projects to configure". `--exclude-paths` matches are bulk-disabled +// unconditionally, independent of the interactive walk below, so re-running +// later with one more entry (or none) never re-answers settled decisions. +// Everything else gets a configure-or-inherit prompt (see processCandidate), +// except a workspace member a parent build already declares - learned via +// markWorkspaceCoverage right after the parent's own prompt, not a separate +// bulk pass, so a reactor member is silently skipped instead of prompted. export async function setupRecursiveManifestConfig( cwd: string, defaultOnReadError: boolean, excludePaths?: string[] | undefined, ): Promise> { logger.log('') - logger.log(`Configuring the root project at ${cwd} ...`) - const rootResult = await setupRecursiveRootDefaults(cwd, defaultOnReadError) - if (!rootResult.ok) { - return rootResult - } - if (rootResult.data.canceled) { - return canceledByUser() - } + logger.log(`Configuring ${cwd} ...`) - logger.log('') - const wantsDiscovery = await askYesNo('Recursively discover build roots?') - if (wantsDiscovery === undefined || wantsDiscovery === null) { - return canceledByUser() - } - if (!wantsDiscovery) { - logger.log('') - logger.success('Recursive setup complete.') - return notCanceled() - } - - // Re-read: the root wizard may have just written a new socket.json. - const rootSockJson = readOrDefaultSocketJson(cwd) + const initialSockJson = readOrDefaultSocketJson(cwd) // Resolved once here for sortCandidatesForDisplay/disableExclusionRoot's // relative-path math, consistent with discoverBuildRoots' own internal // resolution (see its comment for why this matters). const realCwd = await realpathOrResolved(cwd) logger.log('') - logger.log('Discovering build roots ...') - const { excluded, included, totalCandidateCount } = await discoverBuildRoots({ + logger.log('Scanning for build roots ...') + const { fullByTool, includedByTool } = await scanBuildRoots({ cwd, excludePaths, - rootSockJson, + sockJson: withoutDisabledFlags(initialSockJson), }) + // includedByTool (not the unfiltered fullByTool), so an ecosystem whose + // only candidate is about to be excluded this same run isn't offered + // either - there'd be nothing left for its root defaults to apply to. + const detectedEcosystems = ROOT_ECOSYSTEMS.filter( + ecosystem => (includedByTool.get(ecosystem)?.length ?? 0) > 0, + ) + if (detectedEcosystems.length) { + logger.log( + `Detected: ${detectedEcosystems.map(ecosystem => ECOSYSTEM_LABELS[ecosystem]).join(', ')}.`, + ) + } else { + logger.log(`No gradle/maven/sbt build roots found beneath ${cwd}.`) + } + logger.log('') - if (!totalCandidateCount) { - logger.log(`No build roots found beneath ${cwd}.`) - logger.log('') - logger.success('Recursive setup complete.') - return notCanceled() + // Which detected ecosystems cwd is itself a build root for - see + // setupRecursiveRootDefaults' wording split. + const ecosystemsAtCwd = ROOT_ECOSYSTEMS.filter(ecosystem => + (fullByTool.get(ecosystem) ?? []).includes(realCwd), + ) + + const rootResult = await setupRecursiveRootDefaults( + cwd, + defaultOnReadError, + detectedEcosystems, + ecosystemsAtCwd, + ) + if (!rootResult.ok) { + return rootResult } - logger.log(`Found ${totalCandidateCount} build root(s) beneath ${cwd}.`) + if (rootResult.data.canceled) { + return canceledByUser() + } + + // Re-read: the root wizard may have just written a new socket.json. + const rootSockJson = readOrDefaultSocketJson(cwd) + const { excluded, included } = await discoverBuildRoots({ + cwd, + excludePaths, + fullByTool, + includedByTool, + }) + // Applies regardless of the interactive walk below - see the module doc + // comment on why --exclude-paths is unconditional. if (excludePaths?.length) { if (!excluded.length) { logger.log('None matched --exclude-paths; nothing disabled.') @@ -638,48 +793,106 @@ export async function setupRecursiveManifestConfig( } } - if (included.length) { + if (!included.length) { logger.log('') - logger.log( - 'For each remaining build root, choose to configure it or leave it', - ) - logger.log( - "inheriting its ancestors' defaults. To disable one, re-run with", - ) - logger.log('--exclude-paths instead.') - logger.log( - 'Note: a project that turns out to be a module of a parent multi-module', - ) - logger.log('build is already covered there - configuring it is safe, but') - logger.log('may end up unused.') + logger.success('Recursive setup complete.') + return notCanceled() + } + + logger.log('') + const wantsIndividual = await askYesNo( + 'Configure other build roots found beneath this one, individually?', + ) + if (wantsIndividual === undefined || wantsIndividual === null) { + return canceledByUser() + } + if (!wantsIndividual) { logger.log('') + logger.success('Recursive setup complete.') + return notCanceled() + } - const counts = { configured: 0, inherited: 0, skipped: 0 } - const orderedIncluded = sortCandidatesForDisplay(included, realCwd) - for (const candidate of orderedIncluded) { - // eslint-disable-next-line no-await-in-loop - const result = await processCandidate({ - cwd: realCwd, - dir: candidate.dir, - ecosystem: candidate.ecosystem, - rootSockJson, - }) - if (!result.ok) { - return result - } - if (result.data.canceled) { - // The cancellation itself (select Esc/Ctrl+C, or a sub-wizard's own - // cancel) already logged "User canceled" - don't log it twice. - return { ok: true, data: { canceled: true } } - } - counts[result.data.outcome] += 1 + logger.log('') + logger.log( + 'For each one, choose to configure it or leave it as-is. To disable one,', + ) + logger.log('re-run with --exclude-paths instead.') + logger.log('') + + const coveredByEcosystem = new Map>() + // cwd never appears in `included`, but a reactor root often sits exactly + // at cwd - seed coverage from it first so its declared members still get + // recognized. + for (const ecosystem of ecosystemsAtCwd) { + // eslint-disable-next-line no-await-in-loop + const coverageResult = await markWorkspaceCoverage({ + candidate: { dir: realCwd, ecosystem }, + coveredByEcosystem, + cwd: realCwd, + excludePaths, + rootSockJson, + }) + if (!coverageResult.ok) { + return coverageResult } + } - logger.log('') - logger.log( - `${counts.configured} configured, ${counts.inherited} left inheriting.`, - ) + const counts = { + configured: 0, + covered: 0, + inherited: 0, + reenabled: 0, + skipped: 0, } + const orderedIncluded = sortCandidatesForDisplay(included, realCwd) + for (const candidate of orderedIncluded) { + const covered = coveredByEcosystem.get(candidate.ecosystem) + if (covered?.has(candidate.dir)) { + counts.covered += 1 + continue + } + + // eslint-disable-next-line no-await-in-loop + const result = await processCandidate({ + cwd: realCwd, + dir: candidate.dir, + ecosystem: candidate.ecosystem, + rootSockJson, + }) + if (!result.ok) { + return result + } + if (result.data.canceled) { + // The cancellation itself (select Esc/Ctrl+C, or a sub-wizard's own + // cancel) already logged "User canceled" - don't log it twice. + return { ok: true, data: { canceled: true } } + } + counts[result.data.outcome] += 1 + if (result.data.reenabled) { + // A qualifier on the 'configured'/'inherited' bucket just incremented + // above, not a separate bucket - avoids double-counting the candidate. + counts.reenabled += 1 + } + + // Interleaved rather than a bulk pre-pass, so this (possibly slow) + // build-tool invocation only runs for a project already prompted about. + // eslint-disable-next-line no-await-in-loop + const coverageResult = await markWorkspaceCoverage({ + candidate, + coveredByEcosystem, + cwd: realCwd, + excludePaths, + rootSockJson, + }) + if (!coverageResult.ok) { + return coverageResult + } + } + + logger.log('') + logger.log( + `${counts.configured} configured (${counts.reenabled} re-enabled), ${counts.inherited} left as-is, ${counts.skipped} left disabled, ${counts.covered} covered by a parent build.`, + ) logger.log('') logger.success('Recursive setup complete.') diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 1b5af84943..0e9bdc0ffe 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -5,16 +5,8 @@ vi.mock('./discover-manifest-roots.mts', () => ({ // Identity: test dirs are already-absolute plain strings, no symlinks involved. realpathOrResolved: vi.fn(async (dir: string) => dir), })) -vi.mock('./detect-manifest-actions.mts', () => ({ - detectManifestActions: vi.fn(async () => ({ - bazel: false, - cdxgen: false, - count: 0, - conda: false, - gradle: false, - maven: false, - sbt: false, - })), +vi.mock('./enumerate-workspaces.mts', () => ({ + enumerateWorkspaces: vi.fn(), })) vi.mock('@socketsecurity/registry/lib/prompts', () => ({ select: vi.fn(), @@ -34,14 +26,16 @@ vi.mock('../../utils/socket-json.mts', () => ({ import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' -import { detectManifestActions } from './detect-manifest-actions.mts' import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { enumerateWorkspaces } from './enumerate-workspaces.mts' import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' import { configureCandidate, disableExclusionRoot, discoverBuildRoots, + markWorkspaceCoverage, processCandidate, + scanBuildRoots, setupRecursiveManifestConfig, sortCandidatesForDisplay, } from './setup-recursive-manifest-config.mts' @@ -52,6 +46,7 @@ import { writeSocketJson, } from '../../utils/socket-json.mts' +import type { BuildTool } from './scripts/build-tool.mts' import type { SocketJson } from '../../utils/socket-json.mts' function emptySockJson(): SocketJson { @@ -128,50 +123,62 @@ describe('sortCandidatesForDisplay', () => { }) }) -describe('discoverBuildRoots', () => { +describe('scanBuildRoots', () => { const cwd = '/repo' beforeEach(() => { vi.mocked(findBuildToolCandidates).mockReset() }) - it('excludes cwd itself', async () => { - vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['gradle', [cwd]]]), + it('runs the unfiltered and excludePaths-filtered walks once each and returns both', async () => { + const unfiltered = new Map([['gradle', ['/repo/a', '/repo/legacy']]]) + const filtered = new Map([['gradle', ['/repo/a']]]) + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length ? filtered : unfiltered, ) - const result = await discoverBuildRoots({ + const result = await scanBuildRoots({ cwd, - rootSockJson: emptySockJson(), + excludePaths: ['legacy'], + sockJson: emptySockJson(), }) expect(result).toEqual({ - excluded: [], - included: [], - totalCandidateCount: 0, + fullByTool: unfiltered, + includedByTool: filtered, + }) + expect(findBuildToolCandidates).toHaveBeenCalledTimes(2) + }) +}) + +describe('discoverBuildRoots', () => { + const cwd = '/repo' + + it('excludes cwd itself', async () => { + const result = await discoverBuildRoots({ + cwd, + fullByTool: new Map([['gradle', [cwd]]]), + includedByTool: new Map([['gradle', [cwd]]]), }) + + expect(result).toEqual({ excluded: [], included: [] }) }) it('marks a dir excluded when it is present in the full walk but absent from the excludePaths-filtered walk', async () => { const legacy = '/repo/legacy' const active = '/repo/active' - vi.mocked(findBuildToolCandidates).mockImplementation( - async ({ excludePaths }) => - excludePaths?.length - ? new Map([['gradle', [active]]]) - : new Map([['gradle', [legacy, active]]]), - ) const result = await discoverBuildRoots({ cwd, excludePaths: ['legacy'], - rootSockJson: emptySockJson(), + fullByTool: new Map([['gradle', [legacy, active]]]), + includedByTool: new Map([['gradle', [active]]]), }) expect(result).toEqual({ excluded: [{ dir: legacy, ecosystems: ['gradle'] }], included: [{ dir: active, ecosystem: 'gradle' }], - totalCandidateCount: 2, }) }) @@ -182,33 +189,129 @@ describe('discoverBuildRoots', () => { // legacy/a and legacy/b. const a = '/repo/legacy/a' const b = '/repo/legacy/b' - vi.mocked(findBuildToolCandidates).mockImplementation( - async ({ excludePaths }) => - excludePaths?.length - ? new Map([ - ['maven', []], - ['gradle', []], - ]) - : new Map([ - ['maven', [a]], - ['gradle', [b]], - ]), - ) const result = await discoverBuildRoots({ cwd, excludePaths: ['legacy'], - rootSockJson: emptySockJson(), + fullByTool: new Map([ + ['maven', [a]], + ['gradle', [b]], + ]), + includedByTool: new Map([ + ['maven', []], + ['gradle', []], + ]), }) expect(result).toEqual({ excluded: [{ dir: '/repo/legacy', ecosystems: ['gradle', 'maven'] }], included: [], - totalCandidateCount: 2, }) }) }) +describe('markWorkspaceCoverage', () => { + const cwd = '/repo' + const reactor = '/repo/reactor' + + beforeEach(() => { + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readSocketJsonCascade).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { maven: { bin: 'mvn' } } }, + }) as SocketJson, + ) + vi.mocked(enumerateWorkspaces).mockReset() + }) + + it('marks the candidate itself plus its declared members as covered', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + projects: [ + { + type: 'maven', + name: 'moduleA', + subprojectDir: 'moduleA', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'moduleB', + subprojectDir: 'moduleB', + dependencies: [], + resolvedAs: [], + }, + ], + }) + const coveredByEcosystem = new Map>() + + const result = await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: undefined }) + expect(coveredByEcosystem.get('maven')).toEqual( + new Set([reactor, `${reactor}/moduleA`, `${reactor}/moduleB`]), + ) + }) + + it('does not enumerate, and marks nothing covered, for a disabled candidate', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson) + const coveredByEcosystem = new Map>() + + const result = await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: undefined }) + expect(enumerateWorkspaces).not.toHaveBeenCalled() + expect(coveredByEcosystem.size).toBe(0) + }) + + it('fails closed - marks nothing covered and reports failure - when enumeration fails', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue(undefined) + const coveredByEcosystem = new Map>() + + const result = await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result.ok).toBe(false) + expect(coveredByEcosystem.size).toBe(0) + }) + + it('forwards --exclude-paths, re-anchored to the candidate dir, so a member excluded specifically to dodge a broken resolution is actually skipped', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ projects: [] }) + const coveredByEcosystem = new Map>() + + await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + excludePaths: ['reactor/moduleB'], + rootSockJson: emptySockJson(), + }) + + expect(enumerateWorkspaces).toHaveBeenCalledWith( + expect.objectContaining({ excludePaths: ['moduleB'] }), + ) + }) +}) + describe('disableExclusionRoot', () => { const cwd = '/repo' const dir = '/repo/legacy' @@ -414,11 +517,82 @@ describe('processCandidate', () => { expect(result).toEqual({ ok: true, - data: { canceled: false, outcome: 'skipped' }, + data: { canceled: false, outcome: 'skipped', reenabled: false }, }) expect(select).not.toHaveBeenCalled() }) + it('offers to re-enable when the candidate own file (not just an ancestor) sets disabled:true, then still asks to configure it', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { + manifest: { gradle: { disabled: true, bin: './gradlew' } }, + }, + }) as SocketJson, + ) + vi.mocked(select).mockImplementation(async ({ message }) => { + if (message.includes('re-enable')) { + return true + } + // The configure-or-leave-as-is question, asked right after re-enabling. + return 'inherit' + }) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'inherited', reenabled: true }, + }) + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { + manifest: { gradle: { disabled: false, bin: './gradlew' } }, + }, + }), + ) + }) + + it('declining the re-enable offer leaves the candidate disabled and writes nothing', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + }) as SocketJson, + ) + vi.mocked(select).mockResolvedValue(false) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + it('does not offer a disable choice - that stays --exclude-paths-only', async () => { vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) vi.mocked(select).mockImplementation(async ({ choices }) => { @@ -438,7 +612,7 @@ describe('processCandidate', () => { expect(result).toEqual({ ok: true, - data: { canceled: false, outcome: 'inherited' }, + data: { canceled: false, outcome: 'inherited', reenabled: false }, }) expect(writeSocketJson).not.toHaveBeenCalled() }) @@ -460,7 +634,7 @@ describe('processCandidate', () => { expect(result).toEqual({ ok: true, - data: { canceled: false, outcome: 'configured' }, + data: { canceled: false, outcome: 'configured', reenabled: false }, }) expect(setupGradle).toHaveBeenCalledTimes(1) expect(writeSocketJson).toHaveBeenCalled() @@ -479,7 +653,7 @@ describe('processCandidate', () => { expect(result).toEqual({ ok: true, - data: { canceled: false, outcome: 'inherited' }, + data: { canceled: false, outcome: 'inherited', reenabled: false }, }) expect(writeSocketJson).not.toHaveBeenCalled() }) @@ -505,9 +679,6 @@ describe('setupRecursiveManifestConfig', () => { beforeEach(() => { vi.mocked(select).mockReset() - // Default: decline all three root ecosystem questions ("No" x3), then - // never reach the write-confirmation select at all (configuredAny stays - // false) - matches the common case of a root with no baseline defaults. vi.mocked(select).mockResolvedValue(false) vi.mocked(setupGradle).mockReset() vi.mocked(setupMaven).mockReset() @@ -518,37 +689,25 @@ describe('setupRecursiveManifestConfig', () => { data: emptySockJson(), })) vi.mocked(findBuildToolCandidates).mockReset() + // Default: nothing found anywhere - most tests override this per-scenario. + vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) vi.mocked(readOrDefaultSocketJson).mockReset() vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) vi.mocked(readSocketJsonCascade).mockReset() vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) vi.mocked(writeSocketJson).mockReset() vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) - vi.mocked(detectManifestActions).mockReset() - vi.mocked(detectManifestActions).mockResolvedValue({ - bazel: false, - cdxgen: false, - count: 0, - conda: false, - gradle: false, - maven: false, - sbt: false, - }) + vi.mocked(enumerateWorkspaces).mockReset() + // Default: no reactor members declared anywhere - most tests don't care + // about pruning specifically, so nothing should get collapsed. + vi.mocked(enumerateWorkspaces).mockResolvedValue({ projects: [] }) }) - it('proceeds to discovery when all three root ecosystem questions are declined', async () => { - vi.mocked(select) - // Configure Maven/Gradle/sbt? -> no, no, no. - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - // Recursively discover...? -> yes. - .mockResolvedValue(true) - vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) - + it('asks about nothing and finishes immediately when the scan finds no build roots anywhere', async () => { const result = await setupRecursiveManifestConfig(cwd, false) expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(select).not.toHaveBeenCalled() expect(setupGradle).not.toHaveBeenCalled() expect(setupMaven).not.toHaveBeenCalled() expect(setupSbt).not.toHaveBeenCalled() @@ -556,103 +715,152 @@ describe('setupRecursiveManifestConfig', () => { expect(findBuildToolCandidates).toHaveBeenCalled() }) - it('asks about a detected ecosystem first, phrased as detected, before undetected ones', async () => { - vi.mocked(detectManifestActions).mockResolvedValue({ - bazel: false, - cdxgen: false, - count: 1, - conda: false, - gradle: false, - maven: true, - sbt: false, + it('only asks about ecosystems detected somewhere in the tree, with plain phrasing', async () => { + // Maven is detected (a candidate exists, elsewhere in the tree); gradle + // and sbt have none anywhere, so neither should ever be asked about. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [`${cwd}/service`]]]), + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + return false }) + + await setupRecursiveManifestConfig(cwd, false) + + expect(setupGradle).not.toHaveBeenCalled() + expect(setupSbt).not.toHaveBeenCalled() + // Exactly one root question (Maven) - phrased as a nested-project default + // since the only maven candidate is elsewhere in the tree, not at cwd + // itself - no "detected"/"root"/"anyway" wording either way. + expect(messages[0]).toBe( + 'Configure Maven defaults for any nested projects?', + ) + expect(messages[0]).not.toMatch(/detected|root|anyway/i) + }) + + it('distinguishes a project-specific ecosystem from a purely inherited one at the root', async () => { + // cwd IS a maven project (e.g. a reactor root); gradle only exists in a + // project nested somewhere beneath it (e.g. a submodule's own gradle + // build) - the two questions must read differently, since the maven one + // configures settings for cwd itself while the gradle one only ever sets + // an inheritable default for something else. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([ + ['maven', [cwd]], + ['gradle', [`${cwd}/module-b/standalone-gradle-lib`]], + ]), + ) const messages: string[] = [] vi.mocked(select).mockImplementation(async ({ message }) => { messages.push(message) return false }) - vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) await setupRecursiveManifestConfig(cwd, false) - // Maven (detected) is asked about first, phrased as detected; Gradle and - // sbt (undetected) follow, phrased as "anyway". - expect(messages[0]).toMatch(/Maven was detected at this root/) - expect(messages[1]).toMatch(/Gradle wasn't detected here.*anyway/) - expect(messages[2]).toMatch(/sbt wasn't detected here.*anyway/) + expect(messages).toEqual([ + 'Configure Maven settings for this project?', + 'Configure Gradle defaults for any nested projects?', + 'Configure other build roots found beneath this one, individually?', + ]) }) - it('skips discovery entirely when the user declines the recursive-discovery gate', async () => { - vi.mocked(select) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - // Recursively discover...? -> no. - .mockResolvedValue(false) + it('scans with disabled flags stripped, so an already-disabled ecosystem is still detected', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { maven: { disabled: true, bin: 'mvn' } } }, + }) as SocketJson, + ) + const seenSockJsons: SocketJson[] = [] + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ sockJson }) => { + seenSockJsons.push(sockJson) + return new Map([['maven', [cwd]]]) + }, + ) - const result = await setupRecursiveManifestConfig(cwd, false) + await setupRecursiveManifestConfig(cwd, false) - expect(result).toEqual({ ok: true, data: { canceled: false } }) - expect(findBuildToolCandidates).not.toHaveBeenCalled() + expect(seenSockJsons.length).toBeGreaterThan(0) + for (const seen of seenSockJsons) { + expect(seen.defaults?.manifest?.maven?.disabled).toBe(false) + } }) - it('configures maven at the root and writes it, then still proceeds to discovery', async () => { - vi.mocked(select) - // Configure Maven? -> yes. - .mockResolvedValueOnce(true) - // Configure Gradle? -> no. - .mockResolvedValueOnce(false) - // Configure sbt? -> no. - .mockResolvedValueOnce(false) - // Write the config? -> yes. - .mockResolvedValueOnce(true) - vi.mocked(setupMaven).mockImplementation(async config => { - ;(config as Record)['bin'] = './mvnw' - return { ok: true, data: { canceled: false } } + it('offers to re-enable an ecosystem disabled at the root, before asking to configure it', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: true, + data: { + version: 1, + defaults: { manifest: { maven: { disabled: true, bin: 'mvn' } } }, + } as SocketJson, + })) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + if (message.includes('re-enable')) { + return true + } + if (message.startsWith('Configure Maven')) { + return false + } + return false }) - vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) const result = await setupRecursiveManifestConfig(cwd, false) expect(result).toEqual({ ok: true, data: { canceled: false } }) - expect(setupMaven).toHaveBeenCalledTimes(1) - expect(writeSocketJson).toHaveBeenCalledWith(cwd, expect.any(Object)) + expect(messages[0]).toBe('Maven is currently disabled here - re-enable it?') + expect(writeSocketJson).toHaveBeenCalledWith( + cwd, + expect.objectContaining({ + defaults: { manifest: { maven: { disabled: false, bin: 'mvn' } } }, + }), + ) }) - it('does not write when the user says yes to configure Maven but leaves every prompt blank', async () => { - vi.mocked(select) - // Configure Maven? -> yes. - .mockResolvedValueOnce(true) - // Configure Gradle? -> no. - .mockResolvedValueOnce(false) - // Configure sbt? -> no. - .mockResolvedValueOnce(false) - // Recursively discover...? -> no (never reaches the write-confirmation - // select at all since nothing ended up configured). - .mockResolvedValue(false) - // A no-op wizard: doesn't set a single field. - vi.mocked(setupMaven).mockResolvedValue({ + it('declining the root re-enable question leaves the ecosystem disabled and skips configuring it', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ ok: true, - data: { canceled: false }, - }) + data: { + version: 1, + defaults: { manifest: { maven: { disabled: true, bin: 'mvn' } } }, + } as SocketJson, + })) + vi.mocked(select).mockResolvedValueOnce(false) const result = await setupRecursiveManifestConfig(cwd, false) expect(result).toEqual({ ok: true, data: { canceled: false } }) - expect(setupMaven).toHaveBeenCalledTimes(1) + expect(setupMaven).not.toHaveBeenCalled() expect(writeSocketJson).not.toHaveBeenCalled() }) - it('stops when canceling one of the root ecosystem questions', async () => { + it('stops when canceling the only detected root ecosystem question', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) vi.mocked(select).mockResolvedValueOnce(null) const result = await setupRecursiveManifestConfig(cwd, false) expect(result.ok && result.data.canceled).toBe(true) - expect(findBuildToolCandidates).not.toHaveBeenCalled() }) it('stops when a root ecosystem sub-wizard is canceled', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) vi.mocked(select).mockResolvedValueOnce(true) vi.mocked(setupMaven).mockResolvedValue({ ok: true, @@ -662,10 +870,12 @@ describe('setupRecursiveManifestConfig', () => { const result = await setupRecursiveManifestConfig(cwd, false) expect(result.ok && result.data.canceled).toBe(true) - expect(findBuildToolCandidates).not.toHaveBeenCalled() }) it('propagates a hard failure reading the root socket.json', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) vi.mocked(readSocketJsonSync).mockImplementation(() => ({ ok: false, message: 'boom', @@ -674,50 +884,82 @@ describe('setupRecursiveManifestConfig', () => { const result = await setupRecursiveManifestConfig(cwd, false) expect(result.ok).toBe(false) - expect(findBuildToolCandidates).not.toHaveBeenCalled() + expect(findBuildToolCandidates).toHaveBeenCalled() }) - it('reports the discovered count and walks included candidates even when no --exclude-paths is given, instead of doing nothing', async () => { + it('configures maven at the root (itself the only candidate) and writes it, with no individual-configure gate', async () => { + // The only maven candidate anywhere IS cwd itself, so there's nothing + // left to configure individually afterward. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) vi.mocked(select) - // Configure Maven/Gradle/sbt? -> no, no, no. - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - // Recursively discover...? -> yes. + // Configure Maven? -> yes. .mockResolvedValueOnce(true) - // Candidate action prompt -> anything but 'disable'/'configure' is a - // safe no-op (inherit), so this never touches writeSocketJson. - .mockResolvedValue('inherit') + vi.mocked(setupMaven).mockImplementation(async config => { + ;(config as Record)['bin'] = './mvnw' + return { ok: true, data: { canceled: false } } + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith(cwd, expect.any(Object)) + // No individual-configure gate: nothing else was found to ask about. + expect(select).toHaveBeenCalledTimes(1) + }) + + it('does not write when the user says yes to configure Maven but leaves every prompt blank', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['gradle', [`${cwd}/active`]]]), + new Map([['maven', [cwd]]]), ) - vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) - const logSpy = vi.spyOn(logger, 'log') + vi.mocked(select).mockResolvedValueOnce(true) + // A no-op wizard: doesn't set a single field. + vi.mocked(setupMaven).mockResolvedValue({ + ok: true, + data: { canceled: false }, + }) - try { - const result = await setupRecursiveManifestConfig(cwd, false) + const result = await setupRecursiveManifestConfig(cwd, false) - expect(result).toEqual({ ok: true, data: { canceled: false } }) - expect(writeSocketJson).not.toHaveBeenCalled() - const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(logged).toMatch(/Found 1 build root\(s\) beneath/) - expect(logged).toMatch(/0 configured, 1 left inheriting/) - } finally { - logSpy.mockRestore() - } + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).toHaveBeenCalledTimes(1) + expect(writeSocketJson).not.toHaveBeenCalled() }) - it('reports nothing excluded without writing anything', async () => { - vi.mocked(select) - // Configure Maven/Gradle/sbt? -> no, no, no. - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - // Recursively discover...? -> yes. - .mockResolvedValue(true) + it('applies --exclude-paths unconditionally, without asking to configure anything individually', async () => { + const legacy = `${cwd}/legacy` + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map([['gradle', []]]) + : new Map([['gradle', [legacy]]]), + ) + + const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).toHaveBeenCalledWith( + legacy, + expect.objectContaining({ + defaults: { manifest: { gradle: { disabled: true } } }, + }), + ) + // Nothing left to configure individually (the only candidate was + // excluded), so the individual-configure gate is never asked. + expect(select).not.toHaveBeenCalled() + }) + + it('reports nothing excluded without writing anything, then asks about the remaining candidate individually', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( new Map([['gradle', [`${cwd}/active`]]]), ) + vi.mocked(select) + // Configure Gradle defaults? -> no (gradle is detected via `active`). + .mockResolvedValueOnce(false) + // Configure other build roots individually? -> no. + .mockResolvedValueOnce(false) const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) @@ -727,13 +969,6 @@ describe('setupRecursiveManifestConfig', () => { }) it('only writes disabled:true to the topmost of an excluded subtree', async () => { - vi.mocked(select) - // Configure Maven/Gradle/sbt? -> no, no, no. - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - // Recursively discover...? -> yes. - .mockResolvedValue(true) const legacy = `${cwd}/legacy` const nested = `${legacy}/nested` const fake = makeFakeGradleDisk(cwd) @@ -761,6 +996,46 @@ describe('setupRecursiveManifestConfig', () => { defaults: { manifest: { gradle: { disabled: true } } }, }), ) + // Everything was excluded, so the individual-configure gate is skipped. + expect(select).not.toHaveBeenCalled() + }) + + it('reports the discovered count and walks the remaining candidate even when no --exclude-paths is given', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['gradle', [`${cwd}/active`]]]), + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + // Configure Gradle defaults? -> no (gradle is detected via `active`, + // a nested project, not cwd itself). + if (message.startsWith('Configure Gradle defaults')) { + return false + } + // Configure other build roots individually? -> yes. + if (message.startsWith('Configure other build roots')) { + return true + } + // Candidate action prompt -> anything but 'configure' is a safe no-op + // (inherit), so this never touches writeSocketJson. + return 'inherit' + }) + const logSpy = vi.spyOn(logger, 'log') + + try { + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).not.toHaveBeenCalled() + expect(messages).toContain( + 'Configure other build roots found beneath this one, individually?', + ) + const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(logged).toMatch(/Detected: Gradle/) + expect(logged).toMatch(/0 configured \(0 re-enabled\), 1 left as-is/) + } finally { + logSpy.mockRestore() + } }) it('configures and leaves candidates inheriting in a single pass, skipping candidates already disabled via cascade', async () => { @@ -770,11 +1045,11 @@ describe('setupRecursiveManifestConfig', () => { const serviceC = `${cwd}/serviceC` vi.mocked(select) - // Configure Maven/Gradle/sbt? -> no, no, no. - .mockResolvedValueOnce(false) + // Configure Maven? -> no. .mockResolvedValueOnce(false) + // Configure Gradle? -> no. .mockResolvedValueOnce(false) - // Recursively discover...? -> yes. + // Configure other build roots individually? -> yes. .mockResolvedValueOnce(true) // serviceA (depth 1) -> configure. .mockResolvedValueOnce('configure') @@ -804,7 +1079,7 @@ describe('setupRecursiveManifestConfig', () => { const result = await setupRecursiveManifestConfig(cwd, false) expect(result).toEqual({ ok: true, data: { canceled: false } }) - expect(select).toHaveBeenCalledTimes(6) + expect(select).toHaveBeenCalledTimes(5) expect(writeSocketJson).toHaveBeenCalledTimes(1) expect(writeSocketJson).toHaveBeenCalledWith( serviceA, @@ -813,4 +1088,97 @@ describe('setupRecursiveManifestConfig', () => { }), ) }) + + it('seeds coverage from cwd itself, so a reactor rooted exactly at cwd still gets its declared members pruned', async () => { + // cwd IS the maven reactor root (has its own pom.xml) - discoverBuildRoots + // always excludes cwd from `included` (it already got its own root + // wizard), so without seeding coverage from cwd specifically, module-a + // and module-b would never be recognized as reactor members. + const independentService = `${cwd}/independent-service` + const moduleA = `${cwd}/module-a` + const moduleB = `${cwd}/module-b` + + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd, independentService, moduleA, moduleB]]]), + ) + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => + dir === cwd + ? { + projects: [ + { + type: 'maven', + name: 'module-a', + subprojectDir: 'module-a', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'module-b', + subprojectDir: 'module-b', + dependencies: [], + resolvedAs: [], + }, + ], + } + : { projects: [] }, + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + if (message.startsWith('Configure Maven settings')) { + return false + } + if (message.startsWith('Configure other build roots')) { + return true + } + return 'inherit' + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + // Only independent-service is ever asked about - module-a/module-b are + // recognized as covered via cwd's own enumeration and never prompted. + // cwd itself is a maven project (the reactor root), so the root question + // is phrased as this project's own settings, not a nested-project default. + expect(messages).toEqual([ + 'Configure Maven settings for this project?', + 'Configure other build roots found beneath this one, individually?', + 'independent-service (maven)', + ]) + }) + + it("aborts the whole walk (fail-closed) when a candidate's workspace layout cannot be determined", async () => { + const independentService = `${cwd}/independent-service` + const laterCandidate = `${cwd}/zzz-later` + + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [independentService, laterCandidate]]]), + ) + vi.mocked(select).mockImplementation(async ({ message }) => { + if (message.startsWith('Configure Maven defaults')) { + return false + } + if (message.startsWith('Configure other build roots')) { + return true + } + return 'inherit' + }) + // Simulates a missing $JAVA8_HOME env var reference: enumeration fails + // for independent-service specifically. + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => + dir === independentService ? undefined : { projects: [] }, + ) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok).toBe(false) + // The later candidate is never reached - the walk aborted right after + // independent-service's own enumeration failed, not after processing + // everything and reporting failures at the end. + expect(enumerateWorkspaces).not.toHaveBeenCalledWith( + expect.objectContaining({ cwd: laterCandidate }), + ) + }) }) From 36f354f08cd52930c0700b04030f0b963accfae2 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 14:52:59 +0200 Subject: [PATCH 14/27] Trim verbose comments across the dynamic-sbom-inference feature Cuts several multi-line comment blocks down to their non-obvious why, matching the repo's comment-style guidelines. --- .../manifest/discover-manifest-roots.mts | 10 +++------- src/commands/manifest/enumerate-workspaces.mts | 8 +++----- src/commands/manifest/manifest-flags.mts | 7 +++---- .../ext/CoanaWorkspacesLifecycleParticipant.java | 8 +++----- .../socket/SocketWorkspacesRecordsEngine.java | 8 +++----- src/commands/manifest/scripts/run.mts | 7 ++----- .../scripts/socket-workspaces.init.gradle | 9 +++------ .../scripts/socket-workspaces.plugin.scala | 9 +++------ src/utils/socket-json.mts | 16 ++++++---------- 9 files changed, 29 insertions(+), 53 deletions(-) diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index d79991fc02..131fb3097f 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -53,13 +53,9 @@ function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] { }) } -// Recursively discovers gradle/sbt/maven build-tool roots under `cwd`, one -// filesystem pass for all three ecosystems' marker files (fast-glob unions the -// patterns internally). Each ecosystem's candidate list is depth-sorted -// (root-most first) so a reactor/multi-project root is always visited before -// its own members; the caller uses that ordering plus the resulting facts -// SBOM's `projects[].subprojectDir` to avoid re-invoking the manifest script on -// directories a parent build root already covers. +// Depth-sorted (root-most first) so the caller, using each build root's +// `subprojectDir` facts, visits a reactor root before its own members and can +// skip subprojects a parent root already covers. export async function findBuildToolCandidates({ cwd, excludePaths, diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index 270d72d774..3d6a533654 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -10,11 +10,9 @@ export type EnumerateWorkspacesResult = { projects: SocketFactsSbomProject[] } -// Cheaply discovers a build root's subprojects (no dependency resolution): used -// for `socket manifest setup --recursive` discovery. Distinct from -// dynamic-sbom-inference's own coverage tracking, which gets the same -// subproject list for free as a side effect of the full facts run it already -// has to do. +// Cheap subproject discovery (no dependency resolution) for +// `socket manifest setup --recursive`; dynamic-sbom-inference instead gets +// this list for free as a side effect of its own full facts run. export async function enumerateWorkspaces({ bin, buildOpts, diff --git a/src/commands/manifest/manifest-flags.mts b/src/commands/manifest/manifest-flags.mts index dfb656ee54..5eab076535 100644 --- a/src/commands/manifest/manifest-flags.mts +++ b/src/commands/manifest/manifest-flags.mts @@ -1,9 +1,8 @@ import type { MeowFlags } from '../../flags.mts' -// A manifest-scoped variant of `../scan/reachability-flags.mts`'s -// `excludePathsFlag`: these commands only ever generate a manifest/facts -// file, so the description must not reference "the scan" or reachability -// analysis, which don't apply when a manifest command is run standalone. +// Manifest-scoped variant of `../scan/reachability-flags.mts`'s +// `excludePathsFlag`: description avoids "scan"/reachability wording since +// these commands only ever generate a manifest/facts file. export const excludePathsFlag: MeowFlags = { excludePaths: { type: 'string', diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java index 994535c49b..2e73b72d00 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java @@ -14,11 +14,9 @@ import java.util.Properties; /** - * Lightweight sibling of {@link CoanaFactsLifecycleParticipant}: loaded from the same extension - * jar, gated by {@code -Dcoana.task=socket-workspaces}. Hooks {@code afterProjectsRead} instead of - * {@code afterSessionEnd} - it fires as soon as Maven determines the reactor project list, before - * any lifecycle phase runs - and needs no {@code RepositorySystem}/{@code DependencyGraphBuilder} - * since it never builds a dependency graph. + * Sibling of {@link CoanaFactsLifecycleParticipant}, gated by {@code -Dcoana.task=socket-workspaces}. + * Hooks {@code afterProjectsRead} (fires once the reactor project list is known, before any + * lifecycle phase runs) since it only needs that list, never a dependency graph. */ @Named("coana-workspaces") @Singleton diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java index 0e70498dbb..a2fd395583 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java @@ -12,11 +12,9 @@ import java.util.List; /** - * Lightweight sibling of {@link SocketFactsRecordsEngine}: emits only `meta`/`project` records - * from {@code session.getProjects()} (Maven's own reactor list, already populated before any - * lifecycle phase runs) - no dependency graph is ever built, so no {@code RepositorySystem} or - * {@code DependencyGraphBuilder} is needed. Used for cheap workspace discovery (e.g. - * `socket manifest setup --recursive`) without paying for a full facts-generation build. + * Sibling of {@link SocketFactsRecordsEngine} that emits only `meta`/`project` records from the + * already-populated reactor list, building no dependency graph - cheap workspace discovery for + * `socket manifest setup --recursive` without a full facts-generation build. */ public final class SocketWorkspacesRecordsEngine { diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index d6ee79bb86..898f6f7ff1 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -176,11 +176,8 @@ export async function runManifestScript( } } -// Cheap subproject discovery: emits only `project` records, never resolving -// dependencies. Distinct from a full `runManifestScript` run, which gets the -// same subproject list for free as a side effect of the resolution it already -// has to do; this path exists for discovery BEFORE committing to that cost -// (e.g. `socket manifest setup --recursive`). +// Emits only `project` records, never resolving dependencies, for discovery +// before committing to a full `runManifestScript` resolution cost. export async function enumerateWorkspaces( tool: BuildTool, opts: ManifestScriptOptions, diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle index a41b6ef1f3..0691ca12d1 100644 --- a/src/commands/manifest/scripts/socket-workspaces.init.gradle +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -1,12 +1,9 @@ // Invoke via: // ./gradlew --init-script socket-workspaces.init.gradle socketWorkspaces -// Lightweight sibling of socket-facts.init.gradle: emits only `meta`/`project` records (a build's -// subproject list) with NO dependency resolution at all - no socketFactsCollect-equivalent task, no -// configurations ever touched. Kept as a wholly separate script (not an added task in the facts init -// script) so it can never affect that file's already-verified wide Gradle-version compatibility -// (1.0+). Used for cheap workspace discovery, e.g. `socket manifest setup --dynamic-sbom-inference`, -// without paying for a full facts-generation build. +// Sibling of socket-facts.init.gradle: emits only `meta`/`project` records, no dependency +// resolution. Kept as a separate script so it can't affect that file's already-verified, +// wide Gradle-version compatibility (1.0+). // `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } diff --git a/src/commands/manifest/scripts/socket-workspaces.plugin.scala b/src/commands/manifest/scripts/socket-workspaces.plugin.scala index 8cc9c36feb..6e5b7f998f 100644 --- a/src/commands/manifest/scripts/socket-workspaces.plugin.scala +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -6,12 +6,9 @@ import sbt.Keys._ import scala.collection.mutable /** - * Lightweight sibling of SocketFactsPlugin (socket-facts.plugin.scala): emits only - * `meta`/`project` records (a build's subproject list) with NO dependency resolution at all - no - * `update`/`updateFull` is ever run. Kept as a wholly separate plugin (not a flag on the facts - * task) so it can never affect that file's already-verified wide sbt-version compatibility - * (0.13.x+). Used for cheap workspace discovery, e.g. - * `socket manifest setup --dynamic-sbom-inference`, without paying for a full facts-generation build. + * Sibling of SocketFactsPlugin (socket-facts.plugin.scala): emits only `meta`/`project` + * records, no dependency resolution. Kept as a separate plugin so it can't affect that + * file's already-verified wide sbt-version compatibility (0.13.x+). * * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x, same constraint as the facts plugin. */ diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index f6196eab79..a20ace474b 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -157,10 +157,9 @@ export async function readOrDefaultSocketJsonUp( return getDefaultSocketJson() } -// Shallow-merges `defaults.manifest.` per ecosystem: fields present -// in `override` win, fields it doesn't set fall through to `base`. Everything -// outside `defaults.manifest` (scan-level defaults, etc.) comes from `base` -// only - only the manifest/build-tool section cascades. +// Shallow-merges `defaults.manifest.` per ecosystem: fields set in +// `override` win, others fall through to `base`. Only this section cascades; +// everything else in `defaults` comes from `base` only. function mergeManifestDefaults( base: SocketJson, override: SocketJson, @@ -197,12 +196,9 @@ function mergeManifestDefaults( } } -// Cascades socket.json's `defaults.manifest.*` section from `rootSockJson` -// down to `dir`: every ancestor between `dir` and `boundaryDir` (inclusive of -// `dir`, exclusive of `boundaryDir` - that's already `rootSockJson`) that has -// its own socket.json is merged in, nearest-to-`dir` taking precedence field -// by field. A build root with no socket.json of its own simply inherits -// `rootSockJson` unchanged. +// Cascades `defaults.manifest.*` from `rootSockJson` down to `dir`: each +// ancestor's own socket.json, between `dir` and `boundaryDir` (exclusive), +// merges in, nearest-to-`dir` winning field by field. export function readSocketJsonCascade( dir: string, boundaryDir: string, From 58fa321b59b38bbcac4ef7242c2f6e3cbada2bcf Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 15:17:44 +0200 Subject: [PATCH 15/27] Address code review findings on the dynamic-sbom-inference PR - A root-disabled ecosystem was dropped from the build-tool scan entirely, so a nested socket.json could never re-enable it. Moved the strip-before- scan helper (previously wizard-only) to discover-manifest-roots.mts and applied it in generateRecursiveManifests too, letting the existing per-directory cascade check be the sole arbiter of skip vs. include. - runManifestFacts's progress line used logger.log (stdout), polluting --json output with non-JSON lines ahead of the payload. Switched to logger.info (stderr), matching every other status line in that function. - `socket manifest setup --dynamic-sbom-inference` forwarded --exclude-paths without validation, unlike every sibling manifest command. Added the same assertValidExcludePaths call. - Recursive generation inferred a build-root failure from whether process.exitCode changed during a call, which misclassifies a real failure as empty if the exit code was already non-zero beforehand. Gave runManifestFacts an explicit null (failure) vs. undefined (empty) return so callers don't have to infer it from global state. - Verbose error logging in enumerate-workspaces/run-manifest-facts string-coerced the caught error directly; switched to the existing getErrorMessageOr helper. --- src/commands/manifest/cmd-manifest-setup.mts | 2 + .../manifest/discover-manifest-roots.mts | 23 ++++++ .../manifest/enumerate-workspaces.mts | 5 +- .../manifest/generate-recursive-manifests.mts | 33 +++++---- .../generate-recursive-manifests.test.mts | 72 ++++++++++++++++++- src/commands/manifest/run-manifest-facts.mts | 23 ++++-- .../manifest/run-manifest-facts.test.mts | 2 +- .../setup-recursive-manifest-config.mts | 23 +----- .../setup-recursive-manifest-config.test.mts | 16 +++-- 9 files changed, 144 insertions(+), 55 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 64536bdec8..4d0b3b9b68 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -8,6 +8,7 @@ import { commonFlags } from '../../flags.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' import type { CliCommandConfig, @@ -105,6 +106,7 @@ async function run( } const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) await handleManifestSetup( cwd, diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index 131fb3097f..cee91b543d 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -32,6 +32,29 @@ for (const tool of BUILD_TOOLS) { } } +// A disabled ecosystem is skipped entirely by the glob scan below, so a +// nested socket.json re-enabling it would never even be searched for. Callers +// that need the cascade (not just the root file) to decide skip vs. include +// must strip `disabled` here first, and let their own per-directory cascade +// check apply it instead. +export function withoutDisabledFlags(sockJson: SocketJson): SocketJson { + const manifest = sockJson.defaults?.manifest + if (!manifest) { + return sockJson + } + const stripped: Record = { ...manifest } + for (const tool of BUILD_TOOLS) { + const section = manifest[tool] + if (section?.disabled) { + stripped[tool] = { ...section, disabled: false } + } + } + return { + ...sockJson, + defaults: { ...sockJson.defaults, manifest: stripped }, + } as SocketJson +} + export async function realpathOrResolved(dir: string): Promise { try { return await fs.realpath(dir) diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index 3d6a533654..a8a93ae226 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -2,6 +2,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { expandEnvVarRefs } from './expand-env-var-refs.mts' import { enumerateWorkspaces as enumerateWorkspacesScript } from './scripts/run.mts' +import { getErrorMessageOr } from '../../utils/errors.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' @@ -62,7 +63,9 @@ export async function enumerateWorkspaces({ process.exitCode = 1 logger.fail( `Could not run the ${ecosystem} build tool` + - (verbose ? `: ${e}` : ' (run with --verbose for details).'), + (verbose + ? `: ${getErrorMessageOr(e, String(e))}` + : ' (run with --verbose for details).'), ) return } diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index f1af732d5a..2242456bee 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -2,7 +2,10 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' -import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { + findBuildToolCandidates, + withoutDisabledFlags, +} from './discover-manifest-roots.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' @@ -142,10 +145,13 @@ export async function generateRecursiveManifests({ verbose: boolean }): Promise { const rootSockJson = readOrDefaultSocketJson(cwd) + // A root-disabled ecosystem must still be scanned for - a nested socket.json + // may re-enable it - so the per-directory cascade below, not this scan, is + // what actually decides skip vs. include. const candidatesByTool = await findBuildToolCandidates({ cwd, excludePaths, - sockJson: rootSockJson, + sockJson: withoutDisabledFlags(rootSockJson), }) const outcomes: RecursiveManifestOutcome[] = [] @@ -191,7 +197,6 @@ export async function generateRecursiveManifests({ { cwd, target: dir }, ) - const beforeExitCode = process.exitCode // eslint-disable-next-line no-await-in-loop const result = await runManifestFacts({ bin, @@ -206,21 +211,15 @@ export async function generateRecursiveManifests({ verbose, }) - if (!result) { - const failed = Boolean( - process.exitCode && process.exitCode !== beforeExitCode, + if (result === null) { + outcomes.push({ dir, ecosystem, status: 'failed' }) + logger.warn( + `Aborting recursive discovery: ${dir}'s (${ecosystem}) workspace layout could not be determined, so remaining build roots cannot be safely classified as covered or independent.`, ) - outcomes.push({ - dir, - ecosystem, - status: failed ? 'failed' : 'empty', - }) - if (failed) { - logger.warn( - `Aborting recursive discovery: ${dir}'s (${ecosystem}) workspace layout could not be determined, so remaining build roots cannot be safely classified as covered or independent.`, - ) - break ecosystems - } + break ecosystems + } + if (!result) { + outcomes.push({ dir, ecosystem, status: 'empty' }) continue } diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index dab251d635..251107e6c0 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -17,7 +17,12 @@ vi.mock('./run-manifest-facts.mts', () => ({ import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { testPath } from '../../../test/utils.mts' -import { readSocketJsonCascade } from '../../utils/socket-json.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, +} from '../../utils/socket-json.mts' + +import type { SocketJson } from '../../utils/socket-json.mts' const monorepo = path.join( testPath, @@ -33,6 +38,7 @@ function relOf(dir: string): string { describe('generateRecursiveManifests', () => { beforeEach(() => { vi.mocked(runManifestFacts).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReturnValue({} as SocketJson) vi.mocked(readSocketJsonCascade).mockImplementation( (_dir, _boundaryDir, fallback) => fallback, ) @@ -122,7 +128,7 @@ describe('generateRecursiveManifests', () => { async ({ cwd, ecosystem }) => { if (ecosystem === 'maven' && cwd === dualMarkerDir) { process.exitCode = 1 - return undefined + return null } return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } }, @@ -163,6 +169,34 @@ describe('generateRecursiveManifests', () => { } }) + it('still classifies a failure as failed (not empty) when process.exitCode was already non-zero beforehand', async () => { + process.exitCode = 1 + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (ecosystem === 'maven' && cwd === dualMarkerDir) { + process.exitCode = 1 + return null + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('failed') + } finally { + warnSpy.mockRestore() + } + }) + it('reports a non-fatal empty result distinctly from a failure', async () => { vi.mocked(runManifestFacts).mockImplementation( async ({ cwd, ecosystem }) => { @@ -389,6 +423,40 @@ describe('generateRecursiveManifests', () => { } }) + it('still discovers a build root when the root socket.json disables its whole ecosystem, so a nested override can re-enable it', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson) + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === reactor + ? { defaults: { manifest: { maven: { disabled: false } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + // Root-disabled maven is still scanned for at all (not dropped + // entirely), so the nested override is actually found and generated. + expect(byKey.get('maven:reactor')).toBe('generated') + expect(byKey.get('maven:dual-marker-dir')).toBe('skippedDisabled') + } finally { + warnSpy.mockRestore() + } + }) + it('skips (with a warning) a resolved config that sets a cascaded disabled: true', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index da1de532ee..698d8dd4a3 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -8,6 +8,7 @@ import { renderResolutionErrorReport } from './scripts/resolution-report-render. import { runManifestScript } from './scripts/run.mts' import { accumulateSidecar } from './scripts/sidecar.mts' import constants from '../../constants.mts' +import { getErrorMessageOr } from '../../utils/errors.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' @@ -31,6 +32,12 @@ function tailBuildOutput(stdout: string, stderr: string): string { return combined.split('\n').slice(-MAX_FAILURE_OUTPUT_LINES).join('\n') } +// `null` = a real failure (crash, missing config, blocking unresolved +// dependency); `undefined` = genuinely nothing to resolve, not a failure. +// Distinguishing the two here means callers never have to infer it from +// `process.exitCode`, which can already be non-zero for an unrelated reason. +export type RunManifestFactsOutcome = RunManifestFactsResult | null | undefined + // Runs the bundled build-tool resolution script for a JVM project and writes // `.socket.facts.json`. `withFiles` (reachability only) additionally folds // resolved artifact paths into `sidecarAcc`. A blocking resolution failure sets @@ -66,7 +73,7 @@ export async function runManifestFacts({ tmpDir?: string | undefined verbose: boolean withFiles?: boolean | undefined -}): Promise { +}): Promise { const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) let resolvedJavaHome: string | undefined @@ -77,12 +84,12 @@ export async function runManifestFacts({ logger.fail( `javaHome (\`${javaHome}\`) references \`${expanded.missing}\`, which is not set in this environment.`, ) - return + return null } resolvedJavaHome = expanded.value } - logger.log( + logger.info( `Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`, ) @@ -134,9 +141,11 @@ export async function runManifestFacts({ process.exitCode = 1 logger.fail( `Could not run the ${ecosystem} build tool` + - (verbose ? `: ${e}` : ' (run with --verbose for details).'), + (verbose + ? `: ${getErrorMessageOr(e, String(e))}` + : ' (run with --verbose for details).'), ) - return + return null } const { artifactPaths, code, facts, report, stderr, stdout } = result @@ -156,7 +165,7 @@ export async function runManifestFacts({ if (verbose && rendered.details) { logger.log(rendered.details) } - return + return null } } if (rendered.nonBlockingNotice) { @@ -195,7 +204,7 @@ export async function runManifestFacts({ logger.fail( `The ${ecosystem} build failed (exit code ${code}) before producing any Socket facts.`, ) - return + return null } // Nothing resolved at all — no dependencies and no first-party modules. A diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 52e0ddf440..3320707fe7 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -85,7 +85,7 @@ describe('runManifestFacts - javaHome', () => { cwd, javaHome: `$${ENV_VAR}`, }) - expect(result).toBeUndefined() + expect(result).toBeNull() expect(runManifestScript).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) }) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 06235c9d24..286ad8af6a 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -9,6 +9,7 @@ import { select } from '@socketsecurity/registry/lib/prompts' import { findBuildToolCandidates, realpathOrResolved, + withoutDisabledFlags, } from './discover-manifest-roots.mts' import { enumerateWorkspaces } from './enumerate-workspaces.mts' import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' @@ -42,28 +43,6 @@ const ECOSYSTEM_LABELS: Record = { sbt: 'sbt', } as unknown as Record -// findBuildToolCandidates skips scanning for a root-disabled tool entirely -// (correct for generation) but that would hide it from this wizard with no -// way to re-enable it. Strip disabled before the scan only; every prompt and -// write still reads the real sockJson. -function withoutDisabledFlags(sockJson: SocketJson): SocketJson { - const manifest = sockJson.defaults?.manifest - if (!manifest) { - return sockJson - } - const stripped: Record = { ...manifest } - for (const ecosystem of ROOT_ECOSYSTEMS) { - const section = manifest[ecosystem] - if (section?.disabled) { - stripped[ecosystem] = { ...section, disabled: false } - } - } - return { - ...sockJson, - defaults: { ...sockJson.defaults, manifest: stripped }, - } as SocketJson -} - // The shallowest directory matching `--exclude-paths`, not necessarily a // project dir itself - one write here covers every project beneath it. type ExclusionRoot = { dir: string; ecosystems: BuildTool[] } diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 0e9bdc0ffe..9db8097745 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('./discover-manifest-roots.mts', () => ({ - findBuildToolCandidates: vi.fn(), - // Identity: test dirs are already-absolute plain strings, no symlinks involved. - realpathOrResolved: vi.fn(async (dir: string) => dir), -})) +vi.mock('./discover-manifest-roots.mts', async importOriginal => { + const actual = + await importOriginal() + return { + findBuildToolCandidates: vi.fn(), + // Identity: test dirs are already-absolute plain strings, no symlinks involved. + realpathOrResolved: vi.fn(async (dir: string) => dir), + // Real (pure) implementation - no need to mock it. + withoutDisabledFlags: actual.withoutDisabledFlags, + } +}) vi.mock('./enumerate-workspaces.mts', () => ({ enumerateWorkspaces: vi.fn(), })) From 23660e73ad29a8e676d2197e987528732fb35b81 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 15:23:07 +0200 Subject: [PATCH 16/27] Refresh CLI banner snapshots after merging v1.x's version bump The merge brought package.json's version from 1.1.150-prerelease to a clean release version, so these inline snapshots no longer matched. --- src/commands/manifest/cmd-manifest-auto.test.mts | 4 ++-- .../manifest/cmd-manifest-dynamic-sbom-inference.test.mts | 4 ++-- src/commands/manifest/cmd-manifest-gradle.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-kotlin.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-maven.test.mts | 4 ++-- src/commands/manifest/cmd-manifest-scala.test.mts | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index 3403ee69f1..7025d5face 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -42,7 +42,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) @@ -63,7 +63,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 28baf2e573..4207a94035 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -42,7 +42,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) @@ -65,7 +65,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index 1eb8c8bb62..fd2a4c23b0 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -65,7 +65,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 47c4d7d295..81906dd3e4 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -65,7 +65,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 54c48c19b5..6388bb311d 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -53,7 +53,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) @@ -74,7 +74,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 2acf7bd31a..96941b8884 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -79,7 +79,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -100,7 +100,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -118,7 +118,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) From 4359368b6dbcde462278edeba15a9b28ee3bbc05 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 15:33:59 +0200 Subject: [PATCH 17/27] Use 1.1.153-prerelease pending the stuck 1.1.152 release 1.1.152 never actually published (still 404s on the registry, staged dist-tag is still 1.1.151), so bump to the next-version-hint convention used elsewhere in this repo's release history and refresh the CLI banner snapshots to match. --- package.json | 2 +- src/commands/manifest/cmd-manifest-auto.test.mts | 4 ++-- .../manifest/cmd-manifest-dynamic-sbom-inference.test.mts | 4 ++-- src/commands/manifest/cmd-manifest-gradle.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-kotlin.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-maven.test.mts | 4 ++-- src/commands/manifest/cmd-manifest-scala.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-setup.test.mts | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 6fc40e2961..61e3c4fa4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.152", + "version": "1.1.153-prerelease", "description": "CLI for Socket.dev", "contentPolicy": { "class": "dual-use" diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index 7025d5face..3403ee69f1 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -42,7 +42,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) @@ -63,7 +63,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 4207a94035..28baf2e573 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -42,7 +42,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) @@ -65,7 +65,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index fd2a4c23b0..1eb8c8bb62 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -65,7 +65,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 81906dd3e4..47c4d7d295 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -65,7 +65,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 6388bb311d..54c48c19b5 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -53,7 +53,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) @@ -74,7 +74,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 96941b8884..2acf7bd31a 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -79,7 +79,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -100,7 +100,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -118,7 +118,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-setup.test.mts b/src/commands/manifest/cmd-manifest-setup.test.mts index bd2fb489d9..e306606d08 100644 --- a/src/commands/manifest/cmd-manifest-setup.test.mts +++ b/src/commands/manifest/cmd-manifest-setup.test.mts @@ -52,7 +52,7 @@ describe('socket manifest setup', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: " `) @@ -73,7 +73,7 @@ describe('socket manifest setup', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: + | __|___ ___| |_ ___| |_ | CLI: -prerelease |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: " `) From 4a4169550a1da2f8cfd2cd1c790a9b78ae71c8ca Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 15:48:48 +0200 Subject: [PATCH 18/27] Simplify dynamic-sbom-inference's summary output Drop reactor-member "skippedCovered" lines from the per-line table - they're implied by their parent's line already showing up, and the aggregate count in the summary still reports them. Also drop the "across N build root(s)" total from the summary line: it counted every candidate directory visited, including reactor members that aren't independent build roots, which overstated how many actually exist. --- .../manifest/output-manifest-dynamic-sbom-inference.mts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts index c7aca3a9f3..883f3a99fd 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -7,7 +7,11 @@ import type { RecursiveManifestOutcome } from './generate-recursive-manifests.mt import type { CResult, OutputKind } from '../../types.mts' function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { + // A reactor member covered by its parent's own facts run is implied by that + // parent's line already showing up above it; listing it again here is just + // noise, and the aggregate count still shows up in summarize(). return outcomes + .filter(o => o.status !== 'skippedCovered') .map( o => `- ${o.dir} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`, @@ -25,9 +29,8 @@ function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { o => o.status === 'skippedDisabled', ).length const empty = outcomes.filter(o => o.status === 'empty').length - const roots = new Set(outcomes.map(o => o.dir)).size return ( - `Generated ${generated} Socket facts file(s) across ${roots} build root(s); ` + + `Generated ${generated} Socket facts file(s); ` + `${failed} failed, ${skippedCovered} skipped (already covered), ` + `${skippedDisabled} skipped (disabled/pom), ${empty} empty.` ) From 53502d080777f0cf75b497730ea558a5a95d04ff Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 15:53:29 +0200 Subject: [PATCH 19/27] Reduce the summary line to just the generated count Drop failed/skipped/empty from the tally: a failure aborts the whole walk immediately rather than accumulating (and is already reported via its own fail message), and the disabled/covered/empty buckets count candidate directories rather than independent build roots, so a total there is just as misleading as the "N build root(s)" figure already removed. --- ...output-manifest-dynamic-sbom-inference.mts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts index 883f3a99fd..c5b9f03077 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -19,21 +19,14 @@ function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { .join('\n') } +// Only the generated count is reported: a failure aborts the whole walk +// immediately (already reported via its own fail message) rather than +// accumulating alongside successes, and the disabled/covered/empty buckets +// count candidate directories, not independent build roots, so a total +// there would be just as misleading as the removed "N build root(s)" one. function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { const generated = outcomes.filter(o => o.status === 'generated').length - const failed = outcomes.filter(o => o.status === 'failed').length - const skippedCovered = outcomes.filter( - o => o.status === 'skippedCovered', - ).length - const skippedDisabled = outcomes.filter( - o => o.status === 'skippedDisabled', - ).length - const empty = outcomes.filter(o => o.status === 'empty').length - return ( - `Generated ${generated} Socket facts file(s); ` + - `${failed} failed, ${skippedCovered} skipped (already covered), ` + - `${skippedDisabled} skipped (disabled/pom), ${empty} empty.` - ) + return `Generated ${generated} Socket facts file(s).` } export async function outputManifestDynamicSbomInference( From 0ce3863db0366ea5ba23fee7848e91aad8677b3b Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 15:55:00 +0200 Subject: [PATCH 20/27] Trim verbose comment in output-manifest-dynamic-sbom-inference --- .../manifest/output-manifest-dynamic-sbom-inference.mts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts index c5b9f03077..8f2a0c97eb 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -7,9 +7,7 @@ import type { RecursiveManifestOutcome } from './generate-recursive-manifests.mt import type { CResult, OutputKind } from '../../types.mts' function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { - // A reactor member covered by its parent's own facts run is implied by that - // parent's line already showing up above it; listing it again here is just - // noise, and the aggregate count still shows up in summarize(). + // A covered reactor member is implied by its parent's line above it. return outcomes .filter(o => o.status !== 'skippedCovered') .map( @@ -19,11 +17,6 @@ function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { .join('\n') } -// Only the generated count is reported: a failure aborts the whole walk -// immediately (already reported via its own fail message) rather than -// accumulating alongside successes, and the disabled/covered/empty buckets -// count candidate directories, not independent build roots, so a total -// there would be just as misleading as the removed "N build root(s)" one. function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { const generated = outcomes.filter(o => o.status === 'generated').length return `Generated ${generated} Socket facts file(s).` From ff715efb809709686a1a08d870069e12e5fb02bf Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 3 Aug 2026 16:14:56 +0200 Subject: [PATCH 21/27] Make the CLI-banner-version test normalization suffix-aware Root cause of the flaky snapshot mismatches on this PR: whether the CLI itself redacts its version banner (VITEST baked in at build time) or prints the real one and leaves redaction to this test helper depends on env propagation into the build step, not just the test run - so the same source can produce either "" or the real "vX.Y.Z-prerelease" depending on how it was built. normalizeBanner's regex only stripped a bare "vX.Y.Z", leaving a trailing prerelease suffix dangling in one case but not the other. Broadened it to match a trailing prerelease/build suffix and to be idempotent on an already-redacted value, so both cases normalize identically. Refreshed the now-correct snapshots. --- src/commands/manifest/cmd-manifest-auto.test.mts | 4 ++-- .../cmd-manifest-dynamic-sbom-inference.test.mts | 4 ++-- src/commands/manifest/cmd-manifest-gradle.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-kotlin.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-maven.test.mts | 4 ++-- src/commands/manifest/cmd-manifest-scala.test.mts | 6 +++--- src/commands/manifest/cmd-manifest-setup.test.mts | 4 ++-- test/utils.mts | 10 ++++++++-- 8 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index 3403ee69f1..7025d5face 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -42,7 +42,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) @@ -63,7 +63,7 @@ describe('socket manifest auto', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 28baf2e573..4207a94035 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -42,7 +42,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) @@ -65,7 +65,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index 1eb8c8bb62..fd2a4c23b0 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -65,7 +65,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest gradle', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 47c4d7d295..81906dd3e4 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -65,7 +65,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -86,7 +86,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) @@ -104,7 +104,7 @@ describe('socket manifest kotlin', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 54c48c19b5..6388bb311d 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -53,7 +53,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) @@ -74,7 +74,7 @@ describe('socket manifest maven', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest maven\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 2acf7bd31a..96941b8884 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -79,7 +79,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -100,7 +100,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) @@ -118,7 +118,7 @@ describe('socket manifest scala', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: " `) diff --git a/src/commands/manifest/cmd-manifest-setup.test.mts b/src/commands/manifest/cmd-manifest-setup.test.mts index e306606d08..bd2fb489d9 100644 --- a/src/commands/manifest/cmd-manifest-setup.test.mts +++ b/src/commands/manifest/cmd-manifest-setup.test.mts @@ -52,7 +52,7 @@ describe('socket manifest setup', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: " `) @@ -73,7 +73,7 @@ describe('socket manifest setup', async () => { expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: -prerelease + | __|___ ___| |_ ___| |_ | CLI: |__ | * | _| '_| -_| _| | token: , org: |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: " `) diff --git a/test/utils.mts b/test/utils.mts index 5badbc0bf7..8c814c6536 100644 --- a/test/utils.mts +++ b/test/utils.mts @@ -69,8 +69,14 @@ function normalizeCoanaVersion(str: string): string { function normalizeBanner(str: string): string { return ( str - // Replace CLI version like "v1.1.67" with "". - .replace(/\| CLI: v[\d.]+/g, '| CLI: ') + // Replace a version like "v1.1.67" or "v1.1.67-prerelease" with + // "" - also matches an already-redacted value, since whether + // the CLI itself redacts depends on env baked in at build time, not + // just this test run. + .replace( + /\| CLI: (?:v[\d.]+(?:[-+][\w.]+)?|)/g, + '| CLI: ', + ) // Replace token and org info with "". .replace( /\| (?:Node: [^,]+, )?token: [^,]+, (?:org: [^\n"]+)/g, From ff6a16e677edec28b9915a973d72f5ee232ce74d Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 4 Aug 2026 09:23:37 +0200 Subject: [PATCH 22/27] Fix realpath/boundary mismatch and a silent-zero-projects gap generateRecursiveManifests read candidate dirs realpath-resolved (findBuildToolCandidates already does this) but passed the raw cwd as the socket.json cascade boundary and as the anchor for re-anchoring --exclude-paths. Whenever cwd contains a symlink (macOS /tmp -> /private/tmp, several CI layouts), the boundary comparison never matched: the cascade walked all the way to the filesystem root instead of stopping at cwd, and --exclude-paths silently stopped reaching the build tool invocation. Resolve cwd once and use it consistently for both. Also realpath-resolve a project's subprojectDir before adding it to the covered set, in both generate-recursive-manifests.mts and setup-recursive-manifest-config.mts, so a symlinked reactor member is correctly recognized as covered instead of escaping and being reinvoked as an independent root. enumerateWorkspaces treated a clean exit with zero projects as success, but every real enumeration reports at least the build's own root project, so zero projects always means the task never ran (e.g. an extension jar built before the workspace-enumeration participant existed). Drop the exit-code condition so this is always a failure. --- .../manifest/enumerate-workspaces.mts | 4 +- .../manifest/enumerate-workspaces.test.mts | 12 +++++ .../manifest/generate-recursive-manifests.mts | 19 +++++-- .../generate-recursive-manifests.test.mts | 50 +++++++++++++++++++ .../setup-recursive-manifest-config.mts | 9 +++- 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index a8a93ae226..f83f346568 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -70,10 +70,10 @@ export async function enumerateWorkspaces({ return } - if (result.code !== 0 && !result.projects.length) { + if (!result.projects.length) { process.exitCode = 1 logger.fail( - `The ${ecosystem} build failed (exit code ${result.code}) before producing any workspace records.`, + `The ${ecosystem} build produced no workspace records (exit code ${result.code}); every build reports at least its own root project, so the enumeration task did not run.`, ) return } diff --git a/src/commands/manifest/enumerate-workspaces.test.mts b/src/commands/manifest/enumerate-workspaces.test.mts index e97abd95f7..6d6735f8dd 100644 --- a/src/commands/manifest/enumerate-workspaces.test.mts +++ b/src/commands/manifest/enumerate-workspaces.test.mts @@ -94,4 +94,16 @@ describe('enumerateWorkspaces', () => { expect(result).toBeUndefined() expect(process.exitCode).toBe(1) }) + + it('fails even on a clean exit if no workspace records were produced - every real run reports at least its own root project', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue({ + code: 0, + projects: [], + stderr: '', + stdout: '', + }) + const result = await enumerateWorkspaces(baseArgs) + expect(result).toBeUndefined() + expect(process.exitCode).toBe(1) + }) }) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 2242456bee..3bc432ff85 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -4,6 +4,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { findBuildToolCandidates, + realpathOrResolved, withoutDisabledFlags, } from './discover-manifest-roots.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' @@ -145,6 +146,10 @@ export async function generateRecursiveManifests({ verbose: boolean }): Promise { const rootSockJson = readOrDefaultSocketJson(cwd) + // Candidate dirs come back realpath-resolved (findBuildToolCandidates); cwd + // must match or every boundary/relative-path comparison below breaks as + // soon as cwd contains a symlink (macOS /tmp -> /private/tmp, etc.). + const realCwd = await realpathOrResolved(cwd) // A root-disabled ecosystem must still be scanned for - a nested socket.json // may re-enable it - so the per-directory cascade below, not this scan, is // what actually decides skip vs. include. @@ -167,7 +172,7 @@ export async function generateRecursiveManifests({ const nearestRoot = nearestDisabledRoot(dir, disabledRoots) const sockJson = nearestRoot ? readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) - : readSocketJsonCascade(dir, cwd, rootSockJson) + : readSocketJsonCascade(dir, realCwd, rootSockJson) const { bin, buildOpts, @@ -194,7 +199,7 @@ export async function generateRecursiveManifests({ const excludePathsForRoot = projectIgnorePathsToReachExcludePaths( excludePaths, - { cwd, target: dir }, + { cwd: realCwd, target: dir }, ) // eslint-disable-next-line no-await-in-loop @@ -224,8 +229,14 @@ export async function generateRecursiveManifests({ } covered.add(dir) - for (const project of result.projects) { - covered.add(path.resolve(dir, project.subprojectDir)) + // eslint-disable-next-line no-await-in-loop + const resolvedSubprojectDirs = await Promise.all( + result.projects.map(project => + realpathOrResolved(path.resolve(dir, project.subprojectDir)), + ), + ) + for (const subprojectDir of resolvedSubprojectDirs) { + covered.add(subprojectDir) } outcomes.push({ dir, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 251107e6c0..2249501a36 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -1,3 +1,5 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -487,4 +489,52 @@ describe('generateRecursiveManifests', () => { warnSpy.mockRestore() } }) + + it('bounds the socket.json cascade at a symlinked cwd instead of walking past it to the real filesystem root', async () => { + const actual = await vi.importActual< + typeof import('../../utils/socket-json.mts') + >('../../utils/socket-json.mts') + vi.mocked(readOrDefaultSocketJson).mockImplementation( + actual.readOrDefaultSocketJson, + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + actual.readSocketJsonCascade, + ) + + const outer = await fs.mkdtemp(path.join(tmpdir(), 'symlink-cascade-')) + const realCwd = path.join(outer, 'real-cwd') + const cwdLink = path.join(outer, 'cwd-link') + const project = path.join(realCwd, 'project') + try { + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(project, 'pom.xml'), '') + // Sits strictly above the intended recursion root - must never be read. + await fs.writeFile( + path.join(outer, 'socket.json'), + JSON.stringify({ + version: 1, + defaults: { manifest: { maven: { bin: 'LEAKED-BIN' } } }, + }), + ) + await fs.symlink(realCwd, cwdLink) + + vi.mocked(runManifestFacts).mockImplementation(async ({ bin, cwd }) => { + expect(bin).not.toBe('LEAKED-BIN') + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: cwdLink, + verbose: false, + }) + + expect(outcomes.some(o => o.status === 'generated')).toBe(true) + expect(runManifestFacts).toHaveBeenCalled() + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) }) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 286ad8af6a..ed7f73c25b 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -245,8 +245,13 @@ export async function markWorkspaceCoverage({ const set = coveredByEcosystem.get(candidate.ecosystem) ?? new Set() set.add(candidate.dir) - for (const project of enumResult.projects) { - set.add(path.resolve(candidate.dir, project.subprojectDir)) + const resolvedSubprojectDirs = await Promise.all( + enumResult.projects.map(project => + realpathOrResolved(path.resolve(candidate.dir, project.subprojectDir)), + ), + ) + for (const subprojectDir of resolvedSubprojectDirs) { + set.add(subprojectDir) } coveredByEcosystem.set(candidate.ecosystem, set) return { ok: true, data: undefined } From 651c03d5e02540d3efd481365178cac2828fea0a Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 4 Aug 2026 09:41:06 +0200 Subject: [PATCH 23/27] Address remaining non-blocking review findings - Hide `socket manifest dynamic-sbom-inference` - its name collides with the unrelated, root-only --dynamic-sbom-inference flag on scan create/reach (different semantics: this one is recursive per-root). Keep it internal until that naming collision is resolved. - --json --verbose emitted invalid JSON: the verbose debug preamble and run-manifest-facts' verbose resolution-detail logging both wrote to stdout ahead of the JSON payload. This is the only manifest command with --json, so the combination was newly reachable. Gated the preamble on !json and switched the detail logging to stderr. - Fixed two stale "socket manifest setup --recursive" comments left over from an earlier flag name. - renderTable no longer prints a bare blank line when there are no non-covered outcomes to show. --- .../cmd-manifest-dynamic-sbom-inference.mts | 9 +++++++-- src/commands/manifest/cmd-manifest.test.mts | 1 - src/commands/manifest/enumerate-workspaces.mts | 5 +++-- .../output-manifest-dynamic-sbom-inference.mts | 14 ++++++++++---- src/commands/manifest/run-manifest-facts.mts | 4 ++-- .../socket/SocketWorkspacesRecordsEngine.java | 2 +- 6 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index f80fe072fd..61fc28864e 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -22,7 +22,10 @@ const config: CliCommandConfig = { commandName: 'dynamic-sbom-inference', description: 'Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each', - hidden: false, + // Hidden: `--dynamic-sbom-inference` already names an unrelated, root-only + // scan create/reach flag (see reachability-flags.mts). Keep this hidden + // until the naming collision between the two is resolved. + hidden: true, flags: { ...commonFlags, ...outputFlags, @@ -88,7 +91,9 @@ async function run( // If given path is absolute then cwd should not affect it. cwd = path.resolve(process.cwd(), cwd) - if (verbose) { + // This debug block prints to stdout; --json's payload does too, so skip it + // here (unlike the other manifest commands, this one supports --json). + if (verbose && !json) { logger.group('- ', parentName, config.commandName, ':') logger.group('- flags:', cli.flags) logger.groupEnd() diff --git a/src/commands/manifest/cmd-manifest.test.mts b/src/commands/manifest/cmd-manifest.test.mts index 124258616b..93c264770a 100644 --- a/src/commands/manifest/cmd-manifest.test.mts +++ b/src/commands/manifest/cmd-manifest.test.mts @@ -27,7 +27,6 @@ describe('socket manifest', async () => { bazel [beta] Bazel SBOM support \\u2014 generate manifest files for a Bazel project (Maven, PyPI) cdxgen Run cdxgen for SBOM generation conda [beta] Convert a Conda environment.yml file to a python requirements.txt - dynamic-sbom-inference Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each gradle [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Gradle/Java/Kotlin/etc project kotlin [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Kotlin project maven [beta] Generate a Socket facts file from a Maven \`pom.xml\` project diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index f83f346568..4d138c42b6 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -12,8 +12,9 @@ export type EnumerateWorkspacesResult = { } // Cheap subproject discovery (no dependency resolution) for -// `socket manifest setup --recursive`; dynamic-sbom-inference instead gets -// this list for free as a side effect of its own full facts run. +// `socket manifest setup --dynamic-sbom-inference`; dynamic-sbom-inference +// itself instead gets this list for free as a side effect of its own full +// facts run. export async function enumerateWorkspaces({ bin, buildOpts, diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts index 8f2a0c97eb..3112b76683 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -39,25 +39,31 @@ export async function outputManifestDynamicSbomInference( logger.fail(failMsgWithBadge(result.message, result.cause)) const data = result.data as RecursiveManifestOutcome[] | undefined if (Array.isArray(data)) { - logger.log(renderTable(data)) + const table = renderTable(data) + if (table) { + logger.log(table) + } logger.log(summarize(data)) } return } + const table = renderTable(result.data) + if (outputKind === 'markdown') { logger.log( [ '# Dynamic SBOM inference', '', - renderTable(result.data), - '', + ...(table ? [table, ''] : []), summarize(result.data), ].join('\n'), ) return } - logger.log(renderTable(result.data)) + if (table) { + logger.log(table) + } logger.log(summarize(result.data)) } diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 698d8dd4a3..061f629bcc 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -163,7 +163,7 @@ export async function runManifestFacts({ process.exitCode = 1 logger.fail(rendered.summary) if (verbose && rendered.details) { - logger.log(rendered.details) + logger.info(rendered.details) } return null } @@ -172,7 +172,7 @@ export async function runManifestFacts({ logger.info(rendered.nonBlockingNotice) } if (verbose && rendered.details) { - logger.log(rendered.details) + logger.info(rendered.details) } // A non-zero build exit with no usable output (no graph, no first-party diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java index a2fd395583..fb32202274 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java @@ -14,7 +14,7 @@ /** * Sibling of {@link SocketFactsRecordsEngine} that emits only `meta`/`project` records from the * already-populated reactor list, building no dependency graph - cheap workspace discovery for - * `socket manifest setup --recursive` without a full facts-generation build. + * `socket manifest setup --dynamic-sbom-inference` without a full facts-generation build. */ public final class SocketWorkspacesRecordsEngine { From 462491eae224662911934983daf48c1dba6d6d48 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 4 Aug 2026 09:55:47 +0200 Subject: [PATCH 24/27] Make configureCandidate write a minimal diff instead of snapshotting the cascade Every field shown to the sub-wizard (including ones inherited from an ancestor, never touched by the user) was written verbatim into the candidate's own file, permanently pinning that value against future changes to the ancestor - converting inheritance into a one-time snapshot despite the field-level cascade being the point. Now diffs the final seed against an ancestor-only baseline (the cascade computed from dir's parent, excluding dir's own file) and only writes fields that actually differ, so an untouched field keeps inheriting live. --- .../setup-recursive-manifest-config.mts | 23 +++++++++++++++---- .../setup-recursive-manifest-config.test.mts | 6 +++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index ed7f73c25b..62ccc06174 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -360,6 +360,14 @@ export async function configureCandidate({ ownSockJson.defaults.manifest = {} } + // Ancestor-only baseline (excludes dir's own file, unlike the cascade below) + // so a field left exactly as shown can be told apart from one the user + // actually set - otherwise every accepted default gets written verbatim, + // permanently pinning it against future changes to an ancestor's value. + const inherited = getEcosystemSection( + readSocketJsonCascade(path.dirname(dir), cwd, rootSockJson), + ecosystem, + ) const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) const seed: Record = { ...getEcosystemSection(cascade, ecosystem), @@ -370,15 +378,22 @@ export async function configureCandidate({ if (!result.ok || result.data.canceled) { return result } - // Nothing inherited and nothing set - writing an empty section would just - // be noise (own file unaffected, `dir` keeps inheriting exactly as before). - if (!Object.keys(seed).length) { + + const toWrite: Record = {} + for (const key of Object.keys(seed)) { + if (seed[key] !== inherited[key]) { + toWrite[key] = seed[key] + } + } + // Every field equals what dir would already inherit - writing it would just + // be noise (own file unaffected, dir keeps inheriting exactly as before). + if (!Object.keys(toWrite).length) { logger.log(`No changes for ${relDir} (${ecosystem}); nothing written.`) return notCanceled() } const manifest = ownSockJson.defaults.manifest as Record - manifest[ecosystem] = seed + manifest[ecosystem] = toWrite const writeResult = await writeSocketJson(dir, ownSockJson) if (!writeResult.ok) { diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 9db8097745..0e7d53ea94 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -407,7 +407,7 @@ describe('configureCandidate', () => { vi.mocked(setupGradle).mockReset() }) - it('seeds the sub-wizard with the cascaded value, own-file value winning, and writes the mutated result', async () => { + it('seeds the sub-wizard with the cascaded value, own-file value winning, but only writes fields that actually differ from what dir would inherit', async () => { vi.mocked(readOrDefaultSocketJson).mockImplementation( () => ({ @@ -444,7 +444,9 @@ describe('configureCandidate', () => { defaults: { manifest: { gradle: { - bin: './gradlew', + // `bin` is omitted: it's identical to what dir already inherits + // from its ancestors, so writing it would only pin a value that + // should keep tracking the ancestor's own if that ever changes. javaHome: '/opt/jdk-17', gradleOpts: '--offline', }, From f756bda24d9ec712d48371a8efdd52dd42bf4567 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 4 Aug 2026 09:56:23 +0200 Subject: [PATCH 25/27] Add compat-matrix coverage for the workspace-enumeration producers socket-workspaces.init.gradle, socket-workspaces.plugin.scala, and CoanaWorkspacesLifecycleParticipant/SocketWorkspacesRecordsEngine had no automated coverage at all - the local compat matrix only exercised the facts scripts, so a registration or compile error in the workspaces siblings would surface only as silent degradation (the setup wizard's reactor-coverage pruning treating a real reactor as if it had no members). Adds a smoke-test-workspaces.sh per ecosystem, wired into the same per-version matrix run-compat.sh already runs for the facts scripts, asserting each variant emits exactly a meta record plus the expected project record(s) and nothing else (no node/root/file records, confirming no dependency resolution happens). Verified locally against the currently installed gradle/maven/sbt. --- src/commands/manifest/scripts/test/README.md | 9 ++- .../scripts/test/gradle-compat/.gitignore | 1 + .../gradle-compat/smoke-test-workspaces.sh | 53 +++++++++++++++ .../scripts/test/maven-compat/.gitignore | 1 + .../maven-compat/smoke-test-workspaces.sh | 53 +++++++++++++++ .../manifest/scripts/test/run-compat.sh | 3 + .../scripts/test/sbt-compat/.gitignore | 1 + .../test/sbt-compat/smoke-test-workspaces.sh | 65 +++++++++++++++++++ 8 files changed, 183 insertions(+), 3 deletions(-) create mode 100755 src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh create mode 100755 src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh create mode 100755 src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md index 572062a1ad..2996366644 100644 --- a/src/commands/manifest/scripts/test/README.md +++ b/src/commands/manifest/scripts/test/README.md @@ -1,9 +1,12 @@ # JVM manifest-script compatibility tests These exercise the bundled build-tool scripts — the Gradle init script -(`socket-facts.init.gradle`), the sbt plugin (`socket-facts.plugin.scala`), and -the Maven extension (`maven-extension/`) — against a matrix of build-tool -versions, asserting they still emit the expected line-protocol records. +(`socket-facts.init.gradle`), the sbt plugin (`socket-facts.plugin.scala`), the +Maven extension (`maven-extension/`), and each ecosystem's lightweight +workspace-enumeration sibling (`socket-workspaces.init.gradle`, +`socket-workspaces.plugin.scala`, `CoanaWorkspacesLifecycleParticipant`) — +against a matrix of build-tool versions, asserting they still emit the +expected line-protocol records. ## Run locally, on demand diff --git a/src/commands/manifest/scripts/test/gradle-compat/.gitignore b/src/commands/manifest/scripts/test/gradle-compat/.gitignore index a60b93e601..f721b94c92 100644 --- a/src/commands/manifest/scripts/test/gradle-compat/.gitignore +++ b/src/commands/manifest/scripts/test/gradle-compat/.gitignore @@ -1,6 +1,7 @@ # generated at test time project/localrepo/ project/records.tsv +project/workspaces-records.tsv project/.socket.facts.json project/.gradle/ project/build/ diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh new file mode 100755 index 0000000000..7e197d6d3f --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Run socket-workspaces.init.gradle (the lightweight workspace-enumeration sibling of +# socket-facts.init.gradle) against the smoke project and assert it emits exactly the expected +# project record - no dependency resolution, no node/root/file records at all. Guards, across the +# same Gradle compat matrix as the facts script, that the socketWorkspaces task registers and runs +# cleanly. +# +# Usage: smoke-test-workspaces.sh /path/to/gradle +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +GRADLE="${1:?usage: smoke-test-workspaces.sh }" +INIT="$HERE/../../socket-workspaces.init.gradle" +PROJECT="$HERE/project" +GUH="$HERE/.gradle-home" # isolated Gradle user home -> hermetic, no global init scripts +RECORDS="$PROJECT/workspaces-records.tsv" + +rm -rf "$GUH" "$RECORDS" "$PROJECT/.gradle" "$PROJECT/build" + +echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1) (workspaces)" +( cd "$PROJECT" && "$GRADLE" --no-daemon --offline -g "$GUH" \ + --init-script "$INIT" -Psocket.recordsFile="$RECORDS" socketWorkspaces -q ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +errors = [] +tool = None +projects = [] +for r in rows: + if r[0] == 'meta': + tool = r[1] + elif r[0] == 'project': + projects.append(r) + else: + errors.append(f"unexpected record kind {r[0]!r} - workspaces must never resolve dependencies") + +if tool != 'gradle': + errors.append(f"meta tool {tool!r} != 'gradle'") +if len(projects) != 1: + errors.append(f"expected exactly 1 project record, got {len(projects)}: {projects}") +else: + _, path, group, name, _version, dir_ = projects[0] + if path != ':': errors.append(f"root project path {path!r} != ':'") + if group != 'demo': errors.append(f"root project group {group!r} != 'demo'") + if name != 'gradle-compat-smoke': errors.append(f"root project name {name!r} != 'gradle-compat-smoke'") + if dir_ != '.': errors.append(f"root project dir {dir_!r} != '.'") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS: tool=gradle; 1 project record, no dependency-resolution records") +PY diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore index 1d6ccc626c..8d703e2373 100644 --- a/src/commands/manifest/scripts/test/maven-compat/.gitignore +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -1,3 +1,4 @@ project/records.tsv +project/workspaces-records.tsv project/target/ project/*/target/ diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh new file mode 100755 index 0000000000..2c72ee66f5 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Load the Coana Maven workspaces participant (CoanaWorkspacesLifecycleParticipant, the lightweight +# sibling of the facts participant) on a given Maven binary and assert it emits exactly the +# expected reactor project records - no dependency graph at all. Guards, across the same Maven +# compat matrix as the facts extension, that the participant registers and fires correctly. +# +# Usage: smoke-test-workspaces.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test-workspaces.sh }" +JAR="${2:?usage: smoke-test-workspaces.sh }" +PROJECT="$HERE/project" +RECORDS="$PROJECT/workspaces-records.tsv" + +rm -rf "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target + +echo "+ $("$MVN" -v 2>/dev/null | head -1) (workspaces)" +( cd "$PROJECT" && "$MVN" --batch-mode -q \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-workspaces \ + "-Dsocket.recordsFile=$RECORDS" \ + validate ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +errors = [] +tool = None +projects = {} +for r in rows: + if r[0] == 'meta': + tool = r[1] + elif r[0] == 'project': + projects[r[3]] = r # keyed by artifactId + else: + errors.append(f"unexpected record kind {r[0]!r} - workspaces must never resolve dependencies") + +if tool != 'maven': + errors.append(f"meta tool {tool!r} != 'maven'") +expected = {'root', 'lib', 'app'} +if set(projects) != expected: + errors.append(f"expected reactor modules {sorted(expected)}, got {sorted(projects)}") +for artifact in expected & set(projects): + group = projects[artifact][2] + if group != 'demo': + errors.append(f"{artifact} group {group!r} != 'demo'") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS: tool=maven; reactor modules {sorted(projects)}, no dependency-resolution records") +PY diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index e6279e75cd..96390940f8 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -52,6 +52,7 @@ run_gradle() { unzip -q -o "$CACHE/gradle.zip" -d "$CACHE" fi bash "$HERE/gradle-compat/smoke-test.sh" "$dir/bin/gradle" + bash "$HERE/gradle-compat/smoke-test-workspaces.sh" "$dir/bin/gradle" done } @@ -71,6 +72,7 @@ run_maven() { unzip -q -o "$CACHE/maven.zip" -d "$CACHE" fi bash "$HERE/maven-compat/smoke-test.sh" "$dir/bin/mvn" "$jar" + bash "$HERE/maven-compat/smoke-test-workspaces.sh" "$dir/bin/mvn" "$jar" done } @@ -86,6 +88,7 @@ run_sbt() { echo "== sbt $ver / scala $scala (wants JDK $java) ==" use_jdk "$java" bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala" + bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala" done } diff --git a/src/commands/manifest/scripts/test/sbt-compat/.gitignore b/src/commands/manifest/scripts/test/sbt-compat/.gitignore index 8b58940dc3..4f108a5bb5 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/.gitignore +++ b/src/commands/manifest/scripts/test/sbt-compat/.gitignore @@ -4,4 +4,5 @@ project/project/target/ project/project/build.properties project/scala-version.sbt project/records.tsv +project/workspaces-records.tsv project/target diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh new file mode 100755 index 0000000000..e198d86a56 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Run socket-workspaces.plugin.scala (the lightweight workspace-enumeration sibling of +# socket-facts.plugin.scala) against the smoke project on a given sbt version and assert it emits +# exactly the expected project record - no dependency resolution at all. Guards, across the same +# sbt/scala compat matrix as the facts plugin, that SocketWorkspacesPlugin registers and runs. +# +# The plugin is activated exactly as run.ts does it: dropped into a fresh sbt global base's plugins/. +# Usage: smoke-test-workspaces.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test-workspaces.sh }" +SCALA_VERSION="${2:?usage: smoke-test-workspaces.sh }" +PLUGIN="$HERE/../../socket-workspaces.plugin.scala" +PROJECT="$HERE/project" +RECORDS="$PROJECT/workspaces-records.tsv" + +GB="$(mktemp -d)/global-base" +mkdir -p "$GB/plugins" +cp "$PLUGIN" "$GB/plugins/SocketWorkspacesPlugin.scala" + +# Pin the sbt + scala versions for this matrix entry (the launcher downloads the sbt version). +# `project/` (the meta-build dir) is an empty dir in git, so it's absent on a fresh checkout. +mkdir -p "$PROJECT/project" +echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" +echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" +rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" + +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (workspaces)" +( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsocket.recordsFile="$RECORDS" --batch socketWorkspaces ) + +python3 - "$RECORDS" "$SCALA_VERSION" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +# CrossVersion.apply appends _ (major.minor) to the artifact name - the same +# identity computation socket-facts.plugin.scala's rootIdOf already does, so this isn't optional. +binary_version = '.'.join(sys.argv[2].split('.')[:2]) +expected_name = f"sbt-compat-smoke_{binary_version}" +errors = [] +tool = None +projects = [] +for r in rows: + if r[0] == 'meta': + tool = r[1] + elif r[0] == 'project': + projects.append(r) + else: + errors.append(f"unexpected record kind {r[0]!r} - workspaces must never resolve dependencies") + +if tool != 'sbt': + errors.append(f"meta tool {tool!r} != 'sbt'") +if len(projects) != 1: + errors.append(f"expected exactly 1 project record, got {len(projects)}: {projects}") +else: + _, _ref, org, name, _ver, dir_ = projects[0] + if org != 'demo': errors.append(f"root project org {org!r} != 'demo'") + if name != expected_name: errors.append(f"root project name {name!r} != {expected_name!r}") + if dir_ != '.': errors.append(f"root project dir {dir_!r} != '.'") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS: tool=sbt; 1 project record, no dependency-resolution records") +PY From 42750b558f30ddf17fb9bccdf9527e02ef32e27d Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 4 Aug 2026 10:16:05 +0200 Subject: [PATCH 26/27] Scope the fail-closed abort to the failing ecosystem, not the whole walk Coverage (`covered`/`disabledRoots`) is tracked per ecosystem, so a gradle failure carries no information about maven or sbt's own classification - yet aborting all remaining ecosystems meant an unrelated one could be blocked from ever starting, with nothing in the output explaining why. Narrowed the abort to the failing ecosystem's own loop, and added an 'aborted' status for that ecosystem's still-untried candidates so they show up explicitly instead of being silently absent from the output. --- .../manifest/generate-recursive-manifests.mts | 20 ++++++++++++------- .../generate-recursive-manifests.test.mts | 16 +++++++++------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 3bc432ff85..979184f604 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -20,6 +20,7 @@ import type { BuildTool } from './scripts/build-tool.mts' import type { SocketJson } from '../../utils/socket-json.mts' export type RecursiveManifestOutcomeStatus = + | 'aborted' | 'empty' | 'failed' | 'generated' @@ -133,9 +134,10 @@ export function resolveEcosystemConfig( // 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 -// own invocation. Fail-closed: a root whose workspace layout can't be -// determined aborts the whole walk, since without its projects[] a later -// candidate can't safely be classified as covered vs. independent. +// own invocation. Fail-closed per ecosystem, not globally: a root whose +// workspace layout can't be determined aborts only that ecosystem's own +// remaining walk (marking its untried candidates 'aborted'), since coverage +// is tracked per ecosystem and an unrelated one has nothing to lose from it. export async function generateRecursiveManifests({ cwd, excludePaths, @@ -160,10 +162,11 @@ export async function generateRecursiveManifests({ }) const outcomes: RecursiveManifestOutcome[] = [] - ecosystems: for (const [ecosystem, dirs] of candidatesByTool) { + for (const [ecosystem, dirs] of candidatesByTool) { const covered = new Set() const disabledRoots: DisabledRoot[] = [] - for (const dir of dirs) { + for (let dirIndex = 0; dirIndex < dirs.length; dirIndex += 1) { + const dir = dirs[dirIndex] as string if (covered.has(dir)) { outcomes.push({ dir, ecosystem, status: 'skippedCovered' }) continue @@ -219,9 +222,12 @@ export async function generateRecursiveManifests({ if (result === null) { outcomes.push({ dir, ecosystem, status: 'failed' }) logger.warn( - `Aborting recursive discovery: ${dir}'s (${ecosystem}) workspace layout could not be determined, so remaining build roots cannot be safely classified as covered or independent.`, + `Aborting ${ecosystem} discovery: ${dir}'s workspace layout could not be determined, so its remaining build roots cannot be safely classified as covered or independent.`, ) - break ecosystems + for (const abortedDir of dirs.slice(dirIndex + 1)) { + outcomes.push({ dir: abortedDir, ecosystem, status: 'aborted' }) + } + break } if (!result) { outcomes.push({ dir, ecosystem, status: 'empty' }) diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 2249501a36..8f4cb97ca1 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -125,7 +125,7 @@ describe('generateRecursiveManifests', () => { expect(atDualMarkerDir.every(o => o.status === 'generated')).toBe(true) }) - it('aborts the entire walk (fail-closed) once a build root fails, instead of continuing to further candidates', async () => { + it('aborts only the failing ecosystem (fail-closed), marking its remaining candidates aborted, while an unrelated ecosystem proceeds normally', async () => { vi.mocked(runManifestFacts).mockImplementation( async ({ cwd, ecosystem }) => { if (ecosystem === 'maven' && cwd === dualMarkerDir) { @@ -149,10 +149,11 @@ describe('generateRecursiveManifests', () => { expect(byKey.get('maven:dual-marker-dir')).toBe('failed') // Without dual-marker-dir's own projects[], reactor's still-undiscovered // members can't be safely told apart from independent projects - so - // nothing else in the maven ecosystem gets attempted, or reported at all. - expect(byKey.has('maven:reactor')).toBe(false) - expect(byKey.has('maven:reactor/moduleB/independent-submodule')).toBe( - false, + // nothing else in the maven ecosystem gets attempted, but that's now + // reported explicitly rather than silently omitted. + expect(byKey.get('maven:reactor')).toBe('aborted') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'aborted', ) expect( vi @@ -161,9 +162,12 @@ describe('generateRecursiveManifests', () => { ([opts]) => opts.ecosystem === 'maven' && opts.cwd === reactor, ), ).toBe(false) + // Coverage is tracked per ecosystem, so maven's failure has nothing to + // do with gradle's - it still runs to completion at the same directory. + expect(byKey.get('gradle:dual-marker-dir')).toBe('generated') expect( warnSpy.mock.calls.some(c => - /Aborting recursive discovery/.test(String(c[0])), + /Aborting maven discovery/.test(String(c[0])), ), ).toBe(true) } finally { From 52f8c53457587aa86f4fd5da55d9c264b58e5371 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 4 Aug 2026 10:19:14 +0200 Subject: [PATCH 27/27] expandEnvVarRefs: report every missing var and add a $$ escape Only the first missing variable was ever reported, so a value referencing two unset vars needed two runs to discover both. Also had no way to represent a literal $WORD - $$ now expands to a literal $, so whatever follows it is left untouched. Added a dedicated test file; this function had none. --- .../manifest/enumerate-workspaces.mts | 7 +- src/commands/manifest/expand-env-var-refs.mts | 27 ++++++-- .../manifest/expand-env-var-refs.test.mts | 67 +++++++++++++++++++ src/commands/manifest/run-manifest-facts.mts | 7 +- 4 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/commands/manifest/expand-env-var-refs.test.mts diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index 4d138c42b6..4525291d22 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -1,6 +1,9 @@ import { logger } from '@socketsecurity/registry/lib/logger' -import { expandEnvVarRefs } from './expand-env-var-refs.mts' +import { + expandEnvVarRefs, + formatMissingEnvVarRefs, +} from './expand-env-var-refs.mts' import { enumerateWorkspaces as enumerateWorkspacesScript } from './scripts/run.mts' import { getErrorMessageOr } from '../../utils/errors.mts' @@ -38,7 +41,7 @@ export async function enumerateWorkspaces({ if (expanded.missing) { process.exitCode = 1 logger.fail( - `javaHome (\`${javaHome}\`) references \`${expanded.missing}\`, which is not set in this environment.`, + `javaHome (\`${javaHome}\`) ${formatMissingEnvVarRefs(expanded.missing)}.`, ) return } diff --git a/src/commands/manifest/expand-env-var-refs.mts b/src/commands/manifest/expand-env-var-refs.mts index c8d65ebc2a..c72ef53b3d 100644 --- a/src/commands/manifest/expand-env-var-refs.mts +++ b/src/commands/manifest/expand-env-var-refs.mts @@ -1,20 +1,33 @@ -const ENV_VAR_REF = /\$\{(\w+)\}|\$(\w+)/g +const ENV_VAR_REF = /\$\$|\$\{(\w+)\}|\$(\w+)/g // Expands `$VAR`/`${VAR}` references (e.g. a team-shared `javaHome: // "$JAVA11_HOME"`) against the CLI process's own environment, so a socket.json // value works across machines instead of hardcoding one developer's path. +// `$$` is a literal `$`, for a value that must contain a literal `$WORD`. export function expandEnvVarRefs(value: string): { - missing?: string + missing?: string[] value: string } { - let missing: string | undefined - const expanded = value.replace(ENV_VAR_REF, (_match, braced, bare) => { + const missing: string[] = [] + const expanded = value.replace(ENV_VAR_REF, (match, braced, bare) => { + if (match === '$$') { + return '$' + } const name = braced ?? bare const resolved = process.env[name] if (resolved === undefined) { - missing ??= name + if (!missing.includes(name)) { + missing.push(name) + } + return '' } - return resolved ?? '' + return resolved }) - return missing ? { missing, value: expanded } : { value: expanded } + return missing.length ? { missing, value: expanded } : { value: expanded } +} + +export function formatMissingEnvVarRefs(missing: readonly string[]): string { + const names = missing.map(name => `\`${name}\``).join(', ') + const verb = missing.length > 1 ? 'are' : 'is' + return `references ${names}, which ${verb} not set in this environment` } diff --git a/src/commands/manifest/expand-env-var-refs.test.mts b/src/commands/manifest/expand-env-var-refs.test.mts new file mode 100644 index 0000000000..f2db002784 --- /dev/null +++ b/src/commands/manifest/expand-env-var-refs.test.mts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + expandEnvVarRefs, + formatMissingEnvVarRefs, +} from './expand-env-var-refs.mts' + +const VAR_A = 'SOCKET_TEST_EXPAND_ENV_VAR_A' +const VAR_B = 'SOCKET_TEST_EXPAND_ENV_VAR_B' + +describe('expandEnvVarRefs', () => { + beforeEach(() => { + delete process.env[VAR_A] + delete process.env[VAR_B] + }) + afterEach(() => { + delete process.env[VAR_A] + delete process.env[VAR_B] + }) + + it('expands a bare $VAR reference', () => { + process.env[VAR_A] = '/opt/jdk-17' + expect(expandEnvVarRefs(`$${VAR_A}`)).toEqual({ value: '/opt/jdk-17' }) + }) + + it('expands a braced ${VAR} reference', () => { + process.env[VAR_A] = '/opt/jdk-17' + expect(expandEnvVarRefs(`\${${VAR_A}}`)).toEqual({ value: '/opt/jdk-17' }) + }) + + it('reports every distinct missing variable, not just the first', () => { + const result = expandEnvVarRefs(`$${VAR_A}/$${VAR_B}`) + expect(result.missing).toEqual([VAR_A, VAR_B]) + expect(result.value).toBe('/') + }) + + it('reports a missing variable only once even if referenced twice', () => { + const result = expandEnvVarRefs(`$${VAR_A}:$${VAR_A}`) + expect(result.missing).toEqual([VAR_A]) + }) + + it('treats $$ as an escaped literal $, leaving the following text untouched', () => { + expect(expandEnvVarRefs('$$HOME')).toEqual({ value: '$HOME' }) + }) + + it('treats $$ as an escaped literal $ ahead of a braced-looking reference', () => { + expect(expandEnvVarRefs('$${HOME}')).toEqual({ value: '${HOME}' }) + }) + + it('does not report a missing variable for an escaped $$ reference', () => { + expect(expandEnvVarRefs(`$$${VAR_A}`).missing).toBeUndefined() + }) +}) + +describe('formatMissingEnvVarRefs', () => { + it('uses singular wording for one missing variable', () => { + expect(formatMissingEnvVarRefs(['FOO'])).toBe( + 'references `FOO`, which is not set in this environment', + ) + }) + + it('uses plural wording for multiple missing variables', () => { + expect(formatMissingEnvVarRefs(['FOO', 'BAR'])).toBe( + 'references `FOO`, `BAR`, which are not set in this environment', + ) + }) +}) diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 061f629bcc..a3816840f8 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -3,7 +3,10 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' -import { expandEnvVarRefs } from './expand-env-var-refs.mts' +import { + expandEnvVarRefs, + formatMissingEnvVarRefs, +} from './expand-env-var-refs.mts' import { renderResolutionErrorReport } from './scripts/resolution-report-render.mts' import { runManifestScript } from './scripts/run.mts' import { accumulateSidecar } from './scripts/sidecar.mts' @@ -82,7 +85,7 @@ export async function runManifestFacts({ if (expanded.missing) { process.exitCode = 1 logger.fail( - `javaHome (\`${javaHome}\`) references \`${expanded.missing}\`, which is not set in this environment.`, + `javaHome (\`${javaHome}\`) ${formatMissingEnvVarRefs(expanded.missing)}.`, ) return null }