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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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")), + [], + ) +})