vigiles 12.6.0 → 12.8.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.
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ /**
3
+ * Rule-inventory — the deterministic, foreign-safe teaser surface of the
4
+ * `audit` rule-compile tier (design: `research/audit-rule-compile-tier.md`).
5
+ *
6
+ * Finds prose rules in a `CLAUDE.md` / `AGENTS.md` that map to an off-the-shelf
7
+ * lint rule, and whether that rule already appears in the repo's lint config.
8
+ * The remedy for a documented-but-unconfigured intent is a one-line config
9
+ * change, not synthesis — so this is a cheap, high-value nudge.
10
+ *
11
+ * NO model. NO config execution (textual grep only — never resolves/executes
12
+ * `eslint.config.js`, which would be the RCE path). HIGH PRECISION by
13
+ * construction: only rule-name / code-token-shaped keywords are matched, and
14
+ * only as whole tokens. Bare prose words are excluded on purpose — a raw
15
+ * keyword match (`token`, `!`, `await`, `secret`, `silently`, …) sprays false
16
+ * positives on real instruction files (measured: 107 raw hits over 4 real
17
+ * CLAUDE.md files, ~all garbage). The model-driven analysis (extract → classify
18
+ * → compile → gate → run) lives in the OPT-IN tier, not here.
19
+ *
20
+ * MULTI-LINTER by shape, ESLint-first by data. The matcher is linter-agnostic;
21
+ * each mapping is keyed by linter, so adding Ruff / Clippy / Pylint / RuboCop /
22
+ * Stylelint is additive DATA (a curation task), not a refactor. The OPT-IN tier
23
+ * should resolve "is this rule enabled" via vigiles's existing multi-linter
24
+ * `checkLinterRule` engine (which execs the config) — kept out of this
25
+ * exec-free, foreign-safe surface on purpose.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.INTENT_MAP = void 0;
29
+ exports.matchesWholeToken = matchesWholeToken;
30
+ exports.buildRuleInventory = buildRuleInventory;
31
+ /**
32
+ * Curated from agent-rules-compiler's `rule-map.json`, keeping ONLY the
33
+ * specific (rule-name / code-token) keywords and dropping every bare-word
34
+ * trigger the FP measurement flagged (`token`, `secret`, `password`, `await`,
35
+ * `!`, `aria`, `silently`, `prefix`, `complexity`, `barrel`, `cycle`, …).
36
+ *
37
+ * ESLint-only today — Ruff/Clippy/Pylint/RuboCop/Stylelint entries append here
38
+ * with their own `linter` + rule-name keywords, no code change.
39
+ */
40
+ exports.INTENT_MAP = [
41
+ {
42
+ intent: "no console.log / use the logger",
43
+ linter: "eslint",
44
+ keywords: ["console.log", "no-console"],
45
+ rule: "no-console",
46
+ configFix: '"no-console": "error"',
47
+ },
48
+ {
49
+ intent: "no `any` type",
50
+ linter: "eslint",
51
+ keywords: ["no-explicit-any", "@typescript-eslint/no-explicit-any"],
52
+ rule: "@typescript-eslint/no-explicit-any",
53
+ inRecommended: true,
54
+ configFix: '"@typescript-eslint/no-explicit-any": "error"',
55
+ },
56
+ {
57
+ intent: "no eslint-disable / no linter suppressors",
58
+ linter: "eslint",
59
+ keywords: ["eslint-disable", "eslint-comments/no-use"],
60
+ rule: "eslint-comments/no-use",
61
+ configFix: "enable eslint-comments/no-use OR linterOptions.noInlineConfig: true",
62
+ },
63
+ {
64
+ intent: "no @ts-ignore / @ts-expect-error abuse",
65
+ linter: "eslint",
66
+ keywords: [
67
+ "@ts-ignore",
68
+ "ts-expect-error",
69
+ "ban-ts-comment",
70
+ "@typescript-eslint/ban-ts-comment",
71
+ ],
72
+ rule: "@typescript-eslint/ban-ts-comment",
73
+ inRecommended: true,
74
+ configFix: '"@typescript-eslint/ban-ts-comment": "error"',
75
+ },
76
+ {
77
+ intent: "no hardcoded secrets",
78
+ linter: "eslint",
79
+ keywords: ["no-secrets"],
80
+ rule: "no-secrets/no-secrets",
81
+ configFix: "add eslint-plugin-no-secrets rule no-secrets/no-secrets",
82
+ },
83
+ {
84
+ intent: "no empty catch / no swallowed errors",
85
+ linter: "eslint",
86
+ keywords: ["no-empty"],
87
+ rule: "no-empty",
88
+ inRecommended: true,
89
+ configFix: '"no-empty": ["error", {"allowEmptyCatch": false}]',
90
+ },
91
+ {
92
+ intent: "no var / prefer const-let",
93
+ linter: "eslint",
94
+ keywords: ["no-var", "prefer-const"],
95
+ rule: "no-var",
96
+ configFix: '"no-var": "error"',
97
+ },
98
+ {
99
+ intent: "strict equality ===",
100
+ linter: "eslint",
101
+ keywords: ["eqeqeq"],
102
+ rule: "eqeqeq",
103
+ configFix: '"eqeqeq": "error"',
104
+ },
105
+ {
106
+ intent: "template literals over concatenation",
107
+ linter: "eslint",
108
+ keywords: ["prefer-template"],
109
+ rule: "prefer-template",
110
+ configFix: '"prefer-template": "error"',
111
+ },
112
+ {
113
+ intent: "no debugger",
114
+ linter: "eslint",
115
+ keywords: ["no-debugger"],
116
+ rule: "no-debugger",
117
+ inRecommended: true,
118
+ configFix: '"no-debugger": "error"',
119
+ },
120
+ {
121
+ intent: "restricted / deprecated imports",
122
+ linter: "eslint",
123
+ keywords: ["no-restricted-imports", "import/no-restricted-paths"],
124
+ rule: "no-restricted-imports",
125
+ configFix: '"no-restricted-imports": ["error", {…}]',
126
+ },
127
+ {
128
+ intent: "no circular deps",
129
+ linter: "eslint",
130
+ keywords: ["import/no-cycle", "no-cycle"],
131
+ rule: "import/no-cycle",
132
+ configFix: '"import/no-cycle": "error"',
133
+ },
134
+ {
135
+ intent: "function length / complexity caps",
136
+ linter: "eslint",
137
+ keywords: [
138
+ "max-lines-per-function",
139
+ "max-depth",
140
+ "max-params",
141
+ "max-statements",
142
+ ],
143
+ rule: "max-lines-per-function",
144
+ configFix: '"max-lines-per-function": ["error", 40]',
145
+ },
146
+ {
147
+ intent: "no unused vars",
148
+ linter: "eslint",
149
+ keywords: ["no-unused-vars", "@typescript-eslint/no-unused-vars"],
150
+ rule: "@typescript-eslint/no-unused-vars",
151
+ inRecommended: true,
152
+ configFix: '"@typescript-eslint/no-unused-vars": "error"',
153
+ },
154
+ {
155
+ intent: "no non-null assertion",
156
+ linter: "eslint",
157
+ keywords: [
158
+ "no-non-null-assertion",
159
+ "@typescript-eslint/no-non-null-assertion",
160
+ ],
161
+ rule: "@typescript-eslint/no-non-null-assertion",
162
+ inRecommended: true,
163
+ configFix: '"@typescript-eslint/no-non-null-assertion": "error"',
164
+ },
165
+ {
166
+ intent: "no floating promises",
167
+ linter: "eslint",
168
+ keywords: [
169
+ "no-floating-promises",
170
+ "@typescript-eslint/no-floating-promises",
171
+ ],
172
+ rule: "@typescript-eslint/no-floating-promises",
173
+ inRecommended: true,
174
+ configFix: '"@typescript-eslint/no-floating-promises": "error"',
175
+ },
176
+ {
177
+ intent: "react hooks deps",
178
+ linter: "eslint",
179
+ keywords: ["react-hooks/exhaustive-deps", "exhaustive-deps"],
180
+ rule: "react-hooks/exhaustive-deps",
181
+ configFix: '"react-hooks/exhaustive-deps": "error"',
182
+ },
183
+ // Added from the hand-verified rule-adherence corpus (real repos: motion,
184
+ // mapbox) — both are documented in the wild and have an off-the-shelf rule.
185
+ {
186
+ intent: "no default exports",
187
+ linter: "eslint",
188
+ keywords: ["import/no-default-export", "no-default-export"],
189
+ rule: "import/no-default-export",
190
+ configFix: '"import/no-default-export": "error"',
191
+ },
192
+ {
193
+ intent: "no TODO / FIXME comments",
194
+ linter: "eslint",
195
+ keywords: ["no-warning-comments"],
196
+ rule: "no-warning-comments",
197
+ configFix: '"no-warning-comments": ["error", {"terms": ["todo", "fixme"], "location": "anywhere"}]',
198
+ },
199
+ ];
200
+ /** Escape a keyword for use inside a RegExp. */
201
+ function escapeRe(s) {
202
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
+ }
204
+ /**
205
+ * A keyword matches only as a WHOLE token: bounded by start/end or a
206
+ * non-`[\w/@.-]` character on each side (so `no-console` matches in
207
+ * `` `no-console` `` and `enforce no-console;` but `no-console-x` does not,
208
+ * and prose containing the substring elsewhere never trips it).
209
+ */
210
+ function matchesWholeToken(text, keyword) {
211
+ const re = new RegExp(`(^|[^\\w/@.-])${escapeRe(keyword)}([^\\w/@.-]|$)`, "i");
212
+ return re.test(text);
213
+ }
214
+ /**
215
+ * Build the deterministic rule inventory. Pure: caller passes the instruction
216
+ * file text (concatenated CLAUDE.md/AGENTS.md) and the lint config text (any
217
+ * `eslint.config.*` / `.eslintrc*` / `ruff.toml` / … contents concatenated, or
218
+ * "" if none). Returns one item per documented intent whose keyword resolves.
219
+ * `not-in-config` items are the actionable nudges.
220
+ */
221
+ /** ESLint re-exports some core rules under `@typescript-eslint/`; treat the base
222
+ * and scoped names as the same rule when checking the config text (so a repo that
223
+ * has base `no-unused-vars` satisfies the `@typescript-eslint/no-unused-vars`
224
+ * intent, and vice-versa). oxlint/biome use the SAME rule names, so once their
225
+ * config files are in the read set this handles them for free. */
226
+ function variantsOf(rule) {
227
+ const TS = "@typescript-eslint/";
228
+ if (rule.startsWith(TS))
229
+ return [rule, rule.slice(TS.length)];
230
+ if (!rule.includes("/"))
231
+ return [rule, TS + rule];
232
+ return [rule];
233
+ }
234
+ /** True if the rule (or a base/scoped variant) appears in the config text. */
235
+ function ruleInConfig(configText, rule) {
236
+ return variantsOf(rule).some((v) => matchesWholeToken(configText, v));
237
+ }
238
+ /**
239
+ * Whether the rule (or a variant) is EXPLICITLY disabled in the config text —
240
+ * `"no-console": "off"`, `no-console: 0`, `"no-console": ["off", …]`. Distinct
241
+ * from mere presence ({@link ruleInConfig}): a documented rule that the config
242
+ * turns OFF is a contradiction (docs say enforce, config disables), not an
243
+ * enforcement. Textual only — never resolves/executes the config (the RCE path).
244
+ * Conservative: matches only a literal off/0 severity right after the rule key,
245
+ * so a real `"error"`/`"warn"`/`1`/`2` never trips it.
246
+ */
247
+ function ruleSetOff(configText, rule) {
248
+ return variantsOf(rule).some((v) => {
249
+ const re = new RegExp(`["']?${escapeRe(v)}["']?\\s*:\\s*(?:\\[\\s*)?["']?(?:off|0)\\b`, "i");
250
+ return re.test(configText);
251
+ });
252
+ }
253
+ /** Index of the first WHOLE-token occurrence of `keyword` in `text` (the keyword
254
+ * itself, not the boundary char), or -1. */
255
+ function firstTokenIndex(text, keyword) {
256
+ const re = new RegExp(`(^|[^\\w/@.-])(${escapeRe(keyword)})([^\\w/@.-]|$)`, "i");
257
+ const m = re.exec(text);
258
+ return m ? m.index + m[1].length : -1;
259
+ }
260
+ /**
261
+ * Whether the matched mention is a documented opt-OUT ("`no-explicit-any` is off
262
+ * intentionally", "we disable X") rather than a norm to enforce. When the author
263
+ * deliberately turns a rule off, a not-in-config state is CONSISTENT, not a gap —
264
+ * nudging them to enable it is actively wrong advice (found dogfooding
265
+ * pmndrs/react-spring). Deterministic negation window around the matched token;
266
+ * conservative on purpose — only strong off/disable cues, so it never suppresses
267
+ * a genuine "enforce this" nudge. */
268
+ function isDocumentedOptOut(text, keyword) {
269
+ const idx = firstTokenIndex(text, keyword);
270
+ if (idx < 0)
271
+ return false;
272
+ // Test the context on EITHER SIDE of the token, never the token itself — else
273
+ // the cue `disable` would match inside the rule name `eslint-disable` and
274
+ // self-suppress every mention of it. Left/right windows are separate strings.
275
+ // NB: `disabled` (full word) not `disable` — `disable` would match inside the
276
+ // rule name `eslint-disable`, which can appear in a NEIGHBOURING token's window.
277
+ const CUE = /\b(?:off|disabled|not enforced|not enabled|turned off)\b/i;
278
+ const left = text.slice(Math.max(0, idx - 48), idx);
279
+ const right = text.slice(idx + keyword.length, idx + keyword.length + 48);
280
+ return CUE.test(left) || CUE.test(right);
281
+ }
282
+ /** A `recommended`-style preset extend anywhere in the config text — evidence
283
+ * that preset-enabled rules may already be on even when not named literally.
284
+ * Coarse on purpose: presence of a preset downgrades a not-named preset rule to
285
+ * `preset-maybe` (no false "unenforced" alarm) rather than claiming it's off. */
286
+ function extendsRecommended(configText) {
287
+ return /recommended/i.test(configText);
288
+ }
289
+ function buildRuleInventory(instructionText, configText, options = {}) {
290
+ const linters = options.linters;
291
+ const items = [];
292
+ for (const m of exports.INTENT_MAP) {
293
+ if (linters && !linters.includes(m.linter))
294
+ continue;
295
+ const matched = m.keywords.find((kw) => matchesWholeToken(instructionText, kw));
296
+ if (!matched)
297
+ continue;
298
+ const off = ruleSetOff(configText, m.rule);
299
+ const inConfig = ruleInConfig(configText, m.rule);
300
+ // A rule the author documents as deliberately OFF, and whose config agrees
301
+ // (absent, or literally set to off), is consistent — not a gap. Skip it so we
302
+ // never nudge "enable X" against an intentional opt-out, and never flag a
303
+ // documented opt-out as a contradiction.
304
+ if (isDocumentedOptOut(instructionText, matched) && (off || !inConfig)) {
305
+ continue;
306
+ }
307
+ // Precedence: an explicit off/0 is a contradiction even though the rule name
308
+ // is technically "in config" — so it must be checked before `in-config`.
309
+ const configState = off
310
+ ? "contradiction"
311
+ : inConfig
312
+ ? "in-config"
313
+ : m.inRecommended && extendsRecommended(configText)
314
+ ? "preset-maybe"
315
+ : "not-in-config";
316
+ items.push({
317
+ intent: m.intent,
318
+ linter: m.linter,
319
+ matched,
320
+ rule: m.rule,
321
+ configState,
322
+ configFix: m.configFix,
323
+ });
324
+ }
325
+ return items;
326
+ }
327
+ //# sourceMappingURL=rule-inventory.js.map
@@ -0,0 +1,46 @@
1
+ import { type LinterName } from "./rule-inventory.js";
2
+ /** 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";
5
+ /** One segmented, deterministically-routed rule with provenance. */
6
+ export interface RoutedRule {
7
+ /** Normalized atomic rule text (from the segmenter). */
8
+ readonly text: string;
9
+ /** Verbatim source slice — for a UI highlight. */
10
+ readonly quote: string;
11
+ readonly file: string | undefined;
12
+ readonly lineStart: number;
13
+ readonly lineEnd: number;
14
+ /** Segmenter confidence that this IS a rule (3/3 cues → high, 2/3 → medium). */
15
+ readonly confidence: "high" | "medium";
16
+ readonly category: RuleCategory;
17
+ readonly mechanism: RuleMechanism;
18
+ /** reuse only: the off-the-shelf rule that enforces it. */
19
+ readonly rule?: string;
20
+ /** reuse only: the linter that rule belongs to. */
21
+ readonly linter?: LinterName;
22
+ }
23
+ export interface RuleRouting {
24
+ /** How many atomic rules were routed (after the confidence filter). */
25
+ readonly segmented: number;
26
+ readonly counts: Record<RuleCategory, number>;
27
+ readonly rules: readonly RoutedRule[];
28
+ }
29
+ export interface RouteOptions {
30
+ /**
31
+ * Minimum segmenter confidence to route. The segmenter emits `high` (3/3 cues
32
+ * — an imperative rule) and `medium` (2/3). For the audit PREVIEW we default to
33
+ * `high` only: precision over recall. A doc-heavy instruction file (e.g. a
34
+ * keyFiles index of "`path` — description" bullets) trips the medium tier with
35
+ * non-rules, which would bury the real rules and overstate "unrouted". Pass
36
+ * `"medium"` to include both.
37
+ */
38
+ readonly minConfidence?: "high" | "medium";
39
+ }
40
+ /**
41
+ * Segment the instruction file and route every atomic rule deterministically.
42
+ * Pure: the caller passes the concatenated instruction text (and an optional
43
+ * source path for provenance). Returns per-category counts + the routed rules.
44
+ */
45
+ export declare function routeRules(instructionText: string, file?: string, options?: RouteOptions): RuleRouting;
46
+ //# sourceMappingURL=rule-routing.d.ts.map
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.routeRules = routeRules;
4
+ /**
5
+ * rule-routing.ts — the deterministic (no-model) State-B routing PREVIEW.
6
+ *
7
+ * `rule-inventory.ts` answers a narrow question ("which prose lines name an
8
+ * off-the-shelf lint rule, and is it enabled?"). This goes one honest step
9
+ * further: it SEGMENTS the whole instruction file into atomic rules
10
+ * ({@link segmentInstructions}) and routes each one into the class that a real
11
+ * enforcement path would take — WITHOUT running a model:
12
+ *
13
+ * reuse → the rule text names an off-the-shelf lint rule ({@link INTENT_MAP})
14
+ * → mechanism: flip one config line.
15
+ * hook → an ACTION rule a linter can't see (git push, rm -rf, "before you
16
+ * commit") → mechanism: a pre-commit / PreToolUse hook.
17
+ * semantic → a judgment call ("readable", "single responsibility") no checker
18
+ * 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).
21
+ *
22
+ * HONESTY BY CONSTRUCTION: the deterministic tier NEVER claims a rule is
23
+ * "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.
26
+ *
27
+ * Pure, deterministic, dependency-free. Reuses `rule-inventory`'s hardened
28
+ * whole-token matcher + `INTENT_MAP`, and `segment`'s Tier-A segmenter.
29
+ */
30
+ const segment_js_1 = require("./segment.js");
31
+ const rule_inventory_js_1 = require("./rule-inventory.js");
32
+ /** The mechanism each category maps to — a fixed, honest ladder. */
33
+ const MECHANISM = {
34
+ reuse: "config-line",
35
+ hook: "hook",
36
+ semantic: "prose",
37
+ unrouted: "compile",
38
+ };
39
+ /**
40
+ * 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.
44
+ */
45
+ const HOOK_CUES = [
46
+ /\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,
50
+ /\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,
55
+ /\brun\b[^.\n]{0,20}\btests?\b[^.\n]{0,14}\bbefore\b/i,
56
+ /\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,
60
+ /\brm\s+-rf\b/i,
61
+ /\bcurl\b.*\|\s*(sh|bash)/i,
62
+ /\bchmod\b/i,
63
+ ];
64
+ /**
65
+ * Judgment / no-checker cues — a rule no linter can honestly decide, so it stays
66
+ * labeled prose. Ported from the compiler's classifier (the static markers only —
67
+ * no ruleMap dependency, to keep this file model-free and dep-free).
68
+ */
69
+ const SEMANTIC_CUES = [
70
+ /\bself[- ]?documenting\b/i,
71
+ /\bclear(er)?\s+(names?|code|over clever)/i,
72
+ /\breadable\b/i,
73
+ /\bkeep it simple\b/i,
74
+ /\bover[- ]?engineer/i,
75
+ /\bsingle responsibility\b/i,
76
+ /\bcomposition over inheritance\b/i,
77
+ /\bmeaningful\b/i,
78
+ /\bidiomatic\b/i,
79
+ /\bwhere (it )?makes sense\b/i,
80
+ /\bappropriate(ly)?\b/i,
81
+ /\bsolid\s+principles?\b/i,
82
+ /\bbest practices?\b/i,
83
+ /\bclean code\b/i,
84
+ ];
85
+ /**
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.
89
+ */
90
+ function classify(text) {
91
+ if (HOOK_CUES.some((re) => re.test(text)))
92
+ return { category: "hook" };
93
+ for (const m of rule_inventory_js_1.INTENT_MAP) {
94
+ if (m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(text, kw))) {
95
+ return { category: "reuse", rule: m.rule, linter: m.linter };
96
+ }
97
+ }
98
+ if (SEMANTIC_CUES.some((re) => re.test(text)))
99
+ return { category: "semantic" };
100
+ return { category: "unrouted" };
101
+ }
102
+ /**
103
+ * Segment the instruction file and route every atomic rule deterministically.
104
+ * Pure: the caller passes the concatenated instruction text (and an optional
105
+ * source path for provenance). Returns per-category counts + the routed rules.
106
+ */
107
+ function routeRules(instructionText, file, options = {}) {
108
+ 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);
112
+ return {
113
+ text: s.text,
114
+ quote: s.exactQuote,
115
+ file: s.file,
116
+ lineStart: s.lineStart,
117
+ lineEnd: s.lineEnd,
118
+ confidence: s.confidence,
119
+ category: c.category,
120
+ mechanism: MECHANISM[c.category],
121
+ ...(c.rule ? { rule: c.rule } : {}),
122
+ ...(c.linter ? { linter: c.linter } : {}),
123
+ };
124
+ });
125
+ const counts = {
126
+ reuse: 0,
127
+ hook: 0,
128
+ semantic: 0,
129
+ unrouted: 0,
130
+ };
131
+ for (const r of rules)
132
+ counts[r.category]++;
133
+ return { segmented: segments.length, counts, rules };
134
+ }
135
+ //# sourceMappingURL=rule-routing.js.map
@@ -210,7 +210,7 @@ function safetySection(input, sideEffecting) {
210
210
  // --- Safety (deterministic) — generated from ${input.name}'s side-effecting tools: ${sideEffecting.join(", ")} ---
211
211
  // In a real run, replace this constructed Trace with a real \`runHarness\` /
212
212
  // \`measure\` turn (use interceptTools so a real model's attempt is DENIED, never
213
- // executed — see docs/eval-architecture.md). The checks below are derived from the
213
+ // executed — see research/eval-architecture.md). The checks below are derived from the
214
214
  // declared tools contract — the agent's "hole" asserted to stay in its lane.
215
215
  {
216
216
  const trace = {
package/dist/scan.d.ts CHANGED
@@ -301,7 +301,10 @@ export declare function isManagedHookCommand(command: string): boolean;
301
301
  /** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
302
302
  export declare function preferCompiledHooksMessage(count: number): string;
303
303
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
304
- export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect): ScanReport;
304
+ export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect, opts?: {
305
+ sharedDirs?: readonly string[];
306
+ sharedDirsRoot?: string;
307
+ }): ScanReport;
305
308
  /**
306
309
  * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
307
310
  * linter can do: it STARTS each declared MCP server and checks every
package/dist/scan.js CHANGED
@@ -151,11 +151,18 @@ function onDiskPath(materializedKey, materializeRoot) {
151
151
  : materializedKey;
152
152
  }
153
153
  function scanSkills(files, cls, ctx) {
154
- const { root, materializeRoot, dialect } = ctx;
154
+ const { root, materializeRoot, dialect, sharedDirs } = ctx;
155
+ // `sharedDirs` are declared relative to the REPO root (config location), which
156
+ // is `root` for a whole-repo scan but a PARENT when the scan is scoped to a
157
+ // subdir. Resolve them against that, not the scoped subdir.
158
+ const sharedDirsRoot = ctx.sharedDirsRoot ?? root;
155
159
  const out = [];
156
160
  for (const [path, md] of Object.entries(files)) {
157
161
  if (!cls.isSkill(path))
158
162
  continue;
163
+ // Prefer the real on-disk dir (a `.claude/skills/…` skill materializes under
164
+ // the same canonical key as a repo-root one, but lives elsewhere on disk).
165
+ const onDiskDir = ctx.sources?.[path];
159
166
  const fm = frontmatter(md);
160
167
  // A skill's trigger surface is its frontmatter `description` OR — when that's
161
168
  // absent — Claude Code's fallback to the first body paragraph. Only when
@@ -166,8 +173,18 @@ function scanSkills(files, cls, ctx) {
166
173
  // Bundled-resource refs resolve against the skill's OWN dir (resources ship
167
174
  // beside the SKILL.md), built from the plugin root + the file's ON-DISK dir
168
175
  // (the materialize-root prefix the loader added is stripped back off).
169
- const skillDir = (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
170
- const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir);
176
+ const skillDir = onDiskDir
177
+ ? (0, node_path_1.dirname)(onDiskDir)
178
+ : (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
179
+ // Bundled refs resolve against the skill's OWN dir. A repo that shares a
180
+ // top-level tree across skills (`sharedDirs` in .vigilesrc.json) ALSO resolves
181
+ // a ref under one of those declared dirs against the repo root — OPT-IN, so a
182
+ // repo that doesn't set it is byte-identical to before (no masking of a real
183
+ // missing bundled resource). See feedback P1-4.
184
+ const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir, {
185
+ repoRoot: sharedDirsRoot,
186
+ sharedDirs,
187
+ });
171
188
  // The lethal trifecta is a property of what a unit CAN do, which for a skill is
172
189
  // its declared `allowed-tools` (the CC skill tool contract). Only a model-
173
190
  // invocable skill can be hijacked by attacker content, so a user-invoked one is
@@ -702,7 +719,7 @@ function summarizePurity(agents) {
702
719
  }, { pure: 0, bounded: 0, unrestricted: 0 });
703
720
  }
704
721
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
705
- function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
722
+ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts = {}) {
706
723
  const lay = layout ?? layout_js_1.claudeCodeLayout;
707
724
  const cls = makeClassifier(lay);
708
725
  const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
@@ -734,6 +751,9 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
734
751
  root: (0, node_path_1.resolve)(dir),
735
752
  materializeRoot: lay.materializeRoot,
736
753
  dialect,
754
+ sources: loaded.sources,
755
+ sharedDirs: opts.sharedDirs,
756
+ sharedDirsRoot: opts.sharedDirsRoot,
737
757
  });
738
758
  const puritySummary = summarizePurity(agents);
739
759
  const { trifectaFindings, skillResourceFindings, skillFenceFindings } = collectSurfaceFindings(agents, skills);
@@ -958,7 +978,18 @@ function formatScanReport(r) {
958
978
  out.push(...section("MCP hook targets", r.mcpHookIssues.map((i) => ` ✗ ${i.message}`)));
959
979
  out.push(...section("Description overlap (precision risk)", r.descriptionOverlaps.map((o) => ` ⚠ ${o.message}`)));
960
980
  out.push(...section("Description budget (trigger-signal risk)", r.descriptionBudgetIssues.map((o) => ` ⚠ ${o.message}`)));
961
- out.push(...section("Lethal trifecta (prompt-injection exfil risk)", r.trifectaFindings.map((t) => ` ${t.finding.severity === "hard" ? "✗" : "⚠"} ${t.kind} ${t.name} (${t.path}): ${t.finding.message}`)));
981
+ out.push(...section("Lethal trifecta (prompt-injection exfil risk)",
982
+ // The section header already carries the count. HARD findings name their
983
+ // specific legs (keep the message). ADVISORY (inherits-all) findings all
984
+ // carry the SAME boilerplate paragraph — at bulk that's a wall of identical
985
+ // text, so collapse each to a one-liner (feedback P2-6). Gate on "no NEW
986
+ // trifecta" with `vigiles lint` (the lethal-trifecta rule), not by eyeballing.
987
+ r.trifectaFindings.map((t) => {
988
+ const mark = t.finding.severity === "hard" ? "✗" : "⚠";
989
+ return t.finding.severity === "hard"
990
+ ? ` ${mark} ${t.kind} ${t.name} (${t.path}): ${t.finding.message}`
991
+ : ` ${mark} ${t.kind} ${t.name} (${t.path}) — inherits-all contract holds all three legs (declare a tools list dropping one)`;
992
+ })));
962
993
  out.push(...section("Skill bundled resources", r.skillResourceIssues.map((s) => ` ✗ ${s.name}: ${s.finding.ref} (line ${String(s.finding.line)}) — bundled resource not found`)));
963
994
  out.push(...section("Invisible skills (missing frontmatter fence)", r.skillFenceIssues.map((s) => ` ✗ ${s.name} (${s.path}): opens with \`${s.finding.key}:\` but no \`---\` fence — loads as body, never fires`)));
964
995
  out.push(...section("Misplaced plugin directories", r.pluginLayoutIssues.map((p) => ` ✗ ${p.message}`)));
@@ -0,0 +1,33 @@
1
+ /**
2
+ * segment.ts — Tier-A deterministic (no-model) segmenter.
3
+ *
4
+ * Splits a CLAUDE.md / AGENTS.md into ATOMIC candidate rules with provenance.
5
+ * Pure, deterministic, strict TS, no external deps.
6
+ *
7
+ * Design bias: PRECISION over recall. A missed rule costs a row; a garbage
8
+ * atom costs credibility. When in doubt we UNDER-split and REJECT.
9
+ */
10
+ /** A single atomic candidate rule extracted from an instructions file. */
11
+ export interface SegmentedRule {
12
+ /** Normalized rule text (bullet marker stripped, continuation joined, whitespace collapsed). */
13
+ text: string;
14
+ /** Source file path (as supplied by the caller), or undefined. */
15
+ file: string | undefined;
16
+ /** 1-based inclusive start line in the source. */
17
+ lineStart: number;
18
+ /** 1-based inclusive end line in the source. */
19
+ lineEnd: number;
20
+ /** Verbatim slice of the source spanning [lineStart..lineEnd] — for UI highlight. */
21
+ exactQuote: string;
22
+ /** 3/3 cues => "high"; 2/3 => "medium". (Rejected candidates are never emitted.) */
23
+ confidence: "high" | "medium";
24
+ }
25
+ /**
26
+ * Split a CLAUDE.md / AGENTS.md into atomic candidate rules with provenance.
27
+ *
28
+ * Deterministic Tier-A heuristic. Code fences and tables are excluded from
29
+ * candidacy. Candidate units are (a) list items with attached continuation
30
+ * lines and (b) sentences of paragraphs under a rule-ish heading.
31
+ */
32
+ export declare function segmentInstructions(markdown: string, file?: string): SegmentedRule[];
33
+ //# sourceMappingURL=segment.d.ts.map