vigiles 30.0.1 → 30.0.2

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.
@@ -37,7 +37,19 @@ export interface ScriptRunResult {
37
37
  */
38
38
  export declare const SKIP_EXIT_CODE = 77;
39
39
  /**
40
- * Classify one script's run from its exit code and its reported check count.
40
+ * What the runner's load hook saw of one script (`harness-resolve-hooks.mts`).
41
+ *
42
+ * `marked` — the hook saw the script's module as an ES module and planted a
43
+ * marker import at the top of it. `linked` — that marker evaluated, which ESM
44
+ * only does once the WHOLE import graph was found, parsed and linked.
45
+ */
46
+ export interface LoadEvidence {
47
+ readonly marked: boolean;
48
+ readonly linked: boolean;
49
+ }
50
+ /**
51
+ * Classify one script's run from its exit code, its reported check count and
52
+ * what the load hook saw.
41
53
  *
42
54
  * 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
43
55
  * do anything?". Measured 2026-08-08: a file whose whole body is
@@ -60,8 +72,36 @@ export declare const SKIP_EXIT_CODE = 77;
60
72
  * none of it says zero. This is the `undefined`-vs-`[]` distinction
61
73
  * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
62
74
  * happened".
75
+ *
76
+ * 🔴 DID IT LOAD? A non-zero exit from a script whose module was marked and
77
+ * never linked is did-not-load: `"skip"` with the loader's exit code, which
78
+ * keeps its coverage (`fail` RETRACTS it, see `runsFromResults`). A module that
79
+ * never linked executed no assertion — the same "did not run" as a missing
80
+ * `claude`, arriving through a different door. MEASURED, twice on 2026-08-20: a
81
+ * container restore left an old `node_modules`, every harness in a consumer repo
82
+ * died on `Named export 'recordCheck' not found`, and its ledger fell from 48
83
+ * records to 34 and from 47 to 33 for surfaces nothing had touched. The run is
84
+ * still red: `cli-main.ts` fails on any skip the author did not declare.
85
+ *
86
+ * This used to be decided by grepping the child's OUTPUT for loader phrases,
87
+ * and that was wrong in the dangerous direction (#243): a harness that printed
88
+ * a hook transcript containing "Cannot find module", then failed an assertion,
89
+ * was read as did-not-load and kept its coverage. Output is no longer an input.
90
+ * Anything without a marker (CommonJS, an entry the hook never saw) can never
91
+ * claim did-not-load — it is a `fail`, the side that retracts rather than hides.
92
+ * A syntax error in the script itself also never links, so it too is
93
+ * did-not-load; that is the owner's call, not an accident.
94
+ */
95
+ export declare function statusFor(code: number, checks: number | undefined, load?: LoadEvidence): ScriptStatus;
96
+ /**
97
+ * Did this script fail to LOAD — as opposed to declaring a skip (exit 77)?
98
+ * The one predicate behind both the CLI's "never ran" failure and `--min`.
99
+ *
100
+ * Deliberately NOT a fifth `ScriptStatus`: coverage retraction reads the status
101
+ * as a bare string (`coverage-artifact.ts`), so a new member would start
102
+ * retracting silently with no type error.
63
103
  */
64
- export declare function statusFor(code: number, checks: number | undefined, output?: string): ScriptStatus;
104
+ export declare function loadFailed(r: ScriptRunResult): boolean;
65
105
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
66
106
  export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
67
107
  /** Glob suffix matching every accepted script extension, e.g. `harness`. */
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.canRunTypeScript = exports.detectNodeCaps = exports.SCRIPT_EXTS = exports.SKIP_EXIT_CODE = void 0;
4
4
  exports.statusFor = statusFor;
5
+ exports.loadFailed = loadFailed;
5
6
  exports.scriptGlob = scriptGlob;
6
7
  exports.interpreterArgs = interpreterArgs;
7
8
  exports.discoverScripts = discoverScripts;
@@ -36,7 +37,8 @@ const check_count_js_1 = require("../../check-count.js");
36
37
  */
37
38
  exports.SKIP_EXIT_CODE = 77;
38
39
  /**
39
- * Classify one script's run from its exit code and its reported check count.
40
+ * Classify one script's run from its exit code, its reported check count and
41
+ * what the load hook saw.
40
42
  *
41
43
  * 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
42
44
  * do anything?". Measured 2026-08-08: a file whose whole body is
@@ -59,74 +61,47 @@ exports.SKIP_EXIT_CODE = 77;
59
61
  * none of it says zero. This is the `undefined`-vs-`[]` distinction
60
62
  * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
61
63
  * happened".
64
+ *
65
+ * 🔴 DID IT LOAD? A non-zero exit from a script whose module was marked and
66
+ * never linked is did-not-load: `"skip"` with the loader's exit code, which
67
+ * keeps its coverage (`fail` RETRACTS it, see `runsFromResults`). A module that
68
+ * never linked executed no assertion — the same "did not run" as a missing
69
+ * `claude`, arriving through a different door. MEASURED, twice on 2026-08-20: a
70
+ * container restore left an old `node_modules`, every harness in a consumer repo
71
+ * died on `Named export 'recordCheck' not found`, and its ledger fell from 48
72
+ * records to 34 and from 47 to 33 for surfaces nothing had touched. The run is
73
+ * still red: `cli-main.ts` fails on any skip the author did not declare.
74
+ *
75
+ * This used to be decided by grepping the child's OUTPUT for loader phrases,
76
+ * and that was wrong in the dangerous direction (#243): a harness that printed
77
+ * a hook transcript containing "Cannot find module", then failed an assertion,
78
+ * was read as did-not-load and kept its coverage. Output is no longer an input.
79
+ * Anything without a marker (CommonJS, an entry the hook never saw) can never
80
+ * claim did-not-load — it is a `fail`, the side that retracts rather than hides.
81
+ * A syntax error in the script itself also never links, so it too is
82
+ * did-not-load; that is the owner's call, not an accident.
62
83
  */
63
- function statusFor(code, checks, output) {
84
+ function statusFor(code, checks, load) {
64
85
  if (code === exports.SKIP_EXIT_CODE)
65
86
  return "skip";
66
- // 🔴 `checks === undefined` GUARDS THE TEXT MATCH, and it is the load-bearing
67
- // half. A script that REPORTED a count executed: the counter is written by an
68
- // exit handler that exists only once the module was linked and run
69
- // (`check-count.ts`), so the count is a STRUCTURAL fact about the child, while
70
- // `didNotLoad` is a guess about its text.
71
- //
72
- // MEASURED: `statusFor(1, 3, "<hook stderr: Cannot find module …>\nAssertionError")`
73
- // returned `"skip"` and the run exited 0 — a harness that ran, recorded three
74
- // checks and FAILED an assertion, reported as skipped. That is not an exotic
75
- // input: vigiles harnesses drive hooks and print their transcripts, so a
76
- // loader phrase in the output is ordinary EVIDENCE about the thing under test,
77
- // not a diagnosis of the harness. Watching the child from outside cannot tell
78
- // those apart. The count can, and it was already in hand.
79
- if (code !== 0)
80
- return checks === undefined && didNotLoad(output) ? "skip" : "fail";
87
+ if (code !== 0) {
88
+ // A reported count is written by an exit handler that exists only once the
89
+ // module ran (`check-count.ts`), so it overrules a missing marker file.
90
+ const neverLinked = load?.marked === true && !load.linked && checks === undefined;
91
+ return neverLinked ? "skip" : "fail";
92
+ }
81
93
  return checks === 0 ? "vacuous" : "pass";
82
94
  }
83
95
  /**
84
- * Did the script fail to LOAD, rather than fail?
85
- *
86
- * 🔴 The distinction is not cosmetic, because `fail` RETRACTS coverage while
87
- * `skip` does not (see the table on `runsFromResults`). The rule for `fail` —
88
- * "it ran and proved nothing, so it must not keep yesterday's green record
89
- * alive" — is right, and it does not describe a file the runtime never
90
- * evaluated. A module that cannot resolve executed no assertion; it is the same
91
- * "did not run" as a missing `claude` CLI, arriving through a different door.
92
- *
93
- * MEASURED, twice in one session on 2026-08-20: a container restore left an old
94
- * `node_modules` behind, every harness in the consumer repo died on `Named export
95
- * 'recordCheck' not found`, and the ledger dropped from 48 records to 34 and from
96
- * 47 to 33. Nothing about those surfaces had changed — the machine had.
97
- *
98
- * ⚠️ This does NOT make a broken environment quiet: a script classified here
99
- * fails the run by default (`cli-main.ts`, right after `anyFailed`), because a
100
- * skip the AUTHOR never declared is not a skip. What this classification buys is
101
- * only that a machine problem may not delete a measurement taken on a machine
102
- * that worked.
96
+ * Did this script fail to LOAD — as opposed to declaring a skip (exit 77)?
97
+ * The one predicate behind both the CLI's "never ran" failure and `--min`.
103
98
  *
104
- * 🔴 That default is new, and the sentence it replaces was false. It read
105
- * «`--no-skip` — which this repo's own CI passes — still fails the run».
106
- * Measured: `--no-skip` appears ZERO times under `.github/`, `package.json`,
107
- * `scripts/` and `.claude/`; CI passes `--min=14` and nothing else. The
108
- * safety net the non-fatal classification leaned on was never strung.
109
- *
110
- * Deliberately literal, and only the loader's own vocabulary: these strings come
111
- * from Node's module resolution, not from user code. A test that legitimately
112
- * asserts on one of them exits 0 or asserts, and never reaches here.
99
+ * Deliberately NOT a fifth `ScriptStatus`: coverage retraction reads the status
100
+ * as a bare string (`coverage-artifact.ts`), so a new member would start
101
+ * retracting silently with no type error.
113
102
  */
114
- function didNotLoad(output) {
115
- if (output === undefined || output === "")
116
- return false;
117
- return (output.includes("ERR_MODULE_NOT_FOUND") ||
118
- output.includes("Cannot find package") ||
119
- output.includes("Cannot find module") ||
120
- /SyntaxError: Named export '[^']*' not found/.test(output) ||
121
- // Same event, ESM spelling. Node phrases a missing named export one way for
122
- // a CommonJS target and another for an ES module, and only the first was
123
- // listed — so the 2026-08-20 class below still RETRACTED coverage whenever
124
- // the dependency happened to be ESM. Measured on Node 22:
125
- // CJS: SyntaxError: Named export 'recordCheck' not found. The requested module …
126
- // ESM: SyntaxError: The requested module './x.mjs' does not provide an export named 'recordCheck'
127
- /SyntaxError: The requested module '[^']*' does not provide an export named/.test(output) ||
128
- output.includes("ERR_UNSUPPORTED_DIR_IMPORT") ||
129
- output.includes("ERR_PACKAGE_PATH_NOT_EXPORTED"));
103
+ function loadFailed(r) {
104
+ return r.status === "skip" && r.code !== exports.SKIP_EXIT_CODE;
130
105
  }
131
106
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
132
107
  exports.SCRIPT_EXTS = ["mjs", "cjs", "js", "mts", "cts", "ts"];
@@ -240,6 +215,23 @@ function readCheckReport(path) {
240
215
  return undefined;
241
216
  }
242
217
  }
