From a6aa1cf04d98cab6e14f6efb059d60f9fa18f665 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:10:30 +0800 Subject: [PATCH 1/9] chore(mutation-gate): remove all Stryker directives with evidence-backed exclusions The 38 suppression directives on main are removed. Every previously-suppressed line was re-exposed to Stryker first (mutation repro at the #2051 branch head: 100 mutants over the 38 lines). 65 are killed by existing tests - the directives were unnecessary - and the 33 survivors are genuinely equivalent; they are accepted centrally in scripts/stryker-equivalences.json (26 file+line+mutator entries) instead of as in-source comments. - Remove 42 directive comment lines from 9 files (comment-only diff). - Add scripts/stryker-equivalences.json with the equivalence argument per entry. - stryker-diff.mjs: applyEquivalences() drops accepted equivalents from the blocking set, the summary gains an Equivalent column, and registry entries whose file is under test but whose mutant is gone surface as a stale-entry advisory so the registry cannot rot silently. Evidence per file (mutants over the previously-suppressed lines): lm-studio 10/10 killed; qwen-code 10/10 killed; webviewMessageHandler 2/2 killed; pathUtils 9 survivors (defensive bounds, fail-closed guards); abort-signal 2 (once flag unobservable); NativeToolCallParser 2 (GC-only); openai 2 (&& vs ?? via parameter default); openai-codex 2 (abort microtask windows); Task.ts 16 survivors (unreachable guards on the raw-chunk pipeline). Tests: node --test scripts/stryker-diff.test.mjs (51/51). --- scripts/stryker-diff.mjs | 54 +++++- scripts/stryker-diff.test.mjs | 91 ++++++++++ scripts/stryker-equivalences.json | 161 ++++++++++++++++++ src/api/providers/lm-studio.ts | 5 - src/api/providers/openai-codex.ts | 2 - src/api/providers/openai.ts | 6 - src/api/providers/qwen-code.ts | 5 - src/api/providers/utils/abort-signal.ts | 1 - .../assistant-message/NativeToolCallParser.ts | 2 - src/core/task/Task.ts | 7 - src/core/webview/webviewMessageHandler.ts | 2 - src/utils/pathUtils.ts | 11 -- 12 files changed, 300 insertions(+), 47 deletions(-) create mode 100644 scripts/stryker-equivalences.json diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index dd64f4e774..eabd5642be 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -465,6 +465,36 @@ export function mutantCounts(report) { return counts } +export function loadEquivalences(repoRoot) { + const file = path.join(repoRoot, "scripts/stryker-equivalences.json") + if (!fs.existsSync(file)) return [] + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) + return parsed.equivalences ?? [] +} + +export function applyEquivalences(counts, equivalences, packageEntry, reportedFiles = new Set()) { + const index = new Map(equivalences.map((entry) => [`${entry.file}:${entry.line}:${entry.mutator}`, entry])) + const used = new Set() + const blocking = [] + let equivalent = 0 + for (const mutant of counts.blocking) { + const key = `${packageEntry.root}/${mutant.filePath}:${mutant.location?.start?.line}:${mutant.mutatorName}` + if (index.has(key)) { + used.add(key) + equivalent++ + } else { + blocking.push(mutant) + } + } + const unusedEquivalences = equivalences.filter( + (entry) => + entry.file.startsWith(packageEntry.sourceRoot) && + reportedFiles.has(entry.file) && + !used.has(`${entry.file}:${entry.line}:${entry.mutator}`), + ) + return { ...counts, blocking, equivalent, unusedEquivalences } +} + function escapeWorkflowData(value) { return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A") } @@ -569,15 +599,15 @@ export function formatSummary(rows, advisories, manifest = {}) { const lines = [ "## Changed-code mutation testing", "", - "| Package | Changed executable lines | Valid | Killed | Timeout | Survived | No coverage | Result |", + "| Package | Changed executable lines | Valid | Killed | Timeout | Survived | No coverage | Equivalent | Result |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", ] for (const row of rows) { lines.push( - `| ${row.id} | ${row.changedLines} | ${row.valid} | ${row.killed} | ${row.timeout} | ${row.survived} | ${row.noCoverage} | ${row.result} |`, + `| ${row.id} | ${row.changedLines} | ${row.valid} | ${row.killed} | ${row.timeout} | ${row.survived} | ${row.noCoverage} | ${row.equivalent ?? 0} | ${row.result} |`, ) } - if (rows.length === 0) lines.push("| — | 0 | 0 | 0 | 0 | 0 | 0 | Not applicable |") + if (rows.length === 0) lines.push("| — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Not applicable |") if (rows.length > 0) { lines.push("", "### Focused tests") @@ -670,9 +700,17 @@ export function appendSummary(rows, advisories, manifest) { } } -export function evaluateReport(report, packageEntry) { - const counts = mutantCounts(report) +export function evaluateReport(report, packageEntry, equivalences = []) { + const reportedFiles = new Set(Object.keys(report.files ?? {}).map((filePath) => `${packageEntry.root}/${filePath}`)) + const counts = applyEquivalences(mutantCounts(report), equivalences, packageEntry, reportedFiles) const advisories = [] + if (counts.unusedEquivalences.length > 0) { + advisories.push( + `${packageEntry.id} matched none of its accepted equivalence entries for files under test ` + + `(${counts.unusedEquivalences.map((entry) => `${entry.file}:${entry.line}:${entry.mutator}`).join(", ")}). ` + + "The code shifted or the mutant is now killed; update or remove the stale entries.", + ) + } if (counts.valid > MAX_MUTANTS) { advisories.push( `${packageEntry.id} generated ${counts.valid} valid mutants (limit ${MAX_MUTANTS}). ` + @@ -695,8 +733,10 @@ export function runManifest( { runMutation = runStryker, readMutationReport = (reportPath) => JSON.parse(fs.readFileSync(reportPath, "utf8")), + readEquivalences = loadEquivalences, } = {}, ) { + const equivalences = readEquivalences(repoRoot) const rows = [] const advisories = [...(manifest.advisories ?? [])] const annotationState = { total: 0, perFile: new Map() } @@ -737,6 +777,7 @@ export function runManifest( timeout: 0, survived: 0, noCoverage: 0, + equivalent: 0, result: "No mutants generated", }) continue @@ -746,7 +787,7 @@ export function runManifest( const jsonReportPath = path.join(reportRoot, packageEntry.id, "mutation.json") const report = readMutationReport(jsonReportPath) packageEntry.testFiles = testsFromMutationReport(report, packageEntry.testFiles) - counts = evaluateReport(report, packageEntry) + counts = evaluateReport(report, packageEntry, equivalences) for (const annotation of formatAnnotations( counts.blocking, packageEntry.runRoot ?? packageEntry.root, @@ -781,6 +822,7 @@ export function runManifest( timeout: counts?.timeout ?? 0, survived: counts?.survived ?? 0, noCoverage: counts?.noCoverage ?? 0, + equivalent: counts?.equivalent ?? 0, blocking: counts?.blocking ?? [], result: "Advisory incomplete", }) diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 22284e598d..261c263cf8 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -20,6 +20,7 @@ import { formatAnnotationCommand, formatBlockingMutants, formatSummary, + loadEquivalences, mutantCounts, parseChangedLines, parseNameStatus, @@ -988,3 +989,93 @@ describe("report evaluation", () => { ) }) }) + +describe("accepted equivalent mutants", () => { + const packageEntry = { id: "extension", root: "src", sourceRoot: "src/" } + const report = { + files: { + "utils/pathUtils.ts": { + mutants: [ + { + status: "Survived", + mutatorName: "EqualityOperator", + replacement: "<=", + location: { start: { line: 45 } }, + }, + { + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "true", + location: { start: { line: 50 } }, + }, + ], + }, + }, + } + const equivalences = [ + { file: "src/utils/pathUtils.ts", line: 45, mutator: "EqualityOperator", reason: "defensive bound never hit" }, + { file: "src/utils/pathUtils.ts", line: 99, mutator: "BlockStatement", reason: "entry whose mutant is gone" }, + { file: "src/other.ts", line: 7, mutator: "Foo", reason: "file not part of this run" }, + ] + + it("removes accepted equivalent mutants from the blocking set", () => { + const counts = evaluateReport(report, packageEntry, equivalences) + assert.equal(counts.equivalent, 1) + assert.equal(counts.survived, 2) + assert.equal(counts.blocking.length, 1) + assert.equal(counts.blocking[0].mutatorName, "ConditionalExpression") + }) + + it("advises about stale entries only for files present in the report", () => { + const advisories = evaluateReport(report, packageEntry, equivalences).advisories.join("\n") + assert.match(advisories, /src\/utils\/pathUtils\.ts:99:BlockStatement/) + assert.doesNotMatch(advisories, /src\/other\.ts/) + }) + + it("keeps prior behavior when no equivalences are configured", () => { + const counts = evaluateReport(report, packageEntry) + assert.equal(counts.blocking.length, 2) + assert.equal(counts.equivalent, 0) + }) + + it("loads no equivalences when the registry file is absent", () => { + assert.deepEqual(loadEquivalences(fs.mkdtempSync(path.join(os.tmpdir(), "stryker-eq-"))), []) + }) + + it("ships registry entries with file, line, mutator, and reason", () => { + const registry = JSON.parse( + fs.readFileSync(path.join(repositoryRoot, "scripts/stryker-equivalences.json"), "utf8"), + ).equivalences + assert.ok(registry.length > 0) + for (const entry of registry) { + assert.match(entry.file, /^(src|packages)\//) + assert.ok(Number.isInteger(entry.line)) + assert.ok(entry.mutator) + assert.ok(entry.reason.length > 20) + } + }) + + it("reports the equivalent column in the summary", () => { + const summary = formatSummary( + [ + { + id: "extension", + root: "src", + changedLines: 1, + valid: 2, + killed: 1, + timeout: 0, + survived: 1, + noCoverage: 0, + equivalent: 1, + result: "Passed", + testFiles: [], + }, + ], + [], + {}, + ) + assert.ok(summary.includes("| Equivalent |")) + assert.ok(summary.includes("| 1 | Passed |")) + }) +}) diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json new file mode 100644 index 0000000000..0f49d5b535 --- /dev/null +++ b/scripts/stryker-equivalences.json @@ -0,0 +1,161 @@ +{ + "$comment": "Maintainer-accepted equivalent mutants for the changed-code mutation gate (https://github.com/Zoo-Code-Org/Zoo-Code/issues/2050). Matched on file + line + mutator against the Stryker report; when code shifts the entry stops matching and the mutant resurfaces as an advisory finding. Evidence: mutation repro of the 38 previously-suppressed lines at PR #2051 head.", + "equivalences": [ + { + "file": "src/api/providers/openai-codex.ts", + "line": 276, + "mutator": "ConditionalExpression", + "reason": "the false mutant differs only if an abort lands between the OAuth race settle and this catch, a microtask window" + }, + { + "file": "src/api/providers/openai-codex.ts", + "line": 1459, + "mutator": "ConditionalExpression", + "reason": "the false mutant differs only if an abort lands between the transport last pre-yield check and this consumer resumption" + }, + { + "file": "src/api/providers/openai.ts", + "line": 96, + "mutator": "LogicalOperator", + "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" + }, + { + "file": "src/api/providers/openai.ts", + "line": 381, + "mutator": "LogicalOperator", + "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" + }, + { + "file": "src/api/providers/utils/abort-signal.ts", + "line": 118, + "mutator": "BooleanLiteral", + "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" + }, + { + "file": "src/api/providers/utils/abort-signal.ts", + "line": 118, + "mutator": "ObjectLiteral", + "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" + }, + { + "file": "src/core/assistant-message/NativeToolCallParser.ts", + "line": 380, + "mutator": "ConditionalExpression", + "reason": "the guard and delete are both GC-only; observable behavior is identical" + }, + { + "file": "src/core/assistant-message/NativeToolCallParser.ts", + "line": 381, + "mutator": "CallExpression", + "reason": "deleting an empty WeakMap entry is only observable as GC eligibility" + }, + { + "file": "src/core/task/Task.ts", + "line": 4176, + "mutator": "ConditionalExpression", + "reason": "only tool_use and mcp_tool_use entries ever carry an id, so the guard is always true on this path" + }, + { + "file": "src/core/task/Task.ts", + "line": 4228, + "mutator": "ConditionalExpression", + "reason": "event.name is always a string on this path; delta events carry the tracked start name" + }, + { + "file": "src/core/task/Task.ts", + "line": 4244, + "mutator": "ConditionalExpression", + "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable" + }, + { + "file": "src/core/task/Task.ts", + "line": 4252, + "mutator": "ConditionalExpression", + "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" + }, + { + "file": "src/core/task/Task.ts", + "line": 4252, + "mutator": "EqualityOperator", + "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" + }, + { + "file": "src/core/task/Task.ts", + "line": 4642, + "mutator": "LogicalOperator", + "reason": "finalizeRawChunks emits end events with the tracked start name, so the ?? fallback is unreachable" + }, + { + "file": "src/core/task/Task.ts", + "line": 4658, + "mutator": "ConditionalExpression", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4658, + "mutator": "EqualityOperator", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4658, + "mutator": "LogicalOperator", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4663, + "mutator": "ConditionalExpression", + "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 45, + "mutator": "EqualityOperator", + "reason": "defensive iteration bound: valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 74, + "mutator": "ConditionalExpression", + "reason": "ENOENT is the only expected code on a walk toward an existing ancestor; anything else must fail closed" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 85, + "mutator": "ConditionalExpression", + "reason": "lstat succeeding after realpath ENOENT means a dangling symlink, so the true branch is unreachable" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 114, + "mutator": "ConditionalExpression", + "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 114, + "mutator": "LogicalOperator", + "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 121, + "mutator": "ArrayDeclaration", + "reason": "the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 124, + "mutator": "ConditionalExpression", + "reason": "a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 128, + "mutator": "ConditionalExpression", + "reason": "if no folder could be realized the containment set is empty, so fail-closed is equivalent" + } + ] +} diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index a8f93738e5..63c1f8a186 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -157,9 +157,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan // Handle tool calls in stream - emit partial chunks for NativeToolCallParser if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { - // Stryker disable next-line ConditionalExpression: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. if (toolCall.id) { - // Stryker disable next-line CallExpression: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. activeToolCallIds.add(toolCall.id) } yield { @@ -173,13 +171,10 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } // Process finish_reason to emit tool_call_end events - // Stryker disable next-line ConditionalExpression,EqualityOperator,StringLiteral: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. if (finishReason === "tool_calls") { for (const id of activeToolCallIds) { - // Stryker disable next-line ObjectLiteral,StringLiteral: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. yield { type: "tool_call_end", id } } - // Stryker disable next-line CallExpression: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. activeToolCallIds.clear() } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index e77741ad9e..32210780d3 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -272,7 +272,6 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } catch (error) { // An abort landing while the lookup is pending must surface as the shared abort // contract, not as an authentication failure. - // Stryker disable next-line ConditionalExpression: the false mutant needs an abort inside a microtask window no test can schedule; the non-abort-failure regression kills the true mutant if (isRequestAborted(error, abortSignal)) { throw createAbortError(this.providerName) } @@ -1456,7 +1455,6 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // A buffered chunk can still be pulled in the window between the abort and the // inner generator's own stop, so break here: post-abort output must never be // joined into the completion. - // Stryker disable next-line ConditionalExpression: the false mutant needs an abort inside the yield-hop microtask window no test can schedule; the multi-chunk happy paths kill the true mutant if (requestSignal?.aborted) { break } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index aaf3bab8fb..47672b0dbf 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -92,9 +92,6 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const { info: modelInfo, reasoning } = this.getModel() const modelUrl = this.options.openAiBaseUrl ?? "" const modelId = this.options.openAiModelId ?? "" - // Stryker disable next-line LogicalOperator: equivalent. An unset option makes `&&` - // yield undefined, which the parameter default in convertToolsForOpenAI turns back into - // the same strict value as `??`. const strictToolSchemas = this.options.openAiStrictToolSchemas ?? DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS const isAzureAiInference = this._isAzureAiInference(modelUrl) const deepseekReasoner = this.usesR1Format(modelId) @@ -380,9 +377,6 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ): ApiStream { const { info: modelInfo, reasoning } = this.getModel() const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) - // Stryker disable next-line LogicalOperator: equivalent. An unset option makes `&&` - // yield undefined, which the parameter default in convertToolsForOpenAI turns back into - // the same strict value as `??`. const strictToolSchemas = this.options.openAiStrictToolSchemas ?? DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS if (this.options.openAiStreamingEnabled ?? true) { diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index 8453867254..d4162d9f09 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -292,9 +292,7 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan // Handle tool calls in stream - emit partial chunks for NativeToolCallParser if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { - // Stryker disable next-line ConditionalExpression: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. if (toolCall.id) { - // Stryker disable next-line CallExpression: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. activeToolCallIds.add(toolCall.id) } yield { @@ -308,13 +306,10 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } // Process finish_reason to emit tool_call_end events - // Stryker disable next-line ConditionalExpression,EqualityOperator,StringLiteral: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. if (finishReason === "tool_calls") { for (const id of activeToolCallIds) { - // Stryker disable next-line ObjectLiteral,StringLiteral: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. yield { type: "tool_call_end", id } } - // Stryker disable next-line CallExpression: vi.mock() prevents coverage instrumentation from crossing module boundaries in this spec file. activeToolCallIds.clear() } diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index e1ab5dc7c0..0fb4e23765 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -110,7 +110,6 @@ export function rejectOnAbort(pending: Promise, signal: AbortSignal, provi } return new Promise((resolve, reject) => { const onAbort = () => reject(createAbortError(providerName)) - // Stryker disable next-line ObjectLiteral,BooleanLiteral: a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable signal.addEventListener("abort", onAbort, { once: true }) void pending.then( (value) => { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 90da07cc8d..af28a21d23 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -377,9 +377,7 @@ export class NativeToolCallParser { // Clean up streaming state streamingToolCalls.delete(key) - // Stryker disable next-line ConditionalExpression: the guard and delete are both GC-only; observability is equivalent to the CallExpression rationale below. if (streamingToolCalls.size === 0) { - // Stryker disable next-line CallExpression: deleting an empty WeakMap value is only observable as GC eligibility. this.streamingToolCallsByScope.delete(scope) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index dc7880644c..f32fc760de 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4266,7 +4266,6 @@ export class Task extends EventEmitter implements TaskLike { // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => - // Stryker disable next-line ConditionalExpression: equivalent mutant, only tool_use and mcp_tool_use entries carry an id, so the type check cannot change entry.id === event.id (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4319,7 +4318,6 @@ export class Task extends EventEmitter implements TaskLike { // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined - // Stryker disable next-line ConditionalExpression: equivalent mutant, event.name is always a string here, so the legacy nameless branch is unreachable if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4336,7 +4334,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: equivalent mutant, streamingKey is always a string here (makeStreamingKey), so the null side is unreachable streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4345,7 +4342,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4742,7 +4738,6 @@ export class Task extends EventEmitter implements TaskLike { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. - // Stryker disable next-line LogicalOperator: equivalent mutant, finalizeRawChunks emits end events with a string name, so the ?? event.id fallback is unreachable let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4759,13 +4754,11 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5596b90e67..a68ac457c0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1660,9 +1660,7 @@ export const webviewMessageHandler = async ( // containment as a literal and escape only after that later decode. if (fromMarkdown) { const decoded = decodeUntrustedPathToStable(rawPath) - // Stryker disable next-line ConditionalExpression,BlockStatement: hostile non-stabilizing encodings are unreachable from the webview (its posts are plain link targets); the bound is defensive if (decoded === null) { - // Stryker disable next-line CallExpression: hostile non-stabilizing encodings are pinned by the direct decodeUntrustedPathToStable bound test; the webview never posts such values rejectOutsideWorkspace() break } diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index 4eff5791ac..832a8191fe 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -42,7 +42,6 @@ export function isPathOutsideWorkspace(filePath: string): boolean { */ export function decodeUntrustedPathToStable(filePath: string, maxIterations = 8): string | null { let current = filePath - // Stryker disable next-line EqualityOperator,UpdateOperator,ConditionalExpression: defensive iteration bound; valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit for production input (pinned by the direct bound test) for (let i = 0; i < maxIterations; i++) { let decoded: string try { @@ -56,7 +55,6 @@ export function decodeUntrustedPathToStable(filePath: string, maxIterations = 8) } current = decoded } - // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: defensive bound; valid percent-encoding strictly reduces escape depth per iteration, so non-stabilization is unreachable for real input return null } @@ -73,7 +71,6 @@ async function realPathOfExistingAncestor(filePath: string): Promise { const folders = vscode.workspace.workspaceFolders - // Stryker disable next-line ConditionalExpression,LogicalOperator: equivalent - with no folders the realFolders set stays empty and the empty-set fail-closed below returns the same result if (!folders || folders.length === 0) { - // Stryker disable next-line BlockStatement: no workspace means no allowed root; everything is outside (mirrors isPathOutsideWorkspace) return true } const target = await realPathOfExistingAncestor(filePath) - // Stryker disable next-line ConditionalExpression,BlockStatement: unresolvable target (unexpected fs error) fails closed if (!target) { return true } - // Stryker disable next-line ArrayDeclaration: equivalent - the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable const realFolders: string[] = [] for (const folder of folders) { const real = await realPathOfExistingAncestor(folder.uri.fsPath) - // Stryker disable next-line ConditionalExpression: equivalent - a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry if (real) { realFolders.push(real) } } - // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: if no folder could be realized the containment set is empty, so fail closed if (realFolders.length === 0) { return true } From 2dd80ff93312c5d1d949910659d84b41bacd217c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:12:48 +0800 Subject: [PATCH 2/9] fix(gate): scope equivalence entries to the reviewed replacement CodeRabbit: file+line+mutator cannot distinguish the `=> true` and `=> false` ConditionalExpression mutants of one condition, so a single reviewed entry excluded both. The registry key now includes the mutant replacement, every entry carries it, and the stale-entry advisory prints it. The summary table delimiter row gained the missing Equivalent cell so GitHub renders the table. The registry was regenerated against the current base (main moved the guarded statements): 33 entries, one per reviewed surviving mutant. Tests: node --test scripts/stryker-diff.test.mjs (53/53), including a runManifest integration test that injects readEquivalences end to end. --- scripts/stryker-diff.mjs | 14 ++-- scripts/stryker-diff.test.mjs | 110 +++++++++++++++++++++++++++++- scripts/stryker-equivalences.json | 105 ++++++++++++++++++++++++---- 3 files changed, 206 insertions(+), 23 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index eabd5642be..a9f0723e06 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -473,12 +473,16 @@ export function loadEquivalences(repoRoot) { } export function applyEquivalences(counts, equivalences, packageEntry, reportedFiles = new Set()) { - const index = new Map(equivalences.map((entry) => [`${entry.file}:${entry.line}:${entry.mutator}`, entry])) + // The key includes the replacement: one condition yields both `=> true` and `=> false` + // ConditionalExpression mutants, and only the reviewed replacement may be accepted. + const index = new Map( + equivalences.map((entry) => [`${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}`, entry]), + ) const used = new Set() const blocking = [] let equivalent = 0 for (const mutant of counts.blocking) { - const key = `${packageEntry.root}/${mutant.filePath}:${mutant.location?.start?.line}:${mutant.mutatorName}` + const key = `${packageEntry.root}/${mutant.filePath}:${mutant.location?.start?.line}:${mutant.mutatorName}:${mutant.replacement}` if (index.has(key)) { used.add(key) equivalent++ @@ -490,7 +494,7 @@ export function applyEquivalences(counts, equivalences, packageEntry, reportedFi (entry) => entry.file.startsWith(packageEntry.sourceRoot) && reportedFiles.has(entry.file) && - !used.has(`${entry.file}:${entry.line}:${entry.mutator}`), + !used.has(`${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}`), ) return { ...counts, blocking, equivalent, unusedEquivalences } } @@ -600,7 +604,7 @@ export function formatSummary(rows, advisories, manifest = {}) { "## Changed-code mutation testing", "", "| Package | Changed executable lines | Valid | Killed | Timeout | Survived | No coverage | Equivalent | Result |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |", ] for (const row of rows) { lines.push( @@ -707,7 +711,7 @@ export function evaluateReport(report, packageEntry, equivalences = []) { if (counts.unusedEquivalences.length > 0) { advisories.push( `${packageEntry.id} matched none of its accepted equivalence entries for files under test ` + - `(${counts.unusedEquivalences.map((entry) => `${entry.file}:${entry.line}:${entry.mutator}`).join(", ")}). ` + + `(${counts.unusedEquivalences.map((entry) => `${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}`).join(", ")}). ` + "The code shifted or the mutant is now killed; update or remove the stale entries.", ) } diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 261c263cf8..a73ef999ee 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -1013,9 +1013,27 @@ describe("accepted equivalent mutants", () => { }, } const equivalences = [ - { file: "src/utils/pathUtils.ts", line: 45, mutator: "EqualityOperator", reason: "defensive bound never hit" }, - { file: "src/utils/pathUtils.ts", line: 99, mutator: "BlockStatement", reason: "entry whose mutant is gone" }, - { file: "src/other.ts", line: 7, mutator: "Foo", reason: "file not part of this run" }, + { + file: "src/utils/pathUtils.ts", + line: 45, + mutator: "EqualityOperator", + replacement: "<=", + reason: "defensive bound never hit", + }, + { + file: "src/utils/pathUtils.ts", + line: 99, + mutator: "BlockStatement", + replacement: "{}", + reason: "entry whose mutant is gone", + }, + { + file: "src/other.ts", + line: 7, + mutator: "Foo", + replacement: "bar", + reason: "file not part of this run", + }, ] it("removes accepted equivalent mutants from the blocking set", () => { @@ -1026,6 +1044,42 @@ describe("accepted equivalent mutants", () => { assert.equal(counts.blocking[0].mutatorName, "ConditionalExpression") }) + it("accepts only the reviewed replacement when two mutants share a key", () => { + const twoMutants = { + files: { + "utils/pathUtils.ts": { + mutants: [ + { + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "true", + location: { start: { line: 50 } }, + }, + { + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "false", + location: { start: { line: 50 } }, + }, + ], + }, + }, + } + const reviewed = [ + { + file: "src/utils/pathUtils.ts", + line: 50, + mutator: "ConditionalExpression", + replacement: "true", + reason: "only the true replacement is equivalent here", + }, + ] + const counts = evaluateReport(twoMutants, packageEntry, reviewed) + assert.equal(counts.equivalent, 1) + assert.equal(counts.blocking.length, 1) + assert.equal(counts.blocking[0].replacement, "false") + }) + it("advises about stale entries only for files present in the report", () => { const advisories = evaluateReport(report, packageEntry, equivalences).advisories.join("\n") assert.match(advisories, /src\/utils\/pathUtils\.ts:99:BlockStatement/) @@ -1038,6 +1092,52 @@ describe("accepted equivalent mutants", () => { assert.equal(counts.equivalent, 0) }) + it("applies registry equivalences through the runManifest pipeline", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-equiv-manifest-")) + const packageEntry = { + id: "core", + root: "packages/core", + sourceRoot: "packages/core/", + vitestConfig: "vitest.unit.config.ts", + selectors: ["src/value.ts:1-1"], + changedExecutableLines: 1, + } + try { + const row = runManifest(repo, { packages: [{ ...packageEntry }] }, path.join(repo, "reports"), { + runMutation: (_repoRoot, _entry, _reportRoot, dryRunOnly) => + dryRunOnly ? "Instrumented 1 source file(s) with 1 mutant(s)" : "", + readMutationReport: () => ({ + files: { + "src/value.ts": { + mutants: [ + { + status: "Survived", + mutatorName: "BooleanLiteral", + replacement: "false", + location: { start: { line: 1 } }, + }, + ], + }, + }, + }), + readEquivalences: () => [ + { + file: "packages/core/src/value.ts", + line: 1, + mutator: "BooleanLiteral", + replacement: "false", + reason: "the mutated branch is unreachable in every caller", + }, + ], + })[0] + assert.equal(row.equivalent, 1) + assert.equal(row.result, "Passed") + assert.deepEqual(row.advisories, []) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) + it("loads no equivalences when the registry file is absent", () => { assert.deepEqual(loadEquivalences(fs.mkdtempSync(path.join(os.tmpdir(), "stryker-eq-"))), []) }) @@ -1051,6 +1151,7 @@ describe("accepted equivalent mutants", () => { assert.match(entry.file, /^(src|packages)\//) assert.ok(Number.isInteger(entry.line)) assert.ok(entry.mutator) + assert.equal(typeof entry.replacement, "string") assert.ok(entry.reason.length > 20) } }) @@ -1075,6 +1176,9 @@ describe("accepted equivalent mutants", () => { [], {}, ) + const header = summary.split("\n").find((line) => line.includes("| Equivalent |")) ?? "" + const delimiter = summary.split("\n")[summary.split("\n").indexOf(header) + 1] ?? "" + assert.equal(header.split("|").length, delimiter.split("|").length) assert.ok(summary.includes("| Equivalent |")) assert.ok(summary.includes("| 1 | Passed |")) }) diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index 0f49d5b535..971d9f892d 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -1,160 +1,235 @@ { - "$comment": "Maintainer-accepted equivalent mutants for the changed-code mutation gate (https://github.com/Zoo-Code-Org/Zoo-Code/issues/2050). Matched on file + line + mutator against the Stryker report; when code shifts the entry stops matching and the mutant resurfaces as an advisory finding. Evidence: mutation repro of the 38 previously-suppressed lines at PR #2051 head.", + "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are advisory-checked: if a listed file is under test and the mutant is gone or killed, the gate warns so stale entries are updated. Add entries only with maintainer approval; never add in-source Stryker directives.", "equivalences": [ { "file": "src/api/providers/openai-codex.ts", - "line": 276, + "line": 275, "mutator": "ConditionalExpression", + "replacement": "false", "reason": "the false mutant differs only if an abort lands between the OAuth race settle and this catch, a microtask window" }, { "file": "src/api/providers/openai-codex.ts", - "line": 1459, + "line": 1458, "mutator": "ConditionalExpression", + "replacement": "false", "reason": "the false mutant differs only if an abort lands between the transport last pre-yield check and this consumer resumption" }, { "file": "src/api/providers/openai.ts", - "line": 96, + "line": 95, "mutator": "LogicalOperator", + "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" }, { "file": "src/api/providers/openai.ts", - "line": 381, + "line": 380, "mutator": "LogicalOperator", + "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" }, { "file": "src/api/providers/utils/abort-signal.ts", "line": 118, "mutator": "BooleanLiteral", + "replacement": "false", "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" }, { "file": "src/api/providers/utils/abort-signal.ts", "line": 118, "mutator": "ObjectLiteral", + "replacement": "{}", "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", "line": 380, "mutator": "ConditionalExpression", + "replacement": "false", "reason": "the guard and delete are both GC-only; observable behavior is identical" }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", "line": 381, "mutator": "CallExpression", + "replacement": ";", "reason": "deleting an empty WeakMap entry is only observable as GC eligibility" }, { "file": "src/core/task/Task.ts", - "line": 4176, + "line": 4146, "mutator": "ConditionalExpression", + "replacement": "true", "reason": "only tool_use and mcp_tool_use entries ever carry an id, so the guard is always true on this path" }, { "file": "src/core/task/Task.ts", - "line": 4228, + "line": 4198, "mutator": "ConditionalExpression", + "replacement": "true", "reason": "event.name is always a string on this path; delta events carry the tracked start name" }, { "file": "src/core/task/Task.ts", - "line": 4244, + "line": 4214, "mutator": "ConditionalExpression", + "replacement": "false", "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable" }, { "file": "src/core/task/Task.ts", - "line": 4252, + "line": 4222, + "mutator": "ConditionalExpression", + "replacement": "false", + "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" + }, + { + "file": "src/core/task/Task.ts", + "line": 4222, "mutator": "ConditionalExpression", + "replacement": "true", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" }, { "file": "src/core/task/Task.ts", - "line": 4252, + "line": 4222, "mutator": "EqualityOperator", + "replacement": "streamingKey === undefined", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" }, { "file": "src/core/task/Task.ts", - "line": 4642, + "line": 4612, "mutator": "LogicalOperator", + "replacement": "event.name && event.id", "reason": "finalizeRawChunks emits end events with the tracked start name, so the ?? fallback is unreachable" }, { "file": "src/core/task/Task.ts", - "line": 4658, + "line": 4628, + "mutator": "ConditionalExpression", + "replacement": "true", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4628, + "mutator": "ConditionalExpression", + "replacement": "false", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4628, + "mutator": "ConditionalExpression", + "replacement": "true", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4628, "mutator": "ConditionalExpression", + "replacement": "true", + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + }, + { + "file": "src/core/task/Task.ts", + "line": 4628, + "mutator": "EqualityOperator", + "replacement": "finalToolUse !== null", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4658, + "line": 4628, "mutator": "EqualityOperator", + "replacement": "toolUseIndex !== undefined", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4658, + "line": 4628, "mutator": "LogicalOperator", + "replacement": "finalToolUse === null || toolUseIndex === undefined", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4663, + "line": 4633, "mutator": "ConditionalExpression", + "replacement": "false", + "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" + }, + { + "file": "src/core/task/Task.ts", + "line": 4633, + "mutator": "ConditionalExpression", + "replacement": "true", "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" }, { "file": "src/utils/pathUtils.ts", "line": 45, "mutator": "EqualityOperator", + "replacement": "i <= maxIterations", "reason": "defensive iteration bound: valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit" }, { "file": "src/utils/pathUtils.ts", "line": 74, "mutator": "ConditionalExpression", + "replacement": "false", "reason": "ENOENT is the only expected code on a walk toward an existing ancestor; anything else must fail closed" }, { "file": "src/utils/pathUtils.ts", "line": 85, "mutator": "ConditionalExpression", + "replacement": "true", "reason": "lstat succeeding after realpath ENOENT means a dangling symlink, so the true branch is unreachable" }, { "file": "src/utils/pathUtils.ts", "line": 114, "mutator": "ConditionalExpression", + "replacement": "false", + "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" + }, + { + "file": "src/utils/pathUtils.ts", + "line": 114, + "mutator": "ConditionalExpression", + "replacement": "false", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" }, { "file": "src/utils/pathUtils.ts", "line": 114, "mutator": "LogicalOperator", + "replacement": "!folders && folders.length === 0", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" }, { "file": "src/utils/pathUtils.ts", "line": 121, "mutator": "ArrayDeclaration", + "replacement": "[\"Stryker was here\"]", "reason": "the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable" }, { "file": "src/utils/pathUtils.ts", "line": 124, "mutator": "ConditionalExpression", + "replacement": "true", "reason": "a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry" }, { "file": "src/utils/pathUtils.ts", "line": 128, "mutator": "ConditionalExpression", + "replacement": "false", "reason": "if no folder could be realized the containment set is empty, so fail-closed is equivalent" } ] From 3719b993848f4e0c835860fa95673324a8ca2aaf Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Thu, 22 Oct 2026 02:13:58 +0800 Subject: [PATCH 3/9] fix(mutation-gate): limit each equivalence entry to a single accepted mutant A compound condition emits several mutants that share the equivalence key (same file, line, mutator and replacement, different column). The previous Map keyed index collapsed them, so one reviewed entry accepted every same-key mutant and a new unreviewed mutant on that line passed the gate. Matching is now count-limited: each registry entry accepts exactly one mutant and the surplus stays blocking. Duplicate entries that no extra mutant consumed are reported as stale advisories. --- scripts/stryker-diff.mjs | 34 +++++++++++------ scripts/stryker-diff.test.mjs | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index a9f0723e06..4e13d02181 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -475,27 +475,37 @@ export function loadEquivalences(repoRoot) { export function applyEquivalences(counts, equivalences, packageEntry, reportedFiles = new Set()) { // The key includes the replacement: one condition yields both `=> true` and `=> false` // ConditionalExpression mutants, and only the reviewed replacement may be accepted. - const index = new Map( - equivalences.map((entry) => [`${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}`, entry]), - ) - const used = new Set() + // Matching is count-limited: a compound condition emits several mutants that share the key + // (same line, mutator and replacement, different column), so one registry entry accepts + // exactly one mutant and any further mutant with that key stays blocking. + const entryKey = (entry) => `${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}` + const remaining = new Map() + for (const entry of equivalences) { + const key = entryKey(entry) + remaining.set(key, (remaining.get(key) ?? 0) + 1) + } + const accepted = new Map() const blocking = [] let equivalent = 0 for (const mutant of counts.blocking) { const key = `${packageEntry.root}/${mutant.filePath}:${mutant.location?.start?.line}:${mutant.mutatorName}:${mutant.replacement}` - if (index.has(key)) { - used.add(key) + const left = remaining.get(key) ?? 0 + if (left > 0) { + remaining.set(key, left - 1) + accepted.set(key, (accepted.get(key) ?? 0) + 1) equivalent++ } else { blocking.push(mutant) } } - const unusedEquivalences = equivalences.filter( - (entry) => - entry.file.startsWith(packageEntry.sourceRoot) && - reportedFiles.has(entry.file) && - !used.has(`${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}`), - ) + const seen = new Map() + const unusedEquivalences = equivalences.filter((entry) => { + if (!entry.file.startsWith(packageEntry.sourceRoot) || !reportedFiles.has(entry.file)) return false + const key = entryKey(entry) + const ordinal = (seen.get(key) ?? 0) + 1 + seen.set(key, ordinal) + return (accepted.get(key) ?? 0) < ordinal + }) return { ...counts, blocking, equivalent, unusedEquivalences } } diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index a73ef999ee..d351e8b74d 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -1080,6 +1080,77 @@ describe("accepted equivalent mutants", () => { assert.equal(counts.blocking[0].replacement, "false") }) + it("limits each registry entry to a single accepted mutant", () => { + // A compound condition emits several ConditionalExpression/true mutants on one line that + // differ only by column. One reviewed entry must not accept all of them. + const threeSameKey = { + files: { + "utils/pathUtils.ts": { + mutants: [1, 2, 3].map((column) => ({ + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "true", + location: { start: { line: 50, column } }, + })), + }, + }, + } + const oneEntry = [ + { + file: "src/utils/pathUtils.ts", + line: 50, + mutator: "ConditionalExpression", + replacement: "true", + reason: "the first sub-expression is equivalent here", + }, + ] + const limited = evaluateReport(threeSameKey, packageEntry, oneEntry) + assert.equal(limited.equivalent, 1) + assert.equal(limited.blocking.length, 2) + assert.deepEqual( + limited.blocking.map((mutant) => mutant.location.start.column), + [2, 3], + ) + assert.deepEqual(limited.advisories, []) + + const twoEntries = [...oneEntry, { ...oneEntry[0], reason: "the second sub-expression is also equivalent" }] + const both = evaluateReport(threeSameKey, packageEntry, twoEntries) + assert.equal(both.equivalent, 2) + assert.equal(both.blocking.length, 1) + assert.deepEqual(both.advisories, []) + }) + + it("flags registry duplicates that no extra mutant consumed", () => { + const singleMutant = { + files: { + "utils/pathUtils.ts": { + mutants: [ + { + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "true", + location: { start: { line: 50, column: 1 } }, + }, + ], + }, + }, + } + const oneEntry = { + file: "src/utils/pathUtils.ts", + line: 50, + mutator: "ConditionalExpression", + replacement: "true", + reason: "only one mutant carries this key", + } + const counts = evaluateReport(singleMutant, packageEntry, [ + oneEntry, + { ...oneEntry, reason: "duplicate entry with no second mutant to accept" }, + ]) + assert.equal(counts.equivalent, 1) + assert.equal(counts.unusedEquivalences.length, 1) + assert.equal(counts.unusedEquivalences[0].reason, "duplicate entry with no second mutant to accept") + }) + it("advises about stale entries only for files present in the report", () => { const advisories = evaluateReport(report, packageEntry, equivalences).advisories.join("\n") assert.match(advisories, /src\/utils\/pathUtils\.ts:99:BlockStatement/) From 16105656534261ccc87652cd185715fee3481466 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:08:10 +0800 Subject: [PATCH 4/9] fix(mutation-gate): validate the equivalence registry against the head Review follow-ups: every equivalence line is refreshed to the mutant line at this head (report lines are 0-based, so the editor shows line + 1; all 33 entries now resolve to a real mutant key), and the gate validates the registry before running: it parses each entry file with the TypeScript AST and fails when the line does not hold code of the named mutator (ConditionalExpression also covers if/logical gates, EqualityOperator covers relational swaps). Added tests for the validator, the shipped registry, unknown mutators and missing files. --- scripts/stryker-diff.mjs | 118 ++++++++++++++++++++++++++++++ scripts/stryker-diff.test.mjs | 55 ++++++++++++++ scripts/stryker-equivalences.json | 68 ++++++++--------- 3 files changed, 207 insertions(+), 34 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 4e13d02181..d3c495da2d 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -472,6 +472,117 @@ export function loadEquivalences(repoRoot) { return parsed.equivalences ?? [] } +const MUTATOR_NODE_PREDICATES = new Map([ + [ + "ConditionalExpression", + (node) => + ts.isConditionalExpression(node) || + ts.isIfStatement(node) || + // Stryker also emits ConditionalExpression mutants for logical expressions that gate + // execution (the same && can yield both ConditionalExpression and LogicalOperator). + (ts.isBinaryExpression(node) && + [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(node.operatorToken.kind)), + ], + [ + "LogicalOperator", + (node) => + ts.isBinaryExpression(node) && + [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(node.operatorToken.kind), + ], + [ + "EqualityOperator", + (node) => + ts.isBinaryExpression(node) && + [ + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + // Stryker's EqualityOperator also swaps relational operators. + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, + ].includes(node.operatorToken.kind), + ], + ["BooleanLiteral", (node) => node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword], + ["ObjectLiteral", (node) => ts.isObjectLiteralExpression(node)], + ["ArrayDeclaration", (node) => ts.isArrayLiteralExpression(node)], + ["CallExpression", (node) => ts.isCallExpression(node)], + ["StringLiteral", (node) => ts.isStringLiteral(node) || ts.isTemplateLiteral(node)], + ["BlockStatement", (node) => ts.isBlock(node)], + [ + "ArithmeticOperator", + (node) => + ts.isBinaryExpression(node) && + [ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.MinusToken, + ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, + ts.SyntaxKind.PercentToken, + ].includes(node.operatorToken.kind), + ], +]) + +function lineHasMutatorNode(source, filePath, line, mutator) { + const predicate = MUTATOR_NODE_PREDICATES.get(mutator) + if (!predicate) return false + const scriptKind = filePath.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKind) + // Registry lines are Stryker report lines, which are 0-based: the editor shows the code at + // line + 1. Matching the editor line keeps the registry reviewable in a plain editor. + const editorLine = line + 1 + let found = false + function visit(node) { + if (found) return + if (predicate(node)) { + const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 + if (start === editorLine) { + found = true + return + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return found +} + +/** + * Check that every registry entry's line really holds code of the named mutator at the checked-in + * head. Entries are keyed by report line, so a refactor that shifts code silently disables the + * exclusion; this validator fails the gate instead of letting the entry drift. + */ +export function validateEquivalenceEntries(equivalences, readSource) { + const problems = [] + for (const entry of equivalences) { + const source = readSource(entry.file) + if (source === undefined) { + problems.push(entry.file + ": file not found") + continue + } + if (!MUTATOR_NODE_PREDICATES.has(entry.mutator)) { + problems.push(entry.file + ":" + entry.line + ": unknown mutator " + entry.mutator) + continue + } + if (!lineHasMutatorNode(source, entry.file, entry.line, entry.mutator)) { + problems.push( + entry.file + ":" + entry.line + ": expected " + entry.mutator + " code at line " + (entry.line + 1), + ) + } + } + return problems +} + export function applyEquivalences(counts, equivalences, packageEntry, reportedFiles = new Set()) { // The key includes the replacement: one condition yields both `=> true` and `=> false` // ConditionalExpression mutants, and only the reviewed replacement may be accepted. @@ -863,6 +974,13 @@ function main() { if (!baseSha || !headSha) throw new Error("--base and --head are required") const reportRoot = path.resolve(repoRoot, argument("--reports") ?? "reports/mutation") + const registryProblems = validateEquivalenceEntries(loadEquivalences(repoRoot), (file) => { + const filePath = path.join(repoRoot, file) + return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined + }) + if (registryProblems.length > 0) { + throw new Error("Stryker equivalence registry is stale:\n" + registryProblems.join("\n")) + } const manifest = selectFromGit(repoRoot, baseSha, headSha) if (manifest.packages.length === 0) { appendSummary([], manifest.advisories, manifest) diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index d351e8b74d..a2404c73d6 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -34,6 +34,7 @@ import { selectFromGit, testsFromMutationReport, validateDisableDirectives, + validateEquivalenceEntries, } from "./stryker-diff.mjs" const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -1227,6 +1228,60 @@ describe("accepted equivalent mutants", () => { } }) + it("rejects an entry whose line does not hold the named mutator", () => { + const source = ["const a = 1", "const b = a === 2 ? 3 : 4", "const c = { once: true }", ""].join("\n") + const readSource = () => source + const conditional = { + file: "src/example.ts", + line: 1, + mutator: "ConditionalExpression", + replacement: "true", + reason: "example", + } + + // Report lines are 0-based: line 1 is the ternary on editor line 2. + assert.deepEqual(validateEquivalenceEntries([conditional], readSource), []) + + // The object literal is on editor line 3, so report line 2, not 1. + const driftedObject = { ...conditional, mutator: "ObjectLiteral", line: 1 } + assert.match( + validateEquivalenceEntries([driftedObject], readSource).join("\n"), + /src\/example\.ts:1: expected ObjectLiteral code at line 2/, + ) + + // ConditionalExpression is a different node kind than the object literal. + const driftedConditional = { ...conditional, line: 2 } + assert.match( + validateEquivalenceEntries([driftedConditional], readSource).join("\n"), + /expected ConditionalExpression code at line 3/, + ) + }) + + it("rejects an entry with an unknown mutator or missing file", () => { + const source = "const a = 1\n" + const unknown = { file: "src/example.ts", line: 0, mutator: "NotAMutator", replacement: "x", reason: "example" } + const problems = validateEquivalenceEntries([unknown], () => source) + assert.match(problems.join("\n"), /unknown mutator NotAMutator/) + + const missing = { ...unknown, mutator: "ConditionalExpression", file: "src/missing.ts" } + assert.match( + validateEquivalenceEntries([missing], () => undefined).join("\n"), + /src\/missing\.ts: file not found/, + ) + }) + + it("validates every shipped registry entry against the checked-in head", () => { + const equivalences = loadEquivalences(repositoryRoot) + assert.ok(equivalences.length > 0) + assert.deepEqual( + validateEquivalenceEntries(equivalences, (file) => { + const filePath = path.join(repositoryRoot, file) + return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined + }), + [], + ) + }) + it("reports the equivalent column in the summary", () => { const summary = formatSummary( [ diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index 971d9f892d..19b83d736f 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -1,233 +1,233 @@ { - "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are advisory-checked: if a listed file is under test and the mutant is gone or killed, the gate warns so stale entries are updated. Add entries only with maintainer approval; never add in-source Stryker directives.", + "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are advisory-checked: if a listed file is under test and the mutant is gone or killed, the gate warns so stale entries are updated. Add entries only with maintainer approval; never add in-source Stryker directives. Lines are Stryker report lines (0-based): the editor shows the code at line + 1.", "equivalences": [ { "file": "src/api/providers/openai-codex.ts", - "line": 275, + "line": 274, "mutator": "ConditionalExpression", "replacement": "false", "reason": "the false mutant differs only if an abort lands between the OAuth race settle and this catch, a microtask window" }, { "file": "src/api/providers/openai-codex.ts", - "line": 1458, + "line": 1457, "mutator": "ConditionalExpression", "replacement": "false", "reason": "the false mutant differs only if an abort lands between the transport last pre-yield check and this consumer resumption" }, { "file": "src/api/providers/openai.ts", - "line": 95, + "line": 94, "mutator": "LogicalOperator", "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" }, { "file": "src/api/providers/openai.ts", - "line": 380, + "line": 379, "mutator": "LogicalOperator", "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" }, { "file": "src/api/providers/utils/abort-signal.ts", - "line": 118, + "line": 112, "mutator": "BooleanLiteral", "replacement": "false", "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" }, { "file": "src/api/providers/utils/abort-signal.ts", - "line": 118, + "line": 112, "mutator": "ObjectLiteral", "replacement": "{}", "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", - "line": 380, + "line": 379, "mutator": "ConditionalExpression", "replacement": "false", "reason": "the guard and delete are both GC-only; observable behavior is identical" }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", - "line": 381, + "line": 380, "mutator": "CallExpression", "replacement": ";", "reason": "deleting an empty WeakMap entry is only observable as GC eligibility" }, { "file": "src/core/task/Task.ts", - "line": 4146, + "line": 4268, "mutator": "ConditionalExpression", "replacement": "true", "reason": "only tool_use and mcp_tool_use entries ever carry an id, so the guard is always true on this path" }, { "file": "src/core/task/Task.ts", - "line": 4198, + "line": 4320, "mutator": "ConditionalExpression", "replacement": "true", "reason": "event.name is always a string on this path; delta events carry the tracked start name" }, { "file": "src/core/task/Task.ts", - "line": 4214, + "line": 4336, "mutator": "ConditionalExpression", "replacement": "false", "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable" }, { "file": "src/core/task/Task.ts", - "line": 4222, + "line": 4344, "mutator": "ConditionalExpression", "replacement": "false", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" }, { "file": "src/core/task/Task.ts", - "line": 4222, + "line": 4344, "mutator": "ConditionalExpression", "replacement": "true", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" }, { "file": "src/core/task/Task.ts", - "line": 4222, + "line": 4344, "mutator": "EqualityOperator", "replacement": "streamingKey === undefined", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" }, { "file": "src/core/task/Task.ts", - "line": 4612, + "line": 4740, "mutator": "LogicalOperator", "replacement": "event.name && event.id", "reason": "finalizeRawChunks emits end events with the tracked start name, so the ?? fallback is unreachable" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "ConditionalExpression", "replacement": "true", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "ConditionalExpression", "replacement": "false", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "ConditionalExpression", "replacement": "true", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "ConditionalExpression", "replacement": "true", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "EqualityOperator", "replacement": "finalToolUse !== null", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "EqualityOperator", "replacement": "toolUseIndex !== undefined", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4628, + "line": 4756, "mutator": "LogicalOperator", "replacement": "finalToolUse === null || toolUseIndex === undefined", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" }, { "file": "src/core/task/Task.ts", - "line": 4633, + "line": 4761, "mutator": "ConditionalExpression", "replacement": "false", "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" }, { "file": "src/core/task/Task.ts", - "line": 4633, + "line": 4761, "mutator": "ConditionalExpression", "replacement": "true", "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" }, { "file": "src/utils/pathUtils.ts", - "line": 45, + "line": 44, "mutator": "EqualityOperator", "replacement": "i <= maxIterations", "reason": "defensive iteration bound: valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit" }, { "file": "src/utils/pathUtils.ts", - "line": 74, + "line": 73, "mutator": "ConditionalExpression", "replacement": "false", "reason": "ENOENT is the only expected code on a walk toward an existing ancestor; anything else must fail closed" }, { "file": "src/utils/pathUtils.ts", - "line": 85, + "line": 84, "mutator": "ConditionalExpression", "replacement": "true", "reason": "lstat succeeding after realpath ENOENT means a dangling symlink, so the true branch is unreachable" }, { "file": "src/utils/pathUtils.ts", - "line": 114, + "line": 113, "mutator": "ConditionalExpression", "replacement": "false", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" }, { "file": "src/utils/pathUtils.ts", - "line": 114, + "line": 113, "mutator": "ConditionalExpression", "replacement": "false", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" }, { "file": "src/utils/pathUtils.ts", - "line": 114, + "line": 113, "mutator": "LogicalOperator", "replacement": "!folders && folders.length === 0", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" }, { "file": "src/utils/pathUtils.ts", - "line": 121, + "line": 120, "mutator": "ArrayDeclaration", "replacement": "[\"Stryker was here\"]", "reason": "the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable" }, { "file": "src/utils/pathUtils.ts", - "line": 124, + "line": 123, "mutator": "ConditionalExpression", "replacement": "true", "reason": "a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry" }, { "file": "src/utils/pathUtils.ts", - "line": 128, + "line": 127, "mutator": "ConditionalExpression", "replacement": "false", "reason": "if no folder could be realized the containment set is empty, so fail-closed is equivalent" From 17bd94e71d1eb100676b43d9324b875046f0e00a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:18:46 +0800 Subject: [PATCH 5/9] docs(mutation): describe the enforced registry validation in the comment The registry is now validated against the checked-in source before mutation testing, so stale entries fail the gate instead of only warning; the comment said advisory-checked. Unmatched entries for tested files still produce advisories. --- scripts/stryker-equivalences.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index 19b83d736f..4ff4e1c488 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -1,5 +1,5 @@ { - "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are advisory-checked: if a listed file is under test and the mutant is gone or killed, the gate warns so stale entries are updated. Add entries only with maintainer approval; never add in-source Stryker directives. Lines are Stryker report lines (0-based): the editor shows the code at line + 1.", + "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are validated against the checked-in source before mutation testing; a missing file, unknown mutator, or line that no longer contains the named mutator fails the gate. Entries that do not match a reported mutant produce advisories for the tested files. Add entries only with maintainer approval; never add in-source Stryker directives. Lines are Stryker report lines (0-based): the editor shows the code at line + 1.", "equivalences": [ { "file": "src/api/providers/openai-codex.ts", From b9a23aa3d561b5949863566b3e4cb41edab0864f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:00:41 +0800 Subject: [PATCH 6/9] fix(mutation-gate): pin equivalence entries to the mutant start column Review follow-ups: every registry entry now carries the Stryker start column and the gate match key includes it, so a same-line edit cannot silently re-bind an accepted equivalence to a different mutant; validateEquivalenceEntries rejects entries without a column and checks the column against the AST node (including condition operands). Adds a focused loadEquivalences(registry-present) test, cleans up the temp directory in the absent-registry test, and documents the enforced validation in the registry comment. Gate tests 58 passed. --- scripts/stryker-diff.mjs | 51 +++++++++++---- scripts/stryker-diff.test.mjs | 58 ++++++++++++++--- scripts/stryker-equivalences.json | 101 ++++++++++++++++++++---------- 3 files changed, 157 insertions(+), 53 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index d3c495da2d..dee452b72d 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -533,7 +533,7 @@ const MUTATOR_NODE_PREDICATES = new Map([ ], ]) -function lineHasMutatorNode(source, filePath, line, mutator) { +function lineHasMutatorNode(source, filePath, line, column, mutator) { const predicate = MUTATOR_NODE_PREDICATES.get(mutator) if (!predicate) return false const scriptKind = filePath.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS @@ -541,15 +541,32 @@ function lineHasMutatorNode(source, filePath, line, mutator) { // Registry lines are Stryker report lines, which are 0-based: the editor shows the code at // line + 1. Matching the editor line keeps the registry reviewable in a plain editor. const editorLine = line + 1 + // The registry stores Stryker's 0-based start line and column. Stryker's mutant location for + // condition-shaped mutators points at the mutated sub-expression (for an if statement, the + // test; for a logical expression, the operand), not always at the whole node, so collect the + // candidate roots for the mutator and then match the column against the root or any of its + // expression descendants on that line. Pinning the column stops a same-line edit from + // silently re-binding an accepted equivalence to a different mutant. let found = false + function matchesColumn(node) { + const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + if (start.line + 1 !== editorLine) return false + if (start.character === column) return true + let childMatch = false + ts.forEachChild(node, (child) => { + if (childMatch) return + // Walk only expression-shaped children: a condition's operands are expressions, while + // the statement bodies a matched node contains are not, so this stays line-scoped. + if (!ts.isExpression(child) && !ts.isIfStatement(child) && !ts.isConditionalExpression(child)) return + if (matchesColumn(child)) childMatch = true + }) + return childMatch + } function visit(node) { if (found) return - if (predicate(node)) { - const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 - if (start === editorLine) { - found = true - return - } + if (predicate(node) && matchesColumn(node)) { + found = true + return } ts.forEachChild(node, visit) } @@ -574,9 +591,21 @@ export function validateEquivalenceEntries(equivalences, readSource) { problems.push(entry.file + ":" + entry.line + ": unknown mutator " + entry.mutator) continue } - if (!lineHasMutatorNode(source, entry.file, entry.line, entry.mutator)) { + if (!Number.isInteger(entry.column) || entry.column < 0) { + problems.push(entry.file + ":" + entry.line + ": missing start column") + continue + } + if (!lineHasMutatorNode(source, entry.file, entry.line, entry.column, entry.mutator)) { problems.push( - entry.file + ":" + entry.line + ": expected " + entry.mutator + " code at line " + (entry.line + 1), + entry.file + + ":" + + entry.line + + ":" + + entry.column + + ": expected " + + entry.mutator + + " code at line " + + (entry.line + 1), ) } } @@ -589,7 +618,7 @@ export function applyEquivalences(counts, equivalences, packageEntry, reportedFi // Matching is count-limited: a compound condition emits several mutants that share the key // (same line, mutator and replacement, different column), so one registry entry accepts // exactly one mutant and any further mutant with that key stays blocking. - const entryKey = (entry) => `${entry.file}:${entry.line}:${entry.mutator}:${entry.replacement}` + const entryKey = (entry) => `${entry.file}:${entry.line}:${entry.column}:${entry.mutator}:${entry.replacement}` const remaining = new Map() for (const entry of equivalences) { const key = entryKey(entry) @@ -599,7 +628,7 @@ export function applyEquivalences(counts, equivalences, packageEntry, reportedFi const blocking = [] let equivalent = 0 for (const mutant of counts.blocking) { - const key = `${packageEntry.root}/${mutant.filePath}:${mutant.location?.start?.line}:${mutant.mutatorName}:${mutant.replacement}` + const key = `${packageEntry.root}/${mutant.filePath}:${mutant.location?.start?.line}:${mutant.location?.start?.column}:${mutant.mutatorName}:${mutant.replacement}` const left = remaining.get(key) ?? 0 if (left > 0) { remaining.set(key, left - 1) diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index a2404c73d6..42fb931f10 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -1001,13 +1001,13 @@ describe("accepted equivalent mutants", () => { status: "Survived", mutatorName: "EqualityOperator", replacement: "<=", - location: { start: { line: 45 } }, + location: { start: { line: 45, column: 0 } }, }, { status: "Survived", mutatorName: "ConditionalExpression", replacement: "true", - location: { start: { line: 50 } }, + location: { start: { line: 50, column: 2 } }, }, ], }, @@ -1017,6 +1017,7 @@ describe("accepted equivalent mutants", () => { { file: "src/utils/pathUtils.ts", line: 45, + column: 0, mutator: "EqualityOperator", replacement: "<=", reason: "defensive bound never hit", @@ -1024,6 +1025,7 @@ describe("accepted equivalent mutants", () => { { file: "src/utils/pathUtils.ts", line: 99, + column: 0, mutator: "BlockStatement", replacement: "{}", reason: "entry whose mutant is gone", @@ -1031,6 +1033,7 @@ describe("accepted equivalent mutants", () => { { file: "src/other.ts", line: 7, + column: 0, mutator: "Foo", replacement: "bar", reason: "file not part of this run", @@ -1054,13 +1057,13 @@ describe("accepted equivalent mutants", () => { status: "Survived", mutatorName: "ConditionalExpression", replacement: "true", - location: { start: { line: 50 } }, + location: { start: { line: 50, column: 2 } }, }, { status: "Survived", mutatorName: "ConditionalExpression", replacement: "false", - location: { start: { line: 50 } }, + location: { start: { line: 50, column: 2 } }, }, ], }, @@ -1070,6 +1073,7 @@ describe("accepted equivalent mutants", () => { { file: "src/utils/pathUtils.ts", line: 50, + column: 2, mutator: "ConditionalExpression", replacement: "true", reason: "only the true replacement is equivalent here", @@ -1100,6 +1104,7 @@ describe("accepted equivalent mutants", () => { { file: "src/utils/pathUtils.ts", line: 50, + column: 1, mutator: "ConditionalExpression", replacement: "true", reason: "the first sub-expression is equivalent here", @@ -1114,7 +1119,10 @@ describe("accepted equivalent mutants", () => { ) assert.deepEqual(limited.advisories, []) - const twoEntries = [...oneEntry, { ...oneEntry[0], reason: "the second sub-expression is also equivalent" }] + const twoEntries = [ + ...oneEntry, + { ...oneEntry[0], column: 2, reason: "the second sub-expression is also equivalent" }, + ] const both = evaluateReport(threeSameKey, packageEntry, twoEntries) assert.equal(both.equivalent, 2) assert.equal(both.blocking.length, 1) @@ -1139,6 +1147,7 @@ describe("accepted equivalent mutants", () => { const oneEntry = { file: "src/utils/pathUtils.ts", line: 50, + column: 1, mutator: "ConditionalExpression", replacement: "true", reason: "only one mutant carries this key", @@ -1210,8 +1219,31 @@ describe("accepted equivalent mutants", () => { } }) - it("loads no equivalences when the registry file is absent", () => { - assert.deepEqual(loadEquivalences(fs.mkdtempSync(path.join(os.tmpdir(), "stryker-eq-"))), []) + it("loads no equivalences when the registry file is absent", (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-eq-")) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + assert.deepEqual(loadEquivalences(directory), []) + }) + + it("loads entries from a repository registry file", (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-eq-present-")) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + fs.mkdirSync(path.join(directory, "scripts"), { recursive: true }) + const entry = { + file: "src/example.ts", + line: 3, + column: 2, + mutator: "ConditionalExpression", + replacement: "true", + reason: "example reason that is long enough to be reviewable", + } + fs.writeFileSync( + path.join(directory, "scripts/stryker-equivalences.json"), + JSON.stringify({ equivalences: [entry] }), + "utf8", + ) + + assert.deepEqual(loadEquivalences(directory), [entry]) }) it("ships registry entries with file, line, mutator, and reason", () => { @@ -1222,6 +1254,7 @@ describe("accepted equivalent mutants", () => { for (const entry of registry) { assert.match(entry.file, /^(src|packages)\//) assert.ok(Number.isInteger(entry.line)) + assert.ok(Number.isInteger(entry.column) && entry.column >= 0) assert.ok(entry.mutator) assert.equal(typeof entry.replacement, "string") assert.ok(entry.reason.length > 20) @@ -1234,6 +1267,7 @@ describe("accepted equivalent mutants", () => { const conditional = { file: "src/example.ts", line: 1, + column: 10, mutator: "ConditionalExpression", replacement: "true", reason: "example", @@ -1246,7 +1280,7 @@ describe("accepted equivalent mutants", () => { const driftedObject = { ...conditional, mutator: "ObjectLiteral", line: 1 } assert.match( validateEquivalenceEntries([driftedObject], readSource).join("\n"), - /src\/example\.ts:1: expected ObjectLiteral code at line 2/, + /src\/example\.ts:1:10: expected ObjectLiteral code at line 2/, ) // ConditionalExpression is a different node kind than the object literal. @@ -1255,6 +1289,14 @@ describe("accepted equivalent mutants", () => { validateEquivalenceEntries([driftedConditional], readSource).join("\n"), /expected ConditionalExpression code at line 3/, ) + + // A same-line edit that moves the mutated node must invalidate the entry rather than + // silently accepting whatever mutant now sits on the recorded line. + const driftedColumn = { ...conditional, column: 11 } + assert.match( + validateEquivalenceEntries([driftedColumn], readSource).join("\n"), + /src\/example\.ts:1:11: expected ConditionalExpression code at line 2/, + ) }) it("rejects an entry with an unknown mutator or missing file", () => { diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index 4ff4e1c488..7ec1188afb 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -1,236 +1,269 @@ { - "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are validated against the checked-in source before mutation testing; a missing file, unknown mutator, or line that no longer contains the named mutator fails the gate. Entries that do not match a reported mutant produce advisories for the tested files. Add entries only with maintainer approval; never add in-source Stryker directives. Lines are Stryker report lines (0-based): the editor shows the code at line + 1.", + "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are validated against the checked-in source before mutation testing; a missing file, unknown mutator, or line that no longer contains the named mutator fails the gate. Entries that do not match a reported mutant produce advisories for the tested files. Add entries only with maintainer approval; never add in-source Stryker directives. Lines are Stryker report lines (0-based): the editor shows the code at line + 1. Entries include the Stryker start column so a same-line change cannot silently re-bind an entry to a different mutant.", "equivalences": [ { "file": "src/api/providers/openai-codex.ts", "line": 274, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "the false mutant differs only if an abort lands between the OAuth race settle and this catch, a microtask window" + "reason": "the false mutant differs only if an abort lands between the OAuth race settle and this catch, a microtask window", + "column": 7 }, { "file": "src/api/providers/openai-codex.ts", "line": 1457, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "the false mutant differs only if an abort lands between the transport last pre-yield check and this consumer resumption" + "reason": "the false mutant differs only if an abort lands between the transport last pre-yield check and this consumer resumption", + "column": 8 }, { "file": "src/api/providers/openai.ts", "line": 94, "mutator": "LogicalOperator", "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", - "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" + "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??", + "column": 28 }, { "file": "src/api/providers/openai.ts", "line": 379, "mutator": "LogicalOperator", "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", - "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??" + "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??", + "column": 28 }, { "file": "src/api/providers/utils/abort-signal.ts", "line": 112, "mutator": "BooleanLiteral", "replacement": "false", - "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" + "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable", + "column": 52 }, { "file": "src/api/providers/utils/abort-signal.ts", "line": 112, "mutator": "ObjectLiteral", "replacement": "{}", - "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable" + "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable", + "column": 44 }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", "line": 379, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "the guard and delete are both GC-only; observable behavior is identical" + "reason": "the guard and delete are both GC-only; observable behavior is identical", + "column": 6 }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", "line": 380, "mutator": "CallExpression", "replacement": ";", - "reason": "deleting an empty WeakMap entry is only observable as GC eligibility" + "reason": "deleting an empty WeakMap entry is only observable as GC eligibility", + "column": 3 }, { "file": "src/core/task/Task.ts", "line": 4268, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "only tool_use and mcp_tool_use entries ever carry an id, so the guard is always true on this path" + "reason": "only tool_use and mcp_tool_use entries ever carry an id, so the guard is always true on this path", + "column": 12 }, { "file": "src/core/task/Task.ts", "line": 4320, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "event.name is always a string on this path; delta events carry the tracked start name" + "reason": "event.name is always a string on this path; delta events carry the tracked start name", + "column": 14 }, { "file": "src/core/task/Task.ts", "line": 4336, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable" + "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable", + "column": 11 }, { "file": "src/core/task/Task.ts", "line": 4344, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" + "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", + "column": 14 }, { "file": "src/core/task/Task.ts", "line": 4344, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" + "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", + "column": 14 }, { "file": "src/core/task/Task.ts", "line": 4344, "mutator": "EqualityOperator", "replacement": "streamingKey === undefined", - "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)" + "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", + "column": 32 }, { "file": "src/core/task/Task.ts", "line": 4740, "mutator": "LogicalOperator", "replacement": "event.name && event.id", - "reason": "finalizeRawChunks emits end events with the tracked start name, so the ?? fallback is unreachable" + "reason": "finalizeRawChunks emits end events with the tracked start name, so the ?? fallback is unreachable", + "column": 22 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 10 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 10 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 10 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 35 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "EqualityOperator", "replacement": "finalToolUse !== null", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 10 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "EqualityOperator", "replacement": "toolUseIndex !== undefined", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 35 }, { "file": "src/core/task/Task.ts", "line": 4756, "mutator": "LogicalOperator", "replacement": "finalToolUse === null || toolUseIndex === undefined", - "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves" + "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", + "column": 10 }, { "file": "src/core/task/Task.ts", "line": 4761, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" + "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable", + "column": 11 }, { "file": "src/core/task/Task.ts", "line": 4761, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable" + "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable", + "column": 11 }, { "file": "src/utils/pathUtils.ts", "line": 44, "mutator": "EqualityOperator", "replacement": "i <= maxIterations", - "reason": "defensive iteration bound: valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit" + "reason": "defensive iteration bound: valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit", + "column": 17 }, { "file": "src/utils/pathUtils.ts", "line": 73, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "ENOENT is the only expected code on a walk toward an existing ancestor; anything else must fail closed" + "reason": "ENOENT is the only expected code on a walk toward an existing ancestor; anything else must fail closed", + "column": 7 }, { "file": "src/utils/pathUtils.ts", "line": 84, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "lstat succeeding after realpath ENOENT means a dangling symlink, so the true branch is unreachable" + "reason": "lstat succeeding after realpath ENOENT means a dangling symlink, so the true branch is unreachable", + "column": 8 }, { "file": "src/utils/pathUtils.ts", "line": 113, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" + "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", + "column": 5 }, { "file": "src/utils/pathUtils.ts", "line": 113, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" + "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", + "column": 17 }, { "file": "src/utils/pathUtils.ts", "line": 113, "mutator": "LogicalOperator", "replacement": "!folders && folders.length === 0", - "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result" + "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", + "column": 5 }, { "file": "src/utils/pathUtils.ts", "line": 120, "mutator": "ArrayDeclaration", "replacement": "[\"Stryker was here\"]", - "reason": "the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable" + "reason": "the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable", + "column": 31 }, { "file": "src/utils/pathUtils.ts", "line": 123, "mutator": "ConditionalExpression", "replacement": "true", - "reason": "a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry" + "reason": "a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry", + "column": 6 }, { "file": "src/utils/pathUtils.ts", "line": 127, "mutator": "ConditionalExpression", "replacement": "false", - "reason": "if no folder could be realized the containment set is empty, so fail-closed is equivalent" + "reason": "if no folder could be realized the containment set is empty, so fail-closed is equivalent", + "column": 5 } ] } From c0bdef1ee7b3dbb83b555408f3b31b9165a330dc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:39:32 +0800 Subject: [PATCH 7/9] fix(mutation-gate): pin CallExpression equivalents to their own node column Review follow-ups: descendant-column matching in validateEquivalenceEntries is now limited to the condition-shaped mutators whose Stryker location can start at a sub-expression (ConditionalExpression, LogicalOperator). Every other mutator must start exactly at the recorded column, so an identifier left at a removed inner call former column no longer validates a CallExpression entry; a source-edit rejection test covers outer(inner(arg)) -> outer(arg). Removed the registry equivalence for the openai-codex OAuth ConditionalExpression mutant as requested; the unrelated stream-consumer equivalence is unchanged. node --test scripts/stryker-diff.test.mjs passes. --- scripts/stryker-diff.mjs | 10 ++++++++++ scripts/stryker-diff.test.mjs | 25 +++++++++++++++++++++++++ scripts/stryker-equivalences.json | 8 -------- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index dee452b72d..ce18516301 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -533,6 +533,9 @@ const MUTATOR_NODE_PREDICATES = new Map([ ], ]) +// Mutators whose Stryker start location can point at a sub-expression of the mutated node. +const MUTATOR_SUBELEMENT_LOCATIONS = new Set(["ConditionalExpression", "LogicalOperator"]) + function lineHasMutatorNode(source, filePath, line, column, mutator) { const predicate = MUTATOR_NODE_PREDICATES.get(mutator) if (!predicate) return false @@ -548,10 +551,17 @@ function lineHasMutatorNode(source, filePath, line, column, mutator) { // expression descendants on that line. Pinning the column stops a same-line edit from // silently re-binding an accepted equivalence to a different mutant. let found = false + // Stryker reports a condition-shaped mutant's location at the mutated sub-expression (for an + // if statement the test, for a logical expression the operand), so only those mutators may + // match a descendant at the recorded column. Every other mutator must have its own node start + // exactly at the column: an identifier that happens to sit at a removed call's former column + // must not validate a CallExpression entry. + const allowDescendantColumn = MUTATOR_SUBELEMENT_LOCATIONS.has(mutator) function matchesColumn(node) { const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) if (start.line + 1 !== editorLine) return false if (start.character === column) return true + if (!allowDescendantColumn) return false let childMatch = false ts.forEachChild(node, (child) => { if (childMatch) return diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 42fb931f10..64059625ae 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -1299,6 +1299,31 @@ describe("accepted equivalent mutants", () => { ) }) + it("rejects a CallExpression entry whose recorded column now holds only an identifier", () => { + // A registered call edited from outer(inner(arg)) to outer(arg) leaves the identifier + // arg at the removed inner call's former column. Descendant-column matching is only for + // condition-shaped mutators, so an identifier must not validate a CallExpression entry. + const sourceBefore = "const y = outer(inner(arg))\n" + const sourceAfter = "const y = outer(arg)\n" + const entry = { + file: "src/example.ts", + line: 0, + column: 16, + mutator: "CallExpression", + replacement: "undefined", + reason: "example", + } + + assert.deepEqual( + validateEquivalenceEntries([entry], () => sourceBefore), + [], + ) + assert.match( + validateEquivalenceEntries([entry], () => sourceAfter).join("\n"), + /src\/example\.ts:0:16: expected CallExpression code at line 1/, + ) + }) + it("rejects an entry with an unknown mutator or missing file", () => { const source = "const a = 1\n" const unknown = { file: "src/example.ts", line: 0, mutator: "NotAMutator", replacement: "x", reason: "example" } diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index 7ec1188afb..bc86f6fadb 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -1,14 +1,6 @@ { "$comment": "Maintainer-reviewed equivalent mutants for the changed-code mutation gate. Each entry accepts exactly one mutant (file, start line, mutator name, replacement) as equivalent, with the review rationale. Entries are validated against the checked-in source before mutation testing; a missing file, unknown mutator, or line that no longer contains the named mutator fails the gate. Entries that do not match a reported mutant produce advisories for the tested files. Add entries only with maintainer approval; never add in-source Stryker directives. Lines are Stryker report lines (0-based): the editor shows the code at line + 1. Entries include the Stryker start column so a same-line change cannot silently re-bind an entry to a different mutant.", "equivalences": [ - { - "file": "src/api/providers/openai-codex.ts", - "line": 274, - "mutator": "ConditionalExpression", - "replacement": "false", - "reason": "the false mutant differs only if an abort lands between the OAuth race settle and this catch, a microtask window", - "column": 7 - }, { "file": "src/api/providers/openai-codex.ts", "line": 1457, From a1740014af4626eeb660429fbe0758e2a5c56cd7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:10:14 +0800 Subject: [PATCH 8/9] fix(mutation-gate): bind equivalence entries to the exact AST node fingerprint Review follow-ups: findMatchedMutatorNode returns the matched node, and every registry entry now stores its normalized source text as a node fingerprint that validateEquivalenceEntries compares against the checked-in AST (a same-line edit leaving a different expression invalidates the entry). Added tests for missing/negative start columns and node fingerprint drift; the registry-shape test requires the fingerprint on every shipped entry. All 32 registry entries were regenerated from the checked-in sources. Issue #2060 now documents the gemini.ts exception tracked in #2201. node --test scripts/stryker-diff.test.mjs passes. --- scripts/stryker-diff.mjs | 38 +++++++++--- scripts/stryker-diff.test.mjs | 46 +++++++++++++++ scripts/stryker-equivalences.json | 96 ++++++++++++++++++++----------- 3 files changed, 141 insertions(+), 39 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index ce18516301..1582c420f9 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -536,9 +536,10 @@ const MUTATOR_NODE_PREDICATES = new Map([ // Mutators whose Stryker start location can point at a sub-expression of the mutated node. const MUTATOR_SUBELEMENT_LOCATIONS = new Set(["ConditionalExpression", "LogicalOperator"]) -function lineHasMutatorNode(source, filePath, line, column, mutator) { +/** The AST node matched for one registry entry's line/column/mutator, or undefined. */ +function findMatchedMutatorNode(source, filePath, line, column, mutator) { const predicate = MUTATOR_NODE_PREDICATES.get(mutator) - if (!predicate) return false + if (!predicate) return undefined const scriptKind = filePath.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKind) // Registry lines are Stryker report lines, which are 0-based: the editor shows the code at @@ -550,7 +551,7 @@ function lineHasMutatorNode(source, filePath, line, column, mutator) { // candidate roots for the mutator and then match the column against the root or any of its // expression descendants on that line. Pinning the column stops a same-line edit from // silently re-binding an accepted equivalence to a different mutant. - let found = false + let matchedNode // Stryker reports a condition-shaped mutant's location at the mutated sub-expression (for an // if statement the test, for a logical expression the operand), so only those mutators may // match a descendant at the recorded column. Every other mutator must have its own node start @@ -573,15 +574,20 @@ function lineHasMutatorNode(source, filePath, line, column, mutator) { return childMatch } function visit(node) { - if (found) return + if (matchedNode) return if (predicate(node) && matchesColumn(node)) { - found = true + matchedNode = node return } ts.forEachChild(node, visit) } visit(sourceFile) - return found + return matchedNode +} + +/** Normalize source text for a stable node fingerprint across whitespace-only edits. */ +function normalizeNodeText(text) { + return text.replace(/\s+/g, " ").trim() } /** @@ -605,7 +611,8 @@ export function validateEquivalenceEntries(equivalences, readSource) { problems.push(entry.file + ":" + entry.line + ": missing start column") continue } - if (!lineHasMutatorNode(source, entry.file, entry.line, entry.column, entry.mutator)) { + const matchedNode = findMatchedMutatorNode(source, entry.file, entry.line, entry.column, entry.mutator) + if (!matchedNode) { problems.push( entry.file + ":" + @@ -617,6 +624,23 @@ export function validateEquivalenceEntries(equivalences, readSource) { " code at line " + (entry.line + 1), ) + continue + } + // The node fingerprint binds the entry to this exact AST node: a same-line/column refactor + // that leaves a different expression in place invalidates the entry instead of silently + // re-binding the accepted equivalence. + if (typeof entry.node === "string") { + if (normalizeNodeText(matchedNode.getText()) !== normalizeNodeText(entry.node)) { + problems.push( + entry.file + + ":" + + entry.line + + ":" + + entry.column + + ": node fingerprint mismatch for " + + entry.mutator, + ) + } } } return problems diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 64059625ae..e7b28cefc5 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -1258,6 +1258,10 @@ describe("accepted equivalent mutants", () => { assert.ok(entry.mutator) assert.equal(typeof entry.replacement, "string") assert.ok(entry.reason.length > 20) + // The node fingerprint binds the entry to the exact AST node, so a same-line edit that + // leaves a different expression cannot silently re-bind the accepted equivalence. + assert.equal(typeof entry.node, "string") + assert.ok(entry.node.length > 0) } }) @@ -1337,6 +1341,48 @@ describe("accepted equivalent mutants", () => { ) }) + it("rejects an entry with a missing or negative start column", () => { + const source = "const a = 1\nconst b = a === 2 ? 3 : 4\n" + const base = { + file: "src/example.ts", + line: 1, + column: 10, + mutator: "ConditionalExpression", + replacement: "true", + reason: "example", + } + + const missing = { ...base } + delete missing.column + assert.match( + validateEquivalenceEntries([missing], () => source).join("\n"), + /src\/example\.ts:1: missing start column/, + ) + + const negative = { ...base, column: -1 } + assert.match( + validateEquivalenceEntries([negative], () => source).join("\n"), + /src\/example\.ts:1: missing start column/, + ) + }) + + it("rejects an entry whose node fingerprint does not match the current AST node", () => { + const source = "const a = 1\nconst b = a === 2 ? 3 : 4\n" + const entry = { + file: "src/example.ts", + line: 1, + column: 10, + mutator: "ConditionalExpression", + replacement: "true", + reason: "example", + node: "a !== 2 ? 3 : 4", + } + assert.match( + validateEquivalenceEntries([entry], () => source).join("\n"), + /node fingerprint mismatch for ConditionalExpression/, + ) + }) + it("validates every shipped registry entry against the checked-in head", () => { const equivalences = loadEquivalences(repositoryRoot) assert.ok(equivalences.length > 0) diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index bc86f6fadb..a7f5d8630d 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -7,7 +7,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "the false mutant differs only if an abort lands between the transport last pre-yield check and this consumer resumption", - "column": 8 + "column": 8, + "node": "if (requestSignal?.aborted) { break }" }, { "file": "src/api/providers/openai.ts", @@ -15,7 +16,8 @@ "mutator": "LogicalOperator", "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??", - "column": 28 + "column": 28, + "node": "this.options.openAiStrictToolSchemas ?? DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS" }, { "file": "src/api/providers/openai.ts", @@ -23,7 +25,8 @@ "mutator": "LogicalOperator", "replacement": "this.options.openAiStrictToolSchemas && DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS", "reason": "an unset option makes && yield undefined so the parameter default supplies the same strict value as ??", - "column": 28 + "column": 28, + "node": "this.options.openAiStrictToolSchemas ?? DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS" }, { "file": "src/api/providers/utils/abort-signal.ts", @@ -31,7 +34,8 @@ "mutator": "BooleanLiteral", "replacement": "false", "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable", - "column": 52 + "column": 52, + "node": "true" }, { "file": "src/api/providers/utils/abort-signal.ts", @@ -39,7 +43,8 @@ "mutator": "ObjectLiteral", "replacement": "{}", "reason": "a signal fires its abort event exactly once and the settle handler removes this listener, so the once flag is unobservable", - "column": 44 + "column": 44, + "node": "{ once: true }" }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", @@ -47,7 +52,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "the guard and delete are both GC-only; observable behavior is identical", - "column": 6 + "column": 6, + "node": "if (streamingToolCalls.size === 0) { this.streamingToolCallsByScope.delete(scope) }" }, { "file": "src/core/assistant-message/NativeToolCallParser.ts", @@ -55,7 +61,8 @@ "mutator": "CallExpression", "replacement": ";", "reason": "deleting an empty WeakMap entry is only observable as GC eligibility", - "column": 3 + "column": 3, + "node": "this.streamingToolCallsByScope.delete(scope)" }, { "file": "src/core/task/Task.ts", @@ -63,7 +70,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "only tool_use and mcp_tool_use entries ever carry an id, so the guard is always true on this path", - "column": 12 + "column": 12, + "node": "(entry.type === \"tool_use\" || entry.type === \"mcp_tool_use\") && (entry as { id?: string }).id === event.id" }, { "file": "src/core/task/Task.ts", @@ -71,7 +79,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "event.name is always a string on this path; delta events carry the tracked start name", - "column": 14 + "column": 14, + "node": "if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { const legacyEntry = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) streamingKey = legacyEntry ? NativeToolCallParser.makeStreamingKey( legacyEntry.id, legacyEntry.name, ) : undefined }" }, { "file": "src/core/task/Task.ts", @@ -79,7 +88,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable", - "column": 11 + "column": 11, + "node": "streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( streamingKey, event.delta, nativeToolCallParserScope, )" }, { "file": "src/core/task/Task.ts", @@ -87,7 +97,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", - "column": 14 + "column": 14, + "node": "if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. const toolUseIndex = this.streamingToolCallIndices.get(streamingKey) if (toolUseIndex !== undefined) { // Store the ID for native protocol ;(partialToolUse as any).id = event.id // Update the existing tool use with new partial data this.assistantMessageContent[toolUseIndex] = partialToolUse // Present updated tool use /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } }" }, { "file": "src/core/task/Task.ts", @@ -95,7 +106,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", - "column": 14 + "column": 14, + "node": "if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. const toolUseIndex = this.streamingToolCallIndices.get(streamingKey) if (toolUseIndex !== undefined) { // Store the ID for native protocol ;(partialToolUse as any).id = event.id // Update the existing tool use with new partial data this.assistantMessageContent[toolUseIndex] = partialToolUse // Present updated tool use /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } }" }, { "file": "src/core/task/Task.ts", @@ -103,7 +115,8 @@ "mutator": "EqualityOperator", "replacement": "streamingKey === undefined", "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", - "column": 32 + "column": 32, + "node": "streamingKey !== undefined" }, { "file": "src/core/task/Task.ts", @@ -111,7 +124,8 @@ "mutator": "LogicalOperator", "replacement": "event.name && event.id", "reason": "finalizeRawChunks emits end events with the tracked start name, so the ?? fallback is unreachable", - "column": 22 + "column": 22, + "node": "event.name ?? event.id" }, { "file": "src/core/task/Task.ts", @@ -119,7 +133,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 10 + "column": 10, + "node": "if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) } }" }, { "file": "src/core/task/Task.ts", @@ -127,7 +142,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 10 + "column": 10, + "node": "if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) } }" }, { "file": "src/core/task/Task.ts", @@ -135,7 +151,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 10 + "column": 10, + "node": "if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) } }" }, { "file": "src/core/task/Task.ts", @@ -143,7 +160,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 35 + "column": 35, + "node": "if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) } }" }, { "file": "src/core/task/Task.ts", @@ -151,7 +169,8 @@ "mutator": "EqualityOperator", "replacement": "finalToolUse !== null", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 10 + "column": 10, + "node": "finalToolUse === null" }, { "file": "src/core/task/Task.ts", @@ -159,7 +178,8 @@ "mutator": "EqualityOperator", "replacement": "toolUseIndex !== undefined", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 35 + "column": 35, + "node": "toolUseIndex === undefined" }, { "file": "src/core/task/Task.ts", @@ -167,7 +187,8 @@ "mutator": "LogicalOperator", "replacement": "finalToolUse === null || toolUseIndex === undefined", "reason": "the both-miss case is unreachable via the raw-chunk pipeline: end events carry the locked start name so the compound key resolves", - "column": 10 + "column": 10, + "node": "finalToolUse === null && toolUseIndex === undefined" }, { "file": "src/core/task/Task.ts", @@ -175,7 +196,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable", - "column": 11 + "column": 11, + "node": "if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) }" }, { "file": "src/core/task/Task.ts", @@ -183,7 +205,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable", - "column": 11 + "column": 11, + "node": "if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) }" }, { "file": "src/utils/pathUtils.ts", @@ -191,7 +214,8 @@ "mutator": "EqualityOperator", "replacement": "i <= maxIterations", "reason": "defensive iteration bound: valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit", - "column": 17 + "column": 17, + "node": "i < maxIterations" }, { "file": "src/utils/pathUtils.ts", @@ -199,7 +223,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "ENOENT is the only expected code on a walk toward an existing ancestor; anything else must fail closed", - "column": 7 + "column": 7, + "node": "if (code !== \"ENOENT\") { return null }" }, { "file": "src/utils/pathUtils.ts", @@ -207,7 +232,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "lstat succeeding after realpath ENOENT means a dangling symlink, so the true branch is unreachable", - "column": 8 + "column": 8, + "node": "if (stat.isSymbolicLink()) { return null }" }, { "file": "src/utils/pathUtils.ts", @@ -215,7 +241,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", - "column": 5 + "column": 5, + "node": "if (!folders || folders.length === 0) { return true }" }, { "file": "src/utils/pathUtils.ts", @@ -223,7 +250,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", - "column": 17 + "column": 17, + "node": "if (!folders || folders.length === 0) { return true }" }, { "file": "src/utils/pathUtils.ts", @@ -231,7 +259,8 @@ "mutator": "LogicalOperator", "replacement": "!folders && folders.length === 0", "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", - "column": 5 + "column": 5, + "node": "!folders || folders.length === 0" }, { "file": "src/utils/pathUtils.ts", @@ -239,7 +268,8 @@ "mutator": "ArrayDeclaration", "replacement": "[\"Stryker was here\"]", "reason": "the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable", - "column": 31 + "column": 31, + "node": "[]" }, { "file": "src/utils/pathUtils.ts", @@ -247,7 +277,8 @@ "mutator": "ConditionalExpression", "replacement": "true", "reason": "a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry", - "column": 6 + "column": 6, + "node": "if (real) { realFolders.push(real) }" }, { "file": "src/utils/pathUtils.ts", @@ -255,7 +286,8 @@ "mutator": "ConditionalExpression", "replacement": "false", "reason": "if no folder could be realized the containment set is empty, so fail-closed is equivalent", - "column": 5 + "column": 5, + "node": "if (realFolders.length === 0) { return true }" } ] } From 77eaf80e0df08d905ec2ef320a6fee9f62829ebf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:39:46 +0800 Subject: [PATCH 9/9] fix(mutation-gate): drop the non-equivalent streaming and missing-workspace approvals Review follow-ups: remove both compound streaming guard approvals (Task.ts:4344) and the missing-workspace guard plus its folders.length LogicalOperator approval (pathUtils.ts:113); each mutant is classified independently since the false/tree replacements change behavior or can throw. 27 entries remain; stryker-diff tests pass. --- scripts/stryker-equivalences.json | 45 ------------------------------- 1 file changed, 45 deletions(-) diff --git a/scripts/stryker-equivalences.json b/scripts/stryker-equivalences.json index a7f5d8630d..38f347dd17 100644 --- a/scripts/stryker-equivalences.json +++ b/scripts/stryker-equivalences.json @@ -91,24 +91,6 @@ "column": 11, "node": "streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( streamingKey, event.delta, nativeToolCallParserScope, )" }, - { - "file": "src/core/task/Task.ts", - "line": 4344, - "mutator": "ConditionalExpression", - "replacement": "false", - "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", - "column": 14, - "node": "if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. const toolUseIndex = this.streamingToolCallIndices.get(streamingKey) if (toolUseIndex !== undefined) { // Store the ID for native protocol ;(partialToolUse as any).id = event.id // Update the existing tool use with new partial data this.assistantMessageContent[toolUseIndex] = partialToolUse // Present updated tool use /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } }" - }, - { - "file": "src/core/task/Task.ts", - "line": 4344, - "mutator": "ConditionalExpression", - "replacement": "true", - "reason": "streamingKey is always a defined string on this path (same invariant as the guard above)", - "column": 14, - "node": "if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. const toolUseIndex = this.streamingToolCallIndices.get(streamingKey) if (toolUseIndex !== undefined) { // Store the ID for native protocol ;(partialToolUse as any).id = event.id // Update the existing tool use with new partial data this.assistantMessageContent[toolUseIndex] = partialToolUse // Present updated tool use /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } }" - }, { "file": "src/core/task/Task.ts", "line": 4344, @@ -235,33 +217,6 @@ "column": 8, "node": "if (stat.isSymbolicLink()) { return null }" }, - { - "file": "src/utils/pathUtils.ts", - "line": 113, - "mutator": "ConditionalExpression", - "replacement": "false", - "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", - "column": 5, - "node": "if (!folders || folders.length === 0) { return true }" - }, - { - "file": "src/utils/pathUtils.ts", - "line": 113, - "mutator": "ConditionalExpression", - "replacement": "false", - "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", - "column": 17, - "node": "if (!folders || folders.length === 0) { return true }" - }, - { - "file": "src/utils/pathUtils.ts", - "line": 113, - "mutator": "LogicalOperator", - "replacement": "!folders && folders.length === 0", - "reason": "with no folders the realFolders set stays empty and the empty-set fail-closed returns the same result", - "column": 5, - "node": "!folders || folders.length === 0" - }, { "file": "src/utils/pathUtils.ts", "line": 120,