HookStack
Back to catalogue
ValidationStop

Per-modified-file coverage (Stop)

Catches the file that quietly dropped coverage

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.

What does the Per-modified-file coverage (Stop) hook do?

Per-modified-file coverage (Stop) is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Catches the file that quietly dropped coverage.

As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

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

Tags

#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
// @hookstack stop-per-file-coverage
import { execSync } from "node:child_process";
// @hookstack stop-per-file-coverage
// Vérifie la coverage ≥80% par fichier .ts/.tsx modifié (Stop)
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node: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);
}

Learn more

Related hooks