Skip to content

Commit f0c931a

Browse files
FE-871: monorepo-robust test-dir + workspace runner detection (slice 4)
Amp-Thread-ID: https://ampcode.com/threads/T-019ecb9a-9a08-733b-833d-76885fc8243a Co-authored-by: Amp <amp@ampcode.com>
1 parent 6adb3a8 commit f0c931a

3 files changed

Lines changed: 170 additions & 22 deletions

File tree

memory/PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,11 +406,11 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
406406
- **Name:** Brunch toolchain detection — read the project toolchain from the repo
407407
- **Linear:** FE-871 · branch `ka/fe-871-brunch-detect` (stacked on FE-867)
408408
- **Kind:** bounded feature
409-
- **Status:** done (FE-871). Slice 1 — `detectProfile(repoDir)` / `project-detect.ts`: a pure, evidence-first detector mapping manifests/lockfiles to a registry `ProfileId` (bun lockfile → bun; deno config → deno; `package.json` vitest/jest/none → node-vitest/node-jest/node-test). One clear supported signal resolves; ambiguous evidence (both vitest **and** jest declared) and any repo with no JS/TS evidence return a loud `{detected:false, reason}` via one catch-all rather than silently defaulting to bun — the cheap "which lockfile is present" check, not a language-detection engine (no per-stack Python/Go branches; the catch-all message is already actionable). Slice 2 — `detected` is wired into the `plan-emitter` selection chain as the brownfield front (`flag ≫ detected (brownfield) ≫ spec ≫ architect-classified ≫ bun`) via `resolveEmittedProfile`; a loud detection failure throws rather than silently falling to bun (falling through to an explicit spec/architect choice first). Greenfield (or brownfield without a `repoDir`) keeps the unchanged FE-843 chain — the greenfield no-op. `repoDir` threads CLI launch cwd → `runPlan` → `emitPlanFromSnapshot`; an injectable `detect` seam keeps the emitter tests hermetic. Slice 3 — `detectTestDir(repoDir)` co-locates generated tests where the brownfield repo already keeps its own: detection picks the *runner* (profile), this picks the *path*. A profile's default test directory (`tests/{id}.test.ts`) can fall outside a host repo whose vitest `include` is narrowed (e.g. `src/**`), so the chosen path is unrunnable — vitest reports "No test files found" for an explicitly-named file (observed in a real brownfield cook). Rather than parse the runner's executable-TS config, it samples existing `*.test.*`/`*.spec.*` files (zero-dep bounded `fs` walk, skipping `node_modules`/build dirs) and returns the dominant top-level directory; `withTestDir(toolchain, dir)` relocates the targets while preserving the filename convention. Brownfield-only; `null` (no existing tests) keeps the profile default; greenfield never relocates. Stacked on `agent-extension-host`.
409+
- **Status:** done (FE-871). Slice 1 — `detectProfile(repoDir)` / `project-detect.ts`: a pure, evidence-first detector mapping manifests/lockfiles to a registry `ProfileId` (bun lockfile → bun; deno config → deno; `package.json` vitest/jest/none → node-vitest/node-jest/node-test). One clear supported signal resolves; ambiguous evidence (both vitest **and** jest declared) and any repo with no JS/TS evidence return a loud `{detected:false, reason}` via one catch-all rather than silently defaulting to bun — the cheap "which lockfile is present" check, not a language-detection engine (no per-stack Python/Go branches; the catch-all message is already actionable). Slice 2 — `detected` is wired into the `plan-emitter` selection chain as the brownfield front (`flag ≫ detected (brownfield) ≫ spec ≫ architect-classified ≫ bun`) via `resolveEmittedProfile`; a loud detection failure throws rather than silently falling to bun (falling through to an explicit spec/architect choice first). Greenfield (or brownfield without a `repoDir`) keeps the unchanged FE-843 chain — the greenfield no-op. `repoDir` threads CLI launch cwd → `runPlan` → `emitPlanFromSnapshot`; an injectable `detect` seam keeps the emitter tests hermetic. Slice 3 — `detectTestDir(repoDir)` co-locates generated tests where the brownfield repo already keeps its own: detection picks the *runner* (profile), this picks the *path*. A profile's default test directory (`tests/{id}.test.ts`) can fall outside a host repo whose vitest `include` is narrowed (e.g. `src/**`), so the chosen path is unrunnable — vitest reports "No test files found" for an explicitly-named file (observed in a real brownfield cook). Rather than parse the runner's executable-TS config, it samples existing `*.test.*`/`*.spec.*` files (zero-dep bounded `fs` walk, skipping `node_modules`/build dirs) and returns the dominant directory; `withTestDir(toolchain, dir)` relocates the targets while preserving the filename convention. Brownfield-only; `null` (no existing tests) keeps the profile default; greenfield never relocates. Slice 4 — monorepo hardening: `detectTestDir` returns the dominant *full* directory (not just the top segment) so a package-rooted include glob still covers the path; `detectProfile` widens runner detection to declared workspace packages (npm/yarn `workspaces`, pnpm `pnpm-workspace.yaml`; literal + single-level `dir/*` globs) **only when the root declares no runner**, scoped to declared workspaces so a stray nested project (docs prototype, example app) can't poison detection — a root runner still wins without scanning, and workspaces collectively declaring both vitest+jest stays loudly ambiguous. Stacked on `agent-extension-host`.
410410
- **Objective:** Resolve a registry `ProfileId` at **plan time** from the repo's manifest/lockfile evidence — the cheap "which lockfile/manifest is present" check, mapping only to ids already in the FE-843 registry. It is **not** a language-detection engine: anything without a single clear supported signal (ambiguous JS runners, or non-JS stacks like Python/Go) returns a loud `{detected:false}` reason via one actionable catch-all, never a guessed profile. Brownfield-only front of the selection chain (`flag ≫ detected ≫ spec ≫ architect ≫ bun`); the resolved id is stamped into `plan.yaml` so `brunch cook` runs the same toolchain. Greenfield never detects (empty worktree). Resolves toolchain **identity** only — real file paths / existing wiring / `writes` reconciliation is cook-time agent grounding, out of scope here.
411411
- **Why now / unlocks:** The "no manual steps" goal requires reading the real toolchain rather than inferring from spec prose or a `--profile` flag — and it must happen at plan time, because the deterministic test runner reads the stamped `plan.profile` with **no agent in the loop** (`cook-cli.ts`, `pi-actions.ts`), so a wrong default runs the wrong test command with no diagnostic. The cook agent's `read`/`bash` cannot substitute. FE-843 built the registry but deferred detection; this closes that gap.
412412
- **Acceptance:** (1) detection maps a real repo to a registry profile id from manifest/lockfile evidence *(slice 1, done)*; (2) brownfield cook/plan resolves toolchain via detection at the front of the FE-843 chain (`--profile` still overrides) *(slice 2)*; (3) greenfield resolution is unchanged (no detection input); (4) ambiguous/unknown repo fails with an actionable message, not a silent default *(slice 1, done)*; (5) the 3 reference fixtures + greenfield smoke score identically before/after.
413-
- **Verification:** detector unit tests *(slice 1, done — per-stack fixtures + loud `{detected:false}`)*; slice 2: resolution-chain precedence tests (detect vs flag vs spec) + greenfield no-op / before-after-identical test; slice 3: `detectTestDir` clustering/skip/null tests + `withTestDir` relocation tests + emitter tests asserting brownfield targets follow the detected dir while greenfield keeps the profile default.
413+
- **Verification:** detector unit tests *(slice 1, done — per-stack fixtures + loud `{detected:false}`)*; slice 2: resolution-chain precedence tests (detect vs flag vs spec) + greenfield no-op / before-after-identical test; slice 3: `detectTestDir` clustering/skip/null tests + `withTestDir` relocation tests + emitter tests asserting brownfield targets follow the detected dir while greenfield keeps the profile default; slice 4: full-dir/monorepo `detectTestDir` tests + workspace runner-detection tests (npm/yarn/pnpm, root-wins, literal dir, cross-workspace ambiguity).
414414
- **Depends on:** `toolchain-profile-expansion` (FE-843).
415415
- **Traceability:** Requirements 46–50; refines I130-K; greenfield-protecting invariant (new — record in SPEC via ln-sync). **D160-K boundary:** detection is plan-time profile-*id* resolution (an input to authoring), not architect host-introspection — D160-K constrains the architect/authoring stage, not profile resolution, so `brunch-detect` needs no D160-K amendment.
416416
- **Design docs:** `docs/design/orchestrator.md`.

