vigiles 13.0.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,46 @@
1
+ /**
2
+ * rule-routing.ts — the deterministic (no-model) State-B routing PREVIEW.
3
+ *
4
+ * `rule-inventory.ts` answers a narrow question ("which prose lines name an
5
+ * off-the-shelf lint rule, and is it enabled?"). This goes one honest step
6
+ * further: it SEGMENTS the whole instruction file into atomic rules
7
+ * ({@link segmentInstructions}) and routes each one into the class that a real
8
+ * enforcement path would take — WITHOUT running a model:
9
+ *
10
+ * reuse → the rule text names an off-the-shelf lint rule ({@link INTENT_MAP})
11
+ * → mechanism: flip one config line. The "narrow list we compile
12
+ * very well" — everything else is honestly labelled, not force-fit.
13
+ * hook → an ACTION rule a linter can't see (git push, rm -rf, "before you
14
+ * commit") → mechanism: a pre-commit / PreToolUse hook.
15
+ * meta → an agent-instruction, not a code rule ("read X first", "tell the
16
+ * user", "you are …") → mechanism: stays prose. Split out of
17
+ * `unrouted` so it never reads as "compilable but hard" (it isn't).
18
+ * semantic → a judgment call ("readable", "single responsibility") no checker
19
+ * can honestly decide → mechanism: stays prose.
20
+ * unrouted → looks like a code rule but matched no off-the-shelf rule: HARD to
21
+ * codify → mechanism `synthesize`: the opt-in SYNTHESIS tier (a
22
+ * skill on your subscription) MIGHT write a custom checker, gated —
23
+ * but it is NOT guaranteed (the gate may abstain). This is the
24
+ * bucket audit must present clearly as "hard", never as done.
25
+ *
26
+ * NB "compile" is NOT used here — `vigiles compile` is the unrelated spec→markdown
27
+ * verb. Synthesis is its own opt-in tier; the mechanism value is `synthesize`.
28
+ *
29
+ * HONESTY BY CONSTRUCTION: the deterministic tier NEVER claims a rule is
30
+ * "synthesizable" — deciding that a custom rule can be written (and gating it)
31
+ * is exactly the work the opt-in model tier does. `unrouted` means "hard — a
32
+ * synthesis skill may try", never a promise; `meta`/`semantic` mean "not an
33
+ * enforceable code rule at all" (a different, honest kind of no).
34
+ *
35
+ * Pure, deterministic, dependency-free. Reuses `rule-inventory`'s hardened
36
+ * whole-token matcher + `INTENT_MAP`, and `segment`'s Tier-A segmenter.
37
+ */
38
+ import { type SkippedBullet } from "./segment.js";
1
39
  import { type LinterName } from "./rule-inventory.js";
40
+ import type { RuleCatalog } from "./core/rule-catalog.js";
2
41
  /** How a routed rule would be enforced (a MECHANISM ladder, not a 1-10 score). */
3
- export type RuleCategory = "reuse" | "hook" | "semantic" | "unrouted";
4
- export type RuleMechanism = "config-line" | "hook" | "prose" | "compile";
42
+ export type RuleCategory = "reuse" | "hook" | "meta" | "semantic" | "unrouted";
43
+ export type RuleMechanism = "config-line" | "hook" | "prose" | "synthesize";
5
44
  /** One segmented, deterministically-routed rule with provenance. */
