HookStackGitHub
Back to catalogue
ValidationStopStopWhen the agent finishes its task· non-blocking

Missing test detection (Stop)

On every session stop, inspects git diff to list modified source files and checks that a matching test file exists. Fails with the list of uncovered files if tests are missing.

Use cases

  • Ensure no business file ships without an associated test
  • Integrate a lightweight 'Definition of Done' without running the full test suite

Providers & tags

Claude Code
#tests#missing-tests#definition-of-done#git-diff#coverage

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/missing-test-detection.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/missing-test-detection.mjs

#!/usr/bin/env node
// Détecte les fichiers sources modifiés sans test correspondant (Stop)
import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'fs';
import { execSync } from 'child_process';
import { basename } from 'path';
import { fileURLToPath } from 'url';

const pid = process.ppid ?? 'unknown';

function defaultExec(cmd) {
  try { return execSync(cmd, { encoding: 'utf8', timeout: 10_000 }).trim(); } catch { return ''; }
}

export function run({
  exec = defaultExec,
  exists = existsSync,
  readFile = readFileSync,
  writeFile = writeFileSync,
  unlink = unlinkSync,
  counterFile = `/tmp/.claude-missing-tests-count-${pid}`,
  disableFile = `/tmp/.claude-missing-tests-disabled-${pid}`,
} = {}) {
  if (exists(disableFile)) {
    process.stderr.write(`[missing-test-detection] SUSPENDU (≥3 échecs). rm '${disableFile}' pour réactiver.\n`);
    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');

  const missing = [];
  for (const f of raw.split('\n').filter(Boolean)) {
    if (!/^src\/(lib|store|hooks)\/[^/]+\.ts$/.test(f)) continue;
    if (/\.(test|spec)\.ts$/.test(f)) continue; // un fichier de test n'exige pas son propre test
    if (!exists(f)) continue; // fichier supprimé → pas de test requis
    const name = basename(f, '.ts');
    const found = exec(`find src tests -name "${name}.test.ts" -o -name "${name}.spec.ts" 2>/dev/null`);
    if (!found) missing.push(f);
  }

  if (!missing.length) {
    try { unlink(counterFile); } catch {}
    process.stderr.write('[missing-test-detection] ✓ Aucun fichier source sans test détecté.\n');
    return { exitCode: 0 };
  }

  let count = 0;
  try { count = parseInt(readFile(counterFile, 'utf8').trim(), 10) || 0; } catch {}
  count++;
  writeFile(counterFile, String(count));

  let msg = `[FAIL] Tests manquants pour les fichiers modifiés :\n${missing.map(f => `  - ${f}`).join('\n')}\n→ Créer les fichiers de test correspondants.\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.stderr.write(result.message);
  process.exit(result.exitCode);
}