mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
fix(compile): anchor the emitted hook path at the project root (#230)
`vigiles compile` wired hooks with a relative path, which only works while cwd is the project root. This codebase already said so twice — and both times about READING such a command, never about writing one: `bareToken` header (2026-08-21): "it spells the path relative to the cwd, so it dies with exit 2 the moment the agent runs from a subdirectory" `PluginLayout.projectRootTokens`: "Claude Code's DOCUMENTED spelling for a project hook, because hooks do not run with a stable cwd" `projectRootOf` goes further and relies on the anchored spelling "by construction" — but the compiler never emitted it. Measured in a consumer repo 2026-09-10: after a compile, one `cd` into a subdirectory made a PreToolUse bash-gate fail to load, and a gate that cannot load must block, so the repository seized — every command refused, including `vigiles compile`, which loads through the same resolver. For a react/inject hook the same wiring fails worse: it silently stops firing. `hookGateRef(ref, projectRootTokens)` anchors the emitted path when the harness declares such a variable, and returns the bare ref when it does not. It sits beside `bareToken`, which strips exactly this prefix and these quotes — one contract read from two ends, and while the ends sat apart only one was ever fixed. Idempotency is asserted rather than assumed, and the old relative spelling is still matched, so upgrading rewrites the wiring in place. Two assertions in hook.test.ts had ENCODED the defect: they pinned the relative spelling this repo elsewhere calls broken, so the emitter could not be fixed without them going red. They now pin the anchored form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -228,6 +228,7 @@ import {
|
||||
discoverProviderFiles,
|
||||
mergeHooksJson,
|
||||
mergeHooksToml,
|
||||
hookGateRef,
|
||||
normalizeHookRef,
|
||||
serializeConfig,
|
||||
} from "./hook-install.js";
|
||||
@@ -7272,7 +7273,13 @@ async function installHookFile(
|
||||
// appends a duplicate block instead of replacing the existing one.
|
||||
const ref = normalizeHookRef(file);
|
||||
const compiled = compileHookProgram(source, program, {
|
||||
gateCommand: `npx vigiles hook-runtime run-program ${ref}`,
|
||||
// 🔴 ANCHORED AT THE PROJECT ROOT. A hook command does not run with a stable cwd —
|
||||
// this codebase says so twice (`bareToken`'s header, `PluginLayout.projectRootTokens`)
|
||||
// and `projectRootOf` relies on the anchored spelling "by construction", but the
|
||||
// emitter never produced it. Measured 2026-09-10 in a consumer repo: after a compile,
|
||||
// one `cd` into a subdirectory made a PreToolUse gate fail to load, and a gate that
|
||||
// cannot load must block — the repo seized, every command refused including the repair.
|
||||
gateCommand: `npx vigiles hook-runtime run-program ${hookGateRef(ref, adapter.layout.projectRootTokens)}`,
|
||||
dialect: adapter.dialect,
|
||||
hookProtocol: adapter.hookProtocol,
|
||||
settingsFormat: adapter.layout.settingsFormat,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
hookGateRef,
|
||||
mergeHooksJson,
|
||||
mergeHooksToml,
|
||||
normalizeHookRef,
|
||||
@@ -348,3 +349,64 @@ describe("discoverHookFiles", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookGateRef — what compile EMITS", () => {
|
||||
const CC = ["${CLAUDE_PROJECT_DIR}", "${CLAUDE_PROJECT}"] as const;
|
||||
const wire = (ref: string, tokens: readonly string[] | undefined) => ({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [
|
||||
{
|
||||
type: "command" as const,
|
||||
command: `npx vigiles hook-runtime run-program ${hookGateRef(ref, tokens)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 🔴 THE REGRESSION THIS FILE NOW OWNS AT BOTH ENDS. Until 2026-09-10 `compile` emitted
|
||||
// the BARE ref — the exact spelling `bareToken`'s header calls broken ("dies with exit 2
|
||||
// the moment the agent runs from a subdirectory"). Reading was fixed 2026-08-21; writing
|
||||
// was not. Measured in a consumer repo: one `cd` into a subdirectory and a PreToolUse gate
|
||||
// failed to load — a gate that cannot load must block, so the repo seized.
|
||||
it("anchors the path at the project root when the harness has one", () => {
|
||||
expect(hookGateRef(".claude/hooks/x.hook.ts", CC)).toBe(
|
||||
'"${CLAUDE_PROJECT_DIR}/.claude/hooks/x.hook.ts"',
|
||||
);
|
||||
});
|
||||
|
||||
// A harness with no such variable has nothing to anchor to; inventing one would emit a
|
||||
// command expanding to `/.claude/...` — worse than relative, and silently so.
|
||||
it("leaves the ref alone when the harness declares no project-root token", () => {
|
||||
expect(hookGateRef(".claude/hooks/x.hook.ts", undefined)).toBe(
|
||||
".claude/hooks/x.hook.ts",
|
||||
);
|
||||
expect(hookGateRef(".claude/hooks/x.hook.ts", [])).toBe(
|
||||
".claude/hooks/x.hook.ts",
|
||||
);
|
||||
});
|
||||
|
||||
// The property that keeps a recompile idempotent instead of duplicating: what the emitter
|
||||
// writes, the matcher must recognise as the SAME hook. Asserted through the public merge.
|
||||
it("what it emits is still matched as the same hook, so a recompile REPLACES", () => {
|
||||
const ref = ".claude/hooks/x.hook.ts";
|
||||
const merged = mergeHooksJson({ hooks: wire(ref, CC) }, wire(ref, CC), ref);
|
||||
expect(merged.hooks?.PreToolUse).toHaveLength(1);
|
||||
});
|
||||
|
||||
// And the pre-2026-09-10 relative spelling is replaced, so upgrading vigiles REWRITES the
|
||||
// wiring in place rather than leaving a stale relative twin beside the new one.
|
||||
it("the old relative spelling is replaced and rewritten, not duplicated", () => {
|
||||
const ref = ".claude/hooks/x.hook.ts";
|
||||
const merged = mergeHooksJson(
|
||||
{ hooks: wire(ref, undefined) },
|
||||
wire(ref, CC),
|
||||
ref,
|
||||
);
|
||||
const entries = merged.hooks?.PreToolUse ?? [];
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.hooks[0]?.command).toContain("${CLAUDE_PROJECT_DIR}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,33 @@ export function normalizeHookRef(
|
||||
return chosen.split(sep).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* The path token `compile` EMITS into the harness config — anchored at the project root
|
||||
* when the harness declares such a variable.
|
||||
*
|
||||
* 🔴 IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
|
||||
* and these quotes; this one ADDS them. They are one contract read from two ends, and while
|
||||
* the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
|
||||
* anchored spelling, and the emitter went on writing the relative one for three more weeks.
|
||||
*
|
||||
* Why anchored at all, from the two measurements already in this file and in
|
||||
* `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
|
||||
* relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
|
||||
* PreToolUse gate that is not a lost nudge — a gate that cannot load must block, so the
|
||||
* repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
|
||||
* only, because every command was refused, including the one that repairs it.
|
||||
*
|
||||
* `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
|
||||
* it is asserted directly rather than left to inspection.
|
||||
*/
|
||||
export function hookGateRef(
|
||||
ref: string,
|
||||
projectRootTokens: readonly string[] | undefined,
|
||||
): string {
|
||||
const token = projectRootTokens?.[0];
|
||||
return token === undefined ? ref : `"${token}/${ref}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an entry's command routes through the runtime for `hookPath`.
|
||||
*
|
||||
|
||||
+10
-2
@@ -191,7 +191,15 @@ test("compile (hook): a clean hook compiles, MERGES into settings.json, stamps,
|
||||
resolve(dir, ".claude/settings.json"),
|
||||
"utf-8",
|
||||
);
|
||||
assert.match(settings, /hook-runtime run-program guard\.mjs/);
|
||||
// 🔴 ANCHORED, not bare. Until 2026-09-10 this pinned `run-program guard.mjs` — the
|
||||
// relative spelling `bareToken`'s own header calls broken, because it dies the moment the
|
||||
// agent runs from a subdirectory. The assertion ENCODED the defect, so the emitter could
|
||||
// not be fixed without this going red. `settings` is RAW file text, so the quotes around
|
||||
// the path arrive JSON-escaped as \" — matching a bare " would fail on a correct file.
|
||||
assert.match(
|
||||
settings,
|
||||
/hook-runtime run-program \\"\$\{CLAUDE_PROJECT_DIR\}\/guard\.mjs\\"/,
|
||||
);
|
||||
|
||||
// The compiled hook still enforces.
|
||||
const ok = runHook(
|
||||
@@ -257,7 +265,7 @@ test("compile (hook): recompiling is idempotent, whatever the path spelling", ()
|
||||
assert.equal(entries.length, 1, "one wiring per hook file, not four");
|
||||
assert.equal(
|
||||
entries[0].hooks[0].command,
|
||||
"npx vigiles hook-runtime run-program guard.mjs",
|
||||
'npx vigiles hook-runtime run-program "${CLAUDE_PROJECT_DIR}/guard.mjs"',
|
||||
);
|
||||
|
||||
// And the single surviving wiring still enforces.
|
||||
|
||||
Reference in New Issue
Block a user