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/3] 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/3] 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/3] 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/)