vigiles 15.2.1 → 15.3.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.
@@ -71,6 +71,23 @@ function fieldSchema(type) {
71
71
  return { type: "boolean" };
72
72
  case "string[]":
73
73
  return { type: "array", items: { type: "string" } };
74
+ default:
75
+ // 🔴 Unreachable from TypeScript, reachable from JavaScript — and the one
76
+ // shipped example of this API (examples/experimental-emit/run-emit.mjs) is
77
+ // .mjs, so this is the path a real author takes.
78
+ //
79
+ // Without this arm the switch fell through to `undefined`, and every later
80
+ // step read that as a field: `"actions" in properties` is TRUE for an
81
+ // undefined value, so nothing noticed; `JSON.stringify` then DROPPED the
82
+ // key while `required` kept it and `additionalProperties: false` forbade
83
+ // it. The served schema demanded a property it also banned — unsatisfiable,
84
+ // silent, and contradicted by `.instruction`, which still asked the model
85
+ // to send it. Throwing here makes the contradiction impossible to construct
86
+ // instead of merely unlikely.
87
+ throw new TypeError(`experimental_emitTool: unsupported field type ${JSON.stringify(type)}. ` +
88
+ `Supported: "string", "number", "boolean", "string[]". ` +
89
+ `Nested objects and enums are outside this surface's ceiling — flatten the field, ` +
90
+ `or use the fenced fork rail if the shape cannot be flattened.`);
74
91
  }
75
92
  }
