vigiles 21.0.2 → 22.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/cli.d.ts CHANGED
@@ -9,5 +9,6 @@
9
9
  * `self-command-refs.test.ts` did not catch it because it guards against refs to
10
10
  * REMOVED commands, not against a list that merely stops growing.
11
11
  */
12
- export {};
12
+ /** Reason the most recent `loadSpec()` returned null, or null if it succeeded. */
13
+ export declare function specLoadFailureReason(): string | null;
13
14
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -11,8 +11,10 @@
11
11
  * REMOVED commands, not against a list that merely stops growing.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.specLoadFailureReason = specLoadFailureReason;
14
15
  const node_fs_1 = require("node:fs");
15
16
  const node_path_1 = require("node:path");
17
+ const node_child_process_1 = require("node:child_process");
16
18
  const glob_1 = require("glob");
17
19
  const generate_types_js_1 = require("./core/generate-types.js");
18
20
  const generate_harness_js_1 = require("./core/generate-harness.js");
@@ -96,54 +98,168 @@ function findSpecs(pattern) {
96
98
  cwd: process.cwd(),
97
99
  });
98
100
  }
99
- async function loadSpec(specPath) {
100
- const fullPath = (0, node_path_1.resolve)(process.cwd(), specPath);
101
- // Try multiple dist/ path strategies
102
- const candidates = [];
103
- // src/ dist/ mapping (e.g., src/CLAUDE.md.spec.ts dist/CLAUDE.md.spec.js)
104
- if (fullPath.includes("/src/")) {
105
- candidates.push(fullPath.replace(/\/src\//, "/dist/").replace(/\.ts$/, ".js"));
106
- }
107
- // Root-level spec → dist/ (e.g., CLAUDE.md.spec.ts → dist/CLAUDE.md.spec.js)
108
- const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
109
- const base = fullPath.substring(fullPath.lastIndexOf("/") + 1);
110
- candidates.push((0, node_path_1.resolve)(dir, "dist", base.replace(/\.ts$/, ".js")));
111
- // examples/ dist/examples/ mapping
112
- candidates.push(fullPath
113
- .replace(/\.ts$/, ".js")
114
- .replace(process.cwd(), (0, node_path_1.resolve)(process.cwd(), "dist")));
115
- for (const distPath of candidates) {
116
- if ((0, node_fs_1.existsSync)(distPath)) {
101
+ /**
102
+ * Why the last `loadSpec()` returned null.
103
+ *
104
+ * Kept as module state rather than a widened return type: `loadSpec` has six
105
+ * call sites and only one of them reports to a human.
106
+ */
107
+ let lastSpecLoadFailure = null;
108
+ /** Reason the most recent `loadSpec()` returned null, or null if it succeeded. */
109
+ function specLoadFailureReason() {
110
+ return lastSpecLoadFailure;
111
+ }
112
+ /**
113
+ * How long one spec may take to evaluate before the host is killed.
114
+ *
115
+ * Overridable because 15s is a guess that fits the specs we have seen, not a
116
+ * law; a repo with genuinely slow specs should be able to raise it rather than
117
+ * discover the number by hitting it.
118
+ */
119
+ const SPEC_DEADLINE_MS = Number(process.env.VIGILES_SPEC_TIMEOUT_MS) || 15_000;
120
+ let host = null;
121
+ /** The compiled host entry, beside this file in `dist/`. */
122
+ function hostEntry() {
123
+ return (0, node_path_1.resolve)(__dirname, "spec-host.mjs");
124
+ }
125
+ function startHost() {
126
+ const child = (0, node_child_process_1.spawn)(process.execPath, [hostEntry()], {
127
+ cwd: process.cwd(),
128
+ stdio: ["pipe", "pipe", "pipe"],
129
+ });
130
+ const h = { child, pending: new Map(), started: null, buffered: "" };
131
+ child.stdout.setEncoding("utf-8");
132
+ child.stdout.on("data", (chunk) => {
133
+ h.buffered += chunk;
134
+ let nl;
135
+ while ((nl = h.buffered.indexOf("\n")) >= 0) {
136
+ const line = h.buffered.slice(0, nl).trim();
137
+ h.buffered = h.buffered.slice(nl + 1);
138
+ if (!line)
139
+ continue;
140
+ let reply;
117
141
  try {
118
- const mod = (await import(distPath));
119
- // CJS double-default: `{ default: { default: spec } }`.
120
- const raw = mod.default;
121
- if (raw && typeof raw === "object" && "default" in raw) {
122
- return raw.default;
123
- }
124
- return raw;
142
+ reply = JSON.parse(line);
125
143
  }
126
144
  catch {
127
- // Try next candidate
145
+ continue; // not ours; a spec writing to stdout cannot corrupt the stream
146
+ }
147
+ if ("phase" in reply) {
148
+ h.started = reply.path;
149
+ continue;
128
150
  }
151
+ const done = h.pending.get(reply.path);
152
+ h.pending.delete(reply.path);
153
+ done?.(reply);
129
154
  }
155
+ });
156
+ // Anything the child says on stderr is the spec's own noise; keep it out of
157
+ // our stdout so `--json` consumers are not corrupted, but do not lose it.
158
+ child.stderr.setEncoding("utf-8");
159
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
160
+ // 🔴 Unreferenced, or the CLI never exits. A piped child and its three
161
+ // streams each hold the event loop open, so `compile` finished its work and
162
+ // then hung forever waiting on a host that had nothing left to say. The
163
+ // in-flight deadline timer keeps the loop alive while a request is pending,
164
+ // which is exactly as long as we need it.
165
+ // ONE exit listener per host, not one per request: with concurrent callers the
166
+ // per-request version added a listener each time and Node warned at eleven.
167
+ // It fails every outstanding request, because a dead host answers none of them.
168
+ child.once("exit", () => {
169
+ const waiting = [...h.pending.values()];
170
+ h.pending.clear();
171
+ for (const settle of waiting)
172
+ settle("died");
173
+ });
174
+ // The stdio types are Readable/Writable, which do not declare `unref` — the
175
+ // objects are pipes and do have it. Optional-called so this stays correct if
176
+ // a platform ever hands back a stream that genuinely lacks it.
177
+ const unref = (s) => s?.unref?.();
178
+ child.unref();
179
+ unref(child.stdin);
180
+ unref(child.stdout);
181
+ unref(child.stderr);
182
+ return h;
183
+ }
184
+ /**
185
+ * Kill the host and forget it; the next request starts a fresh one.
186
+ *
187
+ * Outstanding requests are failed rather than dropped: a killed host will never
188
+ * answer them, and a promise nobody settles is a hang wearing a different hat.
189
+ */
190
+ function dropHost() {
191
+ if (!host)
192
+ return;
193
+ const dying = host;
194
+ host = null;
195
+ const waiting = [...dying.pending.values()];
196
+ dying.pending.clear();
197
+ dying.child.kill("SIGKILL");
198
+ for (const settle of waiting)
199
+ settle("died");
200
+ }
201
+ process.on("exit", dropHost);
202
+ /**
203
+ * Load one spec in the spec host.
204
+ *
205
+ * 🔴 **Why a child process rather than `import()` here.** A module evaluation
206
+ * cannot be cancelled once started — `Promise.race` hands control back but the
207
+ * evaluation keeps running and holds the event loop — so an in-process loader
208
+ * gives a stalled spec an unbounded hang in `compile`, `test` and `audit`. It
209
+ * also cannot tell whether a failed spec already ran (Node reports
210
+ * `ERR_MODULE_NOT_FOUND` and `SyntaxError` both before and during evaluation),
211
+ * which is what made the previous two-loader arrangement unfixable rather than
212
+ * merely buggy: it had to guess whether re-running was safe.
213
+ *
214
+ * The host is spawned with `process.execPath` — never `npx` — so nothing is
215
+ * fetched and nothing needs installing.
216
+ */
217
+ async function loadSpec(specPath) {
218
+ const fullPath = (0, node_path_1.resolve)(process.cwd(), specPath);
219
+ lastSpecLoadFailure = null;
220
+ if (!(0, node_fs_1.existsSync)(fullPath)) {
221
+ lastSpecLoadFailure = `no such file: ${specPath}`;
222
+ return null;
130
223
  }
131
- // Try loading .ts directly via tsx
132
- try {
133
- const { execSync } = require("node:child_process");
134
- // Handle ESM/CJS double-default: m.default may itself have a .default
135
- const script = `import(${JSON.stringify(fullPath)}).then(m => { const d = m.default?.default ?? m.default; console.log(JSON.stringify(d)); })`;
136
- const output = execSync(`npx tsx -e '${script.replace(/'/g, "'\\''")}'`, {
137
- encoding: "utf-8",
138
- cwd: process.cwd(),
139
- stdio: ["pipe", "pipe", "pipe"],
140
- timeout: 15000,
141
- });
142
- return JSON.parse(output.trim());
224
+ host ??= startHost();
225
+ const h = host;
226
+ const reply = await new Promise((done) => {
227
+ let settled = false;
228
+ const finish = (r) => {
229
+ if (settled)
230
+ return;
231
+ settled = true;
232
+ clearTimeout(timer);
233
+ h.pending.delete(fullPath);
234
+ done(r);
235
+ };
236
+ const timer = setTimeout(() => {
237
+ finish("timeout");
238
+ }, SPEC_DEADLINE_MS);
239
+ h.pending.set(fullPath, finish);
240
+ h.child.stdin.write(JSON.stringify({ path: fullPath }) + "\n");
241
+ });
242
+ if (reply === "timeout") {
243
+ // The host's last `start` names the spec that stalled. Without it a hang
244
+ // produced N identical failures and no culprit.
245
+ const culprit = h.started ?? fullPath;
246
+ dropHost();
247
+ lastSpecLoadFailure =
248
+ `evaluating ${(0, node_path_1.relative)(process.cwd(), culprit)} exceeded ` +
249
+ `${SPEC_DEADLINE_MS}ms and was killed. Set VIGILES_SPEC_TIMEOUT_MS to ` +
250
+ `raise the limit, or look for a top-level await that never settles.`;
251
+ return null;
143
252
  }
144
- catch {
253
+ if (reply === "died") {
254
+ dropHost();
255
+ lastSpecLoadFailure = "the spec host exited unexpectedly.";
256
+ return null;
257
+ }
258
+ if (!("ok" in reply) || !reply.ok) {
259
+ lastSpecLoadFailure = `the spec did not load. ${"error" in reply ? reply.error : "no reason given"}`;
145
260
  return null;
146
261
  }
262
+ return reply.value;
147
263
  }
148
264
  // ---------------------------------------------------------------------------
149
265
  // Output helpers
@@ -357,7 +473,7 @@ async function compile(specPaths, config, opts = {}) {
357
473
  const spec = await loadSpec(specPath);
358
474
  if (!spec) {
359
475
  console.log(`\n✗ ${specPath} — failed to load`);
360
- console.log(` Ensure the spec is compiled: run \`npm run build\` first.`);
476
+ console.log(` ${specLoadFailureReason() ?? "reason unavailable"}`);
361
477
  allValid = false;
362
478
  continue;
363
479
  }
@@ -0,0 +1,35 @@
1
+ type ResolveContext = {
2
+ parentURL?: string;
3
+ conditions: string[];
4
+ };
5
+ type Resolved = {
6
+ url: string;
7
+ format?: string | null;
8
+ shortCircuit?: boolean;
9
+ };
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;
17
+ source?: string | ArrayBuffer;
18
+ shortCircuit?: boolean;
19
+ };
20
+ type NextLoad = (url: string, context: LoadContext) => Loaded | Promise<Loaded>;
21
+ /**
22
+ * `./x.js` → `./x.ts` when the sibling exists.
23
+ *
24
+ * This is the TypeScript ESM convention (`tsc` under `nodenext` requires the
25
+ * `.js` extension in the source), which `tsx` implements and native Node does
26
+ * not. It is the ONE divergence that matters in practice: this repository's own
27
+ * dogfood specs import `src/core/spec.js`, a file that does not exist on disk.
28
+ * Attempted only AFTER normal resolution fails, so it can never shadow a real
29
+ * `.js` file.
30
+ */
31
+ export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<Resolved>;
32
+ /** Transpile `.ts`/`.mts` with the TypeScript this package already ships. */
33
+ export declare function load(url: string, context: LoadContext, nextLoad: NextLoad): Promise<Loaded>;
34
+ export {};
35
+ //# sourceMappingURL=spec-hooks.d.mts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Module customization hooks for the spec host — vigiles' OWN loader for `.ts`
3
+ * specs, replacing "whichever loader happens to be installed".
4
+ *
5
+ * Why vigiles owns this rather than shelling to `tsx`:
6
+ *
7
+ * - **No install, no network.** `typescript` is already a runtime dependency
8
+ * of this package (`dependencies`, and `core/compile-generator.ts` uses it),
9
+ * so `ts.transpileModule` costs nothing extra. The bug that started this
10
+ * work was a consuming repo without `tsx`, where `npx tsx` went to the
11
+ * registry and every one of 50 specs blew a 15s budget.
12
+ * - **One resolution contract.** Before this, a spec's module resolution
13
+ * depended on the user's Node version and on which loader won — so a spec
14
+ * could load locally and fail in CI under different rules. A tool that
15
+ * audits other tools for that kind of quiet divergence should not have it.
16
+ *
17
+ * Scope is deliberately small and documented as such: `.ts`/`.mts` sources, the
18
+ * `./x.js` → `./x.ts` specifier rewrite, and bare specifiers. NOT tsconfig
19
+ * `paths`, JSX, or decorator configuration — specs are configuration modules,
20
+ * not applications.
21
+ */
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+ import ts from "typescript";
25
+ const TS_SOURCE = /\.m?ts$/;
26
+ /**
27
+ * `./x.js` → `./x.ts` when the sibling exists.
28
+ *
29
+ * This is the TypeScript ESM convention (`tsc` under `nodenext` requires the
30
+ * `.js` extension in the source), which `tsx` implements and native Node does
31
+ * not. It is the ONE divergence that matters in practice: this repository's own
32
+ * dogfood specs import `src/core/spec.js`, a file that does not exist on disk.
33
+ * Attempted only AFTER normal resolution fails, so it can never shadow a real
34
+ * `.js` file.
35
+ */
36
+ export async function resolve(specifier, context, nextResolve) {
37
+ try {
38
+ return await nextResolve(specifier, context);
39
+ }
40
+ catch (err) {
41
+ if (specifier.endsWith(".js") && context.parentURL) {
42
+ const candidate = new URL(specifier.slice(0, -3) + ".ts", context.parentURL);
43
+ if (existsSync(fileURLToPath(candidate))) {
44
+ return { url: candidate.href, format: "module", shortCircuit: true };
45
+ }
46
+ }
47
+ throw err;
48
+ }
49
+ }
50
+ /** Transpile `.ts`/`.mts` with the TypeScript this package already ships. */
51
+ export async function load(url, context, nextLoad) {
52
+ if (!TS_SOURCE.test(new URL(url).pathname))
53
+ return nextLoad(url, context);
54
+ const fileName = fileURLToPath(url);
55
+ const { outputText } = ts.transpileModule(readFileSync(fileName, "utf-8"), {
56
+ fileName,
57
+ compilerOptions: {
58
+ module: ts.ModuleKind.ESNext,
59
+ target: ts.ScriptTarget.ES2022,
60
+ // Erasing types is the whole job; anything that changes SEMANTICS is not
61
+ // ours to decide for a spec.
62
+ verbatimModuleSyntax: false,
63
+ isolatedModules: true,
64
+ },
65
+ });
66
+ return { format: "module", source: outputText, shortCircuit: true };
67
+ }
68
+ //# sourceMappingURL=spec-hooks.mjs.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=spec-host.d.mts.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The spec host — a child process that loads specs and streams results as NDJSON.
3
+ *
4
+ * ONE host per CLI command, not one per spec: Node startup and the TypeScript
5
+ * load are paid once, then each spec costs a transpile.
6
+ *
7
+ * 🔴 **Why a child process at all, when `import()` works in-process.** Because a
8
+ * module evaluation cannot be cancelled once started. `Promise.race` returns
9
+ * control to the caller but the evaluation keeps running and holds the event
10
+ * loop, so a spec that stalls at top level hangs `compile`, `test` and `audit`
11
+ * with no bound. A child can be killed. That is the entire argument, and it is
12
+ * why the in-process loader this replaced could not be repaired: it also had to
13
+ * answer "did the module body already run?" to know whether re-running was
14
+ * safe, and Node does not expose that bit — `ERR_MODULE_NOT_FOUND` and
15
+ * `SyntaxError` each occur both before and during evaluation.
16
+ *
17
+ * Protocol, one JSON object per line each way:
18
+ * in {"path":"<abs path to spec>"}
19
+ * out {"path":"…","phase":"start"} — emitted BEFORE evaluation
20
+ * out {"path":"…","ok":true,"value":{…}}
21
+ * out {"path":"…","ok":false,"error":"…"}
22
+ *
23
+ * The `start` line is what makes a hang diagnosable: when the parent's deadline
24
+ * fires, the last `start` without a result NAMES the spec that stalled. Before
25
+ * this, a stalled load produced N identical failures and no culprit.
26
+ *
27
+ * Values cross as JSON, which is not a new constraint — the previous `npx tsx`
28
+ * path already did `JSON.stringify` in the child and `JSON.parse` in the parent,
29
+ * so every spec that has ever loaded survived this round trip. Spec types carry
30
+ * no functions; TypeScript is the authoring layer, the value is data.
31
+ */
32
+ import { register } from "node:module";
33
+ import { pathToFileURL } from "node:url";
34
+ register(new URL("./spec-hooks.mjs", import.meta.url));
35
+ function say(line) {
36
+ process.stdout.write(JSON.stringify(line) + "\n");
37
+ }
38
+ async function loadOne(path) {
39
+ say({ path, phase: "start" });
40
+ try {
41
+ const mod = (await import(pathToFileURL(path).href));
42
+ // CJS interop can nest the default one level deeper.
43
+ const raw = mod.default;
44
+ const value = raw && typeof raw === "object" && "default" in raw ? raw.default : raw;
45
+ if (value === undefined) {
46
+ say({ path, ok: false, error: "the spec has no default export." });
47
+ return;
48
+ }
49
+ say({ path, ok: true, value });
50
+ }
51
+ catch (err) {
52
+ say({
53
+ path,
54
+ ok: false,
55
+ error: err instanceof Error ? (err.stack ?? err.message) : String(err),
56
+ });
57
+ }
58
+ }
59
+ // Requests are serialised: a spec may depend on module state a previous one set
60
+ // up, and interleaving would make a hang impossible to attribute.
61
+ let queue = Promise.resolve();
62
+ let buffered = "";
63
+ process.stdin.setEncoding("utf-8");
64
+ process.stdin.on("data", (chunk) => {
65
+ buffered += chunk;
66
+ let nl;
67
+ while ((nl = buffered.indexOf("\n")) >= 0) {
68
+ const line = buffered.slice(0, nl).trim();
69
+ buffered = buffered.slice(nl + 1);
70
+ if (!line)
71
+ continue;
72
+ const { path } = JSON.parse(line);
73
+ queue = queue.then(() => loadOne(path));
74
+ }
75
+ });
76
+ process.stdin.on("end", () => {
77
+ queue.then(() => process.exit(0));
78
+ });
79
+ //# sourceMappingURL=spec-host.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "21.0.2",
3
+ "version": "22.0.0",
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",