6
45
  export interface RoutedRule {
7
46
  /** Normalized atomic rule text (from the segmenter). */
@@ -19,12 +58,28 @@ export interface RoutedRule {
19
58
  readonly rule?: string;
20
59
  /** reuse only: the linter that rule belongs to. */
21
60
  readonly linter?: LinterName;
61
+ /** reuse via the DYNAMIC catalog only: whether the rule is currently enabled in
62
+ * the repo's config (a disabled match is the "documented but OFF" nudge). */
63
+ readonly enabled?: boolean;
64
+ /** How this rule was found: `"marker"` = an explicit `**Enforced by:**` /
65
+ * `**Guard:**` / `**Guidance only**` marker (definitive, zero-heuristic — a
66
+ * compiled/marked doc); `"heuristic"` = the Tier-A segmenter. Absent ⇒ heuristic. */
67
+ readonly source?: "marker" | "heuristic";
22
68
  }
23
69
  export interface RuleRouting {
24
- /** How many atomic rules were routed (after the confidence filter). */
70
+ /** How many CONFIDENT atomic rules were routed (high or rescued). */
25
71
  readonly segmented: number;
26
72
  readonly counts: Record<RuleCategory, number>;
73
+ /** The CONFIDENT tier — cleared the precision bar; these are the routed rules. */
27
74
  readonly rules: readonly RoutedRule[];
75
+ /** The POSSIBLE tier — rule-ish bullets (medium confidence) that did NOT clear
76
+ * the bar, still classified so a human can review + promote them. Detection is
77
+ * precision-first, so this is where a declarative rule ("Every X must Y") that
78
+ * the confident tier misses shows up. See `research/rule-compiler-design.md` §2. */
79
+ readonly possible: readonly RoutedRule[];
80
+ /** Bullets the segmenter decided were NOT rules, each with a reason — so the
81
+ * report is honest about what it set aside (§3). */
82
+ readonly skipped: readonly SkippedBullet[];
28
83
  }
29
84
  export interface RouteOptions {
30
85
  /**
@@ -36,6 +91,14 @@ export interface RouteOptions {
36
91
  * `"medium"` to include both.
37
92
  */
38
93
  readonly minConfidence?: "high" | "medium";
94
+ /**
95
+ * The repo's DYNAMIC available-rule catalog (from `enumerateEslintCatalog`).
96
+ * When provided (OWN-REPO / consented — it executes the linter), a bullet that
97
+ * NAMES any of the repo's real rules routes to `reuse` with its enabled state —
98
+ * not just the ~23 static `INTENT_MAP` aliases. Absent = foreign-safe default
99
+ * (static map only, no execution).
100
+ */
101
+ readonly availableRules?: RuleCatalog;
39
102
  }
40
103
  /**
41
104
  * Segment the instruction file and route every atomic rule deterministically.
@@ -43,4 +106,11 @@ export interface RouteOptions {
43
106
  * source path for provenance). Returns per-category counts + the routed rules.
44
107
  */
45
108
  export declare function routeRules(instructionText: string, file?: string, options?: RouteOptions): RuleRouting;
109
+ /**
110
+ * Merge per-file routings into one. Each instruction source is routed SEPARATELY
111
+ * (so every rule keeps its OWN file path + line numbers — concatenating first
112
+ * would corrupt the provenance the preview promises), then folded here: rules
113
+ * concatenated, counts + segmented summed. Pure. `[]` → an empty routing.
114
+ */
115
+ export declare function mergeRoutings(routings: readonly RuleRouting[]): RuleRouting;
46
116
  //# sourceMappingURL=rule-routing.d.ts.map
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.routeRules = routeRules;
4
+ exports.mergeRoutings = mergeRoutings;
4
5
  /**
5
6
  * rule-routing.ts — the deterministic (no-model) State-B routing PREVIEW.
6
7
  *
@@ -11,18 +12,29 @@ exports.routeRules = routeRules;
11
12
  * enforcement path would take — WITHOUT running a model:
12
13
  *
13
14
  * reuse → the rule text names an off-the-shelf lint rule ({@link INTENT_MAP})
14
- * → mechanism: flip one config line.
15
+ * → mechanism: flip one config line. The "narrow list we compile
16
+ * very well" — everything else is honestly labelled, not force-fit.
15
17
  * hook → an ACTION rule a linter can't see (git push, rm -rf, "before you
16
18
  * commit") → mechanism: a pre-commit / PreToolUse hook.
19
+ * meta → an agent-instruction, not a code rule ("read X first", "tell the
20
+ * user", "you are …") → mechanism: stays prose. Split out of
21
+ * `unrouted` so it never reads as "compilable but hard" (it isn't).
17
22
  * semantic → a judgment call ("readable", "single responsibility") no checker
18
23
  * can honestly decide → mechanism: stays prose.
19
- * unrouted → none of the above fired deterministically → mechanism: the opt-in
20
- * `compile` tier routes it (reuse / synthesize / hook / prose).
24
+ * unrouted → looks like a code rule but matched no off-the-shelf rule: HARD to
25
+ * codify → mechanism `synthesize`: the opt-in SYNTHESIS tier (a
26
+ * skill on your subscription) MIGHT write a custom checker, gated —
27
+ * but it is NOT guaranteed (the gate may abstain). This is the
28
+ * bucket audit must present clearly as "hard", never as done.
29
+ *
30
+ * NB "compile" is NOT used here — `vigiles compile` is the unrelated spec→markdown
31
+ * verb. Synthesis is its own opt-in tier; the mechanism value is `synthesize`.
21
32
  *
22
33
  * HONESTY BY CONSTRUCTION: the deterministic tier NEVER claims a rule is
23
34
  * "synthesizable" — deciding that a custom rule can be written (and gating it)
24
- * is exactly the work the opt-in model tier does. Everything this file can't
25
- * pin to a concrete cue is `unrouted` ("compile to find out"), not a promise.
35
+ * is exactly the work the opt-in model tier does. `unrouted` means "hard — a
36
+ * synthesis skill may try", never a promise; `meta`/`semantic` mean "not an
37
+ * enforceable code rule at all" (a different, honest kind of no).
26
38
  *
27
39
  * Pure, deterministic, dependency-free. Reuses `rule-inventory`'s hardened
28
40
  * whole-token matcher + `INTENT_MAP`, and `segment`'s Tier-A segmenter.
@@ -33,33 +45,74 @@ const rule_inventory_js_1 = require("./rule-inventory.js");
33
45
  const MECHANISM = {
34
46
  reuse: "config-line",
35
47
  hook: "hook",
48
+ meta: "prose",
36
49
  semantic: "prose",
37
- unrouted: "compile",
50
+ unrouted: "synthesize",
38
51
  };
52
+ /**
53
+ * A deontic/norm signal ANYWHERE in a bullet — the marker of a rule the imperative
54
+ * gate missed because the norm isn't at the head ("every function MUST have a
55
+ * docstring", "public APIs SHOULD stay stable"). Used to keep the POSSIBLE review
56
+ * tier to genuine rule-candidates instead of arbitrary unparsed prose. Deliberately
57
+ * narrow (modal verbs only) so it doesn't re-admit the noise it exists to exclude.
58
+ */
59
+ const NORM_SIGNAL = /\b(?:must(?:n't)?|should(?:n't)?|shall|never|always|avoids?|require[sd]?|forbidden|disallow(?:ed)?|prohibited|banned?|prefers?|do not|don't)\b/i;
39
60
  /**
40
61
  * ACTION-rule cues — things a linter never sees (git, filesystem, shell,
41
- * process). A hook is the right gate, not a lint rule. Ported from the compiler's
42
- * classifier; deliberately specific so it doesn't grab a lint rule that merely
43
- * mentions a file.
62
+ * process). A hook is the right gate, not a lint rule. Widened to the article's
63
+ * measured surfaces (vcs / process / shell / redirect) — the narrow original
64
+ * (git-push + rm-rf only) reported ~2% hooks where the 252-rule hand-sort found
65
+ * **37%** (the largest bucket); it was missing push-to-branch, before-push,
66
+ * after-edit, tool-substitution, amend/rebase-pushed, and dependency guards.
67
+ * See `research/rule-compiler-multilang-design.md` §5b (the hook lane).
44
68
  */
45
69
  const HOOK_CUES = [
70
+ // — branch / push guards (vcs) —
46
71
  /\bgit\s+push\b/i,
47
- // "push … to main/master/prod" — tolerate backticks/adverbs between (real
48
- // phrasings: "push directly to `main`", "pushing straight to master").
49
- /\bpush\w*\b[^.\n]{0,24}\b(main|master|prod)\b/i,
72
+ // "push … to main/master/prod/origin" — tolerate backticks/adverbs between.
73
+ /\bpush\w*\b[^.\n]{0,24}\b(main|master|prod|production|origin|development)\b/i,
50
74
  /\bforce[- ]?push/i,
51
- /--no-verify/i,
52
- /\bnever\s+commit\b/i,
53
- // "before you/each/every commit", "before committing".
54
- /\bbefore\s+(you\s+|each\s+|every\s+)?commit(ting)?\b/i,
75
+ /\bpush\w*\b[^.\n]{0,16}\bbranch/i,
76
+ // — protected paths (vcs) —
77
+ /\b(don'?t|do not|never)\s+(edit|modify|touch|change)\b[^.\n]{0,30}\b(generated|vendored|lock(file)?|\.pb\.|proto-gen|_mock|snapshot)/i,
78
+ /\bgenerated\s+files?\b/i,
79
+ // — sequencing / tests-before / after-edit (process) —
80
+ /\b(run|execute)\b[^.\n]{0,30}\b(tests?|lint|check|type-?check|format|prettier|ruff)\b[^.\n]{0,20}\bbefore\b/i,
81
+ /\bbefore\s+(you\s+|each\s+|every\s+)?(commit|push|merg|committing|pushing)/i,
55
82
  /\brun\b[^.\n]{0,20}\btests?\b[^.\n]{0,14}\bbefore\b/i,
83
+ /\b(format|lint|check|run)\b[^.\n]{0,30}\b(after|immediately after)\b[^.\n]{0,16}\b(writ|edit)/i,
84
+ // — tool substitution / redirect —
85
+ /\b(never|do not|don'?t)\s+run\b[^.\n]{0,30}\b(directly|instead)\b/i,
86
+ /\b(never|do not|don'?t)\s+run\b[^.\n]{0,20}`?(eslint|prettier|npm|npx|cargo|yarn|pnpm|pip|black|ruff)\b/i,
87
+ /\buse\b\s+`?[\w:.-]+`?\s+(instead of|not|over|rather than)\s+`?(npm|npx|cargo|yarn|pnpm|eslint|prettier)\b/i,
88
+ // — regenerate-on-change guard (vigiles's own guard() path→cmd shape: "run X
89
+ // after/when Y changes") — the single biggest missed-hook pattern in the OSS
90
+ // dogfood. Both clause orders; the trigger MUST be a change-verb so a benign
91
+ // "run it when ready" doesn't match.
92
+ /\b(re-?run|regenerate|re-?generate|rebuild|regen|run|update)\b[^.\n]{0,48}\b(after|when|whenever|once|if)\b[^.\n]{0,24}\b(chang|add(?:ing|ed)?|modif|updat|edit|new\b)/i,
93
+ /\b(after|when|whenever|once)\b[^.\n]{0,24}\b(chang|add(?:ing|ed)?|modif|updat|edit)[^.\n]{0,48}\b(re-?run|regenerate|rebuild|regen|run|update)\b/i,
94
+ // — commit content / history (vcs) —
95
+ /\b(amend|squash|rebase)\b[^.\n]{0,40}\b(pushed|review|remote|shared)\b/i,
96
+ /--no-verify/i,
97
+ /\b(never|do not|don'?t)\s+commit\b/i,
56
98
  /\bsigned-off-by\b/i,
57
- /\b(don'?t|do not|never)\s+edit\b.*\b(generated|\.pb\.|_mock|proto-gen|lock)/i,
58
- /\bgenerated\s+files?\b/i,
59
- /\bco[- ]?authored[- ]?by\b/i,
99
+ /\bco-?author(?:s|ed|ing|ed-by)?\b/i,
100
+ // commit/PR METADATA hygiene phrased without a push verb (attribution, PR
101
+ // title, commit message, semantic/conventional commits) — a VCS-surface gate.
102
+ /\b(generated with|attribution)\b[^.\n]{0,24}\b(claude|ai|footer|commit|pr)\b/i,
103
+ /\b(ai|claude|assistant)\b[^.\n]{0,16}\battribution\b/i,
104
+ /\b(semantic|conventional)\s+(commit|pr|pull request)/i,
105
+ // NB: deliberately NO broad `(commit|pr) (title|message)` cue — it mislabels
106
+ // style sentences ("use sentence case for PR titles", "keep commit messages
107
+ // concise") as gates. The enforceable format/attribution rules are caught by
108
+ // the semantic/conventional + attribution cues above; the rest stay prose.
109
+ // — dependency / config guard —
110
+ /\b(never|do not|don'?t)\b[^.\n]{0,20}\bupdate\b[^.\n]{0,20}\b(depend|package\.json|lock)/i,
111
+ // — destructive / shell —
60
112
  /\brm\s+-rf\b/i,
61
113
  /\bcurl\b.*\|\s*(sh|bash)/i,
62
114
  /\bchmod\b/i,
115
+ /\bdestructive\s+git\b/i,
63
116
  ];
64
117
  /**
65
118
  * Judgment / no-checker cues — a rule no linter can honestly decide, so it stays
@@ -83,22 +136,264 @@ const SEMANTIC_CUES = [
83
136
  /\bclean code\b/i,
84
137
  ];
85
138
  /**
86
- * Route one atomic rule. Order matters: an ACTION cue (git push) wins over a
87
- * rule-name mention ("never commit console.log" is a hook, not a lint rule);
88
- * reuse (a concrete off-the-shelf rule) wins over a soft semantic cue.
139
+ * META cues — an instruction to the AGENT, not a norm about the CODE ("read X
140
+ * first", "tell the user", "you are …", "when in doubt ask"). It is not a lint
141
+ * rule and never will be, so it must NOT land in `unrouted` (which reads as
142
+ * "compilable, just hard"). High-precision by design — specific phrasings a real
143
+ * code rule would not use.
144
+ */
145
+ const META_CUES = [
146
+ // allow `.` in the gap — the referenced thing is often a filename (CLAUDE.md)
147
+ /\bread\b[^\n]{0,30}\bfirst\b/i,
148
+ /\bwhen in doubt\b/i,
149
+ /\bif (you'?re |you are )?unsure\b/i,
150
+ /\bask (the user|first|before)\b/i,
151
+ /\btell (the user|me)\b/i,
152
+ /\byou are\b[^.\n]{0,40}\b(assistant|agent|engineer|claude|model)\b/i,
153
+ /\byour (job|task|role) is\b/i,
154
+ /\b(do not|don'?t|never) (tell|mention|reveal|say)\b/i,
155
+ /\bin (chat|your (reply|response|answer))\b/i,
156
+ // H5 agent-ATTENTION norms (Fable's hook-lane taxonomy): re-read / re-run /
157
+ // re-fetch "without code changes". NOTHING reliably gates these — blocking a
158
+ // re-read breaks post-compaction recovery (false safety worse than an
159
+ // under-blocking guard) — so they are agent-guidance (meta), never a gate. The
160
+ // right instrument is MEASUREMENT (the flight recorder), not enforcement.
161
+ /\bre-?read(?:ing)?\b/i,
162
+ /\bre-?run(?:ning)?\b[^\n]{0,30}\b(?:test|command|suite)\b/i,
163
+ /\bre-?fetch(?:ing)?\b/i,
164
+ /\bwithout code changes\b/i,
165
+ ];
166
+ const PATTERN_RULE_MAP = [
167
+ {
168
+ construct: "default exports",
169
+ rule: "no-restricted-syntax",
170
+ linter: "eslint",
171
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid|prefer\s+named\s+(?:exports?\s+)?over)\b[^.\n]{0,24}\bdefault\s+exports?\b/i,
172
+ configFix: '"no-restricted-syntax": ["error", { "selector": "ExportDefaultDeclaration", "message": "Use named exports." }]',
173
+ },
174
+ {
175
+ construct: "enums",
176
+ rule: "no-restricted-syntax",
177
+ linter: "eslint",
178
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid)\b[^.\n]{0,24}\benums?\b/i,
179
+ configFix: '"no-restricted-syntax": ["error", { "selector": "TSEnumDeclaration", "message": "Use a union or const object instead of an enum." }]',
180
+ },
181
+ {
182
+ construct: "for...in",
183
+ rule: "no-restricted-syntax",
184
+ linter: "eslint",
185
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid)\b[^.\n]{0,16}\bfor[\s.]{0,3}in\b/i,
186
+ configFix: '"no-restricted-syntax": ["error", { "selector": "ForInStatement", "message": "Use for...of or Object.keys()." }]',
187
+ },
188
+ {
189
+ construct: "namespaces",
190
+ rule: "no-restricted-syntax",
191
+ linter: "eslint",
192
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid)\b[^.\n]{0,24}\bnamespaces?\b/i,
193
+ configFix: '"no-restricted-syntax": ["error", { "selector": "TSModuleDeclaration", "message": "Use ES modules instead of namespaces." }]',
194
+ },
195
+ {
196
+ construct: "classes",
197
+ rule: "no-restricted-syntax",
198
+ linter: "eslint",
199
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid)\b[^.\n]{0,12}\b(?<!css |style |styling |utility |tailwind |dom |react |component )(?:es6?\s+|javascript\s+)?class(?:es)?\b(?![\s-]*(?:name|attribute|selector|list))/i,
200
+ configFix: '"no-restricted-syntax": ["error", { "selector": ":matches(ClassDeclaration, ClassExpression)", "message": "Prefer functions and closures over classes." }]',
201
+ },
202
+ // Pylint: REQUIRE docstrings → missing-function-docstring. A PRESENCE context is
203
+ // required (a presence verb near "docstring", or "docstrings required/for each")
204
+ // so a bare "docstring" mention does NOT over-fire: the dogfood caught langchain
205
+ // routing docstring-CONTENT/STYLE rules ("docstring warnings", "backticks in
206
+ // docstrings", "don't repeat the default in the docstring") to this presence
207
+ // check — those are pydocstyle/ruff-D territory, not missing-docstring.
208
+ {
209
+ construct: "docstrings (presence)",
210
+ rule: "missing-function-docstring",
211
+ linter: "pylint",
212
+ pattern: /\b(?:add|require|requires?|write|writing|include|need|needs?|use|using|provide|document|must\s+have)\b[^.\n]{0,24}\bdocstrings?\b|\bdocstrings?\b[^.\n]{0,24}\b(?:required|mandatory|for\s+(?:all|every|each|every|public)|on\s+(?:all|every|each))\b/i,
213
+ configFix: "pylint enables missing-function-docstring (C0116) by default; keep it out of the disable list",
214
+ },
215
+ ];
216
+ /**
217
+ * Backticked rule-id-shaped tokens a bullet names — `curly`, `curly: error` →
218
+ * `curly`, `@typescript-eslint/consistent-type-imports`, `no-only-tests/no-only-tests`.
219
+ * The leading id is taken (severity/args after a `:`/space are dropped), so the
220
+ * token can be looked up against the dynamic catalog.
89
221
  */
90
- function classify(text) {
222
+ const CODE_SPAN_RE = /`([^`]+)`/g;
223
+ function namedRuleTokens(text) {
224
+ const out = [];
225
+ for (const m of text.matchAll(CODE_SPAN_RE)) {
226
+ const id = m[1].trim().match(/^@?[a-z][\w-]*(?:\/[a-z][\w-]*)*/i);
227
+ if (id)
228
+ out.push(id[0]);
229
+ }
230
+ return out;
231
+ }
232
+ /** Combine two hits that a doc-token resolves to (a cross-linter id collision).
233
+ * enabled OR-s — a "**Enforced by:** X" claim is satisfied if ANY linter has X
234
+ * on, so we never cry "documented but OFF" when one linter enforces it — and
235
+ * provenance follows the enforcing linter. */
236
+ function combineHits(a, b) {
237
+ if (a.enabled === b.enabled)
238
+ return { enabled: a.enabled, linter: a.linter };
239
+ return a.enabled ? a : b; // exactly one is on → it wins (enabled OR-s to true)
240
+ }
241
+ /** Build the doc-token → hit lookup from a (possibly polyglot) rule list. A rule
242
+ * is matchable by its id AND, for Pylint, its numeric code. A bare id CAN collide
243
+ * across linters (`no-else-return` is in both ESLint and Pylint) → combine
244
+ * conservatively. A numeric code is unique to its linter, so it never collides
245
+ * and KEEPS its own (linter, enabled) — a doc naming the Pylint code `R1705`
246
+ * still surfaces "documented but OFF" even when the symbol is enabled in ESLint. */
247
+ function buildCatalogLookup(rules) {
248
+ const map = new Map();
249
+ const put = (key, hit) => {
250
+ const prev = map.get(key);
251
+ map.set(key, prev ? combineHits(prev, hit) : hit);
252
+ };
253
+ for (const r of rules) {
254
+ const hit = { enabled: r.enabled, linter: r.linter };
255
+ put(r.id, hit);
256
+ if (r.code)
257
+ put(r.code, hit);
258
+ }
259
+ return map;
260
+ }
261
+ function classify(text, catalog) {
91
262
  if (HOOK_CUES.some((re) => re.test(text)))
92
263
  return { category: "hook" };
264
+ if (META_CUES.some((re) => re.test(text)))
265
+ return { category: "meta" };
266
+ // Dynamic catalog: a bullet that NAMES one of the repo's real rules → reuse,
267
+ // carrying its linter + whether it's currently enabled (a disabled hit = the
268
+ // "documented but OFF" nudge). Own-repo only — catalog is present only when the
269
+ // linter was enumerated with consent.
270
+ if (catalog) {
271
+ for (const tok of namedRuleTokens(text)) {
272
+ const hit = catalog.get(tok);
273
+ if (hit !== undefined)
274
+ return {
275
+ category: "reuse",
276
+ rule: tok,
277
+ enabled: hit.enabled,
278
+ linter: hit.linter,
279
+ };
280
+ }
281
+ }
93
282
  for (const m of rule_inventory_js_1.INTENT_MAP) {
94
283
  if (m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(text, kw))) {
95
284
  return { category: "reuse", rule: m.rule, linter: m.linter };
96
285
  }
97
286
  }
287
+ // Pattern → a built-in parameterized rule (eslint no-restricted-syntax
288
+ // construct-prohibitions; pylint docstring-presence). These LOOK custom but
289
+ // are reuse; each carries its own linter.
290
+ for (const r of PATTERN_RULE_MAP) {
291
+ if (r.pattern.test(text))
292
+ return { category: "reuse", rule: r.rule, linter: r.linter };
293
+ }
98
294
  if (SEMANTIC_CUES.some((re) => re.test(text)))
99
295
  return { category: "semantic" };
100
296
  return { category: "unrouted" };
101
297
  }
298
+ // --- Structured-marker pre-pass (S0/S1) ------------------------------------
299
+ const ENFORCED_RE = /^\*\*Enforced by:\*\*\s*`([^`]+)`/;
300
+ const GUARD_RE = /^\*\*Guard:\*\*/;
301
+ const GUIDANCE_RE = /^\*\*Guidance only\*\*/;
302
+ const MARK_HEADING = /^(#{2,6})\s+(.*)$/;
303
+ const RULE_ID_SHAPE = /^@?[a-z][a-z0-9._/-]*$/;
304
+ // Pylint's numeric alias (C0116, W9006) — the catalog advertises these as
305
+ // matchable, so a marker using one must parse as a rule id, not a prose claim.
306
+ const PYLINT_CODE_SHAPE = /^[A-Z]\d+$/;
307
+ /** Does this `**Enforced by:**` value parse as a lint-rule id (vs a prose claim
308
+ * like "CI" or "the linter")? A hand-written marker is a CLAIM — only a rule-id
309
+ * shape is treated as a real reuse rule. */
310
+ function looksLikeRuleId(s) {
311
+ const t = s.trim();
312
+ return (t.length >= 3 && RULE_ID_SHAPE.test(t)) || PYLINT_CODE_SHAPE.test(t);
313
+ }
314
+ /**
315
+ * Extract rules from EXPLICIT structured markers (`**Enforced by:** \`rule\``,
316
+ * `**Guard:**`, `**Guidance only**`) — the S0/S1 tier. A compiled/marked doc
317
+ * declares its own routing, so these are definitive (zero-heuristic) and are
318
+ * CONSUMED before the heuristic segmenter runs (the returned `skip` line set) so
319
+ * a marked rule is never double-counted. Each marker → ONE atom named by its
320
+ * `##`/`###` heading; a `**Guidance only**` body is still routed through
321
+ * `classify` (the promote-prose signal: a guidance whose body says "before
322
+ * commit" surfaces as a would-be hook). Foreign hand-written `**Enforced by:**`
323
+ * is a CLAIM — only a rule-id-shaped value becomes a reuse rule (gated + verified
324
+ * against the catalog when present; never an inferred contradiction).
325
+ */
326
+ function extractMarkedRules(text, file, catalog) {
327
+ const lines = text.split("\n");
328
+ const rules = [];
329
+ const skip = new Set();
330
+ for (let i = 0; i < lines.length; i++) {
331
+ const h = MARK_HEADING.exec(lines[i]);
332
+ if (!h)
333
+ continue;
334
+ let j = i + 1;
335
+ while (j < lines.length && !MARK_HEADING.test(lines[j]))
336
+ j++;
337
+ const section = lines.slice(i, j); // [heading … next-heading)
338
+ const heading = h[2].trim();
339
+ let marked = null;
340
+ for (const raw of section.slice(1)) {
341
+ const bl = raw.trim();
342
+ const em = ENFORCED_RE.exec(bl);
343
+ if (em) {
344
+ if (!looksLikeRuleId(em[1]))
345
+ break; // a prose claim, not a rule id
346
+ const hit = catalog?.get(em[1].trim());
347
+ marked = {
348
+ category: "reuse",
349
+ rule: em[1].trim(),
350
+ ...(hit !== undefined
351
+ ? { enabled: hit.enabled, linter: hit.linter }
352
+ : {}),
353
+ };
354
+ break;
355
+ }
356
+ if (GUARD_RE.test(bl)) {
357
+ marked = { category: "hook" };
358
+ break;
359
+ }
360
+ if (GUIDANCE_RE.test(bl)) {
361
+ // Route the guidance BODY through classify (promote-prose): a guidance
362
+ // whose text is really an action shows up as a would-be hook.
363
+ const body = section.slice(1).join(" ");
364
+ const c = classify(body, catalog);
365
+ // A guidance body that names a catalog rule is still "documented as
366
+ // guidance" — keep it prose unless it's a genuine action/agent cue.
367
+ marked =
368
+ c.category === "hook" || c.category === "meta"
369
+ ? c
370
+ : { category: "semantic" };
371
+ break;
372
+ }
373
+ }
374
+ if (!marked)
375
+ continue;
376
+ // Consume the section BODY lines (heading stays a non-candidate) so the
377
+ // heuristic segmenter never re-emits this marked rule. (1-based.)
378
+ for (let k = i + 1; k < j; k++)
379
+ skip.add(k + 1);
380
+ rules.push({
381
+ text: heading,
382
+ quote: lines[i],
383
+ file,
384
+ lineStart: i + 1,
385
+ lineEnd: j,
386
+ confidence: "high",
387
+ category: marked.category,
388
+ mechanism: MECHANISM[marked.category],
389
+ source: "marker",
390
+ ...(marked.rule ? { rule: marked.rule } : {}),
391
+ ...(marked.linter ? { linter: marked.linter } : {}),
392
+ ...(marked.enabled !== undefined ? { enabled: marked.enabled } : {}),
393
+ });
394
+ }
395
+ return { rules, skip };
396
+ }
102
397
  /**
103
398
  * Segment the instruction file and route every atomic rule deterministically.
104
399
  * Pure: the caller passes the concatenated instruction text (and an optional
@@ -106,9 +401,40 @@ function classify(text) {
106
401
  */
107
402
  function routeRules(instructionText, file, options = {}) {
108
403
  const minConfidence = options.minConfidence ?? "high";
109
- const segments = (0, segment_js_1.segmentInstructions)(instructionText, file).filter((s) => minConfidence === "medium" || s.confidence === "high");
110
- const rules = segments.map((s) => {
111
- const c = classify(s.text);
404
+ const catalog = options.availableRules
405
+ ? buildCatalogLookup(options.availableRules.rules)
406
+ : undefined;
407
+ // A MEDIUM segment that NAMES a rule the repo's catalog actually has is
408
+ // enforceable — the catalog is ground truth, so it's higher-precision than the
409
+ // segmenter's imperative-head cue. This rescues declarative-subject bullets
410
+ // ("The core layer must not import X (`boundaries/dependencies`)") that score
411
+ // medium (context+shape, no imperative head) and are otherwise dropped by the
412
+ // high-only default. Own-repo only (catalog present ⇒ enumerated with consent);
413
+ // the foreign-safe textual path stays conservative by design.
414
+ const namesCatalogRule = (text) => catalog !== undefined &&
415
+ namedRuleTokens(text).some((tok) => catalog.has(tok));
416
+ // A MEDIUM segment matching a construct-prohibition ("No default exports")
417
+ // scores medium ("No" is a prohibition head, not a verb) but is a real reuse
418
+ // rule (no-restricted-syntax) — rescue it, same as the catalog rescue. The
419
+ // patterns are their own precision gate (prohibition + construct proximity).
420
+ const matchesPatternRule = (text) => PATTERN_RULE_MAP.some((r) => r.pattern.test(text));
421
+ // A MEDIUM segment that matches an INTENT_MAP keyword (code-shaped, high-
422
+ // precision) is a real reuse rule — rescue it, same as catalog/restricted-
423
+ // syntax. Fixes construct-prohibitions with no verb ("No bare except clauses")
424
+ // that score medium and would otherwise drop before classify() reuses them.
425
+ const matchesIntentMap = (text) => rule_inventory_js_1.INTENT_MAP.some((m) => m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(text, kw)));
426
+ // A segment is CONFIDENT if it's high, rescued by the catalog/pattern/intent, or
427
+ // the caller opted into medium. Everything else the segmenter emitted is a
428
+ // POSSIBLE rule (medium, unrescued) — surfaced for review, not routed as fact.
429
+ // A RESCUE — the text NAMES/matches a real rule (catalog / restricted-syntax /
430
+ // intent). This promotes even a gate-rejected bullet to confident, because it
431
+ // provably maps to an off-the-shelf rule; independent of the medium opt-in.
432
+ const isRescued = (text) => namesCatalogRule(text) ||
433
+ matchesPatternRule(text) ||
434
+ matchesIntentMap(text);
435
+ const isConfident = (s) => minConfidence === "medium" || s.confidence === "high" || isRescued(s.text);
436
+ const toRouted = (s) => {
437
+ const c = classify(s.text, catalog);
112
438
  return {
113
439
  text: s.text,
114
440
  quote: s.exactQuote,
@@ -118,18 +444,102 @@ function routeRules(instructionText, file, options = {}) {
118
444
  confidence: s.confidence,
119
445
  category: c.category,
120
446
  mechanism: MECHANISM[c.category],
447
+ source: "heuristic",
121
448
  ...(c.rule ? { rule: c.rule } : {}),
122
449
  ...(c.linter ? { linter: c.linter } : {}),
450
+ ...(c.enabled !== undefined ? { enabled: c.enabled } : {}),
123
451
  };
452
+ };
453
+ // S0/S1 pre-pass: explicit markers are definitive and are CONSUMED (their body
454
+ // lines are skipped) so the heuristic segmenter can't double-count them.
455
+ const marked = extractMarkedRules(instructionText, file, catalog);
456
+ const { segments, skipped: rawSkipped } = (0, segment_js_1.segmentInstructions)(instructionText, file, marked.skip);
457
+ // A bullet the gate rejected as `no-signal` is a rule CANDIDATE only if it
458
+ // carries a deontic/norm signal (a modal like must/should/never/avoid) — that
459
+ // keeps the POSSIBLE review tier to genuine recall-misses ("every function must
460
+ // have a docstring") instead of flooding it with prose ("README.md documents
461
+ // v2"). A no-signal bullet WITHOUT a norm signal is confidently not a rule, so
462
+ // it stays SKIPPED alongside the index/description/section rejects. A folded
463
+ // candidate that NAMES/matches a real rule is still rescued to CONFIDENT.
464
+ const asCandidate = (s) => ({
465
+ text: s.text,
466
+ exactQuote: s.text,
467
+ file: s.file,
468
+ lineStart: s.lineStart,
469
+ lineEnd: s.lineEnd,
470
+ confidence: "medium",
124
471
  });
472
+ // Real SEGMENTS route by the full confident check (incl. the medium opt-in).
473
+ // Gate-rejected `no-signal` bullets are folded back in as candidates, but they
474
+ // are promoted to confident ONLY by a real RESCUE — NEVER by the blanket medium
475
+ // opt-in, which must not resurrect bullets the gate explicitly rejected.
476
+ const noSignal = rawSkipped.filter((s) => s.reason === "no-signal");
477
+ const folds = noSignal.map(asCandidate);
478
+ const heuristicRules = [
479
+ ...segments.filter(isConfident),
480
+ ...folds.filter((s) => isRescued(s.text)),
481
+ ].map(toRouted);
482
+ // The non-confident leftovers split by the norm signal: a rule-ish bullet
483
+ // (carries a deontic modal) is a genuine recall-miss → POSSIBLE (review); the
484
+ // rest is prose → SKIPPED with a `no-signal` reason (visible, not dropped).
485
+ const leftover = [
486
+ ...segments.filter((s) => !isConfident(s)),
487
+ ...folds.filter((s) => !isRescued(s.text)),
488
+ ];
489
+ const possible = leftover
490
+ .filter((s) => NORM_SIGNAL.test(s.text))
491
+ .map(toRouted);
492
+ const skipped = [
493
+ ...rawSkipped.filter((s) => s.reason !== "no-signal"),
494
+ ...leftover
495
+ .filter((s) => !NORM_SIGNAL.test(s.text))
496
+ .map((s) => ({
497
+ text: s.text,
498
+ file: s.file,
499
+ lineStart: s.lineStart,
500
+ lineEnd: s.lineEnd,
501
+ reason: "no-signal",
502
+ })),
503
+ ];
504
+ // Marker rules first (definitive), then the heuristic residue.
505
+ const rules = [...marked.rules, ...heuristicRules];
125
506
  const counts = {
126
507
  reuse: 0,
127
508
  hook: 0,
509
+ meta: 0,
128
510
  semantic: 0,
129
511
  unrouted: 0,
130
512
  };
131
513
  for (const r of rules)
132
514
  counts[r.category]++;
133
- return { segmented: segments.length, counts, rules };
515
+ return { segmented: rules.length, counts, rules, possible, skipped };
516
+ }
517
+ /**
518
+ * Merge per-file routings into one. Each instruction source is routed SEPARATELY
519
+ * (so every rule keeps its OWN file path + line numbers — concatenating first
520
+ * would corrupt the provenance the preview promises), then folded here: rules
521
+ * concatenated, counts + segmented summed. Pure. `[]` → an empty routing.
522
+ */
523
+ function mergeRoutings(routings) {
524
+ const counts = {
525
+ reuse: 0,
526
+ hook: 0,
527
+ meta: 0,
528
+ semantic: 0,
529
+ unrouted: 0,
530
+ };
531
+ const rules = [];
532
+ const possible = [];
533
+ const skipped = [];
534
+ let segmented = 0;
535
+ for (const r of routings) {
536
+ segmented += r.segmented;
537
+ rules.push(...r.rules);
538
+ possible.push(...r.possible);
539
+ skipped.push(...r.skipped);
540
+ for (const k of Object.keys(counts))
541
+ counts[k] += r.counts[k];
542
+ }
543
+ return { segmented, counts, rules, possible, skipped };
134
544
  }
135
545
  //# sourceMappingURL=rule-routing.js.map