Back to catalogue
ValidationStopStopWhen the agent finishes its task· non-blocking
Per-modified-file coverage (Stop)
Reads the JSON coverage report (vitest/jest coverage-summary.json or pytest coverage.json) and checks that each file modified since the merge base reaches ≥80% line coverage. Reports files below the threshold with their exact percentage.
Use cases
- Block a session that lowered coverage on a specific file without seeing it in global coverage
- Focus feedback on the files actually modified rather than the whole project
Providers & tags
Claude Code
#coverage#per-file#git-diff#vitest#pytest#definition-of-done
settings.json fragment
{
"hooks": {
"Stop": [
{
"hooks": [
{
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/per-file-coverage.mjs",
"type": "command"
}
]
}
]
}
}Script · .claude/hooks/per-file-coverage.mjs
#!/usr/bin/env node
// Vérifie la coverage ≥80% par fichier .ts/.tsx modifié (Stop)
import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'fs';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const pid = process.ppid ?? 'unknown';
function defaultExec(cmd) {
try { return execSync(cmd, { encoding: 'utf8', timeout: 10_000 }).trim(); } catch { return ''; }
}
const SKIP = /^src\/(types|constants|router|main)\//;
export function run({
exec = defaultExec,
exists = existsSync,
readFile = readFileSync,
writeFile = writeFileSync,
unlink = unlinkSync,
cwd = process.cwd(),
counterFile = `/tmp/.claude-per-cov-count-${pid}`,
disableFile = `/tmp/.claude-per-cov-disabled-${pid}`,
covJson = './coverage/coverage-summary.json',
} = {}) {
if (exists(disableFile)) {
process.stderr.write(`[per-file-coverage] SUSPENDU (≥3 échecs). rm '${disableFile}' pour réactiver.\n`);
return { exitCode: 0 };
}
if (!exists(covJson)) return { exitCode: 0 };
const base = exec('git merge-base origin/main HEAD');
const head = exec('git rev-parse HEAD');
const raw = base && base !== head
? exec(`git diff --name-only ${base} HEAD`)
: exec('git diff --name-only HEAD');
let coverage;
try { coverage = JSON.parse(readFile(covJson, 'utf8')); } catch { return { exitCode: 0 }; }
const lowCov = [];
for (const f of raw.split('\n').filter(Boolean)) {
if (!/^src\/.*\.tsx?$/.test(f) || SKIP.test(f)) continue;
const pct = coverage[`${cwd}/${f}`]?.lines?.pct;
if (pct != null && pct < 80) lowCov.push({ f, pct });
}
if (!lowCov.length) {
try { unlink(counterFile); } catch {}
return { exitCode: 0 };
}
let count = 0;
try { count = parseInt(readFile(counterFile, 'utf8').trim(), 10) || 0; } catch {}
count++;
writeFile(counterFile, String(count));
let msg = `[FAIL] Coverage < 80% pour les fichiers modifiés :\n${lowCov.map(({ f, pct }) => ` - ${f}: ${Math.round(pct)}%`).join('\n')}\n→ Ajouter des tests pour atteindre le seuil.\n`;
if (count >= 3) {
writeFile(disableFile, '');
msg += `[AUTO-DISABLE] hook suspendu après ${count} échecs. rm '${disableFile}' pour réactiver.\n`;
}
return { exitCode: 2, message: msg };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const result = run();
if (result.message) process.stdout.write(result.message);
process.exit(result.exitCode);
}