diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb61ddc1..d5879af22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed +- `--dynamic-sbom-inference` now builds every Maven POM a scan uploads, not only `pom.xml`: `-.pom` and `.pom.xml` files, a bare `pom`, and names in any case, each as a build of its own. Like the upload, it skips POMs matched by `.gitignore` or by `projectIgnorePaths` in `socket.yml`. + ## [1.11.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.11.0) - 2026-10-26 ### Added diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index 695d10e3d..1b7f67555 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -1,6 +1,9 @@ import { existsSync } from 'node:fs' import path from 'node:path' +import micromatch from 'micromatch' + +import { BUILD_FILES } from './scripts/build-tool.mts' import { realpathOrResolved } from '../../utils/fs.mts' import { globWithGitIgnore } from '../../utils/glob.mts' import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' @@ -8,30 +11,35 @@ import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' import type { SocketJson } from '../../utils/socket-json.mts' +import type { SocketYml } from '@socketsecurity/config' 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 - } +const BUILD_FILE_MATCHERS = new Map( + BUILD_TOOLS.map(tool => { + const files = BUILD_FILES[tool] + return [ + tool, + files.byFile + ? micromatch.matcher(files.nocaseGlob, { dot: true, nocase: true }) + : (basename: string) => files.names.includes(basename), + ] + }), +) + +// `entryFile` (a basename in `dir`) is set only for a tool that addresses its +// builds by file, where one directory can hold several builds. +export type BuildRoot = { dir: string; entryFile?: string | undefined } + +export function buildRootPath(root: BuildRoot): string { + return path.join(root.dir, root.entryFile ?? '') +} + +function buildFileTool( + tools: readonly BuildTool[], + basename: string, +): BuildTool | undefined { + return tools.find(tool => BUILD_FILE_MATCHERS.get(tool)?.(basename)) } // A disabled ecosystem is skipped entirely by the glob scan below, so a @@ -85,27 +93,37 @@ export async function addCoveredBuildFiles( } } -// `dir` must be realpath-resolved, as findBuildToolCandidates returns it. +// `root.dir` must be realpath-resolved, as findBuildToolCandidates returns it. export function isBuildRootCovered( covered: ReadonlySet, tool: BuildTool, - dir: string, + root: BuildRoot, ): boolean { - return MARKERS_BY_TOOL[tool].some(marker => - covered.has(path.join(dir, marker)), - ) + const files = BUILD_FILES[tool] + if (files.byFile) { + return !!root.entryFile && covered.has(buildRootPath(root)) + } + return files.names.some(name => covered.has(path.join(root.dir, name))) } -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) +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function sortByDepthThenPath( + roots: readonly T[], + cwd: string, +): T[] { + return [...roots].sort((a, b) => { + const relA = path.relative(cwd, a.dir) + const relB = path.relative(cwd, b.dir) 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 + return ( + depthA - depthB || + compareStrings(relA, relB) || + compareStrings(a.entryFile ?? '', b.entryFile ?? '') + ) }) } @@ -115,51 +133,67 @@ export async function findBuildToolCandidates({ cwd, excludePaths, sockJson, + socketConfig, }: { cwd: string excludePaths?: string[] | undefined + // The socket.yml a scan applies to its upload, so a build file a scan would + // not upload is not built either. + socketConfig: SocketYml | undefined sockJson: SocketJson -}): Promise> { +}): Promise> { const enabledTools = BUILD_TOOLS.filter( tool => !sockJson.defaults?.manifest?.[tool]?.disabled, ) - const result = new Map() + 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, - }) + // Build files are matched after the walk, as a scan matches them after its + // ignores, so --exclude-paths and .gitignore stay case-sensitive. + const hits = await globWithGitIgnore( + enabledTools.flatMap(tool => { + const files = BUILD_FILES[tool] + return (files.byFile ? [files.walkGlob] : files.names).map( + glob => `**/${glob}`, + ) + }), + { absolute: true, additionalIgnores, cwd, socketConfig }, + ) - const dirSetsByTool = new Map>( - enabledTools.map(tool => [tool, new Set()]), + const rootsByTool = new Map>( + enabledTools.map(tool => [tool, new Map()]), ) - const resolvedHits = await Promise.all( - hits.map(async hit => ({ - dir: await realpathOrResolved(path.dirname(hit)), - tool: TOOL_BY_MARKER[path.basename(hit)], + const matched = hits.flatMap(hit => { + const basename = path.basename(hit) + const tool = buildFileTool(enabledTools, basename) + return tool ? [{ basename, hit, tool }] : [] + }) + const roots = await Promise.all( + matched.map(async ({ basename, hit, tool }) => ({ + root: { + dir: await realpathOrResolved(path.dirname(hit)), + entryFile: BUILD_FILES[tool].byFile ? basename : undefined, + }, + tool, })), ) - for (const { dir, tool } of resolvedHits) { - if (tool) { - dirSetsByTool.get(tool)?.add(dir) - } + for (const { root, tool } of roots) { + rootsByTool.get(tool)?.set(buildRootPath(root), root) } const realCwd = await realpathOrResolved(cwd) for (const tool of enabledTools) { result.set( tool, - sortByDepthThenPath([...(dirSetsByTool.get(tool) ?? [])], realCwd), + sortByDepthThenPath( + [...(rootsByTool.get(tool)?.values() ?? [])], + realCwd, + ), ) } return result diff --git a/src/commands/manifest/discover-manifest-roots.test.mts b/src/commands/manifest/discover-manifest-roots.test.mts index 206e988d2..2b1c64a1e 100644 --- a/src/commands/manifest/discover-manifest-roots.test.mts +++ b/src/commands/manifest/discover-manifest-roots.test.mts @@ -1,25 +1,85 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' -import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { + buildRootPath, + findBuildToolCandidates, + isBuildRootCovered, + sortByDepthThenPath, +} from './discover-manifest-roots.mts' import { testPath } from '../../../test/utils.mts' +import { loadSocketConfig } from '../../utils/config.mts' +import type { BuildRoot } from './discover-manifest-roots.mts' import type { SocketJson } from '../../utils/socket-json.mts' const monorepo = path.join( testPath, 'fixtures/commands/manifest/dynamic-sbom-inference/monorepo', ) +const pomNames = path.join( + testPath, + 'fixtures/commands/manifest/dynamic-sbom-inference/pom-names', +) -function relDirs(dirs: string[]): string[] { - return dirs.map(d => path.relative(monorepo, d).replaceAll('\\', '/')).sort() +function relDirs(roots: BuildRoot[]): string[] { + return roots + .map(r => path.relative(monorepo, r.dir).replaceAll('\\', '/')) + .sort() +} + +async function pomNamesRoots(excludePaths?: string[]): Promise { + const candidates = await findBuildToolCandidates({ + cwd: pomNames, + excludePaths, + socketConfig: undefined, + sockJson: {} as SocketJson, + }) + return (candidates.get('maven') ?? []).map(r => + path.relative(pomNames, buildRootPath(r)).replaceAll('\\', '/'), + ) +} + +async function rootsBesideIgnoredPom( + ignoredPomDir: string, + ignoreFile: { content: string; name: string }, +): Promise { + const cwd = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'ignored-poms-')), + ) + try { + await fs.mkdir(path.join(cwd, ignoredPomDir), { recursive: true }) + await fs.writeFile( + path.join(cwd, ignoredPomDir, 'lib-1.0.pom'), + '', + ) + await fs.writeFile(path.join(cwd, 'pom.xml'), '') + await fs.writeFile(path.join(cwd, ignoreFile.name), ignoreFile.content) + const candidates = await findBuildToolCandidates({ + cwd, + socketConfig: loadSocketConfig(cwd), + sockJson: {} as SocketJson, + }) + return candidates + .get('maven') + ?.map(root => ({ ...root, dir: path.relative(cwd, root.dir) })) + } finally { + await fs.rm(cwd, { recursive: true, force: true }) + } +} + +function dirsOf(roots: BuildRoot[] | undefined): string[] { + return (roots ?? []).map(r => r.dir) } describe('findBuildToolCandidates', () => { it('discovers maven, gradle, and sbt candidates in depth order', async () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, + socketConfig: undefined, sockJson: {} as SocketJson, }) @@ -45,9 +105,10 @@ describe('findBuildToolCandidates', () => { it('lists a reactor root before its own members (depth-ascending)', async () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, + socketConfig: undefined, sockJson: {} as SocketJson, }) - const maven = candidates.get('maven') ?? [] + const maven = dirsOf(candidates.get('maven')) const rootIndex = maven.findIndex(d => d === path.join(monorepo, 'reactor')) const moduleAIndex = maven.findIndex( d => d === path.join(monorepo, 'reactor/moduleA'), @@ -59,9 +120,10 @@ describe('findBuildToolCandidates', () => { it('includes a gradle project nested inside a maven candidate directory tree', async () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, + socketConfig: undefined, sockJson: {} as SocketJson, }) - expect(candidates.get('gradle')).toContain( + expect(dirsOf(candidates.get('gradle'))).toContain( path.join(monorepo, 'reactor/moduleA/nested-gradle'), ) }) @@ -69,16 +131,18 @@ describe('findBuildToolCandidates', () => { it('lists a dual-marker directory on both ecosystems', async () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, + socketConfig: undefined, sockJson: {} as SocketJson, }) const dualDir = path.join(monorepo, 'dual-marker-dir') - expect(candidates.get('maven')).toContain(dualDir) - expect(candidates.get('gradle')).toContain(dualDir) + expect(dirsOf(candidates.get('maven'))).toContain(dualDir) + expect(dirsOf(candidates.get('gradle'))).toContain(dualDir) }) it('drops a disabled ecosystem entirely', async () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, + socketConfig: undefined, sockJson: { defaults: { manifest: { maven: { disabled: true } } }, } as SocketJson, @@ -91,14 +155,123 @@ describe('findBuildToolCandidates', () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, excludePaths: ['reactor'], + socketConfig: undefined, sockJson: {} as SocketJson, }) expect(candidates.get('maven')).toEqual([ - path.join(monorepo, 'dual-marker-dir'), + { dir: path.join(monorepo, 'dual-marker-dir'), entryFile: 'pom.xml' }, ]) expect(candidates.get('gradle')).toEqual([ - path.join(monorepo, 'dual-marker-dir'), - path.join(monorepo, 'standalone-gradle'), + { dir: path.join(monorepo, 'dual-marker-dir'), entryFile: undefined }, + { dir: path.join(monorepo, 'standalone-gradle'), entryFile: undefined }, + ]) + }) + + it('lists a gradle directory once despite several markers', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + socketConfig: undefined, + sockJson: {} as SocketJson, + }) + const standalone = path.join(monorepo, 'standalone-gradle') + expect( + dirsOf(candidates.get('gradle')).filter(d => d === standalone), + ).toHaveLength(1) + }) + + it('lists each POM the scan pattern uploads as its own maven build root', async () => { + const roots = await pomNamesRoots() + expect(roots).toEqual([ + 'app-dev.pom.xml', + 'pom.xml', + 'bare/pom', + 'upper/POM.XML', + 'vendored/lib-1.0.pom', + ]) + }) + + it('skips POMs a .gitignore covers, as a scan does when uploading', async () => { + expect( + await rootsBesideIgnoredPom('.m2/repository/com/example/lib/1.0', { + content: '.m2/\n', + name: '.gitignore', + }), + ).toEqual([{ dir: '', entryFile: 'pom.xml' }]) + }) + + it("skips POMs socket.yml's projectIgnorePaths covers, as a scan does when uploading", async () => { + expect( + await rootsBesideIgnoredPom('libs/repo', { + content: 'version: 2\nprojectIgnorePaths:\n - libs/repo\n', + name: 'socket.yml', + }), + ).toEqual([{ dir: '', entryFile: 'pom.xml' }]) + }) + + it('respects --exclude-paths for non-default POMs, case-sensitively', async () => { + expect( + await pomNamesRoots(['vendored', '**/*.pom', 'app-dev.pom.xml']), + ).toEqual(['pom.xml', 'bare/pom', 'upper/POM.XML']) + expect(await pomNamesRoots(['UPPER', 'Bare'])).toEqual([ + 'app-dev.pom.xml', + 'pom.xml', + 'bare/pom', + 'upper/POM.XML', + 'vendored/lib-1.0.pom', + ]) + }) +}) + +describe('isBuildRootCovered', () => { + it('covers a POM only by its own file', () => { + const covered = new Set(['/repo/service/pom.xml']) + expect( + isBuildRootCovered(covered, 'maven', { + dir: '/repo/service', + entryFile: 'pom.xml', + }), + ).toBe(true) + expect( + isBuildRootCovered(covered, 'maven', { + dir: '/repo/service', + entryFile: 'app-dev.pom.xml', + }), + ).toBe(false) + }) + + it('covers a root without an entry file through a marker file', () => { + expect( + isBuildRootCovered(new Set(['/repo/lib/settings.gradle.kts']), 'gradle', { + dir: '/repo/lib', + }), + ).toBe(true) + expect( + isBuildRootCovered(new Set(['/repo/lib/sub/build.gradle']), 'gradle', { + dir: '/repo/lib', + }), + ).toBe(false) + }) +}) + +describe('sortByDepthThenPath', () => { + it('sorts shallower dirs first, then by path, then by entry file', () => { + expect( + sortByDepthThenPath( + [ + { dir: '/repo/b/nested' }, + { dir: '/repo/b', entryFile: 'pom.xml' }, + { dir: '/repo/b', entryFile: 'app-dev.pom.xml' }, + { dir: '/repo/a' }, + { dir: '/repo' }, + ], + '/repo', + ), + ).toEqual([ + { dir: '/repo' }, + { dir: '/repo/a' }, + { dir: '/repo/b', entryFile: 'app-dev.pom.xml' }, + { dir: '/repo/b', entryFile: 'pom.xml' }, + { dir: '/repo/b/nested' }, ]) }) }) diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index 6108798cd..524de6ee9 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -24,6 +24,7 @@ export async function enumerateWorkspaces({ buildOpts, cwd, ecosystem, + entryFile, excludePaths, javaHome, verbose, @@ -32,6 +33,8 @@ export async function enumerateWorkspaces({ buildOpts: string[] cwd: string ecosystem: BuildTool + // See ManifestScriptOptions.entryFile. + entryFile?: string | undefined excludePaths?: string[] | undefined javaHome?: string | undefined verbose: boolean @@ -51,6 +54,7 @@ export async function enumerateWorkspaces({ const scriptOpts = { bin: bin || undefined, + entryFile, excludePaths: excludePaths?.length ? excludePaths : undefined, // `env` replaces the spawned process's whole environment, not just JAVA_HOME. env: resolvedJavaHome diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 3a0061f76..dfdfee445 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 { addCoveredBuildFiles, + buildRootPath, findBuildToolCandidates, isBuildRootCovered, realpathOrResolved, @@ -17,6 +18,7 @@ import { REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, } from './scripts/resolution-report-render.mts' import { SOCKET_JSON } from '../../constants.mts' +import { loadSocketConfig } from '../../utils/config.mts' import { withTmpDir } from '../../utils/fs.mts' import { readOrDefaultSocketJson, @@ -24,6 +26,7 @@ import { } from '../../utils/socket-json.mts' import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' +import type { BuildRoot } from './discover-manifest-roots.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { BuildOption } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' @@ -37,8 +40,7 @@ export type RecursiveManifestOutcomeStatus = | 'skippedCovered' | 'skippedDisabled' -export type RecursiveManifestOutcome = { - dir: string +export type RecursiveManifestOutcome = BuildRoot & { ecosystem: BuildTool factsPath?: string | undefined status: RecursiveManifestOutcomeStatus @@ -82,7 +84,44 @@ function getSkipReason( type DisabledRoot = { dir: string; sockJson: SocketJson } -// Nearest already-confirmed-disabled ancestor of `dir`, if any - lets the +// Maven 3's options starting with `f`; its GnuParser takes a long name after +// one dash too, and splits `-fX` and `-f=X` into `-f X`. +const MAVEN_FILE_OPTS = new Set(['f', 'file']) +const MAVEN_OTHER_F_OPTS = new Set([ + 'fae', + 'fail-at-end', + 'fail-fast', + 'fail-never', + 'ff', + 'fn', +]) + +// Each POM is built by its own `--file`, so a user one is dropped. +function withoutMavenFileOpts(dir: string, opts: string[]): string[] { + const kept: string[] = [] + for (let i = 0, { length } = opts; i < length; i += 1) { + const opt = opts[i]! + const name = opt.replace(/^--?/, '') + if (opt === name || MAVEN_OTHER_F_OPTS.has(name)) { + kept.push(opt) + } else if (MAVEN_FILE_OPTS.has(name)) { + i += 1 + } else if ( + !MAVEN_FILE_OPTS.has(name.split('=')[0]!) && + !(opt.startsWith('-f') && !opt.startsWith('--')) + ) { + kept.push(opt) + } + } + if (kept.length !== opts.length) { + logger.warn( + `Ignoring --file in mavenOpts for ${dir}: each POM there is built by its own file.`, + ) + } + return kept +} + +// Nearest already-confirmed-disabled `dir` or ancestor of it, if any - lets the // caller shorten `readSocketJsonCascade`'s walk to start there instead of // all the way back at `cwd`, without skipping any nested override. function nearestDisabledRoot( @@ -92,7 +131,7 @@ function nearestDisabledRoot( let nearest: DisabledRoot | undefined for (const root of disabledRoots) { if ( - dir.startsWith(`${root.dir}${path.sep}`) && + (dir === root.dir || dir.startsWith(`${root.dir}${path.sep}`)) && (!nearest || root.dir.length > nearest.dir.length) ) { nearest = root @@ -113,7 +152,7 @@ async function runEcosystemCandidates({ verbose, withFiles, }: { - candidatesByTool: Map + candidatesByTool: Map excludePaths: string[] | undefined ignoreUnresolved: boolean | undefined optionFlags: ReadonlySet @@ -130,55 +169,70 @@ async function runEcosystemCandidates({ }): Promise { const outcomes: RecursiveManifestOutcome[] = [] const covered = new Set() - for (const [ecosystem, dirs] of candidatesByTool) { + for (const [ecosystem, roots] of candidatesByTool) { const disabledRoots: DisabledRoot[] = [] - for (let dirIndex = 0; dirIndex < dirs.length; dirIndex += 1) { - const dir = dirs[dirIndex] as string - if (isBuildRootCovered(covered, ecosystem, dir)) { - outcomes.push({ dir, ecosystem, status: 'skippedCovered' }) + const configByDir = new Map< + string, + EcosystemBuildConfig & { excludePaths: string[] } + >() + for (const { 0: rootIndex, 1: root } of roots.entries()) { + const { dir, entryFile } = root + if (isBuildRootCovered(covered, ecosystem, root)) { + outcomes.push({ ...root, ecosystem, status: 'skippedCovered' }) continue } - const nearestRoot = nearestDisabledRoot(dir, disabledRoots) - const sockJson = nearestRoot - ? readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) - : readSocketJsonCascade(dir, realCwd, rootSockJson) + let dirConfig = configByDir.get(dir) + if (!dirConfig) { + const nearestRoot = nearestDisabledRoot(dir, disabledRoots) + const sockJson = nearestRoot + ? readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) + : readSocketJsonCascade(dir, realCwd, rootSockJson) + dirConfig = { + ...resolveEcosystemConfig(ecosystem, dir, sockJson), + excludePaths: projectIgnorePathsToReachExcludePaths(excludePaths, { + cwd: realCwd, + target: dir, + }), + } + configByDir.set(dir, dirConfig) + if (dirConfig.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}): ${dirConfig.skipReason}.`, + ) + } + disabledRoots.push({ dir, sockJson }) + } + } const { bin, buildOpts, excludeConfigs, + excludePaths: excludePathsForRoot, ignoreUnresolved, includeConfigs, javaHome, skipReason, verbose: configVerbose, - } = resolveEcosystemConfig(ecosystem, dir, sockJson) - + } = dirConfig if (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 }) + outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } - const excludePathsForRoot = projectIgnorePathsToReachExcludePaths( - excludePaths, - { cwd: realCwd, target: dir }, - ) - // eslint-disable-next-line no-await-in-loop const result = await runManifestFacts({ bin, buildOpts, cwd: dir, ecosystem, + entryFile, excludeConfigs, excludePaths: excludePathsForRoot, hints: { @@ -199,24 +253,24 @@ async function runEcosystemCandidates({ }) if (result === null) { - outcomes.push({ dir, ecosystem, status: 'failed' }) - const abortedDirs = dirs.slice(dirIndex + 1) - if (abortedDirs.length) { + outcomes.push({ ...root, ecosystem, status: 'failed' }) + const abortedRoots = roots.slice(rootIndex + 1) + if (abortedRoots.length) { logger.warn( - `Aborting ${ecosystem} discovery: ${dir} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`, + `Aborting ${ecosystem} discovery: ${buildRootPath(root)} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`, ) } - for (const abortedDir of abortedDirs) { - outcomes.push({ dir: abortedDir, ecosystem, status: 'aborted' }) + for (const aborted of abortedRoots) { + outcomes.push({ ...aborted, ecosystem, status: 'aborted' }) } break } if (!result) { - outcomes.push({ dir, ecosystem, status: 'empty' }) + outcomes.push({ ...root, ecosystem, status: 'empty' }) continue } - // `-f`/`-p` can root the reactor away from `dir`. + // Gradle's `-p` can root the build away from `dir`. // eslint-disable-next-line no-await-in-loop await addCoveredBuildFiles( covered, @@ -224,7 +278,7 @@ async function runEcosystemCandidates({ result.projects, ) outcomes.push({ - dir, + ...root, ecosystem, factsPath: result.factsPath, status: 'generated', @@ -280,6 +334,7 @@ export async function generateRecursiveManifests({ const candidatesByTool = await findBuildToolCandidates({ cwd, excludePaths, + socketConfig: loadSocketConfig(cwd), sockJson: withoutDisabledFlags(rootSockJson), }) @@ -357,7 +412,10 @@ export function resolveEcosystemConfig( const bin = config?.bin ?? undefined return { bin: bin ?? resolveBuildToolBin('maven', dir), - buildOpts: parseBuildToolOpts(config?.mavenOpts ?? undefined), + buildOpts: withoutMavenFileOpts( + dir, + parseBuildToolOpts(config?.mavenOpts ?? undefined), + ), excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 5bbfd45d7..5bcf8afc2 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -16,6 +16,7 @@ vi.mock('./run-manifest-facts.mts', () => ({ runManifestFacts: vi.fn(), })) +import { buildRootPath } from './discover-manifest-roots.mts' import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { testPath } from '../../../test/utils.mts' @@ -30,6 +31,10 @@ const monorepo = path.join( testPath, 'fixtures/commands/manifest/dynamic-sbom-inference/monorepo', ) +const pomNames = path.join( + testPath, + 'fixtures/commands/manifest/dynamic-sbom-inference/pom-names', +) const reactor = path.join(monorepo, 'reactor') const dualMarkerDir = path.join(monorepo, 'dual-marker-dir') @@ -1028,4 +1033,107 @@ describe('generateRecursiveManifests', () => { expect(opts.withFiles).toBe(true) } }) + + it('runs every POM in a directory as its own build', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, entryFile }) => ({ + factsPath: path.join(cwd, `${entryFile}.socket.facts.json`), + projects: [], + }), + ) + + const outcomes = await generateRecursiveManifests({ + cwd: pomNames, + verbose: false, + }) + + const calls = vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => [ + path.relative(pomNames, opts.cwd).replaceAll('\\', '/'), + opts.entryFile, + ]) + expect(calls).toEqual([ + ['', 'app-dev.pom.xml'], + ['', 'pom.xml'], + ['bare', 'pom'], + ['upper', 'POM.XML'], + ['vendored', 'lib-1.0.pom'], + ]) + expect(outcomes.every(o => o.status === 'generated')).toBe(true) + }) + + it('covers a POM by file, so a sibling POM in the same directory still runs', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, entryFile }) => ({ + factsPath: path.join(cwd, `${entryFile}.socket.facts.json`), + projects: + cwd === pomNames && entryFile === 'pom.xml' + ? [ + { + type: 'maven', + name: 'bare', + subprojectDir: 'bare', + manifestFiles: [{ file: 'bare/pom' }], + children: [], + }, + ] + : [], + }), + ) + + const outcomes = await generateRecursiveManifests({ + cwd: pomNames, + verbose: false, + }) + + const statusOf = (rel: string) => + outcomes.find(o => buildRootPath(o) === path.join(pomNames, rel))?.status + expect(statusOf('pom.xml')).toBe('generated') + expect(statusOf('app-dev.pom.xml')).toBe('generated') + expect(statusOf('bare/pom')).toBe('skippedCovered') + }) + + it('resolves settings once per directory and drops a --file in mavenOpts with a warning', async () => { + vi.mocked(readSocketJsonCascade).mockClear() + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { + manifest: { + maven: { + mavenOpts: + '--file other.xml -P ci -fae -fother.xml -fail-fast -file=x.xml', + }, + }, + }, + } as SocketJson) + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, entryFile }) => ({ + factsPath: path.join(cwd, `${entryFile}.socket.facts.json`), + projects: [], + }), + ) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + await generateRecursiveManifests({ cwd: pomNames, verbose: false }) + + const cascadeDirs = vi + .mocked(readSocketJsonCascade) + .mock.calls.map(([dir]) => dir) + expect(cascadeDirs.length).toBeGreaterThan(0) + expect(cascadeDirs).toEqual([...new Set(cascadeDirs)]) + expect( + warnSpy.mock.calls.filter(c => String(c[0]).includes('--file')), + ).toHaveLength(cascadeDirs.length) + expect( + vi.mocked(runManifestFacts).mock.calls.map(([o]) => o.buildOpts), + ).toEqual( + vi + .mocked(runManifestFacts) + .mock.calls.map(() => ['-P', 'ci', '-fae', '-fail-fast']), + ) + } 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 3112b7668..dcdfde724 100644 --- a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -1,5 +1,6 @@ import { logger } from '@socketsecurity/registry/lib/logger' +import { buildRootPath } from './discover-manifest-roots.mts' import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' import { serializeResultJson } from '../../utils/serialize-result-json.mts' @@ -12,7 +13,7 @@ function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { .filter(o => o.status !== 'skippedCovered') .map( o => - `- ${o.dir} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`, + `- ${buildRootPath(o)} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`, ) .join('\n') } diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 1a69346a4..1c09c4a37 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 { buildRootPath } from './discover-manifest-roots.mts' import { expandEnvVarRefs, formatMissingEnvVarRefs, @@ -60,6 +61,7 @@ export async function runManifestFacts({ buildOpts, cwd, ecosystem, + entryFile, excludeConfigs, excludePaths, hints = FLAG_HINTS, @@ -75,6 +77,8 @@ export async function runManifestFacts({ buildOpts: string[] cwd: string ecosystem: BuildTool + // See ManifestScriptOptions.entryFile. + entryFile?: string | undefined excludeConfigs: string excludePaths?: string[] | undefined // Where the user sets build options, for remedy hints. @@ -109,12 +113,14 @@ export async function runManifestFacts({ return null } + const location = buildRootPath({ dir: cwd, entryFile }) logger.info( - `Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`, + `Generating Socket facts for the ${ecosystem} project at \`${location}\` ...`, ) const scriptOpts = { bin: bin || undefined, + entryFile, excludeConfigs: excludeConfigs || undefined, excludePaths: excludePaths?.length ? excludePaths : undefined, // `env` replaces the spawned process's whole environment, not just JAVA_HOME. @@ -233,7 +239,7 @@ export async function runManifestFacts({ // successful run couldn't resolve. process.exitCode = 1 logger.fail( - `The ${ecosystem} build failed (exit code ${code}) before producing any Socket facts.`, + `The ${ecosystem} build at \`${location}\` failed (exit code ${code}) before producing any Socket facts.`, ) return null } diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 53c6268f0..6416cc1fd 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -21,14 +21,33 @@ const BUILD_TOOL_WRAPPER = { maven: 'mvnw', } as unknown as Partial> -// Gradle (8+) and sbt hold one build per directory; Maven builds are addressed -// by POM file, so `mvn -f other-pom.xml` puts a second build in the directory. -const ADDRESSED_BY_FILE: Record = { +// Maven builds by file (`mvn --file`), so a directory can hold several: the +// POMs a scan uploads, matched case-insensitively. `walkGlob` is a +// case-sensitive superset of `nocaseGlob`, for the walk. +export type BuildFiles = + | { byFile: false; names: readonly string[] } + | { byFile: true; nocaseGlob: string; walkGlob: string } + +// `settings.gradle(.kts)` covers Kotlin-DSL roots that have no root +// `build.gradle`. +export const BUILD_FILES: Record = { __proto__: null, - gradle: false, - maven: true, - sbt: false, -} as unknown as Record + gradle: { + byFile: false, + names: [ + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts', + ], + }, + maven: { + byFile: true, + nocaseGlob: '{*-*.,}pom{.xml,}', + walkGlob: '*[pP][oO][mM]*', + }, + sbt: { byFile: false, names: ['build.sbt'] }, +} as unknown as Record // sbt happily runs in any directory, synthesizing a default project from its // name, so an sbt run outside a build yields a plausible but bogus SBOM. Maven @@ -60,7 +79,7 @@ export function socketFactsFileName( tool: BuildTool, entryFile: string | undefined, ): string | undefined { - if (!ADDRESSED_BY_FILE[tool]) { + if (!BUILD_FILES[tool].byFile) { return `${tool}${constants.DOT_SOCKET_DOT_FACTS_JSON}` } return entryFile diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 23ecb0c32..e6c6e0db8 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -22,6 +22,8 @@ export type ManifestScriptOptions = { projectDir: string // Unset ⇒ resolved to the project wrapper, else PATH (resolveBuildToolBin). bin?: string | undefined + // Maven only: the POM in projectDir to build; unset leaves Maven's default. + entryFile?: string | undefined // Reachability-only: also materialize resolved artifact paths (artifactPaths). withFiles?: boolean | undefined // sbt only: caller-supplied directory to use as sbt's isolated global base. @@ -389,8 +391,10 @@ async function invokeMaven( `-Dsocket.recordsFile=${recordsFile}`, ...commonProps(opts, '-D'), ] + const fileOpts = opts.entryFile ? ['--file', opts.entryFile] : [] const args = [ ...props, + ...fileOpts, ...(opts.toolOpts ?? []), '--batch-mode', 'validate', diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index 1caf1becb..9192a699f 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -11,6 +11,14 @@ import { enumerateWorkspaces, runManifestScript } from './run.mts' vi.mock('@socketsecurity/registry/lib/spawn', () => ({ spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })), })) +// The Maven extension jar exists only in a built checkout. +vi.mock('node:fs', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + existsSync: (p: string) => p.endsWith('.jar') || actual.existsSync(p), + } +}) describe('build tool output', () => { it.each([ @@ -65,3 +73,29 @@ describe('shared sbt global base', () => { } }) }) + +describe('maven entry file', () => { + it.each([ + ['app-dev.pom.xml', ['--file', 'app-dev.pom.xml', '-P', 'ci']], + ['pom.xml', ['--file', 'pom.xml', '-P', 'ci']], + [undefined, ['-P', 'ci']], + ] as const)( + 'builds %s, ahead of the given options', + async (entryFile, expected) => { + vi.mocked(spawn).mockClear() + await runManifestScript('maven', { + bin: 'mvn', + entryFile, + projectDir: process.cwd(), + stdio: 'pipe', + toolOpts: ['-P', 'ci'], + }) + const args = vi.mocked(spawn).mock.calls[0]?.[1] ?? [] + expect(args.filter(a => !a.startsWith('-D'))).toEqual([ + ...expected, + '--batch-mode', + 'validate', + ]) + }, + ) +}) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 28f7a12cb..13e3f25fe 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -8,15 +8,18 @@ import { select } from '@socketsecurity/registry/lib/prompts' import { addCoveredBuildFiles, + buildRootPath, findBuildToolCandidates, isBuildRootCovered, realpathOrResolved, + sortByDepthThenPath, withoutDisabledFlags, } 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 { loadSocketConfig } from '../../utils/config.mts' import { readOrDefaultSocketJson, readSocketJsonCascade, @@ -28,13 +31,14 @@ import { projectIgnorePathsToReachExcludePaths, } from '../scan/exclude-paths.mts' +import type { BuildRoot } from './discover-manifest-roots.mts' 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 } +// One prompt per directory, as settings are per directory; Maven may have +// several POMs, so several roots, there. +type Candidate = { dir: string; ecosystem: BuildTool; roots: BuildRoot[] } const ROOT_ECOSYSTEMS: BuildTool[] = ['maven', 'gradle', 'sbt'] @@ -71,26 +75,6 @@ function getEcosystemSection( ) } -// 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, -): T[] { - 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 - } - return relA < relB ? -1 : relA > relB ? 1 : 0 - }) -} - function toPosixRelative(cwd: string, dir: string): string { return path.relative(cwd, dir).split(path.sep).join('/') } @@ -113,8 +97,8 @@ function findExclusionRoot( } export type ScannedBuildRoots = { - fullByTool: Map - includedByTool: Map + fullByTool: Map + includedByTool: Map } // Both the unfiltered and --exclude-paths-filtered scans, once, so ecosystem @@ -129,9 +113,10 @@ export async function scanBuildRoots({ excludePaths?: string[] | undefined sockJson: SocketJson }): Promise { + const socketConfig = loadSocketConfig(cwd) const [fullByTool, includedByTool] = await Promise.all([ - findBuildToolCandidates({ cwd, sockJson }), - findBuildToolCandidates({ cwd, excludePaths, sockJson }), + findBuildToolCandidates({ cwd, socketConfig, sockJson }), + findBuildToolCandidates({ cwd, excludePaths, socketConfig, sockJson }), ]) return { fullByTool, includedByTool } } @@ -156,24 +141,51 @@ export async function discoverBuildRoots({ }: { cwd: string excludePaths?: string[] | undefined - fullByTool: Map - includedByTool: Map + fullByTool: Map + includedByTool: Map }): Promise { const realCwd = await realpathOrResolved(cwd) 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) { + for (const [ecosystem, fullRoots] of fullByTool) { + const includedPaths = new Set( + (includedByTool.get(ecosystem) ?? []).map(buildRootPath), + ) + const rootsByDir = new Map() + for (const root of fullRoots) { + if (root.dir === realCwd) { continue } - if (includedDirs.has(dir)) { - included.push({ dir, ecosystem }) - continue + const roots = rootsByDir.get(root.dir) + if (roots) { + roots.push(root) + } else { + rootsByDir.set(root.dir, [root]) } + } + for (const { 0: dir, 1: roots } of rootsByDir) { const relDir = toPosixRelative(realCwd, dir) + const includedRoots: BuildRoot[] = [] + const excludedRoots: BuildRoot[] = [] + for (const root of roots) { + if (includedPaths.has(buildRootPath(root))) { + includedRoots.push(root) + } else { + excludedRoots.push(root) + } + } + if (includedRoots.length) { + included.push({ dir, ecosystem, roots: includedRoots }) + // `disabled` is per directory, so it cannot exclude one build file + // without its included siblings. + for (const root of excludedRoots) { + logger.warn( + `Not disabling ${ecosystem} in ${relDir}: --exclude-paths matches ${buildRootPath(root)} but not the other build files there. Pass the same --exclude-paths to the scan instead.`, + ) + } + continue + } const rootRelDir = findExclusionRoot(relDir, ignorePatterns) const rootDir = path.join(realCwd, rootRelDir) const ecosystems = ecosystemsByRoot.get(rootDir) ?? new Set() @@ -192,53 +204,72 @@ export async function discoverBuildRoots({ } // Fails closed: without the members' build files, covered and independent -// candidates can't be told apart. +// candidates can't be told apart. In order, as a root's enumeration can cover +// the roots after it. export async function markWorkspaceCoverage({ - candidate, covered, cwd, + dir, + ecosystem, excludePaths, rootSockJson, + roots, }: { - candidate: Candidate covered: Set cwd: string + dir: string + ecosystem: BuildTool excludePaths?: string[] | undefined rootSockJson: SocketJson + roots: readonly BuildRoot[] }): Promise> { - const cascade = readSocketJsonCascade(candidate.dir, cwd, rootSockJson) + if (roots.every(root => isBuildRootCovered(covered, ecosystem, root))) { + return { ok: true, data: undefined } + } + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) const { bin, buildOpts, javaHome, skipReason } = resolveEcosystemConfig( - candidate.ecosystem, - candidate.dir, + ecosystem, + dir, cascade, ) if (skipReason) { return { ok: true, data: undefined } } - const excludePathsForCandidate = projectIgnorePathsToReachExcludePaths( + const excludePathsForDir = projectIgnorePathsToReachExcludePaths( excludePaths, - { cwd, target: candidate.dir }, + { cwd, target: 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.`, + for (const root of roots) { + if (isBuildRootCovered(covered, ecosystem, root)) { + continue + } + // eslint-disable-next-line no-await-in-loop + const enumResult = await enumerateWorkspaces({ + bin, + buildOpts, + cwd: dir, + ecosystem, + entryFile: root.entryFile, + excludePaths: excludePathsForDir, + javaHome, + verbose: false, + }) + if (!enumResult) { + const relRoot = path.relative(cwd, buildRootPath(root)) || '.' + return { + ok: false, + message: `Could not determine ${ecosystem} workspace layout for ${relRoot}; aborting rather than risk misclassifying its members.`, + } } + // eslint-disable-next-line no-await-in-loop + await addCoveredBuildFiles( + covered, + enumResult.buildRoot, + enumResult.projects, + ) } - - await addCoveredBuildFiles(covered, enumResult.buildRoot, enumResult.projects) return { ok: true, data: undefined } } @@ -700,7 +731,7 @@ export async function setupRecursiveManifestConfig( logger.log(`Configuring ${cwd} ...`) const initialSockJson = readOrDefaultSocketJson(cwd) - // Resolved once here for sortCandidatesForDisplay/disableExclusionRoot's + // Resolved once here for sortByDepthThenPath/disableExclusionRoot's // relative-path math, consistent with discoverBuildRoots' own internal // resolution (see its comment for why this matters). const realCwd = await realpathOrResolved(cwd) @@ -744,7 +775,7 @@ export async function setupRecursiveManifestConfig( // 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), + (fullByTool.get(ecosystem) ?? []).some(root => root.dir === realCwd), ) const rootResult = await setupRecursiveRootDefaults( @@ -775,7 +806,7 @@ export async function setupRecursiveManifestConfig( if (!excluded.length) { logger.log('None matched --exclude-paths; nothing disabled.') } else { - const orderedExcluded = sortCandidatesForDisplay(excluded, realCwd) + const orderedExcluded = sortByDepthThenPath(excluded, realCwd) for (const candidate of orderedExcluded) { // eslint-disable-next-line no-await-in-loop const result = await disableExclusionRoot({ @@ -821,14 +852,18 @@ export async function setupRecursiveManifestConfig( // 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) { + for (const ecosystem of ROOT_ECOSYSTEMS) { // eslint-disable-next-line no-await-in-loop const coverageResult = await markWorkspaceCoverage({ - candidate: { dir: realCwd, ecosystem }, covered, cwd: realCwd, + dir: realCwd, + ecosystem, excludePaths, rootSockJson, + roots: (includedByTool.get(ecosystem) ?? []).filter( + root => root.dir === realCwd, + ), }) if (!coverageResult.ok) { return coverageResult @@ -842,9 +877,16 @@ export async function setupRecursiveManifestConfig( reenabled: 0, skipped: 0, } - const orderedIncluded = sortCandidatesForDisplay(included, realCwd) + // A parent is prompted before its children, so a child's shown default + // reflects the parent's just-written cascade. + const orderedIncluded = sortByDepthThenPath(included, realCwd) for (const candidate of orderedIncluded) { - if (isBuildRootCovered(covered, candidate.ecosystem, candidate.dir)) { + const { ecosystem } = candidate + if ( + candidate.roots.every(root => + isBuildRootCovered(covered, ecosystem, root), + ) + ) { counts.covered += 1 continue } @@ -853,7 +895,7 @@ export async function setupRecursiveManifestConfig( const result = await processCandidate({ cwd: realCwd, dir: candidate.dir, - ecosystem: candidate.ecosystem, + ecosystem, rootSockJson, }) if (!result.ok) { @@ -875,11 +917,13 @@ export async function setupRecursiveManifestConfig( // build-tool invocation only runs for a project already prompted about. // eslint-disable-next-line no-await-in-loop const coverageResult = await markWorkspaceCoverage({ - candidate, covered, cwd: realCwd, + dir: candidate.dir, + ecosystem, excludePaths, rootSockJson, + roots: candidate.roots, }) if (!coverageResult.ok) { return coverageResult diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 6c1c32f56..8358f4563 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -43,7 +43,10 @@ vi.mock('../../utils/socket-json.mts', () => ({ import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' -import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { + buildRootPath, + findBuildToolCandidates, +} from './discover-manifest-roots.mts' import { enumerateWorkspaces } from './enumerate-workspaces.mts' import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' import { @@ -54,7 +57,6 @@ import { processCandidate, scanBuildRoots, setupRecursiveManifestConfig, - sortCandidatesForDisplay, } from './setup-recursive-manifest-config.mts' import { readOrDefaultSocketJson, @@ -63,12 +65,35 @@ import { writeSocketJson, } from '../../utils/socket-json.mts' +import type { BuildRoot } from './discover-manifest-roots.mts' import type { SocketJson } from '../../utils/socket-json.mts' +function dirRoots(...dirs: string[]): BuildRoot[] { + return dirs.map(dir => ({ dir })) +} + +function mavenRoots(...dirs: string[]): BuildRoot[] { + return dirs.map(dir => ({ dir, entryFile: 'pom.xml' })) +} + function emptySockJson(): SocketJson { return { version: 1 } as SocketJson } +// Declines the root questions, opts into the individual walk and leaves each +// candidate inheriting. Returns the prompts asked, in order. +function inheritEveryCandidate(): string[] { + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + if (message.startsWith('Configure other build roots')) { + return true + } + return message.startsWith('Configure') ? false : 'inherit' + }) + return messages +} + // 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 @@ -121,24 +146,6 @@ function makeFakeGradleDisk(cwd: string) { } } -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', ecosystems: ['gradle'] }, - { dir: '/repo/independent-service', ecosystems: ['maven'] }, - ], - cwd, - ) - expect(sorted).toEqual([ - { dir: '/repo/independent-service', ecosystems: ['maven'] }, - { dir: '/repo/module-b/standalone-gradle-lib', ecosystems: ['gradle'] }, - ]) - }) -}) - describe('scanBuildRoots', () => { const cwd = '/repo' @@ -147,8 +154,10 @@ describe('scanBuildRoots', () => { }) 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']]]) + const unfiltered = new Map([ + ['gradle', dirRoots('/repo/a', '/repo/legacy')], + ]) + const filtered = new Map([['gradle', dirRoots('/repo/a')]]) vi.mocked(findBuildToolCandidates).mockImplementation( async ({ excludePaths }) => excludePaths?.length ? filtered : unfiltered, @@ -174,8 +183,8 @@ describe('discoverBuildRoots', () => { it('excludes cwd itself', async () => { const result = await discoverBuildRoots({ cwd, - fullByTool: new Map([['gradle', [cwd]]]), - includedByTool: new Map([['gradle', [cwd]]]), + fullByTool: new Map([['gradle', dirRoots(cwd)]]), + includedByTool: new Map([['gradle', dirRoots(cwd)]]), }) expect(result).toEqual({ excluded: [], included: [] }) @@ -188,13 +197,13 @@ describe('discoverBuildRoots', () => { const result = await discoverBuildRoots({ cwd, excludePaths: ['legacy'], - fullByTool: new Map([['gradle', [legacy, active]]]), - includedByTool: new Map([['gradle', [active]]]), + fullByTool: new Map([['gradle', dirRoots(legacy, active)]]), + includedByTool: new Map([['gradle', dirRoots(active)]]), }) expect(result).toEqual({ excluded: [{ dir: legacy, ecosystems: ['gradle'] }], - included: [{ dir: active, ecosystem: 'gradle' }], + included: [{ dir: active, ecosystem: 'gradle', roots: dirRoots(active) }], }) }) @@ -210,12 +219,12 @@ describe('discoverBuildRoots', () => { cwd, excludePaths: ['legacy'], fullByTool: new Map([ - ['maven', [a]], - ['gradle', [b]], + ['maven', mavenRoots(a)], + ['gradle', dirRoots(b)], ]), includedByTool: new Map([ - ['maven', []], - ['gradle', []], + ['maven', mavenRoots()], + ['gradle', dirRoots()], ]), }) @@ -224,6 +233,40 @@ describe('discoverBuildRoots', () => { included: [], }) }) + + it('does not disable a directory to exclude one POM while a sibling POM there stays included', async () => { + const service = '/repo/service' + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const result = await discoverBuildRoots({ + cwd, + excludePaths: ['service/app-dev.pom.xml'], + fullByTool: new Map([ + [ + 'maven', + [ + { dir: service, entryFile: 'app-dev.pom.xml' }, + { dir: service, entryFile: 'pom.xml' }, + ], + ], + ]), + includedByTool: new Map([['maven', mavenRoots(service)]]), + }) + + expect(result).toEqual({ + excluded: [], + included: [ + { dir: service, ecosystem: 'maven', roots: mavenRoots(service) }, + ], + }) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Not disabling maven in service'), + ) + } finally { + warnSpy.mockRestore() + } + }) }) describe('markWorkspaceCoverage', () => { @@ -295,10 +338,12 @@ describe('markWorkspaceCoverage', () => { const covered = new Set() const result = await markWorkspaceCoverage({ - candidate: { dir: reactor, ecosystem: 'maven' }, covered, cwd, + dir: reactor, + ecosystem: 'maven', rootSockJson: emptySockJson(), + roots: [{ dir: reactor, entryFile: 'pom.xml' }], }) expect(result).toEqual({ ok: true, data: undefined }) @@ -327,10 +372,12 @@ describe('markWorkspaceCoverage', () => { const covered = new Set() await markWorkspaceCoverage({ - candidate: { dir: reactor, ecosystem: 'maven' }, covered, cwd, + dir: reactor, + ecosystem: 'maven', rootSockJson: emptySockJson(), + roots: [{ dir: reactor, entryFile: 'pom.xml' }], }) expect(covered).toEqual(new Set([`${reactor}/build/member/pom.xml`])) @@ -359,10 +406,12 @@ describe('markWorkspaceCoverage', () => { const covered = new Set() await markWorkspaceCoverage({ - candidate: { dir: reactor, ecosystem: 'maven' }, covered, cwd, + dir: reactor, + ecosystem: 'maven', rootSockJson: emptySockJson(), + roots: [{ dir: reactor, entryFile: 'pom.xml' }], }) expect(covered).toEqual(new Set([`${reactor}/moduleA/pom.xml`])) @@ -376,10 +425,12 @@ describe('markWorkspaceCoverage', () => { const covered = new Set() const result = await markWorkspaceCoverage({ - candidate: { dir: reactor, ecosystem: 'maven' }, covered, cwd, + dir: reactor, + ecosystem: 'maven', rootSockJson: emptySockJson(), + roots: [{ dir: reactor, entryFile: 'pom.xml' }], }) expect(result).toEqual({ ok: true, data: undefined }) @@ -392,16 +443,50 @@ describe('markWorkspaceCoverage', () => { const covered = new Set() const result = await markWorkspaceCoverage({ - candidate: { dir: reactor, ecosystem: 'maven' }, covered, cwd, + dir: reactor, + ecosystem: 'maven', rootSockJson: emptySockJson(), + roots: [{ dir: reactor, entryFile: 'pom.xml' }], }) expect(result.ok).toBe(false) expect(covered.size).toBe(0) }) + it('resolves settings once per directory and skips a POM an earlier one covers', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + buildRoot: reactor, + projects: [ + { + type: 'maven', + name: 'reactor', + subprojectDir: '.', + manifestFiles: [{ file: 'pom.xml' }], + children: [], + }, + ], + }) + + await markWorkspaceCoverage({ + covered: new Set(), + cwd, + dir: reactor, + ecosystem: 'maven', + rootSockJson: emptySockJson(), + roots: [ + { dir: reactor, entryFile: 'all.pom.xml' }, + { dir: reactor, entryFile: 'pom.xml' }, + ], + }) + + expect(readSocketJsonCascade).toHaveBeenCalledTimes(1) + expect( + vi.mocked(enumerateWorkspaces).mock.calls.map(([o]) => o.entryFile), + ).toEqual(['all.pom.xml']) + }) + 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({ buildRoot: reactor, @@ -409,11 +494,13 @@ describe('markWorkspaceCoverage', () => { }) await markWorkspaceCoverage({ - candidate: { dir: reactor, ecosystem: 'maven' }, covered: new Set(), cwd, + dir: reactor, + ecosystem: 'maven', excludePaths: ['reactor/moduleB'], rootSockJson: emptySockJson(), + roots: [{ dir: reactor, entryFile: 'pom.xml' }], }) expect(enumerateWorkspaces).toHaveBeenCalledWith( @@ -839,7 +926,7 @@ describe('setupRecursiveManifestConfig', () => { async ({ excludePaths }) => excludePaths?.length ? new Map() - : new Map([['maven', [`${cwd}/service`]]]), + : new Map([['maven', mavenRoots(`${cwd}/service`)]]), ) const result = await setupRecursiveManifestConfig(cwd, false, ['service']) @@ -851,7 +938,7 @@ describe('setupRecursiveManifestConfig', () => { // 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`]]]), + new Map([['maven', mavenRoots(`${cwd}/service`)]]), ) const messages: string[] = [] vi.mocked(select).mockImplementation(async ({ message }) => { @@ -880,8 +967,8 @@ describe('setupRecursiveManifestConfig', () => { // an inheritable default for something else. vi.mocked(findBuildToolCandidates).mockResolvedValue( new Map([ - ['maven', [cwd]], - ['gradle', [`${cwd}/module-b/standalone-gradle-lib`]], + ['maven', mavenRoots(cwd)], + ['gradle', dirRoots(`${cwd}/module-b/standalone-gradle-lib`)], ]), ) const messages: string[] = [] @@ -911,7 +998,7 @@ describe('setupRecursiveManifestConfig', () => { vi.mocked(findBuildToolCandidates).mockImplementation( async ({ sockJson }) => { seenSockJsons.push(sockJson) - return new Map([['maven', [cwd]]]) + return new Map([['maven', mavenRoots(cwd)]]) }, ) @@ -925,7 +1012,7 @@ describe('setupRecursiveManifestConfig', () => { 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]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(readSocketJsonSync).mockImplementation(() => ({ ok: true, @@ -960,7 +1047,7 @@ describe('setupRecursiveManifestConfig', () => { it('declining the root re-enable question leaves the ecosystem disabled and skips configuring it', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [cwd]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(readSocketJsonSync).mockImplementation(() => ({ ok: true, @@ -980,7 +1067,7 @@ describe('setupRecursiveManifestConfig', () => { it('stops when canceling the only detected root ecosystem question', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [cwd]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(select).mockResolvedValueOnce(null) @@ -991,7 +1078,7 @@ describe('setupRecursiveManifestConfig', () => { it('stops when a root ecosystem sub-wizard is canceled', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [cwd]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(select).mockResolvedValueOnce(true) vi.mocked(setupMaven).mockResolvedValue({ @@ -1006,7 +1093,7 @@ describe('setupRecursiveManifestConfig', () => { it('propagates a hard failure reading the root socket.json', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [cwd]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(readSocketJsonSync).mockImplementation(() => ({ ok: false, @@ -1023,7 +1110,7 @@ describe('setupRecursiveManifestConfig', () => { // 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]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(select) // Configure Maven? -> yes. @@ -1044,7 +1131,7 @@ describe('setupRecursiveManifestConfig', () => { it('does not write when the user says yes to configure Maven but leaves every prompt blank', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [cwd]]]), + new Map([['maven', mavenRoots(cwd)]]), ) vi.mocked(select).mockResolvedValueOnce(true) // A no-op wizard: doesn't set a single field. @@ -1065,8 +1152,8 @@ describe('setupRecursiveManifestConfig', () => { vi.mocked(findBuildToolCandidates).mockImplementation( async ({ excludePaths }) => excludePaths?.length - ? new Map([['gradle', []]]) - : new Map([['gradle', [legacy]]]), + ? new Map([['gradle', dirRoots()]]) + : new Map([['gradle', dirRoots(legacy)]]), ) const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) @@ -1085,7 +1172,7 @@ describe('setupRecursiveManifestConfig', () => { it('reports nothing excluded without writing anything, then asks about the remaining candidate individually', async () => { vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['gradle', [`${cwd}/active`]]]), + new Map([['gradle', dirRoots(`${cwd}/active`)]]), ) vi.mocked(select) // Configure Gradle defaults? -> no (gradle is detected via `active`). @@ -1114,8 +1201,8 @@ describe('setupRecursiveManifestConfig', () => { vi.mocked(findBuildToolCandidates).mockImplementation( async ({ excludePaths }) => excludePaths?.length - ? new Map([['gradle', []]]) - : new Map([['gradle', [legacy, nested]]]), + ? new Map([['gradle', dirRoots()]]) + : new Map([['gradle', dirRoots(legacy, nested)]]), ) const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) @@ -1134,24 +1221,9 @@ describe('setupRecursiveManifestConfig', () => { 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`]]]), + new Map([['gradle', dirRoots(`${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 messages = inheritEveryCandidate() const logSpy = vi.spyOn(logger, 'log') try { @@ -1191,8 +1263,8 @@ describe('setupRecursiveManifestConfig', () => { // --exclude-paths run, say - so neither is ever prompted. vi.mocked(findBuildToolCandidates).mockResolvedValue( new Map([ - ['maven', [serviceA, serviceB, serviceBSubmodule]], - ['gradle', [serviceC]], + ['maven', mavenRoots(serviceA, serviceB, serviceBSubmodule)], + ['gradle', dirRoots(serviceC)], ]), ) vi.mocked(readSocketJsonCascade).mockImplementation(dir => @@ -1221,6 +1293,126 @@ describe('setupRecursiveManifestConfig', () => { ) }) + it('asks once about a directory holding several POMs and enumerates each', async () => { + const service = `${cwd}/service` + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([ + [ + 'maven', + [ + { dir: service, entryFile: 'app-dev.pom.xml' }, + { dir: service, entryFile: 'pom.xml' }, + ], + ], + ]), + ) + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => ({ + buildRoot: dir, + projects: [], + })) + const messages = inheritEveryCandidate() + const logSpy = vi.spyOn(logger, 'log') + + try { + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(messages.filter(m => m === 'service (maven)')).toHaveLength(1) + expect( + vi.mocked(enumerateWorkspaces).mock.calls.map(([o]) => o.entryFile), + ).toEqual(['app-dev.pom.xml', 'pom.xml']) + const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(logged).toMatch(/0 configured \(0 re-enabled\), 1 left as-is/) + } finally { + logSpy.mockRestore() + } + }) + + it('does not enumerate a POM at cwd that --exclude-paths excludes', async () => { + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + new Map([ + [ + 'maven', + excludePaths?.length + ? mavenRoots(cwd, `${cwd}/service`) + : [ + { dir: cwd, entryFile: 'broken.pom.xml' }, + ...mavenRoots(cwd, `${cwd}/service`), + ], + ], + ]), + ) + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => ({ + buildRoot: dir, + projects: [], + })) + inheritEveryCandidate() + + const result = await setupRecursiveManifestConfig(cwd, false, [ + 'broken.pom.xml', + ]) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect( + vi + .mocked(enumerateWorkspaces) + .mock.calls.some(([o]) => o.entryFile === 'broken.pom.xml'), + ).toBe(false) + }) + + it('does not enumerate a POM at cwd that another POM there already aggregates', async () => { + const cwd = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'seed-coverage-poms-')), + ) + await fs.writeFile(path.join(cwd, 'all.pom.xml'), '') + await fs.writeFile(path.join(cwd, 'pom.xml'), '') + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([ + [ + 'maven', + [ + { dir: cwd, entryFile: 'all.pom.xml' }, + ...mavenRoots(cwd, `${cwd}/service`), + ], + ], + ]), + ) + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => ({ + buildRoot: dir, + projects: + dir === cwd + ? [ + { + type: 'maven', + name: 'core', + subprojectDir: '', + manifestFiles: [{ file: 'pom.xml' }], + children: [], + }, + ] + : [], + })) + inheritEveryCandidate() + + try { + await setupRecursiveManifestConfig(cwd, false) + + expect( + vi + .mocked(enumerateWorkspaces) + .mock.calls.map(([o]) => + buildRootPath({ dir: o.cwd, entryFile: o.entryFile }), + ), + ).toEqual([ + path.join(cwd, 'all.pom.xml'), + path.join(cwd, 'service/pom.xml'), + ]) + } finally { + await fs.rm(cwd, { recursive: true, force: true }) + } + }) + it('seeds coverage from cwd itself, so a reactor rooted exactly at cwd still gets its declared members pruned', async () => { const cwd = await fs.realpath( await fs.mkdtemp(path.join(tmpdir(), 'seed-coverage-')), @@ -1240,7 +1432,9 @@ describe('setupRecursiveManifestConfig', () => { const moduleB = `${cwd}/module-b` vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [cwd, independentService, moduleA, moduleB]]]), + new Map([ + ['maven', mavenRoots(cwd, independentService, moduleA, moduleB)], + ]), ) vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => dir === cwd @@ -1265,17 +1459,7 @@ describe('setupRecursiveManifestConfig', () => { } : { buildRoot: dir, 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 messages = inheritEveryCandidate() try { const result = await setupRecursiveManifestConfig(cwd, false) @@ -1300,17 +1484,9 @@ describe('setupRecursiveManifestConfig', () => { const laterCandidate = `${cwd}/zzz-later` vi.mocked(findBuildToolCandidates).mockResolvedValue( - new Map([['maven', [independentService, laterCandidate]]]), + new Map([['maven', mavenRoots(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' - }) + inheritEveryCandidate() // Simulates a missing $JAVA8_HOME env var reference: enumeration fails // for independent-service specifically. vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => diff --git a/src/utils/config.mts b/src/utils/config.mts index 3d3de8c2f..57486404a 100644 --- a/src/utils/config.mts +++ b/src/utils/config.mts @@ -17,6 +17,7 @@ * Key Functions: * - findSocketYmlSync: Locate socket.yml configuration file * - getConfigValue: Retrieve configuration value by key + * - loadSocketConfig: Parsed socket.yml, if one is found and valid * - overrideCachedConfig: Apply temporary config overrides * - updateConfigValue: Persist configuration changes */ @@ -245,6 +246,11 @@ let _cachedConfig: LocalConfig | undefined // When using --config or SOCKET_CLI_CONFIG, do not persist the config. let _configFromFlag = false +export function loadSocketConfig(dir: string): SocketYml | undefined { + const result = findSocketYmlSync(dir) + return result.ok ? result.data?.parsed : undefined +} + /** * Reset config cache for testing purposes. * This allows tests to start with a fresh config state. diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/app-dev.pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/app-dev.pom.xml new file mode 100644 index 000000000..8a47fd0d9 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/app-dev.pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + app-dev-pom-xml + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/bare/pom b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/bare/pom new file mode 100644 index 000000000..438851ec5 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/bare/pom @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + pom + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/other.pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/other.pom.xml new file mode 100644 index 000000000..5865b355a --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/other.pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + other-pom-xml + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml new file mode 100644 index 000000000..855bd9219 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + pom-xml + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml.bak b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml.bak new file mode 100644 index 000000000..9900ea600 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml.bak @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + pom-xml-bak + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/upper/POM.XML b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/upper/POM.XML new file mode 100644 index 000000000..8cb052e88 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/upper/POM.XML @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + POM-XML + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/vendored/lib-1.0.pom b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/vendored/lib-1.0.pom new file mode 100644 index 000000000..f095a8483 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/vendored/lib-1.0.pom @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + lib-1-0-pom + 1.0.0 +