From d2a970ca00be5222d8ea7569ad5805f33fe5dffe Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 28 Jun 2026 22:58:53 +0200 Subject: [PATCH 1/3] fix(scope): allow the co-located test sibling of an in-scope source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a discovery eval sweep: 2 of 4 multi-file seeds (query, checkout) dead-stalled to the cycle cap on test-sibling-required. Root cause is a harness contradiction — the gate DEMANDS the model add a co-located test (e.g. lexer.test.ts) for a logic file it changed, but the editable scope lists only the sources (lexer.ts, parser.ts, ...), so writable() REJECTED the very file the rule required. Unsatisfiable → guaranteed thrash. (Single-file seeds escaped it: their one acceptance test is provided, satisfying the sibling.) Fix: writable() now also allows a *.test/*.spec sibling whose underlying source is in scope (co-located). isInScope stays literal; only writable widens. Can't write a test for an out-of-scope source. Tests added. --- packages/core/src/lib/scope/scope.ts | 29 +++++++++++++++++++++++++++- packages/core/tests/scope.test.ts | 23 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lib/scope/scope.ts b/packages/core/src/lib/scope/scope.ts index 9c1a571..8b2b93b 100644 --- a/packages/core/src/lib/scope/scope.ts +++ b/packages/core/src/lib/scope/scope.ts @@ -36,5 +36,32 @@ 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`). Only the test of an IN-SCOPE source is allowed. + const source = testSibling(file); + + return source !== null && isInScope(source, patterns); +} + +/** The source path a `*.test.*` / `*.spec.*` file tests (strip the test/spec + * segment), or null when `file` isn't a test file. `lexer.test.ts` → `lexer.ts`, + * `src/a.spec.tsx` → `src/a.tsx`. */ +function testSibling(file: string): string | null { + const m = /^(.*)\.(?:test|spec)(\.[cm]?[jt]sx?)$/u.exec(file); + + if (m === null) { + return null; + } + + const [, base, ext] = m; + + return base === undefined || ext === undefined ? null : `${base}${ext}`; } diff --git a/packages/core/tests/scope.test.ts b/packages/core/tests/scope.test.ts index 94c67d2..fcf52f4 100644 --- a/packages/core/tests/scope.test.ts +++ b/packages/core/tests/scope.test.ts @@ -45,3 +45,26 @@ 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); + + // 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); + + // 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); +}); From 243406f62c26c1dd4127d755d01ad5a2625a7b68 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 28 Jun 2026 23:03:34 +0200 Subject: [PATCH 2/3] fix(scope): match test sibling on stem across source extensions (Gemini review) A .tsx/.jsx source is commonly tested by a plain .test.ts. Match the test's STEM against source extensions (ts/tsx/mts/cts/js/jsx/mjs/cjs) rather than keeping the test file's own extension, so Component.test.ts is allowed when Component.tsx is in scope. Test added. --- packages/core/src/lib/scope/scope.ts | 39 +++++++++++++++++----------- packages/core/tests/scope.test.ts | 5 ++++ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/core/src/lib/scope/scope.ts b/packages/core/src/lib/scope/scope.ts index 8b2b93b..de55d69 100644 --- a/packages/core/src/lib/scope/scope.ts +++ b/packages/core/src/lib/scope/scope.ts @@ -45,23 +45,32 @@ export function writable(file: string, patterns: string[]): boolean { // 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`). Only the test of an IN-SCOPE source is allowed. - const source = testSibling(file); + // `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 source !== null && isInScope(source, patterns); + return ( + stem !== null && + SOURCE_EXTENSIONS.some((ext) => isInScope(`${stem}.${ext}`, patterns)) + ); } -/** The source path a `*.test.*` / `*.spec.*` file tests (strip the test/spec - * segment), or null when `file` isn't a test file. `lexer.test.ts` → `lexer.ts`, - * `src/a.spec.tsx` → `src/a.tsx`. */ -function testSibling(file: string): string | null { - const m = /^(.*)\.(?:test|spec)(\.[cm]?[jt]sx?)$/u.exec(file); +/** 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", +]; - if (m === null) { - return null; - } - - const [, base, ext] = m; - - return base === undefined || ext === undefined ? null : `${base}${ext}`; +/** 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 { + return /^(.*)\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u.exec(file)?.[1] ?? null; } diff --git a/packages/core/tests/scope.test.ts b/packages/core/tests/scope.test.ts index fcf52f4..e475422 100644 --- a/packages/core/tests/scope.test.ts +++ b/packages/core/tests/scope.test.ts @@ -59,6 +59,11 @@ test("writable allows the co-located test sibling of an in-scope source", () => 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); From a8bc1b946105578e5972d15dfd68191cbbedad83 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 29 Jun 2026 00:39:51 +0200 Subject: [PATCH 3/3] fix(scope): restrict test-sibling match to real extensions (Copilot review) The loose [cm]?[jt]sx? also matched non-existent extensions (.mjsx/.mtsx), widening the writable set (lexer.test.mjsx accepted when lexer.ts in scope). Pin to the real test-file extensions: ts/tsx/js/jsx + [cm]ts/[cm]js. Tests for both the rejected bogus extensions and the valid module variants. --- packages/core/src/lib/scope/scope.ts | 8 +++++++- packages/core/tests/scope.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lib/scope/scope.ts b/packages/core/src/lib/scope/scope.ts index de55d69..ecaf166 100644 --- a/packages/core/src/lib/scope/scope.ts +++ b/packages/core/src/lib/scope/scope.ts @@ -72,5 +72,11 @@ const SOURCE_EXTENSIONS = [ * 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 { - return /^(.*)\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u.exec(file)?.[1] ?? 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 e475422..a4bd62a 100644 --- a/packages/core/tests/scope.test.ts +++ b/packages/core/tests/scope.test.ts @@ -68,6 +68,14 @@ test("writable allows the co-located test sibling of an in-scope source", () => 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);