diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 979184f60..ca3246cd9 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -134,7 +134,10 @@ export function resolveEcosystemConfig( // root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's // own projects[].subprojectDir, not by pruning the whole discovered subtree, // so an unrelated nested project a reactor doesn't declare still gets its -// own invocation. Fail-closed per ecosystem, not globally: a root whose +// 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. @@ -242,7 +245,20 @@ export async function generateRecursiveManifests({ ), ) for (const subprojectDir of resolvedSubprojectDirs) { - covered.add(subprojectDir) + // 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.startsWith(`${dir}${path.sep}`)) { + covered.add(subprojectDir) + } } outcomes.push({ dir, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 8f4cb97ca..e81d65bab 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -106,6 +106,67 @@ describe('generateRecursiveManifests', () => { ) }) + it.each([ + // Escaping references must get their own independent invocation + // regardless of where they happen to sort alphabetically relative to the + // reactor that declares them - before ('aaa-shared-lib') and after + // ('zzz-shared-lib') both have to behave identically. + ['aaa-shared-lib'], + ['zzz-shared-lib'], + ])( + 'never suppresses a sibling subprojectDir that escapes its declaring reactor (name: %s)', + async sharedLibName => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'escaping-subproject-')), + ) + const reactorA = path.join(outer, 'reactor-a') + const sharedLib = path.join(outer, sharedLibName) + try { + await fs.mkdir(reactorA, { recursive: true }) + await fs.mkdir(sharedLib, { recursive: true }) + await fs.writeFile(path.join(reactorA, 'pom.xml'), '') + await fs.writeFile(path.join(sharedLib, 'pom.xml'), '') + + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => { + if (cwd === reactorA) { + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'shared-lib', + subprojectDir: `../${sharedLibName}`, + dependencies: [], + resolvedAs: [], + }, + ], + } + } + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(reactorA)).toBe('generated') + expect(byDir.get(sharedLib)).toBe('generated') + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some(([opts]) => opts.cwd === sharedLib), + ).toBe(true) + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }, + ) + it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => { vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ factsPath: path.join(cwd, '.socket.facts.json'), diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 62ccc0617..a82ede0fe 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -192,12 +192,14 @@ 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. 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. +// 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. export async function markWorkspaceCoverage({ candidate, coveredByEcosystem, @@ -251,7 +253,15 @@ export async function markWorkspaceCoverage({ ), ) for (const subprojectDir of resolvedSubprojectDirs) { - set.add(subprojectDir) + // 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) return { ok: true, data: undefined } diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 0e7d53ea9..c96e0750a 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -266,6 +266,42 @@ describe('markWorkspaceCoverage', () => { ) }) + it('does not mark a sibling subprojectDir that escapes the candidate directory as covered', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + projects: [ + { + type: 'maven', + name: 'moduleA', + subprojectDir: 'moduleA', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'shared-lib', + subprojectDir: '../shared-lib', + dependencies: [], + resolvedAs: [], + }, + ], + }) + const coveredByEcosystem = new Map>() + + await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(coveredByEcosystem.get('maven')).toEqual( + new Set([reactor, `${reactor}/moduleA`]), + ) + expect(coveredByEcosystem.get('maven')?.has(`${cwd}/shared-lib`)).toBe( + false, + ) + }) + it('does not enumerate, and marks nothing covered, for a disabled candidate', async () => { vi.mocked(readSocketJsonCascade).mockReturnValue({ version: 1,