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.
- package/README.md +43 -15
- package/dist/adapters/claude-code/layout.js +4 -0
- package/dist/audit-report.d.ts +38 -0
- package/dist/audit-report.js +13 -2
- package/dist/audit-report.template.html +44 -29
- package/dist/audit-score.js +9 -1
- package/dist/audit-verdict.d.ts +89 -0
- package/dist/audit-verdict.js +281 -0
- package/dist/cli.js +235 -66
- package/dist/core/layout.d.ts +11 -0
- package/dist/core/skill-resources.d.ts +16 -0
- package/dist/core/skill-resources.js +32 -4
- package/dist/core/types.d.ts +12 -0
- package/dist/eval-cost.d.ts +1 -1
- package/dist/eval.d.ts +2 -2
- package/dist/eval.js +1 -1
- package/dist/leaderboard.js +9 -3
- package/dist/plugin-loader.d.ts +9 -0
- package/dist/plugin-loader.js +93 -16
- package/dist/rule-inventory.d.ts +90 -0
- package/dist/rule-inventory.js +327 -0
- package/dist/rule-routing.d.ts +46 -0
- package/dist/rule-routing.js +135 -0
- package/dist/scaffold-test.js +1 -1
- package/dist/scan.d.ts +4 -1
- package/dist/scan.js +36 -5
- package/dist/segment.d.ts +33 -0
- package/dist/segment.js +454 -0
- package/dist/test-coverage.js +29 -3
- package/package.json +1 -1
|
@@ -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
|
package/dist/scaffold-test.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
170
|
-
|
|
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)",
|
|
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
|