vigiles 32.0.1 → 32.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.
@@ -239,7 +239,17 @@ function excludesIn(text, parse) {
239
239
  /** Does this rule file declare a `paths:` scope — i.e. load only on demand? */
240
240
  function isPathScoped(text) {
241
241
  const { data } = (0, frontmatter_read_js_1.readFrontmatter)(text);
242
- return data !== null && Object.hasOwn(data, PATH_SCOPE_KEY);
242
+ if (data === null || !Object.hasOwn(data, PATH_SCOPE_KEY))
243
+ return false;
244
+ // A SCOPE IS A NON-EMPTY LIST OF GLOBS, not the presence of the key.
245
+ // `paths: []` names no file to load on and a scalar is not the documented
246
+ // shape; reading either as on-demand would let one frontmatter line drop a
247
+ // rule's whole size from the always-loaded total — an under-report, the
248
+ // direction that reads as "you are fine". So both count as always-loaded.
249
+ const value = data[PATH_SCOPE_KEY];
250
+ return (Array.isArray(value) &&
251
+ value.length > 0 &&
252
+ value.every((g) => typeof g === "string"));
243
253
  }
244
254
  /**
245
255
  * `CLAUDE.md` → `CLAUDE.local.md`: the per-machine sibling, DERIVED so the two
@@ -452,6 +462,21 @@ function supersederOf(files, input, isExcluded) {
452
462
  // keeps another.
453
463
  return candidates.find((c) => files[c.path] !== undefined && !isExcluded(c.path));
454
464
  }
465
+ /** One loaded file's `@import` tokens, reported only — never taken. */
466
+ function nameImportsOf(b, entry) {
467
+ const text = b.files[entry.path];
468
+ if (text === undefined)
469
+ return;
470
+ const named = importTokens(text)
471
+ .filter((token) => (0, instruction_chain_js_1.isRepoRootedImport)(token.slice(1)))
472
+ .map((token) => ({
473
+ path: (0, instruction_chain_js_1.resolveImportPath)(entry.path, token.slice(1)),
474
+ token,
475
+ from: entry.path,
476
+ }))
477
+ .filter((n) => !b.imports.some((m) => m.path === n.path && m.from === n.from));
478
+ b.imports.push(...named);
479
+ }
455
480
  /** One loaded file's `@import` tokens: reported, and TAKEN when already present. */
456
481
  function takeImportsOf(b, entry) {
457
482
  const text = b.files[entry.path];
@@ -561,8 +586,17 @@ function claudeCodeInstructionChain(files, input) {
561
586
  // assumed — `instruction-chain.test.ts` runs each against an `AGENTS.md` that
562
587
  // got in as a ROOT file, since a role-keyed version of either would pass every
563
588
  // `CLAUDE.md` case and fail exactly those two.
564
- for (const entry of [...b.loaded])
589
+ const firstHop = [...b.loaded];
590
+ for (const entry of firstHop)
565
591
  takeImportsOf(b, entry);
592
+ // THE SECOND HOP IS NAMED, NEVER TAKEN. One level is what this reads (the
593
+ // corpus measurement is on `resolveImports`), but the vendor follows up to
594
+ // four, so an imported file's own tokens are real weight this number lacks.
595
+ // Recording them as imports — not loading them — lets the weight list them
596
+ // as unread instead of silently dropping them.
597
+ for (const entry of b.loaded.filter((e) => !firstHop.includes(e))) {
598
+ nameImportsOf(b, entry);
599
+ }
566
600
  // THE SUBDIRECTORY PASS, AFTER THE IMPORTS AND NOT BEFORE THEM. Order is the
567
601
  // whole of a fixed bug: this pass claims ANY slash path whose leaf is an
568
602
  // instruction name, and `takeImportsOf` skips a path already `known`. Running
@@ -78,7 +78,13 @@ exports.COMMAND_FLAGS = {
78
78
  ],
79
79
  compile: [],
80
80
  eject: ["--keep-spec"],
81
- lint: ["--bundles=", "--summary", "--json", "--json-out="],
81
+ lint: [
82
+ "--bundles=",
83
+ "--summary",
84
+ "--json",
85
+ "--json-out=",
86
+ "--update-baseline",
87
+ ],
82
88
  // handleRunScripts (free tier — no lock flags).
83
89
  test: ["--min=", "--all", "--yes", "--no-interactive", "--no-skip"],
84
90
  // handleRunScripts + resolveEvalLockEnv + the trials knob.
@@ -77,6 +77,8 @@ interface LintReport {
77
77
  hookBlockErrors: number;
78
78
  hookMatcherIssues: number;
79
79
  hookMatcherErrors: number;
80
+ instructionWeightIssues: number;
81
+ instructionWeightErrors: number;
80
82
  docRefErrors: number;
81
83
  symbolRefErrors: number;
82
84
  mcpRefErrors: number;
package/dist/cli-main.js CHANGED
@@ -34,6 +34,7 @@ const setup_plan_js_1 = require("./setup-plan.js");
34
34
  const types_js_1 = require("./core/types.js");
35
35
  const test_coverage_js_1 = require("./test-coverage.js");
36
36
  const scan_js_1 = require("./scan.js");
37
+ const instruction_weight_ratchet_js_1 = require("./instruction-weight-ratchet.js");
37
38
  const frame_js_1 = require("./core/frame.js");
38
39
  const surface_discovery_js_1 = require("./core/surface-discovery.js");
39
40
  const scan_trigger_suggest_js_1 = require("./scan-trigger-suggest.js");
@@ -978,6 +979,7 @@ function lintExitCode(report) {
978
979
  report.delegationTrifectaErrors > 0 ||
979
980
  report.hookBlockErrors > 0 ||
980
981
  report.hookMatcherErrors > 0 ||
982
+ report.instructionWeightErrors > 0 ||
981
983
  report.symbolRefErrors > 0 ||
982
984
  report.mcpRefErrors > 0 ||
983
985
  // `doc-refs` is opt-in and this counter is only non-zero when the user set
@@ -1675,6 +1677,22 @@ async function runLint(restArgs, flags, excludes, config) {
1675
1677
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1676
1678
  // server naming, or an undeclared MCP server).
1677
1679
  const hookMatcher = overBundles(checkHookMatcher, run, lintRoots);
1680
+ // 7v. Instruction-weight ratchet — the always-loaded instruction weight may
1681
+ // not move off the committed baseline. One file for the whole run (bundles
1682
+ // are keys inside it), so it runs ONCE over `lintRoots`, not per bundle.
1683
+ const instructionWeight = (0, instruction_weight_ratchet_js_1.checkInstructionWeightRatchet)({
1684
+ root: frame.root,
1685
+ severity: (0, types_js_1.ruleSeverity)(config?.rules?.["instruction-weight"]),
1686
+ update: flags.includes("--update-baseline"),
1687
+ silent,
1688
+ measure: () => lintRoots.map((abs) => [
1689
+ frame.bundle(abs).at,
1690
+ (0, scan_js_1.measureInstructionWeight)(abs, adapter),
1691
+ ]),
1692
+ annotate: (level, message) => {
1693
+ ghAnnotate(level, message);
1694
+ },
1695
+ });
1678
1696
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1679
1697
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1680
1698
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1803,6 +1821,8 @@ async function runLint(restArgs, flags, excludes, config) {
1803
1821
  hookBlockErrors: hookBlock.errors,
1804
1822
  hookMatcherIssues: hookMatcher.issues,
1805
1823
  hookMatcherErrors: hookMatcher.errors,
1824
+ instructionWeightIssues: instructionWeight.issues,
1825
+ instructionWeightErrors: instructionWeight.errors,
1806
1826
  // Only the "error" tier gates. At "warn" the findings are printed and
1807
1827
  // annotated, and the exit code is untouched — same contract as every other
1808
1828
  // opt-in rule here.
@@ -6636,6 +6656,18 @@ async function main() {
6636
6656
  if (specs.length > 0)
6637
6657
  valid =
6638
6658
  (await compile(specs, config, excludes, { harnessFlag })) && valid;
6659
+ // The always-loaded weight the author just produced, against the
6660
+ // committed baseline — shown at the moment of change. Report-only:
6661
+ // `lint` is the gate (rule `instruction-weight`).
6662
+ if (specs.length > 0) {
6663
+ const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(process.cwd(), (0, scan_js_1.measureInstructionWeight)(process.cwd(), (0, adapter_registry_js_1.resolveHarnessSelection)({
6664
+ root: process.cwd(),
6665
+ flag: harnessFlag,
6666
+ configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
6667
+ }).adapter));
6668
+ if (weightLine !== null)
6669
+ console.log(`\n${weightLine}`);
6670
+ }
6639
6671
  valid =
6640
6672
  (await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses))) && valid;
6641
6673
  // Keep an existing whole-harness registry in sync (cheap, opt-in) so the
@@ -131,6 +131,7 @@ export declare const vigilesConfigSchema: z.ZodObject<{
131
131
  "hook-block-ineffective": 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 | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
132
132
  "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 | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
133
133
  "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 | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
134
+ "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 | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
134
135
  }, z.core.$strict>>;
135
136
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
136
137
  maxRules: z.ZodOptional<z.ZodNumber>;
@@ -163,6 +163,7 @@ const rulesSchema = zod_1.z
163
163
  "hook-block-ineffective": severitySchema.default("warn"),
164
164
  "hook-matcher": severitySchema.default("warn"),
165
165
  "doc-refs": severitySchema.default(false),
166
+ "instruction-weight": severitySchema.default("error"),
166
167
  })
167
168
  .strict();
168
169
  /** The rule names, for the "did you mean" on an unknown one. */
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The instruction-weight RATCHET: the always-loaded weight may not grow past
3
+ * what the repository itself last recorded.
4
+ *
5
+ * WHY A RATCHET AND NOT THE BUDGET. `./instruction-weight.ts` explains why the
6
+ * harness's own threshold cannot gate: real repositories sit several times over
7
+ * it, and a rule that fails every repo on day one is switched off on day one.
8
+ * What CAN gate is the repository's own past. The baseline is whatever the repo
9
+ * weighed when it was last recorded, so a repo four times over budget starts
10
+ * green — and stays green only while it does not get heavier. The measured case
11
+ * this exists for: a root instruction file cut from ~4 100 lines to ~1 900, then
12
+ * regrown by ~1 000 lines over ten ordinary commits, with nothing in CI to say so.
13
+ *
14
+ * WHY THE SUM, AGAIN. The baseline records `committedTotal`, the same number the
15
+ * audit report scores: everything the harness loads without being asked that a
16
+ * teammate on the same commit also loads. A per-file baseline would read text
17
+ * moved into an always-loaded sibling as a shrink — the evasion the weight
18
+ * module was built to see through. The per-file sizes are recorded too, but only
19
+ * so a message can say WHICH file moved; the verdict is decided on the total.
20
+ *
21
+ * WHY A SHRINK IS A FINDING TOO. A baseline left above the tree is headroom, and
22
+ * headroom is exactly where the regrowth above landed: every character up to the
23
+ * old number would pass. So, like ESLint's unpruned suppressions and `betterer
24
+ * ci`, a tree LIGHTER than its baseline asks for the baseline to be lowered —
25
+ * one command, in the same change that made the cut. The baseline then always
26
+ * equals the tree on the default branch, and any growth at all is a diff a
27
+ * reviewer sees.
28
+ *
29
+ * WHO WRITES IT. Never `lint` on its own: a read never writes. The file is
30
+ * written only by the explicit `vigiles lint --update-baseline`, the same shape
31
+ * as `eslint --suppress-all` / `--prune-suppressions` and `jest -u`. Raising the
32
+ * baseline is allowed and is the point: growth is not forbidden, it is made
33
+ * visible as a reviewed diff of a committed file.
34
+ *
35
+ * Pure: parsing, comparison and formatting only. Reading and writing the file
36
+ * is the caller's (`src/instruction-weight-ratchet.ts`).
37
+ */
38
+ import { z } from "zod";
39
+ import type { InstructionWeight } from "./instruction-weight.js";
40
+ /** Where the baseline lives, relative to the directory `.vigilesrc.json` is read from. */
41
+ export declare const INSTRUCTION_BASELINE_FILE = ".vigiles/instruction-weight.json";
42
+ /** The command that writes the baseline — named once, quoted by every message. */
43
+ export declare const UPDATE_BASELINE_COMMAND = "vigiles lint --update-baseline";
44
+ /** Bumped only on an incompatible change to the on-disk shape. */
45
+ export declare const INSTRUCTION_BASELINE_VERSION = 1;
46
+ /**
47
+ * Which MEASUREMENT produced a recorded number — bumped whenever a release
48
+ * changes what counts (chain membership in any adapter, how a file is sized),
49
+ * even when the file shape stays the same.
50
+ *
51
+ * Without it, a vigiles fix that moves a file into or out of the chain moves
52
+ * `committedTotal` on every consumer with a committed baseline, and they see
53
+ * `grew`/`shrank` attributed to their own content. With it, they see one
54
+ * "re-record" verdict that names the cause. `version` cannot do this job: it
55
+ * describes the file, not the number in it.
56
+ */
57
+ export declare const INSTRUCTION_WEIGHT_MEASURE = 1;
58
+ declare const entrySchema: z.ZodObject<{
59
+ unit: z.ZodEnum<{
60
+ chars: "chars";
61
+ bytes: "bytes";
62
+ }>;
63
+ measure: z.ZodNumber;
64
+ total: z.ZodNumber;
65
+ files: z.ZodRecord<z.ZodString, z.ZodNumber>;
66
+ }, z.core.$strict>;
67
+ declare const baselineSchema: z.ZodObject<{
68
+ version: z.ZodLiteral<1>;
69
+ bundles: z.ZodRecord<z.ZodString, z.ZodObject<{
70
+ unit: z.ZodEnum<{
71
+ chars: "chars";
72
+ bytes: "bytes";
73
+ }>;
74
+ measure: z.ZodNumber;
75
+ total: z.ZodNumber;
76
+ files: z.ZodRecord<z.ZodString, z.ZodNumber>;
77
+ }, z.core.$strict>>;
78
+ }, z.core.$strict>;
79
+ /** One bundle's recorded weight. */
80
+ export type BaselineEntry = z.infer<typeof entrySchema>;
81
+ /** The whole committed file. */
82
+ export type InstructionBaseline = z.infer<typeof baselineSchema>;
83
+ /** The file's contents, parsed once at the boundary. */
84
+ export type ParsedBaseline = {
85
+ readonly kind: "ok";
86
+ readonly baseline: InstructionBaseline;
87
+ } | {
88
+ readonly kind: "invalid";
89
+ readonly message: string;
90
+ };
91
+ export declare function parseInstructionBaseline(text: string): ParsedBaseline;
92
+ /** What a measured weight would be recorded as: the SCORED files only. */
93
+ export declare function entryFor(weight: InstructionWeight): BaselineEntry;
94
+ /** One file whose recorded size differs. `null` = absent on that side. */
95
+ export interface FileChange {
96
+ readonly path: string;
97
+ readonly from: number | null;
98
+ readonly to: number | null;
99
+ }
100
+ /**
101
+ * The comparison of a tree against its baseline. Each case is a different fact,
102
+ * so each is its own member — "no baseline" is not "equal", and a baseline in
103
+ * another unit is not a number to subtract.
104
+ */
105
+ export type RatchetVerdict = {
106
+ readonly kind: "unrecorded";
107
+ /** What `--update-baseline` would write. */
108
+ readonly current: BaselineEntry;
109
+ } | {
110
+ readonly kind: "unit-changed";
111
+ readonly recorded: BaselineEntry["unit"];
112
+ readonly current: BaselineEntry["unit"];
113
+ } | {
114
+ readonly kind: "measure-changed";
115
+ readonly recorded: number;
116
+ readonly current: number;
117
+ } | {
118
+ readonly kind: "held";
119
+ readonly total: number;
120
+ readonly unit: BaselineEntry["unit"];
121
+ } | {
122
+ readonly kind: "grew" | "shrank";
123
+ readonly from: number;
124
+ readonly to: number;
125
+ readonly unit: BaselineEntry["unit"];
126
+ /** Every file whose size moved, largest movement first. */
127
+ readonly changes: readonly FileChange[];
128
+ };
129
+ export declare function compareToBaseline(weight: InstructionWeight, recorded: BaselineEntry | undefined): RatchetVerdict;
130
+ /** Replace the measured bundles' entries; `null` removes one. Others are kept. */
131
+ export declare function recordEntries(previous: InstructionBaseline | undefined, measured: readonly (readonly [string, BaselineEntry | null])[]): InstructionBaseline;
132
+ /** Stable text: sorted keys, two-space indent, trailing newline, no timestamp. */
133
+ export declare function serializeBaseline(baseline: InstructionBaseline): string;
134
+ /** The one-paragraph message a verdict prints as, prefixed by its bundle. */
135
+ export declare function formatVerdict(verdict: RatchetVerdict, at: string): string;
136
+ /** The compile-time one-liner: weight now, against the baseline when there is one. */
137
+ export declare function formatWeightLine(verdict: RatchetVerdict): string;
138
+ export {};
139
+ //# sourceMappingURL=instruction-baseline.d.ts.map
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INSTRUCTION_WEIGHT_MEASURE = exports.INSTRUCTION_BASELINE_VERSION = exports.UPDATE_BASELINE_COMMAND = exports.INSTRUCTION_BASELINE_FILE = void 0;
4
+ exports.parseInstructionBaseline = parseInstructionBaseline;
5
+ exports.entryFor = entryFor;
6
+ exports.compareToBaseline = compareToBaseline;
7
+ exports.recordEntries = recordEntries;
8
+ exports.serializeBaseline = serializeBaseline;
9
+ exports.formatVerdict = formatVerdict;
10
+ exports.formatWeightLine = formatWeightLine;
11
+ /**
12
+ * The instruction-weight RATCHET: the always-loaded weight may not grow past
13
+ * what the repository itself last recorded.
14
+ *
15
+ * WHY A RATCHET AND NOT THE BUDGET. `./instruction-weight.ts` explains why the
16
+ * harness's own threshold cannot gate: real repositories sit several times over
17
+ * it, and a rule that fails every repo on day one is switched off on day one.
18
+ * What CAN gate is the repository's own past. The baseline is whatever the repo
19
+ * weighed when it was last recorded, so a repo four times over budget starts
20
+ * green — and stays green only while it does not get heavier. The measured case
21
+ * this exists for: a root instruction file cut from ~4 100 lines to ~1 900, then
22
+ * regrown by ~1 000 lines over ten ordinary commits, with nothing in CI to say so.
23
+ *
24
+ * WHY THE SUM, AGAIN. The baseline records `committedTotal`, the same number the
25
+ * audit report scores: everything the harness loads without being asked that a
26
+ * teammate on the same commit also loads. A per-file baseline would read text
27
+ * moved into an always-loaded sibling as a shrink — the evasion the weight
28
+ * module was built to see through. The per-file sizes are recorded too, but only
29
+ * so a message can say WHICH file moved; the verdict is decided on the total.
30
+ *
31
+ * WHY A SHRINK IS A FINDING TOO. A baseline left above the tree is headroom, and
32
+ * headroom is exactly where the regrowth above landed: every character up to the
33
+ * old number would pass. So, like ESLint's unpruned suppressions and `betterer
34
+ * ci`, a tree LIGHTER than its baseline asks for the baseline to be lowered —
35
+ * one command, in the same change that made the cut. The baseline then always
36
+ * equals the tree on the default branch, and any growth at all is a diff a
37
+ * reviewer sees.
38
+ *
39
+ * WHO WRITES IT. Never `lint` on its own: a read never writes. The file is
40
+ * written only by the explicit `vigiles lint --update-baseline`, the same shape
41
+ * as `eslint --suppress-all` / `--prune-suppressions` and `jest -u`. Raising the
42
+ * baseline is allowed and is the point: growth is not forbidden, it is made
43
+ * visible as a reviewed diff of a committed file.
44
+ *
45
+ * Pure: parsing, comparison and formatting only. Reading and writing the file
46
+ * is the caller's (`src/instruction-weight-ratchet.ts`).
47
+ */
48
+ const zod_1 = require("zod");
49
+ /** Where the baseline lives, relative to the directory `.vigilesrc.json` is read from. */
50
+ exports.INSTRUCTION_BASELINE_FILE = ".vigiles/instruction-weight.json";
51
+ /** The command that writes the baseline — named once, quoted by every message. */
52
+ exports.UPDATE_BASELINE_COMMAND = "vigiles lint --update-baseline";
53
+ /** Bumped only on an incompatible change to the on-disk shape. */
54
+ exports.INSTRUCTION_BASELINE_VERSION = 1;
55
+ /**
56
+ * Which MEASUREMENT produced a recorded number — bumped whenever a release
57
+ * changes what counts (chain membership in any adapter, how a file is sized),
58
+ * even when the file shape stays the same.
59
+ *
60
+ * Without it, a vigiles fix that moves a file into or out of the chain moves
61
+ * `committedTotal` on every consumer with a committed baseline, and they see
62
+ * `grew`/`shrank` attributed to their own content. With it, they see one
63
+ * "re-record" verdict that names the cause. `version` cannot do this job: it
64
+ * describes the file, not the number in it.
65
+ */
66
+ exports.INSTRUCTION_WEIGHT_MEASURE = 1;
67
+ const entrySchema = zod_1.z
68
+ .object({
69
+ unit: zod_1.z.enum(["chars", "bytes"]),
70
+ measure: zod_1.z.number().int().nonnegative(),
71
+ total: zod_1.z.number().int().nonnegative(),
72
+ files: zod_1.z.record(zod_1.z.string(), zod_1.z.number().int().nonnegative()),
73
+ })
74
+ .strict()
75
+ .refine((e) => Object.values(e.files).reduce((a, b) => a + b, 0) === e.total, {
76
+ message: "`total` is not the sum of `files` — the file was edited by hand; re-record it",
77
+ });
78
+ const baselineSchema = zod_1.z
79
+ .object({
80
+ version: zod_1.z.literal(exports.INSTRUCTION_BASELINE_VERSION),
81
+ /** Keyed by bundle location from the config root; `.` is the root itself. */
82
+ bundles: zod_1.z.record(zod_1.z.string(), entrySchema),
83
+ })
84
+ .strict();
85
+ function parseInstructionBaseline(text) {
86
+ const json = (() => {
87
+ try {
88
+ return { ok: true, value: JSON.parse(text) };
89
+ }
90
+ catch {
91
+ return { ok: false };
92
+ }
93
+ })();
94
+ if (!json.ok)
95
+ return { kind: "invalid", message: "not valid JSON" };
96
+ const parsed = baselineSchema.safeParse(json.value);
97
+ return parsed.success
98
+ ? { kind: "ok", baseline: parsed.data }
99
+ : {
100
+ kind: "invalid",
101
+ message: parsed.error.issues
102
+ .map((i) => `${i.path.join(".") || "(top level)"}: ${i.message}`)
103
+ .join("; "),
104
+ };
105
+ }
106
+ /** What a measured weight would be recorded as: the SCORED files only. */
107
+ function entryFor(weight) {
108
+ const scored = weight.files.filter((f) => f.scope === "repo");
109
+ return {
110
+ unit: weight.unit,
111
+ measure: exports.INSTRUCTION_WEIGHT_MEASURE,
112
+ total: weight.committedTotal,
113
+ files: Object.fromEntries([...scored]
114
+ .sort((a, b) => a.path.localeCompare(b.path))
115
+ .map((f) => [f.path, f.size])),
116
+ };
117
+ }
118
+ function changesBetween(before, after) {
119
+ const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])];
120
+ const size = (m, p) => Object.hasOwn(m, p) ? m[p] : null;
121
+ return paths
122
+ .map((path) => ({ path, from: size(before, path), to: size(after, path) }))
123
+ .filter((c) => c.from !== c.to)
124
+ .sort((a, b) => Math.abs((b.to ?? 0) - (b.from ?? 0)) -
125
+ Math.abs((a.to ?? 0) - (a.from ?? 0)) || a.path.localeCompare(b.path));
126
+ }
127
+ function compareToBaseline(weight, recorded) {
128
+ const current = entryFor(weight);
129
+ if (recorded === undefined)
130
+ return { kind: "unrecorded", current };
131
+ if (recorded.unit !== current.unit) {
132
+ return {
133
+ kind: "unit-changed",
134
+ recorded: recorded.unit,
135
+ current: current.unit,
136
+ };
137
+ }
138
+ if (recorded.measure !== current.measure) {
139
+ return {
140
+ kind: "measure-changed",
141
+ recorded: recorded.measure,
142
+ current: current.measure,
143
+ };
144
+ }
145
+ if (current.total === recorded.total) {
146
+ return { kind: "held", total: current.total, unit: current.unit };
147
+ }
148
+ return {
149
+ kind: current.total > recorded.total ? "grew" : "shrank",
150
+ from: recorded.total,
151
+ to: current.total,
152
+ unit: current.unit,
153
+ changes: changesBetween(recorded.files, current.files),
154
+ };
155
+ }
156
+ /** Replace the measured bundles' entries; `null` removes one. Others are kept. */
157
+ function recordEntries(previous, measured) {
158
+ const removed = new Set(measured.filter(([, e]) => e === null).map(([at]) => at));
159
+ const kept = Object.entries(previous?.bundles ?? {}).filter(([at]) => !removed.has(at));
160
+ const fresh = measured.flatMap(([at, e]) => e === null ? [] : [[at, e]]);
161
+ return {
162
+ version: exports.INSTRUCTION_BASELINE_VERSION,
163
+ bundles: Object.fromEntries([...new Map([...kept, ...fresh]).entries()].sort(([a], [b]) => a.localeCompare(b))),
164
+ };
165
+ }
166
+ /** Stable text: sorted keys, two-space indent, trailing newline, no timestamp. */
167
+ function serializeBaseline(baseline) {
168
+ return `${JSON.stringify(baseline, null, 2)}\n`;
169
+ }
170
+ const n = (x) => x.toLocaleString("en-US");
171
+ const signed = (x) => (x > 0 ? `+${n(x)}` : n(x));
172
+ function describeChange(c) {
173
+ if (c.from === null)
174
+ return `${c.path} +${n(c.to ?? 0)} (new)`;
175
+ if (c.to === null)
176
+ return `${c.path} -${n(c.from)} (gone)`;
177
+ return `${c.path} ${signed(c.to - c.from)}`;
178
+ }
179
+ /** The one-paragraph message a verdict prints as, prefixed by its bundle. */
180
+ function formatVerdict(verdict, at) {
181
+ const where = at === "." ? "" : `${at}: `;
182
+ switch (verdict.kind) {
183
+ case "unrecorded":
184
+ return (`${where}no instruction-weight baseline recorded (${n(verdict.current.total)} ${verdict.current.unit} always loaded) — ` +
185
+ `run \`${exports.UPDATE_BASELINE_COMMAND}\` and commit ${exports.INSTRUCTION_BASELINE_FILE} to stop it growing unnoticed`);
186
+ case "unit-changed":
187
+ return (`${where}the instruction-weight baseline is in ${verdict.recorded} but this harness counts ${verdict.current} — ` +
188
+ `re-record it with \`${exports.UPDATE_BASELINE_COMMAND}\``);
189
+ case "measure-changed":
190
+ return (`${where}the instruction-weight baseline was recorded by measurement ${String(verdict.recorded)}, this vigiles uses ${String(verdict.current)} — ` +
191
+ `what counts changed in vigiles, not in your files. Re-record it with \`${exports.UPDATE_BASELINE_COMMAND}\``);
192
+ case "held":
193
+ return `${where}always-loaded instructions held at ${n(verdict.total)} ${verdict.unit} (baseline)`;
194
+ case "grew":
195
+ case "shrank": {
196
+ const head = `${n(verdict.from)} → ${n(verdict.to)} ${verdict.unit} (${signed(verdict.to - verdict.from)})`;
197
+ const moved = verdict.changes.length > 0
198
+ ? ` Changed: ${verdict.changes.map(describeChange).join(", ")}.`
199
+ : "";
200
+ return verdict.kind === "grew"
201
+ ? `${where}always-loaded instructions grew ${head} over the recorded baseline.${moved} ` +
202
+ `If the growth is intended, record it with \`${exports.UPDATE_BASELINE_COMMAND}\` — the diff of ${exports.INSTRUCTION_BASELINE_FILE} is the review.`
203
+ : `${where}always-loaded instructions shrank ${head}; the baseline still allows the old weight.${moved} ` +
204
+ `Lock the cut in with \`${exports.UPDATE_BASELINE_COMMAND}\`, or the headroom can be regrown without a finding.`;
205
+ }
206
+ }
207
+ }
208
+ /** The compile-time one-liner: weight now, against the baseline when there is one. */
209
+ function formatWeightLine(verdict) {
210
+ switch (verdict.kind) {
211
+ case "unrecorded":
212
+ return `always-loaded: ${n(verdict.current.total)} ${verdict.current.unit} (no baseline — \`${exports.UPDATE_BASELINE_COMMAND}\`)`;
213
+ case "unit-changed":
214
+ return `always-loaded: baseline is in ${verdict.recorded}, this harness counts ${verdict.current}`;
215
+ case "measure-changed":
216
+ return `always-loaded: baseline recorded by measurement ${String(verdict.recorded)}, this vigiles uses ${String(verdict.current)} — re-record`;
217
+ case "held":
218
+ return `always-loaded: ${n(verdict.total)} ${verdict.unit} (baseline ${n(verdict.total)}, ±0)`;
219
+ case "grew":
220
+ case "shrank":
221
+ return `always-loaded: ${n(verdict.to)} ${verdict.unit} (baseline ${n(verdict.from)}, ${signed(verdict.to - verdict.from)})`;
222
+ }
223
+ }
224
+ //# sourceMappingURL=instruction-baseline.js.map
@@ -28,12 +28,13 @@
28
28
  * number carries a different severity per harness, so the harness must supply
29
29
  * it — hence a port field, not a constant.
30
30
  *
31
- * NOT A GATE, AND THAT IS MEASURED. Both corpora this was built against sit at
32
- * roughly four times the Claude Code threshold. A rule that fails every real
33
- * repo on day one is switched off on day one (`lint-rule-calibration`: severity
34
- * tracks confidence, and a check nobody leaves on catches nothing). So the
35
- * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
36
- * exists that it would not immediately fail.
31
+ * THE BUDGET IS NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
32
+ * against sit at roughly four times the Claude Code threshold. A rule that
33
+ * fails every real repo on day one is switched off on day one
34
+ * (`lint-rule-calibration`: severity tracks confidence, and a check nobody
35
+ * leaves on catches nothing). So `audit` reports the budget, and what `lint`
36
+ * gates is the repo's OWN recorded weight (`./instruction-baseline.ts`): green
37
+ * on day one at any size, red the day it grows.
37
38
  */
38
39
  import type { InstructionChain, InstructionRole, InstructionScope } from "./instruction-chain.js";
39
40
  /** What the harness counts, and what it does when the count is exceeded. */
@@ -147,7 +148,16 @@ export interface InstructionWeight {
147
148
  readonly to: readonly string[];
148
149
  }[];
149
150
  }
