From f5ddbd18fcf2ffdf7b0fbb496a858591fc675c1a Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 11:59:34 +0200 Subject: [PATCH 1/2] fix(manifest): never suppress an escaping subprojectDir's own facts generation A subprojectDir that escapes its declaring reactor's own directory (e.g. Maven's ../shared-lib, or a Gradle projectDir relocation) was previously marked covered like any nested member, so whether it got its own independent facts generation depended purely on alphabetical discovery order relative to the reactor(s) that reference it. Such a path is independently locatable and potentially resolved differently on its own (e.g. a dependency version override the referencing reactor applies but a standalone build of the same directory would not), so it's a distinct, meaningful data point, not a redundant one. Only a genuine descendant of the reactor's own directory is now treated as covered. --- .../manifest/generate-recursive-manifests.mts | 20 +++++- .../generate-recursive-manifests.test.mts | 61 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) 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'), From c78e39c84bc2ca77eb728e71c93941ba0146d0c8 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 12:49:32 +0200 Subject: [PATCH 2/2] fix(manifest): apply the same escaping-subprojectDir fix to the setup wizard's coverage tracker markWorkspaceCoverage (the interactive `socket manifest setup --dynamic-sbom-inference` wizard's own reactor-coverage tracker) had the identical bug as generateRecursiveManifests: a subprojectDir escaping its declaring candidate's own directory was marked covered unconditionally, so an escaping sibling could be silently skipped rather than offered its own socket.json entry, depending on discovery order. Same fix: only mark a subprojectDir covered when it's a genuine descendant of the candidate directory that declared it. --- .../setup-recursive-manifest-config.mts | 24 +++++++++---- .../setup-recursive-manifest-config.test.mts | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) 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,