diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index dd64f4e774..1582c420f9 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -465,6 +465,224 @@ 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 ?? [] +} + +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), + ], +]) + +// Mutators whose Stryker start location can point at a sub-expression of the mutated node. +const MUTATOR_SUBELEMENT_LOCATIONS = new Set(["ConditionalExpression", "LogicalOperator"]) + +/** 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 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 + // 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 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 + // 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 + // 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 (matchedNode) return + if (predicate(node) && matchesColumn(node)) { + matchedNode = node + return + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return matchedNode +} + +/** Normalize source text for a stable node fingerprint across whitespace-only edits. */ +function normalizeNodeText(text) { + return text.replace(/\s+/g, " ").trim() +} + +/** + * 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 (!Number.isInteger(entry.column) || entry.column < 0) { + problems.push(entry.file + ":" + entry.line + ": missing start column") + continue + } + const matchedNode = findMatchedMutatorNode(source, entry.file, entry.line, entry.column, entry.mutator) + if (!matchedNode) { + problems.push( + entry.file + + ":" + + entry.line + + ":" + + entry.column + + ": expected " + + entry.mutator + + " 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 +} + +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.column}:${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.location?.start?.column}:${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 +787,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 +888,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 +921,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 +965,7 @@ export function runManifest( timeout: 0, survived: 0, noCoverage: 0, + equivalent: 0, result: "No mutants generated", }) continue @@ -746,7 +975,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 +1010,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", }) @@ -807,6 +1037,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 22284e598d..e7b28cefc5 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, @@ -33,6 +34,7 @@ import { selectFromGit, testsFromMutationReport, validateDisableDirectives, + validateEquivalenceEntries, } from "./stryker-diff.mjs" const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -988,3 +990,435 @@ 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, column: 0 } }, + }, + { + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "true", + location: { start: { line: 50, column: 2 } }, + }, + ], + }, + }, + } + const equivalences = [ + { + file: "src/utils/pathUtils.ts", + line: 45, + column: 0, + mutator: "EqualityOperator", + replacement: "<=", + reason: "defensive bound never hit", + }, + { + file: "src/utils/pathUtils.ts", + line: 99, + column: 0, + mutator: "BlockStatement", + replacement: "{}", + reason: "entry whose mutant is gone", + }, + { + file: "src/other.ts", + line: 7, + column: 0, + 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, column: 2 } }, + }, + { + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "false", + location: { start: { line: 50, column: 2 } }, + }, + ], + }, + }, + } + const reviewed = [ + { + file: "src/utils/pathUtils.ts", + line: 50, + column: 2, + 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, + column: 1, + 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], 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) + 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, + column: 1, + 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", (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", () => { + 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(Number.isInteger(entry.column) && entry.column >= 0) + 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) + } + }) + + 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, + column: 10, + 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:10: 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/, + ) + + // 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 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" } + 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("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) + 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( + [ + { + 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..38f347dd17 --- /dev/null +++ b/scripts/stryker-equivalences.json @@ -0,0 +1,248 @@ +{ + "$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": 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", + "column": 8, + "node": "if (requestSignal?.aborted) { break }" + }, + { + "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 ??", + "column": 28, + "node": "this.options.openAiStrictToolSchemas ?? DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS" + }, + { + "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 ??", + "column": 28, + "node": "this.options.openAiStrictToolSchemas ?? DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS" + }, + { + "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", + "column": 52, + "node": "true" + }, + { + "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", + "column": 44, + "node": "{ once: true }" + }, + { + "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", + "column": 6, + "node": "if (streamingToolCalls.size === 0) { this.streamingToolCallsByScope.delete(scope) }" + }, + { + "file": "src/core/assistant-message/NativeToolCallParser.ts", + "line": 380, + "mutator": "CallExpression", + "replacement": ";", + "reason": "deleting an empty WeakMap entry is only observable as GC eligibility", + "column": 3, + "node": "this.streamingToolCallsByScope.delete(scope)" + }, + { + "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", + "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", + "line": 4320, + "mutator": "ConditionalExpression", + "replacement": "true", + "reason": "event.name is always a string on this path; delta events carry the tracked start name", + "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", + "line": 4336, + "mutator": "ConditionalExpression", + "replacement": "false", + "reason": "makeStreamingKey always returns a string and the legacy lookup branch is unreachable", + "column": 11, + "node": "streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( streamingKey, event.delta, nativeToolCallParserScope, )" + }, + { + "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)", + "column": 32, + "node": "streamingKey !== undefined" + }, + { + "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", + "column": 22, + "node": "event.name ?? event.id" + }, + { + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "column": 10, + "node": "finalToolUse === null" + }, + { + "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", + "column": 35, + "node": "toolUseIndex === undefined" + }, + { + "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", + "column": 10, + "node": "finalToolUse === null && toolUseIndex === undefined" + }, + { + "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", + "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", + "line": 4761, + "mutator": "ConditionalExpression", + "replacement": "true", + "reason": "unreachable defensive region: getStreamingToolCallById can only resolve when the both-miss case is reachable", + "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", + "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", + "column": 17, + "node": "i < maxIterations" + }, + { + "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", + "column": 7, + "node": "if (code !== \"ENOENT\") { return null }" + }, + { + "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", + "column": 8, + "node": "if (stat.isSymbolicLink()) { return null }" + }, + { + "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", + "column": 31, + "node": "[]" + }, + { + "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", + "column": 6, + "node": "if (real) { realFolders.push(real) }" + }, + { + "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", + "column": 5, + "node": "if (realFolders.length === 0) { return true }" + } + ] +} 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 }