150
- /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
151
+ /**
152
+ * Size in the harness's own unit. Bytes and chars differ on any non-ASCII text.
153
+ *
154
+ * Line endings are counted as `\n`. A checkout with `core.autocrlf` holds the
155
+ * same commit with one more character per line, and a number that differs
156
+ * between two clones of one commit cannot be committed and compared — a
157
+ * 1 000-line file read +1 000 on Windows with no content change. The harness
158
+ * may count the CR itself; reproducibility is worth more here than that
159
+ * fidelity.
160
+ */
151
161
  export declare function sizeIn(text: string, unit: "chars" | "bytes"): number;
152
162
  /**
153
163
  * Weigh a harness's LOADED chain against its own budget.
@@ -29,19 +29,30 @@
29
29
  * number carries a different severity per harness, so the harness must supply
30
30
  * it — hence a port field, not a constant.
31
31
  *
32
- * NOT A GATE, AND THAT IS MEASURED. Both corpora this was built against sit at
33
- * roughly four times the Claude Code threshold. A rule that fails every real
34
- * repo on day one is switched off on day one (`lint-rule-calibration`: severity
35
- * tracks confidence, and a check nobody leaves on catches nothing). So the
36
- * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
37
- * exists that it would not immediately fail.
32
+ * THE BUDGET IS NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
33
+ * against sit at roughly four times the Claude Code threshold. A rule that
34
+ * fails every real repo on day one is switched off on day one
35
+ * (`lint-rule-calibration`: severity tracks confidence, and a check nobody
36
+ * leaves on catches nothing). So `audit` reports the budget, and what `lint`
37
+ * gates is the repo's OWN recorded weight (`./instruction-baseline.ts`): green
38
+ * on day one at any size, red the day it grows.
38
39
  */
