vigiles 16.1.2 → 17.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/adapter-conformance.js +17 -0
- package/dist/adapters/claude-code/dialect.d.ts +19 -13
- package/dist/adapters/claude-code/dialect.js +40 -62
- package/dist/adapters/claude-code/run-scripts.d.ts +19 -3
- package/dist/adapters/claude-code/run-scripts.js +17 -7
- package/dist/adapters/claude-code/vocabulary.d.ts +133 -0
- package/dist/adapters/claude-code/vocabulary.js +208 -0
- package/dist/adapters/codex/eval.js +2 -0
- package/dist/audit-score.js +1 -1
- package/dist/cli.js +7 -2
- package/dist/core/compile.js +6 -1
- package/dist/core/dialect.d.ts +27 -0
- package/dist/core/eval-load-phase.d.ts +78 -0
- package/dist/core/eval-load-phase.js +104 -0
- package/dist/core/hook-events.d.ts +32 -15
- package/dist/core/hook-events.js +23 -29
- package/dist/core/hook-program.js +12 -4
- package/dist/core/rule-meta.js +2 -2
- package/dist/core/tool-contract.d.ts +69 -30
- package/dist/core/tool-contract.js +59 -57
- package/dist/core/vocabulary-consistency.d.ts +35 -0
- package/dist/core/vocabulary-consistency.js +81 -0
- package/dist/core/vocabulary.d.ts +138 -0
- package/dist/core/vocabulary.js +262 -0
- package/dist/eval-define.d.ts +166 -0
- package/dist/eval-define.js +182 -0
- package/dist/eval-entry.d.ts +41 -0
- package/dist/eval-entry.js +203 -0
- package/dist/eval.js +2 -0
- package/dist/judge.js +2 -0
- package/dist/scan-behavioral.js +2 -0
- package/dist/scan-core.d.ts +8 -1
- package/dist/scan-core.js +64 -6
- package/dist/scan-files.js +4 -1
- package/dist/scan.d.ts +45 -0
- package/dist/scan.js +13 -1
- package/dist/test-coverage.d.ts +45 -0
- package/dist/test-coverage.js +91 -3
- package/dist/test.d.ts +2 -0
- package/dist/test.js +8 -1
- package/package.json +1 -1
- package/skills/test-harness/SKILL.md +31 -22
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classify = classify;
|
|
4
|
+
exports.suggest = suggest;
|
|
5
|
+
exports.termIssue = termIssue;
|
|
6
|
+
exports.scoredIssues = scoredIssues;
|
|
7
|
+
exports.advisoryIssues = advisoryIssues;
|
|
8
|
+
exports.authoringIssues = authoringIssues;
|
|
9
|
+
exports.vocabularyFromLists = vocabularyFromLists;
|
|
10
|
+
/**
|
|
11
|
+
* HarnessVocabulary — the platform's own words, with a STATUS per word.
|
|
12
|
+
*
|
|
13
|
+
* The three vocabulary checks (`hook-events`, `subagent-tool-contract`, and the
|
|
14
|
+
* `disallowedTools:` mirror) used to model a harness's vocabulary as two
|
|
15
|
+
* hand-kept arrays — "these names are fine", "these names are dead" — and then
|
|
16
|
+
* decide what to SAY about a name in neither by its EDIT DISTANCE to the first
|
|
17
|
+
* array. That is the defect this module exists to remove, and it failed in both
|
|
18
|
+
* directions at once (measured against the vendor docs, 2026-08-17):
|
|
19
|
+
*
|
|
20
|
+
* - FALSE ACCUSATION. `Setup` is a documented Claude Code hook event. It was
|
|
21
|
+
* reported as "a hook here never fires … Did you mean Stop?" — and the
|
|
22
|
+
* suggested repair rewires a one-shot setup hook onto every turn's Stop.
|
|
23
|
+
* It was caught for one reason only: `setup` happens to sit at edit distance
|
|
24
|
+
* 2 from `stop`.
|
|
25
|
+
* - SILENCE. Of the 31 events the vendor documents, the old catalog held 9.
|
|
26
|
+
* The other 22 were not "accepted" — they were unrecognised and >2 away from
|
|
27
|
+
* any known name, so nothing was said. `SubagentStart` and `PostCompact` sat
|
|
28
|
+
* at distance 3: one character from becoming the next `Setup`.
|
|
29
|
+
*
|
|
30
|
+
* So which of the two a valid name got — an accusation or silence — was decided
|
|
31
|
+
* by spelling luck. Neither is a verdict, and a tool whose pitch is precision
|
|
32
|
+
* cannot ship either as its answer to "I don't know this word".
|
|
33
|
+
*
|
|
34
|
+
* The fix is not a longer array. It is to stop asking "is this string in my
|
|
35
|
+
* list?" (a boolean, which has no room for "I don't know") and start asking
|
|
36
|
+
* "what does my catalog SAY about this string?" — a {@link TermVerdict}, which
|
|
37
|
+
* has four answers because the vendor's own documentation distinguishes four:
|
|
38
|
+
*
|
|
39
|
+
* - `available` — the platform provides it.
|
|
40
|
+
* - `withheld` — the platform removes it, unconditionally. A real defect.
|
|
41
|
+
* - `conditional` — the platform removes it only under a STATED condition
|
|
42
|
+
* (`Agent` at the depth limit; `ExitPlanMode` unless
|
|
43
|
+
* `permissionMode: plan`; every non-background built-in when
|
|
44
|
+
* the subagent runs in the background). vigiles cannot see
|
|
45
|
+
* the condition, so it must not assert — it reports the
|
|
46
|
+
* condition and stops.
|
|
47
|
+
* - `unrecognised` — not in the catalog. This is a statement about VIGILES,
|
|
48
|
+
* not about the repo being audited, and the message says so.
|
|
49
|
+
*
|
|
50
|
+
* THE THIRD OPTION. `unrecognised` is why this module exists. Silence reads as
|
|
51
|
+
* approval and hides our own staleness; an error blames the user for our gap.
|
|
52
|
+
* The honest third answer is an ADVISORY that names vigiles as the possibly-stale
|
|
53
|
+
* party and prints the capture the catalog came from. It is surfaced and it is
|
|
54
|
+
* never scored, so a name newer than our capture can never cost anyone a grade.
|
|
55
|
+
*
|
|
56
|
+
* WHY THE DISTANCE IS NOT ON THE VERDICT. {@link TermVerdict}'s `unrecognised`
|
|
57
|
+
* branch carries no near-match. A caller therefore CANNOT write
|
|
58
|
+
* `if (nearest) report()` — the shape that produced both bugs — because at the
|
|
59
|
+
* point where the report/skip decision is made, no distance is in scope.
|
|
60
|
+
* {@link suggest} exists solely to decorate a message that is already being
|
|
61
|
+
* emitted, and is called from the message builder, not from a branch.
|
|
62
|
+
*
|
|
63
|
+
* FAILING LOUDLY. `classify` is total: every name returns one of four cases, so
|
|
64
|
+
* a `switch` that forgets one is a `tsc` error via the `never` exhaustiveness
|
|
65
|
+
* check in {@link termIssue}. There is no `null`/`undefined` return to ignore.
|
|
66
|
+
*
|
|
67
|
+
* GOING STALE. It will. `capturedFrom` records the exact vendor artifact and
|
|
68
|
+
* version each catalog was read from, and every name we do not hold prints it.
|
|
69
|
+
* That is the difference from an ordinary allowlist: this one reports its own
|
|
70
|
+
* age at the moment it is out of date, rather than silently approving the word
|
|
71
|
+
* it has never heard of.
|
|
72
|
+
*/
|
|
73
|
+
const edit_distance_js_1 = require("./edit-distance.js");
|
|
74
|
+
/** Look the name up. Total: always one of the four verdicts, never null. */
|
|
75
|
+
function classify(vocab, name) {
|
|
76
|
+
const term = vocab.terms.find((t) => t.name === name);
|
|
77
|
+
if (term === undefined)
|
|
78
|
+
return { kind: "unrecognised", name };
|
|
79
|
+
switch (term.status) {
|
|
80
|
+
case "available":
|
|
81
|
+
return { kind: "available", term };
|
|
82
|
+
case "withheld":
|
|
83
|
+
return { kind: "withheld", term };
|
|
84
|
+
case "conditional":
|
|
85
|
+
return { kind: "conditional", term };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Closest catalog name within edit distance 2, else null — a MESSAGE decoration
|
|
90
|
+
* only. Never call this to decide whether to report something; the verdict has
|
|
91
|
+
* already decided that. The ≤2 bound stays tight for the reason it always was:
|
|
92
|
+
* a loose bound mis-suggests (`TaskGet → Task?` is a different real tool, not a
|
|
93
|
+
* typo). Only `available` terms are offered — suggesting a name the platform
|
|
94
|
+
* withholds would trade one dead reference for another.
|
|
95
|
+
*/
|
|
96
|
+
function suggest(vocab, name) {
|
|
97
|
+
const near = nearest(vocab, name);
|
|
98
|
+
return near !== null && near.distance <= SUGGEST_MAX ? near.name : null;
|
|
99
|
+
}
|
|
100
|
+
/** How far a hint may reach. Beyond this a "did you mean" mis-suggests. */
|
|
101
|
+
const SUGGEST_MAX = 2;
|
|
102
|
+
/**
|
|
103
|
+
* How close an unrecognised name must be to a real one before vigiles will call
|
|
104
|
+
* it a TYPO and put it in the grade, rather than treating it as a name it simply
|
|
105
|
+
* does not know.
|
|
106
|
+
*
|
|
107
|
+
* MEASURED, not assumed — and the measurement is the whole reason the old check
|
|
108
|
+
* failed. Over every pair of distinct names in the two shipped Claude Code
|
|
109
|
+
* vocabularies (465 hook-event pairs, 741 subagent-tool pairs):
|
|
110
|
+
*
|
|
111
|
+
* distance 1 : 0 pairs of the 1,206
|
|
112
|
+
* distance 2 : exactly 1 pair in each — `Setup`/`Stop` and `Bash`/`Task`
|
|
113
|
+
*
|
|
114
|
+
* So the vendor does not ship two real names one edit apart, which makes
|
|
115
|
+
* distance 1 strong evidence of a mistyped name. Distance 2, on the other hand,
|
|
116
|
+
* is exactly where two genuinely different real names DO collide — and the one
|
|
117
|
+
* event-pair that collides there is the bug itself: the old code used ≤2 as its
|
|
118
|
+
* threshold and therefore accused `Setup`, a real event, of being a typo of
|
|
119
|
+
* `Stop`. The old bound was not merely too loose, it was set precisely at the
|
|
120
|
+
* width of the collision.
|
|
121
|
+
*
|
|
122
|
+
* Hence: distance 1 is scored, distance 2 still earns a did-you-mean but stays
|
|
123
|
+
* advisory. RESIDUAL RISK, stated plainly: if the vendor ever ships a name one
|
|
124
|
+
* edit from an existing one, it will be scored as a typo until the catalog is
|
|
125
|
+
* updated. Zero of 1,206 current pairs are that close, and `dialect-drift` plus
|
|
126
|
+
* the conformance invariants are what surface the catalog falling behind.
|
|
127
|
+
*/
|
|
128
|
+
const TYPO_MAX = 1;
|
|
129
|
+
/** Closest catalog name and its distance, or null when the vocabulary is empty. */
|
|
130
|
+
function nearest(vocab, name) {
|
|
131
|
+
let best = null;
|
|
132
|
+
let bestDistance = Infinity;
|
|
133
|
+
for (const t of vocab.terms) {
|
|
134
|
+
if (t.status === "withheld")
|
|
135
|
+
continue;
|
|
136
|
+
const d = (0, edit_distance_js_1.editDistance)(name.toLowerCase(), t.name.toLowerCase());
|
|
137
|
+
if (d < bestDistance) {
|
|
138
|
+
bestDistance = d;
|
|
139
|
+
best = t.name;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return best === null ? null : { name: best, distance: bestDistance };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Turn a verdict into the finding to report, or null when there is nothing to
|
|
146
|
+
* say. The severity is decided HERE, once, from the verdict — callers never
|
|
147
|
+
* invent their own policy, which is what let `scan` and `lint` drift apart from
|
|
148
|
+
* `compileAgent` before.
|
|
149
|
+
*
|
|
150
|
+
* `noun` names the thing in the message ("hook event" / "tool"); `subject`
|
|
151
|
+
* describes what listing it does, e.g. "a hook here never fires".
|
|
152
|
+
*/
|
|
153
|
+
function termIssue(vocab, verdict, noun, deadConsequence) {
|
|
154
|
+
switch (verdict.kind) {
|
|
155
|
+
case "available":
|
|
156
|
+
return null;
|
|
157
|
+
case "conditional": {
|
|
158
|
+
const alias = verdict.term.aliasOf !== undefined
|
|
159
|
+
? ` "${verdict.term.name}" is a still-supported deprecated alias of "${verdict.term.aliasOf}".`
|
|
160
|
+
: "";
|
|
161
|
+
return {
|
|
162
|
+
verdict: "conditional",
|
|
163
|
+
severity: "advisory",
|
|
164
|
+
suggestion: null,
|
|
165
|
+
condition: verdict.term.condition,
|
|
166
|
+
message: `${noun} "${verdict.term.name}" is available to a subagent, but the platform ` +
|
|
167
|
+
`removes it ${verdict.term.condition ?? "under a documented condition"}. ` +
|
|
168
|
+
`vigiles cannot see that condition from the file, so this is a note, not a defect.${alias}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
case "withheld":
|
|
172
|
+
return {
|
|
173
|
+
verdict: "withheld",
|
|
174
|
+
severity: "scored",
|
|
175
|
+
suggestion: null,
|
|
176
|
+
message: `${noun} "${verdict.term.name}" is never available to a subagent — remove it from the tools list.`,
|
|
177
|
+
};
|
|
178
|
+
case "unrecognised": {
|
|
179
|
+
const near = nearest(vocab, verdict.name);
|
|
180
|
+
const suggestion = near !== null && near.distance <= SUGGEST_MAX ? near.name : null;
|
|
181
|
+
// One edit from a real name, and no two real names are that close (see
|
|
182
|
+
// TYPO_MAX): this is a mistyped name, and it is dead in the repo now.
|
|
183
|
+
if (near !== null && near.distance <= TYPO_MAX)
|
|
184
|
+
return {
|
|
185
|
+
verdict: "unrecognised",
|
|
186
|
+
severity: "scored",
|
|
187
|
+
suggestion,
|
|
188
|
+
message: `${noun} "${verdict.name}" matches no known name — ${deadConsequence}. ` +
|
|
189
|
+
`Did you mean "${near.name}"?`,
|
|
190
|
+
};
|
|
191
|
+
const hint = suggestion !== null ? ` Did you mean "${suggestion}"?` : "";
|
|
192
|
+
return {
|
|
193
|
+
verdict: "unrecognised",
|
|
194
|
+
severity: "advisory",
|
|
195
|
+
suggestion,
|
|
196
|
+
message: `${noun} "${verdict.name}" is not in vigiles's ${vocab.kind} catalog ` +
|
|
197
|
+
`(captured from ${vocab.capturedFrom}). If it is newer than that capture, or ` +
|
|
198
|
+
`custom to your harness, vigiles is out of date — not your config. ` +
|
|
199
|
+
`If it is a typo, ${deadConsequence}.${hint}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
default: {
|
|
203
|
+
// Exhaustiveness: a new verdict kind with no branch is a tsc error here,
|
|
204
|
+
// so a future status cannot be silently dropped on the floor.
|
|
205
|
+
const never = verdict;
|
|
206
|
+
return never;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The issues that count toward a grade. Replaces the per-check
|
|
212
|
+
* `confidentToolIssues` / `confidentHookEventIssues` helpers, which asked "is
|
|
213
|
+
* there a near match?" — a question about spelling, answered by a helper each
|
|
214
|
+
* caller had to remember to apply and which `compileAgent` did not, so `scan`,
|
|
215
|
+
* `lint` and authoring could disagree about which issues were real. Severity now
|
|
216
|
+
* travels ON the issue, decided once in {@link termIssue}, so the split is the
|
|
217
|
+
* same wherever it is taken.
|
|
218
|
+
*/
|
|
219
|
+
function scoredIssues(issues) {
|
|
220
|
+
return issues.filter((i) => i.severity === "scored");
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The issues that are surfaced but never scored — `conditional` tools and any
|
|
224
|
+
* name newer than our capture. Kept out of the grade on purpose: vigiles's own
|
|
225
|
+
* staleness must not cost someone a letter.
|
|
226
|
+
*/
|
|
227
|
+
function advisoryIssues(issues) {
|
|
228
|
+
return issues.filter((i) => i.severity === "advisory");
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The issues an AUTHORING path treats as errors — everything except
|
|
232
|
+
* `conditional`. Authoring is a CLOSED world: you are writing this spec now,
|
|
233
|
+
* against the vigiles you have, so an unrecognised name is a typo worth stopping
|
|
234
|
+
* for. Auditing is an OPEN world: someone else wrote the file, possibly against
|
|
235
|
+
* a newer platform, so there the same verdict is only an advisory.
|
|
236
|
+
*
|
|
237
|
+
* `conditional` is an error in NEITHER. The tool is real and declaring it is
|
|
238
|
+
* correct; erroring on it is exactly what told delegating subagents to drop
|
|
239
|
+
* `Agent`, and what made `tools: Agent, Read, Bash` — a worked example in the
|
|
240
|
+
* vendor's own docs — fail to compile.
|
|
241
|
+
*/
|
|
242
|
+
function authoringIssues(issues) {
|
|
243
|
+
return issues.filter((i) => i.verdict !== "conditional");
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Build a vocabulary from a dialect that predates this module — `available` from
|
|
247
|
+
* its built-in catalog, `withheld` from its never-available list. A dialect on
|
|
248
|
+
* the legacy shape keeps working and its unknowns become `unrecognised`
|
|
249
|
+
* ADVISORIES rather than silence, which is the honest reading: a catalog with no
|
|
250
|
+
* recorded capture cannot claim a name is invalid.
|
|
251
|
+
*/
|
|
252
|
+
function vocabularyFromLists(kind, capturedFrom, available, withheld = []) {
|
|
253
|
+
return {
|
|
254
|
+
kind,
|
|
255
|
+
capturedFrom,
|
|
256
|
+
terms: [
|
|
257
|
+
...available.map((name) => ({ name, status: "available" })),
|
|
258
|
+
...withheld.map((name) => ({ name, status: "withheld" })),
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=vocabulary.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ArmsCheckReport, EvalDriver, ArmsMeasureSpec, CheckReport, EvalReport, EvalSpec, MeasureSpec, Metrics, TriggerRateReport, TriggerRateSpec } from "./eval.js";
|
|
2
|
+
import type { SelectionMatrixOptions, SelectionReport } from "./scan-behavioral.js";
|
|
3
|
+
/**
|
|
4
|
+
* Brand marking a value as built by {@link defineEval}. A registered symbol, so
|
|
5
|
+
* a descriptor still reads as one across two copies of the package on disk —
|
|
6
|
+
* the shape a monorepo produces routinely.
|
|
7
|
+
*/
|
|
8
|
+
export declare const EVAL_DEFINITION: unique symbol;
|
|
9
|
+
/** `measureSelectionMatrix`'s two arguments as one declarable object. */
|
|
10
|
+
export interface SelectionMatrixSpec extends SelectionMatrixOptions {
|
|
11
|
+
/** The plugin root whose skills are measured against each other. */
|
|
12
|
+
readonly pluginDir: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The five measurements an eval file may declare — the runners that already
|
|
16
|
+
* exist, keyed by their own names, so migrating is renaming a call to a key.
|
|
17
|
+
* EXACTLY ONE must be present: zero is a file that declares nothing, and two is
|
|
18
|
+
* a file whose author expected both to run.
|
|
19
|
+
*/
|
|
20
|
+
export interface EvalMeasurements {
|
|
21
|
+
/** A/B across arms with derived metrics — `paid_runEval`. */
|
|
22
|
+
readonly runEval?: EvalSpec<Metrics>;
|
|
23
|
+
/** Checks scored over N trials of one task — `paid_measure`. */
|
|
24
|
+
readonly measure?: MeasureSpec;
|
|
25
|
+
/** The same checks per arm — `paid_measureArms`. */
|
|
26
|
+
readonly measureArms?: ArmsMeasureSpec;
|
|
27
|
+
/** Does a skill's description actually fire — `paid_measureTriggerRate`. */
|
|
28
|
+
readonly measureTriggerRate?: TriggerRateSpec;
|
|
29
|
+
/**
|
|
30
|
+
* The N×N skill-selection collision matrix — `measureSelectionMatrix` from
|
|
31
|
+
* `vigiles/claude-code`. Declared here because a description is a description
|
|
32
|
+
* whatever harness answers it; only `eval-entry.ts` resolves it to a runner.
|
|
33
|
+
*/
|
|
34
|
+
readonly measureSelectionMatrix?: SelectionMatrixSpec;
|
|
35
|
+
}
|
|
36
|
+
/** The report type produced by each declared measurement. */
|
|
37
|
+
export interface EvalReports {
|
|
38
|
+
readonly runEval: EvalReport;
|
|
39
|
+
readonly measure: CheckReport;
|
|
40
|
+
readonly measureArms: ArmsCheckReport;
|
|
41
|
+
readonly measureTriggerRate: TriggerRateReport;
|
|
42
|
+
readonly measureSelectionMatrix: SelectionReport;
|
|
43
|
+
}
|
|
44
|
+
/** The measurement names, as a type. */
|
|
45
|
+
export type EvalKind = keyof EvalMeasurements;
|
|
46
|
+
/** Everything an eval file declares beyond the measurement itself. */
|
|
47
|
+
export interface EvalHooks<K extends EvalKind> {
|
|
48
|
+
/**
|
|
49
|
+
* A lazy precondition. Return a reason to SKIP (exit 77, loud — never a silent
|
|
50
|
+
* green); return nothing to run. Lazy is the point: a probe at the top of the
|
|
51
|
+
* file would be work at import time, which is the shape being removed.
|
|
52
|
+
*/
|
|
53
|
+
readonly skipIf?: () => string | false | undefined | null;
|
|
54
|
+
/**
|
|
55
|
+
* What the report has to show. Throw to fail. The runner has already printed
|
|
56
|
+
* the standard report by the time this is called, so this is for the derived
|
|
57
|
+
* reads — gates, per-arm significance, a verdict line.
|
|
58
|
+
*/
|
|
59
|
+
readonly assert?: (report: EvalReports[K]) => void | Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Drive the measurement with this harness instead of the real `claude` CLI —
|
|
62
|
+
* the declarative home for the second argument of
|
|
63
|
+
* `measureTriggerRate(spec, { evalDriver })`, e.g. `codexEvalDriver` from
|
|
64
|
+
* `vigiles/codex`, or a fake runner in a test.
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ `measureTriggerRate` ONLY. That is not a limitation of this shape but of
|
|
67
|
+
* the product as it stands: `measureTriggerRate` is the one measurement with a
|
|
68
|
+
* public driver seam, and `runEval(spec)` always drives Claude Code (see
|
|
69
|
+
* docs/harnesses.md, footnote 2). Declaring it with any other measurement is a
|
|
70
|
+
* loud error rather than a field that silently does nothing.
|
|
71
|
+
*/
|
|
72
|
+
readonly evalDriver?: EvalDriver;
|
|
73
|
+
}
|
|
74
|
+
/** One measurement + its hooks: what an eval file default-exports. */
|
|
75
|
+
export type EvalDefinitionInput<K extends EvalKind> = Pick<EvalMeasurements, K> & EvalHooks<K>;
|
|
76
|
+
/** The branded value {@link defineEval} returns. */
|
|
77
|
+
export type EvalDefinition<K extends EvalKind = EvalKind> = EvalDefinitionInput<K> & {
|
|
78
|
+
readonly [EVAL_DEFINITION]: true;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* What a module's default export declares. Total: every answer the runner has to
|
|
82
|
+
* distinguish is a case here, so no caller can forget one.
|
|
83
|
+
*/
|
|
84
|
+
export type DeclaredEval = {
|
|
85
|
+
readonly ok: true;
|
|
86
|
+
readonly kind: EvalKind;
|
|
87
|
+
readonly spec: unknown;
|
|
88
|
+
} | {
|
|
89
|
+
readonly ok: false;
|
|
90
|
+
readonly why: "not-a-definition";
|
|
91
|
+
} | {
|
|
92
|
+
readonly ok: false;
|
|
93
|
+
readonly why: "declares-nothing";
|
|
94
|
+
} | {
|
|
95
|
+
readonly ok: false;
|
|
96
|
+
readonly why: "declares-several";
|
|
97
|
+
readonly kinds: readonly EvalKind[];
|
|
98
|
+
};
|
|
99
|
+
/** The measurement keys, in a fixed order — the one list, read by everything. */
|
|
100
|
+
export declare const EVAL_KINDS: readonly EvalKind[];
|
|
101
|
+
/** Whether a value came from {@link defineEval}. */
|
|
102
|
+
export declare function isEvalDefinition(v: unknown): v is EvalDefinition;
|
|
103
|
+
/**
|
|
104
|
+
* Read a module's default export as a declaration. Pure — this is the whole
|
|
105
|
+
* reason the descriptor is data: the runner answers "what does this file
|
|
106
|
+
* declare?" without executing anything and without spending anything.
|
|
107
|
+
*/
|
|
108
|
+
export declare function declaredEval(def: unknown): DeclaredEval;
|
|
109
|
+
/**
|
|
110
|
+
* The definition a module namespace carries, through the CJS/ESM interop layer.
|
|
111
|
+
*
|
|
112
|
+
* 🔴 THE SECOND `.default` IS NOT DEFENSIVE — it is the only way a TypeScript
|
|
113
|
+
* eval file works, and it was found by a test, not by reading. Three shapes
|
|
114
|
+
* reach this function and they are genuinely different objects:
|
|
115
|
+
*
|
|
116
|
+
* x.eval.mjs real ESM → `mod.default` IS the definition
|
|
117
|
+
* x.eval.cjs `module.exports = defineEval(…)`
|
|
118
|
+
* → `mod.default` is `module.exports`, the definition
|
|
119
|
+
* x.eval.ts `export default …`, transpiled to CJS by tsx
|
|
120
|
+
* → `mod.default` is `module.exports`, and the
|
|
121
|
+
* definition sits at `mod.default.default`
|
|
122
|
+
*
|
|
123
|
+
* Measured 2026-08-18: without the unwrap a `.eval.ts` file reported
|
|
124
|
+
* "no `export default defineEval({…})` found" — a correct file, refused, with a
|
|
125
|
+
* message that sent its author looking in the wrong place.
|
|
126
|
+
*
|
|
127
|
+
* Brand-directed rather than shape-directed: it reaches deeper ONLY when the
|
|
128
|
+
* outer value is not a definition, so a definition that happens to carry a
|
|
129
|
+
* `default` field of its own is never skipped over.
|
|
130
|
+
*/
|
|
131
|
+
export declare function moduleDefault(mod: unknown): unknown;
|
|
132
|
+
/**
|
|
133
|
+
* Was node pointed STRAIGHT at an eval file? `argv1` is `process.argv[1]`: the
|
|
134
|
+
* path node was started with, which no stray configuration can forge.
|
|
135
|
+
*
|
|
136
|
+
* node x.eval.mjs → the file → true
|
|
137
|
+
* vigiles eval x.eval.mjs → dist/eval-entry.js → false
|
|
138
|
+
* node -e 'import("x.eval.mjs")' → undefined → false
|
|
139
|
+
* npx vitest run → …/vitest/…/forks.js → false
|
|
140
|
+
*
|
|
141
|
+
* Pure: a fact in, a boolean out.
|
|
142
|
+
*/
|
|
143
|
+
export declare function ranAsEntry(argv1: string | undefined): boolean;
|
|
144
|
+
/** The words shown when someone runs an eval file directly. Asserted by a test:
|
|
145
|
+
* a refusal that stops a run without saying what to do instead is a ticket. */
|
|
146
|
+
export declare function ranAsEntryRefusal(argv1: string): string;
|
|
147
|
+
/**
|
|
148
|
+
* Declare the eval a file describes. Returns a plain, branded value; it starts
|
|
149
|
+
* nothing, spends nothing, and touches no filesystem.
|
|
150
|
+
*
|
|
151
|
+
* ```js
|
|
152
|
+
* import { defineEval, assertRates } from "vigiles";
|
|
153
|
+
* import { skill } from "vigiles";
|
|
154
|
+
*
|
|
155
|
+
* export default defineEval({
|
|
156
|
+
* measure: { pluginDir, task: "…", checks: [skill("my:skill")], trials: 3 },
|
|
157
|
+
* assert: (report) => assertRates(report, { min: 0.6 }),
|
|
158
|
+
* });
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* @throws if node was pointed straight at the eval file — see the module doc.
|
|
162
|
+
* That is the ONE thing this function does besides build a value, and it is
|
|
163
|
+
* here rather than in each file precisely so that no author can forget it.
|
|
164
|
+
*/
|
|
165
|
+
export declare function defineEval<K extends EvalKind>(def: EvalDefinitionInput<K>): EvalDefinition<K>;
|
|
166
|
+
//# sourceMappingURL=eval-define.d.ts.map
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EVAL_KINDS = exports.EVAL_DEFINITION = void 0;
|
|
4
|
+
exports.isEvalDefinition = isEvalDefinition;
|
|
5
|
+
exports.declaredEval = declaredEval;
|
|
6
|
+
exports.moduleDefault = moduleDefault;
|
|
7
|
+
exports.ranAsEntry = ranAsEntry;
|
|
8
|
+
exports.ranAsEntryRefusal = ranAsEntryRefusal;
|
|
9
|
+
exports.defineEval = defineEval;
|
|
10
|
+
/**
|
|
11
|
+
* `defineEval` — an eval file DESCRIBES its eval; `vigiles eval` runs it.
|
|
12
|
+
*
|
|
13
|
+
* ## The defect this shape removes
|
|
14
|
+
*
|
|
15
|
+
* Eval files used to do their work in the module body:
|
|
16
|
+
*
|
|
17
|
+
* const report = await measureTriggerRate({ … }); // ← top level
|
|
18
|
+
* console.log(formatTriggerRateReport(report));
|
|
19
|
+
* assertTriggerRate(report, { min: 0.6 });
|
|
20
|
+
*
|
|
21
|
+
* In ESM, `import` IS execution. So the cheapest imaginable question — "does
|
|
22
|
+
* this file even parse?" — answered with `import()` launched a real, paid run
|
|
23
|
+
* against a real model. That happened, and was paid for, on 2026-08-12.
|
|
24
|
+
*
|
|
25
|
+
* The fix is not a better warning. It is that the file no longer HOLDS a runner:
|
|
26
|
+
*
|
|
27
|
+
* export default defineEval({
|
|
28
|
+
* measureTriggerRate: { … }, // ← data
|
|
29
|
+
* assert: (r) => assertTriggerRate(r, { min: 0.6 }),
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* `defineEval` builds a plain value. There is nothing in it to run, so importing
|
|
33
|
+
* it starts nothing — not "is discouraged from starting"; there is no call. The
|
|
34
|
+
* five paid runners are reached only by `eval-entry.ts`, the module `vigiles
|
|
35
|
+
* eval` spawns. After the migration, ZERO of this repo's 19 eval files import a
|
|
36
|
+
* runner at all: the paid subpath is no longer part of an eval file's vocabulary.
|
|
37
|
+
* (18 were already named `*.eval.*`; `from-promptfoo.mjs` was renamed in, because
|
|
38
|
+
* it ran a paid runner at the top level while sitting outside the runner's glob —
|
|
39
|
+
* the same defect in a file nothing would have caught.)
|
|
40
|
+
*
|
|
41
|
+
* ## Why data and not a callback
|
|
42
|
+
*
|
|
43
|
+
* `defineEval({ run: async ({ measure }) => … })` would also make importing
|
|
44
|
+
* inert, and it was rejected on a measurable difference: with a callback the
|
|
45
|
+
* runner cannot know WHAT a file declares without executing it, so "this file
|
|
46
|
+
* declares no eval" becomes undecidable, and the loud report on an unmigrated
|
|
47
|
+
* file — the whole discovery path — becomes impossible. With data it is a
|
|
48
|
+
* property of the value, decided before a cent is spent. (See
|
|
49
|
+
* `eval-define.test.ts`, which asserts exactly that on a fixture.)
|
|
50
|
+
*
|
|
51
|
+
* ## Running the file directly is a LOUD failure, not a no-op
|
|
52
|
+
*
|
|
53
|
+
* Every old eval file was documented as `node path/to/x.eval.mjs`, and that
|
|
54
|
+
* habit outlives the migration. A pure description run that way would print
|
|
55
|
+
* nothing and exit 0 — a silent no-op, the same class of defect in a new place.
|
|
56
|
+
* So `defineEval` refuses when node was pointed straight at an eval file (a
|
|
57
|
+
* POSITIVE identification from `process.argv[1]`, the same fact and the same
|
|
58
|
+
* reasoning `foreign-runner.ts` uses; it cannot be forged by configuration).
|
|
59
|
+
* `node --check <file>` — the free syntax check people should be reaching for —
|
|
60
|
+
* never executes, so it is untouched.
|
|
61
|
+
*/
|
|
62
|
+
const node_path_1 = require("node:path");
|
|
63
|
+
/**
|
|
64
|
+
* Brand marking a value as built by {@link defineEval}. A registered symbol, so
|
|
65
|
+
* a descriptor still reads as one across two copies of the package on disk —
|
|
66
|
+
* the shape a monorepo produces routinely.
|
|
67
|
+
*/
|
|
68
|
+
exports.EVAL_DEFINITION = Symbol.for("vigiles.eval.definition");
|
|
69
|
+
/** The measurement keys, in a fixed order — the one list, read by everything. */
|
|
70
|
+
exports.EVAL_KINDS = [
|
|
71
|
+
"runEval",
|
|
72
|
+
"measure",
|
|
73
|
+
"measureArms",
|
|
74
|
+
"measureTriggerRate",
|
|
75
|
+
"measureSelectionMatrix",
|
|
76
|
+
];
|
|
77
|
+
/** Whether a value came from {@link defineEval}. */
|
|
78
|
+
function isEvalDefinition(v) {
|
|
79
|
+
return (typeof v === "object" &&
|
|
80
|
+
v !== null &&
|
|
81
|
+
v[exports.EVAL_DEFINITION] === true);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read a module's default export as a declaration. Pure — this is the whole
|
|
85
|
+
* reason the descriptor is data: the runner answers "what does this file
|
|
86
|
+
* declare?" without executing anything and without spending anything.
|
|
87
|
+
*/
|
|
88
|
+
function declaredEval(def) {
|
|
89
|
+
if (!isEvalDefinition(def))
|
|
90
|
+
return { ok: false, why: "not-a-definition" };
|
|
91
|
+
const rec = def;
|
|
92
|
+
const present = exports.EVAL_KINDS.filter((k) => rec[k] !== undefined);
|
|
93
|
+
if (present.length === 0)
|
|
94
|
+
return { ok: false, why: "declares-nothing" };
|
|
95
|
+
if (present.length > 1)
|
|
96
|
+
return { ok: false, why: "declares-several", kinds: present };
|
|
97
|
+
const kind = present[0];
|
|
98
|
+
if (kind === undefined)
|
|
99
|
+
return { ok: false, why: "declares-nothing" };
|
|
100
|
+
return { ok: true, kind, spec: rec[kind] };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The definition a module namespace carries, through the CJS/ESM interop layer.
|
|
104
|
+
*
|
|
105
|
+
* 🔴 THE SECOND `.default` IS NOT DEFENSIVE — it is the only way a TypeScript
|
|
106
|
+
* eval file works, and it was found by a test, not by reading. Three shapes
|
|
107
|
+
* reach this function and they are genuinely different objects:
|
|
108
|
+
*
|
|
109
|
+
* x.eval.mjs real ESM → `mod.default` IS the definition
|
|
110
|
+
* x.eval.cjs `module.exports = defineEval(…)`
|
|
111
|
+
* → `mod.default` is `module.exports`, the definition
|
|
112
|
+
* x.eval.ts `export default …`, transpiled to CJS by tsx
|
|
113
|
+
* → `mod.default` is `module.exports`, and the
|
|
114
|
+
* definition sits at `mod.default.default`
|
|
115
|
+
*
|
|
116
|
+
* Measured 2026-08-18: without the unwrap a `.eval.ts` file reported
|
|
117
|
+
* "no `export default defineEval({…})` found" — a correct file, refused, with a
|
|
118
|
+
* message that sent its author looking in the wrong place.
|
|
119
|
+
*
|
|
120
|
+
* Brand-directed rather than shape-directed: it reaches deeper ONLY when the
|
|
121
|
+
* outer value is not a definition, so a definition that happens to carry a
|
|
122
|
+
* `default` field of its own is never skipped over.
|
|
123
|
+
*/
|
|
124
|
+
function moduleDefault(mod) {
|
|
125
|
+
const outer = mod?.default;
|
|
126
|
+
if (isEvalDefinition(outer))
|
|
127
|
+
return outer;
|
|
128
|
+
const inner = outer?.default;
|
|
129
|
+
return isEvalDefinition(inner) ? inner : outer;
|
|
130
|
+
}
|
|
131
|
+
/** Filenames vigiles runs as evals — the runner's own glob, as a pattern. */
|
|
132
|
+
const EVAL_FILE = /\.eval\.(?:m|c)?[jt]s$/;
|
|
133
|
+
/**
|
|
134
|
+
* Was node pointed STRAIGHT at an eval file? `argv1` is `process.argv[1]`: the
|
|
135
|
+
* path node was started with, which no stray configuration can forge.
|
|
136
|
+
*
|
|
137
|
+
* node x.eval.mjs → the file → true
|
|
138
|
+
* vigiles eval x.eval.mjs → dist/eval-entry.js → false
|
|
139
|
+
* node -e 'import("x.eval.mjs")' → undefined → false
|
|
140
|
+
* npx vitest run → …/vitest/…/forks.js → false
|
|
141
|
+
*
|
|
142
|
+
* Pure: a fact in, a boolean out.
|
|
143
|
+
*/
|
|
144
|
+
function ranAsEntry(argv1) {
|
|
145
|
+
return argv1 !== undefined && EVAL_FILE.test(argv1.replaceAll("\\", "/"));
|
|
146
|
+
}
|
|
147
|
+
/** The words shown when someone runs an eval file directly. Asserted by a test:
|
|
148
|
+
* a refusal that stops a run without saying what to do instead is a ticket. */
|
|
149
|
+
function ranAsEntryRefusal(argv1) {
|
|
150
|
+
const f = (0, node_path_1.basename)(argv1);
|
|
151
|
+
return (`\`node ${f}\` no longer runs this eval — the file DESCRIBES one.\n` +
|
|
152
|
+
` An eval file that ran itself spent real money on a plain import, so the work moved\n` +
|
|
153
|
+
` into \`vigiles eval\`, which is the only thing that runs a description.\n` +
|
|
154
|
+
` → run it: npx vigiles eval ${argv1}\n` +
|
|
155
|
+
` → check syntax without running anything: node --check ${argv1}`);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Declare the eval a file describes. Returns a plain, branded value; it starts
|
|
159
|
+
* nothing, spends nothing, and touches no filesystem.
|
|
160
|
+
*
|
|
161
|
+
* ```js
|
|
162
|
+
* import { defineEval, assertRates } from "vigiles";
|
|
163
|
+
* import { skill } from "vigiles";
|
|
164
|
+
*
|
|
165
|
+
* export default defineEval({
|
|
166
|
+
* measure: { pluginDir, task: "…", checks: [skill("my:skill")], trials: 3 },
|
|
167
|
+
* assert: (report) => assertRates(report, { min: 0.6 }),
|
|
168
|
+
* });
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* @throws if node was pointed straight at the eval file — see the module doc.
|
|
172
|
+
* That is the ONE thing this function does besides build a value, and it is
|
|
173
|
+
* here rather than in each file precisely so that no author can forget it.
|
|
174
|
+
*/
|
|
175
|
+
function defineEval(def) {
|
|
176
|
+
/* v8 ignore next 3 -- the entry-point branch is exercised through a child process (eval-define.test.ts) */
|
|
177
|
+
const argv1 = process.argv[1];
|
|
178
|
+
if (argv1 !== undefined && ranAsEntry(argv1))
|
|
179
|
+
throw new Error(ranAsEntryRefusal(argv1));
|
|
180
|
+
return { ...def, [exports.EVAL_DEFINITION]: true };
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=eval-define.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type EvalKind } from "./eval-define.js";
|
|
2
|
+
import { type ArmsCheckReport, type CheckReport, type EvalReport, type TriggerRateReport } from "./eval.js";
|
|
3
|
+
import { type SelectionReport } from "./scan-behavioral.js";
|
|
4
|
+
/** Every report this entry can produce. */
|
|
5
|
+
type AnyReport = EvalReport | CheckReport | ArmsCheckReport | TriggerRateReport | SelectionReport;
|
|
6
|
+
/**
|
|
7
|
+
* How many trials this run should use, or `undefined` to leave the spec alone.
|
|
8
|
+
* `vigiles eval --trials=N` arrives as `VIGILES_TRIALS`; a spec's own `trials` is
|
|
9
|
+
* the default. Pure — exported for the tests.
|
|
10
|
+
*
|
|
11
|
+
* A non-numeric or non-positive value is IGNORED rather than treated as zero: a
|
|
12
|
+
* typo'd `--trials=` must not silently turn a measurement into a no-op.
|
|
13
|
+
*/
|
|
14
|
+
export declare function trialsOverride(raw: string | undefined): number | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* How many runs a report is built from, or `undefined` when the shape carries no
|
|
17
|
+
* such count. Pure. Every measurement has one, but under two different names and
|
|
18
|
+
* at two different depths, which is exactly why each eval file used to
|
|
19
|
+
* hand-write its own `report.n === 0` check (and why several forgot to).
|
|
20
|
+
*/
|
|
21
|
+
export declare function runsIn(report: AnyReport): number | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* `evalDriver` is only wired on `measureTriggerRate` — the only measurement with
|
|
24
|
+
* a public driver seam. Naming it beside any other measurement is a mistake the
|
|
25
|
+
* runner REFUSES rather than ignores: a field that silently does nothing would
|
|
26
|
+
* send a Codex user's eval to Claude Code and report the number as theirs.
|
|
27
|
+
*/
|
|
28
|
+
export declare function driverMisplaced(kind: EvalKind, hasDriver: boolean): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The message for a file that is not a description. Separate from the flow so a
|
|
31
|
+
* test can assert the WORDS — this is the ONLY thing an author sees when their
|
|
32
|
+
* pre-migration eval file stops working, so it has to teach the new shape.
|
|
33
|
+
*/
|
|
34
|
+
export declare function notADescriptionMessage(file: string, why: string): string;
|
|
35
|
+
/** The `why` line for each way a default export can fail to be a declaration. */
|
|
36
|
+
export declare function declarationProblem(d: {
|
|
37
|
+
why: "not-a-definition" | "declares-nothing" | "declares-several";
|
|
38
|
+
kinds?: readonly EvalKind[];
|
|
39
|
+
}): string;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=eval-entry.d.ts.map
|