Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/commands/manifest/generate-recursive-manifests.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<module>../shared-lib</module>` 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,
Expand Down
61 changes: 61 additions & 0 deletions src/commands/manifest/generate-recursive-manifests.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), '<project/>')
await fs.writeFile(path.join(sharedLib, 'pom.xml'), '<project/>')

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'),
Expand Down
24 changes: 17 additions & 7 deletions src/commands/manifest/setup-recursive-manifest-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
// `<module>../shared-lib</module>` 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 }
Expand Down
36 changes: 36 additions & 0 deletions src/commands/manifest/setup-recursive-manifest-config.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BuildTool, Set<string>>()

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,
Expand Down