39
40
  Object.defineProperty(exports, "__esModule", { value: true });
40
41
  exports.sizeIn = sizeIn;
41
42
  exports.weighInstructions = weighInstructions;
42
- /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
43
+ /**
44
+ * Size in the harness's own unit. Bytes and chars differ on any non-ASCII text.
45
+ *
46
+ * Line endings are counted as `\n`. A checkout with `core.autocrlf` holds the
47
+ * same commit with one more character per line, and a number that differs
48
+ * between two clones of one commit cannot be committed and compared — a
49
+ * 1 000-line file read +1 000 on Windows with no content change. The harness
50
+ * may count the CR itself; reproducibility is worth more here than that
51
+ * fidelity.
52
+ */
43
53
  function sizeIn(text, unit) {
44
- return unit === "chars" ? text.length : Buffer.byteLength(text, "utf8");
54
+ const lf = text.replaceAll("\r\n", "\n");
55
+ return unit === "chars" ? lf.length : Buffer.byteLength(lf, "utf8");
45
56
  }
46
57
  /**
47
58
  * Weigh a harness's LOADED chain against its own budget.
@@ -107,10 +118,15 @@ function weighInstructions(chain, files, budget) {
107
118
  committedTotal,
108
119
  effectiveTotal: sum(weighed.filter((f) => f.notLoadedHere === undefined)),
109
120
  overBy: committedTotal > budget.limit ? committedTotal - budget.limit : null,
121
+ // NOT "absent from the map": a second-hop target can be in the map (the
122
+ // import pass read it because its importer was also a candidate) and still
123
+ // not be in the chain, because the second hop is named, never taken. What
124
+ // makes an import unread is that the chain did not classify its target.
110
125
  unreadImports: [
111
126
  ...new Set(chain.imports
112
127
  .map((i) => i.path)
113
- .filter((path) => files[path] === undefined)),
128
+ .filter((path) => !chain.loaded.some((e) => e.path === path) &&
129
+ !chain.unloaded.some((e) => e.path === path))),
114
130
  ].sort(),
115
131
  unweighedPatterns: [
116
132
  ...new Set(chain.patterns.map((p) => p.pattern)),
@@ -210,6 +210,14 @@ exports.RULE_META = {
210
210
  summary: "Two model-invocable skills aren't near-identical (wrong one fires).",
211
211
  detector: "findDescriptionOverlaps",
212
212
  },
213
+ "instruction-weight": {
214
+ id: "instruction-weight",
215
+ bucket: "external-decidable",
216
+ surface: ["instruction"],
217
+ defaultSeverity: "error",
218
+ summary: "The always-loaded instruction weight did not move off its committed baseline.",
219
+ detector: "compareToBaseline",
220
+ },
213
221
  "skill-description-budget": {
214
222
  id: "skill-description-budget",
215
223
  bucket: "heuristic-behavioral",
@@ -122,6 +122,14 @@ export interface RulesConfig {
122
122
  * exited 0 (#173). Default: "error", matching `compile`.
123
123
  */
