tickmarkr 1.68.0 → 1.70.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/adapters/kimi.d.ts +25 -1
- package/dist/adapters/kimi.js +80 -0
- package/dist/adapters/types.d.ts +6 -0
- package/dist/cli/commands/eval.d.ts +4 -0
- package/dist/cli/commands/eval.js +26 -0
- package/dist/cli/commands/report.js +40 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/eval/canary.d.ts +46 -0
- package/dist/eval/canary.js +113 -0
- package/dist/eval/dispatch.d.ts +31 -0
- package/dist/eval/dispatch.js +207 -0
- package/dist/eval/fixtures.d.ts +22 -0
- package/dist/eval/fixtures.js +85 -0
- package/dist/eval/report.d.ts +35 -0
- package/dist/eval/report.js +82 -0
- package/dist/eval/selfcheck.d.ts +22 -0
- package/dist/eval/selfcheck.js +177 -0
- package/dist/gates/acceptance.d.ts +5 -1
- package/dist/gates/acceptance.js +72 -10
- package/dist/gates/review.d.ts +10 -2
- package/dist/gates/review.js +72 -19
- package/dist/report/bundle.d.ts +45 -0
- package/dist/report/bundle.js +158 -0
- package/dist/report/compare.d.ts +64 -0
- package/dist/report/compare.js +231 -0
- package/dist/run/daemon.js +152 -103
- package/dist/run/environment.d.ts +12 -0
- package/dist/run/environment.js +41 -0
- package/dist/run/interactive-seed.d.ts +15 -0
- package/dist/run/interactive-seed.js +25 -0
- package/fixtures/eval/canary/solution/a.txt +1 -0
- package/fixtures/eval/canary/spec.md +8 -0
- package/fixtures/eval/canary/start/a.txt +1 -0
- package/fixtures/eval/sample/solution/a.txt +1 -0
- package/fixtures/eval/sample/spec.md +8 -0
- package/fixtures/eval/sample/start/a.txt +1 -0
- package/fixtures/gsd-sample/07-live-check/07-01-PLAN.md +42 -0
- package/fixtures/gsd-sample/07-live-check/07-02-PLAN.md +21 -0
- package/fixtures/gsd-sample/07-live-check/07-03-PLAN.md +18 -0
- package/fixtures/gsd-sample/07-live-check/07-03-SUMMARY.md +1 -0
- package/fixtures/missing-mandatory-gate.native.md +10 -0
- package/fixtures/sample-pin.prd.md +18 -0
- package/fixtures/sample.native.md +35 -0
- package/fixtures/sample.prd.md +22 -0
- package/fixtures/speckit-sample/tasks.md +20 -0
- package/package.json +3 -2
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { channelKey, modelAuthed } from "../adapters/types.js";
|
|
6
|
+
import { buildTaskPrompt } from "../adapters/prompt.js";
|
|
7
|
+
import { compileSource } from "../compile/index.js";
|
|
8
|
+
import { detectGateCommands } from "../gates/baseline.js";
|
|
9
|
+
import { testFiltered } from "../gates/acceptance.js";
|
|
10
|
+
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
11
|
+
import { sh } from "../run/git.js";
|
|
12
|
+
import { seedFixture } from "./fixtures.js";
|
|
13
|
+
function isDeterministic(item) {
|
|
14
|
+
return typeof item === "object" && (item.oracle === "command" || item.oracle === "test");
|
|
15
|
+
}
|
|
16
|
+
// Mirrors the vitest/jest summary parser in src/gates/acceptance.ts so the selfcheck uses the same
|
|
17
|
+
// fail-closed rule for named-test oracles: exit 0 is vacuous if the name filter matched zero tests.
|
|
18
|
+
function testsRan(output) {
|
|
19
|
+
const lines = output.replace(/\x1b\[[\d;#]*[A-Za-z]/g, "").split("\n");
|
|
20
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
21
|
+
const line = lines[i].trim();
|
|
22
|
+
const m = line.match(/^Tests\s+(.+?)\s*\(\d+\)\s*$/);
|
|
23
|
+
if (!m)
|
|
24
|
+
continue;
|
|
25
|
+
let ran = 0;
|
|
26
|
+
for (const chunk of m[1].split("|").map((s) => s.trim())) {
|
|
27
|
+
const n = chunk.match(/^(\d+)\s+(passed|failed)\b/);
|
|
28
|
+
if (n)
|
|
29
|
+
ran += Number(n[1]);
|
|
30
|
+
}
|
|
31
|
+
return ran;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function tail(out, n = 8) {
|
|
36
|
+
const t = out.trim();
|
|
37
|
+
if (!t)
|
|
38
|
+
return "";
|
|
39
|
+
return "\n" + t.split("\n").slice(-n).join("\n");
|
|
40
|
+
}
|
|
41
|
+
async function runDeterministicAcceptance(task, cwd, cfg) {
|
|
42
|
+
const testCmd = detectGateCommands(cwd, cfg).test;
|
|
43
|
+
const passed = [];
|
|
44
|
+
for (const item of task.acceptance) {
|
|
45
|
+
if (!isDeterministic(item))
|
|
46
|
+
continue;
|
|
47
|
+
if (item.oracle === "command") {
|
|
48
|
+
const r = await sh(item.command, cwd);
|
|
49
|
+
if (r.code !== 0) {
|
|
50
|
+
return {
|
|
51
|
+
pass: false,
|
|
52
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} (exit ${r.code})${tail(r.stderr || r.stdout)}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
passed.push(`✓ ${renderAcceptanceItem(item)} (exit 0)`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (!testCmd) {
|
|
59
|
+
return {
|
|
60
|
+
pass: false,
|
|
61
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} — no test command detected (failing closed)`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const r = await sh(testFiltered(testCmd, item.test), cwd);
|
|
65
|
+
const out = (r.stderr || "") + "\n" + (r.stdout || "");
|
|
66
|
+
if (r.code !== 0) {
|
|
67
|
+
return {
|
|
68
|
+
pass: false,
|
|
69
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} (exit ${r.code})${tail(r.stderr || r.stdout)}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const ran = testsRan(out);
|
|
73
|
+
if (ran === null || ran < 1) {
|
|
74
|
+
return {
|
|
75
|
+
pass: false,
|
|
76
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} — name filter matched zero tests (filter: ${item.test})${tail(out)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
passed.push(`✓ ${renderAcceptanceItem(item)} (exit 0)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
pass: true,
|
|
84
|
+
details: passed.length ? passed.join("\n") : "no deterministic acceptance oracles",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function findSpec(fixtureDir) {
|
|
88
|
+
const direct = join(fixtureDir, "spec.md");
|
|
89
|
+
if (existsSync(direct))
|
|
90
|
+
return direct;
|
|
91
|
+
for (const ent of readdirSyncSafe(fixtureDir)) {
|
|
92
|
+
if (!ent.isFile())
|
|
93
|
+
continue;
|
|
94
|
+
const { name } = ent;
|
|
95
|
+
if (name.endsWith(".native.md") ||
|
|
96
|
+
name.endsWith(".prd.md") ||
|
|
97
|
+
name.endsWith(".spec.md") ||
|
|
98
|
+
name === "tasks.md") {
|
|
99
|
+
return join(fixtureDir, name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
function readdirSyncSafe(dir) {
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function compileFixtureTask(fixture) {
|
|
113
|
+
const specPath = findSpec(fixture.path);
|
|
114
|
+
if (!specPath)
|
|
115
|
+
return { task: undefined, error: "fixture has no spec" };
|
|
116
|
+
try {
|
|
117
|
+
const graph = compileSource(specPath);
|
|
118
|
+
if (graph.tasks.length !== 1) {
|
|
119
|
+
return {
|
|
120
|
+
task: undefined,
|
|
121
|
+
error: `fixture spec must contain exactly one task (found ${graph.tasks.length})`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const task = graph.tasks[0];
|
|
125
|
+
const runnable = task.acceptance.some(isDeterministic);
|
|
126
|
+
if (!runnable) {
|
|
127
|
+
return { task: undefined, error: "fixture has no deterministic acceptance oracle" };
|
|
128
|
+
}
|
|
129
|
+
return { task };
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
return { task: undefined, error: `fixture spec failed to compile: ${message}` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function skipReason(channel, adapters, health) {
|
|
137
|
+
const adapter = adapters.find((a) => a.id === channel.adapter);
|
|
138
|
+
if (!adapter)
|
|
139
|
+
return `unknown adapter ${channel.adapter}`;
|
|
140
|
+
const h = health[channel.adapter];
|
|
141
|
+
if (!h?.installed)
|
|
142
|
+
return `adapter ${channel.adapter} is not installed`;
|
|
143
|
+
if (!h?.authed)
|
|
144
|
+
return `adapter ${channel.adapter} is not authenticated`;
|
|
145
|
+
if (!modelAuthed(h, channel.model)) {
|
|
146
|
+
const v = h.modelAuth?.[channel.model];
|
|
147
|
+
if (v?.authed === false)
|
|
148
|
+
return `${channel.adapter}:${channel.model} is not authenticated (${v.reason ?? "probe failed"})`;
|
|
149
|
+
return `${channel.adapter}:${channel.model} has no auth verdict — run tickmarkr doctor`;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
async function dispatchChannel(task, channel, adapter, promptFile, nonce, fixture, cfg) {
|
|
154
|
+
const seeded = await seedFixture(fixture);
|
|
155
|
+
const assignment = {
|
|
156
|
+
adapter: channel.adapter,
|
|
157
|
+
model: channel.model,
|
|
158
|
+
channel: channel.channel,
|
|
159
|
+
tier: channel.tier,
|
|
160
|
+
};
|
|
161
|
+
const invocation = adapter.invoke(task, seeded.repo, assignment, { promptFile });
|
|
162
|
+
const run = await sh(invocation.command, seeded.repo, cfg.taskTimeoutMinutes * 60_000);
|
|
163
|
+
const output = `${run.stdout}\n${run.stderr}`.trim();
|
|
164
|
+
const worker = adapter.parse(output, nonce);
|
|
165
|
+
const acceptance = await runDeterministicAcceptance(task, seeded.repo, cfg);
|
|
166
|
+
return { repo: seeded.repo, worker, acceptance, seeded };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Render one prompt for a fixture and dispatch that identical, unmodified prompt to every channel
|
|
170
|
+
* under test. Each channel runs inside its own isolated seeded repository, and the fixture's own
|
|
171
|
+
* deterministic acceptance oracles are evaluated against each channel's resulting diff independently.
|
|
172
|
+
* Channels that fail install or model authentication are skipped with a recorded reason rather than
|
|
173
|
+
* dispatched. The dispatch path reuses the existing adapter `invoke` and `parse` contract.
|
|
174
|
+
*/
|
|
175
|
+
export async function dispatchFixture(opts) {
|
|
176
|
+
const { task, error } = compileFixtureTask(opts.fixture);
|
|
177
|
+
if (error || !task) {
|
|
178
|
+
throw new Error(error ?? "fixture task could not be compiled");
|
|
179
|
+
}
|
|
180
|
+
const nonce = randomBytes(8).toString("hex");
|
|
181
|
+
const promptDir = mkdtempSync(join(tmpdir(), "tickmarkr-eval-prompt-"));
|
|
182
|
+
const promptFile = join(promptDir, "prompt.md");
|
|
183
|
+
writeFileSync(promptFile, buildTaskPrompt(task, "", nonce));
|
|
184
|
+
const results = [];
|
|
185
|
+
try {
|
|
186
|
+
for (const channel of opts.channels) {
|
|
187
|
+
const key = channelKey(channel);
|
|
188
|
+
const reason = skipReason(channel, opts.adapters, opts.health);
|
|
189
|
+
if (reason) {
|
|
190
|
+
results.push({ channel, channelKey: key, skipped: true, skipReason: reason });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const adapter = opts.adapters.find((a) => a.id === channel.adapter);
|
|
194
|
+
const { seeded, ...rest } = await dispatchChannel(task, channel, adapter, promptFile, nonce, opts.fixture, opts.cfg);
|
|
195
|
+
try {
|
|
196
|
+
results.push({ channel, channelKey: key, skipped: false, repo: rest.repo, worker: rest.worker, acceptance: rest.acceptance });
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await seeded.cleanup();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
rmSync(promptDir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface Fixture {
|
|
2
|
+
id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
startDir: string;
|
|
5
|
+
solutionDir: string;
|
|
6
|
+
}
|
|
7
|
+
export interface InvalidFixture {
|
|
8
|
+
id: string;
|
|
9
|
+
path: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SeededFixture {
|
|
13
|
+
repo: string;
|
|
14
|
+
cleanup: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare const FIXTURE_REQUIRED_PARTS: readonly ["start", "solution"];
|
|
17
|
+
export declare function discoverFixtures(root: string): {
|
|
18
|
+
valid: Fixture[];
|
|
19
|
+
invalid: InvalidFixture[];
|
|
20
|
+
};
|
|
21
|
+
export declare function seedFixture(fixture: Fixture): Promise<SeededFixture>;
|
|
22
|
+
export declare function resolveFixturesRoot(arg: string | undefined, cwd: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { shGitOk } from "../run/git.js";
|
|
5
|
+
export const FIXTURE_REQUIRED_PARTS = ["start", "solution"];
|
|
6
|
+
function isFixtureDir(dir) {
|
|
7
|
+
for (const part of FIXTURE_REQUIRED_PARTS) {
|
|
8
|
+
const p = join(dir, part);
|
|
9
|
+
if (!existsSync(p) || !lstatSync(p).isDirectory())
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function missingPart(dir) {
|
|
15
|
+
for (const part of FIXTURE_REQUIRED_PARTS) {
|
|
16
|
+
const p = join(dir, part);
|
|
17
|
+
if (!existsSync(p) || !lstatSync(p).isDirectory())
|
|
18
|
+
return part;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function copyDirContents(src, dst) {
|
|
23
|
+
for (const ent of readdirSync(src, { withFileTypes: true })) {
|
|
24
|
+
const from = join(src, ent.name);
|
|
25
|
+
const to = join(dst, ent.name);
|
|
26
|
+
if (ent.isDirectory()) {
|
|
27
|
+
mkdirSync(to, { recursive: true });
|
|
28
|
+
copyDirContents(from, to);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
writeFileSync(to, readFileSync(from));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function discoverFixtures(root) {
|
|
36
|
+
const valid = [];
|
|
37
|
+
const invalid = [];
|
|
38
|
+
if (!existsSync(root) || !lstatSync(root).isDirectory()) {
|
|
39
|
+
return { valid, invalid };
|
|
40
|
+
}
|
|
41
|
+
for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
42
|
+
if (!ent.isDirectory())
|
|
43
|
+
continue;
|
|
44
|
+
const dir = join(root, ent.name);
|
|
45
|
+
if (isFixtureDir(dir)) {
|
|
46
|
+
valid.push({
|
|
47
|
+
id: relative(root, dir),
|
|
48
|
+
path: dir,
|
|
49
|
+
startDir: join(dir, "start"),
|
|
50
|
+
solutionDir: join(dir, "solution"),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const part = missingPart(dir);
|
|
55
|
+
invalid.push({
|
|
56
|
+
id: relative(root, dir),
|
|
57
|
+
path: dir,
|
|
58
|
+
reason: part ? `missing required part: ${part}` : "missing required part",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
valid.sort((a, b) => a.id.localeCompare(b.id));
|
|
63
|
+
invalid.sort((a, b) => a.id.localeCompare(b.id));
|
|
64
|
+
return { valid, invalid };
|
|
65
|
+
}
|
|
66
|
+
export async function seedFixture(fixture) {
|
|
67
|
+
const repo = mkdtempSync(join(tmpdir(), "tickmarkr-eval-"));
|
|
68
|
+
await shGitOk("git init -b main", repo);
|
|
69
|
+
await shGitOk("git config user.email tickmarkr@eval.local", repo);
|
|
70
|
+
await shGitOk("git config user.name tickmarkr-eval", repo);
|
|
71
|
+
// Copy only the starting files; the reference solution must never be seeded.
|
|
72
|
+
copyDirContents(fixture.startDir, repo);
|
|
73
|
+
await shGitOk("git add -A", repo);
|
|
74
|
+
await shGitOk("git commit -m init --no-gpg-sign", repo);
|
|
75
|
+
return {
|
|
76
|
+
repo,
|
|
77
|
+
cleanup: async () => {
|
|
78
|
+
rmSync(repo, { recursive: true, force: true });
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function resolveFixturesRoot(arg, cwd) {
|
|
83
|
+
const root = arg ?? "fixtures/eval";
|
|
84
|
+
return isAbsolute(root) ? root : join(cwd, root);
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BillingChannel, WorkerResult } from "../adapters/types.js";
|
|
2
|
+
import type { AcceptanceRunResult, ChannelResult } from "./dispatch.js";
|
|
3
|
+
import type { Fixture } from "./fixtures.js";
|
|
4
|
+
export interface ReportedResult {
|
|
5
|
+
runAt: string;
|
|
6
|
+
fixture: {
|
|
7
|
+
id: string;
|
|
8
|
+
revision: string;
|
|
9
|
+
};
|
|
10
|
+
channel: BillingChannel & {
|
|
11
|
+
channelKey: string;
|
|
12
|
+
};
|
|
13
|
+
skipped: boolean;
|
|
14
|
+
skipReason?: string;
|
|
15
|
+
worker?: WorkerResult;
|
|
16
|
+
acceptance?: AcceptanceRunResult;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Deterministic content identity for a fixture revision. Hashes every file under
|
|
20
|
+
* the fixture root (start + solution) so any change to the fixture's contents
|
|
21
|
+
* changes the revision digest.
|
|
22
|
+
*/
|
|
23
|
+
export declare function fixtureRevisionHash(fixture: Fixture): string;
|
|
24
|
+
/**
|
|
25
|
+
* Append a single fixture-channel result to the incremental JSON output file.
|
|
26
|
+
* The write is synchronous so the bytes are on disk before the caller moves on
|
|
27
|
+
* to the next channel.
|
|
28
|
+
*/
|
|
29
|
+
export declare function appendChannelResult(path: string, fixture: Fixture, result: ChannelResult, runAt?: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Read every parseable line from an incremental JSON report. Torn or corrupt
|
|
32
|
+
* trailing lines are ignored, so an interrupted run still yields every result
|
|
33
|
+
* that was fully written.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readReport(path: string): ReportedResult[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, relative } from "node:path";
|
|
4
|
+
const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic content identity for a fixture revision. Hashes every file under
|
|
7
|
+
* the fixture root (start + solution) so any change to the fixture's contents
|
|
8
|
+
* changes the revision digest.
|
|
9
|
+
*/
|
|
10
|
+
export function fixtureRevisionHash(fixture) {
|
|
11
|
+
const hash = createHash("sha256");
|
|
12
|
+
function walk(dir) {
|
|
13
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
14
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
15
|
+
for (const ent of entries) {
|
|
16
|
+
if (EXCLUDED_NAMES.has(ent.name))
|
|
17
|
+
continue;
|
|
18
|
+
const full = `${dir}/${ent.name}`;
|
|
19
|
+
const rel = relative(fixture.path, full);
|
|
20
|
+
if (ent.isDirectory()) {
|
|
21
|
+
walk(full);
|
|
22
|
+
}
|
|
23
|
+
else if (ent.isFile()) {
|
|
24
|
+
hash.update(`${rel}\0`);
|
|
25
|
+
hash.update(readFileSync(full));
|
|
26
|
+
hash.update("\0");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(fixture.path)) {
|
|
31
|
+
walk(fixture.path);
|
|
32
|
+
}
|
|
33
|
+
return hash.digest("hex");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Append a single fixture-channel result to the incremental JSON output file.
|
|
37
|
+
* The write is synchronous so the bytes are on disk before the caller moves on
|
|
38
|
+
* to the next channel.
|
|
39
|
+
*/
|
|
40
|
+
export function appendChannelResult(path, fixture, result, runAt) {
|
|
41
|
+
const dir = dirname(path);
|
|
42
|
+
if (!existsSync(dir)) {
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
const row = {
|
|
46
|
+
runAt: runAt ?? new Date().toISOString(),
|
|
47
|
+
fixture: {
|
|
48
|
+
id: fixture.id,
|
|
49
|
+
revision: fixtureRevisionHash(fixture),
|
|
50
|
+
},
|
|
51
|
+
channel: {
|
|
52
|
+
...result.channel,
|
|
53
|
+
channelKey: result.channelKey,
|
|
54
|
+
},
|
|
55
|
+
skipped: result.skipped,
|
|
56
|
+
...(result.skipReason !== undefined ? { skipReason: result.skipReason } : {}),
|
|
57
|
+
...(result.worker !== undefined ? { worker: result.worker } : {}),
|
|
58
|
+
...(result.acceptance !== undefined ? { acceptance: result.acceptance } : {}),
|
|
59
|
+
};
|
|
60
|
+
appendFileSync(path, JSON.stringify(row) + "\n");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read every parseable line from an incremental JSON report. Torn or corrupt
|
|
64
|
+
* trailing lines are ignored, so an interrupted run still yields every result
|
|
65
|
+
* that was fully written.
|
|
66
|
+
*/
|
|
67
|
+
export function readReport(path) {
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
return [];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
72
|
+
if (!line.trim())
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
out.push(JSON.parse(line));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// torn trailing write after an interrupt — ignore; earlier lines are intact
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Fixture } from "./fixtures.js";
|
|
2
|
+
export interface SelfcheckRunResult {
|
|
3
|
+
pass: boolean;
|
|
4
|
+
details: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SelfcheckResult {
|
|
7
|
+
fixtureId: string;
|
|
8
|
+
specPath: string;
|
|
9
|
+
start: SelfcheckRunResult;
|
|
10
|
+
solution: SelfcheckRunResult;
|
|
11
|
+
valid: boolean;
|
|
12
|
+
invalidReason?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run the fixture selfcheck: compile the fixture's own spec and run its deterministic acceptance
|
|
16
|
+
* oracles (command/test) against both the starting tree and the reference solution tree.
|
|
17
|
+
*
|
|
18
|
+
* The fixture is valid only when the starting tree fails the acceptance check and the reference
|
|
19
|
+
* solution tree passes the identical check — mechanizing the starting-state-must-fail principle
|
|
20
|
+
* already applied to shell-runnable oracles.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runSelfcheck(fixture: Fixture): Promise<SelfcheckResult>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DEFAULT_CONFIG } from "../config/config.js";
|
|
4
|
+
import { compileSource } from "../compile/index.js";
|
|
5
|
+
import { detectGateCommands } from "../gates/baseline.js";
|
|
6
|
+
import { testFiltered } from "../gates/acceptance.js";
|
|
7
|
+
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
8
|
+
import { sh } from "../run/git.js";
|
|
9
|
+
function findSpec(fixtureDir) {
|
|
10
|
+
const direct = join(fixtureDir, "spec.md");
|
|
11
|
+
if (existsSync(direct))
|
|
12
|
+
return direct;
|
|
13
|
+
for (const ent of readdirSync(fixtureDir, { withFileTypes: true })) {
|
|
14
|
+
if (!ent.isFile())
|
|
15
|
+
continue;
|
|
16
|
+
const { name } = ent;
|
|
17
|
+
if (name.endsWith(".native.md") ||
|
|
18
|
+
name.endsWith(".prd.md") ||
|
|
19
|
+
name.endsWith(".spec.md") ||
|
|
20
|
+
name === "tasks.md") {
|
|
21
|
+
return join(fixtureDir, name);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function isDeterministic(item) {
|
|
27
|
+
return typeof item === "object" && (item.oracle === "command" || item.oracle === "test");
|
|
28
|
+
}
|
|
29
|
+
// Mirrors the vitest/jest summary parser in src/gates/acceptance.ts so the selfcheck uses the same
|
|
30
|
+
// fail-closed rule for named-test oracles: exit 0 is vacuous if the name filter matched zero tests.
|
|
31
|
+
function testsRan(output) {
|
|
32
|
+
const lines = output.replace(/\x1b\[[\d;#]*[A-Za-z]/g, "").split("\n");
|
|
33
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
34
|
+
const line = lines[i].trim();
|
|
35
|
+
const m = line.match(/^Tests\s+(.+?)\s*\(\d+\)\s*$/);
|
|
36
|
+
if (!m)
|
|
37
|
+
continue;
|
|
38
|
+
let ran = 0;
|
|
39
|
+
for (const chunk of m[1].split("|").map((s) => s.trim())) {
|
|
40
|
+
const n = chunk.match(/^(\d+)\s+(passed|failed)\b/);
|
|
41
|
+
if (n)
|
|
42
|
+
ran += Number(n[1]);
|
|
43
|
+
}
|
|
44
|
+
return ran;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function tail(out, n = 8) {
|
|
49
|
+
const t = out.trim();
|
|
50
|
+
if (!t)
|
|
51
|
+
return "";
|
|
52
|
+
return "\n" + t.split("\n").slice(-n).join("\n");
|
|
53
|
+
}
|
|
54
|
+
async function runDeterministicAcceptance(task, cwd) {
|
|
55
|
+
const testCmd = detectGateCommands(cwd, DEFAULT_CONFIG).test;
|
|
56
|
+
const passed = [];
|
|
57
|
+
for (const item of task.acceptance) {
|
|
58
|
+
if (!isDeterministic(item))
|
|
59
|
+
continue;
|
|
60
|
+
if (item.oracle === "command") {
|
|
61
|
+
const r = await sh(item.command, cwd);
|
|
62
|
+
if (r.code !== 0) {
|
|
63
|
+
return {
|
|
64
|
+
pass: false,
|
|
65
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} (exit ${r.code})${tail(r.stderr || r.stdout)}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
passed.push(`✓ ${renderAcceptanceItem(item)} (exit 0)`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
if (!testCmd) {
|
|
72
|
+
return {
|
|
73
|
+
pass: false,
|
|
74
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} — no test command detected (failing closed)`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const r = await sh(testFiltered(testCmd, item.test), cwd);
|
|
78
|
+
const out = (r.stderr || "") + "\n" + (r.stdout || "");
|
|
79
|
+
if (r.code !== 0) {
|
|
80
|
+
return {
|
|
81
|
+
pass: false,
|
|
82
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} (exit ${r.code})${tail(r.stderr || r.stdout)}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const ran = testsRan(out);
|
|
86
|
+
if (ran === null || ran < 1) {
|
|
87
|
+
return {
|
|
88
|
+
pass: false,
|
|
89
|
+
details: `oracle failed: ${renderAcceptanceItem(item)} — name filter matched zero tests (filter: ${item.test})${tail(out)}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
passed.push(`✓ ${renderAcceptanceItem(item)} (exit 0)`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
pass: true,
|
|
97
|
+
details: passed.length ? passed.join("\n") : "no deterministic acceptance oracles",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Run the fixture selfcheck: compile the fixture's own spec and run its deterministic acceptance
|
|
102
|
+
* oracles (command/test) against both the starting tree and the reference solution tree.
|
|
103
|
+
*
|
|
104
|
+
* The fixture is valid only when the starting tree fails the acceptance check and the reference
|
|
105
|
+
* solution tree passes the identical check — mechanizing the starting-state-must-fail principle
|
|
106
|
+
* already applied to shell-runnable oracles.
|
|
107
|
+
*/
|
|
108
|
+
export async function runSelfcheck(fixture) {
|
|
109
|
+
const specPath = findSpec(fixture.path);
|
|
110
|
+
if (!specPath) {
|
|
111
|
+
return {
|
|
112
|
+
fixtureId: fixture.id,
|
|
113
|
+
specPath: "",
|
|
114
|
+
start: { pass: false, details: "no spec found" },
|
|
115
|
+
solution: { pass: false, details: "no spec found" },
|
|
116
|
+
valid: false,
|
|
117
|
+
invalidReason: "fixture has no spec (expected spec.md or a recognized spec file)",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let task;
|
|
121
|
+
try {
|
|
122
|
+
const graph = compileSource(specPath);
|
|
123
|
+
if (graph.tasks.length !== 1) {
|
|
124
|
+
return {
|
|
125
|
+
fixtureId: fixture.id,
|
|
126
|
+
specPath,
|
|
127
|
+
start: { pass: false, details: "spec has more than one task" },
|
|
128
|
+
solution: { pass: false, details: "spec has more than one task" },
|
|
129
|
+
valid: false,
|
|
130
|
+
invalidReason: `fixture spec must contain exactly one task (found ${graph.tasks.length})`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
task = graph.tasks[0];
|
|
134
|
+
const runnable = task.acceptance.some(isDeterministic);
|
|
135
|
+
if (!runnable) {
|
|
136
|
+
return {
|
|
137
|
+
fixtureId: fixture.id,
|
|
138
|
+
specPath,
|
|
139
|
+
start: { pass: false, details: "no deterministic oracles" },
|
|
140
|
+
solution: { pass: false, details: "no deterministic oracles" },
|
|
141
|
+
valid: false,
|
|
142
|
+
invalidReason: "fixture has no shell-runnable acceptance oracle (command: or test:)",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
148
|
+
return {
|
|
149
|
+
fixtureId: fixture.id,
|
|
150
|
+
specPath,
|
|
151
|
+
start: { pass: false, details: message },
|
|
152
|
+
solution: { pass: false, details: message },
|
|
153
|
+
valid: false,
|
|
154
|
+
invalidReason: `fixture spec failed to compile: ${message}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const start = await runDeterministicAcceptance(task, fixture.startDir);
|
|
158
|
+
const solution = await runDeterministicAcceptance(task, fixture.solutionDir);
|
|
159
|
+
let valid = true;
|
|
160
|
+
let invalidReason;
|
|
161
|
+
if (start.pass) {
|
|
162
|
+
valid = false;
|
|
163
|
+
invalidReason = "starting tree already passes the acceptance check";
|
|
164
|
+
}
|
|
165
|
+
else if (!solution.pass) {
|
|
166
|
+
valid = false;
|
|
167
|
+
invalidReason = "reference tree still fails the acceptance check";
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
fixtureId: fixture.id,
|
|
171
|
+
specPath,
|
|
172
|
+
start,
|
|
173
|
+
solution,
|
|
174
|
+
valid,
|
|
175
|
+
invalidReason,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -2,13 +2,17 @@ import { type WorkerAdapter } from "../adapters/types.js";
|
|
|
2
2
|
import { type Task } from "../graph/schema.js";
|
|
3
3
|
import { type LlmVia } from "./llm.js";
|
|
4
4
|
import type { GateResult } from "./types.js";
|
|
5
|
+
export interface EvidenceCitation {
|
|
6
|
+
path: string;
|
|
7
|
+
line: number;
|
|
8
|
+
}
|
|
5
9
|
export interface JudgeVerdict {
|
|
6
10
|
pass: boolean;
|
|
7
11
|
criteria: Array<{
|
|
8
12
|
criterion: string;
|
|
9
13
|
met: boolean;
|
|
10
14
|
reason: string;
|
|
11
|
-
evidence: string;
|
|
15
|
+
evidence: string | EvidenceCitation;
|
|
12
16
|
}>;
|
|
13
17
|
}
|
|
14
18
|
export declare function judgeCriterionId(index: number): string;
|