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.
- package/dist/adapters/claude-code/instruction-chain.js +36 -2
- package/dist/cli-flag-check.js +7 -1
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.js +38 -5
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/hook-program.d.ts +67 -1
- package/dist/core/hook-program.js +73 -1
- package/dist/core/instruction-baseline.d.ts +139 -0
- package/dist/core/instruction-baseline.js +224 -0
- package/dist/core/instruction-weight.d.ts +17 -7
- package/dist/core/instruction-weight.js +25 -9
- package/dist/core/rule-meta.js +8 -0
- package/dist/core/types.d.ts +8 -0
- package/dist/coverage-probe.d.ts +15 -0
- package/dist/coverage-probe.js +29 -0
- package/dist/hook-check-install.d.ts +3 -0
- package/dist/hook-check-install.js +57 -0
- package/dist/hook-check.d.ts +36 -0
- package/dist/hook-check.js +125 -0
- package/dist/hook-install.d.ts +0 -36
- package/dist/hook-install.js +0 -38
- package/dist/hook-runtime.js +17 -24
- package/dist/instruction-weight-ratchet.d.ts +53 -0
- package/dist/instruction-weight-ratchet.js +132 -0
- package/dist/local-files.js +2 -0
- package/dist/scan.d.ts +10 -0
- package/dist/scan.js +30 -4
- package/dist/vigilesrc.schema.json +36 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/cli-flag-check.js
CHANGED
|
@@ -78,7 +78,13 @@ exports.COMMAND_FLAGS = {
|
|
|
78
78
|
],
|
|
79
79
|
compile: [],
|
|
80
80
|
eject: ["--keep-spec"],
|
|
81
|
-
lint: [
|
|
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.
|
package/dist/cli-main.d.ts
CHANGED
|
@@ -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
|
|
6001
|
-
// shell does when that
|
|
6002
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|