vigiles 26.2.0 → 27.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/audit-score.js +1 -1
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +1 -1
- package/dist/cli-main.d.ts +146 -0
- package/dist/cli-main.js +6811 -0
- package/dist/cli.d.ts +42 -126
- package/dist/cli.js +76 -7278
- package/dist/core/bash-effects.js +44 -29
- package/dist/core/compile.d.ts +0 -6
- package/dist/core/compile.js +16 -6
- package/dist/core/hook-program.d.ts +0 -6
- package/dist/core/hook-program.js +48 -7
- package/dist/core/linters.d.ts +11 -2
- package/dist/core/linters.js +12 -3
- package/dist/core/types.d.ts +4 -2
- package/dist/coverage-evidence.d.ts +1 -1
- package/dist/coverage-evidence.js +2 -2
- package/dist/exclude.js +8 -72
- package/dist/hook-install.js +8 -2
- package/dist/hook-runtime.d.ts +63 -0
- package/dist/hook-runtime.js +540 -0
- package/dist/hook.d.ts +1 -1
- package/dist/hook.js +2 -3
- package/dist/test-coverage.d.ts +3 -3
- package/dist/test-coverage.js +7 -7
- package/package.json +1 -1
|
@@ -32,11 +32,26 @@ exports.stripWrappers = stripWrappers;
|
|
|
32
32
|
exports.leafCommandsNormalized = leafCommandsNormalized;
|
|
33
33
|
exports.leafArgvSource = leafArgvSource;
|
|
34
34
|
exports.commandWords = commandWords;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The parser is loaded ON FIRST PARSE, not at module load — `sh()` memoizes it.
|
|
37
|
+
*
|
|
38
|
+
* WHY, measured 2026-09-08 (Node 22.22.2, `tools/measure-hook-startup.mjs`):
|
|
39
|
+
* `require("mvdan-sh")` is a GopherJS build and costs ~100 ms on top of a 42 ms
|
|
40
|
+
* bare Node start. This module sits in the import graph of `core/hook-program.ts`
|
|
41
|
+
* (and `core/hook-providers.ts`), which the compiled-hook RUNTIME loads on EVERY
|
|
42
|
+
* matching tool call. Only a BASH predicate ever parses: `decideProgram` returns
|
|
43
|
+
* `allow()` before building a `commandView` when the tool is not Bash, so a file
|
|
44
|
+
* gate, an inject, a react or a stop gate never reaches this file's functions at
|
|
45
|
+
* all — and used to pay ~100 ms per tool call for a parser it never called.
|
|
46
|
+
*
|
|
47
|
+
* The deferral is CLEAN because the boundary is real, not a trick: every entry
|
|
48
|
+
* point here starts by parsing, so there is no path that touches `sh()` without
|
|
49
|
+
* needing the parser. A bash gate still pays, and that cost is the work it asked
|
|
50
|
+
* for. Do NOT hoist this back to a top-level `require` — `src/hook-runtime-graph.test.ts`
|
|
51
|
+
* asserts `mvdan-sh` is absent from a non-Bash decision's module graph.
|
|
52
|
+
*/
|
|
53
|
+
let _sh;
|
|
54
|
+
const sh = () => (_sh ??= require("mvdan-sh"));
|
|
40
55
|
// ---------------------------------------------------------------------------
|
|
41
56
|
// Redirect operator codes from mvdan-sh (empirically confirmed via probe).
|
|
42
57
|
// Op values:
|
|
@@ -234,7 +249,7 @@ function getLiteral(word) {
|
|
|
234
249
|
const part = word.Parts[0];
|
|
235
250
|
if (!part)
|
|
236
251
|
return null;
|
|
237
|
-
if (sh.syntax.NodeType(part) === "Lit")
|
|
252
|
+
if (sh().syntax.NodeType(part) === "Lit")
|
|
238
253
|
return part.Value ?? null;
|
|
239
254
|
return null;
|
|
240
255
|
}
|
|
@@ -245,7 +260,7 @@ function getLiteralDeep(word) {
|
|
|
245
260
|
const p = word.Parts[0];
|
|
246
261
|
if (!p)
|
|
247
262
|
return null;
|
|
248
|
-
const t = sh.syntax.NodeType(p);
|
|
263
|
+
const t = sh().syntax.NodeType(p);
|
|
249
264
|
if (t === "Lit")
|
|
250
265
|
return p.Value ?? null;
|
|
251
266
|
if (t === "DblQuoted") {
|
|
@@ -253,7 +268,7 @@ function getLiteralDeep(word) {
|
|
|
253
268
|
if (!inner.Parts || inner.Parts.length !== 1)
|
|
254
269
|
return null;
|
|
255
270
|
const ip = inner.Parts[0];
|
|
256
|
-
if (!ip || sh.syntax.NodeType(ip) !== "Lit")
|
|
271
|
+
if (!ip || sh().syntax.NodeType(ip) !== "Lit")
|
|
257
272
|
return null;
|
|
258
273
|
return ip.Value ?? null;
|
|
259
274
|
}
|
|
@@ -346,7 +361,7 @@ function classifyStmt(stmt) {
|
|
|
346
361
|
}
|
|
347
362
|
/** Classify a Cmd node (the typed command inside a Stmt). */
|
|
348
363
|
function classifyCmd(cmd) {
|
|
349
|
-
const t = sh.syntax.NodeType(cmd);
|
|
364
|
+
const t = sh().syntax.NodeType(cmd);
|
|
350
365
|
switch (t) {
|
|
351
366
|
case "CallExpr":
|
|
352
367
|
return classifyCall(cmd);
|
|
@@ -386,10 +401,10 @@ function classifyStmtList(stmts) {
|
|
|
386
401
|
/** Returns true if the AST contains a ProcSubst node anywhere. */
|
|
387
402
|
function hasProcSubst(root) {
|
|
388
403
|
let found = false;
|
|
389
|
-
sh.syntax.Walk(root, (node) => {
|
|
404
|
+
sh().syntax.Walk(root, (node) => {
|
|
390
405
|
if (found)
|
|
391
406
|
return false;
|
|
392
|
-
if (sh.syntax.NodeType(node) === "ProcSubst") {
|
|
407
|
+
if (sh().syntax.NodeType(node) === "ProcSubst") {
|
|
393
408
|
found = true;
|
|
394
409
|
return false;
|
|
395
410
|
}
|
|
@@ -411,7 +426,7 @@ function hasProcSubst(root) {
|
|
|
411
426
|
function classifyBashCommand(command) {
|
|
412
427
|
let file;
|
|
413
428
|
try {
|
|
414
|
-
file = sh.syntax.NewParser().Parse(command, "cmd.sh");
|
|
429
|
+
file = sh().syntax.NewParser().Parse(command, "cmd.sh");
|
|
415
430
|
}
|
|
416
431
|
catch {
|
|
417
432
|
// mvdan-sh throws a Go error object (not an Error instance) on parse failure.
|
|
@@ -443,14 +458,14 @@ function isReadOnlyBash(command) {
|
|
|
443
458
|
function leafCommands(command) {
|
|
444
459
|
let file;
|
|
445
460
|
try {
|
|
446
|
-
file = sh.syntax.NewParser().Parse(command, "cmd.sh");
|
|
461
|
+
file = sh().syntax.NewParser().Parse(command, "cmd.sh");
|
|
447
462
|
}
|
|
448
463
|
catch {
|
|
449
464
|
return [];
|
|
450
465
|
}
|
|
451
466
|
const out = [];
|
|
452
|
-
sh.syntax.Walk(file, (node) => {
|
|
453
|
-
if (sh.syntax.NodeType(node) === "CallExpr" && node.Args) {
|
|
467
|
+
sh().syntax.Walk(file, (node) => {
|
|
468
|
+
if (sh().syntax.NodeType(node) === "CallExpr" && node.Args) {
|
|
454
469
|
const argv = node.Args.map((w) => getLiteral(w)).filter((s) => s !== null);
|
|
455
470
|
if (argv.length > 0)
|
|
456
471
|
out.push(argv);
|
|
@@ -489,7 +504,7 @@ function normalizeParts(parts, inDoubleQuotes = false, unescape = false) {
|
|
|
489
504
|
return null;
|
|
490
505
|
let out = "";
|
|
491
506
|
for (const p of parts) {
|
|
492
|
-
const t = sh.syntax.NodeType(p);
|
|
507
|
+
const t = sh().syntax.NodeType(p);
|
|
493
508
|
if (t === "Lit") {
|
|
494
509
|
out += unescape
|
|
495
510
|
? unescapeLit(p.Value ?? "", inDoubleQuotes)
|
|
@@ -782,14 +797,14 @@ function stripWrappers(argv) {
|
|
|
782
797
|
function leafCommandsNormalized(command) {
|
|
783
798
|
let file;
|
|
784
799
|
try {
|
|
785
|
-
file = sh.syntax.NewParser().Parse(command, "cmd.sh");
|
|
800
|
+
file = sh().syntax.NewParser().Parse(command, "cmd.sh");
|
|
786
801
|
}
|
|
787
802
|
catch {
|
|
788
803
|
return [];
|
|
789
804
|
}
|
|
790
805
|
const out = [];
|
|
791
|
-
sh.syntax.Walk(file, (node) => {
|
|
792
|
-
if (sh.syntax.NodeType(node) !== "Stmt" || !node.Cmd)
|
|
806
|
+
sh().syntax.Walk(file, (node) => {
|
|
807
|
+
if (sh().syntax.NodeType(node) !== "Stmt" || !node.Cmd)
|
|
793
808
|
return true;
|
|
794
809
|
const leaf = normalizeCallExpr(node.Cmd, node.Redirs ?? []);
|
|
795
810
|
if (leaf)
|
|
@@ -815,7 +830,7 @@ function sourceParts(parts) {
|
|
|
815
830
|
return null;
|
|
816
831
|
let out = "";
|
|
817
832
|
for (const p of parts) {
|
|
818
|
-
const t = sh.syntax.NodeType(p);
|
|
833
|
+
const t = sh().syntax.NodeType(p);
|
|
819
834
|
if (t === "Lit" || t === "SglQuoted") {
|
|
820
835
|
out += p.Value ?? "";
|
|
821
836
|
}
|
|
@@ -880,7 +895,7 @@ function sourceParts(parts) {
|
|
|
880
895
|
function leafArgvSource(command) {
|
|
881
896
|
let file;
|
|
882
897
|
try {
|
|
883
|
-
file = sh.syntax.NewParser().Parse(command, "cmd.sh");
|
|
898
|
+
file = sh().syntax.NewParser().Parse(command, "cmd.sh");
|
|
884
899
|
}
|
|
885
900
|
catch {
|
|
886
901
|
return [];
|
|
@@ -913,7 +928,7 @@ function leafArgvSource(command) {
|
|
|
913
928
|
const descend = (node) => {
|
|
914
929
|
if (!node)
|
|
915
930
|
return false;
|
|
916
|
-
switch (sh.syntax.NodeType(node)) {
|
|
931
|
+
switch (sh().syntax.NodeType(node)) {
|
|
917
932
|
case "Stmt":
|
|
918
933
|
// `cmd &` runs in a background SUBSHELL, so a terminator inside it never
|
|
919
934
|
// reaches this shell (measured: `exit 0 & ./x.sh` runs `./x.sh`).
|
|
@@ -1002,14 +1017,14 @@ function terminates(call) {
|
|
|
1002
1017
|
*/
|
|
1003
1018
|
function mayTerminate(node) {
|
|
1004
1019
|
let found = false;
|
|
1005
|
-
sh.syntax.Walk(node, (n) => {
|
|
1020
|
+
sh().syntax.Walk(node, (n) => {
|
|
1006
1021
|
if (found)
|
|
1007
1022
|
return false;
|
|
1008
1023
|
// A function BODY is not executed where it is written, so a `return`/`exit`
|
|
1009
1024
|
// inside one says nothing about control here.
|
|
1010
|
-
if (sh.syntax.NodeType(n) === "FuncDecl")
|
|
1025
|
+
if (sh().syntax.NodeType(n) === "FuncDecl")
|
|
1011
1026
|
return false;
|
|
1012
|
-
if (sh.syntax.NodeType(n) === "CallExpr" && terminates(n))
|
|
1027
|
+
if (sh().syntax.NodeType(n) === "CallExpr" && terminates(n))
|
|
1013
1028
|
found = true;
|
|
1014
1029
|
return !found;
|
|
1015
1030
|
});
|
|
@@ -1105,7 +1120,7 @@ function collectAssigns(node) {
|
|
|
1105
1120
|
* wraps it) to a {@link NormalizedLeaf}, or null if it isn't one / has a dynamic head.
|
|
1106
1121
|
*/
|
|
1107
1122
|
function normalizeCallExpr(node, redirs) {
|
|
1108
|
-
if (sh.syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
|
|
1123
|
+
if (sh().syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
|
|
1109
1124
|
return null;
|
|
1110
1125
|
const headRaw = normalizeParts(node.Args[0]?.Parts, false, true);
|
|
1111
1126
|
if (headRaw === null)
|
|
@@ -1249,14 +1264,14 @@ function commandWords(command) {
|
|
|
1249
1264
|
function commandWordsAt(command, depth) {
|
|
1250
1265
|
let file;
|
|
1251
1266
|
try {
|
|
1252
|
-
file = sh.syntax.NewParser().Parse(command, "cmd.sh");
|
|
1267
|
+
file = sh().syntax.NewParser().Parse(command, "cmd.sh");
|
|
1253
1268
|
}
|
|
1254
1269
|
catch {
|
|
1255
1270
|
return null;
|
|
1256
1271
|
}
|
|
1257
1272
|
const out = [];
|
|
1258
|
-
sh.syntax.Walk(file, (node) => {
|
|
1259
|
-
if (sh.syntax.NodeType(node) === "CallExpr" && node.Args?.length)
|
|
1273
|
+
sh().syntax.Walk(file, (node) => {
|
|
1274
|
+
if (sh().syntax.NodeType(node) === "CallExpr" && node.Args?.length)
|
|
1260
1275
|
fileOperandsOf(node.Args, depth, out);
|
|
1261
1276
|
return true;
|
|
1262
1277
|
});
|
package/dist/core/compile.d.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vigiles v2 — Compiler: spec → markdown.
|
|
3
|
-
*
|
|
4
|
-
* Reads .spec.ts files, validates references, and produces
|
|
5
|
-
* markdown instruction files with integrity hashes.
|
|
6
|
-
*/
|
|
7
1
|
import type { ClaudeSpec, SkillSpec, AgentSpec, Railway } from "./spec.js";
|
|
8
2
|
import type { LinterCheckResult } from "./linters.js";
|
|
9
3
|
import type { HarnessDialect } from "./dialect.js";
|
package/dist/core/compile.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* vigiles v2 — Compiler: spec → markdown.
|
|
4
|
-
*
|
|
5
|
-
* Reads .spec.ts files, validates references, and produces
|
|
6
|
-
* markdown instruction files with integrity hashes.
|
|
7
|
-
*/
|
|
8
2
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
4
|
};
|
|
@@ -27,6 +21,10 @@ exports.validateRailway = validateRailway;
|
|
|
27
21
|
exports.compileRailway = compileRailway;
|
|
28
22
|
exports.checkFileHash = checkFileHash;
|
|
29
23
|
exports.adoptDiff = adoptDiff;
|
|
24
|
+
/**
|
|
25
|
+
* Compiler: spec → markdown with SHA-256 hash, linter verification, reference validation; compileClaude/compileSkill/compileAgent (subagents: frontmatter + verified tool contract + body marks + result-contract Output section) + compileRailway/validateRailway (orchestrator command over flat workers; delegate-target resolution + bounded recovery) + purity-floor enforcement (purityViolations: a pure/bounded contract rejects a tool looser than its floor; absent tools = inherits-all = checked as the '*' wildcard, never trivially pure) + emits a <!-- vigiles:purity:LEVEL --> marker on a compiled agent OR skill (dangerously-unrestricted → the neutral runtime level unrestricted) so the runtime PreToolUse gate can read+enforce the declared floor (parseAgentPurity/parseSkillPurity → decidePurityGate). renderFragment/validateRefs also handle the effect() EffectRegion fragment — rendering its body wrapped in <!-- vigiles:effect -->…<!-- /vigiles:effect --> markers (inside the integrity hash) and recursing to verify inner file()/cmd() refs.
|
|
26
|
+
* Compile gates added 2026-06-20: a generous DEFAULT_MAX_SECTION_LINES=200 guard on every named prose section (claude + agent), overridable via maxSectionLines (TS types can't bound string length); agent disallowedTools verified via disallowedToolIssues (a close typo blocks nothing); a forked skill's output renders the SAME ## Output contract via renderOutputContract, and output without context:'fork' is the output-without-fork error; effect() in a skill body is the effect-in-skill error (effect() is a SUBAGENT primitive — a skill has no call→return region to scope; it declares a purity floor + context:fork instead)
|
|
27
|
+
*/
|
|
30
28
|
const node_fs_1 = require("node:fs");
|
|
31
29
|
const glob_1 = require("glob");
|
|
32
30
|
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
@@ -321,6 +319,12 @@ function compileRule(id, rule) {
|
|
|
321
319
|
// an egregious dump (a whole essay pasted into one section / prose``).
|
|
322
320
|
// Override per spec with `maxSectionLines`; `maxTokens` is the global backstop.
|
|
323
321
|
const DEFAULT_MAX_SECTION_LINES = 200;
|
|
322
|
+
// A key-files entry is a POINTER, not an essay: the prose about why a file is
|
|
323
|
+
// shaped the way it is belongs in that file's own header, where it is read when
|
|
324
|
+
// the file is opened. Calibrated against this repo on 2026-09-08 — 285 entries,
|
|
325
|
+
// median 371 chars, longest 4782 — so 800 names the paragraphs without firing
|
|
326
|
+
// on an ordinary one-line description.
|
|
327
|
+
const DEFAULT_MAX_KEYFILE_CHARS = 800;
|
|
324
328
|
function validateSectionContent(name, text, maxSectionLines) {
|
|
325
329
|
const errors = [];
|
|
326
330
|
const contentLines = text.split("\n");
|
|
@@ -392,6 +396,12 @@ function compileKeyFilesSection(spec, basePath) {
|
|
|
392
396
|
const err = validateFileRef(filePath, basePath);
|
|
393
397
|
if (err)
|
|
394
398
|
errors.push(err);
|
|
399
|
+
if (desc.length > DEFAULT_MAX_KEYFILE_CHARS) {
|
|
400
|
+
errors.push({
|
|
401
|
+
type: "section-too-long",
|
|
402
|
+
message: `Key file "${filePath}" has a ${String(desc.length)}-character description (max ${String(DEFAULT_MAX_KEYFILE_CHARS)}). A key-files entry is a pointer; move the reasoning into that file's own header comment, where a reader meets it on opening the file.`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
395
405
|
}
|
|
396
406
|
return { lines: [lines.join("\n")], errors };
|
|
397
407
|
}
|
|
@@ -313,18 +313,12 @@ export interface BashToolEvent<N extends readonly NeedSpec[] = readonly Provider
|
|
|
313
313
|
/** A hook program: where it fires + the pure decision. */
|
|
314
314
|
export interface HookProgram<N extends readonly NeedSpec[] = readonly ProviderName[]> {
|
|
315
315
|
readonly on: string;
|
|
316
|
-
readonly match: {
|
|
317
|
-
readonly tool: string;
|
|
318
|
-
};
|
|
319
316
|
/** `enforce` (default) blocks on a `deny`; `observe` records + allows. */
|
|
320
317
|
readonly mode?: HookMode;
|
|
321
318
|
/** Declared context providers the trusted runtime gathers into `e.ctx`. */
|
|
322
319
|
readonly needs?: N;
|
|
323
320
|
readonly decide: (e: BashToolEvent<N>) => Decision;
|
|
324
321
|
}
|
|
325
|
-
export declare const tool: (name: string) => {
|
|
326
|
-
tool: string;
|
|
327
|
-
};
|
|
328
322
|
/**
|
|
329
323
|
* @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending.
|
|
330
324
|
* Imported and CALLED as `experimental_defineHook` — do not alias the prefix away at
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.nothing = exports.notice = exports.run = exports.inject = exports.tools = exports.HookCompileError = exports.
|
|
3
|
+
exports.nothing = exports.notice = exports.run = exports.inject = exports.tools = exports.HookCompileError = exports.ask = exports.deny = exports.allow = void 0;
|
|
4
4
|
exports.matchesTool = matchesTool;
|
|
5
5
|
exports.invalidToolPatterns = invalidToolPatterns;
|
|
6
6
|
exports.gateAction = gateAction;
|
|
@@ -76,8 +76,20 @@ exports.isLoadPathRepairEvent = isLoadPathRepairEvent;
|
|
|
76
76
|
*/
|
|
77
77
|
const bash_effects_js_1 = require("./bash-effects.js");
|
|
78
78
|
const hash_js_1 = require("./hash.js");
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* `@iarna/toml` is required LAZILY, at the one call site that serializes a Codex
|
|
81
|
+
* TOML settings block — never at module load. MEASURED 2026-09-08 (Node 22.22.2,
|
|
82
|
+
* `tools/measure-hook-startup.mjs`): the top-level import cost 56 ms of a
|
|
83
|
+
* ~170 ms `require("dist/core/hook-program.js")`, and this module is on the hot
|
|
84
|
+
* path of `vigiles hook-runtime run-program`, which runs on EVERY matching tool
|
|
85
|
+
* call. A hook DECIDES; it never serializes a settings block, so it paid 56 ms
|
|
86
|
+
* per tool call for a compile-time dependency. Keep it a call-site require —
|
|
87
|
+
* hoisting it back to the top is the regression, and `src/hook-runtime-graph.test.ts`
|
|
88
|
+
* fails if `@iarna/toml` reappears in a decision's module graph.
|
|
89
|
+
*/
|
|
90
|
+
const stringifyToml = (value) => require("@iarna/toml").stringify(value);
|
|
80
91
|
const hook_events_js_1 = require("./hook-events.js");
|
|
92
|
+
const tool_contract_js_1 = require("./tool-contract.js");
|
|
81
93
|
const merge_conflict_js_1 = require("./merge-conflict.js");
|
|
82
94
|
const hook_providers_js_1 = require("./hook-providers.js");
|
|
83
95
|
const hook_state_js_1 = require("./hook-state.js");
|
|
@@ -131,6 +143,8 @@ function matchesTool(tools, name) {
|
|
|
131
143
|
return false;
|
|
132
144
|
}
|
|
133
145
|
}
|
|
146
|
+
/** Regex metacharacters — an entry containing one is a PATTERN, not a name. */
|
|
147
|
+
const REGEX_META = /[.*+?^${}()|[\]\\]/;
|
|
134
148
|
/** Tool patterns that are not valid regexes — rejected at compile, see {@link matchesTool}. */
|
|
135
149
|
function invalidToolPatterns(tools) {
|
|
136
150
|
return tools.filter((t) => {
|
|
@@ -595,8 +609,6 @@ function commandView(raw, root) {
|
|
|
595
609
|
normalized.some((leaf) => isBareShellLeaf(leaf.argv)),
|
|
596
610
|
};
|
|
597
611
|
}
|
|
598
|
-
const tool = (name) => ({ tool: name });
|
|
599
|
-
exports.tool = tool;
|
|
600
612
|
/**
|
|
601
613
|
* @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending.
|
|
602
614
|
* Imported and CALLED as `experimental_defineHook` — do not alias the prefix away at
|
|
@@ -624,7 +636,18 @@ function experimental_defineHook(p) {
|
|
|
624
636
|
function decideProgram(program, rawEvent, ctx = {}, root = typeof rawEvent.cwd === "string"
|
|
625
637
|
? rawEvent.cwd
|
|
626
638
|
: undefined) {
|
|
627
|
-
|
|
639
|
+
// A bash gate is Bash BY CONSTRUCTION — `BashToolEvent.tool` is the literal
|
|
640
|
+
// "Bash" and every one of the 34 call sites in both repos wrote
|
|
641
|
+
// `match: tool("Bash")`. The field carried no information and one real risk:
|
|
642
|
+
// `match: tool("Edit")` type-checked, compiled, wired a PreToolUse matcher
|
|
643
|
+
// `Edit`, fired on edits, built `commandView("")` from a missing
|
|
644
|
+
// `tool_input.command`, found every predicate false and returned allow() — a
|
|
645
|
+
// silently dead guard, which is the exact class the header above says this
|
|
646
|
+
// subsystem eliminates. Worse, the comparison was `!==` while every sibling
|
|
647
|
+
// role routes through `matchesTool`; that runtime/emit disagreement is the
|
|
648
|
+
// one MEASURED and fixed for reacts on 2026-08-12 (see the header), and it
|
|
649
|
+
// survived here on the flagship role. Removed 2026-09-08.
|
|
650
|
+
if (rawEvent.tool_name !== "Bash")
|
|
628
651
|
return (0, exports.allow)();
|
|
629
652
|
const command = typeof rawEvent.tool_input?.command === "string"
|
|
630
653
|
? rawEvent.tool_input.command
|
|
@@ -694,7 +717,8 @@ function hookRouting(hook) {
|
|
|
694
717
|
return { on: hook.on };
|
|
695
718
|
return { on: hook.on, matcher: hook.match.tools.join("|") };
|
|
696
719
|
}
|
|
697
|
-
|
|
720
|
+
// Bash by construction — see decideProgram; the author no longer declares it.
|
|
721
|
+
return { on: hook.on, matcher: "Bash" };
|
|
698
722
|
}
|
|
699
723
|
/** Apply a harness's matcher style to the neutral `A|B` matcher join. */
|
|
700
724
|
function styleMatcher(matcher, protocol) {
|
|
@@ -712,7 +736,7 @@ function renderSettingsBlock(on, matcher, gateCommand, format) {
|
|
|
712
736
|
const entry = matcher === undefined
|
|
713
737
|
? { command: gateCommand }
|
|
714
738
|
: { matcher, command: gateCommand };
|
|
715
|
-
return (
|
|
739
|
+
return stringifyToml({ hooks: { [on]: [entry] } }).trim();
|
|
716
740
|
}
|
|
717
741
|
const entry = matcher === undefined
|
|
718
742
|
? { hooks: [{ type: "command", command: gateCommand }] }
|
|
@@ -747,6 +771,23 @@ function compileHookProgram(source, hook, opts = {}) {
|
|
|
747
771
|
throw new HookCompileError(`invalid tool matcher pattern(s): ${bad.join(", ")} — a tool matcher is a ` +
|
|
748
772
|
`regex (that is why "Edit|Write" works), so it must parse as one.`);
|
|
749
773
|
}
|
|
774
|
+
// …and a VALID regex can still be a dead matcher. `tools("Edt")` parses
|
|
775
|
+
// fine, wires a matcher `Edt`, and the hook never fires — the same defect
|
|
776
|
+
// the event check below rejects, on the axis it did not cover.
|
|
777
|
+
//
|
|
778
|
+
// Only an entry with NO regex metacharacter is read as a literal tool NAME.
|
|
779
|
+
// A matcher IS a regex — `tools("mcp__github__.*")` is correct and must not
|
|
780
|
+
// be cross-referenced as a name — so the check is scoped to the spellings a
|
|
781
|
+
// typo actually produces. MCP names are skipped inside verifyToolContract
|
|
782
|
+
// itself (dialect.mcpToolPattern), so a fully-spelled server tool passes on
|
|
783
|
+
// both counts.
|
|
784
|
+
if (opts.dialect) {
|
|
785
|
+
const literal = hook.match.tools.filter((t) => !REGEX_META.test(t));
|
|
786
|
+
const badNames = (0, hook_events_js_1.authoringIssues)((0, tool_contract_js_1.verifyToolContract)(literal, opts.dialect));
|
|
787
|
+
if (badNames.length > 0) {
|
|
788
|
+
throw new HookCompileError(badNames[0].message);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
750
791
|
}
|
|
751
792
|
// A hook registered under an event the harness never fires is dead — reject
|
|
752
793
|
// it. AUTHORING is a closed world (you are writing this hook now, against the
|
package/dist/core/linters.d.ts
CHANGED
|
@@ -7,8 +7,17 @@
|
|
|
7
7
|
* Ktlint, Checkstyle, golangci-lint (CLI),
|
|
8
8
|
* Cedar (filesystem policies for AWS Bedrock AgentCore / Vectimus).
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Across 11 catalog APIs (10 linters + Cedar policy language): a rule name is
|
|
11
|
+
* RESOLVED against the linter's own catalog rather than matched as a string, and
|
|
12
|
+
* its enabled state is read from the project's config.
|
|
13
|
+
*
|
|
14
|
+
* An exclusivity claim stood here ("the core moat — no other tool ..."). Removed
|
|
15
|
+
* 2026-09-08 for two independent reasons, noted rather than deleted silently so
|
|
16
|
+
* it is not restored as a wording change: it rested on a competitor matrix
|
|
17
|
+
* checked against DOCUMENTATION rather than a run (the same matrix put a false
|
|
18
|
+
* claim on the landing page, see tools/measure-validate-overlap.mjs), and this
|
|
19
|
+
* repository is public, where the `no-product-strategy-here` rule forbids
|
|
20
|
+
* competitive positioning outright. Describe the mechanism; let a reader compare.
|
|
12
21
|
*/
|
|
13
22
|
import { editDistance } from "./edit-distance.js";
|
|
14
23
|
export type { ConfigEnabledStatus, DiscoveredRules, LinterAdapter, LinterCapabilities, } from "./linter-adapter.js";
|
package/dist/core/linters.js
CHANGED
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
* Ktlint, Checkstyle, golangci-lint (CLI),
|
|
9
9
|
* Cedar (filesystem policies for AWS Bedrock AgentCore / Vectimus).
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Across 11 catalog APIs (10 linters + Cedar policy language): a rule name is
|
|
12
|
+
* RESOLVED against the linter's own catalog rather than matched as a string, and
|
|
13
|
+
* its enabled state is read from the project's config.
|
|
14
|
+
*
|
|
15
|
+
* An exclusivity claim stood here ("the core moat — no other tool ..."). Removed
|
|
16
|
+
* 2026-09-08 for two independent reasons, noted rather than deleted silently so
|
|
17
|
+
* it is not restored as a wording change: it rested on a competitor matrix
|
|
18
|
+
* checked against DOCUMENTATION rather than a run (the same matrix put a false
|
|
19
|
+
* claim on the landing page, see tools/measure-validate-overlap.mjs), and this
|
|
20
|
+
* repository is public, where the `no-product-strategy-here` rule forbids
|
|
21
|
+
* competitive positioning outright. Describe the mechanism; let a reader compare.
|
|
13
22
|
*/
|
|
14
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
24
|
exports.LINTERS = exports.editDistance = void 0;
|
|
@@ -671,7 +680,7 @@ const stylelintConfigEnabled = createCachedChecker((basePath) => {
|
|
|
671
680
|
* `enforce("ruff/...")` reported `enabled: "unknown"`; `touch dummy.py` in the
|
|
672
681
|
* same repo flipped the identical rule to "enabled" and an out-of-select rule
|
|
673
682
|
* to "disabled". The failure was SILENT because only "disabled" is ever
|
|
674
|
-
* surfaced as a finding (src/core/compile.ts, src/cli.ts) — "unknown" reads as
|
|
683
|
+
* surfaced as a finding (src/core/compile.ts, src/cli-main.ts) — "unknown" reads as
|
|
675
684
|
* clean, so a genuinely disabled rule passed its check.
|
|
676
685
|
*
|
|
677
686
|
* A directory works where a synthesized filename does not, including the case
|
package/dist/core/types.d.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface OrphansConfig {
|
|
|
83
83
|
*/
|
|
84
84
|
export interface TestCoverageConfig {
|
|
85
85
|
/** Globs of test files that count as coverage. */
|
|
86
|
-
|
|
86
|
+
include?: readonly string[];
|
|
87
87
|
/** Extra ignore globs. */
|
|
88
88
|
exclude?: readonly string[];
|
|
89
89
|
/**
|
|
@@ -215,7 +215,9 @@ export interface RulesConfig {
|
|
|
215
215
|
* Flag a hook command that references a script file which doesn't exist on
|
|
216
216
|
* disk (with `${CLAUDE_PLUGIN_ROOT}` resolved) — the hook silently never runs.
|
|
217
217
|
* FP-safe: skips unresolved `$VAR` paths, existence-guarded one-liners, and
|
|
218
|
-
* inline commands.
|
|
218
|
+
* inline commands. NOT covered by Anthropic's `claude plugin validate` — the
|
|
219
|
+
* "matches" claim here was measured false on 2026-09-08 (Claude Code 2.1.263;
|
|
220
|
+
* see docs/rules/hook-script-exists.md). Default
|
|
219
221
|
* "warn"; "error" gates CI. Same detector as `scan` (hooks status "missing").
|
|
220
222
|
*/
|
|
221
223
|
"hook-script-exists"?: RuleSeverity;
|
|
@@ -104,7 +104,7 @@ export declare function hookScriptRefs(manifestText: string | undefined, layout:
|
|
|
104
104
|
*/
|
|
105
105
|
export declare function evidenceFor(_surface: CoverableSurface, _test: PreparedTest, colocated: boolean, configured?: boolean): CoverageEvidence | null;
|
|
106
106
|
/**
|
|
107
|
-
* The `{surface}` placeholder in a user's `
|
|
107
|
+
* The `{surface}` placeholder in a user's `include` — the ONE thing that makes
|
|
108
108
|
* a centralized test layout expressible without weakening what coverage MEANS.
|
|
109
109
|
*
|
|
110
110
|
* The retired `declared` and `name-mentioned` tiers died because they could
|
|
@@ -195,7 +195,7 @@ const SCRIPT_RE = (0, source_refs_js_1.scriptRefPattern)();
|
|
|
195
195
|
* - As the full suffix `.eval.mjs` it was a MONEY HAZARD — `foo.eval.ts` fell
|
|
196
196
|
* into the free branch and would have spent real model calls on every push.
|
|
197
197
|
* - As the bare INFIX `.eval.` it made a FALSE GRANT — `parser.eval.test.ts`,
|
|
198
|
-
* an ordinary deterministic test discovered by
|
|
198
|
+
* an ordinary deterministic test discovered by an `include` of
|
|
199
199
|
* `**\/*.test.ts`, was credited to the paid tier and dropped from the free
|
|
200
200
|
* one. `vigiles eval` globs `**\/*.eval.{mjs,cjs,js,mts,cts,ts}`, so that name
|
|
201
201
|
* is not discoverable by the eval runner at all: the surface was reported
|
|
@@ -286,7 +286,7 @@ function evidenceFor(_surface, _test, colocated, configured = false) {
|
|
|
286
286
|
return configured ? "configured" : null;
|
|
287
287
|
}
|
|
288
288
|
/**
|
|
289
|
-
* The `{surface}` placeholder in a user's `
|
|
289
|
+
* The `{surface}` placeholder in a user's `include` — the ONE thing that makes
|
|
290
290
|
* a centralized test layout expressible without weakening what coverage MEANS.
|
|
291
291
|
*
|
|
292
292
|
* The retired `declared` and `name-mentioned` tiers died because they could
|
package/dist/exclude.js
CHANGED
|
@@ -3,78 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.EXCLUDE_FLOOR = void 0;
|
|
4
4
|
exports.excludeSet = excludeSet;
|
|
5
5
|
/**
|
|
6
|
-
* The ONE exclusion policy for every walk that polices the user's repository.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `glob`'s string `ignore` treats a bare directory name as a file pattern:
|
|
15
|
-
* measured 2026-09-03 on glob 13, `ignore: ["bench"]` and `["bench/"]` exclude
|
|
16
|
-
* NOTHING, only `["bench/**"]` works. The minimatch helper inside
|
|
17
|
-
* `discoverNestedBundles` accepted the bare name. tsc and ESLint both treat the
|
|
18
|
-
* bare name as the directory (measured the same day). So a user who wrote the
|
|
19
|
-
* key the way its JSDoc promised got the nested-bundle pass filtered and every
|
|
20
|
-
* glob-backed pass unfiltered — with no way to tell from the output.
|
|
21
|
-
*
|
|
22
|
-
* This module is the fix in the shape the fence-parser fix took
|
|
23
|
-
* (`core/markdown.ts`): not one WALK — the walks legitimately have different
|
|
24
|
-
* scopes — but ONE PREDICATE, parsed once from config, that every walk consumes.
|
|
25
|
-
* It offers the predicate in the two forms a walk needs and nothing else:
|
|
26
|
-
*
|
|
27
|
-
* - `globIgnore` — an `IgnoreLike` for `globSync`, keyed on the path's position
|
|
28
|
-
* RELATIVE TO THE REPO ROOT, so a glob rooted below the root (`vigiles lint
|
|
29
|
-
* some/dir`) still applies a root-relative `exclude` correctly. The string
|
|
30
|
-
* list could not: `bench/**` relative to `some/dir` matches nothing.
|
|
31
|
-
* - `ignore` — the normalized string list, for pure detectors in `core/` that
|
|
32
|
-
* take an ignore list by injection and glob from the repo root themselves.
|
|
33
|
-
* - `matches(rel)` / `explain(rel)` — for `readdirSync`-style walks and for the
|
|
34
|
-
* one line printed when an explicitly named path is processed anyway.
|
|
35
|
-
*
|
|
36
|
-
* 🔴 EVERY IN-SCOPE WALK TAKES AN `ExcludeSet` AS A REQUIRED PARAMETER. The
|
|
37
|
-
* shape that let `findSpecs` go a year without the key was
|
|
38
|
-
* `exclude: readonly string[] = []` — optional-with-default lets a call site
|
|
39
|
-
* forget, and a forgotten argument is indistinguishable from an empty config.
|
|
40
|
-
* Do not reintroduce an optional `ExcludeSet` anywhere in scope.
|
|
41
|
-
*
|
|
42
|
-
* EXPLICITLY NAMED PATHS WIN, LOUDLY. Four tools were measured (2026-09-03):
|
|
43
|
-
* ripgrep and tsc process an explicitly named ignored file silently; ESLint
|
|
44
|
-
* skips it with a warning; prettier skips it and prints "All matched files use
|
|
45
|
-
* Prettier code style!" — the silent no-op this repo has burned itself on. vigiles
|
|
46
|
-
* takes the rg/tsc semantics (an argument is an instruction) with ESLint's
|
|
47
|
-
* loudness: the path is processed and ONE line says which pattern it matched.
|
|
48
|
-
* `exclude` filters DISCOVERY, never an argument.
|
|
49
|
-
*
|
|
50
|
-
* WHAT DELIBERATELY DOES NOT GO THROUGH THIS MODULE — so the next reader does not
|
|
51
|
-
* "fix" it (the classification is issue #192's comment):
|
|
52
|
-
*
|
|
53
|
-
* - `core/compile.ts#validateGlobRef` — verifies a spec's own `glob()` reference
|
|
54
|
-
* resolves to ≥1 file. That is reference verification of the user's claim
|
|
55
|
-
* about the repo, not lint scope; excluding a dir must not make a true ref
|
|
56
|
-
* false.
|
|
57
|
-
* - `cli.ts#specReferencedElsewhere` — eject's "is this spec compiled anywhere
|
|
58
|
-
* else" safety check. Wider is safer: a target under an excluded dir is still
|
|
59
|
-
* a target that would be orphaned.
|
|
60
|
-
* - `core/validate.ts#expandGlobs` — expands a pattern the user TYPED. An
|
|
61
|
-
* argument wins (see above); it is not discovery.
|
|
62
|
-
* - Surface walks INSIDE a bundle (`plugin-loader.ts#readTree`,
|
|
63
|
-
* `skill-reachability.ts`): `exclude` applies at BUNDLE granularity via
|
|
64
|
-
* `discoverNestedBundles`; a skill inside your own `skills/` is yours.
|
|
65
|
-
* - Internal machinery that never enumerates the user's repo as lint surface:
|
|
66
|
-
* eval temp installs (`eval.ts`, `adapters/codex/eval.ts`), the eval cache and
|
|
67
|
-
* locks (`eval-cache.ts`, `eval-lock.ts`, `run-script.ts#snapshotTree`),
|
|
68
|
-
* `.vigiles/hooks/` discovery (`hook-install.ts`, a fixed dir), sidecars
|
|
69
|
-
* (`core/sidecar.ts`), linter catalogs / rulesDirs / toolchain paths
|
|
70
|
-
* (`core/linters.ts`, `core/generate-schema.ts`, `core/generate-types.ts`),
|
|
71
|
-
* `init`'s shallow adoptable-surface sweep (`cli.ts#discoverAdoptableSurfaces`)
|
|
72
|
-
* and lint-config collection (`cli.ts#safeReaddir`), and
|
|
73
|
-
* `core/generate-harness.ts` (an explicit, non-recursive dir argument).
|
|
74
|
-
*
|
|
75
|
-
* The floor (`node_modules`, `dist`, `.git`, `.vigiles`) lives here too, so a
|
|
76
|
-
* walk cannot carry its own private copy of it — the original `findSpecs` list
|
|
77
|
-
* lacked `.vigiles/**` while the three `core/` detectors had it.
|
|
6
|
+
* The ONE exclusion policy for every walk that polices the user's repository (#192) — the parsed `.vigilesrc.json#exclude` as an `ExcludeSet`, built ONCE where `loadConfig()` runs and taken as a REQUIRED parameter by every in-scope discovery (`findSpecs`, `findInstructionFiles`, `discoverNestedBundles`, `collectDocumentedRules`, `gatherInstructionFiles` in cli.ts; the string face handed to `findDocRefs`, `findOrphanDocs` (`repoExclude`), `findUntestedSurfaces`/`skillTestNudge`, `discoverScripts`, `computeScriptCoverage`).
|
|
7
|
+
* Two faces: `globIgnore` (an `IgnoreLike` keyed on the path's position relative to the REPO root, so a glob rooted below it — `vigiles lint some/dir` — still applies a root-relative exclude) and `ignore` (the normalized string list for pure core detectors that glob from the root).
|
|
8
|
+
* A bare directory name excludes its subtree, as tsconfig/ESLint do — measured 2026-09-03: glob's own string `ignore` treated `bench` and `bench/` as matching NOTHING while the minimatch helper in `discoverNestedBundles` accepted them, so the two walks that honoured `exclude` disagreed.
|
|
9
|
+
* The floor (node_modules/dist/.git/.vigiles) lives here, not per walk.
|
|
10
|
+
* `exclude` filters DISCOVERY only: an explicitly named path is processed and ONE line names the pattern it matched (rg/tsc semantics with ESLint's loudness; never prettier's silent 'all clean').
|
|
11
|
+
* Rule-level `orphans.exclude` / `untested-*` `exclude` NARROW (union with the floor), never override.
|
|
12
|
+
* The header carries the exception table — `validateGlobRef` (reference verification), `specReferencedElsewhere` (eject safety), `expandGlobs` (a typed argument), surface walks inside a bundle, and internal machinery — so the next reader does not 'fix' them.
|
|
13
|
+
* Enforced by the ESLint discovery guard (eslint.config.mjs: `globSync` without `ignore`, a literal in an ignore list, a raw `readdirSync` in cli.ts) and the source gate in exclude-cli.test.ts
|
|
78
14
|
*/
|
|
79
15
|
const minimatch_1 = require("minimatch");
|
|
80
16
|
const node_path_1 = require("node:path");
|
package/dist/hook-install.js
CHANGED
|
@@ -27,7 +27,13 @@ exports.serializeConfig = serializeConfig;
|
|
|
27
27
|
*/
|
|
28
28
|
const node_fs_1 = require("node:fs");
|
|
29
29
|
const node_path_1 = require("node:path");
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Lazy for the same reason as in `core/hook-program.ts` — and this module is
|
|
32
|
+
* reached from the hook runtime through `hook-state-store.ts` (`normalizeHookRef`),
|
|
33
|
+
* so a top-level import here put `@iarna/toml` back into the graph of every hook
|
|
34
|
+
* decision even after that one was fixed. Measured 2026-09-08: 56 ms per spawn.
|
|
35
|
+
*/
|
|
36
|
+
const stringifyToml = (value) => require("@iarna/toml").stringify(value);
|
|
31
37
|
/** The agnostic, committed home for hook SOURCE — one dir, cross-adapter. */
|
|
32
38
|
exports.HOOKS_DIR = ".vigiles/hooks";
|
|
33
39
|
/** The committed home for registered context-provider SOURCE (v2). */
|
|
@@ -152,7 +158,7 @@ function mergeHooksToml(existing, compiled, hookPath) {
|
|
|
152
158
|
/** Serialize a merged config back to its on-disk text (with trailing newline). */
|
|
153
159
|
function serializeConfig(merged, format) {
|
|
154
160
|
return format === "toml"
|
|
155
|
-
? (
|
|
161
|
+
? stringifyToml(merged).trimEnd() + "\n"
|
|
156
162
|
: JSON.stringify(merged, null, 2) + "\n";
|
|
157
163
|
}
|
|
158
164
|
//# sourceMappingURL=hook-install.js.map
|