76
93
  function trackSchema(shape) {
@@ -0,0 +1,96 @@
1
+ /** One edit: the file, the exact substring to find, and what replaces it. */
2
+ export type MutationEdit = readonly [
3
+ file: string,
4
+ find: string,
5
+ replace: string
6
+ ];
7
+ /** One planted defect and the assertion that must catch it. */
8
+ export interface MutationCase {
9
+ /** Short id, printed in the report. */
10
+ readonly name: string;
11
+ /** What the mutation takes away, in words — the column a reader scans. */
12
+ readonly disables: string;
13
+ /**
14
+ * The edits that plant it. A LIST, because some defects are only expressible as more than one:
15
+ * removing a guard AND the vocabulary check that would otherwise throw for an unrelated reason.
16
+ * Every edit must match its `find` EXACTLY ONCE, or the case is reported `not-applied`.
17
+ */
18
+ readonly edits: readonly MutationEdit[];
19
+ /** The test file that must go red. Its absence is refused, loudly, before anything is touched. */
20
+ readonly test: string;
21
+ /** A substring the test's complaint must contain, so a kill by a NEIGHBOUR is not counted. */
22
+ readonly expect: string;
23
+ }
24
+ /**
25
+ * - `killed` — the test went red AND printed `expect`. The only outcome that counts as proof.
26
+ * - `wrong-assertion` — red, but not with this case's message: two defects share one assertion and
27
+ * neither is really watched.
28
+ * - `survived` — the test stayed green. The assertion is vacuous, or absent.
29
+ * - `unjudgeable` — the test was ALREADY red before the run, and `expect` never printed, so "red"
30
+ * carries no information about this mutation. Not a pass and not a failure of the assertion.
31
+ * - `not-applied` — the edit did not land: `find` matched zero or several times, or the
32
+ * replacement equalled the original.
33
+ */
34
+ export type MutationVerdict = "killed" | "wrong-assertion" | "survived" | "unjudgeable" | "not-applied";
35
+ /** What one case did. */
36
+ export interface MutationOutcome {
37
+ readonly name: string;
38
+ readonly disables: string;
39
+ readonly verdict: MutationVerdict;
40
+ /** Human-readable specifics — which file, how many matches, whether a retry was needed. */
41
+ readonly detail: string;
42
+ }
43
+ /** What a whole run did. */
44
+ export interface MutationReport {
45
+ readonly outcomes: readonly MutationOutcome[];
46
+ /** Cases with verdict `killed`. */
47
+ readonly killed: number;
48
+ /** Test files that were red BEFORE any mutation, so they can testify about nothing. */
49
+ readonly alreadyRed: readonly string[];
50
+ /**
51
+ * Whether every test that was green before the run is green again after it. `false` means the
52
+ * restore failed and the working tree is not what it was — the one outcome worth interrupting for.
53
+ */
54
+ readonly restored: boolean;
55
+ }
56
+ export interface RunMutationsOptions {
57
+ /** Working directory for the test runs; every path in `edits` and `test` is absolute. */
58
+ readonly cwd: string;
59
+ readonly cases: readonly MutationCase[];
60
+ /**
61
+ * Extra environment for each test run, merged over `process.env`.
62
+ * Use it to hand the test the same variables its normal runner would.
63
+ */
64
+ readonly env?: NodeJS.ProcessEnv;
65
+ }
66
+ /**
67
+ * Plant each case's defect, run the test that owns it, restore, and report what happened.
68
+ *
69
+ * ```ts
70
+ * const report = runMutations({
71
+ * cwd: repoRoot,
72
+ * cases: [{
73
+ * name: "year",
74
+ * disables: "the year comparison",
75
+ * edits: [[checker, "rec.year !== ourYear", "false"]],
76
+ * test: harness,
77
+ * expect: "a wrong year was not reported",
78
+ * }],
79
+ * });
80
+ * console.log(formatMutationReport(report));
81
+ * process.exit(report.killed === report.outcomes.length && report.restored ? 0 : 1);
82
+ * ```
83
+ *
84
+ * 🔴 THIS REWRITES THE FILES NAMED IN `edits` AND RESTORES THEM. The restore runs in a `finally`
85
+ * AND on SIGINT/SIGTERM, because a run killed midway would otherwise leave a neutered checker on
86
+ * disk — and the next run would then measure an already-broken repo and call it healthy. Commit
87
+ * before running. (Do not reach for `git stash` to clear the tree: the stash is repository-global,
88
+ * so a second agent working in another worktree of the same repo will pop your entries.)
89
+ *
90
+ * @throws if `cases` is empty, or if any named test file does not exist — both BEFORE any file is
91
+ * touched, so the message arrives with a clean working tree.
92
+ */
93
+ export declare function runMutations(o: RunMutationsOptions): MutationReport;
94
+ /** Render a report the way the CLI-style runners in this package render theirs. */
95
+ export declare function formatMutationReport(report: MutationReport): string;
96
+ //# sourceMappingURL=mutations.d.ts.map
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runMutations = runMutations;
4
+ exports.formatMutationReport = formatMutationReport;
5
+ /**
6
+ * `runMutations` — prove a test can FAIL, by breaking the thing it watches.
7
+ *
8
+ * A green test says the checker passed. It does not say the test would notice if the check were
9
+ * deleted: an assertion can be vacuous, a fixture can be wrong in the same direction as the code,
10
+ * and both look exactly like a pass. The only way to tell is to plant a defect and require the
11
+ * test to go red — with the message that NAMES that defect, not merely with something red.
12
+ *
13
+ * ## Why this is in vigiles rather than in a repo's own scripts
14
+ *
15
+ * It arrived as ten hand-written copies of one driver in a dogfooding repo (1799 lines), and the
16
+ * copies had DRIFTED. Counted against the committed versions on 2026-08-14:
17
+ *
18
+ * - the NO-OP guard (a replacement equal to the original leaves a green test proving nothing)
19
+ * was in 4 of 10;
20
+ * - the RETRY (a non-kill re-run once before it is believed) was in 1 of 10;
21
+ * - the strict rule — killed by its OWN named assertion, not merely by something going red —
22
+ * was in 1 of 10.
23
+ *
24
+ * Every one of those was written AFTER it caught something, in whichever copy happened to catch
25
+ * it, and never travelled to the other nine. Two copies also named a test file that a refactor had
26
+ * deleted; the runner they used exits 0 on a path matching nothing, so those cases reported
27
+ * SURVIVED on every run for three days. That is the argument for one engine: not less code, but
28
+ * one place where each of those becomes impossible again.
29
+ *
30
+ * ## What this is NOT
31
+ *
32
+ * Not Stryker/mutmut/PIT. Those GENERATE mutants from operators (flip a `<`, drop a `return`) over
33
+ * production code and score a suite by kill ratio. Here the mutations are HAND-AUTHORED and each
34
+ * one names the assertion that must catch it, because the subject is usually not general-purpose
35
+ * code — it is a checker, a hook, an instruction file — where "flip an operator" produces mostly
36
+ * unreachable nonsense and a kill ratio measures nothing. The generated-operator approach is the
37
+ * better tool when it applies; reach for it there.
38
+ *
39
+ * ## The contract, in one sentence
40
+ *
41
+ * Plant one defect, run the test that owns it, and require the test to fail with the message that
42
+ * names it — anything else is reported as a finding, never as a pass.
43
+ *
44
+ * @module
45
+ */
46
+ const node_fs_1 = require("node:fs");
47
+ const node_child_process_1 = require("node:child_process");
48
+ const node_path_1 = require("node:path");
49
+ const run_scripts_js_1 = require("./adapters/claude-code/run-scripts.js");
50
+ function runTest(file, o) {
51
+ const argv = (0, run_scripts_js_1.interpreterArgs)(file, (0, run_scripts_js_1.detectNodeCaps)(o.cwd));
52
+ const r = (0, node_child_process_1.spawnSync)("node", argv, {
53
+ cwd: o.cwd,
54
+ encoding: "utf8",
55
+ env: { ...process.env, ...o.env },
56
+ });
57
+ return { failed: r.status !== 0, out: (r.stdout ?? "") + (r.stderr ?? "") };
58
+ }
59
+ /**
60
+ * Plant each case's defect, run the test that owns it, restore, and report what happened.
61
+ *
62
+ * ```ts
63
+ * const report = runMutations({
64
+ * cwd: repoRoot,
65
+ * cases: [{
66
+ * name: "year",
67
+ * disables: "the year comparison",
68
+ * edits: [[checker, "rec.year !== ourYear", "false"]],
69
+ * test: harness,
70
+ * expect: "a wrong year was not reported",
71
+ * }],
72
+ * });
73
+ * console.log(formatMutationReport(report));
74
+ * process.exit(report.killed === report.outcomes.length && report.restored ? 0 : 1);
75
+ * ```
76
+ *
77
+ * 🔴 THIS REWRITES THE FILES NAMED IN `edits` AND RESTORES THEM. The restore runs in a `finally`
78
+ * AND on SIGINT/SIGTERM, because a run killed midway would otherwise leave a neutered checker on
79
+ * disk — and the next run would then measure an already-broken repo and call it healthy. Commit
80
+ * before running. (Do not reach for `git stash` to clear the tree: the stash is repository-global,
81
+ * so a second agent working in another worktree of the same repo will pop your entries.)
82
+ *
83
+ * @throws if `cases` is empty, or if any named test file does not exist — both BEFORE any file is
84
+ * touched, so the message arrives with a clean working tree.
85
+ */
86
+ function runMutations(o) {
87
+ if (o.cases.length === 0) {
88
+ throw new Error("runMutations: no cases. A mutation run with nothing to run reports success, which is the exact claim this API exists to make impossible.");
89
+ }
90
+ const tests = [...new Set(o.cases.map((c) => c.test))];
91
+ // A test path that resolves to nothing is the defect that hid for three days in the corpus this
92
+ // came from: the runner exits 0 on "no files matched", so every case naming it reported SURVIVED
93
+ // and sent the reader hunting for an assertion that was never reached.
94
+ const missing = tests.filter((t) => !(0, node_fs_1.existsSync)(t));
95
+ if (missing.length > 0) {
96
+ throw new Error(`runMutations: test file(s) do not exist, so no case naming them could ever be judged:\n ${missing.join("\n ")}`);
97
+ }
98
+ // Baselined BEFORE anything is touched. Without this, a test that is red on purpose (an open
99
+ // finding it is meant to report) makes every run announce "the restore failed" about a working
100
+ // restore, and makes every case against it look killed.
101
+ const alreadyRed = tests.filter((t) => runTest(t, o).failed);
102
+ const redBefore = new Set(alreadyRed);
103
+ const targets = [...new Set(o.cases.flatMap((c) => c.edits.map(([f]) => f)))];
104
+ const originals = new Map(targets.map((f) => [f, (0, node_fs_1.readFileSync)(f, "utf8")]));
105
+ const restore = () => {
106
+ for (const [f, text] of originals)
107
+ (0, node_fs_1.writeFileSync)(f, text);
108
+ };
109
+ const onSignal = (sig) => {
110
+ restore();
111
+ process.exit(sig === "SIGINT" ? 130 : 143);
112
+ };
113
+ process.on("SIGINT", onSignal);
114
+ process.on("SIGTERM", onSignal);
115
+ const outcomes = [];
116
+ try {
117
+ for (const c of o.cases) {
118
+ const applied = apply(c);
119
+ if (applied) {
120
+ outcomes.push({
121
+ name: c.name,
122
+ disables: c.disables,
123
+ verdict: "not-applied",
124
+ detail: applied,
125
+ });
126
+ restore();
127
+ continue;
128
+ }
129
+ // A non-kill is retried ONCE before it is believed. Observed on a real corpus: a row killed
130
+ // as named came back as a neighbour's assertion in a later full run and reproduced as a clean
131
+ // kill when replayed alone. Reporting a flake as a survivor sends the next reader to rewrite
132
+ // a working assertion, which is worse than one extra run.
133
+ let judged = judge(c, o, redBefore);
134
+ if (judged.verdict !== "killed") {
135
+ const second = judge(c, o, redBefore);
136
+ if (second.verdict === "killed")
137
+ judged = {
138
+ verdict: "killed",
139
+ detail: "killed on retry (the first run was a flake)",
140
+ };
141
+ else
142
+ judged = second;
143
+ }
144
+ outcomes.push({ name: c.name, disables: c.disables, ...judged });
145
+ restore();
146
+ }
147
+ }
148
+ finally {
149
+ restore();
150
+ process.off("SIGINT", onSignal);
151
+ process.off("SIGTERM", onSignal);
152
+ }
153
+ // Only a test that was GREEN before can testify about the restore.
154
+ const restored = tests
155
+ .filter((t) => !redBefore.has(t))
156
+ .every((t) => !runTest(t, o).failed);
157
+ return {
158
+ outcomes,
159
+ killed: outcomes.filter((r) => r.verdict === "killed").length,
160
+ alreadyRed,
161
+ restored,
162
+ };
163
+ }
164
+ /** Plants one case's edits. Returns a reason string when it could not be planted, else null. */
165
+ function apply(c) {
166
+ for (const [file, find, replace] of c.edits) {
167
+ const src = (0, node_fs_1.readFileSync)(file, "utf8");
168
+ const n = src.split(find).length - 1;
169
+ if (n !== 1) {
170
+ return n === 0
171
+ ? `"find" matched nothing in ${(0, node_path_1.basename)(file)} — the source moved under the case`
172
+ : `"find" matched ${String(n)} times in ${(0, node_path_1.basename)(file)}; an edit must be unambiguous`;
173
+ }
174
+ const next = src.replace(find, replace);
175
+ // The mutation-that-does-not-mutate, caught against the BYTES rather than the intent: a
176
+ // replacement equal to the original leaves a green test that reads exactly like a kill.
177
+ if (next === src)
178
+ return `the replacement equals the original in ${(0, node_path_1.basename)(file)}`;
179
+ (0, node_fs_1.writeFileSync)(file, next);
180
+ }
181
+ return null;
182
+ }
183
+ function judge(c, o, redBefore) {
184
+ const { failed, out } = runTest(c.test, o);
185
+ if (!failed)
186
+ return {
187
+ verdict: "survived",
188
+ detail: `${(0, node_path_1.basename)(c.test)} stayed green with the defect planted`,
189
+ };
190
+ if (out.includes(c.expect))
191
+ return { verdict: "killed", detail: `named by "${c.expect}"` };
192
+ // When the test was ALREADY red, "red" carries no information — but the MESSAGE still does,
193
+ // because a runner that aborts at the first failure never reaches a later assertion. Absent it,
194
+ // the two causes are indistinguishable, and calling it a wrong assertion would be a guess.
195
+ return redBefore.has(c.test)
196
+ ? {
197
+ verdict: "unjudgeable",
198
+ detail: `${(0, node_path_1.basename)(c.test)} was red before the run and "${c.expect}" never printed`,
199
+ }
200
+ : {
201
+ verdict: "wrong-assertion",
202
+ detail: `red, but "${c.expect}" never printed — a neighbour's assertion caught it`,
203
+ };
204
+ }
205
+ /** Render a report the way the CLI-style runners in this package render theirs. */
206
+ function formatMutationReport(report) {
207
+ const lines = [];
208
+ if (report.alreadyRed.length > 0) {
209
+ lines.push(`â„šī¸ already red before any mutation: ${report.alreadyRed.map((t) => (0, node_path_1.basename)(t)).join(", ")} — excluded from the restore check; a case naming one is reported unjudgeable.`, "");
210
+ }
211
+ const width = Math.max(...report.outcomes.map((r) => r.name.length));
212
+ const mark = {
213
+ killed: "✓ killed",
214
+ "wrong-assertion": "🔴 wrong assertion",
215
+ survived: "🔴 SURVIVED",
216
+ unjudgeable: "🔴 unjudgeable",
217
+ "not-applied": "🔴 not applied",
218
+ };
219
+ for (const r of report.outcomes) {
220
+ lines.push(`${r.name.padEnd(width)} ${mark[r.verdict].padEnd(20)} ${r.disables}`);
221
+ if (r.verdict !== "killed")
222
+ lines.push(`${" ".repeat(width + 2)} ${r.detail}`);
223
+ }
224
+ lines.push("");
225
+ lines.push(report.restored
226
+ ? "restored: every test that was green before the run is green again"
227
+ : "🔴 RESTORE FAILED — a test that was green before the run is red now. The working tree is not what it was.");
228
+ const bad = report.outcomes.length - report.killed;
229
+ lines.push(bad === 0
230
+ ? `✓ all ${String(report.outcomes.length)} mutations killed, each at its own assertion`
231
+ : `🔴 ${String(bad)} of ${String(report.outcomes.length)} not killed as named: ${report.outcomes
232
+ .filter((r) => r.verdict !== "killed")
233
+ .map((r) => r.name)
234
+ .join(", ")}`);
235
+ return lines.join("\n");
236
+ }
237
+ //# sourceMappingURL=mutations.js.map
package/dist/testing.d.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * `research/adapter-api-design.md`.
12
12
  */
13
13
  export { recordCheck } from "./check-count.js";
14
+ export { runMutations, formatMutationReport, type MutationCase, type MutationEdit, type MutationOutcome, type MutationReport, type MutationVerdict, type RunMutationsOptions, } from "./mutations.js";
14
15
  export { runScript } from "./run-script.js";
15
16
  export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
16
17
  export { runHook, propertyHook, fileToolEvents } from "./run-hook.js";
package/dist/testing.js CHANGED
@@ -30,7 +30,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
30
30
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
31
31
  };
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.fileToolEvents = exports.propertyHook = exports.runHook = exports.runScript = exports.recordCheck = void 0;
33
+ exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.fileToolEvents = exports.propertyHook = exports.runHook = exports.runScript = exports.formatMutationReport = exports.runMutations = exports.recordCheck = void 0;
34
34
  // --- reporting: how much did this script actually do? ---
