vigiles 32.0.2 → 32.1.1

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");
@@ -68,6 +69,7 @@ const action_gate_js_1 = require("./action-gate.js");
68
69
  const guards_js_1 = require("./core/guards.js");
69
70
  const hook_program_js_1 = require("./core/hook-program.js");
70
71
  const hook_install_js_1 = require("./hook-install.js");
72
+ const hook_check_install_js_1 = require("./hook-check-install.js");
71
73
  const hook_providers_js_1 = require("./core/hook-providers.js");
72
74
  const toml_1 = require("@iarna/toml");
73
75
  // The named-state STORE. Lifted out of this file so a TEST can seed a fact
@@ -978,6 +980,7 @@ function lintExitCode(report) {
978
980
  report.delegationTrifectaErrors > 0 ||
979
981
  report.hookBlockErrors > 0 ||
980
982
  report.hookMatcherErrors > 0 ||
983
+ report.instructionWeightErrors > 0 ||
981
984
  report.symbolRefErrors > 0 ||
982
985
  report.mcpRefErrors > 0 ||
983
986
  // `doc-refs` is opt-in and this counter is only non-zero when the user set
@@ -1675,6 +1678,22 @@ async function runLint(restArgs, flags, excludes, config) {
1675
1678
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1676
1679
  // server naming, or an undeclared MCP server).
1677
1680
  const hookMatcher = overBundles(checkHookMatcher, run, lintRoots);
1681
+ // 7v. Instruction-weight ratchet — the always-loaded instruction weight may
1682
+ // not move off the committed baseline. One file for the whole run (bundles
1683
+ // are keys inside it), so it runs ONCE over `lintRoots`, not per bundle.
1684
+ const instructionWeight = (0, instruction_weight_ratchet_js_1.checkInstructionWeightRatchet)({
1685
+ root: frame.root,
1686
+ severity: (0, types_js_1.ruleSeverity)(config?.rules?.["instruction-weight"]),
1687
+ update: flags.includes("--update-baseline"),
1688
+ silent,
1689
+ measure: () => lintRoots.map((abs) => [
1690
+ frame.bundle(abs).at,
1691
+ (0, scan_js_1.measureInstructionWeight)(abs, adapter),
1692
+ ]),
1693
+ annotate: (level, message) => {
1694
+ ghAnnotate(level, message);
1695
+ },
1696
+ });
1678
1697
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1679
1698
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1680
1699
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1803,6 +1822,8 @@ async function runLint(restArgs, flags, excludes, config) {
1803
1822
  hookBlockErrors: hookBlock.errors,
1804
1823
  hookMatcherIssues: hookMatcher.issues,
1805
1824
  hookMatcherErrors: hookMatcher.errors,
1825
+ instructionWeightIssues: instructionWeight.issues,
1826
+ instructionWeightErrors: instructionWeight.errors,
1806
1827
  // Only the "error" tier gates. At "warn" the findings are printed and
1807
1828
  // annotated, and the exit code is untouched — same contract as every other
1808
1829
  // opt-in rule here.
@@ -5997,12 +6018,11 @@ async function installHookFile(file, adapter, registeredProviders = []) {
5997
6018
  // cannot load must block — the repo seized, every command refused including the repair.
5998
6019
  //
5999
6020
  // 🔴 AND LAUNCHED LOCALLY, NOT THROUGH `npx` — 193 ms against 2545 ms on a warm
6000
- // cache, thirteen times, on every tool call. The trailing `|| exit N` is what the
6001
- // shell does when that binary cannot start at all, and N is decided by the hook's
6002
- // ROLE: see `hookRuntimeRef` and `hookRuntimeMissingExit` for both measurements
6003
- // and for why a gate and a nudge must answer differently.
6021
+ // cache, thirteen times, on every tool call. The `|| exit N` ending is what
6022
+ // the shell does when that file cannot start at all. `compileHookProgram`
6023
+ // adds it (see `hookRuntimeMissingExit`), so it is not written here.
6004
6024
  gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
6005
- `${(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)} || exit ${(0, hook_install_js_1.hookRuntimeMissingExit)((0, hook_program_js_1.dispatchKind)(program))}`,
6025
+ (0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens),
6006
6026
  dialect: adapter.dialect,
6007
6027
  hookProtocol: adapter.hookProtocol,
6008
6028
  settings: adapter.layout.settings,
@@ -6151,6 +6171,7 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
6151
6171
  }
6152
6172
  }
6153
6173
  }
6174
+ (0, hook_check_install_js_1.installHookCheck)(adapters);
6154
6175
  return ok;
6155
6176
  }
