From 66fb96d56af97d70f586f41735cc4894b6526aae Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 13 Oct 2026 21:16:14 +0200 Subject: [PATCH] fix(manifest): cover recursive build roots by build file, not directory Recursive facts generation and the recursive setup wizard skipped a discovered build root whenever its directory was a subproject of an already-processed build, tracked separately per ecosystem. A directory shared by a module of one build and an independent build of the same tool (e.g. a reactor module declared as `sub/alt.xml` next to a standalone `sub/pom.xml`) was therefore never generated. Coverage is now a single set of the build files each processed build reports for its projects (`manifestFiles`); a root is skipped only when one of its own build files is in it. Files outside the build root or absent on disk never count. - The workspace-enumeration scripts emit `buildRoot` and `projectBuild` records, so the setup wizard uses the same rule. - sbt projects also list their base directory's `build.sbt`: sbt loads it into that project even when it sets nothing, which setting positions alone miss. --- CHANGELOG.md | 1 + .../cmd-manifest-dynamic-sbom-inference.mts | 5 +- ...d-manifest-dynamic-sbom-inference.test.mts | 5 +- .../manifest/discover-manifest-roots.mts | 44 ++++- .../manifest/enumerate-workspaces.mts | 11 +- .../manifest/enumerate-workspaces.test.mts | 16 +- .../manifest/generate-recursive-manifests.mts | 49 +---- .../generate-recursive-manifests.test.mts | 155 +++++++++++++++ .../socket/SocketWorkspacesRecordsEngine.java | 10 +- src/commands/manifest/scripts/run.mts | 3 + .../scripts/socket-facts.plugin.scala | 13 +- .../scripts/socket-workspaces.init.gradle | 15 +- .../scripts/socket-workspaces.plugin.scala | 32 +++- .../gradle-compat/smoke-test-workspaces.sh | 14 +- .../maven-compat/smoke-test-workspaces.sh | 13 +- .../test/sbt-compat/smoke-test-workspaces.sh | 14 +- .../setup-recursive-manifest-config.mts | 48 ++--- .../setup-recursive-manifest-config.test.mts | 177 +++++++++++++----- 18 files changed, 479 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a06ca5d98..75524c5db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Reachability scans in pnpm and yarn projects no longer print a 403 from the Socket API when run with a repository-scoped token. The safety check that runs before Coana launches now uses Socket's public token and only looks up the Coana package itself. +- `--dynamic-sbom-inference` no longer skips an independent Maven, Gradle or sbt build that shares a directory with a module of another build: only build files another build actually uses count as covered. ## [1.8.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.8.1) - 2026-10-12 diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index 6ec3b684b..e8d06a0af 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -42,8 +42,9 @@ const config: CliCommandConfig = { (pom.xml.socket.facts.json, gradle.socket.facts.json, or sbt.socket.facts.json, so builds sharing a directory never overwrite each other), - skipping subproject/reactor-module directories a parent build root already - covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. + skipping build roots whose build file a parent build already reports for + one of its subprojects. Unlike \`socket manifest auto\`, this looks beyond + CWD itself. Options ${getFlagListOutput(config.flags)} 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 fa31714a3..53ac8fe5f 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -28,8 +28,9 @@ describe('socket manifest dynamic-sbom-inference', async () => { (pom.xml.socket.facts.json, gradle.socket.facts.json, or sbt.socket.facts.json, so builds sharing a directory never overwrite each other), - skipping subproject/reactor-module directories a parent build root already - covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. + skipping build roots whose build file a parent build already reports for + one of its subprojects. Unlike \`socket manifest auto\`, this looks beyond + CWD itself. Options --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. diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index e8ff4f901..695d10e3d 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import path from 'node:path' import { realpathOrResolved } from '../../utils/fs.mts' @@ -5,6 +6,7 @@ import { globWithGitIgnore } from '../../utils/glob.mts' 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' const BUILD_TOOLS: BuildTool[] = ['gradle', 'maven', 'sbt'] @@ -57,6 +59,43 @@ export function withoutDisabledFlags(sockJson: SocketJson): SocketJson { export { realpathOrResolved } +// Gradle reports a project's build file even when absent, which covers +// nothing. A sibling outside the build root is independently locatable. +export async function addCoveredBuildFiles( + covered: Set, + buildRoot: string, + projects: readonly SocketFactsSbomProject[], +): Promise { + const realBuildRoot = await realpathOrResolved(buildRoot) + const files = await Promise.all( + projects + .flatMap(project => project.manifestFiles ?? []) + .map(async ({ file }) => { + const resolved = path.resolve(buildRoot, file) + return path.join( + await realpathOrResolved(path.dirname(resolved)), + path.basename(resolved), + ) + }), + ) + for (const file of files) { + if (file.startsWith(`${realBuildRoot}${path.sep}`) && existsSync(file)) { + covered.add(file) + } + } +} + +// `dir` must be realpath-resolved, as findBuildToolCandidates returns it. +export function isBuildRootCovered( + covered: ReadonlySet, + tool: BuildTool, + dir: string, +): boolean { + return MARKERS_BY_TOOL[tool].some(marker => + covered.has(path.join(dir, marker)), + ) +} + function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] { return [...dirs].sort((a, b) => { const relA = path.relative(cwd, a) @@ -70,9 +109,8 @@ function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] { }) } -// 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. +// Depth-sorted (root-most first) so the caller visits a reactor root before +// its own members and can skip those whose build files it 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 4525291d2..f5361e770 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -11,6 +11,7 @@ import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' export type EnumerateWorkspacesResult = { + buildRoot: string projects: SocketFactsSbomProject[] } @@ -82,5 +83,13 @@ export async function enumerateWorkspaces({ return } - return { projects: result.projects } + if (!result.buildRoot) { + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build did not report its build root, so its projects' paths cannot be located.`, + ) + return + } + + return { buildRoot: result.buildRoot, projects: result.projects } } diff --git a/src/commands/manifest/enumerate-workspaces.test.mts b/src/commands/manifest/enumerate-workspaces.test.mts index bf2621578..207da368f 100644 --- a/src/commands/manifest/enumerate-workspaces.test.mts +++ b/src/commands/manifest/enumerate-workspaces.test.mts @@ -13,6 +13,7 @@ const ENV_VAR = 'SOCKET_TEST_ENUMERATE_JAVA_HOME' function okResult(): WorkspaceEnumerationResult { return { + buildRoot: '/tmp/some-project', code: 0, projects: [ { @@ -42,9 +43,10 @@ describe('enumerateWorkspaces', () => { process.exitCode = undefined }) - it('returns the projects from a successful run', async () => { + it('returns the build root and projects from a successful run', async () => { vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) const result = await enumerateWorkspaces(baseArgs) + expect(result?.buildRoot).toBe(okResult().buildRoot) expect(result?.projects).toEqual(okResult().projects) }) @@ -84,6 +86,7 @@ describe('enumerateWorkspaces', () => { it('fails when the build crashed before producing any workspace records', async () => { vi.mocked(enumerateWorkspacesScript).mockResolvedValue({ + buildRoot: undefined, code: 1, projects: [], stderr: '', @@ -96,6 +99,7 @@ describe('enumerateWorkspaces', () => { 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({ + buildRoot: undefined, code: 0, projects: [], stderr: '', @@ -105,4 +109,14 @@ describe('enumerateWorkspaces', () => { expect(result).toBeUndefined() expect(process.exitCode).toBe(1) }) + + it('fails when the build did not report its build root', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue({ + ...okResult(), + buildRoot: undefined, + }) + 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 70f76047e..91562239b 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -3,7 +3,9 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { + addCoveredBuildFiles, findBuildToolCandidates, + isBuildRootCovered, realpathOrResolved, withoutDisabledFlags, } from './discover-manifest-roots.mts' @@ -109,12 +111,12 @@ async function runEcosystemCandidates({ withFiles: boolean | undefined }): Promise { const outcomes: RecursiveManifestOutcome[] = [] + const covered = new Set() for (const [ecosystem, dirs] of candidatesByTool) { - const covered = new Set() const disabledRoots: DisabledRoot[] = [] for (let dirIndex = 0; dirIndex < dirs.length; dirIndex += 1) { const dir = dirs[dirIndex] as string - if (covered.has(dir)) { + if (isBuildRootCovered(covered, ecosystem, dir)) { outcomes.push({ dir, ecosystem, status: 'skippedCovered' }) continue } @@ -184,35 +186,13 @@ async function runEcosystemCandidates({ continue } - covered.add(dir) // `-f`/`-p` can root the reactor away from `dir`. // eslint-disable-next-line no-await-in-loop - const buildRoot = await realpathOrResolved(path.dirname(result.factsPath)) - // eslint-disable-next-line no-await-in-loop - const resolvedSubprojectDirs = await Promise.all( - result.projects.map(project => - realpathOrResolved(path.resolve(buildRoot, project.subprojectDir)), - ), + await addCoveredBuildFiles( + covered, + path.dirname(result.factsPath), + result.projects, ) - for (const subprojectDir of resolvedSubprojectDirs) { - // Only a genuinely nested member (a descendant of this reactor's own - // directory) has no independent existence worth its own standalone - // analysis. A subprojectDir that escapes this reactor's own tree (a - // sibling, e.g. Maven's `../shared-lib` or Gradle's - // relocated projectDir) is independently locatable and potentially - // independently consumed or published - its own un-mediated - // resolution (e.g. a dependency version this reactor's own - // dependency management happens to override) is a distinct, - // meaningful data point, not a redundant one. Never suppress its own - // build-root invocation, regardless of which reactor(s) also - // incorporate it or the order candidates happen to be discovered in. - if ( - subprojectDir === buildRoot || - subprojectDir.startsWith(`${buildRoot}${path.sep}`) - ) { - covered.add(subprojectDir) - } - } outcomes.push({ dir, ecosystem, @@ -224,17 +204,8 @@ async function runEcosystemCandidates({ return outcomes } -// Generates one Socket facts file 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 - and only a properly nested subprojectDir counts as -// coverage at all; one that escapes its declaring reactor's own directory -// still gets its own independent invocation too (see the covered.add call -// below). 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. +// A failed root aborts only its own ecosystem's remaining walk: the build +// files it would have covered are all its own tool's. export async function generateRecursiveManifests({ cwd, excludePaths, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index cae73d216..71eae7017 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -60,12 +60,14 @@ describe('generateRecursiveManifests', () => { type: 'maven', name: 'moduleA', subprojectDir: 'moduleA', + manifestFiles: [{ file: 'moduleA/pom.xml' }], children: [], }, { type: 'maven', name: 'moduleB', subprojectDir: 'moduleB', + manifestFiles: [{ file: 'moduleB/pom.xml' }], children: [], }, ], @@ -134,6 +136,7 @@ describe('generateRecursiveManifests', () => { type: 'maven', name: 'shared-lib', subprojectDir: `../${sharedLibName}`, + manifestFiles: [{ file: `../${sharedLibName}/pom.xml` }], children: [], }, ], @@ -164,6 +167,156 @@ describe('generateRecursiveManifests', () => { }, ) + it('covers only the build files a build reports, so an independent build sharing a member directory still runs', async () => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'co-located-builds-')), + ) + const shared = path.join(outer, 'shared') + const gradleMember = path.join(outer, 'gradle-member') + try { + await fs.mkdir(shared, { recursive: true }) + await fs.mkdir(gradleMember, { recursive: true }) + await fs.writeFile(path.join(outer, 'pom.xml'), '') + // The reactor declares `shared/alt.xml`. + await fs.writeFile(path.join(shared, 'alt.xml'), '') + await fs.writeFile(path.join(shared, 'pom.xml'), '') + await fs.writeFile(path.join(outer, 'settings.gradle'), '') + await fs.writeFile(path.join(gradleMember, 'build.gradle'), '') + await fs.writeFile(path.join(gradleMember, 'pom.xml'), '') + + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (cwd === outer && ecosystem === 'maven') { + return { + factsPath: path.join(cwd, 'pom.xml.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'shared', + subprojectDir: 'shared', + manifestFiles: [{ file: 'shared/alt.xml' }], + children: [], + }, + ], + } + } + if (cwd === outer && ecosystem === 'gradle') { + return { + factsPath: path.join(cwd, 'gradle.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'gradle-member', + subprojectDir: 'gradle-member', + manifestFiles: [{ file: 'gradle-member/build.gradle' }], + children: [], + }, + ], + } + } + return { + factsPath: path.join(cwd, `${ecosystem}.socket.facts.json`), + projects: [], + } + }, + ) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${o.dir}`, o.status]), + ) + expect(byKey.get(`maven:${shared}`)).toBe('generated') + expect(byKey.get(`gradle:${gradleMember}`)).toBe('skippedCovered') + expect(byKey.get(`maven:${gradleMember}`)).toBe('generated') + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + + it('covers a build root through any of its build files, not only the one a build was run on', async () => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'sbt-member-')), + ) + const member = path.join(outer, 'member') + try { + await fs.mkdir(member, { recursive: true }) + await fs.writeFile(path.join(outer, 'build.sbt'), '') + await fs.writeFile(path.join(member, 'build.sbt'), '') + + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, 'sbt.socket.facts.json'), + projects: + cwd === outer + ? [ + { + type: 'maven', + name: 'member', + subprojectDir: 'member', + manifestFiles: [ + { file: 'build.sbt' }, + { file: 'member/build.sbt' }, + ], + children: [], + }, + ] + : [], + })) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(member)).toBe('skippedCovered') + expect(runManifestFacts).toHaveBeenCalledTimes(1) + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + + it('does not cover a build root through a reported build file that is absent on disk', async () => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'absent-build-file-')), + ) + const tools = path.join(outer, 'tools') + try { + await fs.mkdir(tools, { recursive: true }) + await fs.writeFile(path.join(outer, 'settings.gradle'), "include 'tools'") + await fs.writeFile(path.join(tools, 'settings.gradle'), '') + + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, 'gradle.socket.facts.json'), + projects: + cwd === outer + ? [ + { + type: 'maven', + name: 'tools', + subprojectDir: 'tools', + manifestFiles: [{ file: 'tools/build.gradle' }], + children: [], + }, + ] + : [], + })) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(tools)).toBe('generated') + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + it('judges coverage against the reported build root, not the discovery directory', async () => { const outer = await fs.realpath( await fs.mkdtemp(path.join(tmpdir(), 'relocated-build-root-')), @@ -187,12 +340,14 @@ describe('generateRecursiveManifests', () => { type: 'maven', name: 'member', subprojectDir: 'member', + manifestFiles: [{ file: 'member/pom.xml' }], dependencies: [], }, { type: 'maven', name: 'escaped', subprojectDir: '../escaped', + manifestFiles: [{ file: '../escaped/pom.xml' }], dependencies: [], }, ], 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 5befb7c63..969a2acb1 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,9 +12,10 @@ import java.util.regex.Pattern; /** - * 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 --dynamic-sbom-inference` without a full facts-generation build. + * Sibling of {@link SocketFactsRecordsEngine} that emits only `meta`/`buildRoot`/`project`/ + * `projectBuild` records from the already-populated reactor list, building no dependency graph - + * cheap workspace discovery for `socket manifest setup --dynamic-sbom-inference` without a full + * facts-generation build. */ public final class SocketWorkspacesRecordsEngine { @@ -32,6 +33,7 @@ public static void run(List reactor, File rootDir, Options opts, S List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + rec(lines, "buildRoot", rootDir.getAbsolutePath()); for (MavenProject module : reactor) { // No basedir: Maven's stand-in project for a directory without a POM. Skipping it lets Maven's @@ -40,6 +42,8 @@ public static void run(List reactor, File rootDir, Options opts, S 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); + File pom = module.getFile(); + if (pom != null && pom.isFile()) rec(lines, "projectBuild", ws, SocketSupport.relativePath(rootDir.toPath(), pom.toPath())); } write(opts.recordsFile, lines); diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 66a59ed0f..d2c07b16c 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -70,6 +70,8 @@ const SBT_FACTS_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' const SBT_WORKSPACES_PLUGIN_FILENAME = 'SocketWorkspacesPlugin.scala' export type WorkspaceEnumerationResult = { + // Absolute; what each project's subprojectDir and manifestFiles are relative to. + buildRoot: string | undefined code: number projects: SocketFactsSbomProject[] stderr: string @@ -198,6 +200,7 @@ export async function enumerateWorkspaces( ? enumerateSbtWorkspaces(opts) : enumerateMavenWorkspaces(opts)) return { + buildRoot: result.buildRoot, code: result.code, projects: result.facts.projects ?? [], stderr: result.stderr, diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 129d014dd..cbd4502f2 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -90,7 +90,7 @@ object SocketFactsPlugin extends AutoPlugin { } } .groupBy(_._1) - .map { case (ref, pairs) => ref -> pairs.map(_._2).distinct.sorted } + .map { case (ref, pairs) => ref -> pairs.map(_._2) } val sb = new StringBuilder def rec(fields: String*): Unit = { @@ -106,8 +106,10 @@ object SocketFactsPlugin extends AutoPlugin { if (!isExcludedRef(ref)) { val mid = rootIdOf(extracted, 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)))) - buildFilesByRef.getOrElse(ref, Nil).foreach(f => rec("projectBuild", ref.project, f)) + val baseDir = extracted.get(baseDirectory.in(ref)) + rec("project", ref.project, mid.organization, mid.name, ver, relOf(baseDir)) + (buildFilesByRef.getOrElse(ref, Nil) ++ baseBuildSbt(baseDir, rootCanonPath).map(relOf)).distinct.sorted + .foreach(f => rec("projectBuild", ref.project, f)) if (withFiles) { moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach { case (sources, targets) => @@ -509,6 +511,11 @@ object SocketFactsPlugin extends AutoPlugin { // ---- misc helpers -------------------------------------------------------- + // sbt loads the `.sbt` files in a project's base directory into that project + // (Load.configurationSources), so its build.sbt is the project's even when it sets nothing. + private def baseBuildSbt(base: File, rootCanonPath: java.nio.file.Path): Option[File] = + Some(new File(base, "build.sbt")).filter(f => f.isFile && f.getCanonicalFile.toPath.startsWith(rootCanonPath)) + private def boolProp(name: String): Boolean = java.lang.Boolean.parseBoolean(sys.props.getOrElse(name, "false")) diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle index 43d440d2e..5b1c4857b 100644 --- a/src/commands/manifest/scripts/socket-workspaces.init.gradle +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -1,9 +1,9 @@ // Invoke via: // ./gradlew --init-script socket-workspaces.init.gradle socketWorkspaces -// 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+). +// Sibling of socket-facts.init.gradle: emits only `meta`/`buildRoot`/`project`/`projectBuild` +// 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 } @@ -55,12 +55,18 @@ gradle.projectsEvaluated { g -> if (g.ext.socketIsExcluded.call(rel(p.projectDir))) { return } + def buildFiles = [] + try { + def bf = p.buildFile + if (bf != null) buildFiles << rel(bf) + } catch (Exception ignore) {} g.socketWorkspacesInfo.add([ path : p.path, group : (p.group ?: '').toString(), name : p.name, version: (p.version ?: '').toString(), dir : rel(p.projectDir), + buildFiles: buildFiles, ]) } } @@ -68,6 +74,7 @@ gradle.projectsEvaluated { g -> rootProject { rp -> def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() def defaultRecordsFile = new File(rp.projectDir, '.socket.workspaces.records.tsv').absolutePath + def buildRootPath = rp.projectDir.absolutePath rp.tasks.create('socketWorkspaces') { group = 'socket' @@ -83,11 +90,13 @@ rootProject { rp -> def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + rec(['buildRoot', buildRootPath]) 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]) + pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f]) } } def outFile = new File(recordsFileOverride ?: defaultRecordsFile) diff --git a/src/commands/manifest/scripts/socket-workspaces.plugin.scala b/src/commands/manifest/scripts/socket-workspaces.plugin.scala index 936606d75..511f8de4e 100644 --- a/src/commands/manifest/scripts/socket-workspaces.plugin.scala +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -4,9 +4,9 @@ import sbt._ import sbt.Keys._ /** - * 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+). + * Sibling of SocketFactsPlugin (socket-facts.plugin.scala): emits only `meta`/`buildRoot`/ + * `project`/`projectBuild` 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. */ @@ -45,18 +45,37 @@ object SocketWorkspacesPlugin extends AutoPlugin { CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref))) } + // Mirrors socket-facts.plugin.scala's projectBuild records. + val buildFilesByRef: Map[ProjectRef, Seq[String]] = + extracted.structure.settings + .flatMap { setting => + (setting.key.scope.project, setting.pos) match { + case (Select(ref: ProjectRef), pos: FilePosition) => + val f = new File(pos.path) + if (f.isAbsolute && f.isFile && f.getCanonicalFile.toPath.startsWith(rootCanonPath)) Some(ref -> relOf(f)) + else None + case _ => None + } + } + .groupBy(_._1) + .map { case (ref, pairs) => ref -> pairs.map(_._2) } + 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", "")) + rec("buildRoot", rootCanonPath.toString) 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 baseDir = extracted.get(baseDirectory.in(ref)) + rec("project", ref.project, mid.organization, mid.name, ver, relOf(baseDir)) + (buildFilesByRef.getOrElse(ref, Nil) ++ baseBuildSbt(baseDir, rootCanonPath).map(relOf)).distinct.sorted + .foreach(f => rec("projectBuild", ref.project, f)) } } @@ -104,6 +123,11 @@ object SocketWorkspacesPlugin extends AutoPlugin { } } + // sbt loads the `.sbt` files in a project's base directory into that project + // (Load.configurationSources), so its build.sbt is the project's even when it sets nothing. + private def baseBuildSbt(base: File, rootCanonPath: java.nio.file.Path): Option[File] = + Some(new File(base, "build.sbt")).filter(f => f.isFile && f.getCanonicalFile.toPath.startsWith(rootCanonPath)) + // Backslash-escape so a value can never break line/field framing (see records.ts unescape). private def esc(v: String): String = { if (v == null) "" 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 index 7e197d6d3..387800111 100755 --- a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh @@ -20,22 +20,32 @@ echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1) (w ( cd "$PROJECT" && "$GRADLE" --no-daemon --offline -g "$GUH" \ --init-script "$INIT" -Psocket.recordsFile="$RECORDS" socketWorkspaces -q ) -python3 - "$RECORDS" <<'PY' +python3 - "$RECORDS" "$(cd "$PROJECT" && pwd -P)" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] errors = [] tool = None +build_root = None projects = [] +build_files = set() for r in rows: if r[0] == 'meta': tool = r[1] + elif r[0] == 'buildRoot': + build_root = r[1] elif r[0] == 'project': projects.append(r) + elif r[0] == 'projectBuild': + build_files.add((r[1], r[2])) 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 build_root != sys.argv[2]: + errors.append(f"buildRoot {build_root!r} != {sys.argv[2]!r}") +if build_files != {(':', 'build.gradle')}: + errors.append(f"projectBuild records {sorted(build_files)} != [(':', 'build.gradle')]") if len(projects) != 1: errors.append(f"expected exactly 1 project record, got {len(projects)}: {projects}") else: @@ -49,5 +59,5 @@ if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print("PASS: tool=gradle; 1 project record, no dependency-resolution records") +print("PASS: tool=gradle; 1 project record with its build file, no dependency-resolution records") PY 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 index 2c72ee66f..209ecb720 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh @@ -21,22 +21,33 @@ echo "+ $("$MVN" -v 2>/dev/null | head -1) (workspaces)" "-Dsocket.recordsFile=$RECORDS" \ validate ) -python3 - "$RECORDS" <<'PY' +python3 - "$RECORDS" "$(cd "$PROJECT" && pwd -P)" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] errors = [] tool = None +build_root = None projects = {} +build_files = set() for r in rows: if r[0] == 'meta': tool = r[1] + elif r[0] == 'buildRoot': + build_root = r[1] elif r[0] == 'project': projects[r[3]] = r # keyed by artifactId + elif r[0] == 'projectBuild': + build_files.add(r[2]) 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'") +if build_root != sys.argv[2]: + errors.append(f"buildRoot {build_root!r} != {sys.argv[2]!r}") +expected_build_files = {'pom.xml', 'lib/pom.xml', 'app/pom.xml'} +if build_files != expected_build_files: + errors.append(f"projectBuild files {sorted(build_files)} != {sorted(expected_build_files)}") expected = {'root', 'lib', 'app'} if set(projects) != expected: errors.append(f"expected reactor modules {sorted(expected)}, got {sorted(projects)}") 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 index e198d86a5..8fbbcf4e0 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh @@ -29,7 +29,7 @@ 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' +python3 - "$RECORDS" "$SCALA_VERSION" "$(cd "$PROJECT" && pwd -P)" <<'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 @@ -38,17 +38,27 @@ binary_version = '.'.join(sys.argv[2].split('.')[:2]) expected_name = f"sbt-compat-smoke_{binary_version}" errors = [] tool = None +build_root = None projects = [] +build_files = set() for r in rows: if r[0] == 'meta': tool = r[1] + elif r[0] == 'buildRoot': + build_root = r[1] elif r[0] == 'project': projects.append(r) + elif r[0] == 'projectBuild': + build_files.add(r[2]) 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 build_root != sys.argv[3]: + errors.append(f"buildRoot {build_root!r} != {sys.argv[3]!r}") +if build_files != {'build.sbt'}: + errors.append(f"projectBuild files {sorted(build_files)} != ['build.sbt']") if len(projects) != 1: errors.append(f"expected exactly 1 project record, got {len(projects)}: {projects}") else: @@ -61,5 +71,5 @@ if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print("PASS: tool=sbt; 1 project record, no dependency-resolution records") +print("PASS: tool=sbt; 1 project record with its build file, no dependency-resolution records") PY diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 952fd41ad..28f7a12cb 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -7,7 +7,9 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { select } from '@socketsecurity/registry/lib/prompts' import { + addCoveredBuildFiles, findBuildToolCandidates, + isBuildRootCovered, realpathOrResolved, withoutDisabledFlags, } from './discover-manifest-roots.mts' @@ -189,26 +191,17 @@ export async function discoverBuildRoots({ } } -// 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 - only a properly nested subprojectDir counts as coverage at -// all; one that escapes this candidate's own directory still gets its own -// entry (see the set.add call below). 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. +// Fails closed: without the members' build files, covered and independent +// candidates can't be told apart. export async function markWorkspaceCoverage({ candidate, - coveredByEcosystem, + covered, cwd, excludePaths, rootSockJson, }: { candidate: Candidate - coveredByEcosystem: Map> + covered: Set cwd: string excludePaths?: string[] | undefined rootSockJson: SocketJson @@ -245,25 +238,7 @@ export async function markWorkspaceCoverage({ } } - const set = coveredByEcosystem.get(candidate.ecosystem) ?? new Set() - set.add(candidate.dir) - const resolvedSubprojectDirs = await Promise.all( - enumResult.projects.map(project => - realpathOrResolved(path.resolve(candidate.dir, project.subprojectDir)), - ), - ) - for (const subprojectDir of resolvedSubprojectDirs) { - // Only a genuinely nested member (a descendant of this candidate's own - // directory) should be skipped as covered by it. A subprojectDir that - // escapes this candidate's own tree (a sibling, e.g. Maven's - // `../shared-lib` or Gradle's relocated projectDir) is - // independently locatable and worth its own socket.json entry - never - // mark it covered, regardless of which candidate(s) also incorporate it. - if (subprojectDir.startsWith(`${candidate.dir}${path.sep}`)) { - set.add(subprojectDir) - } - } - coveredByEcosystem.set(candidate.ecosystem, set) + await addCoveredBuildFiles(covered, enumResult.buildRoot, enumResult.projects) return { ok: true, data: undefined } } @@ -842,7 +817,7 @@ export async function setupRecursiveManifestConfig( logger.log('re-run with --exclude-paths instead.') logger.log('') - const coveredByEcosystem = new Map>() + const covered = new Set() // 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. @@ -850,7 +825,7 @@ export async function setupRecursiveManifestConfig( // eslint-disable-next-line no-await-in-loop const coverageResult = await markWorkspaceCoverage({ candidate: { dir: realCwd, ecosystem }, - coveredByEcosystem, + covered, cwd: realCwd, excludePaths, rootSockJson, @@ -869,8 +844,7 @@ export async function setupRecursiveManifestConfig( } const orderedIncluded = sortCandidatesForDisplay(included, realCwd) for (const candidate of orderedIncluded) { - const covered = coveredByEcosystem.get(candidate.ecosystem) - if (covered?.has(candidate.dir)) { + if (isBuildRootCovered(covered, candidate.ecosystem, candidate.dir)) { counts.covered += 1 continue } @@ -902,7 +876,7 @@ export async function setupRecursiveManifestConfig( // eslint-disable-next-line no-await-in-loop const coverageResult = await markWorkspaceCoverage({ candidate, - coveredByEcosystem, + covered, cwd: realCwd, excludePaths, rootSockJson, diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index d96b3d759..6c1c32f56 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -1,14 +1,25 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' vi.mock('./discover-manifest-roots.mts', async importOriginal => { const actual = await importOriginal() return { + ...actual, 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', () => ({ @@ -52,7 +63,6 @@ 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 { @@ -217,8 +227,31 @@ describe('discoverBuildRoots', () => { }) describe('markWorkspaceCoverage', () => { - const cwd = '/repo' - const reactor = '/repo/reactor' + // Coverage counts only build files present on disk. + let cwd: string + let reactor: string + + beforeAll(async () => { + cwd = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'workspace-coverage-')), + ) + reactor = path.join(cwd, 'reactor') + for (const file of [ + 'reactor/pom.xml', + 'reactor/moduleA/pom.xml', + 'reactor/moduleB/alt.xml', + 'reactor/build/member/pom.xml', + 'shared-lib/pom.xml', + ]) { + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(path.dirname(path.join(cwd, file)), { recursive: true }) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(path.join(cwd, file), '') + } + }) + afterAll(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + }) beforeEach(() => { vi.mocked(readSocketJsonCascade).mockReset() @@ -232,70 +265,107 @@ describe('markWorkspaceCoverage', () => { vi.mocked(enumerateWorkspaces).mockReset() }) - it('marks the candidate itself plus its declared members as covered', async () => { + it('marks the build files its projects report as covered', async () => { vi.mocked(enumerateWorkspaces).mockResolvedValue({ + buildRoot: reactor, projects: [ + { + type: 'maven', + name: 'reactor', + subprojectDir: '.', + manifestFiles: [{ file: 'pom.xml' }], + children: [], + }, { type: 'maven', name: 'moduleA', subprojectDir: 'moduleA', + manifestFiles: [{ file: 'moduleA/pom.xml' }], children: [], }, { type: 'maven', name: 'moduleB', subprojectDir: 'moduleB', + manifestFiles: [{ file: 'moduleB/alt.xml' }], children: [], }, ], }) - const coveredByEcosystem = new Map>() + const covered = new Set() const result = await markWorkspaceCoverage({ candidate: { dir: reactor, ecosystem: 'maven' }, - coveredByEcosystem, + covered, cwd, rootSockJson: emptySockJson(), }) expect(result).toEqual({ ok: true, data: undefined }) - expect(coveredByEcosystem.get('maven')).toEqual( - new Set([reactor, `${reactor}/moduleA`, `${reactor}/moduleB`]), + expect(covered).toEqual( + new Set([ + `${reactor}/pom.xml`, + `${reactor}/moduleA/pom.xml`, + `${reactor}/moduleB/alt.xml`, + ]), ) }) - it('does not mark a sibling subprojectDir that escapes the candidate directory as covered', async () => { + it('resolves build files against the reported build root, not the candidate directory', async () => { vi.mocked(enumerateWorkspaces).mockResolvedValue({ + buildRoot: `${reactor}/build`, + projects: [ + { + type: 'maven', + name: 'member', + subprojectDir: 'member', + manifestFiles: [{ file: 'member/pom.xml' }], + children: [], + }, + ], + }) + const covered = new Set() + + await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + covered, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(covered).toEqual(new Set([`${reactor}/build/member/pom.xml`])) + }) + + it('does not mark a build file that escapes the build root as covered', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + buildRoot: reactor, projects: [ { type: 'maven', name: 'moduleA', subprojectDir: 'moduleA', + manifestFiles: [{ file: 'moduleA/pom.xml' }], children: [], }, { type: 'maven', name: 'shared-lib', subprojectDir: '../shared-lib', + manifestFiles: [{ file: '../shared-lib/pom.xml' }], children: [], }, ], }) - const coveredByEcosystem = new Map>() + const covered = new Set() await markWorkspaceCoverage({ candidate: { dir: reactor, ecosystem: 'maven' }, - coveredByEcosystem, + covered, cwd, rootSockJson: emptySockJson(), }) - expect(coveredByEcosystem.get('maven')).toEqual( - new Set([reactor, `${reactor}/moduleA`]), - ) - expect(coveredByEcosystem.get('maven')?.has(`${cwd}/shared-lib`)).toBe( - false, - ) + expect(covered).toEqual(new Set([`${reactor}/moduleA/pom.xml`])) }) it('does not enumerate, and marks nothing covered, for a disabled candidate', async () => { @@ -303,42 +373,44 @@ describe('markWorkspaceCoverage', () => { version: 1, defaults: { manifest: { maven: { disabled: true } } }, } as SocketJson) - const coveredByEcosystem = new Map>() + const covered = new Set() const result = await markWorkspaceCoverage({ candidate: { dir: reactor, ecosystem: 'maven' }, - coveredByEcosystem, + covered, cwd, rootSockJson: emptySockJson(), }) expect(result).toEqual({ ok: true, data: undefined }) expect(enumerateWorkspaces).not.toHaveBeenCalled() - expect(coveredByEcosystem.size).toBe(0) + expect(covered.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 covered = new Set() const result = await markWorkspaceCoverage({ candidate: { dir: reactor, ecosystem: 'maven' }, - coveredByEcosystem, + covered, cwd, rootSockJson: emptySockJson(), }) expect(result.ok).toBe(false) - expect(coveredByEcosystem.size).toBe(0) + expect(covered.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>() + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + buildRoot: reactor, + projects: [], + }) await markWorkspaceCoverage({ candidate: { dir: reactor, ecosystem: 'maven' }, - coveredByEcosystem, + covered: new Set(), cwd, excludePaths: ['reactor/moduleB'], rootSockJson: emptySockJson(), @@ -740,7 +812,10 @@ describe('setupRecursiveManifestConfig', () => { 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: [] }) + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + buildRoot: cwd, + projects: [], + }) }) it('fails closed when the scan finds no build roots anywhere', async () => { @@ -1147,6 +1222,15 @@ describe('setupRecursiveManifestConfig', () => { }) 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-')), + ) + for (const member of ['module-a', 'module-b']) { + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(path.join(cwd, member)) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(path.join(cwd, member, 'pom.xml'), '') + } // 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 @@ -1161,22 +1245,25 @@ describe('setupRecursiveManifestConfig', () => { vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => dir === cwd ? { + buildRoot: cwd, projects: [ { type: 'maven', name: 'module-a', subprojectDir: 'module-a', + manifestFiles: [{ file: 'module-a/pom.xml' }], children: [], }, { type: 'maven', name: 'module-b', subprojectDir: 'module-b', + manifestFiles: [{ file: 'module-b/pom.xml' }], children: [], }, ], } - : { projects: [] }, + : { buildRoot: dir, projects: [] }, ) const messages: string[] = [] vi.mocked(select).mockImplementation(async ({ message }) => { @@ -1190,18 +1277,22 @@ describe('setupRecursiveManifestConfig', () => { return 'inherit' }) - const result = await setupRecursiveManifestConfig(cwd, false) + try { + 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)', - ]) + 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)', + ]) + } finally { + await fs.rm(cwd, { recursive: true, force: true }) + } }) it("aborts the whole walk (fail-closed) when a candidate's workspace layout cannot be determined", async () => { @@ -1223,7 +1314,7 @@ describe('setupRecursiveManifestConfig', () => { // 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: [] }, + dir === independentService ? undefined : { buildRoot: dir, projects: [] }, ) const result = await setupRecursiveManifestConfig(cwd, false)