vigiles 15.0.3 → 15.2.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/cli.js +108 -10
- package/dist/core/bash-effects.d.ts +20 -0
- package/dist/core/bash-effects.js +41 -1
- package/dist/core/hook-program.d.ts +167 -28
- package/dist/core/hook-program.js +251 -37
- package/dist/core/hook-providers.d.ts +22 -5
- package/dist/core/hook-providers.js +13 -1
- package/dist/core/hook-state.d.ts +307 -0
- package/dist/core/hook-state.js +349 -0
- package/dist/core/sidecar.d.ts +16 -0
- package/dist/core/sidecar.js +23 -8
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +18 -1
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.nothing = exports.notice = exports.run = exports.inject = exports.tools = exports.HookCompileError = exports.tool = exports.ask = exports.deny = exports.allow = void 0;
|
|
4
|
+
exports.matchesTool = matchesTool;
|
|
5
|
+
exports.invalidToolPatterns = invalidToolPatterns;
|
|
4
6
|
exports.gateAction = gateAction;
|
|
5
7
|
exports.trimTrailingSeparators = trimTrailingSeparators;
|
|
6
8
|
exports.commandView = commandView;
|
|
@@ -24,9 +26,13 @@ exports.definePromptGate = definePromptGate;
|
|
|
24
26
|
exports.decidePromptGate = decidePromptGate;
|
|
25
27
|
exports.defineStopGate = defineStopGate;
|
|
26
28
|
exports.decideStopGate = decideStopGate;
|
|
29
|
+
exports.defineInject = defineInject;
|
|
27
30
|
exports.runInject = runInject;
|
|
31
|
+
exports.injectionOf = injectionOf;
|
|
28
32
|
exports.responseView = responseView;
|
|
33
|
+
exports.defineReact = defineReact;
|
|
29
34
|
exports.runReact = runReact;
|
|
35
|
+
exports.outcomeWrites = outcomeWrites;
|
|
30
36
|
exports.rememberHookSource = rememberHookSource;
|
|
31
37
|
exports.hookSource = hookSource;
|
|
32
38
|
exports.runHookProgram = runHookProgram;
|
|
@@ -69,6 +75,69 @@ const toml_1 = require("@iarna/toml");
|
|
|
69
75
|
const hook_events_js_1 = require("./hook-events.js");
|
|
70
76
|
const merge_conflict_js_1 = require("./merge-conflict.js");
|
|
71
77
|
const hook_providers_js_1 = require("./hook-providers.js");
|
|
78
|
+
const hook_state_js_1 = require("./hook-state.js");
|
|
79
|
+
/**
|
|
80
|
+
* Does `name` match a hook's declared tool list, under the SAME semantics as the
|
|
81
|
+
* matcher the compiler emits for it?
|
|
82
|
+
*
|
|
83
|
+
* 🔴 IT DID NOT, AND THE DISAGREEMENT WAS SILENT. `hookRouting` joins a react's
|
|
84
|
+
* tools with `|` and emits that as the harness matcher, and a Claude Code matcher
|
|
85
|
+
* is a REGEX — `Edit|Write|MultiEdit` only works because it is one. The runtime
|
|
86
|
+
* meanwhile compared with `Array.includes`, i.e. exact string equality. So a hook
|
|
87
|
+
* declaring a tool FAMILY compiled fine, was wired up fine, was routed to by the
|
|
88
|
+
* harness fine, and was then dropped by vigiles' own filter without a word.
|
|
89
|
+
*
|
|
90
|
+
* MEASURED 2026-08-12 against the real runtime, before the fix:
|
|
91
|
+
*
|
|
92
|
+
* $ echo '{"tool_name":"mcp__4f54037d-0499__list_events",…}' \
|
|
93
|
+
* | vigiles hook-runtime run-program mcp-family.hook.mjs
|
|
94
|
+
* exit=0 # silence — react() never ran
|
|
95
|
+
* $ echo '{"tool_name":"mcp__.*",…}' | …
|
|
96
|
+
* FIRED on mcp__.* # fires only for a tool LITERALLY named "mcp__.*"
|
|
97
|
+
*
|
|
98
|
+
* That is the false-confidence class this whole subsystem exists to eliminate,
|
|
99
|
+
* living inside the subsystem. The live evidence that the harness really does
|
|
100
|
+
* route these: the knowledge base has shipped `"matcher": "mcp__.*"` in
|
|
101
|
+
* `.claude/settings.json` for months and its stamp file was last written the
|
|
102
|
+
* morning this was measured. The MCP server's id changes per session, so an exact
|
|
103
|
+
* list cannot be written down — a family matcher is the only correct spelling.
|
|
104
|
+
*
|
|
105
|
+
* Anchored `^(…)$` so a pattern cannot match a longer tool name by accident, and
|
|
106
|
+
* identical to `includes` for ordinary names, which contain no metacharacters.
|
|
107
|
+
* An unparseable pattern is rejected at COMPILE ({@link invalidToolPatterns}), so
|
|
108
|
+
* the fallback here is unreachable in a compiled hook and exists only so that a
|
|
109
|
+
* hand-constructed one degrades to exact matching rather than throwing mid-event.
|
|
110
|
+
*/
|
|
111
|
+
function matchesTool(tools, name) {
|
|
112
|
+
// An EMPTY list matches NOTHING. Found by a mutation that was meant to disable
|
|
113
|
+
// tool-less reacts and didn't: with no tools the joined pattern is `^()$`,
|
|
114
|
+
// which matches the EMPTY STRING — and a tool-less event's name is the empty
|
|
115
|
+
// string. Without this line `tools()` would quietly be a catch-all on exactly
|
|
116
|
+
// the events where a react has no tool to check. Declaring nothing must mean
|
|
117
|
+
// nothing, not everything.
|
|
118
|
+
if (tools.length === 0)
|
|
119
|
+
return false;
|
|
120
|
+
if (tools.includes(name))
|
|
121
|
+
return true;
|
|
122
|
+
try {
|
|
123
|
+
return new RegExp(`^(${tools.join("|")})$`).test(name);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Tool patterns that are not valid regexes — rejected at compile, see {@link matchesTool}. */
|
|
130
|
+
function invalidToolPatterns(tools) {
|
|
131
|
+
return tools.filter((t) => {
|
|
132
|
+
try {
|
|
133
|
+
new RegExp(`^(${t})$`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
72
141
|
const allow = () => ({ kind: "allow" });
|
|
73
142
|
exports.allow = allow;
|
|
74
143
|
const deny = (reason) => ({ kind: "deny", reason });
|
|
@@ -371,6 +440,18 @@ function writeTargetsOf(leaf) {
|
|
|
371
440
|
return [];
|
|
372
441
|
}
|
|
373
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* A write target as it lands on disk, given the chdir wrapper the leaf ran under
|
|
445
|
+
* (`env -C migratsiya sed -i s/a/b/ papers/x.tex` writes `migratsiya/papers/x.tex`).
|
|
446
|
+
*
|
|
447
|
+
* Absolute targets and `~`-rooted ones already name their directory and are
|
|
448
|
+
* returned untouched. The join itself is {@link resolveRef} rather than a fresh
|
|
449
|
+
* `a + "/" + b`, because two functions normalising the same string differently is
|
|
450
|
+
* the defect class this file keeps finding.
|
|
451
|
+
*/
|
|
452
|
+
const underChdir = (target, chdir) => chdir === null || target === "" || target.startsWith("~")
|
|
453
|
+
? target
|
|
454
|
+
: resolveRef(chdir, target);
|
|
374
455
|
/**
|
|
375
456
|
* An AST-backed view of a Bash command.
|
|
376
457
|
*
|
|
@@ -388,10 +469,20 @@ function commandView(raw, root) {
|
|
|
388
469
|
// The operation-normalized leaves carry the redirections (and quote-unwrapped,
|
|
389
470
|
// wrapper-resolved argv) that `writesTo` needs; `leafCommands` cannot see them.
|
|
390
471
|
const normalized = (0, bash_effects_js_1.leafCommandsNormalized)(raw);
|
|
391
|
-
const
|
|
472
|
+
const allWriteTargets = normalized.flatMap((leaf) => [
|
|
473
|
+
// 🔴 A REDIRECTION IS NOT JOINED ONTO THE LEAF'S CHDIR, AND THAT IS THE
|
|
474
|
+
// SHELL'S RULE, NOT A SHORTCUT. `env -C dir cmd > out.txt` opens `out.txt`
|
|
475
|
+
// in the SHELL's directory — the redirection happens before `env` ever runs
|
|
476
|
+
// and `-C` only moves the process `env` execs. Joining here would report a
|
|
477
|
+
// file the command never writes.
|
|
392
478
|
...leaf.redirects.flatMap((r) => r.writes && r.target !== null ? [r.target] : []),
|
|
393
|
-
|
|
479
|
+
// The wrapped program's own operands DO resolve against it — see
|
|
480
|
+
// `NormalizedLeaf.chdir` for why the value was being read and discarded.
|
|
481
|
+
...writeTargetsOf(leaf).map((t) => underChdir(t, leaf.chdir)),
|
|
394
482
|
]);
|
|
483
|
+
const matchedWriteTargets = (prefixes) => [
|
|
484
|
+
...new Set(allWriteTargets.filter((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match")))),
|
|
485
|
+
];
|
|
395
486
|
return {
|
|
396
487
|
raw,
|
|
397
488
|
runs(program, opts) {
|
|
@@ -432,7 +523,13 @@ function commandView(raw, root) {
|
|
|
432
523
|
? [tok, tok.slice(tok.indexOf("=") + 1)]
|
|
433
524
|
: [tok])
|
|
434
525
|
.some((tok) => prefixes.some((p) => matchesPrefix(prefixVerdict(tok, p, root), "match"))),
|
|
435
|
-
|
|
526
|
+
// DERIVED, not a second implementation of the same rule. The boolean stays
|
|
527
|
+
// because `writesTo(secrets) ? deny() : allow()` is the common gate shape,
|
|
528
|
+
// but it is a PROJECTION of the list — one code path, so the two can never
|
|
529
|
+
// drift the way `runs()` and `writesTo` did (one reads raw leaves, the other
|
|
530
|
+
// normalized ones, and a gate built on both had a silent hole).
|
|
531
|
+
writesTo: (prefixes) => matchedWriteTargets(prefixes).length > 0,
|
|
532
|
+
writeTargets: matchedWriteTargets,
|
|
436
533
|
pipesToShell: () => leaves.some(isBareShellLeaf),
|
|
437
534
|
};
|
|
438
535
|
}
|
|
@@ -523,6 +620,9 @@ function hookRouting(hook) {
|
|
|
523
620
|
hook.role === "prompt-gate" ||
|
|
524
621
|
hook.role === "stop-gate")
|
|
525
622
|
return { on: hook.on };
|
|
623
|
+
// A react MAY also be tool-less (Stop/SessionEnd) — same shape, same reason.
|
|
624
|
+
if (hook.match === undefined)
|
|
625
|
+
return { on: hook.on };
|
|
526
626
|
return { on: hook.on, matcher: hook.match.tools.join("|") };
|
|
527
627
|
}
|
|
528
628
|
return { on: hook.on, matcher: hook.match.tool };
|
|
@@ -569,6 +669,16 @@ function compileHookProgram(source, hook, opts = {}) {
|
|
|
569
669
|
throw new HookCompileError(`hook program uses capabilities outside \`${ALLOWED_IMPORT}\`: ${violations.join(", ")} — only the sanctioned API is allowed (capability = API surface).`);
|
|
570
670
|
}
|
|
571
671
|
const { on, matcher: rawMatcher } = hookRouting(hook);
|
|
672
|
+
// A tool pattern that is not a valid regex cannot match under the semantics the
|
|
673
|
+
// emitted matcher is read with, so it would compile to a hook that never fires.
|
|
674
|
+
// Reject it here rather than let `matchesTool` fall back silently.
|
|
675
|
+
if ("match" in hook && hook.match !== undefined && "tools" in hook.match) {
|
|
676
|
+
const bad = invalidToolPatterns(hook.match.tools);
|
|
677
|
+
if (bad.length > 0) {
|
|
678
|
+
throw new HookCompileError(`invalid tool matcher pattern(s): ${bad.join(", ")} — a tool matcher is a ` +
|
|
679
|
+
`regex (that is why "Edit|Write" works), so it must parse as one.`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
572
682
|
// A hook registered under an event the harness never fires is dead — reject it.
|
|
573
683
|
if (opts.dialect) {
|
|
574
684
|
const issues = (0, hook_events_js_1.verifyHookEvents)([on], opts.dialect);
|
|
@@ -809,7 +919,8 @@ function defineFileGate(p) {
|
|
|
809
919
|
*/
|
|
810
920
|
function decideFileGate(hook, raw, ctx = {}, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
|
|
811
921
|
const t = raw.tool_name ?? "";
|
|
812
|
-
|
|
922
|
+
// Same matcher semantics as the emitted settings block — see {@link matchesTool}.
|
|
923
|
+
if (!matchesTool(hook.match.tools, t))
|
|
813
924
|
return (0, exports.allow)();
|
|
814
925
|
const fp = typeof raw.tool_input?.file_path === "string"
|
|
815
926
|
? raw.tool_input.file_path
|
|
@@ -844,23 +955,30 @@ function decideStopGate(hook, raw, ctx = {}) {
|
|
|
844
955
|
ctx: ctx,
|
|
845
956
|
});
|
|
846
957
|
}
|
|
847
|
-
|
|
958
|
+
/**
|
|
959
|
+
* Context to add, plus any facts that just became true:
|
|
960
|
+
* `inject(text, record("calendar.nagged"))`.
|
|
961
|
+
*
|
|
962
|
+
* The writes are trailing arguments on every output builder, so there is one rule
|
|
963
|
+
* to learn rather than a per-role spelling — and a hook that records nothing is
|
|
964
|
+
* written exactly as it was before.
|
|
965
|
+
*/
|
|
966
|
+
const inject = (context, ...records) => ({
|
|
848
967
|
kind: "inject",
|
|
849
968
|
context,
|
|
969
|
+
records,
|
|
850
970
|
});
|
|
851
971
|
exports.inject = inject;
|
|
852
|
-
|
|
853
|
-
role: "inject",
|
|
854
|
-
|
|
855
|
-
});
|
|
856
|
-
exports.defineInject = defineInject;
|
|
972
|
+
function defineInject(p) {
|
|
973
|
+
return { role: "inject", ...p };
|
|
974
|
+
}
|
|
857
975
|
/**
|
|
858
976
|
* Run an inject hook → the CC JSON the author never hand-writes. The compiler
|
|
859
977
|
* targets `additionalContext` (the RIGHT field for this event), so the
|
|
860
978
|
* wrong-JSON-field pain can't occur.
|
|
861
979
|
*/
|
|
862
|
-
function runInject(hook, raw) {
|
|
863
|
-
const out =
|
|
980
|
+
function runInject(hook, raw, ctx = {}) {
|
|
981
|
+
const out = injectionOf(hook, raw, ctx);
|
|
864
982
|
return {
|
|
865
983
|
hookSpecificOutput: {
|
|
866
984
|
hookEventName: hook.on,
|
|
@@ -868,6 +986,14 @@ function runInject(hook, raw) {
|
|
|
868
986
|
},
|
|
869
987
|
};
|
|
870
988
|
}
|
|
989
|
+
/** The full {@link Injection} — the runtime needs its `records`, which the CC JSON drops. */
|
|
990
|
+
function injectionOf(hook, raw, ctx = {}) {
|
|
991
|
+
return hook.produce({
|
|
992
|
+
event: hook.on,
|
|
993
|
+
source: raw.source ?? "startup",
|
|
994
|
+
ctx: ctx,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
871
997
|
/** Normalize an arbitrary tool_response payload to text (object → JSON). */
|
|
872
998
|
function responseText(raw) {
|
|
873
999
|
if (typeof raw === "string")
|
|
@@ -888,36 +1014,55 @@ function responseView(raw) {
|
|
|
888
1014
|
contains: (needle) => text.includes(needle),
|
|
889
1015
|
};
|
|
890
1016
|
}
|
|
891
|
-
/**
|
|
892
|
-
|
|
1017
|
+
/**
|
|
1018
|
+
* Run a command in reaction — its effect is classified AT CONSTRUCTION
|
|
1019
|
+
* (audit/diff-able). Trailing arguments record facts.
|
|
1020
|
+
*
|
|
1021
|
+
* ⚠️ `run()` is for invoking a real TOOL. Using it to write a stamp
|
|
1022
|
+
* (`run("date +%s > .claude/.stamp")`) was the only way to remember anything
|
|
1023
|
+
* before `record()` existed; it is now the wrong tool — it spends a subprocess
|
|
1024
|
+
* and a shell on a variable assignment, and it classifies as side-effecting.
|
|
1025
|
+
*/
|
|
1026
|
+
const run = (command, ...records) => ({
|
|
893
1027
|
kind: "run",
|
|
894
1028
|
command,
|
|
895
1029
|
effect: (0, bash_effects_js_1.classifyBashCommand)(command),
|
|
1030
|
+
records,
|
|
896
1031
|
});
|
|
897
1032
|
exports.run = run;
|
|
898
|
-
/** Surface a non-blocking note (no execution). */
|
|
899
|
-
const notice = (message) => ({
|
|
1033
|
+
/** Surface a non-blocking note (no execution). Trailing arguments record facts. */
|
|
1034
|
+
const notice = (message, ...records) => ({
|
|
900
1035
|
kind: "notice",
|
|
901
1036
|
message,
|
|
1037
|
+
records,
|
|
902
1038
|
});
|
|
903
1039
|
exports.notice = notice;
|
|
904
|
-
/**
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1040
|
+
/**
|
|
1041
|
+
* Take no action. Trailing arguments still record facts — `nothing(record("x"))`
|
|
1042
|
+
* is the shape of a hook whose entire job is to WITNESS that something happened
|
|
1043
|
+
* (an MCP call, a deploy) so a different hook can read it later.
|
|
1044
|
+
*/
|
|
1045
|
+
const nothing = (...records) => ({
|
|
1046
|
+
kind: "none",
|
|
1047
|
+
records,
|
|
910
1048
|
});
|
|
911
|
-
exports.
|
|
1049
|
+
exports.nothing = nothing;
|
|
1050
|
+
function defineReact(p) {
|
|
1051
|
+
return { role: "react", ...p };
|
|
1052
|
+
}
|
|
912
1053
|
/**
|
|
913
1054
|
* Run a react hook against a raw PostToolUse event → the (classified) Reaction.
|
|
914
1055
|
*
|
|
915
1056
|
* `root` behaves exactly as in {@link decideFileGate}: the event's own `cwd` by
|
|
916
|
-
* default, the CLI's {@link projectRootOf} when the runtime supplies one.
|
|
1057
|
+
* default, the CLI's {@link projectRootOf} when the runtime supplies one. It
|
|
1058
|
+
* trails `ctx` so the argument order matches {@link decideProgram} and
|
|
1059
|
+
* {@link decideFileGate} — every decode function reads `(hook, raw, ctx, root)`.
|
|
917
1060
|
*/
|
|
918
|
-
function runReact(hook, raw, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
|
|
1061
|
+
function runReact(hook, raw, ctx = {}, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
|
|
919
1062
|
const t = raw.tool_name ?? "";
|
|
920
|
-
|
|
1063
|
+
// No `match` → a tool-less event (Stop/SessionEnd): there is nothing to filter
|
|
1064
|
+
// on, and filtering on the empty string is how these hooks used to die.
|
|
1065
|
+
if (hook.match !== undefined && !matchesTool(hook.match.tools, t))
|
|
921
1066
|
return (0, exports.nothing)();
|
|
922
1067
|
const fp = typeof raw.tool_input?.file_path === "string"
|
|
923
1068
|
? raw.tool_input.file_path
|
|
@@ -927,8 +1072,23 @@ function runReact(hook, raw, root = typeof raw.cwd === "string" ? raw.cwd : unde
|
|
|
927
1072
|
tool: t,
|
|
928
1073
|
path: pathView(fp, root),
|
|
929
1074
|
response: responseView(raw.tool_response),
|
|
1075
|
+
ctx: ctx,
|
|
930
1076
|
});
|
|
931
1077
|
}
|
|
1078
|
+
/**
|
|
1079
|
+
* The state writes an outcome declares, filtered to the ones the runtime may
|
|
1080
|
+
* actually perform. A gate's `Decision` carries none — deliberately: a gate is
|
|
1081
|
+
* the role that must be trustworthy and runs on every tool call, so it READS
|
|
1082
|
+
* state (via `needs`) and never writes it. Adding a write there later is easy;
|
|
1083
|
+
* removing one would not be.
|
|
1084
|
+
*/
|
|
1085
|
+
function outcomeWrites(outcome) {
|
|
1086
|
+
if (outcome.kind === "injection")
|
|
1087
|
+
return (0, hook_state_js_1.admissibleWrites)(outcome.records);
|
|
1088
|
+
if (outcome.kind === "reaction")
|
|
1089
|
+
return (0, hook_state_js_1.admissibleWrites)(outcome.reaction.records);
|
|
1090
|
+
return (0, hook_state_js_1.admissibleWrites)([]);
|
|
1091
|
+
}
|
|
932
1092
|
/**
|
|
933
1093
|
* The file a hook program was LOADED from — the one coverage attribution in this
|
|
934
1094
|
* codebase that needs no parsing at all.
|
|
@@ -989,16 +1149,14 @@ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
|
|
|
989
1149
|
kind: "decision",
|
|
990
1150
|
decision: decideStopGate(hook, event, ctx),
|
|
991
1151
|
};
|
|
992
|
-
case "inject":
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
.additionalContext,
|
|
997
|
-
};
|
|
1152
|
+
case "inject": {
|
|
1153
|
+
const out = injectionOf(hook, event, ctx);
|
|
1154
|
+
return { kind: "injection", context: out.context, records: out.records };
|
|
1155
|
+
}
|
|
998
1156
|
case "react":
|
|
999
1157
|
return {
|
|
1000
1158
|
kind: "reaction",
|
|
1001
|
-
reaction: runReact(hook, event, root),
|
|
1159
|
+
reaction: runReact(hook, event, ctx, root),
|
|
1002
1160
|
};
|
|
1003
1161
|
default:
|
|
1004
1162
|
return (0, hash_js_1.assertNever)(kind);
|
|
@@ -1027,6 +1185,48 @@ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
|
|
|
1027
1185
|
function isAbsoluteRef(ref) {
|
|
1028
1186
|
return ref.startsWith("/") || /^[A-Za-z]:\//.test(ref);
|
|
1029
1187
|
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Does this ROOT name a Windows filesystem? One predicate, because the two
|
|
1190
|
+
* questions that ask it — how many segments `..` may not pop through, and
|
|
1191
|
+
* whether a `//` leader is a share or a stutter — must never disagree about the
|
|
1192
|
+
* same root. `//x` is caught by the second arm: too short to be a share, but
|
|
1193
|
+
* still not something to read as POSIX.
|
|
1194
|
+
*/
|
|
1195
|
+
const namesWindowsFs = (root) => WINDOWS_ROOT.test(root) || root.startsWith("//");
|
|
1196
|
+
/**
|
|
1197
|
+
* How many leading segments of a resolved path ARE its root — the floor a `..`
|
|
1198
|
+
* must not pop through. `0` on POSIX, `1` for a drive (`C:`), `2` for a UNC
|
|
1199
|
+
* share (`//server/share`), and 2/4 for the `//?/` spellings of those two.
|
|
1200
|
+
*
|
|
1201
|
+
* 🔴 A `//` LEADER IS THE ROOT'S ANSWER, NEVER THE OPERAND'S — the round-37
|
|
1202
|
+
* lesson at a third site (after the case fold in {@link caseInsensitiveFs} and
|
|
1203
|
+
* the UNC leader in {@link resolveRef}). Read from the string alone,
|
|
1204
|
+
* `//repo/a/../src/x.ts` looks share-rooted, so a count taken off the operand
|
|
1205
|
+
* would guard `repo/a` and stop `..` collapsing at all — under a POSIX root that
|
|
1206
|
+
* doubled slash is a stutter, not a share. The `//` forms are therefore gated on
|
|
1207
|
+
* the root, exactly as the leader is.
|
|
1208
|
+
*
|
|
1209
|
+
* ⚠️ A DRIVE LETTER IS THE ONE THING THAT NAMES A WINDOWS FILESYSTEM BY ITSELF,
|
|
1210
|
+
* and that is not a second rule — {@link isAtOrUnder} already says it about a
|
|
1211
|
+
* base (`WINDOWS_ROOT.test(rawBase)`), because `C:/x` has no POSIX reading the
|
|
1212
|
+
* way `//x` does. It earns its place at a real call site rather than in the
|
|
1213
|
+
* abstract: {@link absoluteSpelling} resolves a ROOTLESS absolute path against
|
|
1214
|
+
* the literal `"/"`, so gating the drive on the root too would leave
|
|
1215
|
+
* `C:/../repo/x` still losing its drive right there — the sibling call site a
|
|
1216
|
+
* fix written only where the defect was found would have missed.
|
|
1217
|
+
*
|
|
1218
|
+
* The count itself is read off {@link WINDOWS_ROOT} rather than re-derived,
|
|
1219
|
+
* because that regex already IS this file's single answer to "what is a Windows
|
|
1220
|
+
* root"; the two forms and the `//?/` spelling are enumerated there, once.
|
|
1221
|
+
*/
|
|
1222
|
+
const rootSegmentCount = (joined, root) => {
|
|
1223
|
+
const matched = WINDOWS_ROOT.exec(joined)?.[0];
|
|
1224
|
+
if (matched === undefined)
|
|
1225
|
+
return 0; // POSIX, or relative: no root inside `out`
|
|
1226
|
+
if (/^[/\\]/.test(matched) && !namesWindowsFs(root))
|
|
1227
|
+
return 0; // stutter, not share
|
|
1228
|
+
return matched.split(/[/\\]+/).filter((s) => s !== "").length;
|
|
1229
|
+
};
|
|
1030
1230
|
/**
|
|
1031
1231
|
* Resolve a path reference against a root, without node:path (core stays
|
|
1032
1232
|
* dependency-free). Mirrors `resolve(root, ref)`: an absolute ref wins, a
|
|
@@ -1048,12 +1248,27 @@ function resolveRef(root, ref) {
|
|
|
1048
1248
|
const joined = isAbsoluteRef(r)
|
|
1049
1249
|
? r
|
|
1050
1250
|
: `${slashes(root).replace(/\/+$/, "")}/${r}`;
|
|
1251
|
+
// 🔴 `..` CLAMPS AT THE ROOT, IT DOES NOT EAT IT. A real filesystem holds
|
|
1252
|
+
// still at the top: on Windows `C:/..` is `C:/`, on POSIX `/..` is `/`. An
|
|
1253
|
+
// unconditional `out.pop()` popped the DRIVE LETTER out of `C:/../repo/src/x`,
|
|
1254
|
+
// leaving the relative-looking `repo/src/x` — which then failed to resolve
|
|
1255
|
+
// against `C:/repo`, so a gate stopped recognising a path naming its own
|
|
1256
|
+
// repository. A UNC share went one worse: two `..` ate `share` and then
|
|
1257
|
+
// `server`.
|
|
1258
|
+
//
|
|
1259
|
+
// ⚠️ POSIX WAS ALREADY CORRECT BY ACCIDENT, and the accident is worth naming
|
|
1260
|
+
// so nobody "simplifies" it back: its leader `/` is held OUTSIDE `out` (see
|
|
1261
|
+
// the leader below), so popping an empty array is already the clamp. The
|
|
1262
|
+
// Windows forms broke precisely because their root lives INSIDE `out` —
|
|
1263
|
+
// exactly the segments the loop treats as ordinary directories.
|
|
1264
|
+
const floor = rootSegmentCount(joined, root);
|
|
1051
1265
|
const out = [];
|
|
1052
1266
|
for (const seg of joined.split("/")) {
|
|
1053
1267
|
if (seg === "" || seg === ".")
|
|
1054
1268
|
continue;
|
|
1055
1269
|
if (seg === "..") {
|
|
1056
|
-
out.
|
|
1270
|
+
if (out.length > floor)
|
|
1271
|
+
out.pop();
|
|
1057
1272
|
continue;
|
|
1058
1273
|
}
|
|
1059
1274
|
out.push(seg);
|
|
@@ -1071,8 +1286,7 @@ function resolveRef(root, ref) {
|
|
|
1071
1286
|
// stutter, so preserving the pair unconditionally made it stop resolving
|
|
1072
1287
|
// against a POSIX root and an allowlist gate denied a valid edit. Semantics
|
|
1073
1288
|
// belong to the filesystem, and only the root knows which one that is.
|
|
1074
|
-
const unc = joined.startsWith("//") &&
|
|
1075
|
-
(WINDOWS_ROOT.test(root) || root.startsWith("//"));
|
|
1289
|
+
const unc = joined.startsWith("//") && namesWindowsFs(root);
|
|
1076
1290
|
const leader = unc ? "//" : joined.startsWith("/") ? "/" : "";
|
|
1077
1291
|
return leader + out.join("/");
|
|
1078
1292
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type StateEntry, type StateFact, type StateNeed } from "./hook-state.js";
|
|
1
2
|
/** The closed set of built-in facts a gate may declare via `needs`, with value types. */
|
|
2
3
|
export interface ProviderResults {
|
|
3
4
|
/** The current git branch, or "" outside a git repo / on an unborn HEAD. */
|
|
@@ -27,8 +28,16 @@ export interface InlineProvider<Name extends string = string> {
|
|
|
27
28
|
readonly run: string;
|
|
28
29
|
readonly dangerous: boolean;
|
|
29
30
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A `needs` entry — a built-in name, an inline `provide`/`dangerously`, a
|
|
33
|
+
* `provider()` ref, or a `state()` read of a fact some hook recorded.
|
|
34
|
+
*
|
|
35
|
+
* `state()` rides this union rather than getting its own accessor so that ALL of
|
|
36
|
+
* a hook's external inputs stay in one declared list: the dependency is auditable
|
|
37
|
+
* from outside the hook, and reading an undeclared one is a `tsc` error. See the
|
|
38
|
+
* design note in `hook-state.ts`.
|
|
39
|
+
*/
|
|
40
|
+
export type NeedSpec = ProviderName | InlineProvider | RegisteredRef | StateNeed;
|
|
32
41
|
/**
|
|
33
42
|
* Declare an INLINE read-only fact: `provide("k8sCtx", "kubectl config current-context")`.
|
|
34
43
|
* The command MUST be provably read-only (compile rejects it otherwise — use
|
|
@@ -71,8 +80,8 @@ export interface RegisteredRef<Name extends string = string> {
|
|
|
71
80
|
export declare const provider: <const Name extends string>(name: Name) => RegisteredRef<Name>;
|
|
72
81
|
/** name → its registered provider; the runtime resolves a `provider(name)` ref against this. */
|
|
73
82
|
export type ProviderRegistry = Record<string, RegisteredProvider>;
|
|
74
|
-
type NeedName<E extends NeedSpec> = E extends ProviderName ? E : E extends InlineProvider<infer Nm> ? Nm : E extends RegisteredRef<infer Rn> ? Rn : never;
|
|
75
|
-
type NeedValue<E extends NeedSpec> = E extends ProviderName ? ProviderResults[E] : string;
|
|
83
|
+
type NeedName<E extends NeedSpec> = E extends ProviderName ? E : E extends InlineProvider<infer Nm> ? Nm : E extends RegisteredRef<infer Rn> ? Rn : E extends StateNeed<infer Sn> ? Sn : never;
|
|
84
|
+
type NeedValue<E extends NeedSpec> = E extends ProviderName ? ProviderResults[E] : E extends StateNeed ? StateFact : string;
|
|
76
85
|
/**
|
|
77
86
|
* The typed `e.ctx` for a hook that declared `needs: N` — ONLY the declared facts
|
|
78
87
|
* are present (built-in name → its typed value, inline → string), so reading an
|
|
@@ -95,6 +104,14 @@ export interface ProviderIO {
|
|
|
95
104
|
readonly platform: NodeJS.Platform;
|
|
96
105
|
/** Whether the process is on a CI server (the CLI injects `ci-info`'s verdict). */
|
|
97
106
|
readonly isCI: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Read one recorded fact from THIS hook's state namespace, or `null` if it was
|
|
109
|
+
* never recorded. The namespace is resolved by the caller, so a key can never
|
|
110
|
+
* address another owner's store — see `hook-state.ts`.
|
|
111
|
+
*/
|
|
112
|
+
readonly readState: (key: string) => StateEntry | null;
|
|
113
|
+
/** Epoch milliseconds, injected so fact ages are pinnable in a test. */
|
|
114
|
+
readonly now: number;
|
|
98
115
|
}
|
|
99
116
|
interface ProviderDef<K extends ProviderName> {
|
|
100
117
|
/**
|
|
@@ -133,6 +150,6 @@ export declare function unsafeProvider(def: RegisteredProvider): boolean;
|
|
|
133
150
|
* throws. Pure over the injected `io` (CLI passes a real execSync; tests a fake)
|
|
134
151
|
* and the `registry` (the loaded `.vigiles/providers/`, for `provider()` refs).
|
|
135
152
|
*/
|
|
136
|
-
export declare function gatherContext(needs: readonly NeedSpec[], io: ProviderIO, registry?: ProviderRegistry): Record<string, string | boolean>;
|
|
153
|
+
export declare function gatherContext(needs: readonly NeedSpec[], io: ProviderIO, registry?: ProviderRegistry): Record<string, string | boolean | StateFact>;
|
|
137
154
|
export {};
|
|
138
155
|
//# sourceMappingURL=hook-providers.d.ts.map
|
|
@@ -26,6 +26,7 @@ exports.gatherContext = gatherContext;
|
|
|
26
26
|
* testable with a fake exec and core depends on no child_process.
|
|
27
27
|
*/
|
|
28
28
|
const bash_effects_js_1 = require("./bash-effects.js");
|
|
29
|
+
const hook_state_js_1 = require("./hook-state.js");
|
|
29
30
|
/**
|
|
30
31
|
* Declare an INLINE read-only fact: `provide("k8sCtx", "kubectl config current-context")`.
|
|
31
32
|
* The command MUST be provably read-only (compile rejects it otherwise — use
|
|
@@ -105,6 +106,11 @@ function unknownProviders(needs, registeredNames = []) {
|
|
|
105
106
|
for (const n of needs) {
|
|
106
107
|
if (isInline(n))
|
|
107
108
|
continue;
|
|
109
|
+
// A `state()` key is self-defining: it resolves to "never recorded" until
|
|
110
|
+
// some hook records it, which is a legitimate steady state (the very first
|
|
111
|
+
// run of every throttled hook), not a dangling reference.
|
|
112
|
+
if ((0, hook_state_js_1.isStateNeed)(n))
|
|
113
|
+
continue;
|
|
108
114
|
if (isRef(n)) {
|
|
109
115
|
if (!registered.has(n.name))
|
|
110
116
|
out.push(n.name);
|
|
@@ -141,7 +147,13 @@ function unsafeProvider(def) {
|
|
|
141
147
|
function gatherContext(needs, io, registry = {}) {
|
|
142
148
|
const ctx = {};
|
|
143
149
|
for (const need of needs) {
|
|
144
|
-
if (
|
|
150
|
+
if ((0, hook_state_js_1.isStateNeed)(need)) {
|
|
151
|
+
// The one need that reaches no subprocess: the trusted runtime hands the
|
|
152
|
+
// stored entry (or null) straight in, and the fact view does the clamping
|
|
153
|
+
// every shell stamp-reader used to re-implement by hand.
|
|
154
|
+
ctx[need.name] = (0, hook_state_js_1.stateFact)(io.readState(need.name), io.now);
|
|
155
|
+
}
|
|
156
|
+
else if (isInline(need))
|
|
145
157
|
ctx[need.name] = tryExec(io, need.run);
|
|
146
158
|
else if (isRef(need)) {
|
|
147
159
|
const def = registry[need.name];
|