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

Code duplication guard

Warns on code duplication above threshold at session end

At session end, runs jscpd to detect duplicated code blocks across source and test directories. Only fires when jscpd is installed globally (npm install -g jscpd). Non-blocking — emits a warning with clone locations so you can refactor before committing. Configurable threshold (default 5%) and minimum token count (default 50) to avoid false positives on short boilerplate.

What does the Code duplication guard hook do?

Code duplication guard is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Warns on code duplication above threshold at session end.

Use cases

  • Catching copy-pasted test helpers before they spread to a fifth file
  • Enforcing a team duplication budget without CI slowdown
  • Getting a summary of technical debt accumulated in a session

Tags

#quality#duplication#jscpd#code-quality

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-duplication-check.mjs"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/stop-duplication-check.mjs

#!/usr/bin/env node
// Vérifie la duplication de code à l'arrêt de session via jscpd (Stop).
// Non bloquant — avertit si le seuil est dépassé, silencieux si jscpd absent.
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';

const MIN_TOKENS = 50;   // blocs < 50 tokens ignorés (évite les faux positifs sur boilerplate)
const THRESHOLD = 5;     // % de duplication max avant avertissement

function defaultExec(cmd) {
  return execSync(cmd, { encoding: 'utf8', timeout: 30_000, stdio: 'pipe', shell: true });
}

function jscpdBin({ exists = existsSync } = {}) {
  // Préfère la version locale (devDependency), replie sur le PATH
  const local = 'node_modules/.bin/jscpd';
  return exists(local) ? local : 'jscpd';
}

function findSrcDirs({ exists = existsSync } = {}) {
  return ['src', 'lib', 'tests', 'app'].filter((d) => exists(d));
}

export function run(_input, { exec = defaultExec, exists = existsSync } = {}) {
  const dirs = findSrcDirs({ exists });
  if (!dirs.length) return null;

  const bin = jscpdBin({ exists });
  try {
    exec(
      `${bin} --min-tokens ${MIN_TOKENS} --threshold ${THRESHOLD} --reporters console ${dirs.join(' ')} 2>&1`,
    );
    return null; // exit 0 → duplication en-dessous du seuil
  } catch (e) {
    // exit 1 → seuil dépassé (stdout contient le rapport) ; ou jscpd absent (pas de stdout)
    const out = e.stdout ?? '';
    if (out && /found \d+ clone/i.test(out)) {
      return { message: `[duplication-check] Code duplication above ${THRESHOLD}% threshold:\n${out}` };
    }
    return null;
  }
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'));
  const result = run(input);
  if (result) process.stderr.write(JSON.stringify(result));
}