From 38627bceaf969503711dafb9690f94d85262ae71 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:23:02 +0800 Subject: [PATCH 1/5] ci(mutation-gate): forbid Stryker suppression comments in source The changed-code mutation gate already runs on every PR, but its directive policy allowed suppressions and reported malformed ones only as advisories, so `// Stryker disable next-line ...` annotations kept accumulating in production source (38 directives across 9 files on main). - Replace the directive hygiene check with assertNoDisableDirectives: any `// Stryker disable`/`// Stryker enable` comment in a touched mutation-tested file now fails the mutation-diff gate outright. - Update the CI summary guidance, the workflow step name, and the AGENTS.md / CONTRIBUTING.md policies: kill mutants with focused tests; genuinely equivalent mutants are argued in the PR body and become narrow gate-config exclusions at maintainer discretion. The 38 directives already on main are NOT removed here: their removal moves to a dedicated cleanup PR carrying per-line mutation-repro evidence (kill tests where killable, narrow gate-config exclusions where equivalent). Tests: node --test scripts/stryker-diff.test.mjs (44/44). --- .github/workflows/mutation-testing.yml | 2 +- AGENTS.md | 8 +++ CONTRIBUTING.md | 1 + scripts/stryker-diff.mjs | 36 ++++--------- scripts/stryker-diff.test.mjs | 72 ++++++++++++-------------- 5 files changed, 53 insertions(+), 66 deletions(-) diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 2da0c5c5b4..83e52182bc 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -46,7 +46,7 @@ jobs: if: github.event_name == 'pull_request' run: pnpm test:mutation-ci - - name: Enforce executable-line scope and run advisory mutation testing + - name: Enforce executable-line scope and directive policy, run advisory mutation testing if: github.event_name == 'pull_request' env: HEAD_SHA: ${{ github.sha }} diff --git a/AGENTS.md b/AGENTS.md index ce1d760892..8c8b2b8298 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,14 @@ New or substantially changed test files must not add `@typescript-eslint/no-expl - Validate with `pnpm --dir src run check-types`, the narrowest affected Vitest suite, and `pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 `; the run must not add a new entry or raise an existing count. - An exception requires a comment next to the cast explaining why no typed alternative exists and maintainer approval in review. +## Stryker Suppression Comments + +Do not add Stryker suppression comments (`// Stryker disable ...`, `// Stryker enable ...`) to source files. The changed-code mutation gate (`scripts/stryker-diff.mjs`, run by the `mutation-diff` CI job) fails any pull request that introduces them in mutation-tested files. + +- Kill surviving or uncovered mutants by adding or strengthening a focused test that fails under the mutation. +- If a mutant is genuinely equivalent, explain the equivalence in the PR description; maintainers decide on narrow gate-config exclusions. Do not add a suppression locally to get green. +- Suppressions left by earlier revisions are removed when their file is touched; do not reintroduce them. + ## Persisted Setting Checklist When adding or changing a user setting, trace the complete round trip. A setting is not complete merely because its control renders or its value reaches storage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01825e27ab..6dcaba8525 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,7 @@ pnpm install - Follow the ESLint and TypeScript best practices. - Write clear, descriptive commits that reference issues (for example, `Fixes #123`). Follow the [Writing Style](#writing-style). - Test your change fully (`npm test`). +- Do not add Stryker suppression comments (`// Stryker disable ...`). The mutation-testing CI check rejects them. Kill surviving mutants with focused tests instead. - Rebase onto the latest `main` branch before you submit. ### Submitting a Pull Request diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 9742879106..40bd42b4d7 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -74,8 +74,7 @@ const VALID_MUTANT_STATUSES = new Set(["Killed", "Timeout", "Survived", "NoCover const BLOCKING_MUTANT_STATUSES = new Set(["Survived", "NoCoverage"]) const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]) const EXCLUDED_PATH_SEGMENTS = ["/__mocks__/", "/__tests__/", "/fixtures/", "/test-utils/"] -const DISABLE_DIRECTIVE = - /^\s*\/\/\s*Stryker disable next-line ([A-Za-z][A-Za-z0-9]*(?:,[A-Za-z][A-Za-z0-9]*)*)\s*:\s*(\S.*)$/ +const SUPPRESSION_DIRECTIVE = /\/\/\s*Stryker\s+(?:disable|enable)\b/ export function parseNameStatus(output) { const tokens = output.split("\0") @@ -159,19 +158,17 @@ export function executableChangedLines(source, changedLines, filePath = "source. return executable } -export function validateDisableDirectives(source, changedLines, filePath) { +export function assertNoDisableDirectives(source, lineNumbers, filePath) { const lines = source.split(/\r?\n/) - for (const lineNumber of changedLines) { + for (const lineNumber of lineNumbers) { const line = lines[lineNumber - 1] ?? "" - if (!line.includes("Stryker disable")) continue + if (!SUPPRESSION_DIRECTIVE.test(line)) continue - const match = DISABLE_DIRECTIVE.exec(line) - if (!match || match[1].split(",").some((mutator) => mutator.toLowerCase() === "all")) { - throw new Error( - `${filePath}:${lineNumber}: mutation exclusions must use ` + - '"// Stryker disable next-line MutatorName: concrete reason"; broad or unreasoned exclusions are not allowed.', - ) - } + throw new Error( + `${filePath}:${lineNumber}: Stryker suppression comments are not allowed in this repository. ` + + "Kill the mutant with a focused test instead; if the mutant is genuinely equivalent, explain the " + + "equivalence in the pull request and a maintainer will decide on a narrow gate-config exclusion.", + ) } } @@ -225,11 +222,7 @@ export function buildManifest(entries, readSource, diffForPath) { ? new Set(Array.from({ length: sourceLineCount }, (_, index) => index + 1)) : parseChangedLines(diffForPath(entry.path)) - try { - validateDisableDirectives(source, new Set(source.split(/\r?\n/).map((_, index) => index + 1)), entry.path) - } catch (error) { - advisories.push(error.message) - } + assertNoDisableDirectives(source, new Set(source.split(/\r?\n/).map((_, index) => index + 1)), entry.path) const executableLines = executableChangedLines(source, changedLines, entry.path) if (executableLines.size === 0) continue @@ -608,14 +601,7 @@ export function formatSummary(rows, advisories, manifest = {}) { lines.push( "### Resolve a mutation gap", "", - "Add or strengthen a focused test that fails under the mutation. If the mutant is equivalent, request maintainer approval for the narrowest mutator-specific exclusion and explain why it cannot change behavior:", - "", - "```ts", - "// Stryker disable next-line ConditionalExpression: normalized input cannot reach the alternate branch", - "const result = condition ? value : fallback", - "```", - "", - "Broad `all` exclusions and exclusions without a concrete reason are reported as advisories.", + "Add or strengthen a focused test that fails under the mutation. Stryker suppression comments (`// Stryker disable ...`) are not allowed in this repository: the gate fails any pull request that introduces them. If you believe a mutant is genuinely equivalent, explain why in the pull request and a maintainer will decide on a narrow gate-config exclusion.", ) } diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 22284e598d..33d7ffd979 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -11,6 +11,7 @@ import { MAX_MUTANTS, PACKAGE_CONFIGS, appendSummary, + assertNoDisableDirectives, buildManifest, discoverRelatedTestFiles, evaluateReport, @@ -32,7 +33,6 @@ import { runManifest, selectFromGit, testsFromMutationReport, - validateDisableDirectives, } from "./stryker-diff.mjs" const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -61,7 +61,7 @@ describe("mutation testing workflow", () => { assert.ok(!workflow.includes("github.event.pull_request.base.sha")) assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url")) assert.ok(workflow.includes("open the package's mutation.html file")) - assert.ok(workflow.includes("Enforce executable-line scope and run advisory mutation testing")) + assert.ok(workflow.includes("Enforce executable-line scope and directive policy, run advisory mutation testing")) assert.equal(workflow.match(/continue-on-error: true/g)?.length, 1) assert.equal(workflow.match(/Could not write the job summary/g)?.length, 2) const script = fs.readFileSync(path.join(repositoryRoot, "scripts/stryker-diff.mjs"), "utf8") @@ -297,15 +297,16 @@ describe("buildManifest", () => { ) }) - it("reports invalid mutation exclusions without bypassing executable-line accounting", () => { - const manifest = buildManifest( - [{ status: "A", path: "packages/core/src/value.ts" }], - () => "// Stryker disable next-line all: noisy\nexport const value = true\n", - () => "", + it("fails the manifest when a mutation-tested file contains a Stryker suppression comment", () => { + assert.throws( + () => + buildManifest( + [{ status: "A", path: "packages/core/src/value.ts" }], + () => "// Stryker disable next-line all: noisy\nexport const value = true\n", + () => "", + ), + /Stryker suppression comments are not allowed/ ) - - assert.equal(manifest.packages[0].changedExecutableLines, 1) - assert.match(manifest.advisories.join("\n"), /broad or unreasoned exclusions are not allowed/) }) }) @@ -583,36 +584,26 @@ describe("selectFromGit", () => { }) }) -describe("mutation exclusions", () => { - it("allows a targeted mutator exclusion with a reason", () => { - assert.doesNotThrow(() => - validateDisableDirectives( - "// Stryker disable next-line EqualityOperator: equivalent for normalized input\nreturn value <= limit\n", - new Set([1]), - "source.ts", - ), - ) +describe("Stryker suppression directive prohibition", () => { + it("rejects every Stryker suppression comment, however well-formed", () => { + for (const source of [ + "// Stryker disable next-line EqualityOperator: equivalent for normalized input\nreturn value <= limit\n", + "// Stryker disable next-line all: noisy\nreturn value\n", + "// Stryker disable next-line EqualityOperator\nreturn value\n", + "// Stryker disable start\nreturn value\n// Stryker disable end\n", + "// Stryker enable next-line EqualityOperator: undo\nreturn value\n", + "\t\t// Stryker disable string\nreturn value\n", + ]) { + assert.throws( + () => assertNoDisableDirectives(source, new Set([1]), "source.ts"), + /Stryker suppression comments are not allowed/, + ) + } }) - it("rejects broad or unreasoned exclusions", () => { - assert.throws( - () => - validateDisableDirectives( - "// Stryker disable next-line all: noisy\nreturn value\n", - new Set([1]), - "source.ts", - ), - /broad or unreasoned exclusions are not allowed/, - ) - assert.throws( - () => - validateDisableDirectives( - "// Stryker disable next-line EqualityOperator\nreturn value\n", - new Set([1]), - "source.ts", - ), - /broad or unreasoned exclusions are not allowed/, - ) + it("accepts sources without suppression comments", () => { + assert.doesNotThrow(() => assertNoDisableDirectives("return value <= limit\n", new Set([1]), "source.ts")) + assert.doesNotThrow(() => assertNoDisableDirectives("// not a stryker comment\nreturn value\n", new Set([1]), "source.ts")) }) }) @@ -641,7 +632,7 @@ describe("failure output", () => { }, ] - it("lists every advisory mutant with tests, reproduction, exclusion, and report guidance", () => { + it("lists every advisory mutant with tests, reproduction, directive policy, and report guidance", () => { const baseSha = "a".repeat(40) const headSha = "b".repeat(40) const summary = formatSummary( @@ -672,7 +663,8 @@ describe("failure output", () => { for (const mutant of blocking) assert.ok(summary.includes(mutant.mutatorName)) assert.ok(summary.includes('"left \\| right"')) assert.ok(summary.includes(`node scripts/stryker-diff.mjs ci --base ${baseSha} --head ${headSha}`)) - assert.ok(summary.includes("Stryker disable next-line ConditionalExpression:")) + assert.ok(summary.includes("Stryker suppression comments")) + assert.ok(summary.includes("are not allowed in this repository")) assert.ok(summary.includes("`reports/mutation/extension/mutation.html`")) assert.ok(summary.includes("`changed-code-mutation-report` artifact")) assert.ok(summary.includes("### Advisory findings")) From c717e705971ecd05496ca4531db7a845a1550c4d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:33:53 +0800 Subject: [PATCH 2/5] style: prettier-format stryker-diff gate tests --- scripts/stryker-diff.test.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 33d7ffd979..e76431d9d3 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -61,7 +61,9 @@ describe("mutation testing workflow", () => { assert.ok(!workflow.includes("github.event.pull_request.base.sha")) assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url")) assert.ok(workflow.includes("open the package's mutation.html file")) - assert.ok(workflow.includes("Enforce executable-line scope and directive policy, run advisory mutation testing")) + assert.ok( + workflow.includes("Enforce executable-line scope and directive policy, run advisory mutation testing"), + ) assert.equal(workflow.match(/continue-on-error: true/g)?.length, 1) assert.equal(workflow.match(/Could not write the job summary/g)?.length, 2) const script = fs.readFileSync(path.join(repositoryRoot, "scripts/stryker-diff.mjs"), "utf8") @@ -305,7 +307,7 @@ describe("buildManifest", () => { () => "// Stryker disable next-line all: noisy\nexport const value = true\n", () => "", ), - /Stryker suppression comments are not allowed/ + /Stryker suppression comments are not allowed/, ) }) }) @@ -603,7 +605,9 @@ describe("Stryker suppression directive prohibition", () => { it("accepts sources without suppression comments", () => { assert.doesNotThrow(() => assertNoDisableDirectives("return value <= limit\n", new Set([1]), "source.ts")) - assert.doesNotThrow(() => assertNoDisableDirectives("// not a stryker comment\nreturn value\n", new Set([1]), "source.ts")) + assert.doesNotThrow(() => + assertNoDisableDirectives("// not a stryker comment\nreturn value\n", new Set([1]), "source.ts"), + ) }) }) From 39f537ee3341ee1e7bba7133461e29cad1d8b159 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:19:04 +0800 Subject: [PATCH 3/5] fix(gate): match suppression directives in comment tokens only CodeRabbit: the line-based regex also matched string literals such as "// Stryker disable all" used in documentation strings, failing CI for files with no actual directive. Scan comment trivia with the TypeScript scanner instead, flag trailing comments too, and document the legacy-directive touch requirement in CONTRIBUTING.md. Tests: node --test scripts/stryker-diff.test.mjs (45/45). --- CONTRIBUTING.md | 2 +- scripts/stryker-diff.mjs | 30 ++++++++++++++++++++---------- scripts/stryker-diff.test.mjs | 26 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dcaba8525..f2ae74270f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ pnpm install - Follow the ESLint and TypeScript best practices. - Write clear, descriptive commits that reference issues (for example, `Fixes #123`). Follow the [Writing Style](#writing-style). - Test your change fully (`npm test`). -- Do not add Stryker suppression comments (`// Stryker disable ...`). The mutation-testing CI check rejects them. Kill surviving mutants with focused tests instead. +- Do not add Stryker suppression comments (`// Stryker disable ...` or `// Stryker enable ...`). The mutation-testing CI check rejects them in any touched mutation-tested file, including directives left by earlier revisions. Remove existing directives when you touch the file. Kill surviving mutants with focused tests instead. - Rebase onto the latest `main` branch before you submit. ### Submitting a Pull Request diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 40bd42b4d7..6332bdb08c 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -159,16 +159,26 @@ export function executableChangedLines(source, changedLines, filePath = "source. } export function assertNoDisableDirectives(source, lineNumbers, filePath) { - const lines = source.split(/\r?\n/) - for (const lineNumber of lineNumbers) { - const line = lines[lineNumber - 1] ?? "" - if (!SUPPRESSION_DIRECTIVE.test(line)) continue - - throw new Error( - `${filePath}:${lineNumber}: Stryker suppression comments are not allowed in this repository. ` + - "Kill the mutant with a focused test instead; if the mutant is genuinely equivalent, explain the " + - "equivalence in the pull request and a maintainer will decide on a narrow gate-config exclusion.", - ) + // Scan comment trivia only: a string literal such as "// Stryker disable all" documents the + // directive syntax without suppressing anything, so it must not fail the gate. + const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source) + let token = scanner.scan() + while (token !== ts.SyntaxKind.EndOfFileToken) { + if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia) { + const position = scanner.getTokenPos() + const comment = source.slice(position, scanner.getTextPos()) + if (SUPPRESSION_DIRECTIVE.test(comment)) { + const lineNumber = (source.slice(0, position).match(/\n/g)?.length ?? 0) + 1 + if (lineNumbers.has(lineNumber)) { + throw new Error( + `${filePath}:${lineNumber}: Stryker suppression comments are not allowed in this repository. ` + + "Kill the mutant with a focused test instead; if the mutant is genuinely equivalent, explain the " + + "equivalence in the pull request and a maintainer will decide on a narrow gate-config exclusion.", + ) + } + } + } + token = scanner.scan() } } diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index e76431d9d3..a400426c0a 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -603,6 +603,32 @@ describe("Stryker suppression directive prohibition", () => { } }) + it("matches comment tokens only, never string literals", () => { + assert.doesNotThrow(() => + assertNoDisableDirectives( + 'export const guidance = "// Stryker disable all"\nreturn value\n', + new Set([1, 2]), + "source.ts", + ), + ) + assert.doesNotThrow(() => + assertNoDisableDirectives( + "const doc = `// Stryker disable next-line all`\nreturn value\n", + new Set([1, 2]), + "source.ts", + ), + ) + assert.throws( + () => + assertNoDisableDirectives( + "return value // Stryker disable next-line all: reason\n", + new Set([1]), + "source.ts", + ), + /Stryker suppression comments are not allowed/, + ) + }) + it("accepts sources without suppression comments", () => { assert.doesNotThrow(() => assertNoDisableDirectives("return value <= limit\n", new Set([1]), "source.ts")) assert.doesNotThrow(() => From 372f5fbff755ac6cb8459d3faa535b901111bc55 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:48:08 +0800 Subject: [PATCH 4/5] fix(gate): take directive comment ranges from the parsed source CodeRabbit: a raw ts.createScanner loop never resumes the template lexical goal after an interpolation, so text inside a template literal could be misclassified as a comment (false failure) and a directive following a completed template could be swallowed (missed failure). Collect leading and trailing comment ranges per token from the parsed source file instead, and cover both template cases plus closed nested templates with tests. CONTRIBUTING.md: state that genuinely equivalent mutants are explained in the pull request body, matching the advisory-results policy. Tests: node --test scripts/stryker-diff.test.mjs (45/45). --- CONTRIBUTING.md | 2 +- scripts/stryker-diff.mjs | 47 +++++++++++++++++++++-------------- scripts/stryker-diff.test.mjs | 27 +++++++++++++++++++- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2ae74270f..dfe873af71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ pnpm install - Follow the ESLint and TypeScript best practices. - Write clear, descriptive commits that reference issues (for example, `Fixes #123`). Follow the [Writing Style](#writing-style). - Test your change fully (`npm test`). -- Do not add Stryker suppression comments (`// Stryker disable ...` or `// Stryker enable ...`). The mutation-testing CI check rejects them in any touched mutation-tested file, including directives left by earlier revisions. Remove existing directives when you touch the file. Kill surviving mutants with focused tests instead. +- Do not add Stryker suppression comments (`// Stryker disable ...` or `// Stryker enable ...`). The mutation-testing CI check rejects them in any touched mutation-tested file, including directives left by earlier revisions. Remove existing directives when you touch the file. Kill surviving mutants with focused tests; if a mutant is genuinely equivalent, explain the equivalence in the pull request body instead. - Rebase onto the latest `main` branch before you submit. ### Submitting a Pull Request diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 6332bdb08c..865b70435a 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -159,27 +159,38 @@ export function executableChangedLines(source, changedLines, filePath = "source. } export function assertNoDisableDirectives(source, lineNumbers, filePath) { - // Scan comment trivia only: a string literal such as "// Stryker disable all" documents the - // directive syntax without suppressing anything, so it must not fail the gate. - const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source) - let token = scanner.scan() - while (token !== ts.SyntaxKind.EndOfFileToken) { - if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia) { - const position = scanner.getTokenPos() - const comment = source.slice(position, scanner.getTextPos()) - if (SUPPRESSION_DIRECTIVE.test(comment)) { - const lineNumber = (source.slice(0, position).match(/\n/g)?.length ?? 0) + 1 - if (lineNumbers.has(lineNumber)) { - throw new Error( - `${filePath}:${lineNumber}: Stryker suppression comments are not allowed in this repository. ` + - "Kill the mutant with a focused test instead; if the mutant is genuinely equivalent, explain the " + - "equivalence in the pull request and a maintainer will decide on a narrow gate-config exclusion.", - ) - } + // Comment ranges come from the parsed source: string literals and template text that mention + // the directive syntax must not fail the gate, and a raw scanner misclassifies text after a + // template interpolation because it never resumes the template lexical goal. + const scriptKind = filePath.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKind) + const seen = new Set() + function checkRanges(ranges) { + for (const range of ranges ?? []) { + if (seen.has(range.pos)) continue + seen.add(range.pos) + if (!SUPPRESSION_DIRECTIVE.test(source.slice(range.pos, range.end))) continue + const lineNumber = (source.slice(0, range.pos).match(/\n/g)?.length ?? 0) + 1 + if (lineNumbers.has(lineNumber)) { + throw new Error( + `${filePath}:${lineNumber}: Stryker suppression comments are not allowed in this repository. ` + + "Kill the mutant with a focused test instead; if the mutant is genuinely equivalent, explain the " + + "equivalence in the pull request and a maintainer will decide on a narrow gate-config exclusion.", + ) + } + } + } + function visitTokens(node) { + for (const child of node.getChildren(sourceFile)) { + if (ts.isToken(child)) { + checkRanges(ts.getLeadingCommentRanges(source, child.pos)) + checkRanges(ts.getTrailingCommentRanges(source, child.end)) + } else { + visitTokens(child) } } - token = scanner.scan() } + visitTokens(sourceFile) } export function toRanges(lines) { diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index a400426c0a..a6f6f061fa 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -603,7 +603,7 @@ describe("Stryker suppression directive prohibition", () => { } }) - it("matches comment tokens only, never string literals", () => { + it("matches comment tokens only, never string literals or template text", () => { assert.doesNotThrow(() => assertNoDisableDirectives( 'export const guidance = "// Stryker disable all"\nreturn value\n', @@ -618,6 +618,31 @@ describe("Stryker suppression directive prohibition", () => { "source.ts", ), ) + assert.doesNotThrow(() => + assertNoDisableDirectives( + "const text = `${value} // Stryker disable all`\nreturn value\n", + new Set([1, 2]), + "source.ts", + ), + ) + assert.throws( + () => + assertNoDisableDirectives( + "const text = `${value}`\n// Stryker disable next-line all: reason\nreturn value\n", + new Set([1, 2, 3]), + "source.ts", + ), + /Stryker suppression comments are not allowed/, + ) + assert.throws( + () => + assertNoDisableDirectives( + "const a = `x${`y${z}`} tail` // Stryker disable all: reason\nreturn value\n", + new Set([1, 2]), + "source.ts", + ), + /Stryker suppression comments are not allowed/, + ) assert.throws( () => assertNoDisableDirectives( From 052b9e789e7f172affe6bc236f6f077ecdbbe0fb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:58:11 +0800 Subject: [PATCH 5/5] fix(gate): ignore directive-looking JSX text in tsx sources CodeRabbit: getLeadingCommentRanges is text-based, so rendered JSX text such as "
// Stryker disable all" produced a comment range and failed a touched .tsx file. Skip ranges whose token is JsxText; comments inside JSX expression containers ({// ...}) still resolve to a real comment and remain rejected. Tests: node --test scripts/stryker-diff.test.mjs (46/46). --- scripts/stryker-diff.mjs | 3 +++ scripts/stryker-diff.test.mjs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 865b70435a..5a8c5f8b22 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -169,6 +169,9 @@ export function assertNoDisableDirectives(source, lineNumbers, filePath) { for (const range of ranges ?? []) { if (seen.has(range.pos)) continue seen.add(range.pos) + // getLeadingCommentRanges is text-based: inside JSX, directive-looking rendered text + // (e.g.
// Stryker disable all) is JsxText, not a comment, and must not fail. + if (ts.getTokenAtPosition(sourceFile, range.pos).kind === ts.SyntaxKind.JsxText) continue if (!SUPPRESSION_DIRECTIVE.test(source.slice(range.pos, range.end))) continue const lineNumber = (source.slice(0, range.pos).match(/\n/g)?.length ?? 0) + 1 if (lineNumbers.has(lineNumber)) { diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index a6f6f061fa..900b0dd39a 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -654,6 +654,25 @@ describe("Stryker suppression directive prohibition", () => { ) }) + it("ignores directive-looking JSX text in tsx sources", () => { + assert.doesNotThrow(() => + assertNoDisableDirectives( + "const el =
// Stryker disable all\n
\n", + new Set([1, 2]), + "source.tsx", + ), + ) + assert.throws( + () => + assertNoDisableDirectives( + "const el =
{ // Stryker disable next-line all: reason\nx}
\n", + new Set([1, 2]), + "source.tsx", + ), + /Stryker suppression comments are not allowed/, + ) + }) + it("accepts sources without suppression comments", () => { assert.doesNotThrow(() => assertNoDisableDirectives("return value <= limit\n", new Set([1]), "source.ts")) assert.doesNotThrow(() =>