218
+ /**
219
+ * What the load hook left behind for one child. A missing or malformed
220
+ * `seenFile` means the hook never saw the script, so `marked` is false and the
221
+ * run can never be read as did-not-load.
222
+ */
223
+ function readLoadEvidence(seenFile, loadedFile) {
224
+ return { marked: readMarked(seenFile), linked: (0, node_fs_1.existsSync)(loadedFile) };
225
+ }
226
+ function readMarked(seenFile) {
227
+ try {
228
+ const seen = JSON.parse((0, node_fs_1.readFileSync)(seenFile, "utf8"));
229
+ return seen.marked === true;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
243
235
  /**
244
236
  * Run each script as `node <file>` (or `node <entry> <file>`, see
245
237
  * {@link RunScriptsOptions}), inheriting stdio so the script's own report
@@ -293,6 +285,8 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
293
285
  return;
294
286
  }
295
287
  const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
288
+ const seenFile = (0, node_path_1.join)(countDir, `${String(i)}.seen`);
289
+ const loadedFile = (0, node_path_1.join)(countDir, `${String(i)}.loaded`);
296
290
  // Resolve a harness's bare `vigiles` import from the CLI's OWN install, so
297
291
  // running the gate does not require installing the package into the
298
292
  // project — which, in a repo that already has a package.json, drags in the
@@ -301,7 +295,21 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
301
295
  // fails, so a locally installed copy still wins.
302
296
  const selfRoot = (0, node_path_1.resolve)(__dirname, "..", "..", "..");
303
297
  const hook = (0, node_url_1.pathToFileURL)((0, node_path_1.join)(selfRoot, "dist", "harness-resolve-hooks.mjs")).href;
304
- const child = (0, node_child_process_1.spawn)("node", ["--import", hookImport(hook), ...argv], {
298
+ // 🔴 OUR HOOK IS REGISTERED BEFORE `--import tsx`, and the order is
299
+ // load-bearing. Hooks chain last-in-first-out, so this makes tsx the
300
+ // OUTER hook: it transpiles the source we already marked, and its source
301
+ // map describes that source. The other order was measured wrong: tsx
302
+ // emits each module on ONE line with an inline map, a prefix added after
303
+ // it shifts every generated column, and a stack frame on line 2 was
304
+ // reported on line 3 (Node 20.20 and 22.22). The format we see from the
305
+ // inner position is the same (`module` in a `type: module` scope,
306
+ // `commonjs` otherwise — also measured).
307
+ const probe = {
308
+ entryURL: scriptURL(cwd, file),
309
+ loadedFile,
310
+ seenFile,
311
+ };
312
+ const child = (0, node_child_process_1.spawn)("node", ["--import", hookImport(hook, probe), ...argv], {
305
313
  cwd,
306
314
  stdio: ["ignore", "pipe", "pipe"],
307
315
  env: {
@@ -321,7 +329,7 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
321
329
  resolveRun({
322
330
  file,
323
331
  code,
324
- status: statusFor(code, report?.checks, output),
332
+ status: statusFor(code, report?.checks, readLoadEvidence(seenFile, loadedFile)),
325
333
  checks: report?.checks,
326
334
  ...(report ? { surfaces: report.surfaces } : {}),
327
335
  });
@@ -436,13 +444,30 @@ function formatScriptSummary(results) {
436
444
  return lines.join("\n");
437
445
  }
438
446
  /**
439
- * A `--import` argument that registers the resolver hook without a temp file:
447
+ * A `--import` argument that registers the harness hooks without a temp file:
440
448
  * a data: URL calling `module.register`. Inline because writing a shim into the
441
449
  * user's tree to run their tests would be a side effect the runner has no
442
- * business having.
450
+ * business having. The per-child probe rides in `data`, not the environment,
451
+ * so a process the script spawns does not inherit it.
443
452
  */
444
- function hookImport(hookHref) {
445
- const src = `import {register} from "node:module";register(${JSON.stringify(hookHref)});`;
453
+ function hookImport(hookHref, probe) {
454
+ const src = `import {register} from "node:module";register(${JSON.stringify(hookHref)},{data:${JSON.stringify(probe)}});`;
446
455
  return `data:text/javascript,${encodeURIComponent(src)}`;
447
456
  }
457
+ /**
458
+ * The URL Node will load the SCRIPT under — for `eval` too, where the process
459
+ * entry is `eval-entry.js` and the script is what it imports. ESM resolution
460
+ * realpaths file URLs, so a symlinked path must be realpathed here or the hook
461
+ * would never recognise it (which is safe — no marker, no did-not-load claim —
462
+ * but blind).
463
+ */
464
+ function scriptURL(cwd, file) {
465
+ const abs = (0, node_path_1.resolve)(cwd, file);
466
+ try {
467
+ return (0, node_url_1.pathToFileURL)((0, node_fs_1.realpathSync)(abs)).href;
468
+ }
469
+ catch {
470
+ return (0, node_url_1.pathToFileURL)(abs).href;
471
+ }
472
+ }
448
473
  //# sourceMappingURL=run-scripts.js.map
package/dist/cli-main.js CHANGED
@@ -39,6 +39,7 @@ const scan_trigger_suggest_js_1 = require("./scan-trigger-suggest.js");
39
39
  const install_reader_js_1 = require("./core/install-reader.js");
40
40
  const plugin_declaration_js_1 = require("./plugin-declaration.js");
41
41
  const local_files_tracked_js_1 = require("./local-files-tracked.js");
42
+ const local_files_js_1 = require("./local-files.js");
42
43
  const scan_behavioral_js_1 = require("./scan-behavioral.js");
43
44
  const adapter_registry_js_1 = require("./adapter-registry.js");
44
45
  const skill_harness_js_1 = require("./skill-harness.js");
@@ -4915,6 +4916,21 @@ function gitHead(cwd) {
4915
4916
  }
4916
4917
  async function handleRunScripts(kind, args, restArgs, excludes) {
4917
4918
  const cwd = process.cwd();
4919
+ // The ignore file keeps NEW copies of `.vigiles/` local files out of git, but
4920
+ // cannot untrack one a repo already committed. Said here, on the CLI, because
4921
+ // it spawns `git` — never from a hook runtime — and said FIRST, before any
4922
+ // early return: a repo whose harness was removed still carries the stale
4923
+ // committed artifact, and "no files found" must not swallow the warning
4924
+ // (Codex review on #274).
4925
+ //
4926
+ // The ignore file is brought up to date FIRST, and only where `.vigiles/`
4927
+ // already exists: the writers below would otherwise make their edit after the
4928
+ // check, so a tracked ignore file dirtied by this very run went unreported on
4929
+ // the one run a user may ever do (Codex review on #275).
4930
+ const vigilesDir = (0, node_path_1.join)(cwd, local_files_js_1.VIGILES_DIR);
4931
+ if ((0, node_fs_1.existsSync)(vigilesDir))
4932
+ (0, local_files_js_1.ensureLocalFilesIgnored)(vigilesDir);
4933
+ (0, local_files_tracked_js_1.warnTrackedLocalFiles)(cwd);
4918
4934
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
4919
4935
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
4920
4936
  // The eval LOCK flags (`--check`/`--update`) are resolved BEFORE file discovery
@@ -4930,9 +4946,13 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
4930
4946
  // A script under an excluded path is not DISCOVERED (a vendored corpus's own
4931
4947
  // harness must not run as ours), but a script you NAME still runs (#192).
4932
4948
  const files = (0, run_scripts_js_1.discoverScripts)(restArgs.map((p) => noteExplicitOverride(excludes, p, "running")), defaultGlob, cwd, excludes.ignore);
4933
- // `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
4934
- // a renamed file, or a glob that matched nothing fails LOUD instead of passing
4935
- // green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
4949
+ // `--min=N`: a CI gate asserts at least N scripts actually LOADED — so a bad
4950
+ // path, a renamed file, a glob that matched nothing, or a file that matched and
4951
+ // never linked fails LOUD instead of passing green with nothing executed.
4952
+ // Checked twice: here against files MATCHED (cheap, before anything runs), and
4953
+ // after the run against scripts that LOADED — a matched file that could not be
4954
+ // evaluated is not a script that ran (#243). A declared skip (exit 77) did
4955
+ // load, so it counts. Default 0 (off) keeps local runs ergonomic.
4936
4956
  const minFlag = args.find((a) => a.startsWith("--min="));
4937
4957
  const minRequired = minFlag
4938
4958
  ? Math.max(0, Number.parseInt(minFlag.split("=")[1] ?? "", 10) || 0)
@@ -5035,11 +5055,13 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5035
5055
  // a flag: the run already happened, and this is the runner recording what it
5036
5056
  // saw — the same shape as the flight-recorder ledger it already appends to.
5037
5057
  recordRunCoverage(cwd, results, kind, harnessFlagFrom(args));
5038
- // The ignore file keeps NEW copies of `.vigiles/` local files out of git, but
5039
- // cannot untrack one a repo already committed. Said here, on the CLI, because
5040
- // it spawns `git` — never from a hook runtime.
5041
- (0, local_files_tracked_js_1.warnTrackedLocalFiles)(cwd);
5042
5058
  console.log("\n" + (0, run_scripts_js_1.formatScriptSummary)(results));
5059
+ const loadedCount = results.filter((r) => !(0, run_scripts_js_1.loadFailed)(r)).length;
5060
+ const belowFloor = loadedCount < minRequired;
5061
+ if (belowFloor) {
5062
+ console.error(`\n✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(loadedCount)} of ` +
5063
+ `${String(files.length)} matched ${kind} file(s) loaded.`);
5064
+ }
5043
5065
  if ((0, run_scripts_js_1.anyFailed)(results))
5044
5066
  process.exit(1);
5045
5067
  // 🔴 A SKIP THE AUTHOR NEVER DECLARED IS NOT A SKIP — and the discriminator was
@@ -5047,20 +5069,23 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5047
5069
  // the runtime could not evaluate exits with whatever the loader gave it, 1 in
5048
5070
  // practice. Both are classified `"skip"` so that neither RETRACTS coverage —
5049
5071
  // which is right, a file that did not run proved nothing either way — but only
5050
- // the declared one is a reason to stay green.
5072
+ // the declared one is a reason to stay green. Which is which is decided by the
5073
+ // runner's load hook (a marker import that evaluates only once the module
5074
+ // graph linked), not by reading the child's output — see `statusFor`.
5051
5075
  //
5052
5076
  // Reported as #243: `vigiles test .` printed a resolver stack over a `⊘`, said
5053
5077
  // `0 passed, 1 skipped`, and exited 0. Downstream a consumer's README shipped
5054
5078
  // that exact command as its first setup step, so a new reader's suite silently
5055
5079
  // never ran. `--no-skip` would have caught it and is not the default; `--min=1`
5056
- // does not, because it counts files MATCHED, not scripts executed.
5080
+ // did not either, because it counted files MATCHED — it now also counts the
5081
+ // scripts that loaded (above).
5057
5082
  //
5058
5083
  // This is deliberately NOT a fifth `ScriptStatus`. Coverage retraction reads the
5059
5084
  // status as a bare STRING (`executedScripts`, `coverage-artifact.ts`, whose
5060
5085
  // parameter is typed `string`), so a new member would start retracting silently
5061
5086
  // with no type error — breaking the one property the classification exists to
5062
5087
  // protect.
5063
- const notEvaluated = results.filter((r) => r.status === "skip" && r.code !== run_scripts_js_1.SKIP_EXIT_CODE);
5088
+ const notEvaluated = results.filter(run_scripts_js_1.loadFailed);
5064
5089
  if (notEvaluated.length > 0) {
5065
5090
  console.error(`\n✗ vigiles ${kind}: ${String(notEvaluated.length)} script(s) never ran — the runtime could not load them:\n` +
5066
5091
  notEvaluated
@@ -5071,6 +5096,8 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5071
5096
  `a declared skip stays green.`);
5072
5097
  process.exit(1);
5073
5098
  }
5099
+ if (belowFloor)
5100
+ process.exit(1);
5074
5101
  // `--no-skip`: in a context that ASSERTS the capability is present (a CI job),
5075
5102
  // a skipped tier is untested surface — fail loudly instead of passing green.
5076
5103
  if (args.includes("--no-skip") && results.some((r) => r.status === "skip")) {
@@ -8,6 +8,27 @@ type Resolved = {
8
8
  shortCircuit?: boolean;
9
9
  };
10
10
  type NextResolve = (specifier: string, context: ResolveContext) => Resolved | Promise<Resolved>;
11
+ type LoadContext = {
12
+ format?: string | null;
13
+ conditions: string[];
14
+ };
15
+ type Loaded = {
16
+ format?: string | null;
17
+ source?: string | ArrayBuffer | ArrayBufferView | null;
18
+ shortCircuit?: boolean;
19
+ };
20
+ type NextLoad = (url: string, context: LoadContext) => Loaded | Promise<Loaded>;
21
+ /** What the runner hands this hook for ONE child. See `run-scripts.ts`. */
22
+ export interface LoadProbe {
23
+ /** The script's own module URL (realpath), whatever process entry runs it. */
24
+ readonly entryURL: string;
25
+ /** Written (empty) by the marker module when it evaluates: the graph linked. */
26
+ readonly loadedFile: string;
27
+ /** Written with `{ format, marked }` when this hook sees the script's module. */
28
+ readonly seenFile: string;
29
+ }
30
+ export declare function initialize(data: LoadProbe | undefined): void;
11
31
  export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<Resolved>;
32
+ export declare function load(url: string, context: LoadContext, nextLoad: NextLoad): Promise<Loaded>;
12
33
  export {};
13
34
  //# sourceMappingURL=harness-resolve-hooks.d.mts.map
@@ -1,17 +1,47 @@
1
1
  /**
2
- * Module-resolution hook for HARNESS scripts (`vigiles test` / `vigiles eval`):
3
- * make a bare `vigiles` import resolve to the CLI's OWN installation.
2
+ * Module-customization hooks for HARNESS scripts (`vigiles test` / `vigiles eval`).
3
+ * Two jobs, one registration:
4
4
  *
5
- * The rescue itself — why it exists, what it refuses to touch — lives in
6
- * `./self-resolve.mjs`, because the spec host registers the same branch from
7
- * `./spec-hooks.mjs` and two copies of it is exactly the divergence that put
8
- * `test` and `compile` on different answers to the same question.
5
+ * 1. RESOLVE — make a bare `vigiles` import resolve to the CLI's OWN
6
+ * installation. The rescue itself — why it exists, what it refuses to touch —
7
+ * lives in `./self-resolve.mjs`, because the spec host registers the same
8
+ * branch from `./spec-hooks.mjs` and two copies of it is exactly the
9
+ * divergence that put `test` and `compile` on different answers to the same
10
+ * question. What stays here is the protocol: try normal resolution first, and
11
+ * only consider the rescue on the way out of the failure.
9
12
  *
10
- * What stays here is the hook PROTOCOL: try normal resolution first, and only
11
- * consider the rescue on the way out of the failure.
13
+ * 2. LOAD — tell the runner whether the script LOADED (#243). ESM links the
14
+ * whole import graph before evaluating any of it, and evaluates a module's
15
+ * imports in source order. So a marker import placed FIRST in the script's
16
+ * module evaluates if and only if every module in the graph was found, parsed
17
+ * and linked — and before any dependency or the script body runs. The marker
18
+ * writes a file; the runner reads it. No output text is read, so a harness
19
+ * that prints a loader error as EVIDENCE about the thing it tests (a hook
20
+ * transcript, say) can no longer be mistaken for one that never ran.
21
+ *
22
+ * The probe paths arrive through `register(…, { data })` → `initialize`, NOT the
23
+ * environment, so a process the harness spawns cannot inherit them and write
24
+ * over its parent's answer.
12
25
  */
26
+ import { writeFileSync } from "node:fs";
13
27
  import { resolveSelfSpecifier } from "./self-resolve.mjs";
28
+ const MARKER_URL = "vigiles-internal:loaded";
29
+ /**
30
+ * Formats the marker can be planted in. 🔴 CommonJS is deliberately absent: it
31
+ * has no link phase to mark (a `require` resolves while the body runs), so a CJS
32
+ * script carries no marker and a non-zero exit from it is conservatively a
33
+ * `fail` — which retracts coverage rather than hiding a real failure.
34
+ * `module-typescript` is Node's own type stripping (22.6+), which strips in
35
+ * place and so keeps positions.
36
+ */
37
+ const MARKABLE = new Set(["module", "module-typescript"]);
38
+ let probe;
39
+ export function initialize(data) {
40
+ probe = data;
41
+ }
14
42
  export async function resolve(specifier, context, nextResolve) {
43
+ if (probe !== undefined && specifier === MARKER_URL)
44
+ return { url: MARKER_URL, shortCircuit: true };
15
45
  try {
16
46
  return await nextResolve(specifier, context);
17
47
  }
@@ -24,4 +54,42 @@ export async function resolve(specifier, context, nextResolve) {
24
54
  return rescued;
25
55
  }
26
56
  }
57
+ export async function load(url, context, nextLoad) {
58
+ if (probe !== undefined && url === MARKER_URL)
59
+ return {
60
+ format: "module",
61
+ shortCircuit: true,
62
+ source: `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(probe.loadedFile)}, "");`,
63
+ };
64
+ const loaded = await nextLoad(url, context);
65
+ if (probe === undefined || url !== probe.entryURL)
66
+ return loaded;
67
+ const marked = MARKABLE.has(String(loaded.format));
68
+ // Written BEFORE the graph links, so its presence proves the hook saw the
69
+ // script. Without it a missing `loadedFile` would mean nothing: a script this
70
+ // hook never saw (a loader that rewrote its URL, say) is never did-not-load.
71
+ writeFileSync(probe.seenFile, JSON.stringify({ format: loaded.format, marked }));
72
+ if (!marked)
73
+ return loaded;
74
+ return { ...loaded, source: withMarker(sourceText(loaded.source)) };
75
+ }
76
+ function sourceText(source) {
77
+ if (typeof source === "string")
78
+ return source;
79
+ // `String(uint8array)` gives "1,2,3", so decode explicitly.
80
+ return new TextDecoder().decode(source ?? new Uint8Array());
81
+ }
82
+ /**
83
+ * Put the marker import on the file's FIRST line — after a shebang, which must
84
+ * stay first — so no line number moves; only columns on that one line do.
85
+ */
86
+ function withMarker(src) {
87
+ const mark = `import ${JSON.stringify(MARKER_URL)};`;
88
+ if (!src.startsWith("#!"))
89
+ return mark + src;
90
+ const nl = src.indexOf("\n");
91
+ if (nl === -1)
92
+ return `${src}\n${mark}`;
93
+ return src.slice(0, nl + 1) + mark + src.slice(nl + 1);
94
+ }
27
95
  //# sourceMappingURL=harness-resolve-hooks.mjs.map
@@ -5,13 +5,30 @@
5
5
  * every "cannot tell".
6
6
  */
7
7
  export declare function trackedLocalFiles(root: string): string[];
8
+ /**
9
+ * Does the COMMITTED `.vigiles/.gitignore` (the one at `HEAD`) lack entries
10
+ * vigiles needs — i.e. will vigiles' additions show up as a change to a tracked
11
+ * file until someone commits them.
12
+ *
13
+ * The question is asked of `HEAD`, not of the worktree's dirtiness, and that
14
+ * is the point (Codex review on #275, third pass). "The file is modified" was
15
+ * the earlier predicate; it could not tell vigiles' additions from the owner's
16
+ * own unrelated edit, and then advised committing work in progress. Reading the
17
+ * committed copy answers only for vigiles' entries, staged or not, and says
18
+ * nothing once they are committed, whatever else the owner is editing.
19
+ * `HEAD:./path`, not `HEAD:path`: without `./` git resolves the path from the
20
+ * REPOSITORY root, so a package nested in a larger worktree read the wrong file
21
+ * — or none — and stayed silent (Codex review on #275, gitrevisions(7)).
22
+ * Silent on every "cannot tell": not in a repo, no `HEAD`, file not committed.
23
+ */
24
+ export declare function committedIgnoreFileLacksEntries(root: string): boolean;
8
25
  /**
9
26
  * The one-line warning for {@link trackedLocalFiles}, or `null` when there is
10
27
  * nothing to say. Files inside a tracked local DIRECTORY (`state/`,
11
28
  * `eval-cache/`) are named by that directory, so one line stays one line and the
12
29
  * command it prints untracks all of them.
13
30
  */
14
- export declare function formatTrackedLocalFiles(tracked: readonly string[]): string | null;
31
+ export declare function formatTrackedLocalFiles(tracked: readonly string[], committedLacksEntries?: boolean): string | null;
15
32
  /** Print the warning for `root` to stderr when there is one. CLI-only. */
16
33
  export declare function warnTrackedLocalFiles(root: string): void;
17
34
  //# sourceMappingURL=local-files-tracked.d.ts.map
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.trackedLocalFiles = trackedLocalFiles;
4
+ exports.committedIgnoreFileLacksEntries = committedIgnoreFileLacksEntries;
4
5
  exports.formatTrackedLocalFiles = formatTrackedLocalFiles;
5
6
  exports.warnTrackedLocalFiles = warnTrackedLocalFiles;
6
7
  /**
@@ -20,6 +21,8 @@ exports.warnTrackedLocalFiles = warnTrackedLocalFiles;
20
21
  const node_child_process_1 = require("node:child_process");
21
22
  const node_path_1 = require("node:path");
22
23
  const local_files_js_1 = require("./local-files.js");
24
+ /** `.vigiles/.gitignore`, repo-relative. */
25
+ const IGNORE_FILE = node_path_1.posix.join(local_files_js_1.VIGILES_DIR, local_files_js_1.LOCAL_GITIGNORE_FILE);
23
26
  /**
24
27
  * The per-checkout files under `<root>/.vigiles/` that git tracks, as
25
28
  * repo-relative paths from `root`. Empty when nothing is tracked, when `root` is
@@ -42,28 +45,67 @@ function trackedLocalFiles(root) {
42
45
  return [];
43
46
  }
44
47
  }
48
+ /**
49
+ * Does the COMMITTED `.vigiles/.gitignore` (the one at `HEAD`) lack entries
50
+ * vigiles needs — i.e. will vigiles' additions show up as a change to a tracked
51
+ * file until someone commits them.
52
+ *
53
+ * The question is asked of `HEAD`, not of the worktree's dirtiness, and that
54
+ * is the point (Codex review on #275, third pass). "The file is modified" was
55
+ * the earlier predicate; it could not tell vigiles' additions from the owner's
56
+ * own unrelated edit, and then advised committing work in progress. Reading the
57
+ * committed copy answers only for vigiles' entries, staged or not, and says
58
+ * nothing once they are committed, whatever else the owner is editing.
59
+ * `HEAD:./path`, not `HEAD:path`: without `./` git resolves the path from the
60
+ * REPOSITORY root, so a package nested in a larger worktree read the wrong file
61
+ * — or none — and stayed silent (Codex review on #275, gitrevisions(7)).
62
+ * Silent on every "cannot tell": not in a repo, no `HEAD`, file not committed.
63
+ */
64
+ function committedIgnoreFileLacksEntries(root) {
65
+ try {
66
+ const r = (0, node_child_process_1.spawnSync)("git", ["show", `HEAD:./${IGNORE_FILE}`], {
67
+ cwd: root,
68
+ encoding: "utf-8",
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ });
71
+ if (r.status !== 0 || typeof r.stdout !== "string")
72
+ return false;
73
+ return (0, local_files_js_1.entriesNotInEffect)(r.stdout).length > 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
45
79
  /**
46
80
  * The one-line warning for {@link trackedLocalFiles}, or `null` when there is
47
81
  * nothing to say. Files inside a tracked local DIRECTORY (`state/`,
48
82
  * `eval-cache/`) are named by that directory, so one line stays one line and the
49
83
  * command it prints untracks all of them.
50
84
  */
51
- function formatTrackedLocalFiles(tracked) {
52
- if (tracked.length === 0)
53
- return null;
54
- // `.vigiles/state/.claude/hooks/x.json` → `.vigiles/state`: the list entry.
55
- const entries = [
56
- ...new Set(tracked.map((p) => p.split("/").slice(0, 2).join("/"))),
57
- ];
58
- const one = entries.length === 1;
59
- return (`⚠ ${entries.join(", ")} ${one ? "is" : "are"} tracked by git, but ` +
60
- `${local_files_js_1.VIGILES_DIR}/ local files describe one checkout and would credit a ` +
61
- `machine where nothing ran. .gitignore does not untrack a tracked file; ` +
62
- `untrack ${one ? "it" : "them"} once: git rm -r --cached ${entries.join(" ")}`);
85
+ function formatTrackedLocalFiles(tracked, committedLacksEntries = false) {
86
+ const lines = [];
87
+ if (tracked.length > 0) {
88
+ // `.vigiles/state/.claude/hooks/x.json` → `.vigiles/state`: the list entry.
89
+ const entries = [
90
+ ...new Set(tracked.map((p) => p.split("/").slice(0, 2).join("/"))),
91
+ ];
92
+ const one = entries.length === 1;
93
+ lines.push(`⚠ ${entries.join(", ")} ${one ? "is" : "are"} tracked by git, but ` +
94
+ `${local_files_js_1.VIGILES_DIR}/ local files describe one checkout and would credit a ` +
95
+ `machine where nothing ran. .gitignore does not untrack a tracked file; ` +
96
+ `untrack ${one ? "it" : "them"} once: git rm -r --cached ${entries.join(" ")}`);
97
+ }
98
+ // Not "untrack it": the repo may keep its own rules there, and untracking
99
+ // would take them away from everyone else.
100
+ if (committedLacksEntries)
101
+ lines.push(`⚠ ${IGNORE_FILE} is tracked by git and its committed version lacks ` +
102
+ `vigiles' local-file entries, which vigiles adds to your copy. Commit ` +
103
+ `those lines once; entries are appended only when missing.`);
104
+ return lines.length > 0 ? lines.join("\n") : null;
63
105
  }
64
106
  /** Print the warning for `root` to stderr when there is one. CLI-only. */
65
107
  function warnTrackedLocalFiles(root) {
66
- const line = formatTrackedLocalFiles(trackedLocalFiles(root));
108
+ const line = formatTrackedLocalFiles(trackedLocalFiles(root), committedIgnoreFileLacksEntries(root));
67
109
  if (line)
68
110
  process.stderr.write(line + "\n");
69
111
  }
@@ -35,6 +35,8 @@ export declare const LOCAL_FILES: readonly LocalFile[];
35
35
  */
36
36
  export declare const COMMITTED_PATHS: readonly string[];
37
37
  /** The header comment on a `.vigiles/.gitignore` vigiles creates. */
38
+ /** The ignore file vigiles keeps inside `.vigiles/`. One spelling for every reader. */
39
+ export declare const LOCAL_GITIGNORE_FILE = ".gitignore";
38
40
  export declare const LOCAL_GITIGNORE_HEADER: readonly string[];
39
41
  /** The ignore lines, in order: every local path, then the file itself. */
40
42
  export declare function localIgnoreEntries(): string[];
@@ -49,8 +51,9 @@ export declare function localIgnoreEntries(): string[];
49
51
  * or reordered. Appending is enough because git's last matching rule wins.
50
52
  *
51
53
  * "In effect" is judged the way git reads the file, not by text membership:
52
- * lines in order, a later `!/entry` cancels an earlier `/entry`, and leading
53
- * whitespace is part of the pattern (only trailing whitespace is dropped).
54
+ * lines in order, a later negation cancels an earlier `/entry` (see
55
+ * {@link inEffect}), and leading whitespace is part of the pattern (only
56
+ * trailing whitespace is dropped).
54
57
  * Both cases were measured with `git check-ignore`: `/coverage.json` followed
55
58
  * by `!/coverage.json`, and ` /coverage.json`, each leave the file NOT
56
59
  * ignored while a trimmed set would call the entry present.
@@ -58,5 +61,11 @@ export declare function localIgnoreEntries(): string[];
58
61
  * Costs one read on the hot path. Best-effort: any fs error is swallowed —
59
62
  * keeping git tidy must never break a hook, a test run or an audit.
60
63
  */
64
+ /**
65
+ * The entries a `.vigiles/.gitignore` with this content does NOT put in effect.
66
+ * One reader for both questions — "what to append" ({@link ensureLocalFilesIgnored})
67
+ * and "does the committed copy already carry them" (the CLI's tracked-file check).
68
+ */
69
+ export declare function entriesNotInEffect(content: string): string[];
61
70
  export declare function ensureLocalFilesIgnored(vigilesDir: string): void;
62
71
  //# sourceMappingURL=local-files.d.ts.map
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LOCAL_GITIGNORE_HEADER = exports.COMMITTED_PATHS = exports.LOCAL_FILES = exports.EVAL_CACHE_DIR = exports.HOOK_OBSERVATIONS_FILE = exports.GUARD_LEDGER_FILE = exports.EFFECT_ACTIVE_FILE = exports.ACTIVE_AGENT_FILE = exports.ACTIVE_SKILL_FILE = exports.HOOK_STATE_DIR = exports.LEDGER_FILE = exports.COVERAGE_ARTIFACT_FILE = exports.VIGILES_DIR = void 0;
3
+ exports.LOCAL_GITIGNORE_HEADER = exports.LOCAL_GITIGNORE_FILE = exports.COMMITTED_PATHS = exports.LOCAL_FILES = exports.EVAL_CACHE_DIR = exports.HOOK_OBSERVATIONS_FILE = exports.GUARD_LEDGER_FILE = exports.EFFECT_ACTIVE_FILE = exports.ACTIVE_AGENT_FILE = exports.ACTIVE_SKILL_FILE = exports.HOOK_STATE_DIR = exports.LEDGER_FILE = exports.COVERAGE_ARTIFACT_FILE = exports.VIGILES_DIR = void 0;
4
4
  exports.localIgnoreEntries = localIgnoreEntries;
5
+ exports.entriesNotInEffect = entriesNotInEffect;
5
6
  exports.ensureLocalFilesIgnored = ensureLocalFilesIgnored;
6
7
  /**
7
8
  * The files vigiles writes into a project's `.vigiles/` that describe ONE
@@ -108,6 +109,8 @@ exports.COMMITTED_PATHS = [
108
109
  "action-gates.json", // declared action gates (`action-gate.ts`)
109
110
  ];
110
111
  /** The header comment on a `.vigiles/.gitignore` vigiles creates. */
112
+ /** The ignore file vigiles keeps inside `.vigiles/`. One spelling for every reader. */
113
+ exports.LOCAL_GITIGNORE_FILE = ".gitignore";
111
114
  exports.LOCAL_GITIGNORE_HEADER = [
112
115
  "# Written by vigiles. These files describe one checkout on one machine and are",
113
116
  "# never committed. Everything else in .vigiles/ is the project's: commit it.",
@@ -116,16 +119,25 @@ exports.LOCAL_GITIGNORE_HEADER = [
116
119
  function localIgnoreEntries() {
117
120
  return [
118
121
  ...exports.LOCAL_FILES.map((f) => `/${f.name}${f.dir ? "/" : ""}`),
119
- "/.gitignore",
122
+ `/${exports.LOCAL_GITIGNORE_FILE}`,
120
123
  ];
121
124
  }
122
- /** Is `entry` ignoring its path after git reads `lines` top to bottom. */
125
+ /**
126
+ * Is `entry` ignoring its path after git reads `lines` top to bottom.
127
+ *
128
+ * Conservative on purpose: ANY negation after the entry's last occurrence counts
129
+ * as cancelling it. Git re-includes on `!/coverage.json`, but equally on
130
+ * `!coverage.json` or `!*.json` (last matching pattern wins), and matching
131
+ * gitignore globs here would be a second, partial implementation of git. The
132
+ * cost of being conservative is one extra append after an unrelated negation;
133
+ * the next call finds the entries last and leaves the file alone.
134
+ */
123
135
  function inEffect(lines, entry) {
124
136
  let on = false;
125
137
  for (const line of lines) {
126
138
  if (line === entry)
127
139
  on = true;
128
- else if (line === `!${entry}`)
140
+ else if (line.startsWith("!"))
129
141
  on = false;
130
142
  }
131
143
  return on;
@@ -141,8 +153,9 @@ function inEffect(lines, entry) {
141
153
  * or reordered. Appending is enough because git's last matching rule wins.
142
154
  *
143
155
  * "In effect" is judged the way git reads the file, not by text membership:
144
- * lines in order, a later `!/entry` cancels an earlier `/entry`, and leading
145
- * whitespace is part of the pattern (only trailing whitespace is dropped).
156
+ * lines in order, a later negation cancels an earlier `/entry` (see
157
+ * {@link inEffect}), and leading whitespace is part of the pattern (only
158
+ * trailing whitespace is dropped).
146
159
  * Both cases were measured with `git check-ignore`: `/coverage.json` followed
147
160
  * by `!/coverage.json`, and ` /coverage.json`, each leave the file NOT
148
161
  * ignored while a trimmed set would call the entry present.
@@ -150,9 +163,18 @@ function inEffect(lines, entry) {
150
163
  * Costs one read on the hot path. Best-effort: any fs error is swallowed —
151
164
  * keeping git tidy must never break a hook, a test run or an audit.
152
165
  */
166
+ /**
167
+ * The entries a `.vigiles/.gitignore` with this content does NOT put in effect.
168
+ * One reader for both questions — "what to append" ({@link ensureLocalFilesIgnored})
169
+ * and "does the committed copy already carry them" (the CLI's tracked-file check).
170
+ */
171
+ function entriesNotInEffect(content) {
172
+ const lines = content.split(/\r?\n/).map((l) => l.replace(/\s+$/, ""));
173
+ return localIgnoreEntries().filter((e) => !inEffect(lines, e));
174
+ }
153
175
  function ensureLocalFilesIgnored(vigilesDir) {
154
176
  try {
155
- const file = (0, node_path_1.resolve)(vigilesDir, ".gitignore");
177
+ const file = (0, node_path_1.resolve)(vigilesDir, exports.LOCAL_GITIGNORE_FILE);
156
178
  const entries = localIgnoreEntries();
157
179
  let current;
158
180
  try {
@@ -166,11 +188,10 @@ function ensureLocalFilesIgnored(vigilesDir) {
166
188
  (0, node_fs_1.writeFileSync)(file, [...exports.LOCAL_GITIGNORE_HEADER, ...entries, ""].join("\n"));
167
189
  return;
168
190
  }
169
- const lines = current.split(/\r?\n/).map((l) => l.replace(/\s+$/, ""));
170
- const missing = entries.filter((e) => !inEffect(lines, e));
191
+ const missing = entriesNotInEffect(current);
171
192
  if (missing.length === 0)
172
193
  return;
173
- const header = lines.includes(exports.LOCAL_GITIGNORE_HEADER[0])
194
+ const header = current.split(/\r?\n/).includes(exports.LOCAL_GITIGNORE_HEADER[0])
174
195
  ? []
175
196
  : exports.LOCAL_GITIGNORE_HEADER;
176
197
  const sep = current === "" || current.endsWith("\n") ? "" : "\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "30.0.1",
3
+ "version": "30.0.2",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",