124
124
  "spec-refs"?: RuleSeverity;
125
+ /**
126
+ * The always-loaded instruction weight may not move away from the committed
127
+ * baseline in `.vigiles/instruction-weight.json` — a ratchet against the
128
+ * repo's own past, not a fixed budget. No baseline file → a one-line note,
129
+ * never a finding, so turning this on fails no repository on day one.
130
+ * `vigiles lint --update-baseline` records it. Default: "error".
131
+ */
132
+ "instruction-weight"?: RuleSeverity;
125
133
  /**
126
134
  * Near-duplicate rules WITHIN one spec, by NCD similarity — spec bloat, two
127
135
  * rules saying the same thing in different words.
@@ -0,0 +1,53 @@
1
+ import { type InstructionBaseline, type ParsedBaseline } from "./core/instruction-baseline.js";
2
+ import type { InstructionWeight } from "./core/instruction-weight.js";
3
+ import type { RuleSeverity } from "./core/types.js";
4
+ /** The file on disk, read once: absent, parsed, or refused. */
5
+ export type BaselineOnDisk = {
6
+ readonly kind: "absent";
7
+ } | ParsedBaseline;
8
+ export declare function readInstructionBaseline(root: string): BaselineOnDisk;
9
+ /** One bundle's measurement: where it is, and its weight (`null` = nothing to weigh). */
10
+ export type MeasuredBundle = readonly [
11
+ at: string,
12
+ weight: InstructionWeight | null
13
+ ];
14
+ /** Write the baseline for every measured bundle; returns what was written. */
15
+ export declare function writeInstructionBaseline(root: string, onDisk: BaselineOnDisk, measured: readonly MeasuredBundle[]): InstructionBaseline;
16
+ /**
17
+ * One line of lint output. `finding` counts toward the exit code; `ok` is the
18
+ * baseline holding; `note` is the opt-in nudge on a repo with no baseline.
19
+ */
20
+ export interface RatchetLine {
21
+ readonly at: string;
22
+ readonly status: "finding" | "ok" | "note";
23
+ readonly text: string;
24
+ }
25
+ /**
26
+ * Pure: the lines a run prints, given what was measured and what is on disk.
27
+ *
28
+ * A bundle with no entry is a nudge while the repo has NO baseline file (it has
29
+ * not opted in, and a fresh repo must not fail on day one) — but a FINDING once
30
+ * the file exists: a bundle that appeared after the baseline was recorded is
31
+ * weight nobody signed off on.
32
+ */
33
+ export declare function ratchetLines(onDisk: BaselineOnDisk, measured: readonly MeasuredBundle[]): readonly RatchetLine[];
34
+ /** Run the ratchet for `lint`: print, annotate, count. */
35
+ export declare function checkInstructionWeightRatchet(opts: {
36
+ readonly root: string;
37
+ readonly severity: RuleSeverity;
38
+ readonly update: boolean;
39
+ readonly silent: boolean;
40
+ /** Called only when the rule is on or an update was asked for. */
41
+ readonly measure: () => readonly MeasuredBundle[];
42
+ readonly annotate: (level: "error" | "warning", message: string) => void;
43
+ }): {
44
+ issues: number;
45
+ errors: number;
46
+ };
47
+ /**
48
+ * The line `compile` prints after writing instruction files: the weight NOW,
49
+ * against the baseline, at the moment the author changed it. Report-only —
50
+ * `lint` stays the single gate. `null` when there is nothing to weigh.
51
+ */
52
+ export declare function compileWeightLine(root: string, weight: InstructionWeight | null): string | null;
53
+ //# sourceMappingURL=instruction-weight-ratchet.d.ts.map
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readInstructionBaseline = readInstructionBaseline;
4
+ exports.writeInstructionBaseline = writeInstructionBaseline;
5
+ exports.ratchetLines = ratchetLines;
6
+ exports.checkInstructionWeightRatchet = checkInstructionWeightRatchet;
7
+ exports.compileWeightLine = compileWeightLine;
8
+ /**
9
+ * `lint`'s side of the instruction-weight ratchet: measure each scored bundle,
10
+ * compare it with `.vigiles/instruction-weight.json`, and — only under the
11
+ * explicit `--update-baseline` — write that file.
12
+ *
13
+ * The decisions (what a verdict is, what it says) live in
14
+ * `core/instruction-baseline.ts`; this module owns the two effects the core
15
+ * must not have, reading and writing the committed file, plus turning verdicts
16
+ * into lint counters.
17
+ *
18
+ * WHY THE FILE IS COMMITTED, unlike `.vigiles/coverage.json`. Coverage records
19
+ * what tests ran on THIS machine, so a committed copy would credit a checkout
20
+ * where nothing ran. The baseline records a property of the COMMIT: it is built
21
+ * from `committedTotal`, which already leaves out every per-machine file
22
+ * (`CLAUDE.local.md`, a gitignored settings sibling), so two clones of one
23
+ * commit compute byte-identical files. That is what lets CI compare against it.
24
+ */
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = require("node:path");
27
+ const instruction_baseline_js_1 = require("./core/instruction-baseline.js");
28
+ function readInstructionBaseline(root) {
29
+ const path = (0, node_path_1.join)(root, instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE);
30
+ return (0, node_fs_1.existsSync)(path)
31
+ ? (0, instruction_baseline_js_1.parseInstructionBaseline)((0, node_fs_1.readFileSync)(path, "utf-8"))
32
+ : { kind: "absent" };
33
+ }
34
+ /** Write the baseline for every measured bundle; returns what was written. */
35
+ function writeInstructionBaseline(root, onDisk, measured) {
36
+ const next = (0, instruction_baseline_js_1.recordEntries)(onDisk.kind === "ok" ? onDisk.baseline : undefined, measured.map(([at, w]) => [at, w === null ? null : (0, instruction_baseline_js_1.entryFor)(w)]));
37
+ const path = (0, node_path_1.join)(root, instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE);
38
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
39
+ (0, node_fs_1.writeFileSync)(path, (0, instruction_baseline_js_1.serializeBaseline)(next));
40
+ return next;
41
+ }
42
+ /**
43
+ * Pure: the lines a run prints, given what was measured and what is on disk.
44
+ *
45
+ * A bundle with no entry is a nudge while the repo has NO baseline file (it has
46
+ * not opted in, and a fresh repo must not fail on day one) — but a FINDING once
47
+ * the file exists: a bundle that appeared after the baseline was recorded is
48
+ * weight nobody signed off on.
49
+ */
50
+ function ratchetLines(onDisk, measured) {
51
+ if (onDisk.kind === "invalid") {
52
+ return [
53
+ {
54
+ at: ".",
55
+ status: "finding",
56
+ text: `${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE} cannot be read (${onDisk.message}) — ` +
57
+ `re-record it with \`${instruction_baseline_js_1.UPDATE_BASELINE_COMMAND}\``,
58
+ },
59
+ ];
60
+ }
61
+ const recorded = onDisk.kind === "ok" ? onDisk.baseline.bundles : {};
62
+ return measured.flatMap(([at, weight]) => {
63
+ if (weight === null)
64
+ return [];
65
+ const verdict = (0, instruction_baseline_js_1.compareToBaseline)(weight, Object.hasOwn(recorded, at) ? recorded[at] : undefined);
66
+ const status = verdict.kind === "held"
67
+ ? "ok"
68
+ : verdict.kind === "unrecorded" && onDisk.kind !== "ok"
69
+ ? "note"
70
+ : "finding";
71
+ // An import named and not read is weight this number lacks — a second
72
+ // hop, or a path that does not resolve. Said on the line itself, because
73
+ // the gate holding is only as true as the sum it holds.
74
+ const unfollowed = weight.unreadImports.length > 0
75
+ ? ` (${String(weight.unreadImports.length)} import(s) not followed: ${weight.unreadImports.join(", ")} — their size is not in this number)`
76
+ : "";
77
+ return [{ at, status, text: `${(0, instruction_baseline_js_1.formatVerdict)(verdict, at)}${unfollowed}` }];
78
+ });
79
+ }
80
+ /** Run the ratchet for `lint`: print, annotate, count. */
81
+ function checkInstructionWeightRatchet(opts) {
82
+ const { severity, update, silent } = opts;
83
+ if (!severity && !update)
84
+ return { issues: 0, errors: 0 };
85
+ const measured = opts.measure();
86
+ const before = readInstructionBaseline(opts.root);
87
+ const onDisk = update
88
+ ? {
89
+ kind: "ok",
90
+ baseline: writeInstructionBaseline(opts.root, before, measured),
91
+ }
92
+ : before;
93
+ const lines = severity ? ratchetLines(onDisk, measured) : [];
94
+ const findings = lines.filter((l) => l.status === "finding");
95
+ if (!silent && (update || lines.length > 0)) {
96
+ console.log("\nInstruction-weight ratchet:\n");
97
+ if (update)
98
+ console.log(` ✓ recorded ${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE}`);
99
+ const marks = {
100
+ finding: severity === "error" ? "✗" : "⚠",
101
+ ok: "✓",
102
+ note: "ℹ",
103
+ };
104
+ lines.forEach((l) => {
105
+ console.log(` ${marks[l.status]} ${l.text}`);
106
+ });
107
+ }
108
+ const level = severity === "error" ? "error" : "warning";
109
+ findings.forEach((l) => {
110
+ opts.annotate(level, l.text);
111
+ });
112
+ return {
113
+ issues: findings.length,
114
+ errors: severity === "error" ? findings.length : 0,
115
+ };
116
+ }
117
+ /**
118
+ * The line `compile` prints after writing instruction files: the weight NOW,
119
+ * against the baseline, at the moment the author changed it. Report-only —
120
+ * `lint` stays the single gate. `null` when there is nothing to weigh.
121
+ */
122
+ function compileWeightLine(root, weight) {
123
+ if (weight === null)
124
+ return null;
125
+ const onDisk = readInstructionBaseline(root);
126
+ if (onDisk.kind === "invalid") {
127
+ return `always-loaded: ${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE} cannot be read (${onDisk.message})`;
128
+ }
129
+ const recorded = onDisk.kind === "ok" ? onDisk.baseline.bundles : {};
130
+ return (0, instruction_baseline_js_1.formatWeightLine)((0, instruction_baseline_js_1.compareToBaseline)(weight, Object.hasOwn(recorded, ".") ? recorded["."] : undefined));
131
+ }
132
+ //# sourceMappingURL=instruction-weight-ratchet.js.map
@@ -107,6 +107,7 @@ exports.COMMITTED_PATHS = [
107
107
  "schema.json", // YAML-LSP frontmatter schema (`vigiles init`)
108
108
  "guards.json", // the declared guard set (`core/guards.ts`)
109
109
  "action-gates.json", // declared action gates (`action-gate.ts`)
110
+ "instruction-weight.json", // the instruction-weight ratchet's baseline (`core/instruction-baseline.ts`)
110
111
  ];
