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.
- package/README.md +16 -13
- package/dist/adapters/claude-code/dialect.d.ts +1 -1
- package/dist/audit-report.js +4 -1
- package/dist/audit-report.template.html +28 -28
- package/dist/cli.js +194 -35
- package/dist/core/rule-catalog.d.ts +49 -4
- package/dist/core/rule-catalog.js +151 -3
- package/dist/instruction-sources.d.ts +1 -1
- package/dist/instruction-sources.js +1 -1
- package/dist/rule-inventory.js +151 -2
- package/dist/rule-routing.d.ts +63 -1
- package/dist/rule-routing.js +231 -98
- package/dist/rule-signals.d.ts +46 -0
- package/dist/rule-signals.js +49 -0
- package/dist/segment.d.ts +35 -2
- package/dist/segment.js +233 -171
- package/package.json +1 -1
package/dist/rule-routing.js
CHANGED
|
@@ -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-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
238
|
-
// OFF" nudge). Own-repo only — catalog is present only when the
|
|
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
|
|
243
|
-
if (
|
|
244
|
-
return {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
?
|
|
504
|
+
? buildCatalogLookup(options.availableRules.rules)
|
|
364
505
|
: undefined;
|
|
365
|
-
|
|
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
|
-
//
|
|
410
|
-
|
|
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 {
|
|
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>):
|
|
65
|
+
export declare function segmentInstructions(markdown: string, file?: string, skipLines?: ReadonlySet<number>): SegmentResult;
|
|
33
66
|
//# sourceMappingURL=segment.d.ts.map
|