vigiles 37.0.0 → 38.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.js +71 -15
- package/dist/core/compile.d.ts +36 -6
- package/dist/core/compile.js +181 -49
- package/dist/core/config-schema.d.ts +9 -0
- package/dist/core/config-schema.js +15 -0
- package/dist/core/markdown.d.ts +17 -0
- package/dist/core/markdown.js +57 -0
- package/dist/core/presets.d.ts +1 -1
- package/dist/core/presets.js +1 -0
- package/dist/core/rule-meta.js +12 -0
- package/dist/core/spec.d.ts +45 -2
- package/dist/core/spec.js +16 -2
- package/dist/core/types.d.ts +26 -0
- package/dist/vigilesrc.schema.json +94 -0
- package/package.json +1 -1
package/dist/cli-main.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ interface LintReport {
|
|
|
24
24
|
frontmatterRules: number;
|
|
25
25
|
specRefIssues: number;
|
|
26
26
|
specRefErrors: number;
|
|
27
|
+
/** `compile-budget` findings at "error" (reported by `lint` only then). */
|
|
28
|
+
compileBudgetErrors: number;
|
|
27
29
|
duplicatePairs: number;
|
|
28
30
|
/** Severity of `duplicate-instructions` — carried so the exit code can tier it. */
|
|
29
31
|
duplicateSeverity: RuleSeverity;
|
package/dist/cli-main.js
CHANGED
|
@@ -353,6 +353,7 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
|
|
|
353
353
|
maxSectionLines: config.maxSectionLines,
|
|
354
354
|
catalogOnly: config.catalogOnly,
|
|
355
355
|
linters: config.linters,
|
|
356
|
+
...compileBudgetOptions(config),
|
|
356
357
|
});
|
|
357
358
|
const primaryOutput = specPath.replace(/\.spec\.ts$/, "");
|
|
358
359
|
// Budget findings print BEFORE the pass/fail line and never change the exit
|
|
@@ -992,7 +993,8 @@ function lintExitCode(report) {
|
|
|
992
993
|
// error — it used to sit at exit 1 because it fired unasked and could not be
|
|
993
994
|
// turned off.
|
|
994
995
|
report.docRefErrors > 0 ||
|
|
995
|
-
report.specRefErrors > 0
|
|
996
|
+
report.specRefErrors > 0 ||
|
|
997
|
+
report.compileBudgetErrors > 0)
|
|
996
998
|
return 2;
|
|
997
999
|
// Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
|
|
998
1000
|
// change the exit code. Both used to feed the exit directly, which is what
|
|
@@ -1363,9 +1365,13 @@ function overBundles(fn, run, bundles) {
|
|
|
1363
1365
|
*/
|
|
1364
1366
|
async function checkSpecRefs(excludes, config, silent, dialect) {
|
|
1365
1367
|
const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1368
|
+
// `compile-budget` is reported here only at "error": at its default it is a
|
|
1369
|
+
// compile-time warning, and `lint` printed nothing about size before the rule
|
|
1370
|
+
// had a name — the default output does not change.
|
|
1371
|
+
const budgetGate = (0, types_js_1.ruleSeverity)(config?.rules?.["compile-budget"]) === "error";
|
|
1372
|
+
if (!sev && !budgetGate)
|
|
1373
|
+
return { issues: 0, errors: 0, budgetErrors: 0 };
|
|
1374
|
+
const derived = [];
|
|
1369
1375
|
let knownAgents;
|
|
1370
1376
|
for (const specPath of findSpecs(excludes)) {
|
|
1371
1377
|
const target = specPath.replace(/\.spec\.ts$/, "");
|
|
@@ -1379,9 +1385,11 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
|
|
|
1379
1385
|
// collected lazily so a repo with no railway spec never pays for the walk.
|
|
1380
1386
|
if (spec._specType === "railway")
|
|
1381
1387
|
knownAgents ??= await collectAgentNames(excludes);
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1388
|
+
derived.push({
|
|
1389
|
+
target,
|
|
1390
|
+
specPath,
|
|
1391
|
+
findings: await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []),
|
|
1392
|
+
});
|
|
1385
1393
|
}
|
|
1386
1394
|
catch {
|
|
1387
1395
|
// A spec that will not load is `compile`'s finding, not this one's —
|
|
@@ -1389,6 +1397,9 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
|
|
|
1389
1397
|
continue;
|
|
1390
1398
|
}
|
|
1391
1399
|
}
|
|
1400
|
+
const messages = (pick) => derived.flatMap((d) => pick(d.findings).map((e) => `${d.target}: ${e.message} (from ${d.specPath})`));
|
|
1401
|
+
const found = sev ? messages((f) => f.errors) : [];
|
|
1402
|
+
const overBudget = messages((f) => f.budget);
|
|
1392
1403
|
if (found.length > 0 && !silent) {
|
|
1393
1404
|
console.log("\nSpec reference check:\n");
|
|
1394
1405
|
for (const msg of found) {
|
|
@@ -1396,7 +1407,18 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
|
|
|
1396
1407
|
ghAnnotate(sev === "error" ? "error" : "warning", msg);
|
|
1397
1408
|
}
|
|
1398
1409
|
}
|
|
1399
|
-
|
|
1410
|
+
if (overBudget.length > 0 && !silent) {
|
|
1411
|
+
console.log("\nCompile budget check (compile-budget):\n");
|
|
1412
|
+
overBudget.forEach((msg) => {
|
|
1413
|
+
console.log(` ✗ ${msg}`);
|
|
1414
|
+
ghAnnotate("error", `compile-budget: ${msg}`);
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
issues: found.length,
|
|
1419
|
+
errors: sev === "error" ? found.length : 0,
|
|
1420
|
+
budgetErrors: overBudget.length,
|
|
1421
|
+
};
|
|
1400
1422
|
}
|
|
1401
1423
|
/**
|
|
1402
1424
|
* The ONE spec-type dispatcher, in the only shape both directions can share:
|
|
@@ -1420,8 +1442,8 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
|
|
|
1420
1442
|
async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
|
|
1421
1443
|
const basePath = process.cwd();
|
|
1422
1444
|
switch (spec._specType) {
|
|
1423
|
-
case "claude":
|
|
1424
|
-
|
|
1445
|
+
case "claude": {
|
|
1446
|
+
const r = await (0, compile_js_1.compileClaude)(spec, {
|
|
1425
1447
|
basePath,
|
|
1426
1448
|
specFile: specPath,
|
|
1427
1449
|
dialect,
|
|
@@ -1430,19 +1452,52 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
|
|
|
1430
1452
|
maxSectionLines: config?.maxSectionLines,
|
|
1431
1453
|
catalogOnly: config?.catalogOnly,
|
|
1432
1454
|
linters: config?.linters,
|
|
1433
|
-
|
|
1455
|
+
...compileBudgetOptions(config),
|
|
1456
|
+
});
|
|
1457
|
+
// A size finding is `compile-budget`'s, not a dead reference: split by
|
|
1458
|
+
// IDENTITY against the compiler's own list, never by the type's name.
|
|
1459
|
+
return {
|
|
1460
|
+
errors: r.errors.filter((e) => !r.budgetFindings.includes(e)),
|
|
1461
|
+
budget: r.errors.filter((e) => r.budgetFindings.includes(e)),
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1434
1464
|
case "skill":
|
|
1435
|
-
return
|
|
1465
|
+
return {
|
|
1466
|
+
errors: (await (0, compile_js_1.compileSkill)(spec, { basePath, specFile: specPath, dialect })).errors,
|
|
1467
|
+
budget: [],
|
|
1468
|
+
};
|
|
1436
1469
|
case "agent":
|
|
1437
|
-
return
|
|
1470
|
+
return {
|
|
1471
|
+
errors: (await (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })).errors,
|
|
1472
|
+
budget: [],
|
|
1473
|
+
};
|
|
1438
1474
|
case "railway":
|
|
1439
|
-
return
|
|
1475
|
+
return {
|
|
1476
|
+
errors: (0, compile_js_1.compileRailway)(spec, { specFile: specPath, knownAgents })
|
|
1477
|
+
.errors,
|
|
1478
|
+
budget: [],
|
|
1479
|
+
};
|
|
1440
1480
|
default:
|
|
1441
1481
|
// A `pipeline` spec compiles through its underlying railway, so it has no
|
|
1442
1482
|
// artifact of its own to re-derive refs for.
|
|
1443
|
-
return [];
|
|
1483
|
+
return { errors: [], budget: [] };
|
|
1444
1484
|
}
|
|
1445
1485
|
}
|
|
1486
|
+
/**
|
|
1487
|
+
* The `compile-budget` rule, as `compileClaude` options: its severity and any
|
|
1488
|
+
* thresholds the config set. The ONE translation, so `compile` (which writes)
|
|
1489
|
+
* and `lint` (which re-derives) can never measure against different numbers.
|
|
1490
|
+
*/
|
|
1491
|
+
function compileBudgetOptions(config) {
|
|
1492
|
+
const rule = config?.rules?.["compile-budget"];
|
|
1493
|
+
// No config at all (a programmatic caller, a missing file) is the default.
|
|
1494
|
+
if (rule === undefined)
|
|
1495
|
+
return { budgetSeverity: "warn" };
|
|
1496
|
+
return {
|
|
1497
|
+
budgetSeverity: (0, types_js_1.ruleSeverity)(rule),
|
|
1498
|
+
...(0, types_js_1.ruleOptions)(rule),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1446
1501
|
/**
|
|
1447
1502
|
* Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
|
|
1448
1503
|
* that failed to compile cannot reach the disk — there is no stamp to pass.
|
|
@@ -1775,6 +1830,7 @@ async function runLint(restArgs, flags, excludes, config) {
|
|
|
1775
1830
|
frontmatterRules,
|
|
1776
1831
|
specRefIssues: specRefs.issues,
|
|
1777
1832
|
specRefErrors: specRefs.errors,
|
|
1833
|
+
compileBudgetErrors: specRefs.budgetErrors,
|
|
1778
1834
|
duplicatePairs: dups.pairCount,
|
|
1779
1835
|
duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
|
|
1780
1836
|
coverageEnabled: coverage.enabled,
|
package/dist/core/compile.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export declare function verifyHash(content: string): {
|
|
|
56
56
|
*/
|
|
57
57
|
/** @internal */ export declare function estimateTokens(text: string): number;
|
|
58
58
|
export interface CompileError {
|
|
59
|
-
type: "stale-file" | "stale-command" | "stale-ref" | "invalid-rule" | "budget-exceeded" | "section-too-long" | "section-has-header" | "reserved-section-key" | "spec-name-mismatch" | "unknown-tool" | "invalid-railway" | "purity-violation" | "output-without-fork" | "effect-in-skill" | "inline-code-too-long" | "entry-too-long" | "section-too-large";
|
|
59
|
+
type: "stale-file" | "stale-command" | "stale-ref" | "invalid-rule" | "budget-exceeded" | "section-too-long" | "section-has-header" | "reserved-section-key" | "spec-name-mismatch" | "unknown-tool" | "invalid-railway" | "purity-violation" | "output-without-fork" | "effect-in-skill" | "inline-code-too-long" | "entry-too-long" | "section-too-large" | "rules-too-large";
|
|
60
60
|
message: string;
|
|
61
61
|
path?: string;
|
|
62
62
|
}
|
|
@@ -70,12 +70,21 @@ export interface CompileClaudeResult {
|
|
|
70
70
|
markdown: string;
|
|
71
71
|
errors: CompileError[];
|
|
72
72
|
/**
|
|
73
|
-
* Budget findings
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* channel discipline as
|
|
73
|
+
* Budget findings at the rule's DEFAULT severity (`compile-budget: "warn"`).
|
|
74
|
+
* An over-long `keyFiles` description or a bloated section does not break
|
|
75
|
+
* the harness, it makes every request more expensive, so by default it must
|
|
76
|
+
* not stop a faithful adoption from compiling. Same channel discipline as
|
|
77
|
+
* `checkInlineCode`. Empty when the rule is at `"error"` (the findings are in
|
|
78
|
+
* `errors` then) or off.
|
|
77
79
|
*/
|
|
78
80
|
warnings: CompileError[];
|
|
81
|
+
/**
|
|
82
|
+
* Every budget finding, whatever the severity — the SAME objects that were
|
|
83
|
+
* placed in `warnings` or `errors`. `lint` re-derives a spec's references
|
|
84
|
+
* through this compiler and must not report a size finding as a dead
|
|
85
|
+
* reference, so it tells the two apart by identity here, never by type name.
|
|
86
|
+
*/
|
|
87
|
+
budgetFindings: CompileError[];
|
|
79
88
|
linterResults: LinterCheckResult[];
|
|
80
89
|
/** Estimated token count of compiled output (~4 chars/token). */
|
|
81
90
|
tokens: number;
|
|
@@ -83,10 +92,21 @@ export interface CompileClaudeResult {
|
|
|
83
92
|
targets: string[];
|
|
84
93
|
}
|
|
85
94
|
export interface CompileClaudeOptions {
|
|
86
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* The `compile-budget` rule's severity. `"warn"` (the default) reports the
|
|
97
|
+
* per-entry and per-section budgets as warnings and evaluates the Rules total
|
|
98
|
+
* only when a threshold for it is passed; `"error"` (what
|
|
99
|
+
* `vigiles:recommended` sets) evaluates every budget, with defaults, as an
|
|
100
|
+
* error; `false` reports none (an explicit `maxTokens` still fails, as it
|
|
101
|
+
* always has — a separate check).
|
|
102
|
+
*/
|
|
103
|
+
budgetSeverity?: "warn" | "error" | false;
|
|
104
|
+
/** Per-entry `commands` character budget (0 disables). */
|
|
87
105
|
maxEntryChars?: number;
|
|
88
106
|
/** Per-section character budget, beside the line budget (0 disables). */
|
|
89
107
|
maxSectionChars?: number;
|
|
108
|
+
/** Character budget for the whole rendered Rules section (0 disables). */
|
|
109
|
+
maxRulesChars?: number;
|
|
90
110
|
basePath?: string;
|
|
91
111
|
specFile?: string;
|
|
92
112
|
/** Injected harness dialect; its instructionTargets[0] is the default target. */
|
|
@@ -136,6 +156,16 @@ export declare const DEFAULT_MAX_ENTRY_CHARS = 200;
|
|
|
136
156
|
* cost — characters do, because that is what the harness loads.
|
|
137
157
|
*/
|
|
138
158
|
export declare const DEFAULT_MAX_SECTION_CHARS = 15000;
|
|
159
|
+
/**
|
|
160
|
+
* The Rules section, as one budget. Under `compile-budget: "error"` only.
|
|
161
|
+
*
|
|
162
|
+
* WHY THE SECTION AND NOT EACH RULE. A per-rule length cap was built and
|
|
163
|
+
* removed within an hour: it opened on 40 of 53 rules, because a rule's length
|
|
164
|
+
* is not where the cost is — the SUM is, and it is loaded on every request.
|
|
165
|
+
* 20,000 characters is half of Claude Code's 40,000-character instruction
|
|
166
|
+
* budget, which leaves the other half for everything else the file carries.
|
|
167
|
+
*/
|
|
168
|
+
export declare const DEFAULT_MAX_RULES_CHARS = 20000;
|
|
139
169
|
/**
|
|
140
170
|
* Compile a ClaudeSpec into markdown.
|
|
141
171
|
*
|
package/dist/core/compile.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.DEFAULT_MAX_SECTION_CHARS = exports.DEFAULT_MAX_ENTRY_CHARS = exports.DEFAULT_MAX_SECTION_LINES = void 0;
|
|
6
|
+
exports.DEFAULT_MAX_RULES_CHARS = exports.DEFAULT_MAX_SECTION_CHARS = exports.DEFAULT_MAX_ENTRY_CHARS = exports.DEFAULT_MAX_SECTION_LINES = void 0;
|
|
7
7
|
exports.computeHash = computeHash;
|
|
8
8
|
exports.addHash = addHash;
|
|
9
9
|
exports.seal = seal;
|
|
@@ -299,6 +299,38 @@ function renderFragment(fragment) {
|
|
|
299
299
|
// ---------------------------------------------------------------------------
|
|
300
300
|
// Compile CLAUDE.md spec → markdown
|
|
301
301
|
// ---------------------------------------------------------------------------
|
|
302
|
+
/** `[Details](path#anchor)` for a rule's long text. */
|
|
303
|
+
function detailsLink(details) {
|
|
304
|
+
const anchor = details.anchor === undefined ? "" : `#${details.anchor}`;
|
|
305
|
+
return `[Details](${details.file.path}${anchor})`;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The first sentence of `text`: up to the first `.`, `!` or `?` that closes a
|
|
309
|
+
* word (no space before it) and is followed by the end of the text or by
|
|
310
|
+
* whitespace and a capital, digit, quote, backtick or bracket. So a mark inside
|
|
311
|
+
* the sentence — "instead of ! assertions", "e.g. into helpers" — does not end
|
|
312
|
+
* it. A `why` with no sentence end is returned whole — truncating mid-sentence
|
|
313
|
+
* would render a claim nobody wrote.
|
|
314
|
+
*/
|
|
315
|
+
function firstSentence(text) {
|
|
316
|
+
const m = /^[\s\S]*?\S[.!?](?=$|\s+[A-Z0-9"'`(*[])/.exec(text.trim());
|
|
317
|
+
return m ? m[0] : text.trim();
|
|
318
|
+
}
|
|
319
|
+
/** The `**Why:**` line(s) of an enforce rule under its render policy. */
|
|
320
|
+
function enforceWhy(rule) {
|
|
321
|
+
const link = rule.details ? ` ${detailsLink(rule.details)}` : "";
|
|
322
|
+
const render = rule.render ?? "full";
|
|
323
|
+
switch (render) {
|
|
324
|
+
case "full":
|
|
325
|
+
return [`**Why:** ${rule.why}${link}`];
|
|
326
|
+
case "line":
|
|
327
|
+
return [`**Why:** ${firstSentence(rule.why)}${link}`];
|
|
328
|
+
case "id":
|
|
329
|
+
return rule.details ? [detailsLink(rule.details)] : [];
|
|
330
|
+
default:
|
|
331
|
+
return (0, hash_js_1.assertNever)(render);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
302
334
|
function compileRule(id, rule) {
|
|
303
335
|
const title = id
|
|
304
336
|
.replace(/[-_]/g, " ")
|
|
@@ -309,10 +341,16 @@ function compileRule(id, rule) {
|
|
|
309
341
|
`### ${title}`,
|
|
310
342
|
"",
|
|
311
343
|
`**Enforced by:** \`${rule.linterRule}\``,
|
|
312
|
-
|
|
344
|
+
...enforceWhy(rule),
|
|
313
345
|
].join("\n");
|
|
314
|
-
case "guidance":
|
|
315
|
-
|
|
346
|
+
case "guidance": {
|
|
347
|
+
const link = rule.details ? ` ${detailsLink(rule.details)}` : "";
|
|
348
|
+
return [
|
|
349
|
+
`### ${title}`,
|
|
350
|
+
"",
|
|
351
|
+
`**Guidance only** — ${rule.text}${link}`,
|
|
352
|
+
].join("\n");
|
|
353
|
+
}
|
|
316
354
|
case "guard": {
|
|
317
355
|
const patterns = Array.isArray(rule.watch)
|
|
318
356
|
? rule.watch.join("`, `")
|
|
@@ -364,49 +402,99 @@ exports.DEFAULT_MAX_ENTRY_CHARS = 200;
|
|
|
364
402
|
*/
|
|
365
403
|
exports.DEFAULT_MAX_SECTION_CHARS = 15000;
|
|
366
404
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
405
|
+
* The Rules section, as one budget. Under `compile-budget: "error"` only.
|
|
406
|
+
*
|
|
407
|
+
* WHY THE SECTION AND NOT EACH RULE. A per-rule length cap was built and
|
|
408
|
+
* removed within an hour: it opened on 40 of 53 rules, because a rule's length
|
|
409
|
+
* is not where the cost is — the SUM is, and it is loaded on every request.
|
|
410
|
+
* 20,000 characters is half of Claude Code's 40,000-character instruction
|
|
411
|
+
* budget, which leaves the other half for everything else the file carries.
|
|
371
412
|
*/
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
413
|
+
exports.DEFAULT_MAX_RULES_CHARS = 20000;
|
|
414
|
+
/**
|
|
415
|
+
* The thresholds this compile measures against.
|
|
416
|
+
*
|
|
417
|
+
* The Rules total has no default at `"warn"`: a repo that does not extend
|
|
418
|
+
* `vigiles:recommended` and leaves the rule at its default sees exactly the
|
|
419
|
+
* warnings `compile` printed before the rule had a name. At `"error"` (what the
|
|
420
|
+
* preset sets) every budget applies with its default.
|
|
421
|
+
*
|
|
422
|
+
* 🔴 WHAT THIS RULE DOES NOT MEASURE, ON PURPOSE — each has one owner already:
|
|
423
|
+
* - the whole always-loaded size, in the harness's unit (Claude Code chars,
|
|
424
|
+
* Codex bytes) — `instruction-weight` and its `max`, which sums the whole
|
|
425
|
+
* chain, so moving text into a rules file does not fool it;
|
|
426
|
+
* - `keyFiles` — deprecated (`deprecated-key-files`); budgeting a section the
|
|
427
|
+
* reader is told to delete would be a second, contradicting instruction.
|
|
428
|
+
*/
|
|
429
|
+
function resolveBudgetLimits(options, severity) {
|
|
430
|
+
const strict = severity === "error";
|
|
431
|
+
return {
|
|
432
|
+
entryChars: options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS,
|
|
433
|
+
sectionChars: options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS,
|
|
434
|
+
rulesChars: options.maxRulesChars ?? (strict ? exports.DEFAULT_MAX_RULES_CHARS : 0),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
438
|
+
/**
|
|
439
|
+
* Budget findings for `commands` entries, section size and the Rules total.
|
|
440
|
+
* Severity is applied by the caller: the same finding is a warning at the
|
|
441
|
+
* rule's default and an error under `vigiles:recommended`.
|
|
442
|
+
*/
|
|
443
|
+
function checkContentBudgets(input, specFile, limits) {
|
|
444
|
+
return [
|
|
445
|
+
...entryBudget("commands", input.spec.commands, specFile, limits),
|
|
446
|
+
...sectionBudget(input.spec.sections, specFile, limits),
|
|
447
|
+
...rulesBudget(input, specFile, limits),
|
|
448
|
+
];
|
|
449
|
+
}
|
|
450
|
+
/** One finding per `commands` description over the entry budget. */
|
|
451
|
+
function entryBudget(field, map, specFile, limits) {
|
|
452
|
+
if (!map || limits.entryChars <= 0)
|
|
453
|
+
return [];
|
|
454
|
+
return Object.entries(map)
|
|
455
|
+
.filter(([, description]) => description.length > limits.entryChars)
|
|
456
|
+
.map(([key, description]) => ({
|
|
457
|
+
type: "entry-too-long",
|
|
458
|
+
path: specFile,
|
|
459
|
+
message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
|
|
460
|
+
`characters (budget ${String(limits.entryChars)}). An entry is a pointer — say what the ` +
|
|
461
|
+
`command does in one line and move the explanation into the script's own header or ` +
|
|
462
|
+
`--help, which is read when someone runs it rather than on every request.`,
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
/** One finding per plain-prose section over the section budget. */
|
|
466
|
+
function sectionBudget(sections, specFile, limits) {
|
|
467
|
+
if (!sections || limits.sectionChars <= 0)
|
|
468
|
+
return [];
|
|
469
|
+
// Fragment-valued sections are assembled later; only plain prose is
|
|
470
|
+
// measurable here, and it is the shape that grows.
|
|
471
|
+
return Object.entries(sections)
|
|
472
|
+
.filter((pair) => typeof pair[1] === "string" && pair[1].length > limits.sectionChars)
|
|
473
|
+
.map(([name, body]) => ({
|
|
474
|
+
type: "section-too-large",
|
|
475
|
+
path: specFile,
|
|
476
|
+
message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
|
|
477
|
+
`(budget ${String(limits.sectionChars)}). The line guard cannot see this — long lines are ` +
|
|
478
|
+
`the normal shape of compiled prose — and every character is loaded on every request.`,
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
/** The whole Rules section, as one sum. */
|
|
482
|
+
function rulesBudget(input, specFile, limits) {
|
|
483
|
+
const rulesChars = input.renderedRules.length;
|
|
484
|
+
if (limits.rulesChars <= 0 || rulesChars <= limits.rulesChars)
|
|
485
|
+
return [];
|
|
486
|
+
return [
|
|
487
|
+
{
|
|
488
|
+
type: "rules-too-large",
|
|
489
|
+
path: specFile,
|
|
490
|
+
message: `the Rules section is ${n(rulesChars)} characters for ` +
|
|
491
|
+
`${String(Object.keys(input.spec.rules).length)} rules ` +
|
|
492
|
+
`(budget ${n(limits.rulesChars)}). Render a rule shorter instead of deleting it: ` +
|
|
493
|
+
`enforce(rule, why, { render: "id" }) when a linter already holds it, or ` +
|
|
494
|
+
`guidance(statement, { details: file(...) }) with the long text in that file — ` +
|
|
495
|
+
`the rule stays in the spec, where lint and audit read it.`,
|
|
496
|
+
},
|
|
377
497
|
];
|
|
378
|
-
for (const [field, map] of entries) {
|
|
379
|
-
if (!map || maxEntryChars <= 0)
|
|
380
|
-
continue;
|
|
381
|
-
for (const [key, description] of Object.entries(map)) {
|
|
382
|
-
if (description.length <= maxEntryChars)
|
|
383
|
-
continue;
|
|
384
|
-
warns.push({
|
|
385
|
-
type: "entry-too-long",
|
|
386
|
-
path: specFile,
|
|
387
|
-
message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
|
|
388
|
-
`characters (budget ${String(maxEntryChars)}). An entry is a pointer — say what the ` +
|
|
389
|
-
`file is for in one line and move the explanation into its own header, which is read ` +
|
|
390
|
-
`when someone opens it rather than on every request.`,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (spec.sections && maxSectionChars > 0) {
|
|
395
|
-
for (const [name, body] of Object.entries(spec.sections)) {
|
|
396
|
-
// Fragment-valued sections are assembled later; only plain prose is
|
|
397
|
-
// measurable here, and it is the shape that grows.
|
|
398
|
-
if (typeof body !== "string" || body.length <= maxSectionChars)
|
|
399
|
-
continue;
|
|
400
|
-
warns.push({
|
|
401
|
-
type: "section-too-large",
|
|
402
|
-
path: specFile,
|
|
403
|
-
message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
|
|
404
|
-
`(budget ${String(maxSectionChars)}). The line guard cannot see this — long lines are ` +
|
|
405
|
-
`the normal shape of compiled prose — and every character is loaded on every request.`,
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return warns;
|
|
410
498
|
}
|
|
411
499
|
// A key-files entry is a POINTER, not an essay: the prose about why a file is
|
|
412
500
|
// shaped the way it is belongs in that file's own header, where it is read when
|
|
@@ -524,6 +612,29 @@ function shouldVerifyRule(rule, options) {
|
|
|
524
612
|
return false;
|
|
525
613
|
return true;
|
|
526
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* A rule's `details` must land somewhere: the file exists (the same check as
|
|
617
|
+
* every `file()`), and its anchor, if any, is a heading or an explicit id in
|
|
618
|
+
* that file. A short rule whose long text 404s is the rule deleted, with a
|
|
619
|
+
* link standing where it was.
|
|
620
|
+
*/
|
|
621
|
+
function validateRuleDetails(details, basePath) {
|
|
622
|
+
const missing = validateFileRef(details.file.path, basePath);
|
|
623
|
+
if (missing)
|
|
624
|
+
return [missing];
|
|
625
|
+
if (details.anchor === undefined)
|
|
626
|
+
return [];
|
|
627
|
+
const text = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(basePath, details.file.path), "utf-8");
|
|
628
|
+
if ((0, markdown_js_1.markdownAnchors)(text).has(details.anchor))
|
|
629
|
+
return [];
|
|
630
|
+
return [
|
|
631
|
+
{
|
|
632
|
+
type: "stale-ref",
|
|
633
|
+
message: `Anchor "#${details.anchor}" not found in "${details.file.path}" (no heading slugs to it and no <a id> defines it)`,
|
|
634
|
+
path: `${details.file.path}#${details.anchor}`,
|
|
635
|
+
},
|
|
636
|
+
];
|
|
637
|
+
}
|
|
527
638
|
function compileRulesSection(spec, basePath, options) {
|
|
528
639
|
const ruleEntries = Object.entries(spec.rules);
|
|
529
640
|
if (ruleEntries.length === 0) {
|
|
@@ -564,7 +675,14 @@ function compileRulesSection(spec, basePath, options) {
|
|
|
564
675
|
}
|
|
565
676
|
}
|
|
566
677
|
}
|
|
567
|
-
|
|
678
|
+
const detailsErrors = ruleEntries.flatMap(([, rule]) => rule._kind !== "guard" && rule.details
|
|
679
|
+
? validateRuleDetails(rule.details, basePath)
|
|
680
|
+
: []);
|
|
681
|
+
return {
|
|
682
|
+
lines: [ruleLines.join("\n")],
|
|
683
|
+
errors: [...errors, ...detailsErrors],
|
|
684
|
+
linterResults,
|
|
685
|
+
};
|
|
568
686
|
}
|
|
569
687
|
// ---------------------------------------------------------------------------
|
|
570
688
|
// compileClaude
|
|
@@ -622,7 +740,9 @@ async function compileClaude(spec, options = {}) {
|
|
|
622
740
|
errors.push(...prose.errors, ...keyFiles.errors, ...commands.errors, ...rules.errors);
|
|
623
741
|
const body = sections.join("\n\n") + "\n";
|
|
624
742
|
const tokens = estimateTokens(body);
|
|
625
|
-
// Per-spec maxTokens takes precedence, then compile options
|
|
743
|
+
// Per-spec maxTokens takes precedence, then compile options. An explicit
|
|
744
|
+
// ceiling is an error at any `compile-budget` severity, as it always was:
|
|
745
|
+
// the author typed a number and asked for it to hold.
|
|
626
746
|
const maxTokens = spec.maxTokens ?? options.maxTokens;
|
|
627
747
|
if (maxTokens && tokens > maxTokens) {
|
|
628
748
|
errors.push({
|
|
@@ -632,10 +752,22 @@ async function compileClaude(spec, options = {}) {
|
|
|
632
752
|
}
|
|
633
753
|
const markdown = addHash(body, specFile);
|
|
634
754
|
const allTargets = Array.isArray(targets) ? targets : [targets];
|
|
755
|
+
const severity = options.budgetSeverity ?? "warn";
|
|
756
|
+
const budgetFindings = severity === false
|
|
757
|
+
? []
|
|
758
|
+
: [
|
|
759
|
+
...checkContentBudgets({
|
|
760
|
+
spec,
|
|
761
|
+
renderedRules: rules.lines.join("\n\n"),
|
|
762
|
+
}, specFile, resolveBudgetLimits(options, severity)),
|
|
763
|
+
];
|
|
635
764
|
return {
|
|
636
765
|
markdown,
|
|
637
|
-
|
|
638
|
-
|
|
766
|
+
// The SAME objects as `budgetFindings`, so `lint` can tell them apart by
|
|
767
|
+
// identity.
|
|
768
|
+
errors: severity === "error" ? [...errors, ...budgetFindings] : errors,
|
|
769
|
+
warnings: severity === "warn" ? budgetFindings : [],
|
|
770
|
+
budgetFindings,
|
|
639
771
|
linterResults: rules.linterResults,
|
|
640
772
|
tokens,
|
|
641
773
|
targets: allTargets,
|
|
@@ -130,6 +130,15 @@ export declare const vigilesConfigSchema: z.ZodObject<{
|
|
|
130
130
|
"disallowed-tools-contract": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
131
131
|
"description-overlap": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
132
132
|
"skill-description-budget": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
133
|
+
"compile-budget": z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>, z.ZodTuple<[z.ZodLiteral<"warn">, z.ZodObject<{
|
|
134
|
+
maxEntryChars: z.ZodOptional<z.ZodNumber>;
|
|
135
|
+
maxSectionChars: z.ZodOptional<z.ZodNumber>;
|
|
136
|
+
maxRulesChars: z.ZodOptional<z.ZodNumber>;
|
|
137
|
+
}, z.core.$strict>], null>, z.ZodTuple<[z.ZodLiteral<"error">, z.ZodObject<{
|
|
138
|
+
maxEntryChars: z.ZodOptional<z.ZodNumber>;
|
|
139
|
+
maxSectionChars: z.ZodOptional<z.ZodNumber>;
|
|
140
|
+
maxRulesChars: z.ZodOptional<z.ZodNumber>;
|
|
141
|
+
}, z.core.$strict>], null>]>>;
|
|
133
142
|
"frontmatter-valid": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
134
143
|
"mcp-hook-target-resolves": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
135
144
|
"lethal-trifecta": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
@@ -122,6 +122,20 @@ const testCoverageConfig = zod_1.z
|
|
|
122
122
|
testExtension: zod_1.z.string().optional(),
|
|
123
123
|
})
|
|
124
124
|
.strict();
|
|
125
|
+
/**
|
|
126
|
+
* Thresholds for `compile-budget`, in characters. Each one left out keeps its
|
|
127
|
+
* built-in default; `0` turns that one budget off.
|
|
128
|
+
*/
|
|
129
|
+
const compileBudgetThresholds = zod_1.z
|
|
130
|
+
.object({
|
|
131
|
+
/** One `commands` description. */
|
|
132
|
+
maxEntryChars: zod_1.z.number().optional(),
|
|
133
|
+
/** One named prose section. */
|
|
134
|
+
maxSectionChars: zod_1.z.number().optional(),
|
|
135
|
+
/** The whole rendered Rules section. */
|
|
136
|
+
maxRulesChars: zod_1.z.number().optional(),
|
|
137
|
+
})
|
|
138
|
+
.strict();
|
|
125
139
|
/**
|
|
126
140
|
* Every validation rule, with its shipped default severity.
|
|
127
141
|
*
|
|
@@ -156,6 +170,7 @@ const rulesSchema = zod_1.z
|
|
|
156
170
|
"disallowed-tools-contract": severitySchema.default("warn"),
|
|
157
171
|
"description-overlap": severitySchema.default("warn"),
|
|
158
172
|
"skill-description-budget": severitySchema.default("warn"),
|
|
173
|
+
"compile-budget": withOptions(compileBudgetThresholds).default("warn"),
|
|
159
174
|
"frontmatter-valid": severitySchema.default("warn"),
|
|
160
175
|
"mcp-hook-target-resolves": severitySchema.default("warn"),
|
|
161
176
|
"lethal-trifecta": severitySchema.default("warn"),
|
package/dist/core/markdown.d.ts
CHANGED
|
@@ -118,4 +118,21 @@ export interface MarkdownRef {
|
|
|
118
118
|
* which is not recovered — the regexes this replaces did not recover it either.
|
|
119
119
|
*/
|
|
120
120
|
export declare function markdownRefs(src: string): MarkdownRef[];
|
|
121
|
+
/**
|
|
122
|
+
* The fragment ids a markdown file defines — what `file.md#<id>` can land on.
|
|
123
|
+
*
|
|
124
|
+
* Two sources, because both are how real files are linked into:
|
|
125
|
+
*
|
|
126
|
+
* - every HEADING, slugged the way GitHub slugs it (lowercase; drop everything
|
|
127
|
+
* that is not a letter, number, space, `-` or `_`; spaces to `-`; a repeated
|
|
128
|
+
* slug gets `-1`, `-2`, …), with the heading's text taken from the parser's
|
|
129
|
+
* inline token, so emphasis and code spans do not leak their markers in;
|
|
130
|
+
* - every explicit `id="…"` / `name="…"` on raw HTML (`<a id="why"></a>`), which
|
|
131
|
+
* is how a file in a non-Latin language gives its sections stable ASCII ids.
|
|
132
|
+
*
|
|
133
|
+
* Other renderers slug differently (GitLab, mkdocs). A link that resolves on
|
|
134
|
+
* GitHub and nowhere else is still a link this accepts; one that resolves
|
|
135
|
+
* NOWHERE — a renamed heading — is what it exists to catch.
|
|
136
|
+
*/
|
|
137
|
+
export declare function markdownAnchors(src: string): ReadonlySet<string>;
|
|
121
138
|
//# sourceMappingURL=markdown.d.ts.map
|
package/dist/core/markdown.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.fencedLineFlags = fencedLineFlags;
|
|
|
4
4
|
exports.proseLines = proseLines;
|
|
5
5
|
exports.fencedCodeBlocks = fencedCodeBlocks;
|
|
6
6
|
exports.markdownRefs = markdownRefs;
|
|
7
|
+
exports.markdownAnchors = markdownAnchors;
|
|
7
8
|
const MarkdownIt = () => require("markdown-it");
|
|
8
9
|
// One reusable parser; parse() is stateless across calls.
|
|
9
10
|
let mdCached = null;
|
|
@@ -233,4 +234,60 @@ function refsInInline(children, line, out) {
|
|
|
233
234
|
out.push({ kind: "code", value: child.content, line });
|
|
234
235
|
}
|
|
235
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* The fragment ids a markdown file defines — what `file.md#<id>` can land on.
|
|
239
|
+
*
|
|
240
|
+
* Two sources, because both are how real files are linked into:
|
|
241
|
+
*
|
|
242
|
+
* - every HEADING, slugged the way GitHub slugs it (lowercase; drop everything
|
|
243
|
+
* that is not a letter, number, space, `-` or `_`; spaces to `-`; a repeated
|
|
244
|
+
* slug gets `-1`, `-2`, …), with the heading's text taken from the parser's
|
|
245
|
+
* inline token, so emphasis and code spans do not leak their markers in;
|
|
246
|
+
* - every explicit `id="…"` / `name="…"` on raw HTML (`<a id="why"></a>`), which
|
|
247
|
+
* is how a file in a non-Latin language gives its sections stable ASCII ids.
|
|
248
|
+
*
|
|
249
|
+
* Other renderers slug differently (GitLab, mkdocs). A link that resolves on
|
|
250
|
+
* GitHub and nowhere else is still a link this accepts; one that resolves
|
|
251
|
+
* NOWHERE — a renamed heading — is what it exists to catch.
|
|
252
|
+
*/
|
|
253
|
+
function markdownAnchors(src) {
|
|
254
|
+
// The html-enabled parser: with the shared one, `<a id="x">` is paragraph
|
|
255
|
+
// text and its id would be invisible.
|
|
256
|
+
const tokens = mdProse().parse(src, {});
|
|
257
|
+
const headings = tokens.flatMap((t, i) => t.type === "heading_open" ? [headingSlug(tokens[i + 1])] : []);
|
|
258
|
+
const explicit = tokens
|
|
259
|
+
.flatMap(htmlOf)
|
|
260
|
+
.flatMap((h) => [...h.matchAll(HTML_ID)].map((m) => m[1]));
|
|
261
|
+
return new Set([...numberRepeats(headings), ...explicit]);
|
|
262
|
+
}
|
|
263
|
+
/** An explicit `id="…"` / `name="…"` on raw HTML. */
|
|
264
|
+
const HTML_ID = /\b(?:id|name)\s*=\s*["']([^"']+)["']/g;
|
|
265
|
+
/** A heading's GitHub slug before repeats are numbered, from its inline token. */
|
|
266
|
+
function headingSlug(inline) {
|
|
267
|
+
return (inline?.children ?? [])
|
|
268
|
+
.filter((c) => c.type === "text" || c.type === "code_inline")
|
|
269
|
+
.map((c) => c.content)
|
|
270
|
+
.join("")
|
|
271
|
+
.toLowerCase()
|
|
272
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
|
273
|
+
.trim()
|
|
274
|
+
.replace(/\s/g, "-");
|
|
275
|
+
}
|
|
276
|
+
/** GitHub's numbering of a repeated slug: `x`, `x-1`, `x-2`, … */
|
|
277
|
+
function numberRepeats(bases) {
|
|
278
|
+
return bases.map((base, i) => {
|
|
279
|
+
const k = bases.slice(0, i).filter((b) => b === base).length;
|
|
280
|
+
return k === 0 ? base : `${base}-${String(k)}`;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** The raw HTML a token carries: a block, an inline, or an inline run's HTML. */
|
|
284
|
+
function htmlOf(t) {
|
|
285
|
+
if (t.type === "html_block" || t.type === "html_inline")
|
|
286
|
+
return [t.content];
|
|
287
|
+
if (t.type !== "inline")
|
|
288
|
+
return [];
|
|
289
|
+
return (t.children ?? [])
|
|
290
|
+
.filter((c) => c.type === "html_inline")
|
|
291
|
+
.map((c) => c.content);
|
|
292
|
+
}
|
|
236
293
|
//# sourceMappingURL=markdown.js.map
|
package/dist/core/presets.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { RulesConfig } from "./types.js";
|
|
|
30
30
|
* `untested-*`): a clean repo fails those until the work is done, so they stay
|
|
31
31
|
* an explicit opt-in (`init --strict`).
|
|
32
32
|
*/
|
|
33
|
-
export declare const RECOMMENDED_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap"];
|
|
33
|
+
export declare const RECOMMENDED_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap", "compile-budget"];
|
|
34
34
|
/** The preset names `extends` accepts. One today; the tuple is the enum. */
|
|
35
35
|
export declare const PRESET_NAMES: readonly ["vigiles:recommended"];
|
|
36
36
|
/** A preset name `extends` accepts. */
|
package/dist/core/presets.js
CHANGED
|
@@ -24,6 +24,7 @@ exports.RECOMMENDED_RULES = [
|
|
|
24
24
|
"mcp-hook-target-resolves",
|
|
25
25
|
"disallowed-tools-contract",
|
|
26
26
|
"description-overlap",
|
|
27
|
+
"compile-budget",
|
|
27
28
|
];
|
|
28
29
|
/** The preset names `extends` accepts. One today; the tuple is the enum. */
|
|
29
30
|
exports.PRESET_NAMES = ["vigiles:recommended"];
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -226,6 +226,18 @@ exports.RULE_META = {
|
|
|
226
226
|
summary: "The always-loaded instruction weight did not move off its committed baseline.",
|
|
227
227
|
detector: "compareToBaseline",
|
|
228
228
|
},
|
|
229
|
+
"compile-budget": {
|
|
230
|
+
id: "compile-budget",
|
|
231
|
+
// Exact once a threshold is chosen: a length or a count read off the spec
|
|
232
|
+
// and its rendered output. The threshold is the judgment: the default stays
|
|
233
|
+
// `warn`, and `vigiles:recommended` sets `error`.
|
|
234
|
+
bucket: "structural-closed",
|
|
235
|
+
surface: ["instruction"],
|
|
236
|
+
defaultSeverity: "warn",
|
|
237
|
+
summary: "A compiled instruction file stays within its size budgets (command entries, sections, the Rules total).",
|
|
238
|
+
detector: "checkContentBudgets",
|
|
239
|
+
upstreamPrevention: "compile measures the spec before writing; a render policy (enforce render, guidance details) meets a budget without deleting a rule",
|
|
240
|
+
},
|
|
229
241
|
"skill-description-budget": {
|
|
230
242
|
id: "skill-description-budget",
|
|
231
243
|
bucket: "heuristic-behavioral",
|
package/dist/core/spec.d.ts
CHANGED
|
@@ -94,6 +94,30 @@ export interface OpenToolVocabulary extends ToolVocabulary {
|
|
|
94
94
|
* untyped surface accepts any tools.
|
|
95
95
|
*/
|
|
96
96
|
export type AllowedAt<P extends AuthoredPurity | undefined, V extends ToolVocabulary> = P extends "pure" ? V["readOnly"] : P extends "bounded" ? V["bounded"] : string;
|
|
97
|
+
/**
|
|
98
|
+
* Where a rule's long text lives when the compiled file carries a short form —
|
|
99
|
+
* a `file()` reference (verified to exist, like every other one) and an
|
|
100
|
+
* optional anchor inside it (verified to be a heading or an explicit
|
|
101
|
+
* `<a id="…">` in that file). Rendered as a `[Details](path#anchor)` link.
|
|
102
|
+
*/
|
|
103
|
+
export interface RuleDetails {
|
|
104
|
+
readonly file: FileRef;
|
|
105
|
+
readonly anchor?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* How much of an `enforce()` rule the compiled file carries (#355).
|
|
109
|
+
*
|
|
110
|
+
* - `"full"` (default) — the rule id and the whole `why`, as always.
|
|
111
|
+
* - `"line"` — the rule id and the first sentence of `why`.
|
|
112
|
+
* - `"id"` — the rule id only: the linter already holds the rule, so the prose
|
|
113
|
+
* is a second copy that costs tokens on every request and can drift.
|
|
114
|
+
*
|
|
115
|
+
* Whatever is rendered, the rule stays whole in the SPEC — lint, coverage, the
|
|
116
|
+
* audit rule map and `enforce-rules-format` read the spec, not the markdown —
|
|
117
|
+
* and the integrity hash is taken over the RENDERED file, so it stays a
|
|
118
|
+
* function of what the model actually sees.
|
|
119
|
+
*/
|
|
120
|
+
export type RuleRender = "full" | "line" | "id";
|
|
97
121
|
/** A rule delegated to an external tool (linter, ast-grep, dependency-cruiser, etc.) or to a vigiles-internal check. */
|
|
98
122
|
export interface EnforceRule {
|
|
99
123
|
readonly _kind: "enforce";
|
|
@@ -101,12 +125,29 @@ export interface EnforceRule {
|
|
|
101
125
|
readonly why: string;
|
|
102
126
|
/** Skip verification for this rule. Default: true (verify). */
|
|
103
127
|
readonly verify: boolean;
|
|
128
|
+
/** How much of the rule the compiled file carries. Default `"full"`. */
|
|
129
|
+
readonly render?: RuleRender;
|
|
130
|
+
/** Where the long text lives; rendered as a verified link. */
|
|
131
|
+
readonly details?: RuleDetails;
|
|
104
132
|
}
|
|
105
133
|
/** A guidance-only rule (prose, no enforcement). */
|
|
106
134
|
export interface GuidanceRule {
|
|
107
135
|
readonly _kind: "guidance";
|
|
108
136
|
readonly text: string;
|
|
137
|
+
/** Where the long text lives; rendered as a verified link after `text`. */
|
|
138
|
+
readonly details?: RuleDetails;
|
|
109
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The `details` half of a rule's options. An `anchor` is only meaningful
|
|
142
|
+
* inside a `details` file, so the type refuses one without the other.
|
|
143
|
+
*/
|
|
144
|
+
export type RuleDetailsOptions = {
|
|
145
|
+
readonly details: FileRef;
|
|
146
|
+
readonly anchor?: string;
|
|
147
|
+
} | {
|
|
148
|
+
readonly details?: undefined;
|
|
149
|
+
readonly anchor?: never;
|
|
150
|
+
};
|
|
110
151
|
/** A reactive rule: runs a command when watched files change. */
|
|
111
152
|
export interface GuardRule {
|
|
112
153
|
readonly _kind: "guard";
|
|
@@ -130,13 +171,15 @@ export type Rule = EnforceRule | GuidanceRule | GuardRule;
|
|
|
130
171
|
*/
|
|
131
172
|
export declare function enforce(ref: NoInfer<StrictLinterRule> | VigilesRef, why: string, options?: {
|
|
132
173
|
verify?: boolean;
|
|
133
|
-
|
|
174
|
+
render?: RuleRender;
|
|
175
|
+
} & RuleDetailsOptions): EnforceRule;
|
|
134
176
|
/**
|
|
135
177
|
* Declare a guidance-only rule.
|
|
136
178
|
*
|
|
137
179
|
* guidance("Google unfamiliar APIs before implementing.")
|
|
180
|
+
* guidance("Short statement.", { details: file("docs/rules/x.md"), anchor: "why" })
|
|
138
181
|
*/
|
|
139
|
-
export declare function guidance(text: string): GuidanceRule;
|
|
182
|
+
export declare function guidance(text: string, options?: RuleDetailsOptions): GuidanceRule;
|
|
140
183
|
/**
|
|
141
184
|
* Declare a reactive guard: runs a command when watched files change.
|
|
142
185
|
*
|
package/dist/core/spec.js
CHANGED
|
@@ -55,6 +55,13 @@ exports.BUILTIN_LINTERS = [
|
|
|
55
55
|
"golangci-lint",
|
|
56
56
|
"cedar",
|
|
57
57
|
];
|
|
58
|
+
const detailsOf = (o) => {
|
|
59
|
+
if (!o?.details)
|
|
60
|
+
return undefined;
|
|
61
|
+
return o.anchor === undefined
|
|
62
|
+
? { file: o.details }
|
|
63
|
+
: { file: o.details, anchor: o.anchor };
|
|
64
|
+
};
|
|
58
65
|
// ---------------------------------------------------------------------------
|
|
59
66
|
// Builder functions
|
|
60
67
|
// ---------------------------------------------------------------------------
|
|
@@ -72,21 +79,28 @@ exports.BUILTIN_LINTERS = [
|
|
|
72
79
|
* enforce("vigiles/orphan-docs", "No docs without spec references.")
|
|
73
80
|
*/
|
|
74
81
|
function enforce(ref, why, options) {
|
|
82
|
+
const details = detailsOf(options);
|
|
75
83
|
return {
|
|
76
84
|
_kind: "enforce",
|
|
77
85
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
78
86
|
linterRule: ref,
|
|
79
87
|
why,
|
|
80
88
|
verify: options?.verify ?? true,
|
|
89
|
+
...(options?.render === undefined ? {} : { render: options.render }),
|
|
90
|
+
...(details ? { details } : {}),
|
|
81
91
|
};
|
|
82
92
|
}
|
|
83
93
|
/**
|
|
84
94
|
* Declare a guidance-only rule.
|
|
85
95
|
*
|
|
86
96
|
* guidance("Google unfamiliar APIs before implementing.")
|
|
97
|
+
* guidance("Short statement.", { details: file("docs/rules/x.md"), anchor: "why" })
|
|
87
98
|
*/
|
|
88
|
-
function guidance(text) {
|
|
89
|
-
|
|
99
|
+
function guidance(text, options) {
|
|
100
|
+
const details = detailsOf(options);
|
|
101
|
+
return details
|
|
102
|
+
? { _kind: "guidance", text, details }
|
|
103
|
+
: { _kind: "guidance", text };
|
|
90
104
|
}
|
|
91
105
|
/**
|
|
92
106
|
* Declare a reactive guard: runs a command when watched files change.
|
package/dist/core/types.d.ts
CHANGED
|
@@ -99,6 +99,18 @@ export interface TestCoverageConfig {
|
|
|
99
99
|
*/
|
|
100
100
|
testExtension?: string;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Thresholds for the `compile-budget` rule, in characters. A key left out
|
|
104
|
+
* keeps its default; `0` turns that budget off.
|
|
105
|
+
*/
|
|
106
|
+
export interface CompileBudgetConfig {
|
|
107
|
+
/** One `commands` description. Default 200. */
|
|
108
|
+
maxEntryChars?: number;
|
|
109
|
+
/** One named prose section. Default 15,000. */
|
|
110
|
+
maxSectionChars?: number;
|
|
111
|
+
/** The whole rendered Rules section. Default 20,000 (at "error"). */
|
|
112
|
+
maxRulesChars?: number;
|
|
113
|
+
}
|
|
102
114
|
export interface RulesConfig {
|
|
103
115
|
/**
|
|
104
116
|
* Opt-in: a doc in a configured dir (default `docs/`) that no other `.md`
|
|
@@ -271,6 +283,20 @@ export interface RulesConfig {
|
|
|
271
283
|
* `scan` (descriptionBudgetIssues).
|
|
272
284
|
*/
|
|
273
285
|
"skill-description-budget"?: RuleSeverity;
|
|
286
|
+
/**
|
|
287
|
+
* Size budgets on a COMPILED instruction file (`compile` from a `.spec.ts`):
|
|
288
|
+
* one `commands` entry, one prose section, and the whole Rules section. The
|
|
289
|
+
* whole always-loaded size is `instruction-weight`'s; `keyFiles` is
|
|
290
|
+
* deprecated, so neither is measured here.
|
|
291
|
+
*
|
|
292
|
+
* Default "warn", which is exactly the behaviour before this rule had a name:
|
|
293
|
+
* the per-entry and per-section budgets print as warnings and never stop a
|
|
294
|
+
* compile; the Rules total is not evaluated unless a threshold is configured.
|
|
295
|
+
* "error" (set by `vigiles:recommended`) makes every budget fail `compile` and
|
|
296
|
+
* `lint`. Options are thresholds — see {@link CompileBudgetConfig}. See
|
|
297
|
+
* docs/rules/compile-budget.md.
|
|
298
|
+
*/
|
|
299
|
+
"compile-budget"?: RuleWithOptions<CompileBudgetConfig>;
|
|
274
300
|
/**
|
|
275
301
|
* Flag a skill/agent whose `---` frontmatter block EXISTS but isn't valid YAML
|
|
276
302
|
* — fields may not parse as intended. CAVEAT: a real YAML parser (js-yaml) is
|
|
@@ -1294,6 +1294,100 @@
|
|
|
1294
1294
|
}
|
|
1295
1295
|
]
|
|
1296
1296
|
},
|
|
1297
|
+
"compile-budget": {
|
|
1298
|
+
"anyOf": [
|
|
1299
|
+
{
|
|
1300
|
+
"anyOf": [
|
|
1301
|
+
{
|
|
1302
|
+
"type": "string",
|
|
1303
|
+
"const": "warn"
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
"type": "string",
|
|
1307
|
+
"const": "error"
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
"type": "boolean",
|
|
1311
|
+
"const": false
|
|
1312
|
+
},
|
|
1313
|
+
{
|
|
1314
|
+
"type": "string",
|
|
1315
|
+
"const": "off"
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
"type": "number",
|
|
1319
|
+
"const": 0
|
|
1320
|
+
},
|
|
1321
|
+
{
|
|
1322
|
+
"type": "number",
|
|
1323
|
+
"const": 1
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
"type": "number",
|
|
1327
|
+
"const": 2
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
"type": "boolean",
|
|
1331
|
+
"const": true
|
|
1332
|
+
}
|
|
1333
|
+
]
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
"type": "array",
|
|
1337
|
+
"prefixItems": [
|
|
1338
|
+
{
|
|
1339
|
+
"type": "string",
|
|
1340
|
+
"const": "warn"
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
"type": "object",
|
|
1344
|
+
"properties": {
|
|
1345
|
+
"maxEntryChars": {
|
|
1346
|
+
"type": "number"
|
|
1347
|
+
},
|
|
1348
|
+
"maxSectionChars": {
|
|
1349
|
+
"type": "number"
|
|
1350
|
+
},
|
|
1351
|
+
"maxRulesChars": {
|
|
1352
|
+
"type": "number"
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
"additionalProperties": false
|
|
1356
|
+
}
|
|
1357
|
+
],
|
|
1358
|
+
"items": false,
|
|
1359
|
+
"minItems": 2,
|
|
1360
|
+
"maxItems": 2
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
"type": "array",
|
|
1364
|
+
"prefixItems": [
|
|
1365
|
+
{
|
|
1366
|
+
"type": "string",
|
|
1367
|
+
"const": "error"
|
|
1368
|
+
},
|
|
1369
|
+
{
|
|
1370
|
+
"type": "object",
|
|
1371
|
+
"properties": {
|
|
1372
|
+
"maxEntryChars": {
|
|
1373
|
+
"type": "number"
|
|
1374
|
+
},
|
|
1375
|
+
"maxSectionChars": {
|
|
1376
|
+
"type": "number"
|
|
1377
|
+
},
|
|
1378
|
+
"maxRulesChars": {
|
|
1379
|
+
"type": "number"
|
|
1380
|
+
}
|
|
1381
|
+
},
|
|
1382
|
+
"additionalProperties": false
|
|
1383
|
+
}
|
|
1384
|
+
],
|
|
1385
|
+
"items": false,
|
|
1386
|
+
"minItems": 2,
|
|
1387
|
+
"maxItems": 2
|
|
1388
|
+
}
|
|
1389
|
+
]
|
|
1390
|
+
},
|
|
1297
1391
|
"frontmatter-valid": {
|
|
1298
1392
|
"anyOf": [
|
|
1299
1393
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "38.0.0",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|