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

Per-modified-file lint (Stop)

Runs ESLint (--max-warnings=0) on each .js/.ts/.jsx/.tsx file modified since the merge base. Reports files with lint problems and auto-disables after 3 consecutive failures so it never gets stuck blocking a session.

Use cases

  • Catch a lint regression on the exact files changed in this session, not the whole project
  • Keep the definition-of-done green without running a full project lint

Providers & tags

Claude Code
#lint#eslint#per-file#git-diff#definition-of-done

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/per-file-lint.mjs"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/per-file-lint.mjs

#!/usr/bin/env node
// Lint ESLint chaque fichier .js/.ts modifié depuis la merge base (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 ''; }
}

// Signatures indiquant qu'ESLint n'a pas pu DÉMARRER (config absente, mauvaise version,
// binaire introuvable) — à distinguer d'une vraie violation de lint. Dans ce cas on traite
// ESLint comme un outil indisponible et on ne fait PAS échouer le hook (évite les faux positifs).
const ESLINT_UNAVAILABLE =
  /Oops! Something went wrong|could ?n'?t find an? eslint\.config|eslint\.config\.\(|migration guide|Cannot find module|could not determine executable|command not found|ENOENT/i;

export function isEslintUnavailable(output) {
  return !output || ESLINT_UNAVAILABLE.test(output);
}

// Retourne null si le fichier passe ESLint (ou si ESLint est indisponible), sinon la sortie d'erreur.
/* v8 ignore next 14 */
function defaultLint(file) {
  try {
    execSync(`npx --no-install eslint --max-warnings=0 "${file}"`, {
      encoding: 'utf8',
      stdio: 'pipe',
      timeout: 15_000,
    });
    return null;
  } catch (err) {
    const out = `${err.stdout ?? ''}\n${err.stderr ?? ''}`.trim();
    if (isEslintUnavailable(out)) return null; // ESLint n'a pas démarré → skip, pas un échec
    return out || 'lint error';
  }
}

export function run({
  exec = defaultExec,
  lint = defaultLint,
  exists = existsSync,
  readFile = readFileSync,
  writeFile = writeFileSync,
  unlink = unlinkSync,
  counterFile = `/tmp/.claude-per-lint-count-${pid}`,
  disableFile = `/tmp/.claude-per-lint-disabled-${pid}`,
} = {}) {
  if (exists(disableFile)) {
    process.stderr.write(`[per-file-lint] 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 failed = [];
  let checked = 0;
  for (const f of raw.split('\n').filter(Boolean)) {
    if (!/\.[cm]?[jt]sx?$/.test(f)) continue;
    if (!exists(f)) continue; // fichier supprimé → rien à linter
    checked++;
    const out = lint(f);
    if (out) failed.push({ f, out });
  }

  if (!failed.length) {
    try { unlink(counterFile); } catch {}
    return { exitCode: 0, message: checked ? `✓ ESLint: no issues (${checked} file${checked > 1 ? 's' : ''})\n` : '✓ ESLint: nothing to check\n' };
  }

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

  let msg = `[FAIL] ESLint signale des problèmes sur les fichiers modifiés :\n${failed.map(({ f }) => `  - ${f}`).join('\n')}\n→ Corriger les erreurs de lint (ou \`eslint --fix\`).\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 };
}

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