vigiles 4.1.0 → 5.0.1
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/README.md +71 -79
- package/dist/adapters/claude-code/agent-runtime.d.ts +1 -1
- package/dist/adapters/claude-code/agent-runtime.js +1 -1
- package/dist/arg-match.d.ts +28 -0
- package/dist/arg-match.js +61 -0
- package/dist/check.d.ts +52 -1
- package/dist/check.js +121 -0
- package/dist/claude-code.d.ts +1 -0
- package/dist/claude-code.js +11 -0
- package/dist/cli.js +86 -14
- package/dist/core/types.d.ts +17 -12
- package/dist/core/validate.js +14 -3
- package/dist/eval-cache.d.ts +49 -1
- package/dist/eval-cache.js +84 -4
- package/dist/eval.d.ts +243 -14
- package/dist/eval.js +540 -28
- package/dist/integration.d.ts +15 -10
- package/dist/integration.js +27 -10
- package/dist/linting.d.ts +4 -3
- package/dist/linting.js +4 -3
- package/dist/setup-plan.js +3 -1
- package/dist/test-coverage.d.ts +11 -12
- package/dist/test-coverage.js +14 -19
- package/dist/testing.d.ts +4 -1
- package/dist/testing.js +24 -2
- package/dist/tool-intercept.d.ts +101 -0
- package/dist/tool-intercept.js +165 -0
- package/dist/tool-stub.d.ts +35 -0
- package/dist/tool-stub.js +92 -0
- package/package.json +36 -19
- package/skills/test-harness/SKILL.md +56 -3
package/dist/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ const compose_js_1 = require("./core/compose.js");
|
|
|
32
32
|
const compile_generator_js_1 = require("./core/compile-generator.js");
|
|
33
33
|
const action_gate_js_1 = require("./action-gate.js");
|
|
34
34
|
const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
|
|
35
|
+
const tool_intercept_js_1 = require("./tool-intercept.js");
|
|
35
36
|
const refs_js_1 = require("./core/refs.js");
|
|
36
37
|
const mcp_js_1 = require("./core/mcp.js");
|
|
37
38
|
const skill_runtime_js_1 = require("./adapters/claude-code/skill-runtime.js");
|
|
@@ -909,8 +910,8 @@ async function runLint(restArgs, flags, config) {
|
|
|
909
910
|
}
|
|
910
911
|
}
|
|
911
912
|
// 7b. Untested-surface check — skills/agents/hooks shipping without a test or
|
|
912
|
-
// eval. Warning by default (a nudge, exit 0); set rules.untested-
|
|
913
|
-
// "error" to gate CI. See src/test-coverage.ts and docs/rules
|
|
913
|
+
// eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
|
|
914
|
+
// hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
|
|
914
915
|
const untested = checkUntestedSurfaces(config, silent);
|
|
915
916
|
// 8. Validate vigiles builder calls inside markdown code blocks. Default
|
|
916
917
|
// is to validate every ref; illustrative blocks opt out via
|
|
@@ -924,6 +925,16 @@ async function runLint(restArgs, flags, config) {
|
|
|
924
925
|
console.log(` ${line}`);
|
|
925
926
|
}
|
|
926
927
|
}
|
|
928
|
+
// Per-line GitHub annotations for each broken doc ref — each carries file+line,
|
|
929
|
+
// so GitHub renders it INLINE on the PR diff (not just in the summary blob).
|
|
930
|
+
// Previously this check reported to stdout only; the inline/spec checks already
|
|
931
|
+
// annotate per-line, so this closes the gap that left doc-ref findings invisible
|
|
932
|
+
// on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
|
|
933
|
+
if (isGitHubActions() && !silent) {
|
|
934
|
+
for (const e of docRefReport.errors) {
|
|
935
|
+
ghAnnotate("error", `${e.kind}("${e.value}") — ${e.message}`, e.file, e.line);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
927
938
|
// 9. Verify code-shaped symbol references live (see src/refs.ts).
|
|
928
939
|
const symbolRefErrors = verifyMarkdownSymbols(files, silent);
|
|
929
940
|
// 10. Verify `vigiles:mcp server#tool` marks against live MCP servers
|
|
@@ -1877,29 +1888,47 @@ function checkIntegrityForFiles(files, severity, silent) {
|
|
|
1877
1888
|
return severity === "error" ? errorCount : 0;
|
|
1878
1889
|
}
|
|
1879
1890
|
/**
|
|
1880
|
-
* Apply the `untested-
|
|
1881
|
-
* eval (see src/test-coverage.ts).
|
|
1882
|
-
*
|
|
1883
|
-
* "error" fails (exit 2)
|
|
1891
|
+
* Apply the per-kind `untested-skill` / `untested-agent` / `untested-hook` rules:
|
|
1892
|
+
* find skills/agents/hooks with no test or eval (see src/test-coverage.ts). Each
|
|
1893
|
+
* kind is gated by its OWN rule severity — a kind set to `false` is not scanned;
|
|
1894
|
+
* "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
|
|
1895
|
+
* untested count plus the severity-gated error count.
|
|
1884
1896
|
*/
|
|
1885
1897
|
function checkUntestedSurfaces(config, silent) {
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1898
|
+
const rules = config?.rules;
|
|
1899
|
+
const skillSev = (0, types_js_1.ruleSeverity)(rules?.["untested-skill"]);
|
|
1900
|
+
const agentSev = (0, types_js_1.ruleSeverity)(rules?.["untested-agent"]);
|
|
1901
|
+
const hookSev = (0, types_js_1.ruleSeverity)(rules?.["untested-hook"]);
|
|
1902
|
+
if (!skillSev && !agentSev && !hookSev)
|
|
1888
1903
|
return { untested: 0, errors: 0 };
|
|
1889
|
-
const
|
|
1890
|
-
|
|
1904
|
+
const sevFor = (kind) => kind === "skill" ? skillSev : kind === "agent" ? agentSev : hookSev;
|
|
1905
|
+
// Test-discovery options (testGlobs/exclude) are shared; merge them from
|
|
1906
|
+
// whichever of the three rules carries them.
|
|
1907
|
+
const opts = {
|
|
1908
|
+
...(0, types_js_1.ruleOptions)(rules?.["untested-skill"]),
|
|
1909
|
+
...(0, types_js_1.ruleOptions)(rules?.["untested-agent"]),
|
|
1910
|
+
...(0, types_js_1.ruleOptions)(rules?.["untested-hook"]),
|
|
1911
|
+
};
|
|
1912
|
+
const report = (0, test_coverage_js_1.findUntestedSurfaces)({
|
|
1913
|
+
basePath: process.cwd(),
|
|
1914
|
+
skills: skillSev !== false,
|
|
1915
|
+
agents: agentSev !== false,
|
|
1916
|
+
hooks: hookSev !== false,
|
|
1917
|
+
testGlobs: opts.testGlobs,
|
|
1918
|
+
exclude: opts.exclude,
|
|
1919
|
+
});
|
|
1891
1920
|
if (!silent) {
|
|
1892
1921
|
console.log("\nUntested surfaces:\n");
|
|
1893
1922
|
for (const line of (0, test_coverage_js_1.formatUntestedReport)(report).split("\n")) {
|
|
1894
1923
|
console.log(` ${line}`);
|
|
1895
1924
|
}
|
|
1896
1925
|
for (const s of report.untested) {
|
|
1897
|
-
ghAnnotate(
|
|
1926
|
+
ghAnnotate(sevFor(s.kind) === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, s.path);
|
|
1898
1927
|
}
|
|
1899
1928
|
}
|
|
1900
1929
|
return {
|
|
1901
1930
|
untested: report.untested.length,
|
|
1902
|
-
errors:
|
|
1931
|
+
errors: report.untested.filter((s) => sevFor(s.kind) === "error").length,
|
|
1903
1932
|
};
|
|
1904
1933
|
}
|
|
1905
1934
|
/**
|
|
@@ -2081,6 +2110,18 @@ function handleRunScripts(kind, args, restArgs) {
|
|
|
2081
2110
|
// Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
|
|
2082
2111
|
const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
|
|
2083
2112
|
const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
|
|
2113
|
+
// `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
|
|
2114
|
+
// a renamed file, or a glob that matched nothing fails LOUD instead of passing
|
|
2115
|
+
// green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
|
|
2116
|
+
const minFlag = args.find((a) => a.startsWith("--min="));
|
|
2117
|
+
const minRequired = minFlag
|
|
2118
|
+
? Math.max(0, Number.parseInt(minFlag.split("=")[1] ?? "", 10) || 0)
|
|
2119
|
+
: 0;
|
|
2120
|
+
if (files.length < minRequired) {
|
|
2121
|
+
console.error(`✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(files.length)} ${kind} file(s) matched — ` +
|
|
2122
|
+
"evals never executed (check the paths/globs, or that the run was reached).");
|
|
2123
|
+
process.exit(1);
|
|
2124
|
+
}
|
|
2084
2125
|
if (files.length === 0) {
|
|
2085
2126
|
console.log(`No ${defaultGlob} files found.`);
|
|
2086
2127
|
return;
|
|
@@ -2091,6 +2132,10 @@ function handleRunScripts(kind, args, restArgs) {
|
|
|
2091
2132
|
if (kind === "test" && !(0, harness_test_js_1.claudeAvailable)()) {
|
|
2092
2133
|
console.log("ℹ `claude` CLI not found — unit-tier tests run; tests that need it report SKIPPED.\n");
|
|
2093
2134
|
}
|
|
2135
|
+
// `--trials=N` (a run knob: cost/precision, doesn't change WHAT is measured) is
|
|
2136
|
+
// forwarded to scripts via env. The MODEL is deliberately NOT a CLI/env knob —
|
|
2137
|
+
// it's part of the measurement definition, so it belongs in the spec
|
|
2138
|
+
// (`model` / `minModel`), version-controlled, not a hidden override.
|
|
2094
2139
|
const trialsFlag = args.find((a) => a.startsWith("--trials="));
|
|
2095
2140
|
const env = {};
|
|
2096
2141
|
if (trialsFlag)
|
|
@@ -2117,7 +2162,7 @@ function printUsage(command) {
|
|
|
2117
2162
|
console.log(" vigiles compile [files...] Compile .spec.ts → .md");
|
|
2118
2163
|
console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
|
|
2119
2164
|
console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
|
|
2120
|
-
console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N)");
|
|
2165
|
+
console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N, --min=N, --no-skip)");
|
|
2121
2166
|
console.log("");
|
|
2122
2167
|
console.log("Examples:");
|
|
2123
2168
|
console.log(" vigiles init Auto-detect project, create specs, wire CI");
|
|
@@ -2231,7 +2276,7 @@ function skillStartCommand(target) {
|
|
|
2231
2276
|
* PreToolUse-hook entrypoint: enforce the active subagent's allowed-tools
|
|
2232
2277
|
* contract. Reads the tool event on stdin, parses the active agent's compiled
|
|
2233
2278
|
* `.md` tool rail, and blocks (exit 2 + reason on stderr) any tool outside it —
|
|
2234
|
-
* the deterministic boundary `tools:` alone can't provide (Claude Code #
|
|
2279
|
+
* the deterministic boundary `tools:` alone can't provide (Claude Code #4740/#21460, SDK #172).
|
|
2235
2280
|
*/
|
|
2236
2281
|
function agentHookCommand() {
|
|
2237
2282
|
let raw = "";
|
|
@@ -2256,6 +2301,30 @@ function agentHookCommand() {
|
|
|
2256
2301
|
process.exit(2);
|
|
2257
2302
|
}
|
|
2258
2303
|
}
|
|
2304
|
+
/**
|
|
2305
|
+
* `vigiles intercept-tool-hook` — the PreToolUse interception hook for the
|
|
2306
|
+
* tool-call spy. Reads the intercept list from `VIGILES_INTERCEPT_TOOLS`, decides
|
|
2307
|
+
* whether the called tool should be intercepted, and if so denies the real
|
|
2308
|
+
* execution (exit 2) with a block message — the call is intercepted (prevented),
|
|
2309
|
+
* NOT executed. Allowing (return) lets the tool run for real. The model still
|
|
2310
|
+
* emits the `tool_use`, so its arguments land in the Trace for `toolWith` /
|
|
2311
|
+
* `notTool` to assert on. See src/tool-intercept.ts.
|
|
2312
|
+
*/
|
|
2313
|
+
function interceptToolHookCommand() {
|
|
2314
|
+
let raw = "";
|
|
2315
|
+
try {
|
|
2316
|
+
raw = (0, node_fs_1.readFileSync)(0, "utf-8");
|
|
2317
|
+
}
|
|
2318
|
+
catch {
|
|
2319
|
+
/* no stdin */
|
|
2320
|
+
}
|
|
2321
|
+
const intercepts = (0, tool_intercept_js_1.parseIntercepts)(process.env[tool_intercept_js_1.INTERCEPT_TOOLS_ENV] ?? "");
|
|
2322
|
+
const decision = (0, tool_intercept_js_1.interceptHookDecision)(raw, intercepts);
|
|
2323
|
+
if (decision.intercept) {
|
|
2324
|
+
console.error(decision.denyReason);
|
|
2325
|
+
process.exit(2);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2259
2328
|
/** Mark a subagent active so the PreToolUse hook enforces its tool contract. */
|
|
2260
2329
|
function agentStartCommand(target) {
|
|
2261
2330
|
if (!target) {
|
|
@@ -2289,6 +2358,9 @@ function handleSkillCommand(command, restArgs) {
|
|
|
2289
2358
|
case "agent-hook":
|
|
2290
2359
|
agentHookCommand();
|
|
2291
2360
|
return true;
|
|
2361
|
+
case "intercept-tool-hook":
|
|
2362
|
+
interceptToolHookCommand();
|
|
2363
|
+
return true;
|
|
2292
2364
|
case "action-hook":
|
|
2293
2365
|
actionHookCommand();
|
|
2294
2366
|
return true;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -69,16 +69,13 @@ export interface OrphansConfig {
|
|
|
69
69
|
*/
|
|
70
70
|
exclude?: readonly string[];
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Shared options for the per-kind untested-* rules (`untested-skill` /
|
|
74
|
+
* `untested-agent` / `untested-hook`). Which kinds are scanned is controlled by
|
|
75
|
+
* each rule's severity (set a rule to `false` to skip that kind), so only the
|
|
76
|
+
* test-discovery knobs live here.
|
|
77
|
+
*/
|
|
73
78
|
export interface TestCoverageConfig {
|
|
74
|
-
/** Scan skills. Default true. */
|
|
75
|
-
skills?: boolean;
|
|
76
|
-
/** Scan subagents. Default true. */
|
|
77
|
-
agents?: boolean;
|
|
78
|
-
/** Scan hook scripts referenced from plugin.json / settings.json. Default true. */
|
|
79
|
-
hooks?: boolean;
|
|
80
|
-
/** Require a test for user-invoked (disable-model-invocation) skills. Default false. */
|
|
81
|
-
includeUserInvokedSkills?: boolean;
|
|
82
79
|
/** Globs of test files that count as coverage. */
|
|
83
80
|
testGlobs?: readonly string[];
|
|
84
81
|
/** Extra ignore globs. */
|
|
@@ -87,14 +84,22 @@ export interface TestCoverageConfig {
|
|
|
87
84
|
export interface RulesConfig {
|
|
88
85
|
/** Require .spec.ts for CLAUDE.md / AGENTS.md. Default: "warn". */
|
|
89
86
|
"require-spec"?: RuleSeverity;
|
|
90
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* @deprecated Skills are legitimately hand-written; use `untested-skill`
|
|
89
|
+
* ("every skill ships with a test/eval") instead. Default: false (off). The
|
|
90
|
+
* check still runs if you set this explicitly.
|
|
91
|
+
*/
|
|
91
92
|
"require-skill-spec"?: RuleSeverity;
|
|
92
93
|
/** Detect hand-edits to compiled markdown via SHA-256 hash. Default: "warn". */
|
|
93
94
|
integrity?: RuleSeverity;
|
|
94
95
|
/** Enforce minimum spec coverage thresholds. Default: false. ESLint-style: ["warn", { scripts: 50 }]. */
|
|
95
96
|
coverage?: RuleWithOptions<CoverageThresholds>;
|
|
96
|
-
/** Flag
|
|
97
|
-
"untested-
|
|
97
|
+
/** Flag a skill (SKILL.md) that ships with no test or eval. Default: "warn". */
|
|
98
|
+
"untested-skill"?: RuleWithOptions<TestCoverageConfig>;
|
|
99
|
+
/** Flag a subagent (agents/*.md) that ships with no test or eval. Default: "warn". */
|
|
100
|
+
"untested-agent"?: RuleWithOptions<TestCoverageConfig>;
|
|
101
|
+
/** Flag a hook script that ships with no test or eval. Default: "warn". */
|
|
102
|
+
"untested-hook"?: RuleWithOptions<TestCoverageConfig>;
|
|
98
103
|
/**
|
|
99
104
|
* Nudge (or block) when an instruction file has code-shaped references that
|
|
100
105
|
* aren't expressed as vigiles marks (so the lint can't verify them), or a
|
package/dist/core/validate.js
CHANGED
|
@@ -32,10 +32,19 @@ const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
|
32
32
|
const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
|
|
33
33
|
const DEFAULT_RULES = {
|
|
34
34
|
"require-spec": "warn",
|
|
35
|
-
|
|
35
|
+
// DEPRECATED — default OFF. Skills are legitimately hand-written (Level 0/1),
|
|
36
|
+
// so requiring a .spec.ts per SKILL.md was the wrong constraint and only added
|
|
37
|
+
// noise (it also nagged about vendored/fixture/bench skills). Use the
|
|
38
|
+
// `untested-*` rules instead — "every skill/agent/hook ships with a test or
|
|
39
|
+
// eval" is the coverage that matters. The implementation is kept: setting
|
|
40
|
+
// `require-skill-spec` explicitly still works for anyone who wants it.
|
|
41
|
+
"require-skill-spec": false,
|
|
36
42
|
integrity: "warn",
|
|
37
43
|
coverage: false,
|
|
38
|
-
|
|
44
|
+
// Per-kind surface-coverage: a skill/agent/hook must ship with a test or eval.
|
|
45
|
+
"untested-skill": "warn",
|
|
46
|
+
"untested-agent": "warn",
|
|
47
|
+
"untested-hook": "warn",
|
|
39
48
|
"unmarked-refs": "warn",
|
|
40
49
|
};
|
|
41
50
|
const DEFAULT_CONFIG = {
|
|
@@ -175,7 +184,9 @@ function validate(content, { ruleMarkers, rules: rulesConfig, filePath, dialect
|
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
|
-
// --- require-skill-spec (SKILL.md)
|
|
187
|
+
// --- require-skill-spec (SKILL.md) — DEPRECATED but still honored when a
|
|
188
|
+
// user sets it explicitly, so reading the deprecated key here is intentional.
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
179
190
|
const skillSeverity = activeRules["require-skill-spec"];
|
|
180
191
|
if (skillSeverity && isSkill && !disableComment.test(content)) {
|
|
181
192
|
const specPath = filePath + ".spec.ts";
|
package/dist/eval-cache.d.ts
CHANGED
|
@@ -11,6 +11,29 @@ export interface CacheKeyInput {
|
|
|
11
11
|
readonly files: Record<string, string>;
|
|
12
12
|
/** The resolved `.claude/settings.json` for the arm (or undefined). */
|
|
13
13
|
readonly settings: unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Per-run env that affects model behaviour (e.g. `VIGILES_INTERCEPT_TOOLS`).
|
|
16
|
+
* Keyed because two intercept configs that share tool names — so produce
|
|
17
|
+
* identical merged `settings` — still differ in their `when`/`denyReason`, which
|
|
18
|
+
* lives only in the env. Omit when there's no model-affecting env.
|
|
19
|
+
*/
|
|
20
|
+
readonly env?: Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* Content digest of a natively-installed plugin dir (`--plugin-dir`), or
|
|
23
|
+
* undefined when there is none. Folded in so editing a skill INSIDE the dir
|
|
24
|
+
* invalidates the entry — a path-only key would false-replay, since the dir's
|
|
25
|
+
* files are NOT in `files` (that holds only the materialized fixture / `plugin`
|
|
26
|
+
* arm, not a native install). See {@link hashDir}.
|
|
27
|
+
*/
|
|
28
|
+
readonly pluginDirHash?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The harness BINARY version (e.g. `claude --version`). The harness evolves
|
|
31
|
+
* fast — a CLI upgrade changes the system prompt + tool definitions, which steer
|
|
32
|
+
* behaviour as much as the model does — so a cached result must invalidate when
|
|
33
|
+
* the binary changes, or a replay silently serves a result from a different
|
|
34
|
+
* harness. Resolved once per run; omit when unknown (then it doesn't partition).
|
|
35
|
+
*/
|
|
36
|
+
readonly harnessVersion?: string;
|
|
14
37
|
/** Which trial this is — distinct trials are distinct samples, cached apart. */
|
|
15
38
|
readonly trialIndex: number;
|
|
16
39
|
}
|
|
@@ -20,9 +43,23 @@ export interface CacheRecord {
|
|
|
20
43
|
/** Text files present in the cwd after the run (relative path → contents). */
|
|
21
44
|
readonly files: Record<string, string>;
|
|
22
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Cache record-format version, SALTED into every key (Jest `CACHE_VERSION` /
|
|
48
|
+
* webpack `cache.version` pattern). Bump when the `CacheRecord` shape — or how a
|
|
49
|
+
* record is produced in a way the key can't otherwise see — changes, so old
|
|
50
|
+
* entries become *unreachable* rather than deserializing into a stale shape (no
|
|
51
|
+
* brittle read-time version gate needed). A major bump means orphaned files on
|
|
52
|
+
* disk; reclaim them by deleting the cache dir.
|
|
53
|
+
*/
|
|
54
|
+
export declare const CACHE_FORMAT_VERSION = 2;
|
|
23
55
|
/** Deterministic content hash of the key inputs (order-independent). */
|
|
24
56
|
export declare function cacheKey(input: CacheKeyInput): SHA256Hash;
|
|
25
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Read a cached record by key. A MISS (no file) returns `null` — normal, the run
|
|
59
|
+
* proceeds. A CORRUPT record (file present but not valid JSON) **throws** instead
|
|
60
|
+
* of silently degrading to a re-run: a broken cassette is a real failure the CI
|
|
61
|
+
* gate must surface, not mask. The message tells you how to recover.
|
|
62
|
+
*/
|
|
26
63
|
export declare function readCache(dir: string, key: SHA256Hash): CacheRecord | null;
|
|
27
64
|
/** Write a cached record by key (creating the cache dir as needed). */
|
|
28
65
|
export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRecord): void;
|
|
@@ -30,4 +67,15 @@ export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRe
|
|
|
30
67
|
export declare function snapshotDir(cwd: string): Record<string, string>;
|
|
31
68
|
/** Restore a snapshot into `cwd`, recreating directories as needed. */
|
|
32
69
|
export declare function restoreDir(cwd: string, files: Record<string, string>): void;
|
|
70
|
+
/**
|
|
71
|
+
* Content digest of a directory: a lexicographically-sorted list of
|
|
72
|
+
* `relativePath:contentHash` for every file, hashed to one value. Editing,
|
|
73
|
+
* adding, removing, or moving any file changes the digest. It hashes file
|
|
74
|
+
* CONTENT (not mtime — CI checkouts reset mtimes, the classic stale-cache
|
|
75
|
+
* anti-pattern) and includes the relative path (so a rename invalidates and two
|
|
76
|
+
* files can't swap contents undetected). A flat sorted list, NOT a Merkle tree —
|
|
77
|
+
* sufficient at plugin-dir scale; the tree's incremental-recompute payoff isn't
|
|
78
|
+
* worth the complexity here (cf. Bazel/Turborepo hash content per file).
|
|
79
|
+
*/
|
|
80
|
+
export declare function hashDir(dir: string): SHA256Hash;
|
|
33
81
|
//# sourceMappingURL=eval-cache.d.ts.map
|
package/dist/eval-cache.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CACHE_FORMAT_VERSION = void 0;
|
|
3
4
|
exports.cacheKey = cacheKey;
|
|
4
5
|
exports.readCache = readCache;
|
|
5
6
|
exports.writeCache = writeCache;
|
|
6
7
|
exports.snapshotDir = snapshotDir;
|
|
7
8
|
exports.restoreDir = restoreDir;
|
|
9
|
+
exports.hashDir = hashDir;
|
|
8
10
|
/**
|
|
9
11
|
* vigiles — record/replay cache for the eval tier.
|
|
10
12
|
*
|
|
@@ -43,20 +45,70 @@ function canonical(value) {
|
|
|
43
45
|
}
|
|
44
46
|
return value;
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Cache record-format version, SALTED into every key (Jest `CACHE_VERSION` /
|
|
50
|
+
* webpack `cache.version` pattern). Bump when the `CacheRecord` shape — or how a
|
|
51
|
+
* record is produced in a way the key can't otherwise see — changes, so old
|
|
52
|
+
* entries become *unreachable* rather than deserializing into a stale shape (no
|
|
53
|
+
* brittle read-time version gate needed). A major bump means orphaned files on
|
|
54
|
+
* disk; reclaim them by deleting the cache dir.
|
|
55
|
+
*/
|
|
56
|
+
exports.CACHE_FORMAT_VERSION = 2;
|
|
57
|
+
/**
|
|
58
|
+
* Per-run env keys that are PURE NOISE for the cache key — a fresh random path
|
|
59
|
+
* every run, never model-affecting. The opt-in ephemeral run env
|
|
60
|
+
* ({@link ephemeralRunEnv} in `eval.ts`) points `HOME`/`TMPDIR` at a throwaway
|
|
61
|
+
* dir generated per trial, so folding them into the key would make every
|
|
62
|
+
* ephemeral run unique → the cache could NEVER hit. We drop them here so the
|
|
63
|
+
* exclusion holds however the env was assembled. A non-ephemeral run normally
|
|
64
|
+
* carries neither in its per-run `env` (it's an overlay over `process.env`, not
|
|
65
|
+
* a complete env), so dropping them is a no-op there.
|
|
66
|
+
*/
|
|
67
|
+
const CACHE_KEY_ENV_EXCLUDE = ["HOME", "TMPDIR"];
|
|
68
|
+
/** Strip the per-run-noise env keys ({@link CACHE_KEY_ENV_EXCLUDE}) from a keyed
|
|
69
|
+
* env, returning `undefined` when nothing model-affecting remains (so the key is
|
|
70
|
+
* byte-identical to a run that had no env at all). */
|
|
71
|
+
function keyedEnv(env) {
|
|
72
|
+
if (env === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [k, v] of Object.entries(env)) {
|
|
76
|
+
if (CACHE_KEY_ENV_EXCLUDE.includes(k))
|
|
77
|
+
continue;
|
|
78
|
+
out[k] = v;
|
|
79
|
+
}
|
|
80
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
81
|
+
}
|
|
46
82
|
/** Deterministic content hash of the key inputs (order-independent). */
|
|
47
83
|
function cacheKey(input) {
|
|
48
|
-
|
|
84
|
+
const normalized = {
|
|
85
|
+
...input,
|
|
86
|
+
// The tool list is logically a SET, so ["Read","Bash"] and ["Bash","Read"]
|
|
87
|
+
// must hash the same — sort it to avoid phantom-distinct keys. (canonical()
|
|
88
|
+
// already sorts object keys; it deliberately keeps other array order.)
|
|
89
|
+
tools: [...input.tools].sort(),
|
|
90
|
+
// Drop the throwaway ephemeral HOME/TMPDIR — per-run noise, not model input.
|
|
91
|
+
env: keyedEnv(input.env),
|
|
92
|
+
cacheFormatVersion: exports.CACHE_FORMAT_VERSION,
|
|
93
|
+
};
|
|
94
|
+
return (0, hash_js_1.sha256short)(JSON.stringify(canonical(normalized)));
|
|
49
95
|
}
|
|
50
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Read a cached record by key. A MISS (no file) returns `null` — normal, the run
|
|
98
|
+
* proceeds. A CORRUPT record (file present but not valid JSON) **throws** instead
|
|
99
|
+
* of silently degrading to a re-run: a broken cassette is a real failure the CI
|
|
100
|
+
* gate must surface, not mask. The message tells you how to recover.
|
|
101
|
+
*/
|
|
51
102
|
function readCache(dir, key) {
|
|
52
103
|
const path = (0, node_path_1.join)(dir, `${key}.json`);
|
|
53
104
|
if (!(0, node_fs_1.existsSync)(path))
|
|
54
105
|
return null;
|
|
106
|
+
const raw = (0, node_fs_1.readFileSync)(path, "utf-8");
|
|
55
107
|
try {
|
|
56
|
-
return JSON.parse(
|
|
108
|
+
return JSON.parse(raw);
|
|
57
109
|
}
|
|
58
110
|
catch {
|
|
59
|
-
|
|
111
|
+
throw new Error(`eval cache: corrupt record ${path} (invalid JSON) — delete it or clear the cache dir`);
|
|
60
112
|
}
|
|
61
113
|
}
|
|
62
114
|
/** Write a cached record by key (creating the cache dir as needed). */
|
|
@@ -91,4 +143,32 @@ function restoreDir(cwd, files) {
|
|
|
91
143
|
(0, node_fs_1.writeFileSync)(full, content);
|
|
92
144
|
}
|
|
93
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Content digest of a directory: a lexicographically-sorted list of
|
|
148
|
+
* `relativePath:contentHash` for every file, hashed to one value. Editing,
|
|
149
|
+
* adding, removing, or moving any file changes the digest. It hashes file
|
|
150
|
+
* CONTENT (not mtime — CI checkouts reset mtimes, the classic stale-cache
|
|
151
|
+
* anti-pattern) and includes the relative path (so a rename invalidates and two
|
|
152
|
+
* files can't swap contents undetected). A flat sorted list, NOT a Merkle tree —
|
|
153
|
+
* sufficient at plugin-dir scale; the tree's incremental-recompute payoff isn't
|
|
154
|
+
* worth the complexity here (cf. Bazel/Turborepo hash content per file).
|
|
155
|
+
*/
|
|
156
|
+
function hashDir(dir) {
|
|
157
|
+
const root = (0, node_path_1.resolve)(dir);
|
|
158
|
+
const parts = [];
|
|
159
|
+
const walk = (d) => {
|
|
160
|
+
for (const entry of (0, node_fs_1.readdirSync)(d).sort()) {
|
|
161
|
+
if (SKIP_DIRS.has(entry))
|
|
162
|
+
continue;
|
|
163
|
+
const full = (0, node_path_1.join)(d, entry);
|
|
164
|
+
const st = (0, node_fs_1.statSync)(full);
|
|
165
|
+
if (st.isDirectory())
|
|
166
|
+
walk(full);
|
|
167
|
+
else if (st.isFile())
|
|
168
|
+
parts.push(`${(0, node_path_1.relative)(root, full)}:${(0, hash_js_1.sha256short)((0, node_fs_1.readFileSync)(full))}`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
walk(root);
|
|
172
|
+
return (0, hash_js_1.sha256short)(parts.join("\n"));
|
|
173
|
+
}
|
|
94
174
|
//# sourceMappingURL=eval-cache.js.map
|