src/orchestrator/src/project-detect.test.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,24 @@ describe('detectProfile fails loudly rather than defaulting silently', () => {
8888
});
8989

9090
describe('detectTestDir learns the test directory from existing test files', () => {
91-
it('returns the top-level directory tests cluster in (src/**)', () => {
91+
it('returns the full directory tests cluster in, not just the top segment', () => {
9292
const dir = repo({
93-
'src/foo.test.ts': '',
9493
'src/lib/bar.test.ts': '',
94+
'src/lib/qux.test.ts': '',
95+
'src/foo.test.ts': '',
9596
'src/lib/baz.ts': '',
9697
});
97-
expect(detectTestDir(dir)).toBe('src');
98+
// src/lib has 2 test files, src has 1 → the deeper, dominant dir wins.
99+
expect(detectTestDir(dir)).toBe('src/lib');
100+
});
101+
102+
it('returns a deep monorepo test root so a package-rooted include still covers it', () => {
103+
const dir = repo({
104+
'packages/app/src/a.test.ts': '',
105+
'packages/app/src/b.test.ts': '',
106+
'packages/lib/src/c.test.ts': '',
107+
});
108+
expect(detectTestDir(dir)).toBe('packages/app/src');
98109
});
99110

100111
it('picks the dominant directory when tests are split across several', () => {
@@ -129,3 +140,50 @@ describe('detectTestDir learns the test directory from existing test files', ()
129140
expect(detectTestDir(repo({ 'root.test.ts': '' }))).toBeNull();
130141
});
131142
});
143+
144+
describe('detectProfile resolves the runner from workspace packages in a monorepo', () => {
145+
it('finds vitest in a workspace package when the root declares no runner', () => {
146+
const dir = repo({
147+
'package.json': JSON.stringify({ workspaces: ['packages/*'] }),
148+
'packages/app/package.json': pkg({ vitest: '^2.0.0' }),
149+
'packages/lib/package.json': pkg({ typescript: '^5.0.0' }),
150+
});
151+
expect(detectProfile(dir)).toMatchObject({ detected: true, profile: 'node-vitest' });
152+
});
153+
154+
it('finds the runner via a pnpm-workspace.yaml package list', () => {
155+
const dir = repo({
156+
'package.json': JSON.stringify({ name: 'root' }),
157+
'pnpm-workspace.yaml': "packages:\n - 'packages/*'\n",
158+
'packages/web/package.json': pkg({ jest: '^29.0.0' }),
159+
});
160+
expect(detectProfile(dir)).toMatchObject({ detected: true, profile: 'node-jest' });
161+
});
162+
163+
it('a root runner wins without scanning (and a workspace cannot make it ambiguous)', () => {
164+
const dir = repo({
165+
'package.json': JSON.stringify({ workspaces: ['packages/*'], devDependencies: { vitest: '^2.0.0' } }),
166+
'packages/legacy/package.json': pkg({ jest: '^29.0.0' }),
167+
});
168+
expect(detectProfile(dir)).toMatchObject({ detected: true, profile: 'node-vitest' });
169+
});
170+
171+
it('workspaces collectively declaring both runners is ambiguous, not silently picked', () => {
172+
const dir = repo({
173+
'package.json': JSON.stringify({ workspaces: ['packages/*'] }),
174+
'packages/a/package.json': pkg({ vitest: '^2.0.0' }),
175+
'packages/b/package.json': pkg({ jest: '^29.0.0' }),
176+
});
177+
const result = detectProfile(dir);
178+
expect(result.detected).toBe(false);
179+
expect(!result.detected && result.reason).toMatch(/ambiguous/i);
180+
});
181+
182+
it('a literal (non-wildcard) workspace directory is resolved', () => {
183+
const dir = repo({
184+
'package.json': JSON.stringify({ workspaces: ['apps/web'] }),
185+
'apps/web/package.json': pkg({ vitest: '^2.0.0' }),
186+
});
187+
expect(detectProfile(dir)).toMatchObject({ detected: true, profile: 'node-vitest' });
188+
});
189+
});

src/orchestrator/src/project-detect.ts

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,76 @@ function readPackageJsonDeps(dir: string): Set<string> | null {
4242
}
4343
}
4444

45+
/**
46+
* Workspace globs declared by a monorepo root — npm/yarn `workspaces` (array or
47+
* `{ packages }`) or pnpm `pnpm-workspace.yaml`. Empty when the repo is not a
48+
* declared monorepo. Scoping to *declared* workspaces (not every package.json on
49+
* disk) keeps a stray nested project — a docs prototype, an example app — from
50+
* poisoning runner detection.
51+
*/
52+
function readWorkspaceGlobs(repoDir: string): string[] {
53+
const pkgPath = join(repoDir, 'package.json');
54+
if (existsSync(pkgPath)) {
55+
try {
56+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
57+
workspaces?: string[] | { packages?: string[] };
58+
};
59+
const ws = pkg.workspaces;
60+
if (Array.isArray(ws)) return ws;
61+
if (ws && Array.isArray(ws.packages)) return ws.packages;
62+
} catch {
63+
// Malformed root package.json: fall through to the pnpm manifest.
64+
}
65+
}
66+
const pnpmPath = join(repoDir, 'pnpm-workspace.yaml');
67+
if (existsSync(pnpmPath)) {
68+
try {
69+
const globs: string[] = [];
70+
for (const line of readFileSync(pnpmPath, 'utf8').split('\n')) {
71+
const match = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/.exec(line);
72+
if (match) globs.push(match[1].trim());
73+
}
74+
return globs;
75+
} catch {
76+
return [];
77+
}
78+
}
79+
return [];
80+
}
81+
82+
/**
83+
* Resolve a workspace glob to concrete package directories. Handles the two
84+
* forms that cover virtually all real monorepos — a literal directory (`apps/web`)
85+
* and a single-level wildcard (`packages/*`). Deeper/exotic globs are skipped:
86+
* this is the cheap evidence check, not a glob engine.
87+
*/
88+
function resolveWorkspaceDirs(repoDir: string, glob: string): string[] {
89+
const trimmed = glob.replace(/\/+$/, '');
90+
if (trimmed.endsWith('/*')) {
91+
const base = trimmed.slice(0, -2);
92+
try {
93+
return readdirSync(join(repoDir, base), { withFileTypes: true })
94+
.filter((entry) => entry.isDirectory())
95+
.map((entry) => join(base, entry.name));
96+
} catch {
97+
return [];
98+
}
99+
}
100+
return trimmed.includes('*') ? [] : [trimmed];
101+
}
102+
103+
/** Union of dependency names declared across a monorepo root's workspace packages. */
104+
function collectWorkspaceDeps(repoDir: string): Set<string> {
105+
const deps = new Set<string>();
106+
for (const glob of readWorkspaceGlobs(repoDir)) {
107+
for (const wsDir of resolveWorkspaceDirs(repoDir, glob)) {
108+
const wsDeps = readPackageJsonDeps(join(repoDir, wsDir));
109+
if (wsDeps) for (const dep of wsDeps) deps.add(dep);
110+
}
111+
}
112+
return deps;
113+
}
114+
45115
/**
46116
* Detect the toolchain `ProfileId` for a repo by introspecting its manifests and
47117
* lockfiles. Precedence is lockfile/config evidence first (most authoritative),
@@ -60,23 +130,38 @@ export function detectProfile(repoDir: string): ProfileDetection {
60130
}
61131

62132
// Node/TypeScript: pick the runner from declared dependencies.
63-
const deps = readPackageJsonDeps(repoDir);
64-
if (deps !== null) {
133+
const rootDeps = readPackageJsonDeps(repoDir);
134+
if (rootDeps !== null) {
135+
// Root deps are most authoritative. Only when the root declares no runner do
136+
// we widen to the monorepo's workspace packages — a monorepo root often holds
137+
// just tooling while the runner lives in each package. A repo that already
138+
// resolves at the root never pays the workspace scan and can't be made
139+
// ambiguous by a workspace.
140+
let deps = rootDeps;
141+
let source = 'package.json';
142+
if (!rootDeps.has('vitest') && !rootDeps.has('jest')) {
143+
const wsDeps = collectWorkspaceDeps(repoDir);
144+
if (wsDeps.has('vitest') || wsDeps.has('jest')) {
145+
deps = wsDeps;
146+
source = 'workspace package.json';
147+
}
148+
}
149+
65150
const hasVitest = deps.has('vitest');
66151
const hasJest = deps.has('jest');
67152
// Two declared runners is genuinely ambiguous — picking one by check-order
68153
// would silently run the wrong command. Fail loud and let `--profile` decide.
69154
if (hasVitest && hasJest) {
70155
return {
71156
detected: false,
72-
reason: `package.json declares both vitest and jest — ambiguous test runner. Pass --profile to pick node-vitest or node-jest.`,
157+
reason: `${source} declares both vitest and jest — ambiguous test runner. Pass --profile to pick node-vitest or node-jest.`,
73158
};
74159
}
75160
if (hasVitest) {
76-
return { detected: true, profile: 'node-vitest', evidence: 'package.json devDependency vitest' };
161+
return { detected: true, profile: 'node-vitest', evidence: `${source} devDependency vitest` };
77162
}
78163
if (hasJest) {
79-
return { detected: true, profile: 'node-jest', evidence: 'package.json devDependency jest' };
164+
return { detected: true, profile: 'node-jest', evidence: `${source} devDependency jest` };
80165
}
81166
// No third-party runner declared → the built-in node:test runner needs none.
82167
return {
@@ -118,17 +203,21 @@ const MAX_WALK_DEPTH = 8;
118203
* path falls outside a repo whose vitest `include` is narrowed to `src/**`
119204
* (vitest then reports "No test files found" for an explicitly-named file).
120205
*
121-
* Returns the dominant top-level segment (e.g. `'src'`), or `null` when the repo
122-
* has no test files to learn from — cook then keeps the profile's default path.
206+
* Returns the POSIX-relative directory tests cluster in (e.g. `'src'`, or
207+
* `'packages/app/src'` in a monorepo), or `null` when the repo has no test files
208+
* to learn from — cook then keeps the profile's default path. The *full*
209+
* directory (not just the top segment) is returned so a monorepo whose runner
210+
* include is rooted deep (e.g. a per-package `src` glob) still gets a covered
211+
* path.
123212
*/
124213
export function detectTestDir(repoDir: string): string | null {
125-
// Tally test files by their top-level directory segment relative to the repo
126-
// root, because runner include globs are conventionally rooted there
127-
// (`src/**`, `tests/**`). Files directly at the root (segment '') don't teach
128-
// us a directory, so they're ignored.
214+
// Tally test files by their full directory relative to the repo root. Files
215+
// directly at the root (relDir '') don't teach us a directory, so they're
216+
// ignored. Keys are POSIX paths so the emitted target matches profile
217+
// conventions regardless of host separator.
129218
const counts = new Map<string, number>();
130219

131-
const walk = (dir: string, depth: number, topSegment: string | null): void => {
220+
const walk = (dir: string, depth: number, relDir: string): void => {
132221
if (depth > MAX_WALK_DEPTH) return;
133222
let entries: Dirent[];
134223
try {
@@ -139,15 +228,16 @@ export function detectTestDir(repoDir: string): string | null {
139228
for (const entry of entries) {
140229
if (entry.isDirectory()) {
141230
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
142-
walk(join(dir, entry.name), depth + 1, topSegment ?? entry.name);
143-
} else if (topSegment !== null && entry.isFile() && TEST_FILE_RE.test(entry.name)) {
144-
counts.set(topSegment, (counts.get(topSegment) ?? 0) + 1);
231+
walk(join(dir, entry.name), depth + 1, relDir === '' ? entry.name : `${relDir}/${entry.name}`);
232+
} else if (relDir !== '' && entry.isFile() && TEST_FILE_RE.test(entry.name)) {
233+
counts.set(relDir, (counts.get(relDir) ?? 0) + 1);
145234
}
146235
}
147236
};
148-
walk(repoDir, 0, null);
237+
walk(repoDir, 0, '');
149238

150239
if (counts.size === 0) return null;
151-
// Dominant directory wins; ties broken by name for determinism.
240+
// Dominant directory wins; ties broken by name (shallower/earlier first) for
241+
// determinism.
152242
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0];
153243
}

0 commit comments

Comments
 (0)