Skip to content

Commit f04839a

Browse files
committed
improve(ci): explain mutation gate failures
1 parent dea463c commit f04839a

3 files changed

Lines changed: 326 additions & 16 deletions

File tree

.github/workflows/mutation-testing.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,20 @@ jobs:
5858
run: node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA"
5959

6060
- name: Upload mutation reports
61+
id: mutation_report
6162
if: always() && github.event_name == 'pull_request'
6263
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
6364
with:
6465
name: changed-code-mutation-report
6566
path: reports/mutation/
6667
if-no-files-found: ignore
6768
retention-days: 7
69+
70+
- name: Link mutation report artifact
71+
if: always() && github.event_name == 'pull_request' && steps.mutation_report.outputs.artifact-url != ''
72+
env:
73+
ARTIFACT_URL: ${{ steps.mutation_report.outputs.artifact-url }}
74+
run: |
75+
echo "" >> "$GITHUB_STEP_SUMMARY"
76+
echo "### Download mutation reports" >> "$GITHUB_STEP_SUMMARY"
77+
echo "[Open the changed-code-mutation-report artifact]($ARTIFACT_URL), then open the package's mutation.html file." >> "$GITHUB_STEP_SUMMARY"

scripts/stryker-diff.mjs

Lines changed: 162 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -431,8 +431,7 @@ function escapeWorkflowValue(value) {
431431
.replaceAll(",", "%2C")
432432
}
433433

