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,