vigiles 14.0.0 → 14.2.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,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LANE_META = void 0;
3
4
  exports.routeRules = routeRules;
4
5
  exports.mergeRoutings = mergeRoutings;
5
6
  /**
@@ -41,6 +42,7 @@ exports.mergeRoutings = mergeRoutings;
41
42
  */
42
43
  const segment_js_1 = require("./segment.js");
43
44
  const rule_inventory_js_1 = require("./rule-inventory.js");
45
+ const rule_signals_js_1 = require("./rule-signals.js");
44
46
  /** The mechanism each category maps to — a fixed, honest ladder. */
45
47
  const MECHANISM = {
46
48
  reuse: "config-line",
@@ -49,6 +51,28 @@ const MECHANISM = {
49
51
  semantic: "prose",
50
52
  unrouted: "synthesize",
51
53
  };
54
+ /**
55
+ * The user-facing presentation of each routing category — its glyph + lane
56
+ * label. The SINGLE SOURCE the terminal summary reads (and the HTML report
57
+ * mirrors), so the category-name → lane-label mapping lives in one place.
58
+ *
59
+ * NB the type name `unrouted` is a WIRE value (it appears in the versioned
60
+ * `AuditReport` JSON), which is why it isn't renamed to its lane label `custom`;
61
+ * this table is where the human-facing name is resolved. The category meanings
62
+ * are documented in the file header; the mapping is tabled in
63
+ * `research/rule-enforcer-design.md` §4.
64
+ */
65
+ exports.LANE_META = {
66
+ reuse: { glyph: "✓", label: "enforceable" },
67
+ hook: { glyph: "⛓", label: "hook" },
68
+ unrouted: { glyph: "⚙", label: "custom" },
69
+ semantic: { glyph: "✎", label: "judgment" },
70
+ meta: { glyph: "☰", label: "agent-note" },
71
+ };
72
+ // NORM_SIGNAL (a deontic modal ANYWHERE) keeps the POSSIBLE review tier to
73
+ // genuine rule-candidates the imperative gate missed ("every function MUST have
74
+ // a docstring") instead of arbitrary prose. It lives in ./rule-signals.ts
75
+ // alongside the segment gate's RULE_PREDICATE twin so the two can't drift.
52
76
  /**
53
77
  * ACTION-rule cues — things a linter never sees (git, filesystem, shell,
54
78
  * process). A hook is the right gate, not a lint rule. Widened to the article's
@@ -56,7 +80,7 @@ const MECHANISM = {
56
80
  * (git-push + rm-rf only) reported ~2% hooks where the 252-rule hand-sort found
57
81
  * **37%** (the largest bucket); it was missing push-to-branch, before-push,
58
82
  * after-edit, tool-substitution, amend/rebase-pushed, and dependency guards.
59
- * See `research/rule-compiler-multilang-design.md` §5b (the hook lane).
83
+ * See `research/rule-enforcer-multilang-design.md` §5b (the hook lane).
60
84
  */
61
85
  const HOOK_CUES = [
62
86
  // — branch / push guards (vcs) —
@@ -155,38 +179,41 @@ const META_CUES = [
155
179
  /\bre-?fetch(?:ing)?\b/i,
156
180
  /\bwithout code changes\b/i,
157
181
  ];
182
+ /** Every construct-prohibition maps to the same ESLint rule with a different
183
+ * selector — one named constant so the shared id isn't repeated as a literal. */
184
+ const NO_RESTRICTED_SYNTAX = "no-restricted-syntax";
158
185
  const PATTERN_RULE_MAP = [
159
186
  {
160
187
  construct: "default exports",
161
- rule: "no-restricted-syntax",
188
+ rule: NO_RESTRICTED_SYNTAX,
162
189
  linter: "eslint",
163
190
  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
191
  configFix: '"no-restricted-syntax": ["error", { "selector": "ExportDefaultDeclaration", "message": "Use named exports." }]',
165
192
  },
166
193
  {
167
194
  construct: "enums",
168
- rule: "no-restricted-syntax",
195
+ rule: NO_RESTRICTED_SYNTAX,
169
196
  linter: "eslint",
170
197
  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
198
  configFix: '"no-restricted-syntax": ["error", { "selector": "TSEnumDeclaration", "message": "Use a union or const object instead of an enum." }]',
172
199
  },
173
200
  {
174
201
  construct: "for...in",
175
- rule: "no-restricted-syntax",
202
+ rule: NO_RESTRICTED_SYNTAX,
176
203
  linter: "eslint",
177
204
  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
205
  configFix: '"no-restricted-syntax": ["error", { "selector": "ForInStatement", "message": "Use for...of or Object.keys()." }]',
179
206
  },
180
207
  {
181
208
  construct: "namespaces",
182
- rule: "no-restricted-syntax",
209
+ rule: NO_RESTRICTED_SYNTAX,
183
210
  linter: "eslint",
184
211
  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
212
  configFix: '"no-restricted-syntax": ["error", { "selector": "TSModuleDeclaration", "message": "Use ES modules instead of namespaces." }]',
186
213
  },
187
214
  {
188
215
  construct: "classes",
189
- rule: "no-restricted-syntax",
216
+ rule: NO_RESTRICTED_SYNTAX,
190
217
  linter: "eslint",
191
218
  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
219
  configFix: '"no-restricted-syntax": ["error", { "selector": ":matches(ClassDeclaration, ClassExpression)", "message": "Prefer functions and closures over classes." }]',
@@ -221,27 +248,54 @@ function namedRuleTokens(text) {
221
248
  }
222
249
  return out;
223
250
  }
224
- /**
225
- * Route one atomic rule. Order matters: an ACTION cue (git push) wins over a
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.
230
- */
251
+ /** Combine two hits that a doc-token resolves to (a cross-linter id collision).
252
+ * enabled OR-s — a "**Enforced by:** X" claim is satisfied if ANY linter has X
253
+ * on, so we never cry "documented but OFF" when one linter enforces it — and
254
+ * provenance follows the enforcing linter. */
255
+ function combineHits(a, b) {
256
+ if (a.enabled === b.enabled)
257
+ return { enabled: a.enabled, linter: a.linter };
258
+ return a.enabled ? a : b; // exactly one is on → it wins (enabled OR-s to true)
259
+ }
260
+ /** Build the doc-token → hit lookup from a (possibly polyglot) rule list. A rule
261
+ * is matchable by its id AND, for Pylint, its numeric code. A bare id CAN collide
262
+ * across linters (`no-else-return` is in both ESLint and Pylint) → combine
263
+ * conservatively. A numeric code is unique to its linter, so it never collides
264
+ * and KEEPS its own (linter, enabled) — a doc naming the Pylint code `R1705`
265
+ * still surfaces "documented but OFF" even when the symbol is enabled in ESLint. */
266
+ function buildCatalogLookup(rules) {
267
+ const map = new Map();
268
+ const put = (key, hit) => {
269
+ const prev = map.get(key);
270
+ map.set(key, prev ? combineHits(prev, hit) : hit);
271
+ };
272
+ for (const r of rules) {
273
+ const hit = { enabled: r.enabled, linter: r.linter };
274
+ put(r.id, hit);
275
+ if (r.code)
276
+ put(r.code, hit);
277
+ }
278
+ return map;
279
+ }
231
280
  function classify(text, catalog) {
232
281
  if (HOOK_CUES.some((re) => re.test(text)))
233
282
  return { category: "hook" };
234
283
  if (META_CUES.some((re) => re.test(text)))
235
284
  return { category: "meta" };
236
285
  // 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.
286
+ // carrying its linter + whether it's currently enabled (a disabled hit = the
287
+ // "documented but OFF" nudge). Own-repo only — catalog is present only when the
288
+ // linter was enumerated with consent.
240
289
  if (catalog) {
241
290
  for (const tok of namedRuleTokens(text)) {
242
- const enabled = catalog.get(tok);
243
- if (enabled !== undefined)
244
- return { category: "reuse", rule: tok, enabled };
291
+ const hit = catalog.get(tok);
292
+ if (hit !== undefined)
293
+ return {
294
+ category: "reuse",
295
+ rule: tok,
296
+ enabled: hit.enabled,
297
+ linter: hit.linter,
298
+ };
245
299
  }
246
300
  }
247
301
  for (const m of rule_inventory_js_1.INTENT_MAP) {
@@ -266,11 +320,15 @@ const GUARD_RE = /^\*\*Guard:\*\*/;
266
320
  const GUIDANCE_RE = /^\*\*Guidance only\*\*/;
267
321
  const MARK_HEADING = /^(#{2,6})\s+(.*)$/;
268
322
  const RULE_ID_SHAPE = /^@?[a-z][a-z0-9._/-]*$/;
323
+ // Pylint's numeric alias (C0116, W9006) — the catalog advertises these as
324
+ // matchable, so a marker using one must parse as a rule id, not a prose claim.
325
+ const PYLINT_CODE_SHAPE = /^[A-Z]\d+$/;
269
326
  /** Does this `**Enforced by:**` value parse as a lint-rule id (vs a prose claim
270
327
  * like "CI" or "the linter")? A hand-written marker is a CLAIM — only a rule-id
271
328
  * shape is treated as a real reuse rule. */
272
329
  function looksLikeRuleId(s) {
273
- return s.length >= 3 && RULE_ID_SHAPE.test(s.trim());
330
+ const t = s.trim();
331
+ return (t.length >= 3 && RULE_ID_SHAPE.test(t)) || PYLINT_CODE_SHAPE.test(t);
274
332
  }
275
333
  /**
276
334
  * Extract rules from EXPLICIT structured markers (`**Enforced by:** \`rule\``,
@@ -284,6 +342,60 @@ function looksLikeRuleId(s) {
284
342
  * is a CLAIM — only a rule-id-shaped value becomes a reuse rule (gated + verified
285
343
  * against the catalog when present; never an inferred contradiction).
286
344
  */
345
+ /** A `**Guidance only**` body is prose UNLESS its text is really an action/agent
346
+ * cue (promote-prose): route the whole body through classify and keep it prose
347
+ * (`semantic`) unless classify sees a genuine hook/meta signal. */
348
+ function guidanceClassification(section, catalog) {
349
+ const c = classify(section.slice(1).join(" "), catalog);
350
+ return c.category === "hook" || c.category === "meta"
351
+ ? c
352
+ : { category: "semantic" };
353
+ }
354
+ /** Scan a marked section's BODY for the FIRST structured marker and return its
355
+ * classification, or null if the section declares none (or an `**Enforced by:**`
356
+ * whose value is a prose claim, not a rule id). */
357
+ function markerFor(section, catalog) {
358
+ for (const raw of section.slice(1)) {
359
+ const bl = raw.trim();
360
+ const em = ENFORCED_RE.exec(bl);
361
+ if (em) {
362
+ if (!looksLikeRuleId(em[1]))
363
+ return null; // a prose claim, not a rule id
364
+ const rule = em[1].trim();
365
+ const hit = catalog?.get(rule);
366
+ return {
367
+ category: "reuse",
368
+ rule,
369
+ ...(hit !== undefined
370
+ ? { enabled: hit.enabled, linter: hit.linter }
371
+ : {}),
372
+ };
373
+ }
374
+ if (GUARD_RE.test(bl))
375
+ return { category: "hook" };
376
+ if (GUIDANCE_RE.test(bl))
377
+ return guidanceClassification(section, catalog);
378
+ }
379
+ return null;
380
+ }
381
+ /** Build a definitive (zero-heuristic) marker `RoutedRule` from a classification
382
+ * and its source location. */
383
+ function markerRuleFrom(marked, loc) {
384
+ return {
385
+ text: loc.text,
386
+ quote: loc.quote,
387
+ file: loc.file,
388
+ lineStart: loc.lineStart,
389
+ lineEnd: loc.lineEnd,
390
+ confidence: "high",
391
+ category: marked.category,
392
+ mechanism: MECHANISM[marked.category],
393
+ source: "marker",
394
+ ...(marked.rule ? { rule: marked.rule } : {}),
395
+ ...(marked.linter ? { linter: marked.linter } : {}),
396
+ ...(marked.enabled !== undefined ? { enabled: marked.enabled } : {}),
397
+ };
398
+ }
287
399
  function extractMarkedRules(text, file, catalog) {
288
400
  const lines = text.split("\n");
289
401
  const rules = [];
@@ -295,63 +407,92 @@ function extractMarkedRules(text, file, catalog) {
295
407
  let j = i + 1;
296
408
  while (j < lines.length && !MARK_HEADING.test(lines[j]))
297
409
  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
- }
410
+ const marked = markerFor(lines.slice(i, j), catalog);
333
411
  if (!marked)
334
412
  continue;
335
413
  // Consume the section BODY lines (heading stays a non-candidate) so the
336
414
  // heuristic segmenter never re-emits this marked rule. (1-based.)
337
415
  for (let k = i + 1; k < j; k++)
338
416
  skip.add(k + 1);
339
- rules.push({
340
- text: heading,
417
+ rules.push(markerRuleFrom(marked, {
418
+ text: h[2].trim(),
341
419
  quote: lines[i],
342
420
  file,
343
421
  lineStart: i + 1,
344
422
  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
- });
423
+ }));
352
424
  }
353
425
  return { rules, skip };
354
426
  }
427
+ /** The rescue sources, OR-ed (any one promotes a bullet to confident). They are
428
+ * the higher-precision override of the segmenter's imperative-head cue, which
429
+ * alone would drop these medium-scoring bullets. */
430
+ const RESCUE_SOURCES = [
431
+ // catalog — the text NAMES a rule the repo's live catalog actually has (ground
432
+ // truth, own-repo): rescues "The core layer must not import X (`boundaries/…`)".
433
+ (t, cat) => cat !== undefined && namedRuleTokens(t).some((tok) => cat.has(tok)),
434
+ // pattern — a construct-prohibition ("No default exports") → a real
435
+ // `no-restricted-syntax` rule.
436
+ (t) => PATTERN_RULE_MAP.some((r) => r.pattern.test(t)),
437
+ // intent — an INTENT_MAP keyword match ("No bare except clauses"): a
438
+ // code-shaped, high-precision reuse rule with no imperative verb.
439
+ (t) => rule_inventory_js_1.INTENT_MAP.some((m) => m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(t, kw))),
440
+ ];
441
+ /** A bullet is RESCUED — promoted to confident — if any rescue source maps it to
442
+ * a real off-the-shelf rule (independent of the medium opt-in). */
443
+ function isRescued(text, catalog) {
444
+ return RESCUE_SOURCES.some((rescue) => rescue(text, catalog));
445
+ }
446
+ const foldToCandidate = (s) => ({
447
+ text: s.text,
448
+ exactQuote: s.text,
449
+ file: s.file,
450
+ lineStart: s.lineStart,
451
+ lineEnd: s.lineEnd,
452
+ confidence: "medium",
453
+ });
454
+ const toNoSignalSkip = (s) => ({
455
+ text: s.text,
456
+ file: s.file,
457
+ lineStart: s.lineStart,
458
+ lineEnd: s.lineEnd,
459
+ reason: "no-signal",
460
+ });
461
+ /**
462
+ * Split segmenter output into the three routed tiers:
463
+ *
464
+ * - CONFIDENT — high, a medium opt-in, or a RESCUE (names/matches a real rule).
465
+ * - POSSIBLE — a non-confident leftover carrying a norm modal (`NORM_SIGNAL`):
466
+ * a genuine recall-miss surfaced for review ("every function must have a
467
+ * docstring"), not routed as fact.
468
+ * - SKIPPED — the rest (index/description/section rejects + no-signal leftovers).
469
+ *
470
+ * THE LOAD-BEARING ASYMMETRY: a gate-rejected `no-signal` bullet is folded back
471
+ * as a candidate but promoted to confident ONLY by a RESCUE — NEVER by the
472
+ * blanket medium opt-in, which must not resurrect what the gate explicitly
473
+ * rejected. See `research/rule-enforcer-design.md` §2.
474
+ */
475
+ function partitionCandidates(segments, rawSkipped, catalog, minConfidence) {
476
+ const rescued = (t) => isRescued(t, catalog);
477
+ const isConfident = (s) => minConfidence === "medium" || s.confidence === "high" || rescued(s.text);
478
+ const folds = rawSkipped
479
+ .filter((s) => s.reason === "no-signal")
480
+ .map(foldToCandidate);
481
+ const confident = [
482
+ ...segments.filter(isConfident),
483
+ ...folds.filter((s) => rescued(s.text)),
484
+ ];
485
+ const leftover = [
486
+ ...segments.filter((s) => !isConfident(s)),
487
+ ...folds.filter((s) => !rescued(s.text)),
488
+ ];
489
+ const possible = leftover.filter((s) => rule_signals_js_1.NORM_SIGNAL.test(s.text));
490
+ const skipped = [
491
+ ...rawSkipped.filter((s) => s.reason !== "no-signal"),
492
+ ...leftover.filter((s) => !rule_signals_js_1.NORM_SIGNAL.test(s.text)).map(toNoSignalSkip),
493
+ ];
494
+ return { confident, possible, skipped };
495
+ }
355
496
  /**
356
497
  * Segment the instruction file and route every atomic rule deterministically.
357
498
  * Pure: the caller passes the concatenated instruction text (and an optional
@@ -360,36 +501,9 @@ function extractMarkedRules(text, file, catalog) {
360
501
  function routeRules(instructionText, file, options = {}) {
361
502
  const minConfidence = options.minConfidence ?? "high";
362
503
  const catalog = options.availableRules
363
- ? new Map(options.availableRules.rules.map((r) => [r.id, r.enabled]))
504
+ ? buildCatalogLookup(options.availableRules.rules)
364
505
  : 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) => {
506
+ const toRouted = (s) => {
393
507
  const c = classify(s.text, catalog);
394
508
  return {
395
509
  text: s.text,
@@ -405,9 +519,18 @@ function routeRules(instructionText, file, options = {}) {
405
519
  ...(c.linter ? { linter: c.linter } : {}),
406
520
  ...(c.enabled !== undefined ? { enabled: c.enabled } : {}),
407
521
  };
408
- });
409
- // Marker rules first (definitive), then the heuristic residue.
410
- const rules = [...marked.rules, ...heuristicRules];
522
+ };
523
+ // S0/S1 pre-pass: explicit markers are definitive and are CONSUMED (their body
524
+ // lines are skipped) so the heuristic segmenter can't double-count them. The
525
+ // segmenter output then splits into confident / possible / skipped tiers.
526
+ const marked = extractMarkedRules(instructionText, file, catalog);
527
+ const { segments, skipped: rawSkipped } = (0, segment_js_1.segmentInstructions)(instructionText, file, marked.skip);
528
+ const tiers = partitionCandidates(segments, rawSkipped, catalog, minConfidence);
529
+ // Marker rules first (definitive), then the confident heuristic residue.
530
+ const rules = [
531
+ ...marked.rules,
532
+ ...tiers.confident.map(toRouted),
533
+ ];
411
534
  const counts = {
412
535
  reuse: 0,
413
536
  hook: 0,
@@ -417,7 +540,13 @@ function routeRules(instructionText, file, options = {}) {
417
540
  };
418
541
  for (const r of rules)
419
542
  counts[r.category]++;
420
- return { segmented: rules.length, counts, rules };
543
+ return {
544
+ segmented: rules.length,
545
+ counts,
546
+ rules,
547
+ possible: tiers.possible.map(toRouted),
548
+ skipped: tiers.skipped,
549
+ };
421
550
  }
422
551
  /**
423
552
  * Merge per-file routings into one. Each instruction source is routed SEPARATELY
@@ -434,13 +563,17 @@ function mergeRoutings(routings) {
434
563
  unrouted: 0,
435
564
  };
436
565
  const rules = [];
566
+ const possible = [];
567
+ const skipped = [];
437
568
  let segmented = 0;
438
569
  for (const r of routings) {
439
570
  segmented += r.segmented;
440
571
  rules.push(...r.rules);
572
+ possible.push(...r.possible);
573
+ skipped.push(...r.skipped);
441
574
  for (const k of Object.keys(counts))
442
575
  counts[k] += r.counts[k];
443
576
  }
444
- return { segmented, counts, rules };
577
+ return { segmented, counts, rules, possible, skipped };
445
578
  }
446
579
  //# sourceMappingURL=rule-routing.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared LEXICAL signals for "is this text a rule?" — the deontic/imperative
3
+ * vocabulary the detection pipeline keys on, kept in ONE home so the stages that
4
+ * use it can't silently drift. These previously lived as near-duplicate
5
+ * deontic-modal regexes in `segment.ts` (`RULE_PREDICATE`) and `rule-routing.ts`
6
+ * (`NORM_SIGNAL`) with no cross-reference.
7
+ *
8
+ * Two stages consume them:
9
+ *
10
+ * - the segment GATE (`src/segment.ts`) — precision-first accept/reject. Uses
11
+ * `FORM_HEAD` (an imperative/prohibitive sentence HEAD) as a rule cue, and
12
+ * `RULE_PREDICATE` (a deontic modal ANYWHERE) to stop a code-span-led
13
+ * sentence being mis-rejected as a description.
14
+ * - the routing POSSIBLE filter (`src/rule-routing.ts`) — recall recovery. Uses
15
+ * `NORM_SIGNAL` to keep the "possible (review)" tier to genuine recall-misses
16
+ * (a bullet that carries a norm modal) instead of flooding it with prose.
17
+ *
18
+ * `NORM_SIGNAL` and `RULE_PREDICATE` are BOTH "deontic modal anywhere" matchers
19
+ * with slightly different word lists ON PURPOSE — different jobs, calibrated
20
+ * separately against the OSS corpus. They are kept ADJACENT here so a widening
21
+ * of one prompts a review of the other, rather than the two drifting apart in
22
+ * separate files. See `research/rule-enforcer-design.md` §2.
23
+ */
24
+ /**
25
+ * An imperative/prohibitive sentence HEAD ("Never …", "Avoid …", "No …") — the
26
+ * segment gate's `form` cue. Anchored at the start (a HEAD, not anywhere). The
27
+ * deontic verbs (require/disallow/forbid/ban/enforce) are common rule leads
28
+ * ("Require `curly` braces", "Disallow `var`"). NB "no" is bare `no` + the
29
+ * shared trailing `\b` (a boundary right after "no" — before a space OR a
30
+ * backtick), so "No bare except" / "No default exports" / "No `any`" all match,
31
+ * while "Note"/"Nowhere" (no boundary after "no") are rejected. The earlier
32
+ * `no\s+\S` form silently failed "No bare except" (the measured bug).
33
+ */
34
+ export declare const FORM_HEAD: RegExp;
35
+ /**
36
+ * A deontic modal ANYWHERE in the text — routing's POSSIBLE-tier recall gate.
37
+ * Narrow (modal verbs only) so the review tier stays genuine recall-misses.
38
+ */
39
+ export declare const NORM_SIGNAL: RegExp;
40
+ /**
41
+ * A deontic predicate ANYWHERE — the segment gate's description-reject guard: a
42
+ * code-span-led sentence carrying one of these is a RULE ("`const` is preferred
43
+ * over `let`"), not a description, so the description reject must NOT fire.
44
+ */
45
+ export declare const RULE_PREDICATE: RegExp;
46
+ //# sourceMappingURL=rule-signals.d.ts.map
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ /**
3
+ * Shared LEXICAL signals for "is this text a rule?" — the deontic/imperative
4
+ * vocabulary the detection pipeline keys on, kept in ONE home so the stages that
5
+ * use it can't silently drift. These previously lived as near-duplicate
6
+ * deontic-modal regexes in `segment.ts` (`RULE_PREDICATE`) and `rule-routing.ts`
7
+ * (`NORM_SIGNAL`) with no cross-reference.
8
+ *
9
+ * Two stages consume them:
10
+ *
11
+ * - the segment GATE (`src/segment.ts`) — precision-first accept/reject. Uses
12
+ * `FORM_HEAD` (an imperative/prohibitive sentence HEAD) as a rule cue, and
13
+ * `RULE_PREDICATE` (a deontic modal ANYWHERE) to stop a code-span-led
14
+ * sentence being mis-rejected as a description.
15
+ * - the routing POSSIBLE filter (`src/rule-routing.ts`) — recall recovery. Uses
16
+ * `NORM_SIGNAL` to keep the "possible (review)" tier to genuine recall-misses
17
+ * (a bullet that carries a norm modal) instead of flooding it with prose.
18
+ *
19
+ * `NORM_SIGNAL` and `RULE_PREDICATE` are BOTH "deontic modal anywhere" matchers
20
+ * with slightly different word lists ON PURPOSE — different jobs, calibrated
21
+ * separately against the OSS corpus. They are kept ADJACENT here so a widening
22
+ * of one prompts a review of the other, rather than the two drifting apart in
23
+ * separate files. See `research/rule-enforcer-design.md` §2.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.RULE_PREDICATE = exports.NORM_SIGNAL = exports.FORM_HEAD = void 0;
27
+ /**
28
+ * An imperative/prohibitive sentence HEAD ("Never …", "Avoid …", "No …") — the
29
+ * segment gate's `form` cue. Anchored at the start (a HEAD, not anywhere). The
30
+ * deontic verbs (require/disallow/forbid/ban/enforce) are common rule leads
31
+ * ("Require `curly` braces", "Disallow `var`"). NB "no" is bare `no` + the
32
+ * shared trailing `\b` (a boundary right after "no" — before a space OR a
33
+ * backtick), so "No bare except" / "No default exports" / "No `any`" all match,
34
+ * while "Note"/"Nowhere" (no boundary after "no") are rejected. The earlier
35
+ * `no\s+\S` form silently failed "No bare except" (the measured bug).
36
+ */
37
+ exports.FORM_HEAD = /^(?:use|avoid|prefer|never|always|don'?t|do not|no|must|should|keep|run|write|add|remove|only|require|requires?|disallow|forbid|ban|enforce)\b/i;
38
+ /**
39
+ * A deontic modal ANYWHERE in the text — routing's POSSIBLE-tier recall gate.
40
+ * Narrow (modal verbs only) so the review tier stays genuine recall-misses.
41
+ */
42
+ exports.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;
43
+ /**
44
+ * A deontic predicate ANYWHERE — the segment gate's description-reject guard: a
45
+ * code-span-led sentence carrying one of these is a RULE ("`const` is preferred
46
+ * over `let`"), not a description, so the description reject must NOT fire.
47
+ */
48
+ exports.RULE_PREDICATE = /\b(?:must|should|shall|never|always|require|avoid|prefer|banned|forbidden|prohibited|allowed|disallowed|deprecated|discouraged|mandatory|do not|don'?t|only|instead)\b/i;
49
+ //# sourceMappingURL=rule-signals.js.map
package/dist/segment.d.ts CHANGED
@@ -22,12 +22,45 @@ export interface SegmentedRule {
22
22
  /** 3/3 cues => "high"; 2/3 => "medium". (Rejected candidates are never emitted.) */
23
23
  confidence: "high" | "medium";
24
24
  }
25
+ /** Why the segmenter decided a bullet is NOT a rule (the transparency signal —
26
+ * see `research/rule-enforcer-design.md` §3). `index`/`description`/`leadin`/
27
+ * `no-signal` come from the gate; `section` means it sits under a non-rule
28
+ * heading (Setup / Commands / Key Files / Architecture …).
29
+ *
30
+ * `leadin` is a colon-terminated procedure/enumeration HEADER ("To add a
31
+ * setting:", "Run the full test suite:", "Python check:") whose enforceable
32
+ * content — if any — lives in the sub-bullets/code-block it introduces (verified
33
+ * on the OSS corpus: the sub-items are segmented independently, so dropping the
34
+ * header loses nothing). Kept DISTINCT from `no-signal` on purpose: routing
35
+ * re-surfaces `no-signal` skips in the "possible (review)" recall tier, and a
36
+ * lead-in is a CONFIDENT drop that must not re-enter that tier. */
37
+ export type RejectReason = "index" | "description" | "leadin" | "no-signal" | "section";
38
+ /** A BULLET the segmenter saw but did NOT treat as a rule, with the reason — so
39
+ * the audit report can be honest about what it set aside (a heuristic misses
40
+ * declarative rules; showing skips lets a human eyeball a wrong drop). Bounded to
41
+ * list items on purpose; rejected paragraph prose is not reported (too noisy). */
42
+ export interface SkippedBullet {
43
+ readonly text: string;
44
+ readonly file: string | undefined;
45
+ readonly lineStart: number;
46
+ readonly lineEnd: number;
47
+ readonly reason: RejectReason;
48
+ }
49
+ /** The segmenter's full output: the confident/medium candidate rules PLUS the
50
+ * bullets it rejected (with reasons), so nothing is silently dropped. */
51
+ export interface SegmentResult {
52
+ readonly segments: SegmentedRule[];
53
+ readonly skipped: SkippedBullet[];
54
+ }
25
55
  /**
26
56
  * Split a CLAUDE.md / AGENTS.md into atomic candidate rules with provenance.
27
57
  *
28
58
  * Deterministic Tier-A heuristic. Code fences and tables are excluded from
29
59
  * candidacy. Candidate units are (a) list items with attached continuation
30
- * lines and (b) sentences of paragraphs under a rule-ish heading.
60
+ * lines and (b) sentences of paragraphs under a rule-ish heading. This function
61
+ * is a thin DISPATCHER — each block type is handled by its own pure helper
62
+ * (`handleListItem` / `handleParagraph`); the state it threads is the fence
63
+ * toggle and the current `HeadingState`.
31
64
  */
32
- export declare function segmentInstructions(markdown: string, file?: string, skipLines?: ReadonlySet<number>): SegmentedRule[];
65
+ export declare function segmentInstructions(markdown: string, file?: string, skipLines?: ReadonlySet<number>): SegmentResult;
33
66
  //# sourceMappingURL=segment.d.ts.map