Skip to content
Merged
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
44 changes: 43 additions & 1 deletion packages/core/src/lib/scope/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
36 changes: 36 additions & 0 deletions packages/core/tests/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
agjs marked this conversation as resolved.

// 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);
});