434-
export function formatAnnotations(blockingMutants, packageRoot) {
435-
const perFile = new Map()
434+
export function formatAnnotations(blockingMutants, packageRoot, state = { total: 0, perFile: new Map() }) {
436435
const annotations = []
437436

438437
for (const mutant of blockingMutants.sort((left, right) => {
@@ -441,9 +440,8 @@ export function formatAnnotations(blockingMutants, packageRoot) {
441440
})) {
442441
const repositoryPath = path.posix.join(packageRoot, mutant.filePath.replaceAll("\\", "/"))
443442
const key = `${repositoryPath}:${mutant.location.start.line}`
444-
const fileCount = perFile.get(repositoryPath) ?? 0
445-
if (annotations.some((annotation) => annotation.key === key) || fileCount >= 7 || annotations.length >= 20)
446-
continue
443+
const fileCount = state.perFile.get(repositoryPath) ?? 0
444+
if (annotations.some((annotation) => annotation.key === key) || fileCount >= 7 || state.total >= 20) continue
447445

448446
const replacement = String(mutant.replacement ?? "")
449447
.replace(/\s+/g, " ")
@@ -455,17 +453,59 @@ export function formatAnnotations(blockingMutants, packageRoot) {
455453
line: mutant.location.start.line,
456454
message:
457455
`${mutant.status} ${mutant.mutatorName} mutant${replacement ? ` (replacement: ${replacement})` : ""}. ` +
458-
"Add or strengthen a focused test that fails under this mutation, or add a maintainer-approved targeted exclusion with a reason.",
456+
"See the job summary for the complete list and resolution guidance.",
459457
})
460-
perFile.set(repositoryPath, fileCount + 1)
458+
state.perFile.set(repositoryPath, fileCount + 1)
459+
state.total++
461460
}
462461

463462
return annotations
464463
}
465464

466-
function appendSummary(rows, failures) {
467-
if (!process.env.GITHUB_STEP_SUMMARY) return
465+
function markdownCell(value) {
466+
return String(value ?? "—")
467+
.replace(/\s+/g, " ")
468+
.trim()
469+
.replaceAll("|", "\\|")
470+
.slice(0, 120)
471+
}
472+
473+
export function testsFromMutationReport(report, fallback = []) {
474+
const testFiles = Object.keys(report.testFiles ?? {})
475+
return testFiles.length > 0 ? testFiles : fallback
476+
}
477+
478+
export function formatBlockingMutants(blockingMutants, packageRoot) {
479+
const grouped = new Map()
480+
for (const mutant of [...blockingMutants].sort((left, right) => {
481+
const pathOrder = left.filePath.localeCompare(right.filePath)
482+
return pathOrder || left.location.start.line - right.location.start.line
483+
})) {
484+
const repositoryPath = path.posix.join(packageRoot, mutant.filePath.replaceAll("\\", "/"))
485+
const group = grouped.get(repositoryPath) ?? []
486+
group.push(mutant)
487+
grouped.set(repositoryPath, group)
488+
}
468489

490+
const lines = []
491+
for (const [filePath, mutants] of grouped) {
492+
lines.push(
493+
`#### \`${filePath}\``,
494+
"",
495+
"| Line | Status | Mutator | Replacement |",
496+
"| ---: | --- | --- | --- |",
497+
)
498+
for (const mutant of mutants) {
499+
lines.push(
500+
`| ${mutant.location.start.line} | ${markdownCell(mutant.status)} | ${markdownCell(mutant.mutatorName)} | ${markdownCell(mutant.replacement)} |`,
501+
)
502+
}
503+
lines.push("")
504+
}
505+
return lines
506+
}
507+
508+
export function formatSummary(rows, failures, manifest = {}) {
469509
const lines = [
470510
"## Changed-code mutation testing",
471511
"",
@@ -478,8 +518,89 @@ function appendSummary(rows, failures) {
478518
)
479519
}
480520
if (rows.length === 0) lines.push("| — | 0 | 0 | 0 | 0 | 0 | 0 | Not applicable |")
481-
if (failures.length > 0) lines.push("", ...failures.map((failure) => `- ${failure}`))
482-
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`)
521+
522+
if (rows.length > 0) {
523+
lines.push("", "### Focused tests")
524+
for (const row of rows) {
525+
const cwd = row.runRoot ?? row.root
526+
if (row.testFiles?.length > 0) {
527+
lines.push(`- **${row.id}** (cwd \`${cwd}\`): ${row.testFiles.map((file) => `\`${file}\``).join(", ")}`)
528+
} else {
529+
lines.push(
530+
`- **${row.id}** (cwd \`${cwd}\`): the mutation run did not complete far enough to report its selected tests; use the exact reproduction command below.`,
531+
)
532+
}
533+
}
534+
}
535+
536+
const blockingRows = rows.filter((row) => row.blocking?.length > 0)
537+
if (blockingRows.length > 0) {
538+
lines.push(
539+
"",
540+
"### All surviving and uncovered mutants",
541+
"",
542+
"Annotations highlight up to 20 unique locations (maximum 7 per file). This summary lists every blocking mutant.",
543+
"",
544+
)
545+
for (const row of blockingRows) {
546+
lines.push(`### ${row.id}`, "", ...formatBlockingMutants(row.blocking, row.runRoot ?? row.root))
547+
}
548+
lines.push(
549+
"### Resolve a mutation gap",
550+
"",
551+
"Add or strengthen a focused test that fails under the mutation. If the mutant is equivalent, request maintainer approval for the narrowest mutator-specific exclusion and explain why it cannot change behavior:",
552+
"",
553+
"```ts",
554+
"// Stryker disable next-line ConditionalExpression: normalized input cannot reach the alternate branch",
555+
"const result = condition ? value : fallback",
556+
"```",
557+
"",
558+
"Broad `all` exclusions and exclusions without a concrete reason are rejected by the gate.",
559+
)
560+
}
561+
562+
if (manifest.baseSha && manifest.headSha) {
563+
lines.push(
564+
"",
565+
"### Reproduce locally",
566+
"",
567+
"From a full checkout containing both commits:",
568+
"",
569+
"```bash",
570+
"pnpm install --frozen-lockfile",
571+
`node scripts/stryker-diff.mjs ci --base ${manifest.baseSha} --head ${manifest.headSha}`,
572+
"```",
573+
)
574+
}
575+
576+
if (rows.length > 0) {
577+
lines.push("", "### Mutation reports", "")
578+
for (const row of rows) lines.push(`- **${row.id}:** \`${row.reportPath}\``)
579+
lines.push(
580+
"",
581+
"The workflow uploads generated reports in the `changed-code-mutation-report` artifact. A direct artifact link appears below after upload.",
582+
)
583+
}
584+
585+
if (failures.length > 0) {
586+
lines.push(
587+
"",
588+
"### Failures",
589+
"",
590+
...failures.map((failure) => {
591+
const detail =
592+
failure.length > 4_000 ? `${failure.slice(0, 4_000)}\n[truncated; see the step log]` : failure
593+
return `- ${detail.replaceAll("\n", "\n ")}`
594+
}),
595+
)
596+
}
597+
598+
return `${lines.join("\n")}\n`
599+
}
600+
601+
function appendSummary(rows, failures, manifest) {
602+
if (!process.env.GITHUB_STEP_SUMMARY) return
603+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatSummary(rows, failures, manifest))
483604
}
484605

