mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
900dc7e06a
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.
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
/**
|
|
* `loadHook` — load a compiled-hook program from a FILE.
|
|
*
|
|
* The in-process assertions (`assertHookDenies` / `assertHookAllows` /
|
|
* `assertHookNotices` / `assertHookSilent`) take the hook OBJECT, but a
|
|
* `.harness.mjs` test only has the hook's PATH — and the loader that turns one
|
|
* into the other lived privately inside `cli.ts`. So the intended in-process test
|
|
* path was unreachable from the file format `vigiles test` actually runs, and
|
|
* authors fell back to spawning the runtime as a subprocess: exactly the plumbing
|
|
* compiled hooks exist to remove. Measured 2026-08-03.
|
|
*
|
|
* This is now the ONE loader — the CLI runtime calls it too, so a hook that loads
|
|
* in a test loads identically in production (one loader, no drift).
|
|
*/
|
|
import { resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
HookCompileError,
|
|
rememberHookSource,
|
|
type AnyHook,
|
|
} from "./core/hook-program.js";
|
|
|
|
/**
|
|
* Load a compiled-hook program's default export from `file`.
|
|
*
|
|
* JS module formats (`.mjs`/`.cjs`/`.js`) load via dynamic import directly; a
|
|
* TypeScript hook (`.ts`/`.mts`/`.cts`) loads only under a TS-capable runtime
|
|
* (tsx, or Node >= 23.6 with type stripping) — otherwise the error says so and
|
|
* points at authoring the hook as `.mjs`.
|
|
*
|
|
* ```js
|
|
* import { loadHook, assertHookDenies } from "vigiles";
|
|
*
|
|
* const guard = await loadHook(".vigiles/hooks/guard.mjs");
|
|
* assertHookDenies(guard, {
|
|
* tool_name: "Bash",
|
|
* tool_input: { command: "git push --force" },
|
|
* });
|
|
* ```
|
|
*
|
|
* @throws {HookCompileError} when the file can't be imported, or has no
|
|
* default-exported hook program.
|
|
*/
|
|
export async function loadHook(file: string): Promise<AnyHook> {
|
|
const abs = resolve(process.cwd(), file);
|
|
let mod: { default?: unknown };
|
|
try {
|
|
mod = (await import(pathToFileURL(abs).href)) as { default?: unknown };
|
|
} catch (e) {
|
|
if (/\.(?:m|c)?ts$/.test(file)) {
|
|
throw new HookCompileError(
|
|
`Cannot load TypeScript hook "${file}" in this Node runtime. Run under ` +
|
|
`tsx (npx tsx …) / Node >= 23.6, or author the hook as a .mjs file.`,
|
|
);
|
|
}
|
|
throw new HookCompileError(
|
|
`Cannot load hook "${file}": ${(e as Error).message}`,
|
|
);
|
|
}
|
|
// Unwrap the ESM/CJS double-default that `export default` can produce.
|
|
const program =
|
|
(mod.default as { default?: unknown } | undefined)?.default ?? mod.default;
|
|
if (!program || typeof program !== "object") {
|
|
throw new HookCompileError(
|
|
`${file} has no default-exported hook program ` +
|
|
`(use \`export default experimental_defineHook({…})\`).`,
|
|
);
|
|
}
|
|
// Remember WHERE it came from, so the assertion that later EVALUATES it can
|
|
// attribute execution coverage without parsing anything. Remembering is not
|
|
// recording: a load attributes nothing (see `hookSource` in core/hook-program).
|
|
const hook = program as AnyHook;
|
|
rememberHookSource(hook, file);
|
|
return hook;
|
|
}
|