vigiles 13.0.0 → 14.0.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,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,66 @@ 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
  };
39
52
  /**
40
53
  * 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.
54
+ * process). A hook is the right gate, not a lint rule. Widened to the article's
55
+ * measured surfaces (vcs / process / shell / redirect) — the narrow original
56
+ * (git-push + rm-rf only) reported ~2% hooks where the 252-rule hand-sort found
57
+ * **37%** (the largest bucket); it was missing push-to-branch, before-push,
58
+ * after-edit, tool-substitution, amend/rebase-pushed, and dependency guards.
59
+ * See `research/rule-compiler-multilang-design.md` §5b (the hook lane).
44
60
  */
45
61
  const HOOK_CUES = [
62
+ // — branch / push guards (vcs) —
46
63
  /\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,
64
+ // "push … to main/master/prod/origin" — tolerate backticks/adverbs between.
65
+ /\bpush\w*\b[^.\n]{0,24}\b(main|master|prod|production|origin|development)\b/i,
50
66
  /\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,
67
+ /\bpush\w*\b[^.\n]{0,16}\bbranch/i,
68
+ // — protected paths (vcs) —
69
+ /\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,
70
+ /\bgenerated\s+files?\b/i,
71
+ // — sequencing / tests-before / after-edit (process) —
72
+ /\b(run|execute)\b[^.\n]{0,30}\b(tests?|lint|check|type-?check|format|prettier|ruff)\b[^.\n]{0,20}\bbefore\b/i,
73
+ /\bbefore\s+(you\s+|each\s+|every\s+)?(commit|push|merg|committing|pushing)/i,
55
74
  /\brun\b[^.\n]{0,20}\btests?\b[^.\n]{0,14}\bbefore\b/i,
75
+ /\b(format|lint|check|run)\b[^.\n]{0,30}\b(after|immediately after)\b[^.\n]{0,16}\b(writ|edit)/i,
76
+ // — tool substitution / redirect —
77
+ /\b(never|do not|don'?t)\s+run\b[^.\n]{0,30}\b(directly|instead)\b/i,
78
+ /\b(never|do not|don'?t)\s+run\b[^.\n]{0,20}`?(eslint|prettier|npm|npx|cargo|yarn|pnpm|pip|black|ruff)\b/i,
79
+ /\buse\b\s+`?[\w:.-]+`?\s+(instead of|not|over|rather than)\s+`?(npm|npx|cargo|yarn|pnpm|eslint|prettier)\b/i,
80
+ // — regenerate-on-change guard (vigiles's own guard() path→cmd shape: "run X
81
+ // after/when Y changes") — the single biggest missed-hook pattern in the OSS
82
+ // dogfood. Both clause orders; the trigger MUST be a change-verb so a benign
83
+ // "run it when ready" doesn't match.
84
+ /\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,
85
+ /\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,
86
+ // — commit content / history (vcs) —
87
+ /\b(amend|squash|rebase)\b[^.\n]{0,40}\b(pushed|review|remote|shared)\b/i,
88
+ /--no-verify/i,
89
+ /\b(never|do not|don'?t)\s+commit\b/i,
56
90
  /\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,
91
+ /\bco-?author(?:s|ed|ing|ed-by)?\b/i,
92
+ // commit/PR METADATA hygiene phrased without a push verb (attribution, PR
93
+ // title, commit message, semantic/conventional commits) — a VCS-surface gate.
94
+ /\b(generated with|attribution)\b[^.\n]{0,24}\b(claude|ai|footer|commit|pr)\b/i,
95
+ /\b(ai|claude|assistant)\b[^.\n]{0,16}\battribution\b/i,
96
+ /\b(semantic|conventional)\s+(commit|pr|pull request)/i,
97
+ // NB: deliberately NO broad `(commit|pr) (title|message)` cue — it mislabels
98
+ // style sentences ("use sentence case for PR titles", "keep commit messages
99
+ // concise") as gates. The enforceable format/attribution rules are caught by
100
+ // the semantic/conventional + attribution cues above; the rest stay prose.
101
+ // — dependency / config guard —
102
+ /\b(never|do not|don'?t)\b[^.\n]{0,20}\bupdate\b[^.\n]{0,20}\b(depend|package\.json|lock)/i,
103
+ // — destructive / shell —
60
104
  /\brm\s+-rf\b/i,
61
105
  /\bcurl\b.*\|\s*(sh|bash)/i,
62
106
  /\bchmod\b/i,
107
+ /\bdestructive\s+git\b/i,
63
108
  ];
64
109
  /**
65
110
  * Judgment / no-checker cues — a rule no linter can honestly decide, so it stays
@@ -82,23 +127,231 @@ const SEMANTIC_CUES = [
82
127
  /\bbest practices?\b/i,
83
128
  /\bclean code\b/i,
84
129
  ];
130
+ /**
131
+ * META cues — an instruction to the AGENT, not a norm about the CODE ("read X
132
+ * first", "tell the user", "you are …", "when in doubt ask"). It is not a lint
133
+ * rule and never will be, so it must NOT land in `unrouted` (which reads as
134
+ * "compilable, just hard"). High-precision by design — specific phrasings a real
135
+ * code rule would not use.
136
+ */
137
+ const META_CUES = [
138
+ // allow `.` in the gap — the referenced thing is often a filename (CLAUDE.md)
139
+ /\bread\b[^\n]{0,30}\bfirst\b/i,
140
+ /\bwhen in doubt\b/i,
141
+ /\bif (you'?re |you are )?unsure\b/i,
142
+ /\bask (the user|first|before)\b/i,
143
+ /\btell (the user|me)\b/i,
144
+ /\byou are\b[^.\n]{0,40}\b(assistant|agent|engineer|claude|model)\b/i,
145
+ /\byour (job|task|role) is\b/i,
146
+ /\b(do not|don'?t|never) (tell|mention|reveal|say)\b/i,
147
+ /\bin (chat|your (reply|response|answer))\b/i,
148
+ // H5 agent-ATTENTION norms (Fable's hook-lane taxonomy): re-read / re-run /
149
+ // re-fetch "without code changes". NOTHING reliably gates these — blocking a
150
+ // re-read breaks post-compaction recovery (false safety worse than an
151
+ // under-blocking guard) — so they are agent-guidance (meta), never a gate. The
152
+ // right instrument is MEASUREMENT (the flight recorder), not enforcement.
153
+ /\bre-?read(?:ing)?\b/i,
154
+ /\bre-?run(?:ning)?\b[^\n]{0,30}\b(?:test|command|suite)\b/i,
155
+ /\bre-?fetch(?:ing)?\b/i,
156
+ /\bwithout code changes\b/i,
157
+ ];
158
+ const PATTERN_RULE_MAP = [
159
+ {
160
+ construct: "default exports",
161
+ rule: "no-restricted-syntax",
162
+ linter: "eslint",
163
+ 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,
164
+ configFix: '"no-restricted-syntax": ["error", { "selector": "ExportDefaultDeclaration", "message": "Use named exports." }]',
165
+ },
166
+ {
167
+ construct: "enums",
168
+ rule: "no-restricted-syntax",
169
+ linter: "eslint",
170
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid)\b[^.\n]{0,24}\benums?\b/i,
171
+ configFix: '"no-restricted-syntax": ["error", { "selector": "TSEnumDeclaration", "message": "Use a union or const object instead of an enum." }]',
172
+ },
173
+ {
174
+ construct: "for...in",
175
+ rule: "no-restricted-syntax",
176
+ linter: "eslint",
177
+ 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,
178
+ configFix: '"no-restricted-syntax": ["error", { "selector": "ForInStatement", "message": "Use for...of or Object.keys()." }]',
179
+ },
180
+ {
181
+ construct: "namespaces",
182
+ rule: "no-restricted-syntax",
183
+ linter: "eslint",
184
+ pattern: /\b(?:no|never|avoid|don'?t\s+use|do\s+not\s+use|disallow|ban|forbid)\b[^.\n]{0,24}\bnamespaces?\b/i,
185
+ configFix: '"no-restricted-syntax": ["error", { "selector": "TSModuleDeclaration", "message": "Use ES modules instead of namespaces." }]',
186
+ },
187
+ {
188
+ construct: "classes",
189
+ rule: "no-restricted-syntax",
190
+ linter: "eslint",
191
+ 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,
192
+ configFix: '"no-restricted-syntax": ["error", { "selector": ":matches(ClassDeclaration, ClassExpression)", "message": "Prefer functions and closures over classes." }]',
193
+ },
194
+ // Pylint: REQUIRE docstrings → missing-function-docstring. A PRESENCE context is
195
+ // required (a presence verb near "docstring", or "docstrings required/for each")
196
+ // so a bare "docstring" mention does NOT over-fire: the dogfood caught langchain
197
+ // routing docstring-CONTENT/STYLE rules ("docstring warnings", "backticks in
198
+ // docstrings", "don't repeat the default in the docstring") to this presence
199
+ // check — those are pydocstyle/ruff-D territory, not missing-docstring.
200
+ {
201
+ construct: "docstrings (presence)",
202
+ rule: "missing-function-docstring",
203
+ linter: "pylint",
204
+ 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,
205
+ configFix: "pylint enables missing-function-docstring (C0116) by default; keep it out of the disable list",
206
+ },
207
+ ];
208
+ /**
209
+ * Backticked rule-id-shaped tokens a bullet names — `curly`, `curly: error` →
210
+ * `curly`, `@typescript-eslint/consistent-type-imports`, `no-only-tests/no-only-tests`.
211
+ * The leading id is taken (severity/args after a `:`/space are dropped), so the
212
+ * token can be looked up against the dynamic catalog.
213
+ */
214
+ const CODE_SPAN_RE = /`([^`]+)`/g;
215
+ function namedRuleTokens(text) {
216
+ const out = [];
217
+ for (const m of text.matchAll(CODE_SPAN_RE)) {
218
+ const id = m[1].trim().match(/^@?[a-z][\w-]*(?:\/[a-z][\w-]*)*/i);
219
+ if (id)
220
+ out.push(id[0]);
221
+ }
222
+ return out;
223
+ }
85
224
  /**
86
225
  * 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.
226
+ * rule-name mention ("never commit console.log" is a hook, not a lint rule); a
227
+ * META agent-instruction is pulled out before reuse so it isn't mismatched to a
228
+ * rule; the DYNAMIC catalog (if present) and the static `INTENT_MAP` both feed
229
+ * `reuse`; reuse wins over a soft semantic cue.
89
230
  */
90
- function classify(text) {
231
+ function classify(text, catalog) {
91
232
  if (HOOK_CUES.some((re) => re.test(text)))
92
233
  return { category: "hook" };
234
+ if (META_CUES.some((re) => re.test(text)))
235
+ return { category: "meta" };
236
+ // Dynamic catalog: a bullet that NAMES one of the repo's real rules → reuse,
237
+ // carrying whether it's currently enabled (a disabled hit = the "documented but
238
+ // OFF" nudge). Own-repo only — catalog is present only when the linter was
239
+ // enumerated with consent.
240
+ if (catalog) {
241
+ for (const tok of namedRuleTokens(text)) {
242
+ const enabled = catalog.get(tok);
243
+ if (enabled !== undefined)
244
+ return { category: "reuse", rule: tok, enabled };
245
+ }
246
+ }
93
247
  for (const m of rule_inventory_js_1.INTENT_MAP) {
94
248
  if (m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(text, kw))) {
95
249
  return { category: "reuse", rule: m.rule, linter: m.linter };
96
250
  }
97
251
  }
252
+ // Pattern → a built-in parameterized rule (eslint no-restricted-syntax
253
+ // construct-prohibitions; pylint docstring-presence). These LOOK custom but
254
+ // are reuse; each carries its own linter.
255
+ for (const r of PATTERN_RULE_MAP) {
256
+ if (r.pattern.test(text))
257
+ return { category: "reuse", rule: r.rule, linter: r.linter };
258
+ }
98
259
  if (SEMANTIC_CUES.some((re) => re.test(text)))
99
260
  return { category: "semantic" };
100
261
  return { category: "unrouted" };
101
262
  }
263
+ // --- Structured-marker pre-pass (S0/S1) ------------------------------------
264
+ const ENFORCED_RE = /^\*\*Enforced by:\*\*\s*`([^`]+)`/;
265
+ const GUARD_RE = /^\*\*Guard:\*\*/;
266
+ const GUIDANCE_RE = /^\*\*Guidance only\*\*/;
267
+ const MARK_HEADING = /^(#{2,6})\s+(.*)$/;
268
+ const RULE_ID_SHAPE = /^@?[a-z][a-z0-9._/-]*$/;
269
+ /** Does this `**Enforced by:**` value parse as a lint-rule id (vs a prose claim
270
+ * like "CI" or "the linter")? A hand-written marker is a CLAIM — only a rule-id
271
+ * shape is treated as a real reuse rule. */
272
+ function looksLikeRuleId(s) {
273
+ return s.length >= 3 && RULE_ID_SHAPE.test(s.trim());
274
+ }
275
+ /**
276
+ * Extract rules from EXPLICIT structured markers (`**Enforced by:** \`rule\``,
277
+ * `**Guard:**`, `**Guidance only**`) — the S0/S1 tier. A compiled/marked doc
278
+ * declares its own routing, so these are definitive (zero-heuristic) and are
279
+ * CONSUMED before the heuristic segmenter runs (the returned `skip` line set) so
280
+ * a marked rule is never double-counted. Each marker → ONE atom named by its
281
+ * `##`/`###` heading; a `**Guidance only**` body is still routed through
282
+ * `classify` (the promote-prose signal: a guidance whose body says "before
283
+ * commit" surfaces as a would-be hook). Foreign hand-written `**Enforced by:**`
284
+ * is a CLAIM — only a rule-id-shaped value becomes a reuse rule (gated + verified
285
+ * against the catalog when present; never an inferred contradiction).
286
+ */
287
+ function extractMarkedRules(text, file, catalog) {
288
+ const lines = text.split("\n");
289
+ const rules = [];
290
+ const skip = new Set();
291
+ for (let i = 0; i < lines.length; i++) {
292
+ const h = MARK_HEADING.exec(lines[i]);
293
+ if (!h)
294
+ continue;
295
+ let j = i + 1;
296
+ while (j < lines.length && !MARK_HEADING.test(lines[j]))
297
+ j++;
298
+ const section = lines.slice(i, j); // [heading … next-heading)
299
+ const heading = h[2].trim();
300
+ let marked = null;
301
+ for (const raw of section.slice(1)) {
302
+ const bl = raw.trim();
303
+ const em = ENFORCED_RE.exec(bl);
304
+ if (em) {
305
+ if (!looksLikeRuleId(em[1]))
306
+ break; // a prose claim, not a rule id
307
+ const enabled = catalog?.get(em[1].trim());
308
+ marked = {
309
+ category: "reuse",
310
+ rule: em[1].trim(),
311
+ ...(enabled !== undefined ? { enabled } : {}),
312
+ };
313
+ break;
314
+ }
315
+ if (GUARD_RE.test(bl)) {
316
+ marked = { category: "hook" };
317
+ break;
318
+ }
319
+ if (GUIDANCE_RE.test(bl)) {
320
+ // Route the guidance BODY through classify (promote-prose): a guidance
321
+ // whose text is really an action shows up as a would-be hook.
322
+ const body = section.slice(1).join(" ");
323
+ const c = classify(body, catalog);
324
+ // A guidance body that names a catalog rule is still "documented as
325
+ // guidance" — keep it prose unless it's a genuine action/agent cue.
326
+ marked =
327
+ c.category === "hook" || c.category === "meta"
328
+ ? c
329
+ : { category: "semantic" };
330
+ break;
331
+ }
332
+ }
333
+ if (!marked)
334
+ continue;
335
+ // Consume the section BODY lines (heading stays a non-candidate) so the
336
+ // heuristic segmenter never re-emits this marked rule. (1-based.)
337
+ for (let k = i + 1; k < j; k++)
338
+ skip.add(k + 1);
339
+ rules.push({
340
+ text: heading,
341
+ quote: lines[i],
342
+ file,
343
+ lineStart: i + 1,
344
+ lineEnd: j,
345
+ confidence: "high",
346
+ category: marked.category,
347
+ mechanism: MECHANISM[marked.category],
348
+ source: "marker",
349
+ ...(marked.rule ? { rule: marked.rule } : {}),
350
+ ...(marked.enabled !== undefined ? { enabled: marked.enabled } : {}),
351
+ });
352
+ }
353
+ return { rules, skip };
354
+ }
102
355
  /**
103
356
  * Segment the instruction file and route every atomic rule deterministically.
104
357
  * Pure: the caller passes the concatenated instruction text (and an optional
@@ -106,9 +359,38 @@ function classify(text) {
106
359
  */
107
360
  function routeRules(instructionText, file, options = {}) {
108
361
  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);
362
+ const catalog = options.availableRules
363
+ ? new Map(options.availableRules.rules.map((r) => [r.id, r.enabled]))
364
+ : undefined;
365
+ // A MEDIUM segment that NAMES a rule the repo's catalog actually has is
366
+ // enforceable — the catalog is ground truth, so it's higher-precision than the
367
+ // segmenter's imperative-head cue. This rescues declarative-subject bullets
368
+ // ("The core layer must not import X (`boundaries/dependencies`)") that score
369
+ // medium (context+shape, no imperative head) and are otherwise dropped by the
370
+ // high-only default. Own-repo only (catalog present ⇒ enumerated with consent);
371
+ // the foreign-safe textual path stays conservative by design.
372
+ const namesCatalogRule = (text) => catalog !== undefined &&
373
+ namedRuleTokens(text).some((tok) => catalog.has(tok));
374
+ // A MEDIUM segment matching a construct-prohibition ("No default exports")
375
+ // scores medium ("No" is a prohibition head, not a verb) but is a real reuse
376
+ // rule (no-restricted-syntax) — rescue it, same as the catalog rescue. The
377
+ // patterns are their own precision gate (prohibition + construct proximity).
378
+ const matchesPatternRule = (text) => PATTERN_RULE_MAP.some((r) => r.pattern.test(text));
379
+ // A MEDIUM segment that matches an INTENT_MAP keyword (code-shaped, high-
380
+ // precision) is a real reuse rule — rescue it, same as catalog/restricted-
381
+ // syntax. Fixes construct-prohibitions with no verb ("No bare except clauses")
382
+ // that score medium and would otherwise drop before classify() reuses them.
383
+ const matchesIntentMap = (text) => rule_inventory_js_1.INTENT_MAP.some((m) => m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(text, kw)));
384
+ // S0/S1 pre-pass: explicit markers are definitive and are CONSUMED (their body
385
+ // lines are skipped) so the heuristic segmenter can't double-count them.
386
+ const marked = extractMarkedRules(instructionText, file, catalog);
387
+ const segments = (0, segment_js_1.segmentInstructions)(instructionText, file, marked.skip).filter((s) => minConfidence === "medium" ||
388
+ s.confidence === "high" ||
389
+ namesCatalogRule(s.text) ||
390
+ matchesPatternRule(s.text) ||
391
+ matchesIntentMap(s.text));
392
+ const heuristicRules = segments.map((s) => {
393
+ const c = classify(s.text, catalog);
112
394
  return {
113
395
  text: s.text,
114
396
  quote: s.exactQuote,
@@ -118,18 +400,47 @@ function routeRules(instructionText, file, options = {}) {
118
400
  confidence: s.confidence,
119
401
  category: c.category,
120
402
  mechanism: MECHANISM[c.category],
403
+ source: "heuristic",
121
404
  ...(c.rule ? { rule: c.rule } : {}),
122
405
  ...(c.linter ? { linter: c.linter } : {}),
406
+ ...(c.enabled !== undefined ? { enabled: c.enabled } : {}),
123
407
  };
124
408
  });
409
+ // Marker rules first (definitive), then the heuristic residue.
410
+ const rules = [...marked.rules, ...heuristicRules];
125
411
  const counts = {
126
412
  reuse: 0,
127
413
  hook: 0,
414
+ meta: 0,
128
415
  semantic: 0,
129
416
  unrouted: 0,
130
417
  };
131
418
  for (const r of rules)
132
419
  counts[r.category]++;
133
- return { segmented: segments.length, counts, rules };
420
+ return { segmented: rules.length, counts, rules };
421
+ }
422
+ /**
423
+ * Merge per-file routings into one. Each instruction source is routed SEPARATELY
424
+ * (so every rule keeps its OWN file path + line numbers — concatenating first
425
+ * would corrupt the provenance the preview promises), then folded here: rules
426
+ * concatenated, counts + segmented summed. Pure. `[]` → an empty routing.
427
+ */
428
+ function mergeRoutings(routings) {
429
+ const counts = {
430
+ reuse: 0,
431
+ hook: 0,
432
+ meta: 0,
433
+ semantic: 0,
434
+ unrouted: 0,
435
+ };
436
+ const rules = [];
437
+ let segmented = 0;
438
+ for (const r of routings) {
439
+ segmented += r.segmented;
440
+ rules.push(...r.rules);
441
+ for (const k of Object.keys(counts))
442
+ counts[k] += r.counts[k];
443
+ }
444
+ return { segmented, counts, rules };
134
445
  }
135
446
  //# sourceMappingURL=rule-routing.js.map
package/dist/segment.d.ts CHANGED
@@ -29,5 +29,5 @@ export interface SegmentedRule {
29
29
  * candidacy. Candidate units are (a) list items with attached continuation
30
30
  * lines and (b) sentences of paragraphs under a rule-ish heading.
31
31
  */
32
- export declare function segmentInstructions(markdown: string, file?: string): SegmentedRule[];
32
+ export declare function segmentInstructions(markdown: string, file?: string, skipLines?: ReadonlySet<number>): SegmentedRule[];
33
33
  //# sourceMappingURL=segment.d.ts.map