From 07ae2209b3c53bc1cd039b6d1f757a45e7486a31 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:15:24 +0800 Subject: [PATCH 01/11] ci(mutation-gate): surface per-file mutation results in the job summary The changed-code mutation gate already writes a package-level summary and uploads reports/mutation/ as the changed-code-mutation-report artifact, but a reader had to open the artifact (or the annotations) to find which files still have surviving or uncovered mutants. Add a step after the mutation run that parses each package's mutation.json and appends a per-file table (File | Mutants | Killed | Survived | NoCoverage | Score) to GITHUB_STEP_SUMMARY, sorted by Survived + NoCoverage, worst first. Document where to read it in AGENTS.md. No policy change: the job stays advisory and no suppression rule is added or altered. --- .github/workflows/mutation-testing.yml | 73 ++++++++++++++++++++++++++ AGENTS.md | 9 ++++ 2 files changed, 82 insertions(+) diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 2da0c5c5b4..47ddc09ce1 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -54,6 +54,79 @@ jobs: BASE_SHA="$(git rev-parse "$HEAD_SHA^1")" node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" + - name: Summarize per-file mutation results + if: always() && github.event_name == 'pull_request' + continue-on-error: true + run: | + node --input-type=module - <<'NODE' + import fs from "node:fs" + import path from "node:path" + + const REPORT_ROOT = "reports/mutation" + const VALID_STATUSES = new Set(["Killed", "Survived", "NoCoverage", "Timeout"]) + const rows = [] + const unreadable = [] + const directories = fs.existsSync(REPORT_ROOT) ? fs.readdirSync(REPORT_ROOT, { withFileTypes: true }) : [] + + for (const directory of directories) { + if (!directory.isDirectory()) continue + const reportFile = path.join(REPORT_ROOT, directory.name, "mutation.json") + if (!fs.existsSync(reportFile)) continue + let report + try { + report = JSON.parse(fs.readFileSync(reportFile, "utf8")) + } catch (error) { + unreadable.push(directory.name + ": " + error.message) + continue + } + for (const [filePath, file] of Object.entries(report.files ?? {})) { + const counts = { mutants: 0, killed: 0, survived: 0, noCoverage: 0 } + for (const mutant of file.mutants ?? []) { + if (!VALID_STATUSES.has(mutant.status)) continue + counts.mutants += 1 + if (mutant.status === "Killed") counts.killed += 1 + if (mutant.status === "Survived") counts.survived += 1 + if (mutant.status === "NoCoverage") counts.noCoverage += 1 + } + if (counts.mutants === 0) continue + const absolute = path.resolve(report.projectRoot ?? process.cwd(), filePath) + const relative = path.relative(process.cwd(), absolute).replaceAll("\\", "/") + rows.push({ file: relative.startsWith("..") ? filePath : relative, ...counts }) + } + } + + const lines = ["", "### Per-file mutation results", ""] + + if (rows.length === 0) { + lines.push("No Stryker JSON report was produced for this run; see the step log above for the reason.") + } else { + rows.sort((left, right) => { + const blocked = right.survived + right.noCoverage - (left.survived + left.noCoverage) + return blocked === 0 ? left.file.localeCompare(right.file) : blocked + }) + lines.push( + "Every file below comes from the `changed-code-mutation-report` artifact (`reports/mutation//mutation.json`). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as valid but not killed.", + "", + "| File | Mutants | Killed | Survived | NoCoverage | Score |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + ) + for (const row of rows) { + const score = ((row.killed / row.mutants) * 100).toFixed(1) + lines.push(`| \`${row.file}\` | ${row.mutants} | ${row.killed} | ${row.survived} | ${row.noCoverage} | ${score}% |`) + } + lines.push( + "", + "Close a surviving or uncovered mutant with a focused test that fails under the mutation; the artifact does not need to be downloaded to read this table.", + ) + } + + if (unreadable.length > 0) lines.push("", "Unreadable report(s): " + unreadable.join("; ")) + + const summary = lines.join("\n") + "\n" + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) + else process.stdout.write(summary) + NODE + - name: Upload mutation reports id: mutation_report if: always() && github.event_name == 'pull_request' diff --git a/AGENTS.md b/AGENTS.md index 79f91d1ef0..afbfc1e308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,15 @@ Prefer the narrowest test layer that proves the behavior. This follows standard - Use `it.each` for repeated cases and share the setup in a helper. Do not copy a test body per input. - A `Stryker disable` comment states the reason in one sentence. ESLint `max-len` limits it to 300 characters. +## Reading the changed-code mutation report + +The `mutation-diff` job publishes the changed-code mutation report in two places: + +- The job summary (`mutation-diff` → Summary tab) lists every mutated file with its `Survived` and `NoCoverage` counts, worst file first, followed by each surviving and uncovered mutant with its line, mutator, and replacement. Read this first; nothing needs to be downloaded. +- The same data is uploaded as the `changed-code-mutation-report` artifact (`reports/mutation//mutation.json` and `mutation.html`, retained 7 days) for a browsable view. + +Close a listed mutant by adding or strengthening a focused test that fails under the mutation. If a suppression is genuinely unavoidable, follow the existing `Stryker disable` reason rule under Test Placement Guidelines above. + ## Task Lifecycle Changes - Read `docs/architecture/task-lifecycle-model.md` before changing task status, delegation, interruption, completion, abandonment, persistence ownership, or scheduler fan-out behavior. From 82625a5a132337043694f84ac87a9162255bf825 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:53:06 +0800 Subject: [PATCH 02/11] fix(ci): guard the per-file mutation summary instead of a second continue-on-error scripts/stryker-diff.test.mjs asserts the mutation workflow contains exactly one continue-on-error (the artifact upload), so the new summary step cannot use it. Guard the summary write and the report scan instead: a failure now emits a ::warning and the step still exits 0, keeping the advisory job non-blocking. node --test scripts/stryker-diff.test.mjs: 44/44 pass. --- .github/workflows/mutation-testing.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 47ddc09ce1..38d8be0c7c 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -56,7 +56,6 @@ jobs: - name: Summarize per-file mutation results if: always() && github.event_name == 'pull_request' - continue-on-error: true run: | node --input-type=module - <<'NODE' import fs from "node:fs" @@ -66,7 +65,13 @@ jobs: const VALID_STATUSES = new Set(["Killed", "Survived", "NoCoverage", "Timeout"]) const rows = [] const unreadable = [] - const directories = fs.existsSync(REPORT_ROOT) ? fs.readdirSync(REPORT_ROOT, { withFileTypes: true }) : [] + let directories = [] + + try { + directories = fs.existsSync(REPORT_ROOT) ? fs.readdirSync(REPORT_ROOT, { withFileTypes: true }) : [] + } catch (error) { + unreadable.push("reports/mutation: " + error.message) + } for (const directory of directories) { if (!directory.isDirectory()) continue @@ -123,8 +128,13 @@ jobs: if (unreadable.length > 0) lines.push("", "Unreadable report(s): " + unreadable.join("; ")) const summary = lines.join("\n") + "\n" - if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) - else process.stdout.write(summary) + + try { + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) + else process.stdout.write(summary) + } catch (error) { + console.log("::warning title=Mutation test advisory::Could not write the per-file mutation summary: " + error.message) + } NODE - name: Upload mutation reports From d0307b047219e57128a8f0165a612e5293f66be4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:59:04 +0800 Subject: [PATCH 03/11] docs: describe the mutation summary counts and point detail work at the artifact The job summary lists per-file Survived/NoCoverage counts; the per-mutant line/mutator/replacement detail lives in the changed-code-mutation-report artifact. The previous wording claimed the summary carried the detail, which the step does not emit. --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afbfc1e308..deaad9301f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,8 @@ Prefer the narrowest test layer that proves the behavior. This follows standard The `mutation-diff` job publishes the changed-code mutation report in two places: -- The job summary (`mutation-diff` → Summary tab) lists every mutated file with its `Survived` and `NoCoverage` counts, worst file first, followed by each surviving and uncovered mutant with its line, mutator, and replacement. Read this first; nothing needs to be downloaded. -- The same data is uploaded as the `changed-code-mutation-report` artifact (`reports/mutation//mutation.json` and `mutation.html`, retained 7 days) for a browsable view. +- The job summary (`mutation-diff` → Summary tab) lists every mutated file with its `Survived` and `NoCoverage` counts, worst file first. Use the `changed-code-mutation-report` artifact to inspect each surviving or uncovered mutant's line, mutator, and replacement. +- The report is uploaded as the `changed-code-mutation-report` artifact (`reports/mutation//mutation.json` and `mutation.html`, retained 7 days) for a browsable view. Close a listed mutant by adding or strengthening a focused test that fails under the mutation. If a suppression is genuinely unavoidable, follow the existing `Stryker disable` reason rule under Test Placement Guidelines above. From bd71f74d28da78a32499056fc06815b5fb16cd81 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:17:32 +0800 Subject: [PATCH 04/11] ci(mutation): extract and harden the per-file summary script Review findings on the inline summary: report.files keys were trusted and inserted into a Markdown table, and the logic had no committed coverage. The summary now lives in scripts/mutation-summary.mjs, which validates the report shape, normalises and validates every path (repo-relative, safe characters, no traversal, no NUL), escapes Markdown/backslash/CR/LF before rendering and skips unsafe entries with a note. scripts/mutation-summary.test.mjs covers status counting (including Timeout), sorting, path normalisation, empty/missing/malformed reports and the unsafe-path rejects; test:mutation-ci runs it in CI. --- .github/workflows/mutation-testing.yml | 84 +------------- package.json | 2 +- scripts/mutation-summary.mjs | 155 +++++++++++++++++++++++++ scripts/mutation-summary.test.mjs | 106 +++++++++++++++++ 4 files changed, 266 insertions(+), 81 deletions(-) create mode 100644 scripts/mutation-summary.mjs create mode 100644 scripts/mutation-summary.test.mjs diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 38d8be0c7c..d71907f1ff 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -56,86 +56,10 @@ jobs: - name: Summarize per-file mutation results if: always() && github.event_name == 'pull_request' - run: | - node --input-type=module - <<'NODE' - import fs from "node:fs" - import path from "node:path" - - const REPORT_ROOT = "reports/mutation" - const VALID_STATUSES = new Set(["Killed", "Survived", "NoCoverage", "Timeout"]) - const rows = [] - const unreadable = [] - let directories = [] - - try { - directories = fs.existsSync(REPORT_ROOT) ? fs.readdirSync(REPORT_ROOT, { withFileTypes: true }) : [] - } catch (error) { - unreadable.push("reports/mutation: " + error.message) - } - - for (const directory of directories) { - if (!directory.isDirectory()) continue - const reportFile = path.join(REPORT_ROOT, directory.name, "mutation.json") - if (!fs.existsSync(reportFile)) continue - let report - try { - report = JSON.parse(fs.readFileSync(reportFile, "utf8")) - } catch (error) { - unreadable.push(directory.name + ": " + error.message) - continue - } - for (const [filePath, file] of Object.entries(report.files ?? {})) { - const counts = { mutants: 0, killed: 0, survived: 0, noCoverage: 0 } - for (const mutant of file.mutants ?? []) { - if (!VALID_STATUSES.has(mutant.status)) continue - counts.mutants += 1 - if (mutant.status === "Killed") counts.killed += 1 - if (mutant.status === "Survived") counts.survived += 1 - if (mutant.status === "NoCoverage") counts.noCoverage += 1 - } - if (counts.mutants === 0) continue - const absolute = path.resolve(report.projectRoot ?? process.cwd(), filePath) - const relative = path.relative(process.cwd(), absolute).replaceAll("\\", "/") - rows.push({ file: relative.startsWith("..") ? filePath : relative, ...counts }) - } - } - - const lines = ["", "### Per-file mutation results", ""] - - if (rows.length === 0) { - lines.push("No Stryker JSON report was produced for this run; see the step log above for the reason.") - } else { - rows.sort((left, right) => { - const blocked = right.survived + right.noCoverage - (left.survived + left.noCoverage) - return blocked === 0 ? left.file.localeCompare(right.file) : blocked - }) - lines.push( - "Every file below comes from the `changed-code-mutation-report` artifact (`reports/mutation//mutation.json`). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as valid but not killed.", - "", - "| File | Mutants | Killed | Survived | NoCoverage | Score |", - "| --- | ---: | ---: | ---: | ---: | ---: |", - ) - for (const row of rows) { - const score = ((row.killed / row.mutants) * 100).toFixed(1) - lines.push(`| \`${row.file}\` | ${row.mutants} | ${row.killed} | ${row.survived} | ${row.noCoverage} | ${score}% |`) - } - lines.push( - "", - "Close a surviving or uncovered mutant with a focused test that fails under the mutation; the artifact does not need to be downloaded to read this table.", - ) - } - - if (unreadable.length > 0) lines.push("", "Unreadable report(s): " + unreadable.join("; ")) - - const summary = lines.join("\n") + "\n" - - try { - if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) - else process.stdout.write(summary) - } catch (error) { - console.log("::warning title=Mutation test advisory::Could not write the per-file mutation summary: " + error.message) - } - NODE + # The summary logic lives in scripts/mutation-summary.mjs so it is unit-tested; + # the script validates the Stryker report shape, rejects unsafe path keys and + # escapes Markdown before writing the table. + run: node scripts/mutation-summary.mjs - name: Upload mutation reports id: mutation_report diff --git a/package.json b/package.json index 4ca0b672c1..1e53cab9f4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:code-qa-ci": "node --test scripts/code-qa-workflow.test.mjs", - "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", + "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs scripts/mutation-summary.test.mjs", "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && tsx scripts/check-delegated-mode-readers.ts && pnpm tool-turn:model-check", "fanout-protocol:model-check": "tsx scripts/check-task-fanout-protocol.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", diff --git a/scripts/mutation-summary.mjs b/scripts/mutation-summary.mjs new file mode 100644 index 0000000000..642104fa13 --- /dev/null +++ b/scripts/mutation-summary.mjs @@ -0,0 +1,155 @@ +import fs from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" + +/** + * Build the per-file mutation summary that the mutation-testing workflow appends to the job + * summary. + * + * The Stryker JSON reports are untrusted input for this script: report.files keys are file + * paths and they are rendered into a Markdown table. Every path is therefore validated + * (normalised repo-relative form, safe characters) and Markdown-escaped before insertion, and + * the report shape is checked rather than assumed. + */ + +const REPORT_DIRECTORY = "reports/mutation" +const VALID_STATUSES = new Set(["Killed", "Survived", "NoCoverage", "Timeout"]) +// Repo-relative paths only: letters, digits and the separators/characters a source path +// normally uses. Anything else (backticks, pipes, control characters, drive letters, ...) +// is rejected instead of rendered into the summary. +const SAFE_RELATIVE_PATH = /^[A-Za-z0-9._@+/-]+$/ +const BACKSLASH = String.fromCharCode(92) +const BACKTICK = String.fromCharCode(96) + +/** Escape the Markdown table metacharacters and line breaks a file path could carry. */ +export function escapeMarkdownCell(value) { + return String(value) + .split(BACKSLASH) + .join(BACKSLASH + BACKSLASH) + .split("|") + .join(BACKSLASH + "|") + .split(BACKTICK) + .join(BACKSLASH + BACKTICK) + .split("\r") + .join(BACKSLASH + "r") + .split("\n") + .join(BACKSLASH + "n") +} + +/** + * Return the normalised repo-relative path for one report entry, or undefined when the entry + * cannot be trusted for display. + */ +export function normalizeReportPath(filePath, projectRoot, cwd) { + if (typeof filePath !== "string" || filePath.length === 0) return undefined + if (filePath.includes("\u0000")) return undefined + const absolute = path.resolve(projectRoot ?? cwd, filePath) + const relative = path.relative(cwd, absolute).replaceAll("\\", "/") + if (relative.length === 0 || path.isAbsolute(relative) || relative.startsWith("..")) return undefined + if (!SAFE_RELATIVE_PATH.test(relative)) return undefined + return relative +} + +/** + * Collect per-file mutation counts from every report under reportRoot. + * + * Returns the Markdown lines for the summary (including the header when any row exists) and + * the list of unreadable reports that were skipped. + */ +export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = process.cwd() } = {}) { + const rows = [] + const unreadable = [] + let directories = [] + + try { + directories = fs.existsSync(reportRoot) ? fs.readdirSync(reportRoot, { withFileTypes: true }) : [] + } catch (error) { + unreadable.push("reports/mutation: " + error.message) + } + + for (const directory of directories) { + if (!directory.isDirectory()) continue + const reportFile = path.join(reportRoot, directory.name, "mutation.json") + if (!fs.existsSync(reportFile)) continue + let report + try { + report = JSON.parse(fs.readFileSync(reportFile, "utf8")) + } catch (error) { + unreadable.push(directory.name + ": " + error.message) + continue + } + if (report === null || typeof report !== "object" || report.files === null || typeof report.files !== "object") { + unreadable.push(directory.name + ": report has no files map") + continue + } + for (const [filePath, file] of Object.entries(report.files)) { + if (file === null || typeof file !== "object") continue + const counts = { mutants: 0, killed: 0, survived: 0, noCoverage: 0 } + const mutants = Array.isArray(file.mutants) ? file.mutants : [] + for (const mutant of mutants) { + if (mutant === null || typeof mutant !== "object") continue + if (!VALID_STATUSES.has(mutant.status)) continue + counts.mutants += 1 + if (mutant.status === "Killed") counts.killed += 1 + if (mutant.status === "Survived") counts.survived += 1 + if (mutant.status === "NoCoverage") counts.noCoverage += 1 + } + if (counts.mutants === 0) continue + const relative = normalizeReportPath(filePath, report.projectRoot, cwd) + if (relative === undefined) { + unreadable.push(directory.name + ": rejected unsafe report path") + continue + } + rows.push({ file: relative, ...counts }) + } + } + + const lines = ["", "### Per-file mutation results", ""] + + if (rows.length === 0) { + lines.push("No Stryker JSON report was produced for this run; see the step log above for the reason.") + } else { + rows.sort((left, right) => { + const blocked = right.survived + right.noCoverage - (left.survived + left.noCoverage) + return blocked === 0 ? left.file.localeCompare(right.file) : blocked + }) + lines.push( + "Every file below comes from the " + BACKTICK + "changed-code-mutation-report" + BACKTICK + " artifact (" + BACKTICK + "reports/mutation//mutation.json" + BACKTICK + "). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as valid but not killed.", + "", + "| File | Mutants | Killed | Survived | NoCoverage | Score |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + ) + for (const row of rows) { + const score = ((row.killed / row.mutants) * 100).toFixed(1) + lines.push( + "| " + BACKTICK + escapeMarkdownCell(row.file) + BACKTICK + " | " + row.mutants + " | " + row.killed + " | " + row.survived + " | " + row.noCoverage + " | " + score + "% |", + ) + } + lines.push( + "", + "Close a surviving or uncovered mutant with a focused test that fails under the mutation; the artifact does not need to be downloaded to read this table.", + ) + } + + if (unreadable.length > 0) lines.push("", "Unreadable report(s): " + unreadable.map(escapeMarkdownCell).join("; ")) + + return { lines, rows, unreadable } +} + +/** Entry point used by the workflow: append the summary to GITHUB_STEP_SUMMARY (or stdout). */ +export function writeMutationSummary({ reportRoot = REPORT_DIRECTORY, cwd = process.cwd(), outputFile = process.env.GITHUB_STEP_SUMMARY } = {}) { + const { lines } = summarizeMutationReports({ reportRoot, cwd }) + const summary = lines.join("\n") + "\n" + try { + if (outputFile) fs.appendFileSync(outputFile, summary) + else process.stdout.write(summary) + } catch (error) { + console.log("::warning title=Mutation test advisory::Could not write the per-file mutation summary: " + error.message) + } + return summary +} + +// When executed directly (the workflow step), write the summary for this run. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + writeMutationSummary() +} diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs new file mode 100644 index 0000000000..75c276e1d6 --- /dev/null +++ b/scripts/mutation-summary.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" + +import { escapeMarkdownCell, normalizeReportPath, summarizeMutationReports } from "./mutation-summary.mjs" + +const BACKTICK = String.fromCharCode(96) + +function makeRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), "mutation-summary-")) +} + +function writeReport(root, packageName, report) { + const directory = path.join(root, packageName) + fs.mkdirSync(directory, { recursive: true }) + fs.writeFileSync(path.join(directory, "mutation.json"), JSON.stringify(report), "utf8") +} + +function mutants(...statuses) { + return statuses.map((status) => ({ status })) +} + +test("counts valid statuses including Timeout and skips unknown ones", () => { + const root = makeRoot() + writeReport(root, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed", "Survived", "NoCoverage", "Timeout", "Exploded") } } }) + const { lines: summaryLines, rows } = summarizeMutationReports({ reportRoot: root, cwd: root }) + assert.equal(rows.length, 1) + assert.deepEqual( + { mutants: rows[0].mutants, killed: rows[0].killed, survived: rows[0].survived, noCoverage: rows[0].noCoverage }, + { mutants: 4, killed: 1, survived: 1, noCoverage: 1 }, + ) + const text = summaryLines.join("\n") + assert.match(text, /src\/a\.ts/) + assert.match(text, /25\.0%/) +}) + +test("sorts by Survived + NoCoverage, worst first, then by path", () => { + const root = makeRoot() + writeReport(root, "pkg", { + files: { + "src/b.ts": { mutants: mutants("Killed", "Survived") }, + "src/a.ts": { mutants: mutants("Killed", "NoCoverage") }, + "src/c.ts": { mutants: mutants("Killed") }, + "src/d.ts": { mutants: mutants("Killed", "Survived") }, + }, + }) + const { rows } = summarizeMutationReports({ reportRoot: root, cwd: root }) + assert.deepEqual(rows.map((row) => row.file), ["src/a.ts", "src/b.ts", "src/d.ts", "src/c.ts"]) +}) + +test("reports an empty run when no reports exist", () => { + const root = makeRoot() + const { lines: summaryLines, rows } = summarizeMutationReports({ reportRoot: path.join(root, "missing"), cwd: root }) + assert.equal(rows.length, 0) + assert.match(summaryLines.join("\n"), /No Stryker JSON report was produced/) +}) + +test("keeps processing when one report is malformed and reports it", () => { + const root = makeRoot() + fs.mkdirSync(path.join(root, "broken"), { recursive: true }) + fs.writeFileSync(path.join(root, "broken", "mutation.json"), "{not json", "utf8") + writeReport(root, "good", { files: { "src/ok.ts": { mutants: mutants("Killed") } } }) + const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) + assert.equal(rows.length, 1) + assert.equal(unreadable.length, 1) + assert.match(unreadable[0], /broken/) +}) + +test("rejects reports whose files map is missing or not an object", () => { + const root = makeRoot() + writeReport(root, "nofiles", { mutants: [] }) + writeReport(root, "nullfiles", { files: null }) + const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) + assert.equal(rows.length, 0) + assert.equal(unreadable.filter((entry) => /no files map/.test(entry)).length, 2) +}) + +test("rejects unsafe report paths instead of rendering them", () => { + const root = makeRoot() + writeReport(root, "pkg", { + files: { + "src/ok.ts": { mutants: mutants("Killed") }, + "injected|path": { mutants: mutants("Killed") }, + "../outside.ts": { mutants: mutants("Killed") }, + }, + }) + const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) + assert.deepEqual(rows.map((row) => row.file), ["src/ok.ts"]) + assert.equal(unreadable.filter((entry) => /rejected unsafe report path/.test(entry)).length, 2) +}) + +test("escapes Markdown metacharacters in rendered cells", () => { + assert.equal(escapeMarkdownCell("a|b"), "a\\|b") + assert.equal(escapeMarkdownCell("a" + BACKTICK + "b"), "a\\" + BACKTICK + "b") + assert.equal(escapeMarkdownCell("a\r\nb"), "a\\r\\nb") +}) + +test("normalizes a project-root-relative report path against the working directory", () => { + const root = makeRoot() + const projectRoot = path.join(root, "pkg") + assert.equal(normalizeReportPath(path.join(projectRoot, "src", "x.ts"), projectRoot, root), "pkg/src/x.ts") + assert.equal(normalizeReportPath("src/x.ts", projectRoot, root), "pkg/src/x.ts") + assert.equal(normalizeReportPath("../../escape.ts", projectRoot, root), undefined) +}) From 2c64cb7ac0d63353a95fad8ecf45fc0a41605b2c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:20:11 +0800 Subject: [PATCH 05/11] style: format the mutation summary script and its tests --- scripts/mutation-summary.mjs | 43 +++++++++++++++++++++++++++---- scripts/mutation-summary.test.mjs | 26 +++++++++++++++---- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/scripts/mutation-summary.mjs b/scripts/mutation-summary.mjs index 642104fa13..0f10869d7e 100644 --- a/scripts/mutation-summary.mjs +++ b/scripts/mutation-summary.mjs @@ -78,7 +78,12 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = unreadable.push(directory.name + ": " + error.message) continue } - if (report === null || typeof report !== "object" || report.files === null || typeof report.files !== "object") { + if ( + report === null || + typeof report !== "object" || + report.files === null || + typeof report.files !== "object" + ) { unreadable.push(directory.name + ": report has no files map") continue } @@ -114,7 +119,15 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = return blocked === 0 ? left.file.localeCompare(right.file) : blocked }) lines.push( - "Every file below comes from the " + BACKTICK + "changed-code-mutation-report" + BACKTICK + " artifact (" + BACKTICK + "reports/mutation//mutation.json" + BACKTICK + "). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as valid but not killed.", + "Every file below comes from the " + + BACKTICK + + "changed-code-mutation-report" + + BACKTICK + + " artifact (" + + BACKTICK + + "reports/mutation//mutation.json" + + BACKTICK + + "). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as valid but not killed.", "", "| File | Mutants | Killed | Survived | NoCoverage | Score |", "| --- | ---: | ---: | ---: | ---: | ---: |", @@ -122,7 +135,21 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = for (const row of rows) { const score = ((row.killed / row.mutants) * 100).toFixed(1) lines.push( - "| " + BACKTICK + escapeMarkdownCell(row.file) + BACKTICK + " | " + row.mutants + " | " + row.killed + " | " + row.survived + " | " + row.noCoverage + " | " + score + "% |", + "| " + + BACKTICK + + escapeMarkdownCell(row.file) + + BACKTICK + + " | " + + row.mutants + + " | " + + row.killed + + " | " + + row.survived + + " | " + + row.noCoverage + + " | " + + score + + "% |", ) } lines.push( @@ -137,14 +164,20 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = } /** Entry point used by the workflow: append the summary to GITHUB_STEP_SUMMARY (or stdout). */ -export function writeMutationSummary({ reportRoot = REPORT_DIRECTORY, cwd = process.cwd(), outputFile = process.env.GITHUB_STEP_SUMMARY } = {}) { +export function writeMutationSummary({ + reportRoot = REPORT_DIRECTORY, + cwd = process.cwd(), + outputFile = process.env.GITHUB_STEP_SUMMARY, +} = {}) { const { lines } = summarizeMutationReports({ reportRoot, cwd }) const summary = lines.join("\n") + "\n" try { if (outputFile) fs.appendFileSync(outputFile, summary) else process.stdout.write(summary) } catch (error) { - console.log("::warning title=Mutation test advisory::Could not write the per-file mutation summary: " + error.message) + console.log( + "::warning title=Mutation test advisory::Could not write the per-file mutation summary: " + error.message, + ) } return summary } diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs index 75c276e1d6..9a63a721b5 100644 --- a/scripts/mutation-summary.test.mjs +++ b/scripts/mutation-summary.test.mjs @@ -24,11 +24,18 @@ function mutants(...statuses) { test("counts valid statuses including Timeout and skips unknown ones", () => { const root = makeRoot() - writeReport(root, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed", "Survived", "NoCoverage", "Timeout", "Exploded") } } }) + writeReport(root, "pkg", { + files: { "src/a.ts": { mutants: mutants("Killed", "Survived", "NoCoverage", "Timeout", "Exploded") } }, + }) const { lines: summaryLines, rows } = summarizeMutationReports({ reportRoot: root, cwd: root }) assert.equal(rows.length, 1) assert.deepEqual( - { mutants: rows[0].mutants, killed: rows[0].killed, survived: rows[0].survived, noCoverage: rows[0].noCoverage }, + { + mutants: rows[0].mutants, + killed: rows[0].killed, + survived: rows[0].survived, + noCoverage: rows[0].noCoverage, + }, { mutants: 4, killed: 1, survived: 1, noCoverage: 1 }, ) const text = summaryLines.join("\n") @@ -47,12 +54,18 @@ test("sorts by Survived + NoCoverage, worst first, then by path", () => { }, }) const { rows } = summarizeMutationReports({ reportRoot: root, cwd: root }) - assert.deepEqual(rows.map((row) => row.file), ["src/a.ts", "src/b.ts", "src/d.ts", "src/c.ts"]) + assert.deepEqual( + rows.map((row) => row.file), + ["src/a.ts", "src/b.ts", "src/d.ts", "src/c.ts"], + ) }) test("reports an empty run when no reports exist", () => { const root = makeRoot() - const { lines: summaryLines, rows } = summarizeMutationReports({ reportRoot: path.join(root, "missing"), cwd: root }) + const { lines: summaryLines, rows } = summarizeMutationReports({ + reportRoot: path.join(root, "missing"), + cwd: root, + }) assert.equal(rows.length, 0) assert.match(summaryLines.join("\n"), /No Stryker JSON report was produced/) }) @@ -87,7 +100,10 @@ test("rejects unsafe report paths instead of rendering them", () => { }, }) const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) - assert.deepEqual(rows.map((row) => row.file), ["src/ok.ts"]) + assert.deepEqual( + rows.map((row) => row.file), + ["src/ok.ts"], + ) assert.equal(unreadable.filter((entry) => /rejected unsafe report path/.test(entry)).length, 2) }) From cbe61429d1d732b6c31f3f95ae62af5eb990c0b6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:41:36 +0800 Subject: [PATCH 06/11] fix(mutation): score Timeout mutants as detected and validate projectRoot type Review threads: the table score now uses (Killed + Timeout) / Mutants, matching Stryker's mutation score, and a non-string projectRoot from a report is rejected instead of being resolved against. Tests updated (first fixture now 50.0%) plus a non-string projectRoot rejection case; 9 tests pass. --- scripts/mutation-summary.mjs | 10 +++++++--- scripts/mutation-summary.test.mjs | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/mutation-summary.mjs b/scripts/mutation-summary.mjs index 0f10869d7e..78c22cace2 100644 --- a/scripts/mutation-summary.mjs +++ b/scripts/mutation-summary.mjs @@ -42,6 +42,7 @@ export function escapeMarkdownCell(value) { */ export function normalizeReportPath(filePath, projectRoot, cwd) { if (typeof filePath !== "string" || filePath.length === 0) return undefined + if (projectRoot !== undefined && typeof projectRoot !== "string") return undefined if (filePath.includes("\u0000")) return undefined const absolute = path.resolve(projectRoot ?? cwd, filePath) const relative = path.relative(cwd, absolute).replaceAll("\\", "/") @@ -89,7 +90,7 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = } for (const [filePath, file] of Object.entries(report.files)) { if (file === null || typeof file !== "object") continue - const counts = { mutants: 0, killed: 0, survived: 0, noCoverage: 0 } + const counts = { mutants: 0, killed: 0, survived: 0, noCoverage: 0, timeout: 0 } const mutants = Array.isArray(file.mutants) ? file.mutants : [] for (const mutant of mutants) { if (mutant === null || typeof mutant !== "object") continue @@ -98,6 +99,7 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = if (mutant.status === "Killed") counts.killed += 1 if (mutant.status === "Survived") counts.survived += 1 if (mutant.status === "NoCoverage") counts.noCoverage += 1 + if (mutant.status === "Timeout") counts.timeout += 1 } if (counts.mutants === 0) continue const relative = normalizeReportPath(filePath, report.projectRoot, cwd) @@ -127,13 +129,15 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = BACKTICK + "reports/mutation//mutation.json" + BACKTICK + - "). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as valid but not killed.", + "). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as detected (matching Stryker's mutation score).", "", "| File | Mutants | Killed | Survived | NoCoverage | Score |", "| --- | ---: | ---: | ---: | ---: | ---: |", ) for (const row of rows) { - const score = ((row.killed / row.mutants) * 100).toFixed(1) + // Stryker counts a Timeout mutant as detected, so the mutation score numerator is + // Killed + Timeout (the table's Killed column stays Killed-only). + const score = (((row.killed + row.timeout) / row.mutants) * 100).toFixed(1) lines.push( "| " + BACKTICK + diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs index 9a63a721b5..442ee24c71 100644 --- a/scripts/mutation-summary.test.mjs +++ b/scripts/mutation-summary.test.mjs @@ -40,7 +40,8 @@ test("counts valid statuses including Timeout and skips unknown ones", () => { ) const text = summaryLines.join("\n") assert.match(text, /src\/a\.ts/) - assert.match(text, /25\.0%/) + // Stryker counts the Timeout mutant as detected: (1 killed + 1 timeout) / 4 mutants = 50%. + assert.match(text, /50\.0%/) }) test("sorts by Survived + NoCoverage, worst first, then by path", () => { @@ -113,6 +114,19 @@ test("escapes Markdown metacharacters in rendered cells", () => { assert.equal(escapeMarkdownCell("a\r\nb"), "a\\r\\nb") }) +test("rejects a non-string projectRoot instead of resolving against it", () => { + const root = makeRoot() + writeReport(root, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) + const report = JSON.parse(fs.readFileSync(path.join(root, "pkg", "mutation.json"), "utf8")) + report.projectRoot = 42 + fs.writeFileSync(path.join(root, "pkg", "mutation.json"), JSON.stringify(report), "utf8") + + const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) + + assert.equal(rows.length, 0) + assert.equal(unreadable.filter((entry) => /rejected unsafe report path/.test(entry)).length, 1) +}) + test("normalizes a project-root-relative report path against the working directory", () => { const root = makeRoot() const projectRoot = path.join(root, "pkg") From 73aedfa7a6b6c0da9ae32beed5383cd398015807 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:50:45 +0800 Subject: [PATCH 07/11] fix(mutation): harden summary rendering and write atomically Review rows: escape Markdown link/format delimiters ([, ], (, )) in rendered cells; append GITHUB_STEP_SUMMARY through a same-directory temp file plus atomic rename so a partial write cannot truncate the job summary; tests cover configured-file output, stdout fallback, the warning-on-failure path, and temp-root cleanup via t.after. 12 tests pass. --- scripts/mutation-summary.mjs | 35 +++++++++- scripts/mutation-summary.test.mjs | 107 ++++++++++++++++++++++++------ 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/scripts/mutation-summary.mjs b/scripts/mutation-summary.mjs index 78c22cace2..4176d2f313 100644 --- a/scripts/mutation-summary.mjs +++ b/scripts/mutation-summary.mjs @@ -30,6 +30,14 @@ export function escapeMarkdownCell(value) { .join(BACKSLASH + "|") .split(BACKTICK) .join(BACKSLASH + BACKTICK) + .split("[") + .join(BACKSLASH + "[") + .split("]") + .join(BACKSLASH + "]") + .split("(") + .join(BACKSLASH + "(") + .split(")") + .join(BACKSLASH + ")") .split("\r") .join(BACKSLASH + "r") .split("\n") @@ -167,6 +175,31 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = return { lines, rows, unreadable } } +/** + * Append through a crash-safe path: read the existing summary, write the combined content to a + * temporary file in the same directory, then rename it over the target. A partial append would + * leave the job summary truncated; the rename is atomic within one filesystem. + */ +function appendSummaryAtomically(outputFile, summary) { + const existing = fs.existsSync(outputFile) ? fs.readFileSync(outputFile, "utf8") : "" + const directory = path.dirname(outputFile) + const temporary = path.join( + directory, + "." + path.basename(outputFile) + "." + process.pid + "." + Date.now() + ".tmp", + ) + fs.writeFileSync(temporary, existing + summary) + try { + fs.renameSync(temporary, outputFile) + } catch (error) { + try { + fs.unlinkSync(temporary) + } catch { + // Best effort: the caller is already failing the write. + } + throw error + } +} + /** Entry point used by the workflow: append the summary to GITHUB_STEP_SUMMARY (or stdout). */ export function writeMutationSummary({ reportRoot = REPORT_DIRECTORY, @@ -176,7 +209,7 @@ export function writeMutationSummary({ const { lines } = summarizeMutationReports({ reportRoot, cwd }) const summary = lines.join("\n") + "\n" try { - if (outputFile) fs.appendFileSync(outputFile, summary) + if (outputFile) appendSummaryAtomically(outputFile, summary) else process.stdout.write(summary) } catch (error) { console.log( diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs index 442ee24c71..4aed9ddb01 100644 --- a/scripts/mutation-summary.test.mjs +++ b/scripts/mutation-summary.test.mjs @@ -4,12 +4,19 @@ import os from "node:os" import path from "node:path" import { test } from "node:test" -import { escapeMarkdownCell, normalizeReportPath, summarizeMutationReports } from "./mutation-summary.mjs" +import { + escapeMarkdownCell, + normalizeReportPath, + summarizeMutationReports, + writeMutationSummary, +} from "./mutation-summary.mjs" const BACKTICK = String.fromCharCode(96) -function makeRoot() { - return fs.mkdtempSync(path.join(os.tmpdir(), "mutation-summary-")) +function makeRoot(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mutation-summary-")) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + return root } function writeReport(root, packageName, report) { @@ -22,8 +29,8 @@ function mutants(...statuses) { return statuses.map((status) => ({ status })) } -test("counts valid statuses including Timeout and skips unknown ones", () => { - const root = makeRoot() +test("counts valid statuses including Timeout and skips unknown ones", (t) => { + const root = makeRoot(t) writeReport(root, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed", "Survived", "NoCoverage", "Timeout", "Exploded") } }, }) @@ -44,8 +51,8 @@ test("counts valid statuses including Timeout and skips unknown ones", () => { assert.match(text, /50\.0%/) }) -test("sorts by Survived + NoCoverage, worst first, then by path", () => { - const root = makeRoot() +test("sorts by Survived + NoCoverage, worst first, then by path", (t) => { + const root = makeRoot(t) writeReport(root, "pkg", { files: { "src/b.ts": { mutants: mutants("Killed", "Survived") }, @@ -61,8 +68,8 @@ test("sorts by Survived + NoCoverage, worst first, then by path", () => { ) }) -test("reports an empty run when no reports exist", () => { - const root = makeRoot() +test("reports an empty run when no reports exist", (t) => { + const root = makeRoot(t) const { lines: summaryLines, rows } = summarizeMutationReports({ reportRoot: path.join(root, "missing"), cwd: root, @@ -71,8 +78,8 @@ test("reports an empty run when no reports exist", () => { assert.match(summaryLines.join("\n"), /No Stryker JSON report was produced/) }) -test("keeps processing when one report is malformed and reports it", () => { - const root = makeRoot() +test("keeps processing when one report is malformed and reports it", (t) => { + const root = makeRoot(t) fs.mkdirSync(path.join(root, "broken"), { recursive: true }) fs.writeFileSync(path.join(root, "broken", "mutation.json"), "{not json", "utf8") writeReport(root, "good", { files: { "src/ok.ts": { mutants: mutants("Killed") } } }) @@ -82,8 +89,8 @@ test("keeps processing when one report is malformed and reports it", () => { assert.match(unreadable[0], /broken/) }) -test("rejects reports whose files map is missing or not an object", () => { - const root = makeRoot() +test("rejects reports whose files map is missing or not an object", (t) => { + const root = makeRoot(t) writeReport(root, "nofiles", { mutants: [] }) writeReport(root, "nullfiles", { files: null }) const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) @@ -91,8 +98,8 @@ test("rejects reports whose files map is missing or not an object", () => { assert.equal(unreadable.filter((entry) => /no files map/.test(entry)).length, 2) }) -test("rejects unsafe report paths instead of rendering them", () => { - const root = makeRoot() +test("rejects unsafe report paths instead of rendering them", (t) => { + const root = makeRoot(t) writeReport(root, "pkg", { files: { "src/ok.ts": { mutants: mutants("Killed") }, @@ -108,14 +115,15 @@ test("rejects unsafe report paths instead of rendering them", () => { assert.equal(unreadable.filter((entry) => /rejected unsafe report path/.test(entry)).length, 2) }) -test("escapes Markdown metacharacters in rendered cells", () => { +test("escapes Markdown metacharacters in rendered cells", (t) => { assert.equal(escapeMarkdownCell("a|b"), "a\\|b") assert.equal(escapeMarkdownCell("a" + BACKTICK + "b"), "a\\" + BACKTICK + "b") + assert.equal(escapeMarkdownCell("[x](y)"), "\\[x\\]\\(y\\)") assert.equal(escapeMarkdownCell("a\r\nb"), "a\\r\\nb") }) -test("rejects a non-string projectRoot instead of resolving against it", () => { - const root = makeRoot() +test("rejects a non-string projectRoot instead of resolving against it", (t) => { + const root = makeRoot(t) writeReport(root, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) const report = JSON.parse(fs.readFileSync(path.join(root, "pkg", "mutation.json"), "utf8")) report.projectRoot = 42 @@ -127,10 +135,69 @@ test("rejects a non-string projectRoot instead of resolving against it", () => { assert.equal(unreadable.filter((entry) => /rejected unsafe report path/.test(entry)).length, 1) }) -test("normalizes a project-root-relative report path against the working directory", () => { - const root = makeRoot() +test("normalizes a project-root-relative report path against the working directory", (t) => { + const root = makeRoot(t) const projectRoot = path.join(root, "pkg") assert.equal(normalizeReportPath(path.join(projectRoot, "src", "x.ts"), projectRoot, root), "pkg/src/x.ts") assert.equal(normalizeReportPath("src/x.ts", projectRoot, root), "pkg/src/x.ts") assert.equal(normalizeReportPath("../../escape.ts", projectRoot, root), undefined) }) + +test("writeMutationSummary appends to the configured file atomically", (t) => { + const root = makeRoot(t) + const reportRoot = path.join(root, "reports", "mutation") + writeReport(reportRoot, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) + const outputFile = path.join(root, "summary.md") + fs.writeFileSync(outputFile, "existing summary\n", "utf8") + + const summary = writeMutationSummary({ reportRoot, cwd: root, outputFile }) + + const content = fs.readFileSync(outputFile, "utf8") + assert.match(content, /existing summary/) + assert.match(content, /Per-file mutation results/) + assert.ok(content.endsWith(summary)) + // The atomic append leaves no temporary sibling behind. + assert.deepEqual( + fs.readdirSync(root).filter((entry) => entry.includes(".tmp")), + [], + ) +}) + +test("writeMutationSummary writes to stdout when no output file is configured", (t) => { + const root = makeRoot(t) + const reportRoot = path.join(root, "reports", "mutation") + writeReport(reportRoot, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) + const written = [] + const originalWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = (chunk) => { + written.push(String(chunk)) + return true + } + t.after(() => { + process.stdout.write = originalWrite + }) + + writeMutationSummary({ reportRoot, cwd: root, outputFile: undefined }) + + assert.match(written.join(""), /Per-file mutation results/) +}) + +test("writeMutationSummary warns instead of throwing when the output cannot be written", (t) => { + const root = makeRoot(t) + const reportRoot = path.join(root, "reports", "mutation") + writeReport(reportRoot, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) + const logs = [] + const originalLog = console.log + console.log = (message) => { + logs.push(String(message)) + } + t.after(() => { + console.log = originalLog + }) + + // The directory does not exist, so the atomic write must fail and be reported. + assert.doesNotThrow(() => + writeMutationSummary({ reportRoot, cwd: root, outputFile: path.join(root, "missing", "summary.md") }), + ) + assert.match(logs.join("\n"), /Could not write the per-file mutation summary/) +}) From e04c8bb25deca1ff14e29e350fed0876f655eb5d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:51:06 +0800 Subject: [PATCH 08/11] docs(mutation): state the score formula as (Killed + Timeout) / Mutants --- scripts/mutation-summary.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/mutation-summary.mjs b/scripts/mutation-summary.mjs index 4176d2f313..6b3839362a 100644 --- a/scripts/mutation-summary.mjs +++ b/scripts/mutation-summary.mjs @@ -137,7 +137,7 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = BACKTICK + "reports/mutation//mutation.json" + BACKTICK + - "). Rows are sorted by Survived + NoCoverage, worst first; Score is Killed / Mutants, counting Timeout mutants as detected (matching Stryker's mutation score).", + "). Rows are sorted by Survived + NoCoverage, worst first; Score is (Killed + Timeout) / Mutants, matching Stryker's mutation score.", "", "| File | Mutants | Killed | Survived | NoCoverage | Score |", "| --- | ---: | ---: | ---: | ---: | ---: |", From 547b64baf3bd0a79baf4195a2e5b087db9d12969 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:01:16 +0800 Subject: [PATCH 09/11] test(mutation): force the stdout branch in CI In CI GITHUB_STEP_SUMMARY is set, so outputFile: undefined took the file default and the captured stdout stayed empty, failing test:mutation-ci. The test now passes an explicit empty outputFile to exercise the stdout fallback deterministically; verified with the env var both unset and set (12 tests pass). --- scripts/mutation-summary.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs index 4aed9ddb01..744ccf08f5 100644 --- a/scripts/mutation-summary.test.mjs +++ b/scripts/mutation-summary.test.mjs @@ -177,7 +177,8 @@ test("writeMutationSummary writes to stdout when no output file is configured", process.stdout.write = originalWrite }) - writeMutationSummary({ reportRoot, cwd: root, outputFile: undefined }) + // An empty output file forces the stdout branch even when GITHUB_STEP_SUMMARY is set in CI. + writeMutationSummary({ reportRoot, cwd: root, outputFile: "" }) assert.match(written.join(""), /Per-file mutation results/) }) From 1fa318a79a2c309ffd4a3f67a4ab5f25f88da303 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:19:54 +0800 Subject: [PATCH 10/11] fix(mutation): render fixed diagnostics and clean up summary temp files Review rows: no raw parser/filesystem error text in the summary (fixed bounded diagnostics, report names rendered only when slug-shaped); appendSummaryAtomically wraps temp creation/write/rename in one try/finally and sweeps stale temp siblings. Tests: malformed report asserts fixed diagnostic; stale sibling removal and rename-failure cleanup; 14 tests pass. --- scripts/mutation-summary.mjs | 59 ++++++++++++++++++++++--------- scripts/mutation-summary.test.mjs | 46 +++++++++++++++++++++++- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/scripts/mutation-summary.mjs b/scripts/mutation-summary.mjs index 6b3839362a..a74bef071f 100644 --- a/scripts/mutation-summary.mjs +++ b/scripts/mutation-summary.mjs @@ -44,6 +44,14 @@ export function escapeMarkdownCell(value) { .join(BACKSLASH + "n") } +/** + * Render a report directory name for diagnostics. Package directory names are simple slugs; a + * name that does not match that shape is not rendered verbatim. + */ +function renderReportName(name) { + return /^[A-Za-z0-9._@+-]+$/.test(name) ? name : "report" +} + /** * Return the normalised repo-relative path for one report entry, or undefined when the entry * cannot be trusted for display. @@ -72,8 +80,9 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = try { directories = fs.existsSync(reportRoot) ? fs.readdirSync(reportRoot, { withFileTypes: true }) : [] - } catch (error) { - unreadable.push("reports/mutation: " + error.message) + } catch { + // Raw filesystem error text is untrusted input for the summary; keep a fixed diagnostic. + unreadable.push("reports/mutation: directory could not be read") } for (const directory of directories) { @@ -83,8 +92,9 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = let report try { report = JSON.parse(fs.readFileSync(reportFile, "utf8")) - } catch (error) { - unreadable.push(directory.name + ": " + error.message) + } catch { + // The parser message can quote report content; render a fixed, bounded diagnostic. + unreadable.push(renderReportName(directory.name) + ": invalid JSON") continue } if ( @@ -93,7 +103,7 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = report.files === null || typeof report.files !== "object" ) { - unreadable.push(directory.name + ": report has no files map") + unreadable.push(renderReportName(directory.name) + ": report has no files map") continue } for (const [filePath, file] of Object.entries(report.files)) { @@ -112,7 +122,7 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = if (counts.mutants === 0) continue const relative = normalizeReportPath(filePath, report.projectRoot, cwd) if (relative === undefined) { - unreadable.push(directory.name + ": rejected unsafe report path") + unreadable.push(renderReportName(directory.name) + ": rejected unsafe report path") continue } rows.push({ file: relative, ...counts }) @@ -183,20 +193,35 @@ export function summarizeMutationReports({ reportRoot = REPORT_DIRECTORY, cwd = function appendSummaryAtomically(outputFile, summary) { const existing = fs.existsSync(outputFile) ? fs.readFileSync(outputFile, "utf8") : "" const directory = path.dirname(outputFile) - const temporary = path.join( - directory, - "." + path.basename(outputFile) + "." + process.pid + "." + Date.now() + ".tmp", - ) - fs.writeFileSync(temporary, existing + summary) + const baseName = path.basename(outputFile) + const temporary = path.join(directory, "." + baseName + "." + process.pid + "." + Date.now() + ".tmp") + let renamed = false try { + fs.writeFileSync(temporary, existing + summary) fs.renameSync(temporary, outputFile) - } catch (error) { - try { - fs.unlinkSync(temporary) - } catch { - // Best effort: the caller is already failing the write. + renamed = true + } finally { + if (!renamed) { + try { + fs.unlinkSync(temporary) + } catch { + // Best effort: the caller is already failing the write. + } + } + } + // A cancelled earlier run can leave its temp file beside the summary; clean those up. + try { + for (const entry of fs.readdirSync(directory)) { + if (entry.startsWith("." + baseName + ".") && entry.endsWith(".tmp")) { + try { + fs.unlinkSync(path.join(directory, entry)) + } catch { + // Another writer may own it; never fail the summary for the sweep. + } + } } - throw error + } catch { + // The directory sweep is best effort. } } diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs index 744ccf08f5..9ac30dcc88 100644 --- a/scripts/mutation-summary.test.mjs +++ b/scripts/mutation-summary.test.mjs @@ -86,7 +86,8 @@ test("keeps processing when one report is malformed and reports it", (t) => { const { rows, unreadable } = summarizeMutationReports({ reportRoot: root, cwd: root }) assert.equal(rows.length, 1) assert.equal(unreadable.length, 1) - assert.match(unreadable[0], /broken/) + // Parser messages can quote report content: only the fixed diagnostic is rendered. + assert.equal(unreadable[0], "broken: invalid JSON") }) test("rejects reports whose files map is missing or not an object", (t) => { @@ -202,3 +203,46 @@ test("writeMutationSummary warns instead of throwing when the output cannot be w ) assert.match(logs.join("\n"), /Could not write the per-file mutation summary/) }) + +test("writeMutationSummary removes a stale summary temp sibling", (t) => { + const root = makeRoot(t) + const reportRoot = path.join(root, "reports", "mutation") + writeReport(reportRoot, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) + const outputFile = path.join(root, "summary.md") + fs.writeFileSync(outputFile, "existing summary\n", "utf8") + const stale = path.join(root, ".summary.md.other.123.tmp") + fs.writeFileSync(stale, "stale", "utf8") + + writeMutationSummary({ reportRoot, cwd: root, outputFile }) + + assert.equal(fs.existsSync(stale), false) + assert.deepEqual( + fs.readdirSync(root).filter((entry) => entry.includes(".tmp")), + [], + ) +}) + +test("writeMutationSummary cleans its temp file when the rename fails", (t) => { + const root = makeRoot(t) + const reportRoot = path.join(root, "reports", "mutation") + writeReport(reportRoot, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) + // The target is a directory, so the atomic rename fails after the temp file exists. + const outputFile = path.join(root, "summary.md") + fs.mkdirSync(outputFile, { recursive: true }) + const logs = [] + const originalLog = console.log + console.log = (message) => { + logs.push(String(message)) + } + t.after(() => { + console.log = originalLog + }) + + assert.doesNotThrow(() => writeMutationSummary({ reportRoot, cwd: root, outputFile })) + + assert.match(logs.join("\n"), /Could not write the per-file mutation summary/) + assert.deepEqual( + fs.readdirSync(root).filter((entry) => entry.includes(".tmp")), + [], + ) +}) From c44074288550f42f53a062df3c04abc7e75ca9c4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:54:57 +0800 Subject: [PATCH 11/11] test(mutation): exercise the real rename step in the summary cleanup test Review follow-up: the rename-failure test now uses an existing regular file as the target (so the initial summary read succeeds) and stubs fs.renameSync to fail once after the temp file is written. It asserts rename ran exactly once, the warning carries the EIO message, the temp file is removed, and the existing summary is untouched. --- scripts/mutation-summary.test.mjs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/mutation-summary.test.mjs b/scripts/mutation-summary.test.mjs index 9ac30dcc88..d186baff95 100644 --- a/scripts/mutation-summary.test.mjs +++ b/scripts/mutation-summary.test.mjs @@ -226,23 +226,35 @@ test("writeMutationSummary cleans its temp file when the rename fails", (t) => { const root = makeRoot(t) const reportRoot = path.join(root, "reports", "mutation") writeReport(reportRoot, "pkg", { files: { "src/a.ts": { mutants: mutants("Killed") } } }) - // The target is a directory, so the atomic rename fails after the temp file exists. + // A regular existing file is the target, so the initial read succeeds and the write really + // reaches the rename step; the rename itself then fails. const outputFile = path.join(root, "summary.md") - fs.mkdirSync(outputFile, { recursive: true }) + fs.writeFileSync(outputFile, "existing summary\n", "utf8") const logs = [] const originalLog = console.log + const originalRename = fs.renameSync + let renameAttempts = 0 console.log = (message) => { logs.push(String(message)) } + fs.renameSync = () => { + renameAttempts += 1 + throw Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + } t.after(() => { console.log = originalLog + fs.renameSync = originalRename }) assert.doesNotThrow(() => writeMutationSummary({ reportRoot, cwd: root, outputFile })) - assert.match(logs.join("\n"), /Could not write the per-file mutation summary/) + // The write got past the read and the temp write, and the temp file was cleaned up. + assert.equal(renameAttempts, 1) + assert.match(logs.join("\n"), /Could not write the per-file mutation summary: EIO/) assert.deepEqual( fs.readdirSync(root).filter((entry) => entry.includes(".tmp")), [], ) + // The failed rename left the existing summary untouched. + assert.equal(fs.readFileSync(outputFile, "utf8"), "existing summary\n") })