From 44c73ee1643de4c5f01a244363ee6098054b5fe1 Mon Sep 17 00:00:00 2001
From: Joshua Brown
Date: Thu, 8 Oct 2026 17:33:05 -0700
Subject: [PATCH] feat: the coverage report ranks the methods to add a test to
and says what to add
The summary, each file's page and the terminal listed one row per survived mutant, with the same eight test names under every one, and the code came after the rows. They now rank methods with survived mutants, most first, each with a sentence from its surest mutant saying what a new test has to tell apart, and the mutants fold under the row. A file's page shows its code first with a count in the gutter where mutants survived, one note open at a time, and next and previous survived. Source files are a tree worst first with scores coloured against 60 and 80 as Stryker colours them; test files are listed only when something needs fixing.
---
docs/coverage.md | 75 ++++---
src/coverage-advice.js | 40 ++++
src/coverage-html.js | 372 +++++++++++++++++++++++++++--------
src/coverage-report.js | 78 ++++++--
test/coverage-advice.test.js | 35 ++++
test/coverage-html.test.js | 123 ++++++++----
test/coverage-report.test.js | 70 ++++++-
7 files changed, 625 insertions(+), 168 deletions(-)
create mode 100644 src/coverage-advice.js
create mode 100644 test/coverage-advice.test.js
diff --git a/docs/coverage.md b/docs/coverage.md
index efeadd3..5da62a9 100644
--- a/docs/coverage.md
+++ b/docs/coverage.md
@@ -28,23 +28,26 @@ Point it at a repository:
```console
$ perch coverage
Source files Mutation score Survived No coverage
-src/cart.ts 71% (10 of 14) 4 0
src/checkout.ts 60% (3 of 5) 2 0
src/inventory.ts 63% (5 of 8) 2 0
+src/cart.ts 71% (10 of 14) 4 0
All source 67% (18 of 27) 8 0
Test files Quality Duplicates Checks nothing Live services
test/cart.test.ts 43% (3 of 7) 4 0 1
-test/checkout.test.ts 100% (1 of 1) 0 0 0
test/inventory.test.ts 75% (3 of 4) 0 1 0
All tests 58% (7 of 12) 4 1 1
-
-src/cart.ts
- ID Line Problem Confidence Test or method Note
- 0399f832 12 survived 87% applyDiscount With `101` instead of `100`, none of the …
- 1be27338 12 survived 87% applyDiscount With `>` instead of `>=`, none of the 5 t…
- 20fb814a 12 survived 87% applyDiscount With `1` instead of `0`, none of the 5 te…
- 883b4b21 12 survived 84% applyDiscount With `false` as the condition, none of th…
+1 test file has nothing to fix.
+
+Where to add tests
+ Method Where Killed Survived Tests
+ applyDiscount src/cart.ts:11 6 of 10 4 5
+ Add a test that asserts on the value `100` at line 12. 3 more edits survive.
+ placeOrder src/checkout.ts:5 3 of 5 2 1
+ Add a test in which the condition at line 6 is true, and assert on what follows. 1 more edit
+ survives.
+ canFulfil src/inventory.ts:4 5 of 8 2 4
+ Add a test that asserts on the value `0` at line 6. 1 more edit survives.
test/cart.test.ts
ID Line Problem Confidence Test or method Note
@@ -57,7 +60,7 @@ test/cart.test.ts
test/inventory.test.ts
ID Line Problem Confidence Test or method Note
98a1a024 23 mocked - canFulfil > returns … Mocks every method it calls: canFul…
-shop at commit 0ade512: 4 methods, 12 tests, 14 problems, 10 shown, --all for the rest
+shop at commit 0ade512: 4 methods, 12 tests, 14 problems, --all for every mutant
Report: .perch/coverage/index.html
28 requests 0 tokens in $0.0000
```
@@ -65,21 +68,31 @@ Report: .perch/coverage/index.html
The mutation score is the share of mutants killed by at least one test. A test
kills a mutant when Perch puts its chance of failing against it at 70% or more.
No coverage counts the mutants in methods no test reaches through the call
-graph. They count against the score. Perch lists each survived mutant with the
-edit it made and the number of tests that miss it. The confidence is how sure
-Perch is that no test kills the mutant and that the mutant changes what a caller
-sees. A test that checks nothing kills no mutant in the code it reaches. A
-duplicate kills exactly the mutants an earlier test kills. A test that mocks
-what it tests calls only methods it has replaced with its own mocks, so it
-checks the mocks. That is a fact of the call graph, so it is listed with `-`
-for confidence. So is a problem on a test or method the model couldn't be asked
-about. Use `--all` to see every row.
-
-Perch also writes an HTML report. It shows each line of source with its problems
-under it. For a survived mutant, it shows the original and the mutated version
-of the line, and the tests that still pass. The report is one file, unless your
-repository has more than 8 MB of source. Then each file gets its own page in
-the `files/` directory next to `index.html`.
+graph. They count against the score. Source files are listed worst first, and
+test files only when something in them needs fixing.
+
+Under the tables, Perch lists the methods to add a test to, most survived
+mutants first. Each has a line of numbers and, under it, the test to add, from
+its surest survived mutant: the edit a new test has to tell apart, and the line
+it is on. `--all` lists every method, and every survived mutant with the id
+that `perch close` takes. Test problems follow, by file. The confidence is how
+sure Perch is. A test that checks nothing kills no mutant in the code it
+reaches. A duplicate kills exactly the mutants an earlier test kills. A test
+that mocks what it tests calls only methods it has replaced with its own mocks,
+so it checks the mocks. That is a fact of the call graph, so it is listed with
+`-` for confidence. So is a problem on a test or method the model couldn't be
+asked about.
+
+Perch also writes an HTML report. It opens on the methods to add a test to,
+each with its score, how many tests reach it, and the test to add; the mutants
+fold out under the row. Source files are a tree of directories, worst first,
+with each score coloured against 60% and 80% as Stryker colours its own. A
+file's page shows its code with a mark in the gutter on each line where a
+mutant survived; press the mark to see each edit, the line both ways, and a
+Copy prompt button, or step through the survivors with the Next survived
+button. The report is one file, unless your repository has more than 8 MB of
+source. Then each file gets its own page in the `files/` directory next to
+`index.html`.
## How it differs from a coverage report
@@ -200,17 +213,15 @@ changed on the branch:
```console
$ perch coverage --since main
-src/cart.ts
- ID Line Problem Confidence Test or method Note
- 20fb814a 12 survived 86% applyDiscount With `1` instead of `0`, none of the 5 te…
- 1be27338 12 survived 85% applyDiscount With `>` instead of `>=`, none of the 5 t…
- 0399f832 12 survived 84% applyDiscount With `101` instead of `100`, none of the …
- 883b4b21 12 survived 81% applyDiscount With `false` as the condition, none of th…
+Where to add tests
+ Method Where Killed Survived Tests
+ applyDiscount src/cart.ts:11 6 of 10 4 5
+ Add a test that asserts on what line 12 returns. 3 more edits survive.
test/cart.test.ts
ID Line Problem Confidence Test or method Note
427fce7a 22 redundant 76% applyDiscount > tak… Kills the same mutants as applyDis…
-shop at commit 6fe98ce: 4 methods, 12 tests, 5 problems in changed code, 9 elsewhere
+shop at commit 6fe98ce: 4 methods, 12 tests, 5 problems in changed code, 9 elsewhere, --all for every mutant
Report: .perch/coverage/index.html
28 requests 0 tokens in $0.0000
```
diff --git a/src/coverage-advice.js b/src/coverage-advice.js
new file mode 100644
index 0000000..edac7bc
--- /dev/null
+++ b/src/coverage-advice.js
@@ -0,0 +1,40 @@
+/**
+ * What test to add for a survived mutant, in one sentence, from the kind of edit it is. The sentence is fixed wording around the
+ * report's own facts: the line, and the text the edit changed. It says what a test has to tell apart, not how to write it.
+ */
+
+/** A piece of code in a sentence, cut to one line of it. */
+const edit = text => {
+ const one = String(text ?? '').trim().replace(/\s+/g, ' ');
+ return `\`${one.length > 60 ? `${one.slice(0, 57)}…` : one}\``;
+};
+
+/** The other value of a forced condition: a test is missing for the case the mutant removed. */
+const otherBranch = to => (/^(true|True)$/.test(String(to).trim()) ? 'false' : 'true');
+
+export function adviceFor(mutant) {
+ const line = mutant.line;
+ switch (mutant.kind) {
+ case 'body': return 'Add a test that fails when the method does nothing.';
+ case 'removal': return `Add a test that fails when ${edit(mutant.from)} is not called.`;
+ case 'block': return `Add a test that fails when the block at line ${line} is skipped.`;
+ case 'condition': return `Add a test in which the condition at line ${line} is ${otherBranch(mutant.to)}, and assert on what follows.`;
+ case 'boundary': return `Add a test at the boundary of ${edit(mutant.from)} on line ${line}, where ${edit(mutant.from)} and ${edit(mutant.to)} give different results.`;
+ case 'logic': return `Add a test in which only one side of ${edit(mutant.from)} on line ${line} holds.`;
+ case 'string': return `Add a test that asserts on the text ${edit(mutant.from)} at line ${line}.`;
+ case 'number': return `Add a test that asserts on the value ${edit(mutant.from)} at line ${line}.`;
+ case 'return': return `Add a test that asserts on what line ${line} returns.`;
+ case 'arithmetic': case 'update': return `Add a test that asserts on the arithmetic at line ${line}, where ${edit(mutant.from)} can become ${edit(mutant.to)}.`;
+ default: return `Add a test that asserts on the result of line ${line}, where ${edit(mutant.from)} can become ${edit(mutant.to)} unnoticed.`;
+ }
+}
+
+/**
+ * The test to add for a method, from its surest survived mutant, and how many more edits survive beside it. `mutants` are the
+ * method's listed survived mutants, surest first.
+ */
+export function methodAdvice(mutants) {
+ if (!mutants.length) return '';
+ const more = mutants.length - 1;
+ return `${adviceFor(mutants[0])}${more ? ` ${more} more ${more === 1 ? 'edit survives' : 'edits survive'}.` : ''}`;
+}
diff --git a/src/coverage-html.js b/src/coverage-html.js
index e9856b9..859c7ef 100644
--- a/src/coverage-html.js
+++ b/src/coverage-html.js
@@ -5,6 +5,7 @@
*/
import { createHash } from 'node:crypto';
import { WORDMARK, WORDMARK_LIGHT } from './brand.js';
+import { methodAdvice } from './coverage-advice.js';
/** Source text and names come from the repository being read, so every one of them is escaped before it becomes markup. */
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
@@ -27,10 +28,10 @@ const when = stamp => String(stamp ?? '').replace('T', ' ').replace(/:\d\d(\.\d+
const counted = (count, one, many = `${one}s`) => `${escape(count)} ${count === 1 ? one : many}`;
/**
- * The colour a percentage is drawn in. The bands are the ones Istanbul draws, so a page that looks like a coverage report reads
- * like one too. They colour a number the report gave; they do not grade anything the number does not already say.
+ * The colour a percentage is drawn in: red under 60, amber to 80, green from there, the thresholds Stryker ships with, so a score
+ * here reads the same as one in its report. They colour a number the report gave; they do not grade anything it does not say.
*/
-const band = value => (value === null || value === undefined ? 'none' : value < 0.5 ? 'low' : value < 0.8 ? 'mid' : 'high');
+const band = value => (value === null || value === undefined ? 'none' : value < 0.6 ? 'low' : value < 0.8 ? 'mid' : 'high');
/**
* A link to a line of a file: a section of this page, or with `page`, the page of its own that file was written to. The path
@@ -224,44 +225,92 @@ const copyAll = '
');
- expect(under(html, 'src/cart.py', 6)).toContain('Still passes: test_discount_10 and test_discount_20.');
- expect(under(html, 'src/cart.py', 6)).not.toContain('% sure');
- // The tests that reach the method are listed once, in its bar above the code, not again in the gap's note.
- expect(under(html, 'src/cart.py', 6)).not.toContain('Reached by');
+ // The line's note holds its survived mutants, folded until the mark in the gutter is pressed: each one's edit in words and the
+ // line both ways; no percentage when perch is sure (82% is sure enough).
+ const note = under(html, 'src/cart.py', 6);
+ expect(note).toContain('▲');
+ expect(note).toContain('
1 survived mutanton line 6
');
+ expect(note).toContain('
With `>=` instead of `>`, none of the 2 tests reaching it fails.
');
+ expect(note).toContain('
- if percent > 100:\n+ if percent >= 100:
');
+ expect(note).not.toContain('% sure');
+ // The tests that reach the method are listed once, in its bar above the code, not again under every mutant: a survived mutant
+ // is one none of them kills, so a list of which still pass would be the same list.
+ expect(note).not.toContain('Still passes');
+ expect(note).not.toContain('Reached by');
expect(under(html, 'src/cart.py', 2)).toContain('test_discount_10');
+ // The bar above the method says what test to add, from its surest survived mutant.
+ expect(under(html, 'src/cart.py', 2)).toContain('Add a test at the boundary of `>` on line 6, where `>` and `>=` give different results.');
expect(under(html, 'src/cart.py', 5)).not.toContain('Survived mutant');
expect(under(html, 'src/cart.py', 7)).not.toContain('Survived mutant');
// A method no test reaches is said so above its code, and is not a problem: the call graph alone lists nothing.
@@ -157,12 +164,19 @@ describe('coverage HTML report', () => {
expect(under(html, 'tests/test_cart.py', 3)).not.toContain('class="note');
expect(lineClasses(html, 'tests/test_cart.py', 3)).toEqual(['l']);
expect(lineClasses(html, 'tests/test_cart.py', 8)).toEqual(['l', 'weak']);
- // A test file opens on its problems, each linking to its line.
- const file = html.slice(html.indexOf('1');
- expect(problems).toContain('tests/test_cart.py:8');
- expect(file.indexOf('file-problems')).toBeLessThan(file.indexOf('class="code"'));
+ // A source file opens on the methods to add a test to, then its code; a test file opens on its code.
+ const cart = html.slice(html.indexOf('1');
+ expect(methods).toContain('apply_discount');
+ expect(cart.indexOf('file-methods')).toBeLessThan(cart.indexOf('class="code"'));
+ expect(cart).not.toContain('file-problems');
+ const tests = html.slice(html.indexOf('↓ Next survived1 line');
+ expect(legend).toContain(' open every note');
});
it('names each problem on a test', () => {
@@ -230,7 +244,7 @@ describe('coverage HTML report', () => {
expect(score).toContain('3 of 6 mutants killed, 50% on covered code');
expect(card(html, 'No coverage')).toContain('0 of 6');
expect(card(html, 'Survived')).toContain('1 of 6');
- const cartRow = viewOf(html, 'sources').match(/
]*>.*?<\/tr>/s)[0];
expect(cartRow).toContain('3/6');
// The tests tile is how many tests could go.
const tests = card(html, 'Redundant tests');
@@ -255,15 +269,17 @@ describe('coverage HTML report', () => {
const summary = viewOf(html, 'summary');
expect(summary).not.toContain('class="lede"');
// Two lists and no per-file tables: those are the Source files and Tests tabs.
- expect([...summary.matchAll(/
([^<]+?) match[1])).toEqual(['Survived mutants', 'Test problems']);
- // Survived mutants, one row per method. A method no test reaches is not in the list: the call graph alone is not a problem.
- const survivedList = summary.slice(summary.indexOf('
Survived mutants '), summary.indexOf('
Test problems '));
- expect(survivedList).toContain('Survived mutantapply_discount');
- expect(survivedList).not.toContain('>total<');
- // The mutant in words; the code is one click away, at the line.
- expect(survivedList).toContain('
With `>=` instead of `>`, none of the 2 tests reaching it fails.
([^<]+?) match[1])).toEqual(['Where to add tests', 'Test problems']);
+ // Methods to add a test to, one row each with the test to add. A method no test reaches is not in the list: the call graph
+ // alone is not a problem.
+ const methodList = summary.slice(summary.indexOf('
Where to add tests '), summary.indexOf('
Test problems '));
+ expect(methodList).toContain('apply_discountsrc/cart.py:3');
+ expect(methodList).toContain('
1
2
Add a test at the boundary of `>` on line 6, where `>` and `>=` give different results.');
+ expect(methodList).toContain('Copy prompt');
+ expect(methodList).not.toContain('>total<');
+ // The mutants fold under the row, each in words with a link to its line; the code is there.
+ expect(methodList).toContain(`
line 6 With \`>=\` instead of \`>\`, none of the 2 tests reaching it fails.
Test problems '));
expect(testProblems).toContain('Duplicate testtest_discount_20');
// The first review is short lists: each problem once, not every list again.
@@ -276,7 +292,7 @@ describe('coverage HTML report', () => {
expect(discount).toContain('3 of 6 mutants killed');
expect(lineClasses(html, 'src/cart.py', 4)).toEqual(['l', 'part']);
expect(lineClasses(html, 'src/cart.py', 6)).toEqual(['l', 'part', 'gapline']);
- expect(under(html, 'src/cart.py', 6)).toContain('Survived mutant');
+ expect(under(html, 'src/cart.py', 6)).toContain('1 survived mutant');
// No test reaches total.
expect(under(html, 'src/cart.py', 13)).toContain('No test reaches it');
expect(lineClasses(html, 'src/cart.py', 15)).toEqual(['l', 'none']);
@@ -291,6 +307,10 @@ describe('coverage HTML report', () => {
// No second list of what is new: the problem is where it belongs, marked and first.
expect(summary).not.toContain('New since');
expect(summary).toContain('newDuplicate testtest_discount_20');
+ // A method whose survived mutant is new is marked too.
+ const fresh = sampleReport();
+ fresh.diff.findings.new = [edge];
+ expect(viewOf(renderCoverageHtml(fresh), 'summary')).toContain('newapply_discount');
expect(summary.split('test_discount_20')).toHaveLength(2);
// Changes opens on them too.
const changes = viewOf(html, 'changes');
@@ -305,8 +325,8 @@ describe('coverage HTML report', () => {
const summary = viewOf(page, 'summary');
expect(summary).not.toContain('new-badge');
// The lists hold only the changed code's problems.
- const lists = summary.slice(summary.indexOf('
Where to add tests '));
+ expect(lists).toContain('apply_discount');
expect(summary).not.toContain('test_discount_20');
// All problems marks which rows are in changed code, and can show those alone.
const problems = viewOf(page, 'problems');
@@ -332,13 +352,13 @@ describe('coverage HTML report', () => {
const buttons = '
`);
expect(under(html, 'src/cart.py', 6)).toContain(buttons);
expect(html).not.toContain('class="dismiss"');
- // Each list of problems has one button for all of it: the file's Problems box, Risk, and All problems.
+ // Each list of problems has one button for all of it: the file's methods, the summary's, and All problems.
const file = html.slice(html.indexOf('Copy prompt');
- expect(viewOf(html, 'summary')).toContain('
');
expect(viewOf(html, 'problems')).toContain('data-copy-all');
// No app links: the prompt is copied, to paste into whichever agent you use.
expect(html).not.toMatch(/claude-cli:|codex:\/\/|cursor:\/\//);
@@ -378,12 +398,43 @@ describe('coverage HTML report', () => {
it('breaks each test file down by why tests are not worth keeping, and each source file by survived mutants', () => {
const heads = view => [...viewOf(html, view).matchAll(/
');
- // cart.py has the one survived mutant, and no mutant without coverage.
- expect(viewOf(html, 'sources').match(/
.*?<\/tr>/s)[0]).toContain('
1
0
');
+ // cart.py: three mutants killed, one survived, none without coverage.
+ expect(viewOf(html, 'sources').match(/
]*>.*?<\/tr>/s)[0]).toContain('
3
1
0
');
+ });
+
+ it('lays the source files out as a tree, worst first, and lists only the test files with something to fix', () => {
+ const sources = viewOf(html, 'sources');
+ // One directory, src, with its files' numbers summed: 3 of 6 killed, 1 survived; the worse file first.
+ expect(sources).toContain('
');
+ expect(sources).not.toContain('data-closed');
+ // A chain of directories with nothing but the next in them is one row.
+ const deep = sampleReport();
+ deep.files[1].path = 'lib/shop/util/money.py';
+ deep.methods[2].path = deep.files[1].path;
+ expect(viewOf(renderCoverageHtml(deep), 'sources')).toContain('>lib/shop/util/ 1');
+ // Past two hundred files, every directory under the top starts folded.
+ const many = sampleReport();
+ many.files = [...many.files, ...Array.from({ length: 200 }, (_, at) => ({ ...many.files[1], path: `src/more/file${at}.py`, methods: [] }))];
+ const big = viewOf(renderCoverageHtml(many), 'sources');
+ expect(big).toContain('
');
+ expect(big).toContain('
');
+ expect(tests).toContain('
1 test file has nothing to fix.Show them');
+ expect(viewOf(html, 'tests')).not.toContain('nothing to fix');
});
it('fetches nothing from anywhere', () => {
@@ -422,15 +473,15 @@ describe('coverage HTML report', () => {
expect(renderCoverageSite(sampleReport()).index.join('')).toBe(renderCoverageHtml(sampleReport()));
});
- it('names the nearest fifty tests that reach a method and counts the rest', () => {
+ it('names the eight nearest tests that reach a method, the ones it asked, and counts the rest', () => {
const report = sampleReport();
const method = report.methods.find(item => item.id === 'src/cart.py::apply_discount');
const reaching = Array.from({ length: 53 }, (_, at) => ({ ...report.tests[0], id: `tests/test_cart.py::t${at}`, name: `t${at}` }));
report.tests.push(...reaching);
method.tests = reaching.map((test, at) => ({ id: test.id, depth: at < 3 ? 3 : 1 }));
const bar = renderCoverageHtml(report).match(/