111
112
  /** The header comment on a `.vigiles/.gitignore` vigiles creates. */
112
113
  /** The ignore file vigiles keeps inside `.vigiles/`. One spelling for every reader. */
package/dist/scan.d.ts CHANGED
@@ -411,6 +411,16 @@ export interface ScanHarness {
411
411
  readonly dialect: HarnessDialect;
412
412
  readonly roots: readonly string[];
413
413
  }
414
+ /**
415
+ * The always-loaded instruction weight of ONE directory under ONE harness —
416
+ * the bounded read, the harness's chain, and the sum, in one place.
417
+ *
418
+ * `scanPlugin` reports it, `lint`'s instruction-weight ratchet gates on it and
419
+ * `compile` prints it, so all three must arrive at the same number by the same
420
+ * route; this function is that route. `null` when the harness publishes no
421
+ * budget (there is no unit to count in).
422
+ */
423
+ export declare function measureInstructionWeight(dir: string, harness: Pick<ScanHarness, "layout" | "dialect">): InstructionWeight | null;
414
424
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
415
425
  export declare function scanPlugin(dir: string, layout: PluginLayout, dialect: HarnessDialect, opts?: {
416
426
  sharedDirs?: readonly string[];
package/dist/scan.js CHANGED
@@ -27,6 +27,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
27
27
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
28
28
  };
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.measureInstructionWeight = measureInstructionWeight;
30
31
  exports.scanPlugin = scanPlugin;
31
32
  exports.verifyLiveMcpTools = verifyLiveMcpTools;
32
33
  exports.formatMcpContractReport = formatMcpContractReport;
@@ -153,6 +154,25 @@ function ownTestSignalOnDisk(dir) {
153
154
  existsSync: node_fs_1.existsSync,
154
155
  });
