diff --git a/packages/core/src/lib/scope/scope.ts b/packages/core/src/lib/scope/scope.ts index 9c1a571..ecaf166 100644 --- a/packages/core/src/lib/scope/scope.ts +++ b/packages/core/src/lib/scope/scope.ts @@ -36,5 +36,47 @@ export function writable(file: string, patterns: string[]): boolean { return false; } - return isInScope(file, patterns) || file.startsWith(SCRATCH_PREFIX); + if (isInScope(file, patterns) || file.startsWith(SCRATCH_PREFIX)) { + return true; + } + + // The gate's `test-sibling-required` rule makes the model add a CO-LOCATED test + // for any source file it changes. So a test sibling of an in-scope source must be + // writable too — otherwise the rule demands a file the scope forbids, the task is + // unsatisfiable, and the model thrashes to the cycle cap (observed: multi-file + // specs whose scope lists only sources, e.g. `lexer.ts`, deadlocking on + // `lexer.test.ts`). Match on the STEM across source extensions, since a `.tsx` + // source is commonly tested by a plain `.test.ts`. Only an IN-SCOPE source counts. + const stem = testStem(file); + + return ( + stem !== null && + SOURCE_EXTENSIONS.some((ext) => isInScope(`${stem}.${ext}`, patterns)) + ); +} + +/** Source extensions a co-located test may belong to — checked against the test's + * stem so `Component.test.ts` is allowed when `Component.tsx` is in scope. */ +const SOURCE_EXTENSIONS = [ + "ts", + "tsx", + "mts", + "cts", + "js", + "jsx", + "mjs", + "cjs", +]; + +/** The stem of a `*.test.*` / `*.spec.*` path (everything before `.test`/`.spec`), + * or null when `file` isn't a test file. `lexer.test.ts` → `lexer`, + * `src/Component.spec.tsx` → `src/Component`. */ +function testStem(file: string): string | null { + // Restrict to REAL test-file extensions (ts/tsx/js/jsx/mts/cts/mjs/cjs) — a loose + // `[cm]?[jt]sx?` would also match non-existent ones like `.mjsx`/`.mtsx` and widen + // the writable set. No `*tsx`/`*jsx` with a c/m prefix exists. + return ( + /^(.*)\.(?:test|spec)\.(?:tsx?|jsx?|[cm]ts|[cm]js)$/u.exec(file)?.[1] ?? + null + ); } diff --git a/packages/core/tests/scope.test.ts b/packages/core/tests/scope.test.ts index 94c67d2..a4bd62a 100644 --- a/packages/core/tests/scope.test.ts +++ b/packages/core/tests/scope.test.ts @@ -45,3 +45,39 @@ test("a path escaping the workspace is not writable (no traversal)", () => { expect(writable(out, ["**/*"])).toBe(false); expect(writable("/etc/passwd", ["**/*"])).toBe(false); }); + +// Regression: the gate's `test-sibling-required` rule makes the model add a +// co-located test for any source it changes — so the editable scope must implicitly +// allow that test sibling, or the rule demands a file the scope forbids and the +// model stalls to the cycle cap (observed live on multi-file specs: `lexer.ts` in +// scope, but `lexer.test.ts` rejected → deadlock). +test("writable allows the co-located test sibling of an in-scope source", () => { + // Multi-file spec scope: only the sources are listed. + const scope = ["lexer.ts", "parser.ts", "executor.ts", "query.ts"]; + + expect(writable("lexer.test.ts", scope)).toBe(true); + expect(writable("parser.test.ts", scope)).toBe(true); + expect(writable("src/a.spec.tsx", ["src/a.tsx"])).toBe(true); + + // A `.tsx`/`.jsx` source is commonly tested by a plain `.test.ts` — match on the + // stem across source extensions, not the test file's own extension. + expect(writable("src/Component.test.ts", ["src/Component.tsx"])).toBe(true); + expect(writable("Widget.test.ts", ["Widget.jsx"])).toBe(true); + + // But NOT a test whose source is out of scope — no arbitrary test writes. + expect(writable("pricing.test.ts", scope)).toBe(false); + expect(writable("evil.test.ts", scope)).toBe(false); + + // Only REAL test-file extensions count — a bogus `.mjsx`/`.mtsx` must not be + // treated as a test of `lexer` and slip into the writable set. + expect(writable("lexer.test.mjsx", scope)).toBe(false); + expect(writable("lexer.test.mtsx", scope)).toBe(false); + // …but the valid module variants do. + expect(writable("lexer.test.mts", scope)).toBe(true); + expect(writable("lexer.spec.js", scope)).toBe(true); + + // The source file itself is still writable; isInScope stays literal (sibling + // allowance lives in writable, not isInScope). + expect(writable("lexer.ts", scope)).toBe(true); + expect(isInScope("lexer.test.ts", scope)).toBe(false); +});