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.
@@ -32,11 +32,26 @@ exports.stripWrappers = stripWrappers;
32
32
  exports.leafCommandsNormalized = leafCommandsNormalized;
33
33
  exports.leafArgvSource = leafArgvSource;
34
34
  exports.commandWords = commandWords;
35
- // mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types.
36
- // The project compiles to CommonJS (Node16, no "type":"module"), so plain
37
- // require() works and is the idiomatic pattern here (see linters.ts).
38
- const _sh = require("mvdan-sh");
39
- const sh = _sh;
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
  });
@@ -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";
@@ -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.tool = exports.ask = exports.deny = exports.allow = void 0;
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
- const toml_1 = require("@iarna/toml");
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
- if (rawEvent.tool_name !== program.match.tool)
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
- return { on: hook.on, matcher: hook.match.tool };
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 (0, toml_1.stringify)({ hooks: { [on]: [entry] } }).trim();
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
@@ -7,8 +7,17 @@
7
7
  * Ktlint, Checkstyle, golangci-lint (CLI),
8
8
  * Cedar (filesystem policies for AWS Bedrock AgentCore / Vectimus).
9
9
  *
10
- * This is the core moat — no other tool resolves rules across 11 catalog APIs
11
- * (10 linters + Cedar policy language) and checks config-enabled status.
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";
@@ -8,8 +8,17 @@
8
8
  * Ktlint, Checkstyle, golangci-lint (CLI),
9
9
  * Cedar (filesystem policies for AWS Bedrock AgentCore / Vectimus).
10
10
  *
11
- * This is the core moat — no other tool resolves rules across 11 catalog APIs
12
- * (10 linters + Cedar policy language) and checks config-enabled status.
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
@@ -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
- testGlobs?: readonly string[];
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. Matches Anthropic's own `claude plugin validate`. Default
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 `testGlobs` — the ONE thing that makes
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 a `testGlobs` of
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 `testGlobs` — the ONE thing that makes
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
- * `.vigilesrc.json#exclude` is documented as "tsconfig-style" — a list of paths or
9
- * globs the repo's own lint should not police (vendored corpora, benchmark
10
- * fixtures, frozen reproductions). Issue #192: it was honoured by three walks,
11
- * ignored by six, and the three that honoured it did not agree with each other.
12
- *
13
- * 🔴 TWO DIALECTS WERE ALREADY IN `main`, AND THEY DISAGREED ON `"bench"`.
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");
@@ -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
- const toml_1 = require("@iarna/toml");
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
- ? (0, toml_1.stringify)(merged).trimEnd() + "\n"
161
+ ? stringifyToml(merged).trimEnd() + "\n"
156
162
  : JSON.stringify(merged, null, 2) + "\n";
157
163
  }
158
164
  //# sourceMappingURL=hook-install.js.map