site-mariage/.claude/hooks/fact-check-absolutes.js
Corentin Joguet bff653acd6 first commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:30:37 +02:00

188 lines
5.4 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* PreToolUse hook — fact-check absolutes guard.
*
* Scans Edit/Write tool inputs on markdown/documentation paths for
* absolute claims (`always`, `never`, `obviously`, `faster`, `better`,
* `toujours`, `jamais`, `forcement`) without an accompanying source
* reference.
*
* When an unsourced absolute is detected on a doc file, the hook exits
* with decision=block and a clear reason, forcing the author to cite a
* source (matching `_byan/knowledge/sources.md`, `RFC`, `CVE-`, a URL,
* or a `[CLAIM L<n>]` prefix) before writing.
*
* Non-blocking outside of Edit/Write tools or when the target is code
* (not documentation).
*/
const fs = require('fs');
const path = require('path');
const ABSOLUTES = [
/\btoujours\b/i,
/\bjamais\b/i,
/\bforc[eé]ment\b/i,
/\bobviously\b/i,
/\balways\b/i,
/\bnever\b/i,
/\bclearly\b/i,
/\bundoubtedly\b/i,
/\bfaster than\b/i,
/\bbetter than\b/i,
/\bplus rapide que\b/i,
/\bmeilleur que\b/i,
];
const SOURCE_MARKERS = [
/\bRFC\s*\d+/i,
/\bCVE-\d{4}-\d+/i,
/https?:\/\//,
/\[CLAIM\s+L[1-5]\]/i,
/\[FACT\s+USER-VERIFIED/i,
/\bsource\s*:/i,
/_byan\/knowledge\/sources\.md/,
];
const DOC_EXTS = ['.md', '.mdx', '.rst', '.txt'];
// Paths exempted from scanning — these files DESCRIBE the rule or meta-docs
// where absolutes appear as examples, not as claims.
const EXEMPT_PATH_PATTERNS = [
/\.claude\/hooks\//,
/\.claude\/agents\/bmad-compliance\.md$/,
/_byan\/mcp\/byan-mcp-server\/lib\/(peer-review|fd-state)\.js$/,
/_byan\/mcp\/byan-mcp-server\/test\//,
/_byan\/knowledge\/(fact-check|mantras)/i,
/\/fact-check-absolutes\.js$/,
/\.claude\/skills\/byan-fact-check\//,
/install\/__tests__\/.*fact-check/i,
/__tests__\/.*fact-check/i,
/\.claude\/skills\/byan-taste(?:-[\w-]+)?\//,
/\.claude\/skills\/byan-brandkit\//,
/\.claude\/skills\/impeccable\//,
];
function isExemptPath(filePath) {
if (!filePath) return false;
return EXEMPT_PATH_PATTERNS.some((re) => re.test(filePath));
}
// Strip content that cannot be a claim :
// - fenced code blocks ``` ... ```
// - inline backticks `...`
// - block quotes (lines starting with >)
// - regex / array syntax that contains the word as a token
function stripNonClaimZones(text) {
if (!text) return '';
return text
// Fenced code blocks
.replace(/```[\s\S]*?```/g, '')
// Inline code
.replace(/`[^`\n]+`/g, '')
// Markdown block quotes
.replace(/^> .*$/gm, '')
// Lines that look like list of patterns (e.g. "- toujours")
.replace(/^[\s-]*['"]?\b(toujours|jamais|forc[eé]ment|obviously|always|never|clearly|undoubtedly)\b['"]?/gim, '');
}
function readStdin() {
return new Promise((resolve) => {
if (process.stdin.isTTY) return resolve('');
let data = '';
process.stdin.on('data', (c) => (data += c));
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', () => resolve(data));
});
}
function isDoc(filePath) {
if (!filePath) return false;
return DOC_EXTS.some((ext) => filePath.toLowerCase().endsWith(ext));
}
function extractText(toolName, input) {
if (!input) return '';
if (toolName === 'Write') return String(input.content || '');
if (toolName === 'Edit') {
return [input.new_string, input.old_string].filter(Boolean).join('\n');
}
return '';
}
function findUnsourced(text) {
if (!text) return null;
for (const re of ABSOLUTES) {
const match = text.match(re);
if (!match) continue;
const idx = match.index || 0;
const windowStart = Math.max(0, idx - 240);
const windowEnd = Math.min(text.length, idx + match[0].length + 240);
const ctx = text.slice(windowStart, windowEnd);
const hasSource = SOURCE_MARKERS.some((sm) => sm.test(ctx));
if (!hasSource) {
return { absolute: match[0], context: text.slice(Math.max(0, idx - 80), idx + 80) };
}
}
return null;
}
(async () => {
const raw = await readStdin();
let payload = {};
try {
payload = raw ? JSON.parse(raw) : {};
} catch {
payload = {};
}
const toolName = payload.tool_name || payload.toolName || '';
const input = payload.tool_input || payload.toolInput || {};
const target = input.file_path || '';
if (!['Edit', 'Write'].includes(toolName) || !isDoc(target) || isExemptPath(target)) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
},
})
);
process.exit(0);
}
const rawText = extractText(toolName, input);
const text = stripNonClaimZones(rawText);
const hit = findUnsourced(text);
if (!hit) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
},
})
);
process.exit(0);
}
const reason = [
`BYAN fact-check guard : unsourced absolute "${hit.absolute}" detected in ${path.basename(target)}.`,
`Context : ...${hit.context}...`,
`Add a source (RFC, CVE, URL, [CLAIM L<n>], or entry in _byan/knowledge/sources.md) before writing this. `,
`Alternative : reformulate with hedging ("often", "in my tests", "tends to") to drop the absolute claim.`,
].join('\n');
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
},
})
);
process.exit(0);
})();