35
35
  // `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
36
36
  // prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
@@ -39,6 +39,14 @@ exports.formatContainment = exports.compareContainment = exports.skillContract =
39
39
  // vitest's `expect` — so those are visible to the runner too. See check-count.ts.
40
40
  var check_count_js_1 = require("./check-count.js");
41
41
  Object.defineProperty(exports, "recordCheck", { enumerable: true, get: function () { return check_count_js_1.recordCheck; } });
42
+ // --- the tier above the tiers: is a passing test PROVING anything? ---
43
+ // Every tier below reports that a check passed. None can tell a watched assertion
44
+ // from a vacuous one — both print `✓`. `runMutations` plants a defect, runs the
45
+ // test that owns it, and requires the test to fail with the message that NAMES
46
+ // it, so "green" stops being the strongest claim a suite can make about itself.
47
+ var mutations_js_1 = require("./mutations.js");
48
+ Object.defineProperty(exports, "runMutations", { enumerable: true, get: function () { return mutations_js_1.runMutations; } });
49
+ Object.defineProperty(exports, "formatMutationReport", { enumerable: true, get: function () { return mutations_js_1.formatMutationReport; } });
42
50
  // --- unit tier: runScript (the primitive) + runHook (it, plus a decision) ---
43
51
  // `runScript` runs any program and reports what it DID (exit, both streams,
44
52
  // writes, egress). `runHook` is that plus the hook protocol: event to stdin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "15.2.1",
3
+ "version": "15.3.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",