vigiles 4.1.0 → 5.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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A single fake binary: shadow the tool named `name` on PATH so that any
3
+ * invocation prints `stdout` (default `""`), writes `stderr` (default none) to
4
+ * fd 2, and exits with `exitCode` (default 0). Argv is ignored in the MVP.
5
+ */
6
+ export interface ToolStub {
7
+ /** The binary name to shadow on PATH (e.g. `"gh"`, `"psql"`). */
8
+ readonly name: string;
9
+ /** Canned stdout the fake prints. Default `""`. */
10
+ readonly stdout?: string;
11
+ /** Canned stderr the fake writes to fd 2. Default: none. */
12
+ readonly stderr?: string;
13
+ /** Exit code the fake returns. Default `0`. */
14
+ readonly exitCode?: number;
15
+ }
16
+ /**
17
+ * Render the POSIX shell script for one stub. `printf '%s'` (not `echo`) prints
18
+ * the decoded bytes with no added trailing newline and no backslash/`-n`
19
+ * surprises, so the fixture round-trips byte-for-byte.
20
+ */
21
+ export declare function renderToolStub(stub: ToolStub): string;
22
+ /**
23
+ * Write one executable POSIX shell stub per entry into `binDir` (which must
24
+ * already exist). Each file is named exactly `stub.name` and `chmod 0o755` so it
25
+ * is directly executable once `binDir` is on PATH. Pure-ish — fs only, no spawn.
26
+ */
27
+ export declare function writeToolStubs(binDir: string, stubs: readonly ToolStub[]): void;
28
+ /**
29
+ * Convenience: mkdtemp a fresh bin dir under `parentDir`, write `stubs` into it,
30
+ * and return its absolute path. Caller PREPENDS this dir to PATH so the fakes win
31
+ * over the real binaries, and removes it when done (it lives under `parentDir`,
32
+ * so a `parentDir` cleanup also clears it).
33
+ */
34
+ export declare function stubBinDir(stubs: readonly ToolStub[], parentDir: string): string;
35
+ //# sourceMappingURL=tool-stub.d.ts.map
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderToolStub = renderToolStub;
4
+ exports.writeToolStubs = writeToolStubs;
5
+ exports.stubBinDir = stubBinDir;
6
+ /**
7
+ * vigiles — **tool stubs on PATH** (rung R2 of the eval coverage model).
8
+ *
9
+ * A skill/hook/agent often calls a CLI tool (`psql`, `redis-cli`, `gh`, `git`,
10
+ * `z3`, …) and then works with the RESULT. To test that downstream logic without
11
+ * a live service, you SHADOW the real binary on PATH with a fake that emits a
12
+ * **recorded / author-provided canned result** — a standard PATH-shim +
13
+ * VCR-style record/replay technique, not a novel invention. See
14
+ * `research/eval-coverage-and-isolation.md` (the three-rung model).
15
+ *
16
+ * This module is the REPLAY half: write a fake executable per tool that prints a
17
+ * canned stdout/stderr and exits a canned code. A record-from-real-tool half (run
18
+ * the real binary once at a known version, capture its output as the fixture) is
19
+ * a documented follow-on — NOT implemented here.
20
+ *
21
+ * CRITICAL: the canned outputs are author/recorded fixtures, **never**
22
+ * model-synthesized — a synthesized `gh`/`git` output looks plausible but
23
+ * diverges from the real tool/version, producing false confidence (a green test
24
+ * against a fiction).
25
+ *
26
+ * MVP scope: one canned result per binary; argv is IGNORED (every invocation of
27
+ * the stub returns the same result). Argv-matching (different output per
28
+ * sub-command / flags) is a follow-on.
29
+ */
30
+ const node_fs_1 = require("node:fs");
31
+ const node_path_1 = require("node:path");
32
+ /**
33
+ * Encode a string as a single-line base64 literal safe to embed verbatim inside a
34
+ * POSIX shell script.
35
+ *
36
+ * We base64-encode-and-decode the canned content (rather than interpolating it
37
+ * raw, or `cat`ing a sibling data file) because base64's alphabet is a strict
38
+ * subset of `[A-Za-z0-9+/=]` — it can never contain a quote, `$`, backtick,
39
+ * newline, `;`, or any other shell metacharacter, so the embedded literal is
40
+ * injection-proof regardless of the fixture's bytes. The script decodes it back
41
+ * with `base64 -d` at run time, so the original bytes round-trip exactly. This is
42
+ * simpler than a sibling data file (one self-contained script, nothing else to
43
+ * write/clean up) and strictly safer than quoting.
44
+ */
45
+ function b64(s) {
46
+ return Buffer.from(s, "utf-8").toString("base64");
47
+ }
48
+ /**
49
+ * Render the POSIX shell script for one stub. `printf '%s'` (not `echo`) prints
50
+ * the decoded bytes with no added trailing newline and no backslash/`-n`
51
+ * surprises, so the fixture round-trips byte-for-byte.
52
+ */
53
+ function renderToolStub(stub) {
54
+ const lines = ["#!/bin/sh"];
55
+ // stdout: decode the base64 literal straight to stdout. We pipe `base64 -d`'s
56
+ // output directly (NOT through `printf '%s' "$(...)"`) because command
57
+ // substitution strips trailing newlines — piping preserves the bytes exactly.
58
+ if (stub.stdout !== undefined && stub.stdout !== "") {
59
+ lines.push(`printf '%s' '${b64(stub.stdout)}' | base64 -d`);
60
+ }
61
+ // stderr: same, redirected to fd 2.
62
+ if (stub.stderr !== undefined && stub.stderr !== "") {
63
+ lines.push(`printf '%s' '${b64(stub.stderr)}' | base64 -d >&2`);
64
+ }
65
+ lines.push(`exit ${String(stub.exitCode ?? 0)}`);
66
+ return lines.join("\n") + "\n";
67
+ }
68
+ /**
69
+ * Write one executable POSIX shell stub per entry into `binDir` (which must
70
+ * already exist). Each file is named exactly `stub.name` and `chmod 0o755` so it
71
+ * is directly executable once `binDir` is on PATH. Pure-ish — fs only, no spawn.
72
+ */
73
+ function writeToolStubs(binDir, stubs) {
74
+ for (const stub of stubs) {
75
+ const file = (0, node_path_1.join)(binDir, stub.name);
76
+ (0, node_fs_1.writeFileSync)(file, renderToolStub(stub), "utf-8");
77
+ (0, node_fs_1.chmodSync)(file, 0o755);
78
+ }
79
+ }
80
+ /**
81
+ * Convenience: mkdtemp a fresh bin dir under `parentDir`, write `stubs` into it,
82
+ * and return its absolute path. Caller PREPENDS this dir to PATH so the fakes win
83
+ * over the real binaries, and removes it when done (it lives under `parentDir`,
84
+ * so a `parentDir` cleanup also clears it).
85
+ */
86
+ function stubBinDir(stubs, parentDir) {
87
+ (0, node_fs_1.mkdirSync)(parentDir, { recursive: true });
88
+ const binDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(parentDir, "vigiles-stub-bin-"));
89
+ writeToolStubs(binDir, stubs);
90
+ return binDir;
91
+ }
92
+ //# sourceMappingURL=tool-stub.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "description": "Compile .spec.ts files to instruction files (CLAUDE.md, AGENTS.md) with linter cross-referencing",
5
5
  "bin": {
6
6
  "vigiles": "dist/cli.js"
@@ -9,31 +9,20 @@
9
9
  "types": "./dist/core/spec.d.ts",
10
10
  "exports": {
11
11
  ".": "./dist/core/spec.js",
12
+ "./spec": "./dist/core/spec.js",
13
+ "./linting": "./dist/linting.js",
14
+ "./testing": "./dist/testing.js",
12
15
  "./unit": "./dist/unit.js",
13
16
  "./integration": "./dist/integration.js",
14
17
  "./e2e": "./dist/e2e.js",
15
- "./linting": "./dist/linting.js",
16
- "./testing": "./dist/testing.js",
17
18
  "./claude-code": "./dist/claude-code.js",
18
19
  "./codex": "./dist/codex.js",
19
20
  "./adapter": "./dist/adapter.js",
20
- "./spec": "./dist/core/spec.js",
21
- "./compile": "./dist/core/compile.js",
22
- "./linters": "./dist/core/linters.js",
23
- "./eval": "./dist/eval.js",
24
- "./harness-test": "./dist/harness-test.js",
25
- "./harness-assert": "./dist/harness-assert.js",
26
- "./run-hook": "./dist/run-hook.js",
27
- "./plugin-loader": "./dist/adapters/claude-code/plugin-loader.js",
28
- "./mcp": "./dist/core/mcp.js",
29
- "./judge": "./dist/judge.js",
30
- "./mock-model": "./dist/mock-model.js",
31
21
  "./vitest": {
32
22
  "types": "./dist/vitest.d.mts",
33
23
  "default": "./dist/vitest.mjs"
34
24
  },
35
- "./jest": "./dist/jest.js",
36
- "./check": "./dist/check.js"
25
+ "./jest": "./dist/jest.js"
37
26
  },
38
27
  "files": [
39
28
  "dist/**/*.js",
@@ -67,10 +56,15 @@
67
56
  "test:vitest": "npm run build && vitest run --project runners",
68
57
  "test:jest": "npm run build && jest",
69
58
  "test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json",
70
- "demo:plugin": "npm run build && node examples/plugin-test-demo.mjs"
59
+ "demo:plugin": "npm run build && node examples/plugin-test-demo.mjs",
60
+ "api:report": "npm run build && node scripts/api-extractor.mjs --local",
61
+ "api:check": "npm run build && node scripts/api-extractor.mjs",
62
+ "docs:api": "npm run api:report && api-documenter markdown -i temp -o api-reference"
71
63
  },
72
64
  "devDependencies": {
73
65
  "@eslint/js": "^10.0.1",
66
+ "@microsoft/api-documenter": "^7.30.7",
67
+ "@microsoft/api-extractor": "^7.58.9",
74
68
  "@types/js-yaml": "^4.0.9",
75
69
  "@types/minimatch": "^5.1.2",
76
70
  "@types/node": "^20.19.39",
@@ -22,7 +22,8 @@ Match what you're testing to the cheapest tier that can answer it:
22
22
  | "Is the hook actually **wired into** the assembled plugin and does it fire in a real session?" | **Deterministic** | free, no API key (real `claude` + scripted mock) | `runHarnessTest` + `scriptModel` |
23
23
  | "Did the injected context (a SessionStart hook, a `/command`) actually **reach the model**?" | **Deterministic** | free, no API key | `runHarnessTest` → `trace.modelRequests` / `assertRequestContains` |
24
24
  | "Does this skill's **description trigger** when it should (recall) **and stay quiet** when it shouldn't (precision)?" | **Eval** | **paid** (real model) | `measureTriggerRate` (+ `irrelevantPrompts`) → `assertTriggerRate({ min, maxFalsePositive })` |
25
- | "Does this harness change **move what the agent does**?" (A/B, signal vs noise) | **Eval** | **paid** (real model) | `runEval` + `assertSignificant` |
25
+ | "Is this exact skill's **output any good**?" — absolute quality, no on/off baseline (the default for testing one skill) | **Eval** | **paid** (real model) | `measure({ checks: [judged(rubric)] })` `assertRates({ min })` |
26
+ | "Does this harness change **move what the agent does**, _relative_ to off?" — A/B lift, regression, signal vs noise | **Eval** | **paid** (real model) | `runEval` (arms) + `assertSignificant` |
26
27
 
27
28
  Most harness questions — block/allow, wired-in, context-landed — never need a
28
29
  model. Only "does the model trigger / behave differently" needs the eval tier.
@@ -30,6 +31,37 @@ model. Only "does the model trigger / behave differently" needs the eval tier.
30
31
  If the unit and deterministic tiers can both answer it, **prefer unit**: it's
31
32
  faster and reaches events the deterministic mock can't drive.
32
33
 
34
+ ## Step 0.5 — Set honest expectations (what's testable, and at what cost)
35
+
36
+ Be explicit with the user about which bucket each surface falls into — never let
37
+ "we'll test it" hide whether that's free, sub-priced, or needs a container. Every
38
+ surface sorts into one of three buckets:
39
+
40
+ - **A — Free & deterministic** (no model, runs in CI on every commit): a hook's
41
+ block/allow decision (`runHook`), a tool-contract / "did NOT call the forbidden
42
+ tool" check, structural facts (`vigiles scan`), and **record-replay** of any tool
43
+ a skill shells out to (record the real result once, replay it via a PATH stub).
44
+ - **B — Model-gated, on your subscription** (real model, **no metered API**): does a
45
+ skill's description **fire** (`measureTriggerRate`, recall + precision) **and**
46
+ does its guidance actually **produce good output** (score it directly:
47
+ `measure({ checks: [judged(rubric)] })` + `assertRates` — the absolute oracle;
48
+ use a `runEval` A/B on-vs-off only when you need the _relative_ lift). This is
49
+ the half a **prose / guidance skill** lives in —
50
+ its worth is behavioral, so only a model can judge it. That is **not** "uncovered"
51
+ and **not** free: it's fully testable on the sub. State it that way.
52
+ - **C — Needs a real service** (a real browser / DB / redis / a11y runtime): vigiles
53
+ **composes with a container** here; it does not fake real semantics. Name the
54
+ service and hand off — don't pretend a cheap tier substitutes for it.
55
+
56
+ So a prose-skill library is roughly **~100% testable (some free, most on your sub),
57
+ ~0% needs-a-container** — not "poorly covered." An accessibility/browser plugin is
58
+ the worst case, with a large bucket C. When you report coverage, give **two
59
+ numbers**: "% testable at all (free + sub)" vs "% that needs a container", and say
60
+ which surfaces are free vs sub-priced. The model-gated half is the **point** of the
61
+ eval pillar (affordable on the sub), not a gap — and testing a prose skill's
62
+ _behavior_ requires a real model for **everyone** (promptfoo, the SDKs, all of it);
63
+ vigiles just does it on your subscription instead of metered API.
64
+
33
65
  ## Step 1 — Ensure vigiles is installed
34
66
 
35
67
  Check whether `vigiles` is a dependency (`package.json`), and install it as a
@@ -103,8 +135,29 @@ assertHookFired(r, "SessionStart");
103
135
  assertRequestContains(r, "expected injected text"); // did it actually land?
104
136
  ```
105
137
 
106
- **Eval (`runEval`)** — A/B the change on vs off across real-model trials, then
107
- gate on significance, not eyeballing:
138
+ **Eval — absolute (`measure` + `judged`)** — testing _one_ skill, the usual case:
139
+ score its output directly against a rubric. No on/off baseline this is the
140
+ "is it any good?" oracle (what promptfoo/DeepEval lead with), and the right
141
+ default when there's nothing to compare against:
142
+
143
+ ```ts
144
+ import { measure, judged, skill, assertRates } from "vigiles/testing";
145
+
146
+ const report = await measure({
147
+ pluginDir: "./",
148
+ task: "…a task the skill should handle…",
149
+ checks: [
150
+ skill("my-plugin:my-skill"), // it fired
151
+ judged("the answer correctly does X and avoids Y"), // …and the output is good
152
+ ],
153
+ trials: 6,
154
+ });
155
+ assertRates(report, { min: 0.8 }); // each check passes ≥ 80% of trials
156
+ ```
157
+
158
+ **Eval — relative (`runEval` + `assertSignificant`)** — when the question is
159
+ _lift over no-skill_ (regression, or proving a change isn't noise): A/B the
160
+ change on vs off and gate on significance, not eyeballing:
108
161
 
109
162
  ```ts
110
163
  import { runEval, assertSignificant } from "vigiles/testing";