vigiles 32.1.0 → 33.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code/run-scripts.d.ts +19 -3
- package/dist/adapters/claude-code/run-scripts.js +12 -4
- package/dist/cli-main.js +59 -14
- package/dist/core/hook-program.d.ts +67 -1
- package/dist/core/hook-program.js +74 -2
- package/dist/core/hook-providers.d.ts +1 -1
- package/dist/coverage-evidence.d.ts +1 -3
- package/dist/coverage-evidence.js +7 -35
- package/dist/coverage-probe.d.ts +15 -0
- package/dist/coverage-probe.js +29 -0
- package/dist/eval-define.js +3 -3
- package/dist/eval-lock.js +2 -1
- 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 +93 -40
- package/dist/hook-install.js +235 -54
- package/dist/hook-runtime.d.ts +1 -1
- package/dist/hook-runtime.js +25 -26
- package/dist/hook-state-store.d.ts +1 -1
- package/dist/hook.js +1 -1
- package/dist/load-hook.d.ts +1 -1
- package/dist/load-hook.js +1 -1
- package/dist/local-files.js +1 -0
- package/dist/source-kinds.d.ts +128 -0
- package/dist/source-kinds.js +162 -0
- package/dist/test-coverage-files.js +2 -17
- package/dist/test-coverage.js +11 -8
- package/package.json +1 -1
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vigiles — run harness-test / eval script files via the CLI.
|
|
3
|
+
*
|
|
4
|
+
* `vigiles test` and `vigiles eval` discover `*.harness.*` / `*.eval.*`
|
|
5
|
+
* scripts and run each as a child `node` process, so the two-tier
|
|
6
|
+
* harness-testing API (`src/harness-test.ts`, `src/eval.ts`) works as a CI
|
|
7
|
+
* command, not just `node x.mjs`. Scripts may be authored in **JavaScript**
|
|
8
|
+
* (`.mjs` / `.cjs` / `.js`) **or TypeScript** (`.ts` / `.mts` / `.cts`) — a TS
|
|
9
|
+
* script is run through `tsx` when installed, else Node's built-in type
|
|
10
|
+
* stripping (Node >= 22.6). The scripts import from the built `dist/`, so they
|
|
11
|
+
* also run standalone — the CLI just discovers, runs, and aggregates exit codes.
|
|
12
|
+
*/
|
|
13
|
+
import { type TestTier } from "../../source-kinds.js";
|
|
1
14
|
import { type SurfaceProbe } from "../../check-count.js";
|
|
2
15
|
/**
|
|
3
16
|
* The outcome of running one script.
|
|
@@ -102,10 +115,13 @@ export declare function statusFor(code: number, checks: number | undefined, load
|
|
|
102
115
|
* retracting silently with no type error.
|
|
103
116
|
*/
|
|
104
117
|
export declare function loadFailed(r: ScriptRunResult): boolean;
|
|
105
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Filename extensions accepted for harness/eval scripts (JS and TS). The table
|
|
120
|
+
* lives in `source-kinds.ts`; this re-export keeps the runner's name for it.
|
|
121
|
+
*/
|
|
106
122
|
export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
|
|
107
|
-
/**
|
|
108
|
-
export declare function scriptGlob(kind:
|
|
123
|
+
/** The glob that discovers one tier's scripts, derived from the same table as everything else that names a test. */
|
|
124
|
+
export declare function scriptGlob(kind: TestTier): string;
|
|
109
125
|
export { detectNodeCaps, canRunTypeScript, type NodeCaps, } from "../../ts-runner-caps.js";
|
|
110
126
|
import type { NodeCaps } from "../../ts-runner-caps.js";
|
|
111
127
|
import { type GlobIgnore } from "../../core/glob-ignore.js";
|
|
@@ -22,6 +22,7 @@ exports.formatScriptSummary = formatScriptSummary;
|
|
|
22
22
|
* stripping (Node >= 22.6). The scripts import from the built `dist/`, so they
|
|
23
23
|
* also run standalone — the CLI just discovers, runs, and aggregates exit codes.
|
|
24
24
|
*/
|
|
25
|
+
const source_kinds_js_1 = require("../../source-kinds.js");
|
|
25
26
|
const node_child_process_1 = require("node:child_process");
|
|
26
27
|
const node_os_1 = require("node:os");
|
|
27
28
|
const node_path_1 = require("node:path");
|
|
@@ -103,11 +104,14 @@ function statusFor(code, checks, load) {
|
|
|
103
104
|
function loadFailed(r) {
|
|
104
105
|
return r.status === "skip" && r.code !== exports.SKIP_EXIT_CODE;
|
|
105
106
|
}
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Filename extensions accepted for harness/eval scripts (JS and TS). The table
|
|
109
|
+
* lives in `source-kinds.ts`; this re-export keeps the runner's name for it.
|
|
110
|
+
*/
|
|
111
|
+
exports.SCRIPT_EXTS = source_kinds_js_1.RUNNABLE_EXTS;
|
|
112
|
+
/** The glob that discovers one tier's scripts, derived from the same table as everything else that names a test. */
|
|
109
113
|
function scriptGlob(kind) {
|
|
110
|
-
return
|
|
114
|
+
return (0, source_kinds_js_1.testGlob)(kind);
|
|
111
115
|
}
|
|
112
116
|
const TS_EXT = /\.(?:m|c)?ts$/;
|
|
113
117
|
// The capability probe lives in `src/ts-runner-caps.ts` so the SUGGESTER
|
|
@@ -194,6 +198,10 @@ function discoverScripts(patterns, defaultGlob, cwd, ignore) {
|
|
|
194
198
|
ignore: (0, glob_ignore_js_1.withIgnored)([], ignore),
|
|
195
199
|
dot: true,
|
|
196
200
|
nodir: true,
|
|
201
|
+
// `glob` defaults this to TRUE on macOS and Windows, which made
|
|
202
|
+
// `a.hook.HARNESS.mjs` a test there and nothing on Linux. The kind of a
|
|
203
|
+
// name is decided case-sensitively (`source-kinds.ts`); the glob agrees.
|
|
204
|
+
nocase: false,
|
|
197
205
|
})) {
|
|
198
206
|
found.add(m);
|
|
199
207
|
}
|
package/dist/cli-main.js
CHANGED
|
@@ -69,6 +69,7 @@ const action_gate_js_1 = require("./action-gate.js");
|
|
|
69
69
|
const guards_js_1 = require("./core/guards.js");
|
|
70
70
|
const hook_program_js_1 = require("./core/hook-program.js");
|
|
71
71
|
const hook_install_js_1 = require("./hook-install.js");
|
|
72
|
+
const hook_check_install_js_1 = require("./hook-check-install.js");
|
|
72
73
|
const hook_providers_js_1 = require("./core/hook-providers.js");
|
|
73
74
|
const toml_1 = require("@iarna/toml");
|
|
74
75
|
// The named-state STORE. Lifted out of this file so a TEST can seed a fact
|
|
@@ -5980,7 +5981,16 @@ async function refsHookCommand() {
|
|
|
5980
5981
|
*/
|
|
5981
5982
|
async function compileProviders() {
|
|
5982
5983
|
const names = [];
|
|
5983
|
-
|
|
5984
|
+
const { claimed, unclaimed } = (0, hook_install_js_1.discoverProviderFiles)(process.cwd());
|
|
5985
|
+
// A provider that lost (or never had) its marker is not silently skipped: a
|
|
5986
|
+
// hook's `provider()` ref to it would then fail to resolve with a message
|
|
5987
|
+
// about the REF, far from the cause.
|
|
5988
|
+
if (unclaimed.length > 0) {
|
|
5989
|
+
// Every one gets the ✗ (the catch adds the first): the others used to read
|
|
5990
|
+
// as a continuation of the first message.
|
|
5991
|
+
throw new hook_program_js_1.HookCompileError(unclaimed.map((f) => (0, hook_install_js_1.unclaimedReport)(f, "provider")).join("\n✗ "));
|
|
5992
|
+
}
|
|
5993
|
+
for (const file of claimed) {
|
|
5984
5994
|
const def = await (0, hook_runtime_js_1.loadProvider)(file);
|
|
5985
5995
|
if ((0, hook_providers_js_1.unsafeProvider)(def)) {
|
|
5986
5996
|
throw new hook_program_js_1.HookCompileError(`provider ${file} ("${def.run}") is not provably read-only — ` +
|
|
@@ -6017,12 +6027,11 @@ async function installHookFile(file, adapter, registeredProviders = []) {
|
|
|
6017
6027
|
// cannot load must block — the repo seized, every command refused including the repair.
|
|
6018
6028
|
//
|
|
6019
6029
|
// 🔴 AND LAUNCHED LOCALLY, NOT THROUGH `npx` — 193 ms against 2545 ms on a warm
|
|
6020
|
-
// cache, thirteen times, on every tool call. The
|
|
6021
|
-
// shell does when that
|
|
6022
|
-
//
|
|
6023
|
-
// and for why a gate and a nudge must answer differently.
|
|
6030
|
+
// cache, thirteen times, on every tool call. The `|| exit N` ending is what
|
|
6031
|
+
// the shell does when that file cannot start at all. `compileHookProgram`
|
|
6032
|
+
// adds it (see `hookRuntimeMissingExit`), so it is not written here.
|
|
6024
6033
|
gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
|
|
6025
|
-
|
|
6034
|
+
(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens),
|
|
6026
6035
|
dialect: adapter.dialect,
|
|
6027
6036
|
hookProtocol: adapter.hookProtocol,
|
|
6028
6037
|
settings: adapter.layout.settings,
|
|
@@ -6117,8 +6126,8 @@ async function installHookFile(file, adapter, registeredProviders = []) {
|
|
|
6117
6126
|
* resolved from the `--harness=` flag, else `.vigilesrc.json#harnesses`, else auto-detect.
|
|
6118
6127
|
* Returns false if any hook failed to compile for any harness.
|
|
6119
6128
|
*/
|
|
6120
|
-
async function installHooks(hookFiles, harnessFlag, configHarness) {
|
|
6121
|
-
if (hookFiles.length === 0)
|
|
6129
|
+
async function installHooks(hookFiles, harnessFlag, configHarness, validateProvidersAnyway = false) {
|
|
6130
|
+
if (hookFiles.length === 0 && !validateProvidersAnyway)
|
|
6122
6131
|
return true;
|
|
6123
6132
|
const adapters = (0, adapter_registry_js_1.resolveHarnessAdapters)({
|
|
6124
6133
|
root: process.cwd(),
|
|
@@ -6138,6 +6147,8 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
|
|
|
6138
6147
|
}
|
|
6139
6148
|
throw e;
|
|
6140
6149
|
}
|
|
6150
|
+
if (hookFiles.length === 0)
|
|
6151
|
+
return true; // providers validated; nothing to wire
|
|
6141
6152
|
let ok = true;
|
|
6142
6153
|
for (const file of hookFiles) {
|
|
6143
6154
|
try {
|
|
@@ -6171,6 +6182,7 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
|
|
|
6171
6182
|
}
|
|
6172
6183
|
}
|
|
6173
6184
|
}
|
|
6185
|
+
(0, hook_check_install_js_1.installHookCheck)(adapters);
|
|
6174
6186
|
return ok;
|
|
6175
6187
|
}
|
|
6176
6188
|
/**
|
|
@@ -6644,15 +6656,40 @@ async function main() {
|
|
|
6644
6656
|
.filter((f) => f.endsWith(".spec.ts"))
|
|
6645
6657
|
.map((f) => noteExplicitOverride(excludes, f, "compiling"))
|
|
6646
6658
|
: findSpecs(excludes);
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6659
|
+
// WHAT A HOOK IS is decided once, in `source-kinds.ts`: a bare `compile`
|
|
6660
|
+
// and `compile <file>` ask the same classifier, so they cannot disagree.
|
|
6661
|
+
// Only `.hook.` files are compiled; a runnable file that carries no marker
|
|
6662
|
+
// is refused below, out loud, with the command that fixes it.
|
|
6663
|
+
const found = restArgs.length > 0
|
|
6664
|
+
? (0, hook_install_js_1.partitionHookArgs)(restArgs.filter((f) => !f.endsWith(".spec.ts")))
|
|
6665
|
+
: (0, hook_install_js_1.discoveredHookArgs)(process.cwd());
|
|
6666
|
+
const hooks = [...found.hooks];
|
|
6667
|
+
const unclaimed = found.unclaimed;
|
|
6668
|
+
if (specs.length === 0 &&
|
|
6669
|
+
hooks.length === 0 &&
|
|
6670
|
+
unclaimed.length === 0 &&
|
|
6671
|
+
found.skipped.length === 0 &&
|
|
6672
|
+
found.invalid.length === 0) {
|
|
6651
6673
|
console.log("No .spec.ts or .vigiles/hooks/ hook files found.");
|
|
6652
6674
|
console.log("Run `vigiles init` to create one.");
|
|
6653
6675
|
process.exit(0);
|
|
6654
6676
|
}
|
|
6655
6677
|
let valid = true;
|
|
6678
|
+
// Refused files FIRST and on stderr: they are the news in this run, and a
|
|
6679
|
+
// line that scrolled past under forty `✓` lines is a silent skip with
|
|
6680
|
+
// extra steps. Compiling the rest still proceeds — one stray file must
|
|
6681
|
+
// not stop the hooks that are fine.
|
|
6682
|
+
for (const f of unclaimed) {
|
|
6683
|
+
console.error(`✗ ${(0, hook_install_js_1.unclaimedReport)(f, "hook")}`);
|
|
6684
|
+
valid = false;
|
|
6685
|
+
}
|
|
6686
|
+
for (const bad of found.invalid) {
|
|
6687
|
+
console.error(`✗ ${(0, hook_install_js_1.invalidArgMessage)(bad.path, bad.reason)}`);
|
|
6688
|
+
valid = false;
|
|
6689
|
+
}
|
|
6690
|
+
for (const s of found.skipped) {
|
|
6691
|
+
console.log(`- ${s.path} skipped: a ${s.kind}, not a hook.`);
|
|
6692
|
+
}
|
|
6656
6693
|
if (specs.length > 0)
|
|
6657
6694
|
valid =
|
|
6658
6695
|
(await compile(specs, config, excludes, { harnessFlag })) && valid;
|
|
@@ -6669,13 +6706,21 @@ async function main() {
|
|
|
6669
6706
|
console.log(`\n${weightLine}`);
|
|
6670
6707
|
}
|
|
6671
6708
|
valid =
|
|
6672
|
-
(await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses)
|
|
6709
|
+
(await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
|
|
6710
|
+
// A hook-side compile is under way even when every hook was refused:
|
|
6711
|
+
// validate the providers too, so ONE run shows every problem.
|
|
6712
|
+
unclaimed.length > 0)) && valid;
|
|
6673
6713
|
// Keep an existing whole-harness registry in sync (cheap, opt-in) so the
|
|
6674
6714
|
// user never hand-runs `generate-harness`. Skipped when no harness.gen.ts.
|
|
6675
6715
|
if (specs.length > 0)
|
|
6676
6716
|
valid = (await refreshHarnessGenIfPresent(harnessFlag)) && valid;
|
|
6677
6717
|
console.log("");
|
|
6678
|
-
if (valid) {
|
|
6718
|
+
if (valid && specs.length === 0 && hooks.length === 0) {
|
|
6719
|
+
// Skipped paths are not errors, but a run that compiled nothing must not
|
|
6720
|
+
// read like one that did.
|
|
6721
|
+
console.log("Nothing was compiled: every path given was skipped.");
|
|
6722
|
+
}
|
|
6723
|
+
else if (valid) {
|
|
6679
6724
|
console.log("Compilation complete.");
|
|
6680
6725
|
}
|
|
6681
6726
|
else {
|
|
@@ -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;
|
|
@@ -513,7 +515,7 @@ function commandView(raw, root) {
|
|
|
513
515
|
// uses, and for the same reason. This matcher read only `leaves` (raw), so
|
|
514
516
|
// three ordinary rewrites walked past a guard that blocks the plain form.
|
|
515
517
|
// MEASURED 2026-09-02 on the shipped dogfood artifact behind the public
|
|
516
|
-
// 7/7 claim (`examples/harness/safe-bash-guard.mjs`):
|
|
518
|
+
// 7/7 claim (`examples/harness/safe-bash-guard.hook.mjs`):
|
|
517
519
|
// git push --force origin main exit 2 blocked
|
|
518
520
|
// git push "--force" origin main exit 0 ALLOWED
|
|
519
521
|
// sudo git push --force origin main exit 0 ALLOWED
|
|
@@ -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
|
|
@@ -54,7 +54,7 @@ export declare const provide: <const Name extends string>(name: Name, run: strin
|
|
|
54
54
|
export declare const dangerously: <const Name extends string>(name: Name, run: string) => InlineProvider<Name>;
|
|
55
55
|
/**
|
|
56
56
|
* A REGISTERED provider — a reusable, named fact authored once in
|
|
57
|
-
* `.vigiles/providers/<name>.{mjs,ts}` (default-exported via {@link defineProvider})
|
|
57
|
+
* `.vigiles/providers/<name>.provider.{mjs,ts}` (default-exported via {@link defineProvider})
|
|
58
58
|
* and referenced from many hooks by {@link provider} name. The reusable sibling of
|
|
59
59
|
* the one-off inline {@link provide}; same read-only-by-default rule.
|
|
60
60
|
*/
|
|
@@ -64,9 +64,7 @@ export interface PreparedTest {
|
|
|
64
64
|
export declare function isColocatedTest(surface: Pick<CoverableSurface, "name" | "path">, testPath: string): boolean;
|
|
65
65
|
/** Wrap a discovered path. Kept as a function so both twins share one shape. */
|
|
66
66
|
export declare function prepareTest(path: string): PreparedTest;
|
|
67
|
-
|
|
68
|
-
* helper (`node:path` on disk, `posix-path` in the browser twin). */
|
|
69
|
-
export declare function isEvalScript(filename: string): boolean;
|
|
67
|
+
export { isEvalScript } from "./source-kinds.js";
|
|
70
68
|
/**
|
|
71
69
|
* The root variables a hook command may be written against — the structural
|
|
72
70
|
* slice of `PluginLayout` this needs, taken by SHAPE so the browser twin does
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SURFACE_TOKEN = void 0;
|
|
3
|
+
exports.SURFACE_TOKEN = exports.isEvalScript = void 0;
|
|
4
4
|
exports.strongerEvidence = strongerEvidence;
|
|
5
5
|
exports.isColocatedTest = isColocatedTest;
|
|
6
6
|
exports.prepareTest = prepareTest;
|
|
7
|
-
exports.isEvalScript = isEvalScript;
|
|
8
7
|
exports.hookScriptRefs = hookScriptRefs;
|
|
9
8
|
exports.evidenceFor = evidenceFor;
|
|
10
9
|
exports.hasSurfaceToken = hasSurfaceToken;
|
|
@@ -185,39 +184,12 @@ function prepareTest(path) {
|
|
|
185
184
|
* mode here is under-counting, not accusation.
|
|
186
185
|
*/
|
|
187
186
|
const SCRIPT_RE = (0, source_refs_js_1.scriptRefPattern)();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
*
|
|
195
|
-
* - As the full suffix `.eval.mjs` it was a MONEY HAZARD — `foo.eval.ts` fell
|
|
196
|
-
* into the free branch and would have spent real model calls on every push.
|
|
197
|
-
* - As the bare INFIX `.eval.` it made a FALSE GRANT — `parser.eval.test.ts`,
|
|
198
|
-
* an ordinary deterministic test discovered by an `include` of
|
|
199
|
-
* `**\/*.test.ts`, was credited to the paid tier and dropped from the free
|
|
200
|
-
* one. `vigiles eval` globs `**\/*.eval.{mjs,cjs,js,mts,cts,ts}`, so that name
|
|
201
|
-
* is not discoverable by the eval runner at all: the surface was reported
|
|
202
|
-
* covered by the tier that cannot run it, and uncovered by the tier that does.
|
|
203
|
-
*
|
|
204
|
-
* The rule that is wrong in neither direction is the one the runner itself uses:
|
|
205
|
-
* `.eval.` followed by a runnable extension AT THE END of the name. Anything
|
|
206
|
-
* else — including a name that merely contains `.eval.` — is deterministic, or
|
|
207
|
-
* is not a script at all.
|
|
208
|
-
*
|
|
209
|
-
* ⚠️ The extension list is RE-DECLARED, not imported: the authority is
|
|
210
|
-
* `SCRIPT_EXTS` in adapters/claude-code/run-scripts.ts, which pulls in
|
|
211
|
-
* `node:child_process` and `glob` and so cannot be imported by this
|
|
212
|
-
* browser-safe module. A test in test-coverage.test.ts asserts the two agree,
|
|
213
|
-
* so an extension added to the runner cannot silently fail to be billed here.
|
|
214
|
-
*/
|
|
215
|
-
const EVAL_SCRIPT_RE = /\.eval\.(?:mjs|cjs|js|mts|cts|ts)$/;
|
|
216
|
-
/** @param filename a BASENAME — callers strip the directory with their own path
|
|
217
|
-
* helper (`node:path` on disk, `posix-path` in the browser twin). */
|
|
218
|
-
function isEvalScript(filename) {
|
|
219
|
-
return EVAL_SCRIPT_RE.test(filename);
|
|
220
|
-
}
|
|
187
|
+
// Which runner owns a test file — the paid real-model one or the free one — is
|
|
188
|
+
// answered by the shared source-kind table (`source-kinds.ts`), which this
|
|
189
|
+
// browser-safe module can import because it imports nothing. See `isEvalScript`
|
|
190
|
+
// there for the two ways this question has been answered wrongly before.
|
|
191
|
+
var source_kinds_js_1 = require("./source-kinds.js");
|
|
192
|
+
Object.defineProperty(exports, "isEvalScript", { enumerable: true, get: function () { return source_kinds_js_1.isEvalScript; } });
|
|
221
193
|
/**
|
|
222
194
|
* The hook-script paths a manifest's `hooks` block names, repo-relative.
|
|
223
195
|
*
|
package/dist/coverage-probe.d.ts
CHANGED
|
@@ -106,6 +106,21 @@ import { type SurfaceProbe } from "./check-count.js";
|
|
|
106
106
|
* runner resolves refs against real discovered surfaces or drops them.
|
|
107
107
|
*/
|
|
108
108
|
export declare function commandRefs(command: string, env?: Record<string, string>): string[];
|
|
109
|
+
/**
|
|
110
|
+
* The files that a `hook-runtime run-program` command needs to exist: the hook
|
|
111
|
+
* file it names, and the runtime script when the command starts it by path
|
|
112
|
+
* (`node "$DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program …`).
|
|
113
|
+
*
|
|
114
|
+
* Narrower than {@link commandRefs}, which lists every program a command runs.
|
|
115
|
+
* This lists only what a compiled hook depends on, because the start-of-session
|
|
116
|
+
* check (`hook-check.ts`) reports these files as missing. `bash scripts/build.sh`
|
|
117
|
+
* is not one of them; reporting it missing in a fresh clone would be a false
|
|
118
|
+
* alarm that `npm install` does not fix.
|
|
119
|
+
*
|
|
120
|
+
* Like `commandRefs`, it only counts a command that really starts vigiles, so
|
|
121
|
+
* `echo vigiles hook-runtime run-program x.mjs` names nothing.
|
|
122
|
+
*/
|
|
123
|
+
export declare function runProgramFiles(command: string): string[];
|
|
109
124
|
/** The transcript shape this module reads — structural, so it imports no tier. */
|
|
110
125
|
export interface ProbeableTrace {
|
|
111
126
|
readonly toolCalls: readonly {
|
package/dist/coverage-probe.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.commandRefs = commandRefs;
|
|
4
|
+
exports.runProgramFiles = runProgramFiles;
|
|
4
5
|
exports.traceRefs = traceRefs;
|
|
5
6
|
exports.probeCommand = probeCommand;
|
|
6
7
|
exports.probeTrace = probeTrace;
|
|
@@ -586,6 +587,34 @@ function commandRefs(command, env) {
|
|
|
586
587
|
}
|
|
587
588
|
return [...out];
|
|
588
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* The files that a `hook-runtime run-program` command needs to exist: the hook
|
|
592
|
+
* file it names, and the runtime script when the command starts it by path
|
|
593
|
+
* (`node "$DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program …`).
|
|
594
|
+
*
|
|
595
|
+
* Narrower than {@link commandRefs}, which lists every program a command runs.
|
|
596
|
+
* This lists only what a compiled hook depends on, because the start-of-session
|
|
597
|
+
* check (`hook-check.ts`) reports these files as missing. `bash scripts/build.sh`
|
|
598
|
+
* is not one of them; reporting it missing in a fresh clone would be a false
|
|
599
|
+
* alarm that `npm install` does not fix.
|
|
600
|
+
*
|
|
601
|
+
* Like `commandRefs`, it only counts a command that really starts vigiles, so
|
|
602
|
+
* `echo vigiles hook-runtime run-program x.mjs` names nothing.
|
|
603
|
+
*/
|
|
604
|
+
function runProgramFiles(command) {
|
|
605
|
+
const out = new Set();
|
|
606
|
+
for (const argv of (0, bash_effects_js_1.leafArgvSource)(command)) {
|
|
607
|
+
const head = argv[0] ?? "";
|
|
608
|
+
const entry = SCRIPT_RE.test(head) ? head : interpretedScript(argv);
|
|
609
|
+
const hook = runProgramRef(argv, entry);
|
|
610
|
+
if (hook === undefined)
|
|
611
|
+
continue;
|
|
612
|
+
if (entry !== undefined)
|
|
613
|
+
out.add(entry);
|
|
614
|
+
out.add(hook);
|
|
615
|
+
}
|
|
616
|
+
return [...out];
|
|
617
|
+
}
|
|
589
618
|
/**
|
|
590
619
|
* The tools that DISPATCH a subagent. Both spellings, because the live CLI names
|
|
591
620
|
* it `Agent` and older docs name it `Task` — the same pair `parseSubagents`
|
package/dist/eval-define.js
CHANGED
|
@@ -60,6 +60,7 @@ exports.defineEval = defineEval;
|
|
|
60
60
|
* never executes, so it is untouched.
|
|
61
61
|
*/
|
|
62
62
|
const node_path_1 = require("node:path");
|
|
63
|
+
const source_kinds_js_1 = require("./source-kinds.js");
|
|
63
64
|
/**
|
|
64
65
|
* Brand marking a value as built by {@link defineEval}. A registered symbol, so
|
|
65
66
|
* a descriptor still reads as one across two copies of the package on disk —
|
|
@@ -128,8 +129,6 @@ function moduleDefault(mod) {
|
|
|
128
129
|
const inner = outer?.default;
|
|
129
130
|
return isEvalDefinition(inner) ? inner : outer;
|
|
130
131
|
}
|
|
131
|
-
/** Filenames vigiles runs as evals — the runner's own glob, as a pattern. */
|
|
132
|
-
const EVAL_FILE = /\.eval\.(?:m|c)?[jt]s$/;
|
|
133
132
|
/**
|
|
134
133
|
* Was node pointed STRAIGHT at an eval file? `argv1` is `process.argv[1]`: the
|
|
135
134
|
* path node was started with, which no stray configuration can forge.
|
|
@@ -142,7 +141,8 @@ const EVAL_FILE = /\.eval\.(?:m|c)?[jt]s$/;
|
|
|
142
141
|
* Pure: a fact in, a boolean out.
|
|
143
142
|
*/
|
|
144
143
|
function ranAsEntry(argv1) {
|
|
145
|
-
return argv1 !== undefined &&
|
|
144
|
+
return (argv1 !== undefined &&
|
|
145
|
+
(0, source_kinds_js_1.isEvalScript)(argv1.replaceAll("\\", "/").split("/").pop() ?? ""));
|
|
146
146
|
}
|
|
147
147
|
/** The words shown when someone runs an eval file directly. Asserted by a test:
|
|
148
148
|
* a refusal that stops a run without saying what to do instead is a ticket. */
|
package/dist/eval-lock.js
CHANGED
|
@@ -58,6 +58,7 @@ const node_fs_1 = require("node:fs");
|
|
|
58
58
|
const node_path_1 = require("node:path");
|
|
59
59
|
const hash_js_1 = require("./core/hash.js");
|
|
60
60
|
const eval_cache_js_1 = require("./eval-cache.js");
|
|
61
|
+
const source_kinds_js_1 = require("./source-kinds.js");
|
|
61
62
|
/**
|
|
62
63
|
* On-disk lock-format version, salted into nothing (the lock is keyed by name,
|
|
63
64
|
* not by hash) but VALIDATED on read so an incompatible shape fails loud rather
|
|
@@ -162,7 +163,7 @@ function isEvalInputFile(path) {
|
|
|
162
163
|
const p = path.replace(/\\/g, "/");
|
|
163
164
|
if (/(^|\/)SKILL\.md$/.test(p))
|
|
164
165
|
return true;
|
|
165
|
-
return
|
|
166
|
+
return (0, source_kinds_js_1.isEvalScript)(p.split("/").pop() ?? "");
|
|
166
167
|
}
|
|
167
168
|
/**
|
|
168
169
|
* The NON-BLOCKING nudge to emit after an eval-input edit when committed locks
|