Files
zernie__vigiles/src/load-hook.test.ts
T
zernie 900dc7e06a refactor(spec)!: name things for what they are; retire the vigiles/experimental subpath (#169)
Three exported symbols were named for something other than what they are, and
two mechanisms were doing one job.

RENAMES (old names kept one major, @deprecated, removed one major AFTER this one):
  claude()          → instructionFile()     compiles CLAUDE.md AND AGENTS.md, so a
                                            name from one harness was never right
  instructions``    → prose``               builds a prose FRAGMENT; the plural read
                                            as "the instruction file", which is what
                                            instructionFile() builds
  agent()           → experimental_agent()  the shape is not settled, and the tag was
                                            never there (git log -S empty)
  SkillSpec.result: → postcondition:        `result` already meant the subagent output
                                            contract one screen down; the doc comment
                                            had to spend a line disambiguating them

The alias window is not politeness. Measured: the previous rename shipped with
ZERO overlap (18.1.1 old names only, 19.0.0 new only). A container rollback put
the old node_modules under new sources, PreToolUse stopped loading, and a
PreToolUse that does not load denies EVERY Bash command — including the one that
fixes it. One window per major makes that pair unrepresentable.

SUBPATH RETIRED. vigiles/experimental is gone; its members moved to the door
their feature already uses. Of two mechanisms for one promise the subpath was the
weaker: it marks the import line, out of view by the time anyone reads the call.
Measured on the only user-facing example — with the prefix aliased away at
import, the marker survived at 0 of 5 call sites. So the prefix stays, the advice
to alias it away became an explicit prohibition, and the six define* entry points
are DECLARED prefixed rather than gaining it at re-export.

That de-aliasing is what unblocked moving the naming check into ESLint, and the
ORDER was load-bearing: measured before it, dropping the public/internal
exemption produced 14 findings, all from the re-export indirection. Measured
after, across all of src/ with no exemption: 22 tagged declarations, 0
unprefixed. The rule turns on silent.

EXPORT CLEANUP. vigiles/linting export * → curated, 103 → 79 symbols; its header
claimed to be curated with an `export *` one line below, and claimed the builders
were also at the package root (191 exports there, zero matches). ClaudeTool and
HookEvent deleted — zero references, and both had drifted off the dialect they
claimed to mirror; HookEvent listed 5 events, two of which do not exist, against
the 31 the vendor documents.

DOCS. experimental.md deleted and distributed per feature: the skill half into
skills.md §Status, the hook half into a new compiled-hooks.md §Status, and the
emit half — 111 of its 177 lines, a topic doc wearing a stability page's clothes
— into docs/emit-channel.md, which gains the "which channel when" comparison it
never had. The one cross-cutting sentence went to the README.

TWO GATES WERE GREEN AND WRONG, both found by this work:
  - api:check reported "verified for 11 entries — no drift" while reading
    dist/experimental.d.ts from a PREVIOUS build (tsc does not clean dist/) for a
    source file that no longer existed. Now asserted against package.json
    exports, with a mutation proving exit 1 on a phantom entry.
  - check-export-prefixes caught MY error: the R3 services first landed on
    vigiles/eval, where `paid_` means "this call can bill you" — and
    startServices takes a ContainerRuntime and calls no model. Naming it
    paid_experimental_startServices would have redefined paid_ as "expensive in
    some sense". They went to the package root.

SEVENTEEN review findings landed on this branch, all real. Two mechanical
closures came out of them: src/example-imports.test.ts resolves every relative
import under examples/ instead of matching a string, and the naming rule now
ENUMERATES the three export paths it visits rather than describing its coverage
in the abstract. One attempt is recorded as REFUTED: teaching the doc gate to
flag our own exports as free variables measured 66 findings across 93 blocks, so
that gate's original scoping was right.

BREAKING CHANGE: vigiles/experimental is removed. Its exports are unchanged in
name and behaviour and are now reached from `vigiles` (the emit channel, the R3
disposable-service tier). Every renamed symbol keeps a working @deprecated alias
for this major.
2026-08-21 07:54:51 +05:00

126 lines
4.2 KiB
TypeScript

/**
* `loadHook` — the public compiled-hook loader (vitest).
*
* The in-process assertions take the hook OBJECT, but a `.harness.mjs` test only
* has its PATH. Until this was exported the intended in-process test path was
* unreachable from the file format `vigiles test` runs, so authors spawned the
* runtime as a subprocess instead — the plumbing compiled hooks exist to remove.
*
* These drive the loader over REAL files on disk (it's an ESM dynamic import;
* there is nothing to fake) and pin the error messages, which are the whole
* reason a loader is public rather than a one-line `await import()`.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { resolve, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { loadHook } from "./load-hook.js";
import { HookCompileError } from "./core/hook-program.js";
import { assertHookDenies, assertHookAllows } from "./harness-assert.js";
import { makeTmpDir, cleanupTmpDir } from "./core/test-utils.js";
const HOOK_DIST = pathToFileURL(
resolve(__dirname, "..", "dist", "hook.js"),
).href;
const GATE = `import { experimental_defineHook, tool, deny, allow } from "${HOOK_DIST}";
export default experimental_defineHook({
on: "PreToolUse",
match: tool("Bash"),
decide: (e) =>
e.command.runs("git push", { force: true }) ? deny("no force-push") : allow(),
});`;
test("loadHook: a loaded hook feeds the in-process assertions directly", async () => {
const dir = makeTmpDir();
try {
const file = resolve(dir, "guard.mjs");
writeFileSync(file, GATE);
// THE pattern a .harness.mjs test uses: path in, assertions over the object.
const guard = await loadHook(file);
assertHookDenies(guard, {
tool_name: "Bash",
tool_input: { command: "git push --force origin main" },
});
assertHookAllows(guard, {
tool_name: "Bash",
tool_input: { command: "git status" },
});
} finally {
cleanupTmpDir(dir);
}
});
test("loadHook: resolves a RELATIVE path against the cwd, not against this module", async () => {
const dir = makeTmpDir();
try {
const abs = resolve(dir, "guard.mjs");
writeFileSync(abs, GATE);
// The path a .harness.mjs test would actually pass — relative to where the
// test process runs, not to the loader's own file.
const rel = relative(process.cwd(), abs);
assert.ok(!rel.startsWith("/"), "the fixture path must be relative");
const guard = await loadHook(rel);
assertHookAllows(guard, {
tool_name: "Bash",
tool_input: { command: "git status" },
});
} finally {
cleanupTmpDir(dir);
}
});
test("loadHook: a module with no default export fails with an actionable error", async () => {
const dir = makeTmpDir();
try {
const file = resolve(dir, "not-a-hook.mjs");
writeFileSync(file, `export const notDefault = 1;`);
await assert.rejects(
() => loadHook(file),
(e: Error) =>
e instanceof HookCompileError &&
/use `export default experimental_defineHook/.test(e.message),
);
} finally {
cleanupTmpDir(dir);
}
});
test("loadHook: an unimportable file reports WHY, not just that it failed", async () => {
const dir = makeTmpDir();
try {
const file = resolve(dir, "broken.mjs");
writeFileSync(file, `export default {{{ not javascript`);
await assert.rejects(
() => loadHook(file),
(e: Error) =>
e instanceof HookCompileError && /^Cannot load hook /.test(e.message),
);
} finally {
cleanupTmpDir(dir);
}
});
test("loadHook: a TypeScript hook on a non-TS runtime says how to fix it", async () => {
const dir = makeTmpDir();
try {
const file = resolve(dir, "guard.hook.ts");
// Deliberately unparseable AS JS too, so the failure is the runtime's
// inability to load TS on any Node that lacks type stripping.
writeFileSync(file, `const x: number = 1;\nexport default {{{`);
await assert.rejects(
() => loadHook(file),
(e: Error) =>
e instanceof HookCompileError &&
/Run under tsx .*Node >= 23\.6, or author the hook as a \.mjs file/s.test(
e.message,
),
);
} finally {
cleanupTmpDir(dir);
}
});