Files
Michał Pierzchała 17b6ca36f8 test(coverage): rename-only hunks owe no changed-line coverage (#2248)
* test(coverage): rename-only hunks owe no changed-line coverage

Pass --find-renames=90% to the changed-line diff so rename detection no
longer depends on the host diff.renames setting: a 100%-similarity move
contributes no changed lines and an edited move contributes only the
hunks that differ from its source. Threshold unchanged.

* docs(agents): pure moves carry their tests unchanged

Drops the stale src/daemon/handlers/session.ts over-budget bullet (242
lines on main) to stay under the AGENTS.md byte budget.

* style: format coverage-changed run.ts

* docs(agents): restore the session.ts over-budget rule
2026-09-04 11:58:01 +02:00

313 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
CHANGED_LINE_COVERAGE_THRESHOLD,
computeChangedCoverage,
ignoredLineSet,
isIncludableSource,
isTestFile,
normalizePath,
parseLcov,
parseUnifiedDiff,
type ChangedFileDiff,
} from './model.ts';
test('normalizePath strips root, b/ and ./ prefixes and posixifies', () => {
assert.equal(normalizePath('/repo/src/a.ts', '/repo'), 'src/a.ts');
assert.equal(normalizePath('b/src/a.ts'), 'src/a.ts');
assert.equal(normalizePath('./src/a.ts'), 'src/a.ts');
assert.equal(normalizePath(String.raw`src\a.ts`), 'src/a.ts');
});
test('parseLcov reads DA hits and BRDA taken, treating "-" as not taken', () => {
const lcov = [
'SF:/repo/src/a.ts',
'DA:1,3',
'DA:2,0',
'BRDA:1,0,0,3',
'BRDA:2,1,0,-',
'end_of_record',
'SF:src/b.ts',
'DA:10,1',
'end_of_record',
].join('\n');
const index = parseLcov(lcov, '/repo');
const a = index.get('src/a.ts');
assert.ok(a);
assert.equal(a.lineHits.get(1), 3);
assert.equal(a.lineHits.get(2), 0);
assert.deepEqual(a.branches, [
{ line: 1, taken: 3 },
{ line: 2, taken: 0 },
]);
assert.equal(index.get('src/b.ts')?.lineHits.get(10), 1);
});
test('parseUnifiedDiff numbers added lines per hunk and ignores deleted lines', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,0 +1,2 @@',
'+const a = 1;',
'+const b = 2;',
'@@ -10,1 +12,1 @@',
'-old line',
'+new line',
].join('\n');
const [file] = parseUnifiedDiff(diff);
assert.equal(file.path, 'src/a.ts');
assert.equal(file.deleted, false);
assert.deepEqual(file.added, [1, 2, 12]);
});
test('parseUnifiedDiff marks deleted files and keeps rename destinations', () => {
const diff = [
'diff --git a/src/gone.ts b/src/gone.ts',
'--- a/src/gone.ts',
'+++ /dev/null',
'@@ -1,1 +0,0 @@',
'-was here',
'diff --git a/src/old.ts b/src/new.ts',
'rename from src/old.ts',
'rename to src/new.ts',
'--- a/src/old.ts',
'+++ b/src/new.ts',
'@@ -3,0 +4,1 @@',
'+added on new path',
].join('\n');
const files = parseUnifiedDiff(diff);
const gone = files.find((f) => f.path === 'src/gone.ts');
assert.equal(gone?.deleted, true);
const renamed = files.find((f) => f.path === 'src/new.ts');
assert.deepEqual(renamed?.added, [4]);
});
test('source/test classification matches the coverage include glob', () => {
assert.equal(isIncludableSource('src/core/x.ts'), true);
assert.equal(isIncludableSource('src/core/x.test.ts'), false);
assert.equal(isIncludableSource('src/core/__tests__/x.ts'), false);
assert.equal(isIncludableSource('scripts/y.ts'), false);
assert.equal(isIncludableSource('src/core/x.tsx'), false);
// Workspace packages are the second coverage root.
assert.equal(isIncludableSource('packages/contracts/src/x.ts'), true);
assert.equal(isIncludableSource('packages/contracts/src/deep/x.ts'), true);
assert.equal(isIncludableSource('packages/contracts/src/x.test.ts'), false);
assert.equal(isIncludableSource('packages/contracts/src/__tests__/x.ts'), false);
assert.equal(isIncludableSource('packages/contracts/src/x.tsx'), false);
assert.equal(isIncludableSource('packages/contracts/test/x.ts'), false);
assert.equal(isIncludableSource('packages/contracts/x.ts'), false);
assert.equal(isTestFile('src/a.test.ts'), true);
assert.equal(isTestFile('src/__tests__/a.ts'), true);
assert.equal(isTestFile('src/a.ts'), false);
});
test('ignoredLineSet handles next, next N and start/stop ranges', () => {
const lines = [
'const a = 1;', // 1
'/* v8 ignore next */', // 2
'const b = 2;', // 3 ignored
'/* c8 ignore next 2 */', // 4
'const c = 3;', // 5 ignored
'const d = 4;', // 6 ignored
'/* istanbul ignore start */', // 7 ignored
'const e = 5;', // 8 ignored
'/* istanbul ignore stop */', // 9
'const f = 6;', // 10
];
const ignored = ignoredLineSet(lines);
assert.deepEqual(
[...ignored].sort((a, b) => a - b),
[3, 5, 6, 7, 8],
);
});
const coverage = parseLcov(
[
'SF:src/covered.ts',
'DA:1,5',
'DA:2,0',
'DA:3,2',
'BRDA:1,0,0,5',
'BRDA:2,0,0,0',
'end_of_record',
].join('\n'),
);
function diff(path: string, added: number[], deleted = false): ChangedFileDiff {
return { path, added, deleted };
}
test('gate fails when changed-line coverage is below threshold and lists offenders', () => {
const result = computeChangedCoverage({
diffs: [diff('src/covered.ts', [1, 2, 3])],
coverage,
fileLines: () => null,
});
// lines 1 & 3 covered, line 2 uncovered -> 2/3 = 66.67% < 70.
assert.equal(result.totalLines, 3);
assert.equal(result.coveredLines, 2);
assert.equal(result.passed, false);
assert.deepEqual(result.offenders[0]?.uncoveredLines, [2]);
assert.equal(result.offenders[0]?.path, 'src/covered.ts');
// Branches on changed lines are reported, not gated.
assert.equal(result.branch.total, 2);
assert.equal(result.branch.covered, 1);
});
test('workspace package source counts toward the gate like root source', () => {
const packageCoverage = parseLcov(
['SF:packages/contracts/src/feature.ts', 'DA:1,4', 'DA:2,0', 'end_of_record'].join('\n'),
);
const result = computeChangedCoverage({
diffs: [diff('packages/contracts/src/feature.ts', [1, 2])],
coverage: packageCoverage,
fileLines: () => null,
});
// 1/2 = 50% < 70: a package change can now fail the gate on its own.
assert.equal(result.totalLines, 2);
assert.equal(result.coveredLines, 1);
assert.equal(result.passed, false);
assert.equal(result.offenders[0]?.path, 'packages/contracts/src/feature.ts');
assert.deepEqual(result.offenders[0]?.uncoveredLines, [2]);
});
test('non-executable added lines (absent from DA) never enter the denominator', () => {
const result = computeChangedCoverage({
diffs: [diff('src/covered.ts', [1, 3, 99])],
coverage,
fileLines: () => null,
});
assert.equal(result.totalLines, 2);
assert.equal(result.coveredLines, 2);
assert.equal(result.pct, 100);
assert.equal(result.passed, true);
});
test('deleted files and docs-only changes are a trivial pass with null pct', () => {
const result = computeChangedCoverage({
diffs: [diff('src/covered.ts', [1], true), diff('README.md', [1, 2])],
coverage,
fileLines: () => null,
});
assert.equal(result.pct, null);
assert.equal(result.totalLines, 0);
assert.equal(result.passed, true);
});
test('waiver forces a pass but still reports the numbers', () => {
const result = computeChangedCoverage({
diffs: [diff('src/covered.ts', [1, 2, 3])],
coverage,
waived: true,
fileLines: () => null,
});
assert.equal(result.waived, true);
assert.equal(result.passed, true);
assert.equal(result.pct, 66.67);
});
test('includable source absent from lcov counts as excluded-path code lines', () => {
const result = computeChangedCoverage({
diffs: [diff('src/sdk/thing.ts', [1, 2, 3])],
coverage,
fileLines: () => ['const kept = 1;', '', '// a comment'],
});
assert.equal(result.totalLines, 0);
assert.equal(result.excluded.totalLines, 1);
assert.equal(result.excluded.files[0]?.reason, 'excluded-path');
assert.deepEqual(result.excluded.files[0]?.lines, [1]);
});
test('changed line suppressed by an ignore directive is tallied, not gated', () => {
const cov = parseLcov(['SF:src/x.ts', 'DA:1,1', 'end_of_record'].join('\n'));
const result = computeChangedCoverage({
diffs: [diff('src/x.ts', [1, 3])],
coverage: cov,
fileLines: () => ['const a = 1;', '/* v8 ignore next */', 'const suppressed = 3;'],
});
// Line 3 is code-like, dropped from DA by the directive on line 2, and inside
// its ignore range: it lands in the non-gating excluded tally. Only line 1
// (present in DA) feeds the gate denominator, so the tally never inflates it.
assert.equal(result.totalLines, 1);
assert.equal(result.coveredLines, 1);
assert.equal(result.excluded.totalLines, 1);
assert.equal(result.excluded.files[0]?.reason, 'ignored-lines');
assert.deepEqual(result.excluded.files[0]?.lines, [3]);
});
test('threshold constant is the single source of truth', () => {
assert.equal(CHANGED_LINE_COVERAGE_THRESHOLD, 70);
});
// Shape of `git diff --unified=0 --find-renames=90%` for a move PR: a pure
// move carries no hunks, an edited move carries only the hunks that differ from
// its source, and an ordinary edit is unaffected.
const MOVE_PR_DIFF = [
'diff --git a/src/mod.ts b/src/mod.ts',
'index 57ee600..9fe64ef 100644',
'--- a/src/mod.ts',
'+++ b/src/mod.ts',
'@@ -2 +2,2 @@ export const m1 = 1;',
'-export const m2 = 2;',
'+export const m2 = 22;',
'+export const m3 = 3;',
'diff --git a/src/edited.ts b/src/moved-edited.ts',
'similarity index 90%',
'rename from src/edited.ts',
'rename to src/moved-edited.ts',
'index a3d7378..fe85380 100644',
'--- a/src/edited.ts',
'+++ b/src/moved-edited.ts',
'@@ -3 +3 @@ export const b2 = 2;',
'-export const b3 = 3;',
'+export const b3 = 33;',
'@@ -7 +7 @@ export const b6 = 6;',
'-export const b7 = 7;',
'+export const b7 = 77;',
'@@ -9 +9 @@ export const b8 = 8;',
'-export const b9 = 9;',
'+export const b9 = 99;',
'diff --git a/src/pure.ts b/src/moved-pure.ts',
'similarity index 100%',
'rename from src/pure.ts',
'rename to src/moved-pure.ts',
].join('\n');
function uncoveredRecord(file: string, lineCount: number): string {
const da = Array.from({ length: lineCount }, (_, i) => `DA:${i + 1},0`);
return [`SF:${file}`, ...da, 'end_of_record'].join('\n');
}
test('rename-only hunks owe no changed-line coverage; edited moves owe their edits', () => {
const diffs = parseUnifiedDiff(MOVE_PR_DIFF);
assert.deepEqual(
diffs.map((d) => [d.path, d.added]),
[
['src/mod.ts', [2, 3]],
['src/moved-edited.ts', [3, 7, 9]],
['src/moved-pure.ts', []],
],
);
const result = computeChangedCoverage({
diffs,
coverage: parseLcov(
[
uncoveredRecord('src/mod.ts', 3),
uncoveredRecord('src/moved-edited.ts', 30),
uncoveredRecord('src/moved-pure.ts', 30),
].join('\n'),
),
fileLines: () => null,
});
// 2 ordinary edits + 3 edited move lines; the 30-line pure move owes nothing.
assert.equal(result.totalLines, 5);
assert.deepEqual(
result.offenders.map((f) => [f.path, f.uncoveredLines]),
[
['src/mod.ts', [2, 3]],
['src/moved-edited.ts', [3, 7, 9]],
],
);
});