155
156
  }
157
+ /**
158
+ * The always-loaded instruction weight of ONE directory under ONE harness —
159
+ * the bounded read, the harness's chain, and the sum, in one place.
160
+ *
161
+ * `scanPlugin` reports it, `lint`'s instruction-weight ratchet gates on it and
162
+ * `compile` prints it, so all three must arrive at the same number by the same
163
+ * route; this function is that route. `null` when the harness publishes no
164
+ * budget (there is no unit to count in).
165
+ */
166
+ function measureInstructionWeight(dir, harness) {
167
+ // No `exclude` parameter, on purpose — see the note in `scanPlugin`.
168
+ return weighBoundedInstructions(harness, (0, surface_discovery_fs_js_1.boundedInstructionFiles)((0, node_path_1.resolve)(dir), harness.layout));
169
+ }
170
+ function weighBoundedInstructions(harness, instructionFiles) {
171
+ const budget = harness.dialect.instructionBudget;
172
+ return budget
173
+ ? (0, instruction_weight_js_1.weighInstructions)(harness.layout.instructionChain(instructionFiles), instructionFiles, budget)
174
+ : null;
175
+ }
156
176
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
157
177
  function scanPlugin(dir, layout, dialect, opts = {}) {
158
178
  const lay = layout;
@@ -195,7 +215,15 @@ function scanPlugin(dir, layout, dialect, opts = {}) {
195
215
  // expanded an ADAPTER's globs by walking the whole tree; the bound and the
196
216
  // classification are now separate jobs held by separate modules, and only the
197
217
  // second is the adapter's. See `core/instruction-chain.ts`.
198
- const instructionFiles = (0, surface_discovery_fs_js_1.boundedInstructionFiles)((0, node_path_1.resolve)(dir), instructionHarness.layout, opts.excludes);
218
+ //
219
+ // 🔴 `exclude` DOES NOT REACH IT. `exclude` means "vigiles does not lint
220
+ // this"; the harness still loads the file, so hiding it from the weight
221
+ // under-reports — and since the weight is a committed baseline, one more
222
+ // `exclude` line would silently lower the ratchet. The harness's own
223
+ // `claudeMdExcludes` is the channel that removes a file, because it is also
224
+ // what stops the harness loading it. The browser twin never applied `exclude`
225
+ // here either, so this is also where the two engines agree.
226
+ const instructionFiles = (0, surface_discovery_fs_js_1.boundedInstructionFiles)((0, node_path_1.resolve)(dir), instructionHarness.layout);
199
227
  const instructions = loaded.files[instructionFile] !== undefined
200
228
  ? {
201
229
  file: instructionFile,
@@ -321,9 +349,7 @@ function scanPlugin(dir, layout, dialect, opts = {}) {
321
349
  // unit (Claude Code counts 40 000 chars, Codex 32 768 bytes), so there is no
322
350
  // meaningful sum across two; reporting the one that owns the file that
323
351
  // exists is the only reading that is true of something.
324
- instructionWeight: instructionHarness.dialect.instructionBudget
325
- ? (0, instruction_weight_js_1.weighInstructions)(instructionHarness.layout.instructionChain(instructionFiles), instructionFiles, instructionHarness.dialect.instructionBudget)
326
- : null,
352
+ instructionWeight: weighBoundedInstructions(instructionHarness, instructionFiles),
327
353
  untested: coverage.untested.length,
328
354
  untestedHarness: coverage.harness.untested.length,
329
355
  unevaluated: coverage.evals.untested.length,
@@ -1519,6 +1519,42 @@
1519
1519
  "const": true
1520
1520
  }
1521
1521
  ]
1522
+ },
1523
+ "instruction-weight": {
1524
+ "anyOf": [
1525
+ {
1526
+ "type": "string",
1527
+ "const": "warn"
1528
+ },
1529
+ {
1530
+ "type": "string",
1531
+ "const": "error"
1532
+ },
1533
+ {
1534
+ "type": "boolean",
1535
+ "const": false
1536
+ },
1537
+ {
1538
+ "type": "string",
1539
+ "const": "off"
1540
+ },
1541
+ {
1542
+ "type": "number",
1543
+ "const": 0
1544
+ },
1545
+ {
1546
+ "type": "number",
1547
+ "const": 1
1548
+ },
1549
+ {
1550
+ "type": "number",
1551
+ "const": 2
1552
+ },
1553
+ {
1554
+ "type": "boolean",
1555
+ "const": true
1556
+ }
1557
+ ]
1522
1558
  }
1523
1559
  },
1524
1560
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "32.0.1",
3
+ "version": "32.1.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",