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

Automatic quality check (Stop)

Runs a quality check on every stop: type-check (tsc), ESLint (only if an ESLint config file is present), and tests. Blocks the agent from handing back until all checks pass.

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

Providers & tags

Claude Code
#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é complet à la fin d'une session : typecheck + lint + tests (Stop)
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 .']);
  if (hasPkg)
    checks.push(['Tests', 'pnpm test --run 2>/dev/null || yarn test --run 2>/dev/null || bun test 2>/dev/null || npm test --if-present 2>/dev/null || npx --no-install vitest run 2>/dev/null']);

  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);
}