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

Automatic quality check (Stop)

Types and lint must pass before Claude hands back control

Runs a full quality gate when the session stops: TypeScript typecheck and ESLint (with cache). Tests are intentionally left to the stop-run-tests hook to avoid running the suite twice. Blocks the stop with actionable output if any check fails.

What does the Automatic quality check (Stop) hook do?

Automatic quality check (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. Types and lint must pass before Claude hands back control.

Use cases

  • Automatic Definition of Done with no manual intervention
  • Block sessions that leave lint errors or insufficient coverage
  • Parallelize build + tests to minimize wait time

Tags

#quality#tests#coverage#lint#definition-of-done#asyncRewake

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "asyncRewake": true,
            "command": "node .claude/hooks/quality-check.mjs",
            "rewakeMessage": "Des erreurs qualité ont été détectées après la dernière modification :",
            "rewakeSummary": "Erreurs qualité détectées",
            "statusMessage": "Vérification qualité en cours...",
            "timeout": 600,
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/quality-check.mjs

#!/usr/bin/env node
// Bilan qualité à la fin d'une session : typecheck + lint (Stop)
// Les tests sont volontairement exclus : run-tests.mjs (Stop) les exécute déjà
// avec un meilleur rapport d'erreur — les relancer ici doublerait la fin de session.
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';

export function run({
  exec,
  exists = existsSync,
  projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
} = {}) {
  const doExec =
    exec ?? ((cmd) => execSync(cmd, { cwd: projectDir, stdio: 'pipe', timeout: 60_000 }));

  const messages = [];
  function check(label, cmd) {
    try {
      doExec(cmd);
      messages.push(`[quality-check] ✓ ${label}\n`);
      return true;
    } catch (err) {
      const out = err.stdout?.toString()?.trim() ?? '';
      messages.push(`[quality-check] ✗ ${label}\n${out ? out.slice(-500) + '\n' : ''}`);
      return false;
    }
  }

  const checks = [];
  const hasPkg = exists(join(projectDir, 'package.json'));
  if (hasPkg && exists(join(projectDir, 'tsconfig.json')))
    checks.push(['TypeScript', 'npx --no-install tsc --noEmit']);
  const eslintConfigs = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml', '.eslintrc'];
  if (hasPkg && eslintConfigs.some((f) => exists(join(projectDir, f))))
    checks.push(['ESLint', 'npx --no-install eslint --max-warnings=0 --cache --cache-location node_modules/.cache/eslint .']);

  const results = checks.map(([label, cmd]) => check(label, cmd));
  const failed = results.filter((r) => !r).length;

  if (failed > 0) messages.push(`[quality-check] ${failed}/${checks.length} vérification(s) échouée(s).\n`);
  else if (checks.length > 0) messages.push('[quality-check] ✓ Tous les contrôles qualité passent.\n');

  return { checks: checks.length, failed, message: messages.join('') };
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const result = run();
  process.stderr.write(result.message);
  if (result.failed > 0) process.exit(2);
}