vigiles 37.0.0 → 38.1.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.
@@ -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;
@@ -35,6 +35,7 @@ const integrity_js_1 = require("./integrity.js");
35
35
  const markdown_js_1 = require("./markdown.js");
36
36
  const symbols_js_1 = require("./symbols.js");
37
37
  const skill_normalize_js_1 = require("./skill-normalize.js");
38
+ const keyfiles_js_1 = require("./keyfiles.js");
38
39
  const linters_js_1 = require("./linters.js");
39
40
  const tool_contract_js_1 = require("./tool-contract.js");
40
41
  const effects_js_1 = require("./effects.js");
@@ -299,6 +300,38 @@ function renderFragment(fragment) {
299
300
  // ---------------------------------------------------------------------------
300
301
  // Compile CLAUDE.md spec → markdown
301
302
  // ---------------------------------------------------------------------------
303
+ /** `[Details](path#anchor)` for a rule's long text. */
304
+ function detailsLink(details) {
305
+ const anchor = details.anchor === undefined ? "" : `#${details.anchor}`;
306
+ return `[Details](${details.file.path}${anchor})`;
307
+ }
308
+ /**
309
+ * The first sentence of `text`: up to the first `.`, `!` or `?` that closes a
310
+ * word (no space before it) and is followed by the end of the text or by
311
+ * whitespace and a capital, digit, quote, backtick or bracket. So a mark inside
312
+ * the sentence — "instead of ! assertions", "e.g. into helpers" — does not end
313
+ * it. A `why` with no sentence end is returned whole — truncating mid-sentence
314
+ * would render a claim nobody wrote.
315
+ */
316
+ function firstSentence(text) {
317
+ const m = /^[\s\S]*?\S[.!?](?=$|\s+[A-Z0-9"'`(*[])/.exec(text.trim());
318
+ return m ? m[0] : text.trim();
319
+ }
320
+ /** The `**Why:**` line(s) of an enforce rule under its render policy. */
321
+ function enforceWhy(rule) {
322
+ const link = rule.details ? ` ${detailsLink(rule.details)}` : "";
323
+ const render = rule.render ?? "full";
324
+ switch (render) {
325
+ case "full":
326
+ return [`**Why:** ${rule.why}${link}`];
327
+ case "line":
328
+ return [`**Why:** ${firstSentence(rule.why)}${link}`];
329
+ case "id":
330
+ return rule.details ? [detailsLink(rule.details)] : [];
331
+ default:
332
+ return (0, hash_js_1.assertNever)(render);
333
+ }
334
+ }
302
335
  function compileRule(id, rule) {
303
336
  const title = id
304
337
  .replace(/[-_]/g, " ")
@@ -309,10 +342,16 @@ function compileRule(id, rule) {
309
342
  `### ${title}`,
310
343
  "",
311
344
  `**Enforced by:** \`${rule.linterRule}\``,
312
- `**Why:** ${rule.why}`,
345
+ ...enforceWhy(rule),
313
346
  ].join("\n");
314
- case "guidance":
315
- return [`### ${title}`, "", `**Guidance only** — ${rule.text}`].join("\n");
347
+ case "guidance": {
348
+ const link = rule.details ? ` ${detailsLink(rule.details)}` : "";
349
+ return [
350
+ `### ${title}`,
351
+ "",
352
+ `**Guidance only** — ${rule.text}${link}`,
353
+ ].join("\n");
354
+ }
316
355
  case "guard": {
317
356
  const patterns = Array.isArray(rule.watch)
318
357
  ? rule.watch.join("`, `")
@@ -364,49 +403,99 @@ exports.DEFAULT_MAX_ENTRY_CHARS = 200;
364
403
  */
365
404
  exports.DEFAULT_MAX_SECTION_CHARS = 15000;
366
405
  /**
367
- * Budget findings for the two append-only maps and for section size. WARNINGS
368
- * by construction (`lint-rule-calibration`: severity tracks confidence, and a
369
- * gate that fails every real repo on day one is switched off on day two — this
370
- * repo's own corpus opens at 26 over-long entries).
406
+ * The Rules section, as one budget. Under `compile-budget: "error"` only.
407
+ *
408
+ * WHY THE SECTION AND NOT EACH RULE. A per-rule length cap was built and
409
+ * removed within an hour: it opened on 40 of 53 rules, because a rule's length
410
+ * is not where the cost is — the SUM is, and it is loaded on every request.
411
+ * 20,000 characters is half of Claude Code's 40,000-character instruction
412
+ * budget, which leaves the other half for everything else the file carries.
371
413
  */
372
- function checkContentBudgets(spec, specFile, maxEntryChars, maxSectionChars) {
373
- const warns = [];
374
- const entries = [
375
- ["keyFiles", spec.keyFiles],
376
- ["commands", spec.commands],
414
+ exports.DEFAULT_MAX_RULES_CHARS = 20000;
415
+ /**
416
+ * The thresholds this compile measures against.
417
+ *
418
+ * The Rules total has no default at `"warn"`: a repo that does not extend
419
+ * `vigiles:recommended` and leaves the rule at its default sees exactly the
420
+ * warnings `compile` printed before the rule had a name. At `"error"` (what the
421
+ * preset sets) every budget applies with its default.
422
+ *
423
+ * 🔴 WHAT THIS RULE DOES NOT MEASURE, ON PURPOSE — each has one owner already:
424
+ * - the whole always-loaded size, in the harness's unit (Claude Code chars,
425
+ * Codex bytes) — `instruction-weight` and its `max`, which sums the whole
426
+ * chain, so moving text into a rules file does not fool it;
427
+ * - `keyFiles` — deprecated (`deprecated-key-files`); budgeting a section the
428
+ * reader is told to delete would be a second, contradicting instruction.
429
+ */
430
+ function resolveBudgetLimits(options, severity) {
431
+ const strict = severity === "error";
432
+ return {
433
+ entryChars: options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS,
434
+ sectionChars: options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS,
435
+ rulesChars: options.maxRulesChars ?? (strict ? exports.DEFAULT_MAX_RULES_CHARS : 0),
436
+ };
437
+ }
438
+ const n = (x) => x.toLocaleString("en-US");
439
+ /**
440
+ * Budget findings for `commands` entries, section size and the Rules total.
441
+ * Severity is applied by the caller: the same finding is a warning at the
442
+ * rule's default and an error under `vigiles:recommended`.
443
+ */
444
+ function checkContentBudgets(input, specFile, limits) {
445
+ return [
446
+ ...entryBudget("commands", input.spec.commands, specFile, limits),
447
+ ...sectionBudget(input.spec.sections, specFile, limits),
448
+ ...rulesBudget(input, specFile, limits),
449
+ ];
450
+ }
451
+ /** One finding per `commands` description over the entry budget. */
452
+ function entryBudget(field, map, specFile, limits) {
453
+ if (!map || limits.entryChars <= 0)
454
+ return [];
455
+ return Object.entries(map)
456
+ .filter(([, description]) => description.length > limits.entryChars)
457
+ .map(([key, description]) => ({
458
+ type: "entry-too-long",
459
+ path: specFile,
460
+ message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
461
+ `characters (budget ${String(limits.entryChars)}). An entry is a pointer — say what the ` +
462
+ `command does in one line and move the explanation into the script's own header or ` +
463
+ `--help, which is read when someone runs it rather than on every request.`,
464
+ }));
465
+ }
466
+ /** One finding per plain-prose section over the section budget. */
467
+ function sectionBudget(sections, specFile, limits) {
468
+ if (!sections || limits.sectionChars <= 0)
469
+ return [];
470
+ // Fragment-valued sections are assembled later; only plain prose is
471
+ // measurable here, and it is the shape that grows.
472
+ return Object.entries(sections)
473
+ .filter((pair) => typeof pair[1] === "string" && pair[1].length > limits.sectionChars)
474
+ .map(([name, body]) => ({
475
+ type: "section-too-large",
476
+ path: specFile,
477
+ message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
478
+ `(budget ${String(limits.sectionChars)}). The line guard cannot see this — long lines are ` +
479
+ `the normal shape of compiled prose — and every character is loaded on every request.`,
480
+ }));
481
+ }
482
+ /** The whole Rules section, as one sum. */
483
+ function rulesBudget(input, specFile, limits) {
484
+ const rulesChars = input.renderedRules.length;
485
+ if (limits.rulesChars <= 0 || rulesChars <= limits.rulesChars)
486
+ return [];
487
+ return [
488
+ {
489
+ type: "rules-too-large",
490
+ path: specFile,
491
+ message: `the Rules section is ${n(rulesChars)} characters for ` +
492
+ `${String(Object.keys(input.spec.rules).length)} rules ` +
493
+ `(budget ${n(limits.rulesChars)}). Render a rule shorter instead of deleting it: ` +
494
+ `enforce(rule, why, { render: "id" }) when a linter already holds it, or ` +
495
+ `guidance(statement, { details: file(...) }) with the long text in that file — ` +
496
+ `the rule stays in the spec, where lint and audit read it.`,
497
+ },
377
498
  ];
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
499
  }
411
500
  // A key-files entry is a POINTER, not an essay: the prose about why a file is
412
501
  // shaped the way it is belongs in that file's own header, where it is read when
@@ -476,12 +565,12 @@ async function compileSectionsSection(spec, basePath, maxSectionLines) {
476
565
  return { lines, errors };
477
566
  }
478
567
  function compileKeyFilesSection(spec, basePath) {
479
- if (!spec.keyFiles)
568
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- keyFiles is deprecated, not removed: it still compiles until the next major
569
+ const keyFiles = spec.keyFiles;
570
+ if (!keyFiles)
480
571
  return { lines: [], errors: [] };
481
- const lines = ["## Key Files", ""];
482
572
  const errors = [];
483
- for (const [filePath, desc] of Object.entries(spec.keyFiles)) {
484
- lines.push(`- \`${filePath}\` — ${desc}`);
573
+ for (const [filePath, desc] of Object.entries(keyFiles)) {
485
574
  const err = validateFileRef(filePath, basePath);
486
575
  if (err)
487
576
  errors.push(err);
@@ -492,7 +581,9 @@ function compileKeyFilesSection(spec, basePath) {
492
581
  });
493
582
  }
494
583
  }
495
- return { lines: [lines.join("\n")], errors };
584
+ // The one renderer the `deprecated-key-files` rule measures with, so the
585
+ // size a finding reports is the size of what this emits.
586
+ return { lines: [(0, keyfiles_js_1.renderKeyFiles)(keyFiles)], errors };
496
587
  }
497
588
  function compileCommandsSection(spec, basePath) {
498
589
  if (!spec.commands)
@@ -524,6 +615,29 @@ function shouldVerifyRule(rule, options) {
524
615
  return false;
525
616
  return true;
526
617
  }
618
+ /**
619
+ * A rule's `details` must land somewhere: the file exists (the same check as
620
+ * every `file()`), and its anchor, if any, is a heading or an explicit id in
621
+ * that file. A short rule whose long text 404s is the rule deleted, with a
622
+ * link standing where it was.
623
+ */
624
+ function validateRuleDetails(details, basePath) {
625
+ const missing = validateFileRef(details.file.path, basePath);
626
+ if (missing)
627
+ return [missing];
628
+ if (details.anchor === undefined)
629
+ return [];
630
+ const text = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(basePath, details.file.path), "utf-8");
631
+ if ((0, markdown_js_1.markdownAnchors)(text).has(details.anchor))
632
+ return [];
633
+ return [
634
+ {
635
+ type: "stale-ref",
636
+ message: `Anchor "#${details.anchor}" not found in "${details.file.path}" (no heading slugs to it and no <a id> defines it)`,
637
+ path: `${details.file.path}#${details.anchor}`,
638
+ },
639
+ ];
640
+ }
527
641
  function compileRulesSection(spec, basePath, options) {
528
642
  const ruleEntries = Object.entries(spec.rules);
529
643
  if (ruleEntries.length === 0) {
@@ -564,7 +678,14 @@ function compileRulesSection(spec, basePath, options) {
564
678
  }
565
679
  }
566
680
  }
567
- return { lines: [ruleLines.join("\n")], errors, linterResults };
681
+ const detailsErrors = ruleEntries.flatMap(([, rule]) => rule._kind !== "guard" && rule.details
682
+ ? validateRuleDetails(rule.details, basePath)
683
+ : []);
684
+ return {
685
+ lines: [ruleLines.join("\n")],
686
+ errors: [...errors, ...detailsErrors],
687
+ linterResults,
688
+ };
568
689
  }
569
690
  // ---------------------------------------------------------------------------
570
691
  // compileClaude
@@ -622,7 +743,9 @@ async function compileClaude(spec, options = {}) {
622
743
  errors.push(...prose.errors, ...keyFiles.errors, ...commands.errors, ...rules.errors);
623
744
  const body = sections.join("\n\n") + "\n";
624
745
  const tokens = estimateTokens(body);
625
- // Per-spec maxTokens takes precedence, then compile options
746
+ // Per-spec maxTokens takes precedence, then compile options. An explicit
747
+ // ceiling is an error at any `compile-budget` severity, as it always was:
748
+ // the author typed a number and asked for it to hold.
626
749
  const maxTokens = spec.maxTokens ?? options.maxTokens;
627
750
  if (maxTokens && tokens > maxTokens) {
628
751
  errors.push({
@@ -632,10 +755,22 @@ async function compileClaude(spec, options = {}) {
632
755
  }
633
756
  const markdown = addHash(body, specFile);
634
757
  const allTargets = Array.isArray(targets) ? targets : [targets];
758
+ const severity = options.budgetSeverity ?? "warn";
759
+ const budgetFindings = severity === false
760
+ ? []
761
+ : [
762
+ ...checkContentBudgets({
763
+ spec,
764
+ renderedRules: rules.lines.join("\n\n"),
765
+ }, specFile, resolveBudgetLimits(options, severity)),
766
+ ];
635
767
  return {
636
768
  markdown,
637
- errors,
638
- warnings: checkContentBudgets(spec, specFile, options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS, options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS),
769
+ // The SAME objects as `budgetFindings`, so `lint` can tell them apart by
770
+ // identity.
771
+ errors: severity === "error" ? [...errors, ...budgetFindings] : errors,
772
+ warnings: severity === "warn" ? budgetFindings : [],
773
+ budgetFindings,
639
774
  linterResults: rules.linterResults,
640
775
  tokens,
641
776
  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">>>;
@@ -141,6 +150,7 @@ export declare const vigilesConfigSchema: z.ZodObject<{
141
150
  "hook-matcher": 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">>>;
142
151
  "doc-refs": 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">>>;
143
152
  "instruction-weight": 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">>>;
153
+ "deprecated-key-files": 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">>>;
144
154
  }, z.core.$strict>>;
145
155
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
146
156
  maxRules: z.ZodOptional<z.ZodNumber>;
@@ -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"),
@@ -167,6 +182,7 @@ const rulesSchema = zod_1.z
167
182
  "hook-matcher": severitySchema.default("warn"),
168
183
  "doc-refs": severitySchema.default(false),
169
184
  "instruction-weight": severitySchema.default("error"),
185
+ "deprecated-key-files": severitySchema.default("warn"),
170
186
  })
171
187
  .strict();
172
188
  /** The rule names, for the "did you mean" on an unknown one. */
@@ -0,0 +1,9 @@
1
+ import { type KeyFilesUsage } from "./keyfiles.js";
2
+ /**
3
+ * The `keyFiles` usage in one spec's source, or `null` when the spec does not
4
+ * use the field. The first `keyFiles:` property in any object literal counts —
5
+ * an instruction spec has one builder call, and nothing else in a spec is
6
+ * called that.
7
+ */
8
+ export declare function findKeyFilesInSpecSource(source: string): KeyFilesUsage | null;
9
+ //# sourceMappingURL=keyfiles-source.d.ts.map
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.findKeyFilesInSpecSource = findKeyFilesInSpecSource;
7
+ /**
8
+ * Reads `keyFiles` out of an instruction spec's SOURCE, without running it.
9
+ *
10
+ * `lint` and `audit` both report the deprecated field (`deprecated-key-files`),
11
+ * and neither may import a spec: `audit` is a side-effect-free read, and a spec
12
+ * is the user's program. So the source is PARSED (TypeScript syntax tree) and
13
+ * the field is read off the tree — a `keyFiles` inside a comment or a string is
14
+ * not usage, and a text search could not tell the difference.
15
+ *
16
+ * Pure: the file walk that feeds it is `src/deprecated-key-files.ts`.
17
+ */
18
+ const typescript_1 = __importDefault(require("typescript"));
19
+ const keyfiles_js_1 = require("./keyfiles.js");
20
+ /** Field used, size not knowable from the syntax alone. */
21
+ const UNMEASURED = (entries) => ({
22
+ entries,
23
+ chars: 0,
24
+ measured: false,
25
+ });
26
+ /** A literal key or value: `"a"`, `'a'`, `` `a` `` (and, for a key only, `a`). */
27
+ function literalText(node) {
28
+ return typescript_1.default.isStringLiteral(node) || typescript_1.default.isNoSubstitutionTemplateLiteral(node)
29
+ ? node.text
30
+ : undefined;
31
+ }
32
+ /** One `path: "description"` pair, or `undefined` when either side is not literal. */
33
+ function literalEntry(prop) {
34
+ if (!typescript_1.default.isPropertyAssignment(prop))
35
+ return undefined;
36
+ const key = typescript_1.default.isIdentifier(prop.name)
37
+ ? prop.name.text
38
+ : literalText(prop.name);
39
+ const value = literalText(prop.initializer);
40
+ return key === undefined || value === undefined ? undefined : [key, value];
41
+ }
42
+ /**
43
+ * Read a `{ path: "description" }` object literal. A computed key or value, or a
44
+ * spread, makes the map unmeasurable: its pairs are still counted (each one
45
+ * compiles to a line), its size is not guessed.
46
+ */
47
+ function measureLiteral(obj) {
48
+ const pairs = obj.properties.filter(typescript_1.default.isPropertyAssignment);
49
+ const literal = obj.properties
50
+ .map(literalEntry)
51
+ .filter((e) => e !== undefined);
52
+ return literal.length === obj.properties.length
53
+ ? (0, keyfiles_js_1.measureKeyFiles)(Object.fromEntries(literal))
54
+ : UNMEASURED(pairs.length);
55
+ }
56
+ /** The usage a node declares, if it is a `keyFiles` property. */
57
+ function usageAt(node) {
58
+ if (typescript_1.default.isShorthandPropertyAssignment(node))
59
+ return node.name.text === "keyFiles" ? UNMEASURED(0) : undefined;
60
+ if (!typescript_1.default.isPropertyAssignment(node))
61
+ return undefined;
62
+ const name = typescript_1.default.isIdentifier(node.name)
63
+ ? node.name.text
64
+ : literalText(node.name);
65
+ if (name !== "keyFiles")
66
+ return undefined;
67
+ return typescript_1.default.isObjectLiteralExpression(node.initializer)
68
+ ? measureLiteral(node.initializer)
69
+ : UNMEASURED(0);
70
+ }
71
+ /** Depth-first: the first `keyFiles` property under `node`. */
72
+ function firstUsage(node) {
73
+ return (usageAt(node) ??
74
+ typescript_1.default.forEachChild(node, (child) => firstUsage(child)) ??
75
+ undefined);
76
+ }
77
+ /**
78
+ * The `keyFiles` usage in one spec's source, or `null` when the spec does not
79
+ * use the field. The first `keyFiles:` property in any object literal counts —
80
+ * an instruction spec has one builder call, and nothing else in a spec is
81
+ * called that.
82
+ */
83
+ function findKeyFilesInSpecSource(source) {
84
+ const sf = typescript_1.default.createSourceFile("spec.ts", source, typescript_1.default.ScriptTarget.Latest,
85
+ /* setParentNodes */ false, typescript_1.default.ScriptKind.TS);
86
+ return firstUsage(sf) ?? null;
87
+ }
88
+ //# sourceMappingURL=keyfiles-source.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `keyFiles` — DEPRECATED, removed in the major after the one that ships this
3
+ * notice (the one-major alias window in `core/spec.ts`).
4
+ *
5
+ * WHY. The field compiles a flat "## Key Files" path → description list into
6
+ * the instruction file the harness loads on EVERY request. Measured on this
7
+ * repository's own root spec (2026-10-11): 230 entries, 45,467 characters —
8
+ * 29% of a 157,872-character file, paid on every request, to tell an agent where files are
9
+ * when it finds them itself with Glob/Grep. Anthropic's own guidance lists
10
+ * "File-by-file descriptions of the codebase" under what to EXCLUDE from
11
+ * `CLAUDE.md` (https://code.claude.com/docs/en/best-practices).
12
+ *
13
+ * THE REPLACEMENT, decided per entry, in this order:
14
+ * - the file's own header, the docs or the path already say it → delete it;
15
+ * - a fact about that one file, written nowhere else → that file's header
16
+ * comment, where a reader meets it on opening the file;
17
+ * - a rule an agent must follow while working in one folder → that folder's
18
+ * nested instruction file (`<folder>/CLAUDE.md` from its own spec), which
19
+ * loads only when the agent works there;
20
+ * - only when nothing else would lead an agent to the file → a one-line
21
+ * `file()` pointer where the decision that needs it is written (verified
22
+ * exactly like a `keyFiles` path, by `validateFileRef`).
23
+ *
24
+ * This module is pure: the renderer `compile` uses, the measurement the
25
+ * `deprecated-key-files` rule reports, and its message — one wording for lint
26
+ * and audit.
27
+ */
28
+ /** Where the migration is written up. Printed in every finding. */
29
+ export declare const KEYFILES_DOCS_URL = "https://github.com/zernie/vigiles/blob/main/docs/rules/deprecated-key-files.md";
30
+ /**
31
+ * The compiled "## Key Files" section — the ONE renderer, so the size a
32
+ * finding reports is the size of what actually loads.
33
+ */
34
+ export declare function renderKeyFiles(keyFiles: Readonly<Record<string, string>>): string;
35
+ /**
36
+ * What a spec's `keyFiles` costs. `measured: false` when the map is not a
37
+ * literal (a variable, a computed key or value, a spread) — then the field is
38
+ * known to be USED and its size is not guessed.
39
+ */
40
+ export interface KeyFilesUsage {
41
+ readonly entries: number;
42
+ readonly chars: number;
43
+ readonly measured: boolean;
44
+ }
45
+ /** Measure a map the way it compiles. */
46
+ export declare function measureKeyFiles(keyFiles: Readonly<Record<string, string>>): KeyFilesUsage;
47
+ /** The cost half of a finding, e.g. `230 entries, 45,467 characters compiled into …`. */
48
+ export declare function keyFilesCost(u: KeyFilesUsage): string;
49
+ /**
50
+ * The instruction file a spec compiles to — `pkg/AGENTS.md.spec.ts` →
51
+ * `AGENTS.md` — so the fix names the nested file in the reader's own harness
52
+ * instead of assuming one.
53
+ */
54
+ export declare function instructionFileOfSpec(specPath: string): string;
55
+ /**
56
+ * The fix, one sentence — shared by the lint message and the audit line. The
57
+ * four places an entry can go, in the order to try them; the full recipe is
58
+ * the migration guide. `targets` are the instruction files the reported specs
59
+ * compile to (`CLAUDE.md`, `AGENTS.md`), so the nested file is named for real.
60
+ */
61
+ export declare function keyFilesFix(targets: readonly string[]): string;
62
+ /** The whole finding: what it costs, then where each entry goes and where it is written up. */
63
+ export declare function keyFilesDeprecationMessage(u: KeyFilesUsage, target: string): string;
64
+ //# sourceMappingURL=keyfiles.d.ts.map
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * `keyFiles` — DEPRECATED, removed in the major after the one that ships this
4
+ * notice (the one-major alias window in `core/spec.ts`).
5
+ *
6
+ * WHY. The field compiles a flat "## Key Files" path → description list into
7
+ * the instruction file the harness loads on EVERY request. Measured on this
8
+ * repository's own root spec (2026-10-11): 230 entries, 45,467 characters —
9
+ * 29% of a 157,872-character file, paid on every request, to tell an agent where files are
10
+ * when it finds them itself with Glob/Grep. Anthropic's own guidance lists
11
+ * "File-by-file descriptions of the codebase" under what to EXCLUDE from
12
+ * `CLAUDE.md` (https://code.claude.com/docs/en/best-practices).
13
+ *
14
+ * THE REPLACEMENT, decided per entry, in this order:
15
+ * - the file's own header, the docs or the path already say it → delete it;
16
+ * - a fact about that one file, written nowhere else → that file's header
17
+ * comment, where a reader meets it on opening the file;
18
+ * - a rule an agent must follow while working in one folder → that folder's
19
+ * nested instruction file (`<folder>/CLAUDE.md` from its own spec), which
20
+ * loads only when the agent works there;
21
+ * - only when nothing else would lead an agent to the file → a one-line
22
+ * `file()` pointer where the decision that needs it is written (verified
23
+ * exactly like a `keyFiles` path, by `validateFileRef`).
24
+ *
25
+ * This module is pure: the renderer `compile` uses, the measurement the
26
+ * `deprecated-key-files` rule reports, and its message — one wording for lint
27
+ * and audit.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.KEYFILES_DOCS_URL = void 0;
31
+ exports.renderKeyFiles = renderKeyFiles;
32
+ exports.measureKeyFiles = measureKeyFiles;
33
+ exports.keyFilesCost = keyFilesCost;
34
+ exports.instructionFileOfSpec = instructionFileOfSpec;
35
+ exports.keyFilesFix = keyFilesFix;
36
+ exports.keyFilesDeprecationMessage = keyFilesDeprecationMessage;
37
+ /** Where the migration is written up. Printed in every finding. */
38
+ exports.KEYFILES_DOCS_URL = "https://github.com/zernie/vigiles/blob/main/docs/rules/deprecated-key-files.md";
39
+ /**
40
+ * The compiled "## Key Files" section — the ONE renderer, so the size a
41
+ * finding reports is the size of what actually loads.
42
+ */
43
+ function renderKeyFiles(keyFiles) {
44
+ return [
45
+ "## Key Files",
46
+ "",
47
+ ...Object.entries(keyFiles).map(([path, desc]) => `- \`${path}\` — ${desc}`),
48
+ ].join("\n");
49
+ }
50
+ /** Measure a map the way it compiles. */
51
+ function measureKeyFiles(keyFiles) {
52
+ return {
53
+ entries: Object.keys(keyFiles).length,
54
+ chars: renderKeyFiles(keyFiles).length,
55
+ measured: true,
56
+ };
57
+ }
58
+ const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
59
+ /** The cost half of a finding, e.g. `230 entries, 45,467 characters compiled into …`. */
60
+ function keyFilesCost(u) {
61
+ if (!u.measured)
62
+ return "not a literal map — size not measured";
63
+ const noun = u.entries === 1 ? "entry" : "entries";
64
+ return `${String(u.entries)} ${noun}, ${grouped(u.chars)} characters compiled into the instruction file`;
65
+ }
66
+ /**
67
+ * The instruction file a spec compiles to — `pkg/AGENTS.md.spec.ts` →
68
+ * `AGENTS.md` — so the fix names the nested file in the reader's own harness
69
+ * instead of assuming one.
70
+ */
71
+ function instructionFileOfSpec(specPath) {
72
+ return specPath.replace(/^.*[\\/]/, "").replace(/\.spec\.ts$/, "");
73
+ }
74
+ /**
75
+ * The fix, one sentence — shared by the lint message and the audit line. The
76
+ * four places an entry can go, in the order to try them; the full recipe is
77
+ * the migration guide. `targets` are the instruction files the reported specs
78
+ * compile to (`CLAUDE.md`, `AGENTS.md`), so the nested file is named for real.
79
+ */
80
+ function keyFilesFix(targets) {
81
+ // In the order given — the detector sorts findings by path — once each.
82
+ const nested = Array.from(new Set(targets), (t) => `<folder>/${t}`).join(" or ");
83
+ return ("For each entry: delete it if the file or the docs already say it · " +
84
+ "move a fact about one file into that file's header comment · " +
85
+ `move a rule for one folder into ${nested} · ` +
86
+ "keep a one-line file() pointer only if nothing else leads there");
87
+ }
88
+ /** The whole finding: what it costs, then where each entry goes and where it is written up. */
89
+ function keyFilesDeprecationMessage(u, target) {
90
+ return (`\`keyFiles\` is deprecated and goes away in the next major (${keyFilesCost(u)}). ` +
91
+ `${keyFilesFix([target])} (guide: ${exports.KEYFILES_DOCS_URL})`);
92
+ }
93
+ //# sourceMappingURL=keyfiles.js.map