@tachikomagundam/abathur 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -18
- package/config/genomes/historian.example.jsonc +9 -4
- package/dist/bench/adapter.js +22 -4
- package/dist/bench/fixture.js +8 -4
- package/dist/bench/toy.js +3 -3
- package/dist/commands/status.js +6 -1
- package/dist/core/evolve/run-bench.js +2 -1
- package/dist/test/bench-adapter.test.js +24 -0
- package/dist/test/bench-fixture.test.js +6 -2
- package/dist/test/bench-reporoot.test.js +197 -0
- package/dist/test/historian-grader-integrity.test.js +438 -0
- package/dist/test/historian-grader-io.test.js +277 -1
- package/dist/test/historian-run-scenario.test.js +205 -0
- package/dist/test/opencode.test.js +21 -0
- package/dist/test/status-repo.test.js +72 -0
- package/graders/historian/grader-core.d.mts +43 -0
- package/graders/historian/grader-core.mjs +215 -5
- package/graders/historian/grader-support.d.mts +11 -0
- package/graders/historian/grader-support.mjs +28 -0
- package/graders/historian/grader.mjs +62 -5
- package/graders/historian/mutate.sh +6 -3
- package/graders/historian/run-scenario.sh +39 -3
- package/graders/historian/seed-wrapped.sh +1 -1
- package/package.json +1 -1
- package/plugin/abathur.ts +47 -7
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (ABATHUR_GRADER_STATE replaces every wiki/URL call; no live network).
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -146,3 +146,279 @@ test("grader CLI: garbage transcript ⇒ nonzero exit, no score line (adapter
|
|
|
146
146
|
assert.notEqual(r.code, 0);
|
|
147
147
|
assert.equal(r.stdout.trim(), "");
|
|
148
148
|
});
|
|
149
|
+
// ------------------------------------------- S4 seam: active-tree scenario resolution
|
|
150
|
+
//
|
|
151
|
+
// post-seam graderCommand renders `{repoRoot}/{unit.path}` (adapter unitVars), so
|
|
152
|
+
// argv[1] arrives as an ABSOLUTE path into the bench's active tree — incumbent
|
|
153
|
+
// repoPath or candidate worktree, same notion as the run side. Resolution mirrors
|
|
154
|
+
// the ABATHUR_GRADER_STATE offline pattern: the file is read only when it exists
|
|
155
|
+
// (offline fixtures pass relative/nonexistent paths, which must keep working), and
|
|
156
|
+
// a readable scenario must agree with the unit id on the scenario number — the
|
|
157
|
+
// pre-seam failure mode was commands baked against the WRONG tree.
|
|
158
|
+
function stateFor(dir) {
|
|
159
|
+
const stateFile = path.join(dir, "state.json");
|
|
160
|
+
writeFileSync(stateFile, JSON.stringify({
|
|
161
|
+
post: POST_OK,
|
|
162
|
+
content: { 400: "[qwen27b](/_sandbox/llm-inference/qwen27b-threading)", 600: GOOD_PAGE },
|
|
163
|
+
urlStatus: {},
|
|
164
|
+
}));
|
|
165
|
+
return stateFile;
|
|
166
|
+
}
|
|
167
|
+
test("grader CLI resolves an existing {repoRoot}-rendered scenario file ⇒ metrics.scenarioFile", async () => {
|
|
168
|
+
const dir = sandboxWith(transcript(GOOD_FINAL, 5678), PRE);
|
|
169
|
+
const repo = mkdtempSync(path.join(tmpdir(), "t14-seamrepo-"));
|
|
170
|
+
mkdirSync(path.join(repo, "scenarios"), { recursive: true });
|
|
171
|
+
const scenarioAbs = path.join(repo, "scenarios", "01-new-finding.md");
|
|
172
|
+
writeFileSync(scenarioAbs, "# Scenario 01\n\n## Brief\ndo it\n", "utf8");
|
|
173
|
+
const r = await runGrader(dir, ["scenario-01", scenarioAbs, "http://localhost:3000"], {
|
|
174
|
+
ABATHUR_GRADER_STATE: stateFor(dir),
|
|
175
|
+
});
|
|
176
|
+
assert.equal(r.code, 0, r.stderr);
|
|
177
|
+
const parsed = JSON.parse(r.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
178
|
+
assert.equal(parsed.metrics.scenarioFile, scenarioAbs, "resolved active-tree scenario path surfaces in metrics");
|
|
179
|
+
});
|
|
180
|
+
test("grader CLI: scenario number disagreeing with the unit id ⇒ nonzero (never a score)", async () => {
|
|
181
|
+
const dir = sandboxWith(transcript(GOOD_FINAL, 5678), PRE);
|
|
182
|
+
const repo = mkdtempSync(path.join(tmpdir(), "t14-seambad-"));
|
|
183
|
+
mkdirSync(path.join(repo, "scenarios"), { recursive: true });
|
|
184
|
+
const wrongAbs = path.join(repo, "scenarios", "99-wrong-unit.md");
|
|
185
|
+
writeFileSync(wrongAbs, "# Scenario 99\n\n## Brief\nnope\n", "utf8");
|
|
186
|
+
const r = await runGrader(dir, ["scenario-01", wrongAbs, "http://localhost:3000"], {
|
|
187
|
+
ABATHUR_GRADER_STATE: stateFor(dir),
|
|
188
|
+
});
|
|
189
|
+
assert.notEqual(r.code, 0, "a mis-baked template must surface as inconclusive, not as a score");
|
|
190
|
+
assert.match(r.stderr, /does not match/);
|
|
191
|
+
});
|
|
192
|
+
test("grader CLI: absent scenario file keeps grading offline (byte-compatible pre-seam fixtures)", async () => {
|
|
193
|
+
const dir = sandboxWith(transcript(GOOD_FINAL, 5678), PRE);
|
|
194
|
+
const r = await runGrader(dir, ["scenario-01", "scenarios/01-new-finding.md", "http://localhost:3000"], {
|
|
195
|
+
ABATHUR_GRADER_STATE: stateFor(dir),
|
|
196
|
+
});
|
|
197
|
+
assert.equal(r.code, 0, r.stderr);
|
|
198
|
+
const parsed = JSON.parse(r.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
199
|
+
assert.equal(parsed.score, 1);
|
|
200
|
+
assert.equal(parsed.metrics.scenarioFile, null);
|
|
201
|
+
});
|
|
202
|
+
// --------------------------------------- S3 integrity units scenario-10/11/12 (G2)
|
|
203
|
+
//
|
|
204
|
+
// Cross-repo fixtures live in the historian bench repo under baseline/fixtures/,
|
|
205
|
+
// captured live from the seed cycle (evidence: historian .omo/evidence/task-05-scenarios.md).
|
|
206
|
+
// The repo is resolved through the SAME env var the campaign genome consumes for
|
|
207
|
+
// repoPath — ABATHUR_HISTORIAN_REPO — with the documented machine default below.
|
|
208
|
+
// Absent fixtures ⇒ every test below skips with a naming message (inconclusive,
|
|
209
|
+
// never a fake green). The expected numbers are the separations the proposal
|
|
210
|
+
// proved offline (validate-offline.mjs 59/59); here they run against the SHIPPED
|
|
211
|
+
// grader entry end-to-end: real argv, real exit codes, real score lines.
|
|
212
|
+
const HISTORIAN_REPO = process.env.ABATHUR_HISTORIAN_REPO ?? "/home/lab/workspace/harness/historian";
|
|
213
|
+
const FIXDIR = path.join(HISTORIAN_REPO, "baseline", "fixtures");
|
|
214
|
+
function integrityFixturesReady() {
|
|
215
|
+
return (existsSync(path.join(FIXDIR, "scenario-10", "state-donothing.json")) &&
|
|
216
|
+
existsSync(path.join(FIXDIR, "scenario-11", "state-fabricate.json")) &&
|
|
217
|
+
existsSync(path.join(FIXDIR, "scenario-12", "seed-state.json")) &&
|
|
218
|
+
existsSync(path.join(HISTORIAN_REPO, "scenarios", "10-status-contradiction-audit.md")));
|
|
219
|
+
}
|
|
220
|
+
const integritySkip = integrityFixturesReady()
|
|
221
|
+
? false
|
|
222
|
+
: `historian fixtures missing under ${FIXDIR} — set ABATHUR_HISTORIAN_REPO (documented default: /home/lab/workspace/harness/historian)`;
|
|
223
|
+
// Separation table from task-05 evidence §(f)3 — every bad shape bites via I/J.
|
|
224
|
+
const INTEGRITY_CASES = [
|
|
225
|
+
{ unit: "scenario-10", scenarioRel: "scenarios/10-status-contradiction-audit.md", shape: "pass", score: 1, pass: true, total: 8, weight: 8, dims: { D: 1, G: 1, H: 1, I: 1, J: 1 } },
|
|
226
|
+
{ unit: "scenario-10", scenarioRel: "scenarios/10-status-contradiction-audit.md", shape: "fail", score: 0.5, pass: false, total: 4, weight: 8, dims: { D: 1, G: 1, H: 1, I: 0, J: 0 } },
|
|
227
|
+
{ unit: "scenario-10", scenarioRel: "scenarios/10-status-contradiction-audit.md", shape: "donothing", score: 0.5, pass: false, total: 4, weight: 8, dims: { D: 1, G: 1, H: 1, I: 0, J: 0 } },
|
|
228
|
+
{ unit: "scenario-11", scenarioRel: "scenarios/11-expired-card-reverify.md", shape: "pass", score: 1, pass: true, total: 8, weight: 8, dims: { D: 1, G: 1, H: 1, I: 1, J: 1 } },
|
|
229
|
+
{ unit: "scenario-11", scenarioRel: "scenarios/11-expired-card-reverify.md", shape: "fail", score: 0.5, pass: false, total: 4, weight: 8, dims: { D: 1, G: 1, H: 1, I: 0, J: 0 } },
|
|
230
|
+
{ unit: "scenario-11", scenarioRel: "scenarios/11-expired-card-reverify.md", shape: "fabricate", score: 0.5, pass: false, total: 4, weight: 8, dims: { D: 1, G: 1, H: 1, I: 0, J: 0 } },
|
|
231
|
+
{ unit: "scenario-11", scenarioRel: "scenarios/11-expired-card-reverify.md", shape: "donothing", score: 0.5, pass: false, total: 4, weight: 8, dims: { D: 1, G: 1, H: 1, I: 0, J: 0 } },
|
|
232
|
+
{ unit: "scenario-12", scenarioRel: "scenarios/12-desc-junk-detection.md", shape: "pass", score: 1, pass: true, total: 6, weight: 6, dims: { G: 1, H: 1, I: 1, J: 1 } },
|
|
233
|
+
{ unit: "scenario-12", scenarioRel: "scenarios/12-desc-junk-detection.md", shape: "fail", score: 2 / 6, pass: false, total: 2, weight: 6, dims: { G: 1, H: 1, I: 0, J: 0 } },
|
|
234
|
+
{ unit: "scenario-12", scenarioRel: "scenarios/12-desc-junk-detection.md", shape: "donothing", score: 2 / 6, pass: false, total: 2, weight: 6, dims: { G: 1, H: 1, I: 0, J: 0 } },
|
|
235
|
+
];
|
|
236
|
+
function integritySandbox(unit, shape) {
|
|
237
|
+
const fix = path.join(FIXDIR, unit);
|
|
238
|
+
const dir = mkdtempSync(path.join(tmpdir(), "g2-int-"));
|
|
239
|
+
mkdirSync(path.join(dir, ".bench", "transcripts"), { recursive: true });
|
|
240
|
+
copyFileSync(path.join(fix, `transcript-${shape}.jsonl`), path.join(dir, ".bench", "transcripts", `${unit}.jsonl`));
|
|
241
|
+
copyFileSync(path.join(fix, "wiki-pre.json"), path.join(dir, ".bench", "wiki-pre.json"));
|
|
242
|
+
copyFileSync(path.join(fix, "seed-state.json"), path.join(dir, ".bench", "seed-state.json"));
|
|
243
|
+
writeFileSync(path.join(dir, "state.json"), readFileSync(path.join(fix, `state-${shape}.json`), "utf8"));
|
|
244
|
+
return dir;
|
|
245
|
+
}
|
|
246
|
+
function parseLine(stdout) {
|
|
247
|
+
return JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}");
|
|
248
|
+
}
|
|
249
|
+
for (const c of INTEGRITY_CASES) {
|
|
250
|
+
test(`grader CLI ${c.unit}/${c.shape}: shipped separation ${c.score} pass=${c.pass}, dims ${JSON.stringify(c.dims)}, n=2 byte-identical`, { skip: integritySkip }, async () => {
|
|
251
|
+
const dir = integritySandbox(c.unit, c.shape);
|
|
252
|
+
const scenarioAbs = path.join(HISTORIAN_REPO, c.scenarioRel);
|
|
253
|
+
const env = { ABATHUR_GRADER_STATE: path.join(dir, "state.json") };
|
|
254
|
+
const r1 = await runGrader(dir, [c.unit, scenarioAbs, "http://localhost:3000"], env);
|
|
255
|
+
const r2 = await runGrader(dir, [c.unit, scenarioAbs, "http://localhost:3000"], env);
|
|
256
|
+
assert.equal(r1.code, 0, r1.stderr);
|
|
257
|
+
assert.equal(r2.stdout, r1.stdout, "same fixture ×2 ⇒ byte-identical score line (scoring-path CI=0)");
|
|
258
|
+
const line = parseLine(r1.stdout);
|
|
259
|
+
assert.equal(line.unit, c.unit);
|
|
260
|
+
assert.equal(line.score, c.score);
|
|
261
|
+
assert.equal(line.pass, c.pass);
|
|
262
|
+
assert.deepEqual(line.metrics.dims, c.dims);
|
|
263
|
+
assert.equal(line.metrics.total, c.total);
|
|
264
|
+
assert.equal(line.metrics.applicableWeight, c.weight);
|
|
265
|
+
assert.equal(line.metrics.scenarioFile, scenarioAbs, "resolved active-tree scenario path surfaces in metrics");
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
test("grader CLI integrity units: every bad shape scores strictly below its PASS control", { skip: integritySkip }, async () => {
|
|
269
|
+
const byUnit = new Map();
|
|
270
|
+
for (const c of INTEGRITY_CASES) {
|
|
271
|
+
if (c.shape !== "pass")
|
|
272
|
+
continue;
|
|
273
|
+
const dir = integritySandbox(c.unit, c.shape);
|
|
274
|
+
const r = await runGrader(dir, [c.unit, path.join(HISTORIAN_REPO, c.scenarioRel), "http://localhost:3000"], {
|
|
275
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
276
|
+
});
|
|
277
|
+
assert.equal(r.code, 0, r.stderr);
|
|
278
|
+
byUnit.set(c.unit, parseLine(r.stdout).score);
|
|
279
|
+
}
|
|
280
|
+
for (const c of INTEGRITY_CASES) {
|
|
281
|
+
if (c.shape === "pass")
|
|
282
|
+
continue;
|
|
283
|
+
const dir = integritySandbox(c.unit, c.shape);
|
|
284
|
+
const r = await runGrader(dir, [c.unit, path.join(HISTORIAN_REPO, c.scenarioRel), "http://localhost:3000"], {
|
|
285
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
286
|
+
});
|
|
287
|
+
assert.equal(r.code, 0, r.stderr);
|
|
288
|
+
const line = parseLine(r.stdout);
|
|
289
|
+
const passScore = byUnit.get(c.unit) ?? Number.NaN;
|
|
290
|
+
assert.ok(line.score < passScore, `${c.unit}/${c.shape}: ${String(line.score)} must be below pass ${String(passScore)}`);
|
|
291
|
+
assert.ok(line.metrics.dims.I === 0 || line.metrics.dims.J === 0, "a dim bites (I=0 or J=0)");
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
test("grader CLI: unit 10 + scenario-09 file ⇒ nonzero 'does not match' (seam guard, never a score)", { skip: integritySkip }, async () => {
|
|
295
|
+
const dir = integritySandbox("scenario-10", "pass");
|
|
296
|
+
const r = await runGrader(dir, ["scenario-10", path.join(HISTORIAN_REPO, "scenarios", "09-timeline-week-groups.md"), "http://localhost:3000"], {
|
|
297
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
298
|
+
});
|
|
299
|
+
assert.notEqual(r.code, 0, "a mis-baked template must surface as inconclusive, not as a score");
|
|
300
|
+
assert.match(r.stderr, /does not match/);
|
|
301
|
+
assert.equal(r.stdout.trim(), "");
|
|
302
|
+
});
|
|
303
|
+
test("grader CLI: garbage transcript on an integrity unit ⇒ nonzero, no score line", { skip: integritySkip }, async () => {
|
|
304
|
+
const dir = integritySandbox("scenario-10", "pass");
|
|
305
|
+
writeFileSync(path.join(dir, ".bench", "transcripts", "scenario-10.jsonl"), "not json\n{broken");
|
|
306
|
+
const r = await runGrader(dir, ["scenario-10", path.join(HISTORIAN_REPO, "scenarios", "10-status-contradiction-audit.md"), "http://localhost:3000"], {
|
|
307
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
308
|
+
});
|
|
309
|
+
assert.notEqual(r.code, 0);
|
|
310
|
+
assert.equal(r.stdout.trim(), "");
|
|
311
|
+
});
|
|
312
|
+
test("grader CLI: integrity unit WITHOUT .bench/seed-state.json ⇒ inconclusive (never vacuous/guessed)", { skip: integritySkip }, async () => {
|
|
313
|
+
const dir = integritySandbox("scenario-10", "pass");
|
|
314
|
+
rmSync(path.join(dir, ".bench", "seed-state.json"));
|
|
315
|
+
const r = await runGrader(dir, ["scenario-10", path.join(HISTORIAN_REPO, "scenarios", "10-status-contradiction-audit.md"), "http://localhost:3000"], {
|
|
316
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
317
|
+
});
|
|
318
|
+
assert.notEqual(r.code, 0, "missing seed capture ⇒ fail-closed inconclusive");
|
|
319
|
+
assert.equal(r.stdout.trim(), "");
|
|
320
|
+
assert.match(r.stderr, /seed-state/);
|
|
321
|
+
});
|
|
322
|
+
/** s10/pass sandbox with the maintain event replaced by an externalization stub.
|
|
323
|
+
* "ref": full pretty JSON lives in the ref file (head cut above every row).
|
|
324
|
+
* "fallback": ref absent, conflict rows visible in the retained head.
|
|
325
|
+
* "dead": ref absent, no rows visible ⇒ fail-closed. */
|
|
326
|
+
function s10ExternalizedSandbox(kind) {
|
|
327
|
+
const dir = integritySandbox("scenario-10", "pass");
|
|
328
|
+
const tp = path.join(dir, ".bench", "transcripts", "scenario-10.jsonl");
|
|
329
|
+
const ref = path.join(dir, "tool_ext");
|
|
330
|
+
const lines = readFileSync(tp, "utf8")
|
|
331
|
+
.split("\n")
|
|
332
|
+
.map((l) => {
|
|
333
|
+
if (l.length === 0)
|
|
334
|
+
return l;
|
|
335
|
+
const doc = JSON.parse(l);
|
|
336
|
+
if (doc.part?.type !== "tool" || doc.part.state?.input?.action !== "maintain" || doc.part.state.output === undefined)
|
|
337
|
+
return l;
|
|
338
|
+
const compact = doc.part.state.output;
|
|
339
|
+
const rowStart = compact.indexOf('{"path":"_sandbox');
|
|
340
|
+
const rowVisible = compact.indexOf('"key":"status-token-conflict"', rowStart) + '"key":"status-token-conflict"'.length;
|
|
341
|
+
const head = kind === "fallback" ? rowVisible : rowStart;
|
|
342
|
+
if (kind === "ref")
|
|
343
|
+
writeFileSync(ref, JSON.stringify(JSON.parse(compact), null, 2));
|
|
344
|
+
doc.part.state.output = [
|
|
345
|
+
compact.slice(0, head),
|
|
346
|
+
"",
|
|
347
|
+
"...2394 lines truncated...",
|
|
348
|
+
"",
|
|
349
|
+
`The tool call succeeded but the output was truncated. Full output saved to: ${ref}`,
|
|
350
|
+
"Use the Task tool to have explore agent process this file with Grep and Read (with offset/limit). " +
|
|
351
|
+
"Do NOT read the full file yourself - delegate to save context.",
|
|
352
|
+
].join("\n");
|
|
353
|
+
return JSON.stringify(doc);
|
|
354
|
+
});
|
|
355
|
+
writeFileSync(tp, lines.join("\n"));
|
|
356
|
+
return dir;
|
|
357
|
+
}
|
|
358
|
+
async function gradeS10(dir) {
|
|
359
|
+
const r = await runGrader(dir, ["scenario-10", path.join(HISTORIAN_REPO, "scenarios", "10-status-contradiction-audit.md"), "http://localhost:3000"], {
|
|
360
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
361
|
+
});
|
|
362
|
+
assert.equal(r.code, 0, r.stderr);
|
|
363
|
+
return parseLine(r.stdout);
|
|
364
|
+
}
|
|
365
|
+
test("grader CLI P1 stub-follow: externalized maintain(deep) resolved via ref file ⇒ pass flips, n=2 byte-identical", { skip: integritySkip }, async () => {
|
|
366
|
+
const dir = s10ExternalizedSandbox("ref");
|
|
367
|
+
const r1 = await runGrader(dir, ["scenario-10", path.join(HISTORIAN_REPO, "scenarios", "10-status-contradiction-audit.md"), "http://localhost:3000"], {
|
|
368
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
369
|
+
});
|
|
370
|
+
const r2 = await runGrader(dir, ["scenario-10", path.join(HISTORIAN_REPO, "scenarios", "10-status-contradiction-audit.md"), "http://localhost:3000"], {
|
|
371
|
+
ABATHUR_GRADER_STATE: path.join(dir, "state.json"),
|
|
372
|
+
});
|
|
373
|
+
assert.equal(r1.code, 0, r1.stderr);
|
|
374
|
+
assert.equal(r2.stdout, r1.stdout, "stub fixture ×2 ⇒ byte-identical score line");
|
|
375
|
+
const line = parseLine(r1.stdout);
|
|
376
|
+
assert.deepEqual(line.metrics.dims, { D: 1, G: 1, H: 1, I: 1, J: 1 });
|
|
377
|
+
assert.deepEqual({ score: line.score, pass: line.pass }, { score: 1, pass: true });
|
|
378
|
+
});
|
|
379
|
+
test("grader CLI P1 fallback: ref unreadable but seeded row visible in the stub head ⇒ rescue, pass", { skip: integritySkip }, async () => {
|
|
380
|
+
const line = await gradeS10(s10ExternalizedSandbox("fallback"));
|
|
381
|
+
assert.equal(line.metrics.dims.I, 1);
|
|
382
|
+
assert.equal(line.pass, true);
|
|
383
|
+
});
|
|
384
|
+
test("grader CLI P1 fail-closed: ref unreadable, no rows visible ⇒ rc0, I=0 + clean I10 note, never a crash", { skip: integritySkip }, async () => {
|
|
385
|
+
const line = await gradeS10(s10ExternalizedSandbox("dead"));
|
|
386
|
+
assert.equal(line.metrics.dims.I, 0);
|
|
387
|
+
assert.equal(line.pass, false);
|
|
388
|
+
assert.ok(line.metrics.notes.some((n) => n.startsWith("I10: maintain(deep) did not report exactly the seeded _sandbox conflict")), JSON.stringify(line.metrics.notes));
|
|
389
|
+
});
|
|
390
|
+
function withOutsideChurn(dir, churnPath) {
|
|
391
|
+
const preFile = path.join(dir, ".bench", "wiki-pre.json");
|
|
392
|
+
const stFile = path.join(dir, "state.json");
|
|
393
|
+
const pre = JSON.parse(readFileSync(preFile, "utf8"));
|
|
394
|
+
const state = JSON.parse(readFileSync(stFile, "utf8"));
|
|
395
|
+
pre.push({ id: 900, path: churnPath, locale: "en", updatedAt: "2026-09-01T00:00:00Z" });
|
|
396
|
+
state.post.push({ id: 900, path: churnPath, locale: "en", updatedAt: "2026-09-13T12:00:00Z" });
|
|
397
|
+
writeFileSync(preFile, JSON.stringify(pre));
|
|
398
|
+
writeFileSync(stFile, JSON.stringify(state));
|
|
399
|
+
}
|
|
400
|
+
test("grader CLI P2: s10 honest run + plugin-owned _meta/page-map churn ⇒ G=1 pass", { skip: integritySkip }, async () => {
|
|
401
|
+
const dir = integritySandbox("scenario-10", "pass");
|
|
402
|
+
withOutsideChurn(dir, "_meta/page-map");
|
|
403
|
+
const line = await gradeS10(dir);
|
|
404
|
+
assert.equal(line.metrics.dims.G, 1);
|
|
405
|
+
assert.deepEqual({ score: line.score, pass: line.pass }, { score: 1, pass: true });
|
|
406
|
+
});
|
|
407
|
+
test("grader CLI P2 guard: s10 + any other out-of-scope churn ⇒ G=0 pass=false (exemption stays exact)", { skip: integritySkip }, async () => {
|
|
408
|
+
const dir = integritySandbox("scenario-10", "pass");
|
|
409
|
+
withOutsideChurn(dir, "notes/outside-the-sandbox");
|
|
410
|
+
const line = await gradeS10(dir);
|
|
411
|
+
assert.equal(line.metrics.dims.G, 0);
|
|
412
|
+
assert.equal(line.pass, false);
|
|
413
|
+
});
|
|
414
|
+
// F1 don't-break-incumbent: the FULL scenario-01 passing CLI line (offline state,
|
|
415
|
+
// absent scenario file ⇒ scenarioFile null) frozen byte-for-byte from the pre-G2
|
|
416
|
+
// tree 4997618 — the adapter-visible contract of incumbent units cannot move.
|
|
417
|
+
test("grader CLI scenario-01: stdout line byte-identical to pre-G2 capture", async () => {
|
|
418
|
+
const dir = sandboxWith(transcript(GOOD_FINAL, 5678), PRE);
|
|
419
|
+
const r = await runGrader(dir, ["scenario-01", "scenarios/01-new-finding.md", "http://localhost:3000"], {
|
|
420
|
+
ABATHUR_GRADER_STATE: stateFor(dir),
|
|
421
|
+
});
|
|
422
|
+
assert.equal(r.code, 0, r.stderr);
|
|
423
|
+
assert.equal(r.stdout, '{"unit":"scenario-01","score":1,"pass":true,"metrics":{"tokensEst":5678,"turns":1,"dims":{"A":1,"B":1,"C":1,"D":1,"E":1,"F":1,"G":1,"H":1},"total":12,"applicableWeight":12,"notes":[],"scenarioFile":null}}\n');
|
|
424
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// S4 engine seam — run-card injection in graders/historian/run-scenario.sh.
|
|
2
|
+
// Everything OFFLINE: a stub `opencode` (argv-recording + transcript-emitting
|
|
3
|
+
// node script) replaces the real agent; zero model calls. Pins (task-6 e2):
|
|
4
|
+
// (1) no abathur-notes dir (or no *.md in it) ⇒ the captured brief is BYTE-
|
|
5
|
+
// IDENTICAL to the bare awk-extracted scenario Brief and carries no
|
|
6
|
+
// `## Run card` heading — the F1 invariant (incumbent transcripts must not
|
|
7
|
+
// grow a run-card section);
|
|
8
|
+
// (2) one note ⇒ the brief ends with "\n\n## Run card\n\n" + verbatim content;
|
|
9
|
+
// (3) determinism: two consecutive runs over the same tree ⇒ byte-identical
|
|
10
|
+
// prompt AND transcript files;
|
|
11
|
+
// (4) stale-state: changing the note between runs changes the brief (old body
|
|
12
|
+
// gone, new body present) and the transcript;
|
|
13
|
+
// (5) PLUM A/B flow: a planted instruction note ⇒ appears in the brief ⇒
|
|
14
|
+
// appears in the stub transcript's finalMessage (note⇒brief⇒transcript);
|
|
15
|
+
// (6) multiple notes concatenate in LC_ALL=C filename order; an explicit 3rd
|
|
16
|
+
// argv overrides the scenario-derived repoRoot.
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
import { promisify } from "node:util";
|
|
25
|
+
import { parseTranscript } from "../../graders/historian/grader-support.mjs";
|
|
26
|
+
const exec = promisify(execFile);
|
|
27
|
+
const SCRIPT = fileURLToPath(new URL("../../graders/historian/run-scenario.sh", import.meta.url));
|
|
28
|
+
/** Recorded scenario-Brief section — the exact bytes run-scenario.sh awk-extracts. */
|
|
29
|
+
const BRIEF_TEXT = "Do the seam thing.";
|
|
30
|
+
const SCENARIO = `# Scenario 01 — seam probe
|
|
31
|
+
|
|
32
|
+
## Brief
|
|
33
|
+
${BRIEF_TEXT}
|
|
34
|
+
|
|
35
|
+
## Expected Behavior
|
|
36
|
+
|
|
37
|
+
n/a for the stub lane.
|
|
38
|
+
`;
|
|
39
|
+
const STUB_OPENCODE = `#!/usr/bin/env node
|
|
40
|
+
// stub opencode: records the --message value to $STUB_PROMPT_FILE and the FULL
|
|
41
|
+
// argv (one token per line) to $STUB_ARGV_FILE, then echoes an opencode-format
|
|
42
|
+
// transcript on stdout (run-scenario.sh redirects it to $ABATHUR_TRANSCRIPT).
|
|
43
|
+
// The finalMessage is the prompt echoed back — so a planted instruction in the
|
|
44
|
+
// brief provably flows into the transcript.
|
|
45
|
+
import { writeFileSync } from "node:fs";
|
|
46
|
+
const args = process.argv.slice(2);
|
|
47
|
+
const i = args.indexOf("--message");
|
|
48
|
+
const message = i >= 0 ? args[i + 1] ?? "" : "";
|
|
49
|
+
const capture = process.env.STUB_PROMPT_FILE;
|
|
50
|
+
if (capture !== undefined && capture.length > 0) writeFileSync(capture, message, "utf8");
|
|
51
|
+
const argvCapture = process.env.STUB_ARGV_FILE;
|
|
52
|
+
if (argvCapture !== undefined && argvCapture.length > 0) writeFileSync(argvCapture, args.join("\\n") + "\\n", "utf8");
|
|
53
|
+
const out = (doc) => process.stdout.write(JSON.stringify(doc) + "\\n");
|
|
54
|
+
out({ type: "step_start", part: { type: "step-start" } });
|
|
55
|
+
out({ type: "step_finish", part: { type: "step-finish", reason: "stop", tokens: { total: 42 } } });
|
|
56
|
+
out({ type: "text", part: { type: "text", text: message } });
|
|
57
|
+
`;
|
|
58
|
+
function harness(t) {
|
|
59
|
+
const root = mkdtempSync(path.join(tmpdir(), "seam-rs-"));
|
|
60
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
61
|
+
const binDir = path.join(root, "bin");
|
|
62
|
+
mkdirSync(binDir, { recursive: true });
|
|
63
|
+
const stub = path.join(binDir, "oc-stub");
|
|
64
|
+
writeFileSync(stub, STUB_OPENCODE, "utf8");
|
|
65
|
+
chmodSync(stub, 0o755);
|
|
66
|
+
const repoRoot = path.join(root, "repo");
|
|
67
|
+
mkdirSync(path.join(repoRoot, "scenarios"), { recursive: true });
|
|
68
|
+
writeFileSync(path.join(repoRoot, "scenarios", "01-seam.md"), SCENARIO, "utf8");
|
|
69
|
+
return {
|
|
70
|
+
repoRoot,
|
|
71
|
+
promptFile: path.join(root, "prompt.txt"),
|
|
72
|
+
transcript: path.join(root, "transcript.jsonl"),
|
|
73
|
+
bin: binDir,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Invokes the shipped contract `bash run-scenario.sh <unit.id> <scenario-file>`. */
|
|
77
|
+
async function runScenario(h, opts = {}) {
|
|
78
|
+
const scenarioFile = opts.scenario ?? path.join(h.repoRoot, "scenarios", "01-seam.md");
|
|
79
|
+
const argv = [SCRIPT, "scenario-01", scenarioFile, ...(opts.args ?? [])];
|
|
80
|
+
await exec("bash", argv, {
|
|
81
|
+
env: {
|
|
82
|
+
...process.env,
|
|
83
|
+
ABATHUR_TRANSCRIPT: h.transcript,
|
|
84
|
+
STUB_PROMPT_FILE: h.promptFile,
|
|
85
|
+
opencodeBin: path.join(h.bin, "oc-stub"),
|
|
86
|
+
...(opts.argvFile === undefined ? {} : { STUB_ARGV_FILE: opts.argvFile }),
|
|
87
|
+
...(opts.env ?? {}),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function prompt(h) {
|
|
92
|
+
return readFileSync(h.promptFile, "utf8");
|
|
93
|
+
}
|
|
94
|
+
function writeNote(h, name, body) {
|
|
95
|
+
const dir = path.join(h.repoRoot, "abathur-notes");
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
writeFileSync(path.join(dir, name), body, "utf8");
|
|
98
|
+
}
|
|
99
|
+
const RUN_CARD_HEADING = "## Run card";
|
|
100
|
+
test("no-notes: captured brief is byte-identical to the bare scenario Brief (F1 invariant)", async (t) => {
|
|
101
|
+
const h = harness(t);
|
|
102
|
+
await runScenario(h);
|
|
103
|
+
assert.equal(prompt(h), BRIEF_TEXT, "brief must carry exactly the awk-extracted Brief bytes");
|
|
104
|
+
assert.ok(!prompt(h).includes(RUN_CARD_HEADING), "no notes ⇒ no run-card section ever");
|
|
105
|
+
});
|
|
106
|
+
test("empty-notes-dir: a notes directory without *.md changes nothing", async (t) => {
|
|
107
|
+
const h = harness(t);
|
|
108
|
+
mkdirSync(path.join(h.repoRoot, "abathur-notes"), { recursive: true });
|
|
109
|
+
writeFileSync(path.join(h.repoRoot, "abathur-notes", "ignore.txt"), "not a card\n", "utf8");
|
|
110
|
+
await runScenario(h);
|
|
111
|
+
assert.equal(prompt(h), BRIEF_TEXT);
|
|
112
|
+
assert.ok(!prompt(h).includes(RUN_CARD_HEADING));
|
|
113
|
+
});
|
|
114
|
+
test("one-note: brief ends with '## Run card' + verbatim note content", async (t) => {
|
|
115
|
+
const h = harness(t);
|
|
116
|
+
const note = "Prefer concise incident pages with a Related Pages tail.\n";
|
|
117
|
+
writeNote(h, "style.md", note);
|
|
118
|
+
await runScenario(h);
|
|
119
|
+
const expected = `${BRIEF_TEXT}\n\n${RUN_CARD_HEADING}\n\n${note.replace(/\n+$/, "")}`;
|
|
120
|
+
assert.equal(prompt(h), expected, "run card is appended fenced under its heading, verbatim");
|
|
121
|
+
});
|
|
122
|
+
test("determinism: two consecutive runs over the same tree ⇒ byte-identical prompt + transcript", async (t) => {
|
|
123
|
+
const h = harness(t);
|
|
124
|
+
writeNote(h, "a.md", "card alpha\n");
|
|
125
|
+
await runScenario(h);
|
|
126
|
+
const firstPrompt = prompt(h);
|
|
127
|
+
const firstTranscript = readFileSync(h.transcript, "utf8");
|
|
128
|
+
await runScenario(h);
|
|
129
|
+
assert.equal(prompt(h), firstPrompt, "prompt bytes must not move between identical runs");
|
|
130
|
+
assert.equal(readFileSync(h.transcript, "utf8"), firstTranscript, "transcript bytes must not move");
|
|
131
|
+
});
|
|
132
|
+
test("stale-state: changing the note between runs changes the brief and the transcript", async (t) => {
|
|
133
|
+
const h = harness(t);
|
|
134
|
+
writeNote(h, "a.md", "alpha card body\n");
|
|
135
|
+
await runScenario(h);
|
|
136
|
+
const before = prompt(h);
|
|
137
|
+
writeNote(h, "a.md", "beta card body\n");
|
|
138
|
+
await runScenario(h);
|
|
139
|
+
const after = prompt(h);
|
|
140
|
+
assert.ok(before.includes("alpha card body") && !after.includes("alpha card body"));
|
|
141
|
+
assert.ok(after.includes("beta card body"));
|
|
142
|
+
assert.notEqual(after, before, "a changed tree must produce a changed brief");
|
|
143
|
+
});
|
|
144
|
+
test("PLUM flow: planted instruction reaches brief ⇒ stub transcript finalMessage", async (t) => {
|
|
145
|
+
const h = harness(t);
|
|
146
|
+
writeNote(h, "plum.md", "Reply with the single word PLUM at the very end.\n");
|
|
147
|
+
await runScenario(h);
|
|
148
|
+
assert.ok(prompt(h).includes("Reply with the single word PLUM at the very end."));
|
|
149
|
+
const meta = parseTranscript(readFileSync(h.transcript, "utf8"));
|
|
150
|
+
assert.match(meta.finalMessage, /PLUM/, "note ⇒ brief ⇒ transcript delivery must be provable");
|
|
151
|
+
});
|
|
152
|
+
test("multi-note: concatenated in LC_ALL=C filename order", async (t) => {
|
|
153
|
+
const h = harness(t);
|
|
154
|
+
writeNote(h, "zeta.md", "zeta body\n");
|
|
155
|
+
writeNote(h, "alpha.md", "alpha body\n");
|
|
156
|
+
await runScenario(h);
|
|
157
|
+
const p = prompt(h);
|
|
158
|
+
assert.equal(p, `${BRIEF_TEXT}\n\n${RUN_CARD_HEADING}\n\nalpha body\n\nzeta body`, "cat order = sorted filenames, contents joined verbatim");
|
|
159
|
+
});
|
|
160
|
+
test("explicit repoRoot argv overrides the scenario-derived one", async (t) => {
|
|
161
|
+
const h = harness(t);
|
|
162
|
+
writeNote(h, "a.md", "incumbent-tree card\n"); // derived from scenario dir: NOT used
|
|
163
|
+
const otherRoot = path.join(path.dirname(h.repoRoot), "elsewhere");
|
|
164
|
+
mkdirSync(path.join(otherRoot, "abathur-notes"), { recursive: true });
|
|
165
|
+
writeFileSync(path.join(otherRoot, "abathur-notes", "a.md"), "elsewhere card\n", "utf8");
|
|
166
|
+
await runScenario(h, { args: [otherRoot] });
|
|
167
|
+
assert.ok(prompt(h).includes("elsewhere card"), "argv repoRoot wins");
|
|
168
|
+
assert.ok(!prompt(h).includes("incumbent-tree card"));
|
|
169
|
+
});
|
|
170
|
+
// Model pinning (task-06 follow-up): the transcript JSONL carries NO model
|
|
171
|
+
// identity and bundle provenance copies spec.bench.agentModel verbatim
|
|
172
|
+
// (bundle-common.ts) — an unpinned `opencode run` measures the provider
|
|
173
|
+
// DEFAULT while the ledger claims the spec value. ABATHUR_AGENT_MODEL is the
|
|
174
|
+
// engine's existing sandboxEnv channel (fixture.ts); the script must honor it
|
|
175
|
+
// with --model, and unset/empty must keep argv byte-identical (F1).
|
|
176
|
+
function argvTokens(file) {
|
|
177
|
+
return readFileSync(file, "utf8").split("\n").slice(0, -1);
|
|
178
|
+
}
|
|
179
|
+
const BASELINE_ARGV = ["run", "--command", "historian", "--auto", "--format", "json", "--message", BRIEF_TEXT];
|
|
180
|
+
test("model pin: ABATHUR_AGENT_MODEL set ⇒ argv carries --model <value>, brief still flows", async (t) => {
|
|
181
|
+
const h = harness(t);
|
|
182
|
+
const argvFile = path.join(path.dirname(h.promptFile), "argv-pinned.txt");
|
|
183
|
+
await runScenario(h, {
|
|
184
|
+
argvFile,
|
|
185
|
+
env: { ABATHUR_AGENT_MODEL: "testprov/testmodel" },
|
|
186
|
+
});
|
|
187
|
+
const tokens = argvTokens(argvFile);
|
|
188
|
+
const i = tokens.indexOf("--model");
|
|
189
|
+
assert.notEqual(i, -1, `--model missing from argv: ${JSON.stringify(tokens)}`);
|
|
190
|
+
assert.equal(tokens[i + 1], "testprov/testmodel");
|
|
191
|
+
assert.equal(tokens.at(-2), "--message", "pin must not disturb the brief channel");
|
|
192
|
+
assert.equal(prompt(h), BRIEF_TEXT);
|
|
193
|
+
});
|
|
194
|
+
test("model pin: ABATHUR_AGENT_MODEL unset ⇒ argv byte-identical to the pre-fix baseline", async (t) => {
|
|
195
|
+
const h = harness(t);
|
|
196
|
+
const argvFile = path.join(path.dirname(h.promptFile), "argv-unset.txt");
|
|
197
|
+
await runScenario(h, { argvFile, env: { ABATHUR_AGENT_MODEL: undefined } });
|
|
198
|
+
assert.deepEqual(argvTokens(argvFile), BASELINE_ARGV);
|
|
199
|
+
});
|
|
200
|
+
test("model pin: ABATHUR_AGENT_MODEL empty string ⇒ treated as unset (same argv)", async (t) => {
|
|
201
|
+
const h = harness(t);
|
|
202
|
+
const argvFile = path.join(path.dirname(h.promptFile), "argv-empty.txt");
|
|
203
|
+
await runScenario(h, { argvFile, env: { ABATHUR_AGENT_MODEL: "" } });
|
|
204
|
+
assert.deepEqual(argvTokens(argvFile), BASELINE_ARGV);
|
|
205
|
+
});
|
|
@@ -241,6 +241,27 @@ test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowli
|
|
|
241
241
|
assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
|
|
242
242
|
assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
|
|
243
243
|
});
|
|
244
|
+
test("plugin/abathur.ts: config hook self-registers /abathur — template byte-mirrors the command md body", async () => {
|
|
245
|
+
const bytes = await readFile(pluginAssetPath, "utf8");
|
|
246
|
+
// Route B needs the config hook: opencode hands plugins the fully-merged config
|
|
247
|
+
// (file commands already inside cfg.command), so a `??=` injection registers
|
|
248
|
+
// /abathur ONLY when no commands/abathur.md exists — Route A bytes/behaviour
|
|
249
|
+
// stay untouched and the name-keyed command map guarantees no duplicate entry.
|
|
250
|
+
assert.ok(/config:\s*async\s*\(cfg:\s*Config\)/.test(bytes), "config hook with typed cfg required");
|
|
251
|
+
assert.ok(bytes.includes('import { tool, type Config }'), "Config type imported from @opencode-ai/plugin");
|
|
252
|
+
assert.ok(/cfg\.command\s*\?\?=\s*\{\}/.test(bytes), "cfg.command must be lazily created with ??=");
|
|
253
|
+
assert.ok(/cfg\.command\.abathur\s*\?\?=/.test(bytes), "entry must be ??= — a file-installed command is never overwritten");
|
|
254
|
+
assert.ok(bytes.includes('description: "Drive the abathur evolution harness (usage: /abathur <command> [args...])"'), "injected description must be pinned verbatim");
|
|
255
|
+
// Extract the template literal (inner backticks appear escaped as \`) and byte-compare.
|
|
256
|
+
const literal = bytes.match(/const COMMAND_TEMPLATE = `((?:[^`\\]|\\.)*)`;/);
|
|
257
|
+
assert.ok(literal !== null, "COMMAND_TEMPLATE literal must exist");
|
|
258
|
+
const template = (literal[1] ?? "").replaceAll("\\`", "`");
|
|
259
|
+
const md = await readFile(commandAssetPath, "utf8");
|
|
260
|
+
assert.ok(md.startsWith(COMMAND_MARKER), "md must still carry its first-line marker");
|
|
261
|
+
const body = md.slice(md.indexOf("\n") + 1); // everything after the marker line
|
|
262
|
+
assert.ok(body.startsWith("Drive the abathur evolution harness"), "body start sanity");
|
|
263
|
+
assert.equal(template, body, "injected template must byte-equal the command md minus its marker line");
|
|
264
|
+
});
|
|
244
265
|
test("plugin/abathur-command.md: first-line marker, $ARGUMENTS, points at the abathur tool", async () => {
|
|
245
266
|
const text = await readFile(commandAssetPath, "utf8");
|
|
246
267
|
assert.equal(text.split("\n")[0], COMMAND_MARKER, "first line must be the command marker");
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// S4 engine seam (deliverable f) — `abathur status` must resolve the stored
|
|
2
|
+
// repoPath through effectiveRepoPath (the run/genome/kernel/self-eval pattern,
|
|
3
|
+
// run-loop.ts:142) before touching ANY filesystem seam. Pre-fix, status passed
|
|
4
|
+
// the STORED UNRESOLVED `${VAR}` literal as the git cwd; Node reports a missing
|
|
5
|
+
// cwd as the misleading `spawn git ENOENT` (task-09 evidence §C). Pins:
|
|
6
|
+
// (1) env-literal genome + env exported ⇒ status exits 0 (real git + ledger
|
|
7
|
+
// seam on the resolved tree — never an ENOENT);
|
|
8
|
+
// (2) env-literal genome + env UNSET ⇒ clean exit 2 NAMING the variable, and
|
|
9
|
+
// the misleading spawn-ENOENT string is gone;
|
|
10
|
+
// (3) concrete repoPath keeps working unchanged (regression guard).
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import { registerGenome } from "../core/genome.js";
|
|
20
|
+
import { prepareToyGenome } from "../bench/toy.js";
|
|
21
|
+
const CLI = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
|
|
22
|
+
const LIT_VAR = "ABATHUR_SEAM_STATUS_REPO";
|
|
23
|
+
async function fixture(t, label) {
|
|
24
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "seam-status-"));
|
|
25
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
26
|
+
const configDir = path.join(root, "config");
|
|
27
|
+
mkdirSync(configDir, { recursive: true });
|
|
28
|
+
writeFileSync(path.join(configDir, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
|
|
29
|
+
const repo = await prepareToyGenome(path.join(root, "genome"));
|
|
30
|
+
// Register under the exported env (add resolves the same way run does),
|
|
31
|
+
// storing the spec with the UNRESOLVED literal (0.2.x convention).
|
|
32
|
+
const doc = JSON.parse(readFileSync(path.join(repo, "genome.jsonc"), "utf8"));
|
|
33
|
+
doc["label"] = label;
|
|
34
|
+
doc["repoPath"] = `\${${LIT_VAR}}`;
|
|
35
|
+
const specFile = path.join(root, "private.jsonc");
|
|
36
|
+
writeFileSync(specFile, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
37
|
+
process.env[LIT_VAR] = repo;
|
|
38
|
+
try {
|
|
39
|
+
registerGenome(configDir, specFile);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
delete process.env[LIT_VAR];
|
|
43
|
+
}
|
|
44
|
+
return { root, configDir, repo };
|
|
45
|
+
}
|
|
46
|
+
function cli(f, label, env) {
|
|
47
|
+
return spawnSync(process.execPath, [CLI, "status", label], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
cwd: f.root,
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
ABATHUR_CONFIG: path.join(f.configDir, "config.jsonc"),
|
|
53
|
+
HOME: path.join(f.root, "home"),
|
|
54
|
+
XDG_CACHE_HOME: path.join(f.root, "xdg-cache"),
|
|
55
|
+
...env,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
test("status resolves an env-literal repoPath via effectiveRepoPath: exported ⇒ exit 0", async (t) => {
|
|
60
|
+
const f = await fixture(t, "seam-self");
|
|
61
|
+
const r = cli(f, "seam-self", { [LIT_VAR]: f.repo });
|
|
62
|
+
assert.equal(r.status, 0, `stdout: ${r.stdout}\nstderr: ${r.stderr}`);
|
|
63
|
+
assert.match(r.stdout, /genome: seam-self/);
|
|
64
|
+
assert.ok(!r.stderr.includes("ENOENT"), "the misleading spawn-ENOENT string must be gone");
|
|
65
|
+
});
|
|
66
|
+
test("status on an env-literal repoPath with the env UNSET: clean exit 2 naming the variable", async (t) => {
|
|
67
|
+
const f = await fixture(t, "seam-unset");
|
|
68
|
+
const r = cli(f, "seam-unset", { [LIT_VAR]: undefined });
|
|
69
|
+
assert.equal(r.status, 2);
|
|
70
|
+
assert.match(r.stderr, new RegExp(`requires env var ${LIT_VAR}`));
|
|
71
|
+
assert.ok(!r.stderr.includes("ENOENT"), `misleading ENOENT returned: ${r.stderr}`);
|
|
72
|
+
});
|