485606
export function evaluateReport(report, packageEntry) {
@@ -508,9 +629,13 @@ export function evaluateReport(report, packageEntry) {
508629
export function runManifest(repoRoot, manifest, reportRoot) {
509630
const rows = []
510631
const failures = []
632+
const annotationState = { total: 0, perFile: new Map() }
511633

512634
for (const packageEntry of manifest.packages) {
513635
let counts
636+
const reportPath = path
637+
.relative(repoRoot, path.join(reportRoot, packageEntry.id, "mutation.html"))
638+
.replaceAll("\\", "/")
514639
try {
515640
const reportDirectory = path.join(reportRoot, packageEntry.id)
516641
fs.mkdirSync(reportDirectory, { recursive: true })
@@ -531,6 +656,11 @@ export function runManifest(repoRoot, manifest, reportRoot) {
531656
if (generatedMutants === 0) {
532657
rows.push({
533658
id: packageEntry.id,
659+
root: packageEntry.root,
660+
runRoot: packageEntry.runRoot,
661+
selectors: packageEntry.selectors,
662+
testFiles: packageEntry.testFiles ?? [],
663+
reportPath,
534664
changedLines: packageEntry.changedExecutableLines,
535665
valid: 0,
536666
killed: 0,
@@ -543,17 +673,27 @@ export function runManifest(repoRoot, manifest, reportRoot) {
543673
}
544674

545675
runStryker(repoRoot, packageEntry, reportRoot, false)
546-
const reportPath = path.join(reportRoot, packageEntry.id, "mutation.json")
547-
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
676+
const jsonReportPath = path.join(reportRoot, packageEntry.id, "mutation.json")
677+
const report = JSON.parse(fs.readFileSync(jsonReportPath, "utf8"))
678+
packageEntry.testFiles = testsFromMutationReport(report, packageEntry.testFiles)
548679
counts = mutantCounts(report)
549-
for (const annotation of formatAnnotations(counts.blocking, packageEntry.runRoot ?? packageEntry.root)) {
680+
for (const annotation of formatAnnotations(
681+
counts.blocking,
682+
packageEntry.runRoot ?? packageEntry.root,
683+
annotationState,
684+
)) {
550685
console.log(
551686
`::error file=${escapeWorkflowValue(annotation.file)},line=${annotation.line},title=Mutation test gap::${escapeWorkflowValue(annotation.message)}`,
552687
)
553688
}
554689
evaluateReport(report, packageEntry)
555690
rows.push({
556691
id: packageEntry.id,
692+
root: packageEntry.root,
693+
runRoot: packageEntry.runRoot,
694+
selectors: packageEntry.selectors,
695+
testFiles: packageEntry.testFiles ?? [],
696+
reportPath,
557697
changedLines: packageEntry.changedExecutableLines,
558698
...counts,
559699
result: "Passed",
@@ -562,18 +702,24 @@ export function runManifest(repoRoot, manifest, reportRoot) {
562702
failures.push(error.message)
563703
rows.push({
564704
id: packageEntry.id,
705+
root: packageEntry.root,
706+
runRoot: packageEntry.runRoot,
707+
selectors: packageEntry.selectors,
708+
testFiles: packageEntry.testFiles ?? [],
709+
reportPath,
565710
changedLines: packageEntry.changedExecutableLines,
566711
valid: counts?.valid ?? 0,
567712
killed: counts?.killed ?? 0,
568713
timeout: counts?.timeout ?? 0,
569714
survived: counts?.survived ?? 0,
570715
noCoverage: counts?.noCoverage ?? 0,
716+
blocking: counts?.blocking ?? [],
571717
result: "Failed",
572718
})
573719
}
574720
}
575721

576-
appendSummary(rows, failures)
722+
appendSummary(rows, failures, manifest)
577723
if (failures.length > 0) throw new Error(failures.join("\n"))
578724
return rows
579725
}
@@ -596,7 +742,7 @@ function main() {
596742
const reportRoot = path.resolve(repoRoot, argument("--reports") ?? "reports/mutation")
597743
const manifest = selectFromGit(repoRoot, baseSha, headSha)
598744
if (manifest.packages.length === 0) {
599-
appendSummary([], [])
745+
appendSummary([], [], manifest)
600746
console.log("No changed executable lines in mutation-tested packages; mutation testing is not applicable.")
601747
return
602748
}

0 commit comments

Comments
 (0)