@skill-harness/core 0.2.1 → 0.3.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/dist/grade.d.ts +2 -0
- package/dist/grade.js +26 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lift.d.ts +23 -15
- package/dist/lift.js +43 -4
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +85 -18
- package/dist/results.d.ts +36 -13
- package/dist/results.js +54 -13
- package/dist/run.js +21 -33
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +313 -14
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +15 -0
- package/dist/spec.js +31 -0
- package/dist/workspace.d.ts +28 -0
- package/dist/workspace.js +50 -7
- package/package.json +34 -8
package/dist/run.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { sourceHashes } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
|
-
import { runDirFor, transcriptPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
5
|
+
import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { liftHeadline } from "./lift.js";
|
|
8
8
|
import { runSeeded } from "./seeded.js";
|
|
@@ -10,34 +10,6 @@ import { createWorkspace } from "./workspace.js";
|
|
|
10
10
|
import { runPool } from "./scheduler.js";
|
|
11
11
|
import { outcomesToResult } from "./reps.js";
|
|
12
12
|
import { judgeOneRep } from "./regrade.js";
|
|
13
|
-
/** sha256 of a file, or null when it doesn't exist — missing sources are lint's problem, not run's. */
|
|
14
|
-
function sha256(path) {
|
|
15
|
-
try {
|
|
16
|
-
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Hash every source file this run measures: SKILL.md + each distinct
|
|
24
|
-
* system_prompt_file (agents/<name>.md). Recorded in results.yaml so lint can prove
|
|
25
|
-
* a published result still describes the current text.
|
|
26
|
-
*/
|
|
27
|
-
function sourceHashes(skillDir, specPath, scenarios) {
|
|
28
|
-
const hashes = {};
|
|
29
|
-
const skillMd = sha256(resolve(skillDir, "SKILL.md"));
|
|
30
|
-
if (skillMd)
|
|
31
|
-
hashes["SKILL.md"] = skillMd;
|
|
32
|
-
for (const s of scenarios) {
|
|
33
|
-
if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
|
|
34
|
-
const h = sha256(resolve(dirname(specPath), s.systemPromptFile));
|
|
35
|
-
if (h)
|
|
36
|
-
hashes[s.systemPromptFile] = h;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return hashes;
|
|
40
|
-
}
|
|
41
13
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
42
14
|
export async function runSkillModel(opts) {
|
|
43
15
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -99,7 +71,9 @@ export async function runSkillModel(opts) {
|
|
|
99
71
|
label: opts.label ?? null,
|
|
100
72
|
mode,
|
|
101
73
|
...(partial ? { partial: true } : {}),
|
|
102
|
-
|
|
74
|
+
// Only the scenarios this run actually measured: a --only run must not claim
|
|
75
|
+
// coverage of scenarios it skipped.
|
|
76
|
+
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios }),
|
|
103
77
|
scenarios: scenarioResults,
|
|
104
78
|
}, ctx);
|
|
105
79
|
if (ctx) {
|
|
@@ -136,6 +110,10 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
136
110
|
let ws = null;
|
|
137
111
|
let transcript = "";
|
|
138
112
|
let gatePrefix = null;
|
|
113
|
+
// Null until a seeded rep actually reaches its gates: a workspace-setup failure
|
|
114
|
+
// produces no diff, and writing an empty artifact there would misreport "the
|
|
115
|
+
// model changed nothing" for a rep that never ran.
|
|
116
|
+
let stagedDiff = null;
|
|
139
117
|
try {
|
|
140
118
|
try {
|
|
141
119
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
@@ -160,9 +138,11 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
160
138
|
if (scenario.mode === "seeded") {
|
|
161
139
|
const r = await runSeeded(scenario, {
|
|
162
140
|
skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
|
|
141
|
+
specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
|
|
163
142
|
});
|
|
164
143
|
transcript = r.transcript;
|
|
165
144
|
gatePrefix = r.gateFailure;
|
|
145
|
+
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
166
146
|
}
|
|
167
147
|
else {
|
|
168
148
|
transcript = await ctx.adapter.run({
|
|
@@ -178,8 +158,16 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
178
158
|
break;
|
|
179
159
|
}
|
|
180
160
|
}
|
|
181
|
-
|
|
161
|
+
const repSuffix = repCount > 1 ? rep : undefined;
|
|
162
|
+
writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
|
|
182
163
|
if (scenario.mode === "seeded") {
|
|
164
|
+
// The workspace is torn down in the `finally` below, so this is the only
|
|
165
|
+
// chance to keep what the model actually wrote. Persisted uncapped (the
|
|
166
|
+
// transcript's copy is capped for the judge) and for every rep, pass or
|
|
167
|
+
// fail — a gate failure is exactly when you want to read the diff.
|
|
168
|
+
if (stagedDiff !== null) {
|
|
169
|
+
writeFileSync(diffPath(runDir, scenario.id, mode, repSuffix), stagedDiff, "utf8");
|
|
170
|
+
}
|
|
183
171
|
appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
|
|
184
172
|
}
|
|
185
173
|
let verdict;
|
package/dist/seeded.d.ts
CHANGED
|
@@ -1,21 +1,101 @@
|
|
|
1
1
|
import type { Scenario } from "./spec.js";
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
|
+
import { type ExecResult } from "./util/exec.js";
|
|
3
4
|
interface SeededOpts {
|
|
4
5
|
skillDir: string;
|
|
5
6
|
adapter: HarnessAdapter;
|
|
6
7
|
model: ModelRef;
|
|
7
8
|
mode: RunMode;
|
|
8
9
|
cwd: string;
|
|
10
|
+
specDir: string;
|
|
11
|
+
/**
|
|
12
|
+
* How the vitest gates shell out. Defaults to the real `npx vitest run`.
|
|
13
|
+
*
|
|
14
|
+
* A seam, not a mock: a workspace is a bare temp dir, so a test that exercised
|
|
15
|
+
* the real runner would resolve vitest off the network and be slow and flaky.
|
|
16
|
+
* Injecting it lets the gate LOGIC — pass, fail, nothing-collected — be tested
|
|
17
|
+
* deterministically.
|
|
18
|
+
*/
|
|
19
|
+
runVitest?: (args: string[], cwd: string) => Promise<VitestRun>;
|
|
9
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Result of one vitest invocation — deliberately `ExecResult`, not a narrower
|
|
23
|
+
* shape of its own.
|
|
24
|
+
*
|
|
25
|
+
* An earlier version declared `code: number`. That narrowing was a lie the
|
|
26
|
+
* compiler happened not to catch (the default's inferred type silently widened
|
|
27
|
+
* it back), and it is unrepresentable in practice: `exec` SIGKILLs on timeout
|
|
28
|
+
* and a signal-killed child closes with `code === null`. Declaring non-null
|
|
29
|
+
* would have made the vitest gate's timeout path — the one an injected double
|
|
30
|
+
* most needs to reproduce — impossible to express in a test.
|
|
31
|
+
*/
|
|
32
|
+
export type VitestRun = ExecResult;
|
|
10
33
|
export interface SeededOutcome {
|
|
11
34
|
transcript: string;
|
|
12
35
|
gateFailure: string | null;
|
|
36
|
+
diff: string;
|
|
13
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The added/removed lines of a unified diff — what the model actually *changed*,
|
|
40
|
+
* with context lines and file headers dropped.
|
|
41
|
+
*
|
|
42
|
+
* This is the difference between "the diff mentions `lastIndex`" and "the model
|
|
43
|
+
* touched `lastIndex`". A unified diff carries three lines of context around every
|
|
44
|
+
* hunk, so an untouched function sitting near the edit site appears in the diff
|
|
45
|
+
* verbatim. `build` A2 is exactly that shape — its checklist notes that `lastIndex`
|
|
46
|
+
* "sits two lines from the edit site" — so a naive substring test against the whole
|
|
47
|
+
* diff would fail the scenario for every model that fixed the right thing, which is
|
|
48
|
+
* worse than the prose-dependent item it replaces.
|
|
49
|
+
*
|
|
50
|
+
* Classification is HUNK-AWARE rather than prefix-based, because `+++`/`---` are
|
|
51
|
+
* only headers *outside* a hunk. Filtering on those prefixes anywhere would eat a
|
|
52
|
+
* changed line whose own source text starts with `++` or `--` — `++counter;` at
|
|
53
|
+
* column zero, a removed SQL/Lua `-- comment`, a YAML `---` separator. Those
|
|
54
|
+
* became `+++counter;` and `--- comment` once the diff marker was prepended, were
|
|
55
|
+
* read as headers, and vanished: `diff_excludes` then reported OK for a diff that
|
|
56
|
+
* touched the forbidden symbol. A false PASS on an objective gate is worse than
|
|
57
|
+
* the subjective check it replaced, so the parse follows the format instead of
|
|
58
|
+
* guessing from prefixes.
|
|
59
|
+
*/
|
|
60
|
+
export declare function changedLines(diff: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Cut a diff to a byte budget on a line boundary, appending an explicit marker
|
|
63
|
+
* naming how much was dropped.
|
|
64
|
+
*
|
|
65
|
+
* The marker is not decoration: a silently truncated diff would let the judge
|
|
66
|
+
* grade "the function is missing" when it was merely cut off, which is the exact
|
|
67
|
+
* class of false-FAIL this whole change exists to remove. Truncation is reported
|
|
68
|
+
* as a fact about the transcript, and the untruncated diff is always on disk.
|
|
69
|
+
*/
|
|
70
|
+
export declare function capDiff(diff: string, maxBytes?: number): string;
|
|
14
71
|
/**
|
|
15
72
|
* Run a seeded scenario inside a caller-prepared workspace: let the harness edit
|
|
16
|
-
* the repo, then evaluate objective gates
|
|
17
|
-
*
|
|
18
|
-
*
|
|
73
|
+
* the repo, then evaluate the objective gates it declares — `diff_contains`,
|
|
74
|
+
* `diff_excludes`, `vitest` and `post_test`. Every gate that is configured runs;
|
|
75
|
+
* the FIRST failure is what `gateFailure` reports, and a non-null `gateFailure`
|
|
76
|
+
* makes the scenario an auto-FAIL that never reaches the judge.
|
|
77
|
+
*
|
|
78
|
+
* Returns the full staged diff alongside the transcript: the caller persists it
|
|
79
|
+
* as a run artifact, and a size-capped copy is appended to the transcript under
|
|
80
|
+
* `=== STAGED DIFF ===` so the judge grades the code rather than the model's
|
|
81
|
+
* description of it. Workspace creation (fixture copy + git baseline) and
|
|
82
|
+
* teardown are the caller's responsibility (run.ts).
|
|
19
83
|
*/
|
|
20
84
|
export declare function runSeeded(scenario: Scenario, opts: SeededOpts): Promise<SeededOutcome>;
|
|
85
|
+
export interface VitestTally {
|
|
86
|
+
passed: number;
|
|
87
|
+
failed: number;
|
|
88
|
+
skipped: number;
|
|
89
|
+
todo: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse vitest's `Tests N passed | M skipped (T)` summary line.
|
|
93
|
+
*
|
|
94
|
+
* Used to require positive evidence that a hidden `post_test` actually executed
|
|
95
|
+
* assertions, rather than trusting a zero exit code — which vitest also returns
|
|
96
|
+
* when every test in the file is skipped. Returns null when no summary line is
|
|
97
|
+
* present, which the caller treats as "cannot confirm it ran" rather than as a
|
|
98
|
+
* pass.
|
|
99
|
+
*/
|
|
100
|
+
export declare function vitestTally(out: string): VitestTally | null;
|
|
21
101
|
export {};
|
package/dist/seeded.js
CHANGED
|
@@ -1,11 +1,126 @@
|
|
|
1
|
+
import { copyFileSync, statSync } from "node:fs";
|
|
2
|
+
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
1
3
|
import { exec } from "./util/exec.js";
|
|
2
4
|
import { envNum } from "./util/env.js";
|
|
5
|
+
/**
|
|
6
|
+
* Filename STEM the post-test is copied to at the workspace root; the extension
|
|
7
|
+
* (`.test.ts`) is appended at the copy site, which is the part that decides
|
|
8
|
+
* whether vitest collects it at all.
|
|
9
|
+
*
|
|
10
|
+
* Harness-owned rather than the author's basename, for two reasons: it cannot
|
|
11
|
+
* collide with a fixture file by accident, and a model cannot shadow the check by
|
|
12
|
+
* creating a *file* at the path it guesses we will use — the copy happens after
|
|
13
|
+
* the model is done and overwrites unconditionally. (A *directory* there makes
|
|
14
|
+
* the copy fail; that is handled as an infrastructure error, not a model FAIL.)
|
|
15
|
+
*/
|
|
16
|
+
const POST_TEST_BASE = "skill-harness.post";
|
|
3
17
|
const VITEST_TIMEOUT_MS = envNum("VITEST_TIMEOUT_MS", 120_000);
|
|
18
|
+
/**
|
|
19
|
+
* The added/removed lines of a unified diff — what the model actually *changed*,
|
|
20
|
+
* with context lines and file headers dropped.
|
|
21
|
+
*
|
|
22
|
+
* This is the difference between "the diff mentions `lastIndex`" and "the model
|
|
23
|
+
* touched `lastIndex`". A unified diff carries three lines of context around every
|
|
24
|
+
* hunk, so an untouched function sitting near the edit site appears in the diff
|
|
25
|
+
* verbatim. `build` A2 is exactly that shape — its checklist notes that `lastIndex`
|
|
26
|
+
* "sits two lines from the edit site" — so a naive substring test against the whole
|
|
27
|
+
* diff would fail the scenario for every model that fixed the right thing, which is
|
|
28
|
+
* worse than the prose-dependent item it replaces.
|
|
29
|
+
*
|
|
30
|
+
* Classification is HUNK-AWARE rather than prefix-based, because `+++`/`---` are
|
|
31
|
+
* only headers *outside* a hunk. Filtering on those prefixes anywhere would eat a
|
|
32
|
+
* changed line whose own source text starts with `++` or `--` — `++counter;` at
|
|
33
|
+
* column zero, a removed SQL/Lua `-- comment`, a YAML `---` separator. Those
|
|
34
|
+
* became `+++counter;` and `--- comment` once the diff marker was prepended, were
|
|
35
|
+
* read as headers, and vanished: `diff_excludes` then reported OK for a diff that
|
|
36
|
+
* touched the forbidden symbol. A false PASS on an objective gate is worse than
|
|
37
|
+
* the subjective check it replaced, so the parse follows the format instead of
|
|
38
|
+
* guessing from prefixes.
|
|
39
|
+
*/
|
|
40
|
+
export function changedLines(diff) {
|
|
41
|
+
const out = [];
|
|
42
|
+
let inHunk = false;
|
|
43
|
+
for (const line of diff.split("\n")) {
|
|
44
|
+
if (line.startsWith("@@")) {
|
|
45
|
+
inHunk = true; // a hunk header opens the region where +/- mean "changed"
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (line.startsWith("diff --git ")) {
|
|
49
|
+
inHunk = false; // a new file section closes it; its ---/+++ are headers again
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!inHunk)
|
|
53
|
+
continue; // index/mode/---/+++ preamble lines
|
|
54
|
+
if (line.startsWith("+") || line.startsWith("-"))
|
|
55
|
+
out.push(line);
|
|
56
|
+
}
|
|
57
|
+
return out.join("\n");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Byte cap on the diff copy embedded in the judged transcript. The judge prompt
|
|
61
|
+
* is a single request, so an unbounded diff (a fixture-wide refactor, a
|
|
62
|
+
* regenerated lockfile) could overflow the context window and turn a gradeable
|
|
63
|
+
* run into a judge ERROR. The artifact on disk is never capped — only the copy
|
|
64
|
+
* the judge reads.
|
|
65
|
+
*/
|
|
66
|
+
const DIFF_MAX_BYTES = envNum("DIFF_MAX_BYTES", 64_000);
|
|
67
|
+
/**
|
|
68
|
+
* Cut a diff to a byte budget on a line boundary, appending an explicit marker
|
|
69
|
+
* naming how much was dropped.
|
|
70
|
+
*
|
|
71
|
+
* The marker is not decoration: a silently truncated diff would let the judge
|
|
72
|
+
* grade "the function is missing" when it was merely cut off, which is the exact
|
|
73
|
+
* class of false-FAIL this whole change exists to remove. Truncation is reported
|
|
74
|
+
* as a fact about the transcript, and the untruncated diff is always on disk.
|
|
75
|
+
*/
|
|
76
|
+
export function capDiff(diff, maxBytes = DIFF_MAX_BYTES) {
|
|
77
|
+
const total = Buffer.byteLength(diff, "utf8");
|
|
78
|
+
if (total <= maxBytes)
|
|
79
|
+
return diff;
|
|
80
|
+
// Accumulate whole lines until the next one would exceed the budget. `used`
|
|
81
|
+
// counts the separators actually emitted by join() — n lines carry n-1 of
|
|
82
|
+
// them — so the omitted figure in the marker is exact rather than one byte
|
|
83
|
+
// short. This file's whole thesis is telling the judge accurately what it was
|
|
84
|
+
// not shown, so an off-by-one here is a small lie in the wrong place.
|
|
85
|
+
const kept = [];
|
|
86
|
+
let used = 0;
|
|
87
|
+
for (const line of diff.split("\n")) {
|
|
88
|
+
const cost = Buffer.byteLength(line, "utf8") + (kept.length > 0 ? 1 : 0);
|
|
89
|
+
if (used + cost > maxBytes)
|
|
90
|
+
break;
|
|
91
|
+
kept.push(line);
|
|
92
|
+
used += cost;
|
|
93
|
+
}
|
|
94
|
+
// A single line longer than the whole budget (a minified bundle, a lockfile,
|
|
95
|
+
// a generated blob) would otherwise keep nothing at all and hand the judge a
|
|
96
|
+
// marker with zero code under it. Show a byte-safe prefix instead: some
|
|
97
|
+
// evidence beats none, and the marker still says what was cut.
|
|
98
|
+
if (kept.length === 0) {
|
|
99
|
+
const head = Buffer.from(diff, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
100
|
+
// toString() on a boundary-split multibyte sequence yields U+FFFD; drop a
|
|
101
|
+
// trailing one rather than show the judge a corrupted character.
|
|
102
|
+
const clean = head.endsWith("�") ? head.slice(0, -1) : head;
|
|
103
|
+
kept.push(clean);
|
|
104
|
+
used = Buffer.byteLength(clean, "utf8");
|
|
105
|
+
}
|
|
106
|
+
const omitted = total - used;
|
|
107
|
+
return (kept.join("\n") +
|
|
108
|
+
`\n[… diff truncated: ${omitted} of ${total} bytes omitted (cap ${maxBytes}). ` +
|
|
109
|
+
`The complete diff is saved beside this transcript as this scenario's .diff.txt artifact. ` +
|
|
110
|
+
`Do not treat anything below the cut as absent — it was not shown to you. …]`);
|
|
111
|
+
}
|
|
4
112
|
/**
|
|
5
113
|
* Run a seeded scenario inside a caller-prepared workspace: let the harness edit
|
|
6
|
-
* the repo, then evaluate objective gates
|
|
7
|
-
*
|
|
8
|
-
*
|
|
114
|
+
* the repo, then evaluate the objective gates it declares — `diff_contains`,
|
|
115
|
+
* `diff_excludes`, `vitest` and `post_test`. Every gate that is configured runs;
|
|
116
|
+
* the FIRST failure is what `gateFailure` reports, and a non-null `gateFailure`
|
|
117
|
+
* makes the scenario an auto-FAIL that never reaches the judge.
|
|
118
|
+
*
|
|
119
|
+
* Returns the full staged diff alongside the transcript: the caller persists it
|
|
120
|
+
* as a run artifact, and a size-capped copy is appended to the transcript under
|
|
121
|
+
* `=== STAGED DIFF ===` so the judge grades the code rather than the model's
|
|
122
|
+
* description of it. Workspace creation (fixture copy + git baseline) and
|
|
123
|
+
* teardown are the caller's responsibility (run.ts).
|
|
9
124
|
*/
|
|
10
125
|
export async function runSeeded(scenario, opts) {
|
|
11
126
|
const repo = opts.cwd;
|
|
@@ -16,26 +131,157 @@ export async function runSeeded(scenario, opts) {
|
|
|
16
131
|
turns: scenario.turns,
|
|
17
132
|
cwd: repo,
|
|
18
133
|
});
|
|
19
|
-
await git(repo, ["add", "-A"]);
|
|
20
|
-
const diff = (await git(repo, ["diff", "--cached"])).stdout;
|
|
21
134
|
const parts = [harnessOut, "", "=== SEEDED GATES ==="];
|
|
22
135
|
let gateFailure = null;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
136
|
+
const runVitest = opts.runVitest ??
|
|
137
|
+
((args, cwd) => exec("npx", ["vitest", "run", ...args], { cwd, timeoutMs: VITEST_TIMEOUT_MS }));
|
|
138
|
+
// `exec` never throws on a non-zero exit and, on timeout, SIGKILLs the child and
|
|
139
|
+
// resolves with whatever stdout accumulated (code === null). Ignoring that here
|
|
140
|
+
// let a truncated or failed capture flow into every downstream consumer as
|
|
141
|
+
// evidence: diff_contains FAILs blamed on the model, diff_excludes silently
|
|
142
|
+
// passing, the transcript asserting "the model left no staged changes", and a
|
|
143
|
+
// partial diff persisted as the "complete" artifact — under the cap, so the
|
|
144
|
+
// truncation marker never fires. A capture we cannot trust is infrastructure,
|
|
145
|
+
// and must say so instead of being graded.
|
|
146
|
+
const add = await git(repo, ["add", "-A"]);
|
|
147
|
+
const show = await git(repo, ["diff", "--cached"]);
|
|
148
|
+
const diff = show.stdout;
|
|
149
|
+
const gitFailure = [add, show].find((r) => r.code !== 0);
|
|
150
|
+
if (gitFailure) {
|
|
151
|
+
const why = gitFailure.code === null ? "timed out and was killed" : `exited ${gitFailure.code}`;
|
|
152
|
+
const msg = `staged diff could not be captured — git ${why} — infrastructure, not model behavior` +
|
|
153
|
+
(gitFailure.stderr.trim() ? `: ${gitFailure.stderr.trim().split("\n")[0]}` : "");
|
|
154
|
+
parts.push(` staged diff: ERROR (${msg})`);
|
|
155
|
+
gateFailure = msg;
|
|
156
|
+
return finish(parts, gateFailure, diff);
|
|
157
|
+
}
|
|
158
|
+
// BOTH needle gates read the changed lines only, never context. A unified diff
|
|
159
|
+
// carries three lines of context per hunk, so an untouched symbol near the edit
|
|
160
|
+
// site appears verbatim in the diff text — and matching that means neither gate
|
|
161
|
+
// is answering the question it was asked.
|
|
162
|
+
//
|
|
163
|
+
// The positive gate is the one this was measured on. `build` A4 asserts
|
|
164
|
+
// diff_contains ["divide", "ok"], and its fixture's baseline already contains
|
|
165
|
+
// both — `ok` in `{ ok: true; value: T }`, `divide` inside `divideByZero` — so
|
|
166
|
+
// any edit near those lines satisfied the gate whether or not the model wrote
|
|
167
|
+
// either token. Every published A4 result recorded that as an objective pass.
|
|
168
|
+
// Read against changed lines it means what the checklist means: the model
|
|
169
|
+
// returned a Result.
|
|
170
|
+
const changed = changedLines(diff);
|
|
171
|
+
for (const needle of scenario.assert?.diff_contains ?? []) {
|
|
172
|
+
const ok = changed.includes(needle);
|
|
26
173
|
parts.push(` diff_contains ${JSON.stringify(needle)}: ${ok ? "OK" : "MISSING"}`);
|
|
27
174
|
if (!ok && !gateFailure)
|
|
28
175
|
gateFailure = `staged diff missing ${JSON.stringify(needle)}`;
|
|
29
176
|
}
|
|
177
|
+
// Scope discipline, stated as a fact about the diff rather than inferred from
|
|
178
|
+
// whether the model remembered to say "I left lastIndex alone".
|
|
179
|
+
for (const needle of scenario.assert?.diff_excludes ?? []) {
|
|
180
|
+
const ok = !changed.includes(needle);
|
|
181
|
+
parts.push(` diff_excludes ${JSON.stringify(needle)}: ${ok ? "OK" : "PRESENT"}`);
|
|
182
|
+
if (!ok && !gateFailure)
|
|
183
|
+
gateFailure = `staged diff touches forbidden ${JSON.stringify(needle)}`;
|
|
184
|
+
}
|
|
30
185
|
if (scenario.assert?.vitest) {
|
|
31
|
-
const v = await
|
|
186
|
+
const v = await runVitest([], repo);
|
|
187
|
+
// code === null means exec SIGKILLed it at the timeout. That is infrastructure,
|
|
188
|
+
// not a failing test, and must not be reported as the model's fault.
|
|
189
|
+
const killed = v.code === null;
|
|
32
190
|
const passed = v.code === 0;
|
|
33
|
-
parts.push(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
191
|
+
parts.push(killed
|
|
192
|
+
? ` vitest run: ERROR (timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior)`
|
|
193
|
+
: ` vitest run: ${passed ? "PASS" : `FAIL (exit ${v.code})`}`);
|
|
194
|
+
parts.push(indent(bothStreams(v)));
|
|
195
|
+
if (!passed && !gateFailure) {
|
|
196
|
+
gateFailure = killed
|
|
197
|
+
? `vitest timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior`
|
|
198
|
+
: `vitest failed (exit ${v.code})`;
|
|
199
|
+
}
|
|
37
200
|
}
|
|
38
|
-
|
|
201
|
+
const postTest = scenario.assert?.post_test;
|
|
202
|
+
if (postTest) {
|
|
203
|
+
const src = isAbsolute(postTest) ? postTest : resolve(opts.specDir, postTest);
|
|
204
|
+
// statSync().isFile(), not existsSync: a DIRECTORY "exists", and copyFileSync
|
|
205
|
+
// then throws EISDIR. That rejection escapes runSeeded, and runRep guards only
|
|
206
|
+
// workspace setup — so it reaches runSkillModel, writeResults never runs, and a
|
|
207
|
+
// paid multi-scenario run loses every scenario already completed. One spec typo
|
|
208
|
+
// (`post_test: post` for `post/A1.test.ts`) must cost a scenario, not a run.
|
|
209
|
+
if (!isReadableFile(src)) {
|
|
210
|
+
// Not model behavior. It still fails the scenario — a silently skipped gate is
|
|
211
|
+
// worse than a loud one — but the message says whose fault it is so nobody
|
|
212
|
+
// reads it as "the model broke the test".
|
|
213
|
+
const msg = `post_test is not a readable file: ${postTest} — spec error, not model behavior`;
|
|
214
|
+
parts.push(` post_test: ERROR (${msg})`);
|
|
215
|
+
if (!gateFailure)
|
|
216
|
+
gateFailure = msg;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// `.test` is part of the name we build, never taken from the source: extname()
|
|
220
|
+
// of "A2.test.ts" is ".ts", so appending only that produced
|
|
221
|
+
// "skill-harness.post.ts" — a file vitest does not collect as a test at all,
|
|
222
|
+
// which would have made the gate silently vacuous.
|
|
223
|
+
const dest = join(repo, `${POST_TEST_BASE}.test${extname(src) || ".ts"}`);
|
|
224
|
+
try {
|
|
225
|
+
copyFileSync(src, dest);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
// Permissions, a race, a full disk. Same reasoning as the branch above:
|
|
229
|
+
// degrade this scenario, never take the whole run down with it.
|
|
230
|
+
const msg = `post_test could not be copied into the workspace ` +
|
|
231
|
+
`(${e instanceof Error ? e.message : String(e)}) — infrastructure, not model behavior`;
|
|
232
|
+
parts.push(` post_test: ERROR (${msg})`);
|
|
233
|
+
if (!gateFailure)
|
|
234
|
+
gateFailure = msg;
|
|
235
|
+
return finish(parts, gateFailure, diff);
|
|
236
|
+
}
|
|
237
|
+
const v = await runVitest([POST_TEST_BASE], repo);
|
|
238
|
+
const out = `${v.stdout}\n${v.stderr}`;
|
|
239
|
+
// This gate must never pass without positive evidence that assertions ran.
|
|
240
|
+
// Absence-of-failure is not enough: a `.skip`/`.todo` left in the file exits
|
|
241
|
+
// ZERO and prints "Tests 1 skipped", so keying off the exit code reported
|
|
242
|
+
// PASS for a hidden gate that executed nothing — the exact vacuous-gate
|
|
243
|
+
// shape post_test exists to prevent, and one nobody would ever notice
|
|
244
|
+
// because a passing gate produces output no one reads.
|
|
245
|
+
const tally = vitestTally(out);
|
|
246
|
+
// Anchored to line start: the unanchored form also matched this string
|
|
247
|
+
// appearing anywhere in test output or a model-authored console.log, which
|
|
248
|
+
// would flip a genuine pass into a phantom "fixture is broken". The exact
|
|
249
|
+
// wording is vitest's ("No test files found, exiting with code 1", verified
|
|
250
|
+
// against 2.1.9) — recheck it when the vitest major changes.
|
|
251
|
+
const notCollected = /^\s*No test files found/im.test(out);
|
|
252
|
+
const killed = v.code === null;
|
|
253
|
+
let problem = null;
|
|
254
|
+
if (killed) {
|
|
255
|
+
problem = `post_test ${JSON.stringify(postTest)} timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior`;
|
|
256
|
+
}
|
|
257
|
+
else if (notCollected) {
|
|
258
|
+
problem = `post_test ${JSON.stringify(postTest)} was never collected by vitest — spec/fixture error, not model behavior`;
|
|
259
|
+
}
|
|
260
|
+
else if (tally === null) {
|
|
261
|
+
problem = `post_test ${JSON.stringify(postTest)} produced no parseable vitest summary (exit ${v.code}) — cannot confirm it ran`;
|
|
262
|
+
}
|
|
263
|
+
else if (v.code !== 0 || tally.failed > 0) {
|
|
264
|
+
// A real failure is checked BEFORE the vacuity conditions below: a run
|
|
265
|
+
// where everything failed also has zero passes, and reporting that as
|
|
266
|
+
// "ran no assertions" would point the author at their spec instead of at
|
|
267
|
+
// the model's code, which is the actual news.
|
|
268
|
+
problem = `post_test ${JSON.stringify(postTest)} failed (exit ${v.code})`;
|
|
269
|
+
}
|
|
270
|
+
else if (tally.skipped > 0 || tally.todo > 0) {
|
|
271
|
+
problem = `post_test ${JSON.stringify(postTest)} has ${tally.skipped + tally.todo} skipped/todo test(s) — a hidden gate must actually run; spec error, not model behavior`;
|
|
272
|
+
}
|
|
273
|
+
else if (tally.passed === 0) {
|
|
274
|
+
problem = `post_test ${JSON.stringify(postTest)} ran no assertions — spec error, not model behavior`;
|
|
275
|
+
}
|
|
276
|
+
parts.push(problem === null
|
|
277
|
+
? ` post_test ${JSON.stringify(postTest)}: PASS (${tally.passed} assertion-bearing test(s))`
|
|
278
|
+
: ` post_test ${JSON.stringify(postTest)}: ${v.code === 0 && !killed ? "ERROR" : "FAIL"} (${problem})`);
|
|
279
|
+
parts.push(indent(bothStreams(v)));
|
|
280
|
+
if (problem && !gateFailure)
|
|
281
|
+
gateFailure = problem;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return finish(parts, gateFailure, diff);
|
|
39
285
|
}
|
|
40
286
|
function git(cwd, args) {
|
|
41
287
|
return exec("git", args, { cwd, timeoutMs: 30_000 });
|
|
@@ -43,4 +289,57 @@ function git(cwd, args) {
|
|
|
43
289
|
function indent(s) {
|
|
44
290
|
return s.split("\n").map((l) => ` ${l}`).join("\n");
|
|
45
291
|
}
|
|
292
|
+
/** True only for a regular file we can stat. Never throws — a directory, a dangling symlink or EACCES all read as "not usable". */
|
|
293
|
+
function isReadableFile(p) {
|
|
294
|
+
try {
|
|
295
|
+
return statSync(p).isFile();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Both streams, labelled.
|
|
303
|
+
*
|
|
304
|
+
* The previous `stdout.trim() || stderr.trim()` dropped stderr entirely whenever
|
|
305
|
+
* stdout was non-empty — which it always is once vitest prints its banner. That
|
|
306
|
+
* discarded exactly the diagnostics worth keeping, including exec's own
|
|
307
|
+
* `[skill-harness] killed after …ms timeout` notice, leaving a transcript that
|
|
308
|
+
* showed partial test output and an unexplained failure.
|
|
309
|
+
*/
|
|
310
|
+
function bothStreams(v) {
|
|
311
|
+
const o = v.stdout.trim();
|
|
312
|
+
const e = v.stderr.trim();
|
|
313
|
+
if (o && e)
|
|
314
|
+
return `${o}\n[stderr]\n${e}`;
|
|
315
|
+
return o || e;
|
|
316
|
+
}
|
|
317
|
+
/** Append the staged diff and return the outcome. Every exit path goes through here, so the judge always sees the same sections in the same order. */
|
|
318
|
+
function finish(parts, gateFailure, diff) {
|
|
319
|
+
// The code itself, last — the gates above only prove that keywords appeared.
|
|
320
|
+
// Without this section a seeded checklist item about what the code *does* is
|
|
321
|
+
// graded from the model's own description of its work.
|
|
322
|
+
parts.push("", "=== STAGED DIFF ===");
|
|
323
|
+
parts.push(diff.trim() === "" ? " (empty — the model left no staged changes)" : capDiff(diff));
|
|
324
|
+
return { transcript: parts.join("\n"), gateFailure, diff };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Parse vitest's `Tests N passed | M skipped (T)` summary line.
|
|
328
|
+
*
|
|
329
|
+
* Used to require positive evidence that a hidden `post_test` actually executed
|
|
330
|
+
* assertions, rather than trusting a zero exit code — which vitest also returns
|
|
331
|
+
* when every test in the file is skipped. Returns null when no summary line is
|
|
332
|
+
* present, which the caller treats as "cannot confirm it ran" rather than as a
|
|
333
|
+
* pass.
|
|
334
|
+
*/
|
|
335
|
+
export function vitestTally(out) {
|
|
336
|
+
const line = /^\s*Tests\s+(.+)$/m.exec(out);
|
|
337
|
+
if (!line)
|
|
338
|
+
return null;
|
|
339
|
+
const read = (word) => {
|
|
340
|
+
const m = new RegExp(`(\\d+)\\s+${word}`).exec(line[1]);
|
|
341
|
+
return m ? Number(m[1]) : 0;
|
|
342
|
+
};
|
|
343
|
+
return { passed: read("passed"), failed: read("failed"), skipped: read("skipped"), todo: read("todo") };
|
|
344
|
+
}
|
|
46
345
|
//# sourceMappingURL=seeded.js.map
|