diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index dd64f4e774..4e13d02181 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -465,6 +465,50 @@ 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()) { + // The key includes the replacement: one condition yields both `=> true` and `=> false` + // ConditionalExpression mutants, and only the reviewed replacement may be accepted. + // 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}` + 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 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 } +} + function escapeWorkflowData(value) { return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A") } @@ -569,15 +613,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 +714,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}:${entry.replacement}`).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 +747,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 +791,7 @@ export function runManifest( timeout: 0, survived: 0, noCoverage: 0, + equivalent: 0, result: "No mutants generated", }) continue @@ -746,7 +801,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 +836,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..d351e8b74d 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,268 @@ 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", + 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", () => { + 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("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("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/) + 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("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-"))), []) + }) + + 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.equal(typeof entry.replacement, "string") + 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: [], + }, + ], + [], + {}, + ) + 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 new file mode 100644 index 0000000000..971d9f892d --- /dev/null +++ b/scripts/stryker-equivalences.json @@ -0,0 +1,236 @@ +{ + "$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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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" + } + ] +} 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 }