6156
6177
  /**
@@ -6636,6 +6657,18 @@ async function main() {
6636
6657
  if (specs.length > 0)
6637
6658
  valid =
6638
6659
  (await compile(specs, config, excludes, { harnessFlag })) && valid;
6660
+ // The always-loaded weight the author just produced, against the
6661
+ // committed baseline — shown at the moment of change. Report-only:
6662
+ // `lint` is the gate (rule `instruction-weight`).
6663
+ if (specs.length > 0) {
6664
+ const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(process.cwd(), (0, scan_js_1.measureInstructionWeight)(process.cwd(), (0, adapter_registry_js_1.resolveHarnessSelection)({
6665
+ root: process.cwd(),
6666
+ flag: harnessFlag,
6667
+ configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
6668
+ }).adapter));
6669
+ if (weightLine !== null)
6670
+ console.log(`\n${weightLine}`);
6671
+ }
6639
6672
  valid =
6640
6673
  (await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses))) && valid;
6641
6674
  // 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. */
@@ -408,7 +408,11 @@ export interface CompiledHookProgram {
408
408
  * core depends only on these interfaces, never an adapter (core ⊄ adapter).
409
409
  */
410
410
  export interface CompileHookOptions {
411
- /** The command the emitted block routes the event to. */
411
+ /**
412
+ * The command the emitted block runs. The compiler adds `|| exit 2` (a hook
413
+ * that can block) or `|| exit 0` (a reminder-only hook) at the end; one that is
414
+ * already there is replaced.
415
+ */
412
416
  readonly gateCommand?: string;
413
417
  /** Validate `hook.on` against this harness's hook-event catalog (a typo won't compile). */
414
418
  readonly dialect?: HarnessDialect;
@@ -451,6 +455,45 @@ export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | Pro
451
455
  */
452
456
  export type DispatchKind = "bash-gate" | "file-gate" | "prompt-gate" | "stop-gate" | "inject" | "react";
453
457
  export declare function dispatchKind(hook: AnyHook): DispatchKind;
458
+ /**
459
+ * What the shell should do when a hook's command fails before the hook gives an
460
+ * answer: the runtime is not installed, or it starts but cannot load the hook.
461
+ * No hook code ran, so the hook cannot be asked. The answer has to be written
462
+ * into the command itself, and this function is the one place that decides it.
463
+ *
464
+ * There are two kinds of hook and they need different answers:
465
+ *
466
+ * - A gate (it can block a tool call) must exit 2. A gate that cannot run must
467
+ * not let calls through: a protection that silently stops working is worse
468
+ * than one that is known to be missing.
469
+ * - A nudge (it only adds a reminder: an inject or a react) must exit 0. If it
470
+ * blocked, one missing file could stop the whole session. This happened on
471
+ * 2026-08-10: merge-conflict markers in `package.json` stopped every hook from
472
+ * loading, the Bash gate then refused `git merge --abort` (the command that
473
+ * undoes the damage), and the session could not be repaired from inside.
474
+ *
475
+ * The kind is fixed by the type the author picked: a react hook has no `deny`,
476
+ * and an inject hook only returns text.
477
+ *
478
+ * Only this function decides. The runtime used to guess a second time, from the
479
+ * hook's file name ("inject" in the name meant nudge), and the two guesses
480
+ * disagreed (#312). Now the runtime always exits 2 when a hook cannot load, and
481
+ * the `|| exit N` ending (see {@link withRoleExit}) is the only thing that turns
482
+ * that 2 into a 0. It is shell, so a different runtime version cannot change it.
483
+ *
484
+ * This does not rescue a gate that cannot load: it still refuses every command,
485
+ * including the install that would fix it. It only stops a nudge doing the same.
486
+ *
487
+ * (Other tools disagree on this too: husky and lefthook skip, pre-commit fails.)
488
+ */
489
+ export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2;
490
+ /**
491
+ * `command` with `|| exit 0` or `|| exit 2` (chosen by the kind of hook) as its
492
+ * last words. A `|| exit N` already there is replaced, not added to: the shell
493
+ * stops at the first `exit` it reaches, so a nudge wired `|| exit 2 || exit 0`
494
+ * would still block. Applying it twice gives the same result as applying it once.
495
+ */
496
+ export declare function withRoleExit(command: string, kind: DispatchKind): string;
454
497
  /** A gate's {@link HookMode} (`enforce` default); non-gate roles report `enforce` too. */
455
498
  export declare function hookMode(hook: AnyHook): HookMode;
456
499
  /** The context providers a hook declared via `needs` ([] if none / not a gate). */
@@ -1109,6 +1152,29 @@ export declare function isStampRepairEvent(event: RawHookEvent, hookFile: string
1109
1152
  * loads, the gate decides normally and `git merge --abort` is an ordinary allowed
1110
1153
  * command — through the gate, not around it.
1111
1154
  *
1155
+ * ## Why running an install is not allowed either (#312, considered and rejected)
1156
+ *
1157
+ * The usual reason a hook cannot load is that nothing is installed yet (a fresh
1158
+ * container, or a merge that brought the wiring but not the package). The cure
1159
+ * is `npm ci`, which is exactly what the gate refuses. Letting a lockfile
1160
+ * install through was proposed and rejected, for four reasons:
1161
+ *
1162
+ * - Like `git merge --abort` and `vigiles compile` above, it would be allowed for
1163
+ * what it is meant to do, not for what it can do. An install runs the
1164
+ * lifecycle scripts of every package in the lockfile, which is more than the
1165
+ * `.git/hooks/*` scripts that got the git commands removed.
1166
+ * - "The gate is not running anyway" would equally justify allowing `ls`. It
1167
+ * does not single out `npm ci`.
1168
+ * - On a fresh container the runtime itself is missing, so none of this code
1169
+ * runs; the command's own `|| exit 2` decides.
1170
+ * - `npm ci` fails when the lockfile is behind `package.json`. Fixing that needs
1171
+ * plain `npm install`, which fetches versions the repo has not pinned.
1172
+ *
1173
+ * So the way out stays a file write. What is left over is described in
1174
+ * `docs/compiled-hooks.md` ("When a hook cannot load"). Removing the cause would
1175
+ * need hook files that live in the same git commit as their wiring; that is a
1176
+ * separate decision (#312).
1177
+ *
1112
1178
  * ## What is accepted
1113
1179
  *
1114
1180
  * A write to the hook's own source, its stamp sidecar, or one of
@@ -12,6 +12,8 @@ exports.decideProgram = decideProgram;
12
12
  exports.decisionExitCode = decisionExitCode;
13
13
  exports.checkHookImports = checkHookImports;
14
14
  exports.dispatchKind = dispatchKind;
15
+ exports.hookRuntimeMissingExit = hookRuntimeMissingExit;
16
+ exports.withRoleExit = withRoleExit;
15
17
  exports.hookMode = hookMode;
16
18
  exports.hookNeeds = hookNeeds;
17
19
  exports.hookRouting = hookRouting;
@@ -686,6 +688,51 @@ function dispatchKind(hook) {
686
688
  return hook.role === "gate" ? "file-gate" : hook.role;
687
689
  return "bash-gate";
688
690
  }
691
+ /**
692
+ * What the shell should do when a hook's command fails before the hook gives an
693
+ * answer: the runtime is not installed, or it starts but cannot load the hook.
694
+ * No hook code ran, so the hook cannot be asked. The answer has to be written
695
+ * into the command itself, and this function is the one place that decides it.
696
+ *
697
+ * There are two kinds of hook and they need different answers:
698
+ *
699
+ * - A gate (it can block a tool call) must exit 2. A gate that cannot run must
700
+ * not let calls through: a protection that silently stops working is worse
701
+ * than one that is known to be missing.
702
+ * - A nudge (it only adds a reminder: an inject or a react) must exit 0. If it
703
+ * blocked, one missing file could stop the whole session. This happened on
704
+ * 2026-08-10: merge-conflict markers in `package.json` stopped every hook from
705
+ * loading, the Bash gate then refused `git merge --abort` (the command that
706
+ * undoes the damage), and the session could not be repaired from inside.
707
+ *
708
+ * The kind is fixed by the type the author picked: a react hook has no `deny`,
709
+ * and an inject hook only returns text.
710
+ *
711
+ * Only this function decides. The runtime used to guess a second time, from the
712
+ * hook's file name ("inject" in the name meant nudge), and the two guesses
713
+ * disagreed (#312). Now the runtime always exits 2 when a hook cannot load, and
714
+ * the `|| exit N` ending (see {@link withRoleExit}) is the only thing that turns
715
+ * that 2 into a 0. It is shell, so a different runtime version cannot change it.
716
+ *
717
+ * This does not rescue a gate that cannot load: it still refuses every command,
718
+ * including the install that would fix it. It only stops a nudge doing the same.
719
+ *
720
+ * (Other tools disagree on this too: husky and lefthook skip, pre-commit fails.)
721
+ */
722
+ function hookRuntimeMissingExit(kind) {
723
+ return kind === "inject" || kind === "react" ? 0 : 2;
724
+ }
725
+ /** A trailing `|| exit <number>`, the only ending this module writes. */
726
+ const ROLE_EXIT_SUFFIX = /\s*\|\|\s*exit\s+\d+\s*$/;
727
+ /**
728
+ * `command` with `|| exit 0` or `|| exit 2` (chosen by the kind of hook) as its
729
+ * last words. A `|| exit N` already there is replaced, not added to: the shell
730
+ * stops at the first `exit` it reaches, so a nudge wired `|| exit 2 || exit 0`
731
+ * would still block. Applying it twice gives the same result as applying it once.
732
+ */
733
+ function withRoleExit(command, kind) {
734
+ return `${command.replace(ROLE_EXIT_SUFFIX, "")} || exit ${hookRuntimeMissingExit(kind)}`;
735
+ }
689
736
  /** A gate's {@link HookMode} (`enforce` default); non-gate roles report `enforce` too. */
690
737
  function hookMode(hook) {
691
738
  return "mode" in hook && hook.mode ? hook.mode : "enforce";
@@ -942,7 +989,9 @@ function compileHookProgram(source, hook, opts = {}) {
942
989
  .map((u) => `${u.name} ("${u.run}")`)
943
990
  .join(", ")} — use dangerously(name, cmd) to acknowledge a side-effecting/undecidable command, or keep provide() only for a read-only one.`);
944
991
  }
945
- const gateCommand = opts.gateCommand ?? "npx vigiles hook-runtime run-program";
992
+ // The `|| exit N` ending is added here, not by the caller, so every caller gets
993
+ // the right one and none can forget it (#312).
994
+ const gateCommand = withRoleExit(opts.gateCommand ?? "npx vigiles hook-runtime run-program", dispatchKind(hook));
946
995
  const matcher = styleMatcher(rawMatcher, opts.hookProtocol);
947
996
  const entry = matcher === undefined
948
997
  ? { hooks: [{ type: "command", command: gateCommand }] }
@@ -1759,6 +1808,29 @@ function isStampRepairEvent(event, hookFile, repoRoot) {
1759
1808
  * loads, the gate decides normally and `git merge --abort` is an ordinary allowed
1760
1809
  * command — through the gate, not around it.
1761
1810
  *
1811
+ * ## Why running an install is not allowed either (#312, considered and rejected)
1812
+ *
1813
+ * The usual reason a hook cannot load is that nothing is installed yet (a fresh
1814
+ * container, or a merge that brought the wiring but not the package). The cure
1815
+ * is `npm ci`, which is exactly what the gate refuses. Letting a lockfile
1816
+ * install through was proposed and rejected, for four reasons:
1817
+ *
1818
+ * - Like `git merge --abort` and `vigiles compile` above, it would be allowed for
1819
+ * what it is meant to do, not for what it can do. An install runs the
1820
+ * lifecycle scripts of every package in the lockfile, which is more than the
1821
+ * `.git/hooks/*` scripts that got the git commands removed.
1822
+ * - "The gate is not running anyway" would equally justify allowing `ls`. It
1823
+ * does not single out `npm ci`.
1824
+ * - On a fresh container the runtime itself is missing, so none of this code
1825
+ * runs; the command's own `|| exit 2` decides.
1826
+ * - `npm ci` fails when the lockfile is behind `package.json`. Fixing that needs
1827
+ * plain `npm install`, which fetches versions the repo has not pinned.
1828
+ *
1829
+ * So the way out stays a file write. What is left over is described in
1830
+ * `docs/compiled-hooks.md` ("When a hook cannot load"). Removing the cause would
1831
+ * need hook files that live in the same git commit as their wiring; that is a
1832
+ * separate decision (#312).
1833
+ *
1762
1834
  * ## What is accepted
1763
1835
  *
1764
1836
  * A write to the hook's own source, its stamp sidecar, or one of
@@ -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