|
| 1 | +/** |
| 2 | + * @file Shared "is this rule rationale a dated incident log?" matcher. The |
| 3 | + * dated-citation-reminder (PreToolUse, nudges at edit time) and the |
| 4 | + * rule-citations-are-generic check (`check --all`, blocks committed prose) |
| 5 | + * both gate on the same definition, so the two surfaces never drift on what |
| 6 | + * counts as a too-specific citation. |
| 7 | + * |
| 8 | + * The rule (CLAUDE.md "Compound lessons into rules"): when a rule / hook / |
| 9 | + * SKILL / doc cites the case that motivated it, write it GENERICALLY, framed |
| 10 | + * as an example ("e.g. a cascade that shipped without its reconciled |
| 11 | + * lockfile") — NOT as a dated incident log ("2026-06-07: pnpm 11.0.0 vs |
| 12 | + * 11.5.1 at SHA abc1234"). Dates, version deltas, percentages, and commit |
| 13 | + * SHAs age into a changelog and leak detail; the example shape is timeless. |
| 14 | + * |
| 15 | + * Scope: only RATIONALE prose is flagged — a line carrying a rationale marker |
| 16 | + * (`**Why:**`, "incident", "Past incident", "regression", "red-lined") that |
| 17 | + * ALSO carries a specificity token. A bare date elsewhere (a SHA-pin |
| 18 | + * `# <tag> (YYYY-MM-DD)` comment, a `# published: YYYY-MM-DD` soak annotation, |
| 19 | + * a `.gitmodules` `# name-version`, a CHANGELOG entry, a version constant in |
| 20 | + * code) is NOT rationale and is left alone — those dates are required by other |
| 21 | + * rules. Memory files are exempt at the path layer (see EXEMPT_PATH_RE). |
| 22 | + */ |
| 23 | + |
| 24 | +// A line is "rationale" if it carries one of these markers. Only rationale |
| 25 | +// lines are candidates — this keeps the matcher off required-date annotations. |
| 26 | +const RATIONALE_MARKER_RE = |
| 27 | + /\*\*Why:\*\*|\b(?:past\s+)?incident\b|\bred-lined?\b|\bregressed?\b|\bregression\b/i |
| 28 | + |
| 29 | +// Specificity tokens that turn a generic example into a dated incident log. |
| 30 | +const SPECIFICITY_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ |
| 31 | + // ISO-8601 date — the loudest "this is a log entry, not an example" signal. |
| 32 | + { label: 'ISO date (YYYY-MM-DD)', regex: /\b20\d\d-\d\d-\d\d\b/ }, |
| 33 | + // Percentage delta (coverage 98.9%→99.15%, etc). |
| 34 | + { |
| 35 | + label: 'percentage delta', |
| 36 | + regex: /\b\d+(?:\.\d+)?%\s*(?:→|->|to)\s*\d+(?:\.\d+)?%/, |
| 37 | + }, |
| 38 | + // Version delta — two semver-ish versions joined by vs / → / -> ("11.4.0 vs |
| 39 | + // 11.3.0", "bump to 11.5.0"). A SINGLE version alone is not flagged (a rule |
| 40 | + // may legitimately name the version it targets); the delta framing is what |
| 41 | + // marks a changelog entry. |
| 42 | + { |
| 43 | + label: 'version delta', |
| 44 | + regex: |
| 45 | + /\bv?\d+\.\d+(?:\.\d+)?\s*(?:vs\.?|→|->|versus)\s*v?\d+\.\d+(?:\.\d+)?\b/i, |
| 46 | + }, |
| 47 | + // Commit SHA (7–40 hex) named in rationale prose ("at SHA abc1234", "broke |
| 48 | + // at deadbeef"). Requires a sha-ish lead-in word so prose words like |
| 49 | + // "deceased" or hex-looking ids elsewhere don't false-fire. |
| 50 | + { |
| 51 | + label: 'commit SHA', |
| 52 | + regex: /\b(?:sha|commit|at)\s+[0-9a-f]{7,40}\b/i, |
| 53 | + }, |
| 54 | +] |
| 55 | + |
| 56 | +// Paths whose prose is NOT fleet-facing rule rationale, so dated citations are |
| 57 | +// fine there. Memory files keep absolute dates for recall; CHANGELOG has its |
| 58 | +// own date convention; lockstep headers + .gitmodules carry required version |
| 59 | +// stamps. |
| 60 | +export const EXEMPT_PATH_RE = |
| 61 | + /(?:^|\/)(?:CHANGELOG\.md|\.gitmodules|lockstep\.json)$|\/memory\/|\/\.claude\/(?:plans|reports)\// |
| 62 | + |
| 63 | +export interface DatedCitationHit { |
| 64 | + readonly label: string |
| 65 | + readonly line: number |
| 66 | + readonly text: string |
| 67 | +} |
| 68 | + |
| 69 | +/** |
| 70 | + * Scan prose for dated-incident citations. Returns one hit per offending |
| 71 | + * rationale line (first matching specificity token wins per line). `text` is |
| 72 | + * the trimmed offending line, truncated for display. |
| 73 | + */ |
| 74 | +export function findDatedCitations(content: string): DatedCitationHit[] { |
| 75 | + const lines = content.split('\n') |
| 76 | + const hits: DatedCitationHit[] = [] |
| 77 | + for (let i = 0, { length } = lines; i < length; i += 1) { |
| 78 | + const line = lines[i]! |
| 79 | + if (!RATIONALE_MARKER_RE.test(line)) { |
| 80 | + continue |
| 81 | + } |
| 82 | + for (let j = 0, { length: pLen } = SPECIFICITY_PATTERNS; j < pLen; j += 1) { |
| 83 | + const pattern = SPECIFICITY_PATTERNS[j]! |
| 84 | + if (pattern.regex.test(line)) { |
| 85 | + const trimmed = line.trim() |
| 86 | + hits.push({ |
| 87 | + label: pattern.label, |
| 88 | + line: i + 1, |
| 89 | + text: trimmed.length > 160 ? `${trimmed.slice(0, 157)}…` : trimmed, |
| 90 | + }) |
| 91 | + break |
| 92 | + } |
| 93 | + } |
| 94 | + } |
| 95 | + return hits |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * True when `filePath` is a fleet-facing rule-prose surface whose citations |
| 100 | + * must be generic. Used by both the edit-time hook and the commit-time check. |
| 101 | + */ |
| 102 | +export function isRuleProseSurface(filePath: string): boolean { |
| 103 | + if (EXEMPT_PATH_RE.test(filePath)) { |
| 104 | + return false |
| 105 | + } |
| 106 | + return ( |
| 107 | + /(?:^|\/)CLAUDE\.md$/.test(filePath) || |
| 108 | + /(?:^|\/)docs\/claude\.md\/fleet\//.test(filePath) || |
| 109 | + /(?:^|\/)\.claude\/skills\/.*\/SKILL\.md$/.test(filePath) || |
| 110 | + /(?:^|\/)\.claude\/hooks\/fleet\/[^/]+\/README\.md$/.test(filePath) |
| 111 | + ) |
| 112 | +} |
0 commit comments