opencode-harness-kit 0.4.3 → 0.4.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +527 -18
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +307 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ // measure-prune.mjs - how much context the pruner actually removes, on real
3
+ // work.
4
+ //
5
+ // The install-wide comparison in measure-install.mjs is an OBSERVATION: it
6
+ // compares the work before the kit landed with the work after, and the work
7
+ // is never the same. It reported the context per request going UP by 17% on
8
+ // one install, with twice as many reads and twice as many messages in the
9
+ // second window - a number about the jobs, not about the kit.
10
+ //
11
+ // This one is attributable. opencode stores the message parts UNPRUNED: the
12
+ // pruner rewrites the outgoing request, not the history. So the stored parts
13
+ // of a real session are exactly what the pruner was given, and replaying the
14
+ // real pruner over them measures exactly what it removed - on the same
15
+ // messages, with and without, on the work that actually happened.
16
+ //
17
+ // node eval/measure-prune.mjs the last 20 sessions
18
+ // node eval/measure-prune.mjs --sessions 50
19
+ // node eval/measure-prune.mjs --min-saved 20 gate: at least 20% removed
20
+ //
21
+ // The gate is on the CONTROLLED number. The install-wide one stays an
22
+ // observation.
23
+
24
+ import { existsSync, mkdtempSync, rmSync } from "node:fs"
25
+ import { homedir, tmpdir } from "node:os"
26
+ import { dirname, join } from "node:path"
27
+ import { fileURLToPath } from "node:url"
28
+
29
+ import { canQuerySqlite, sqliteMissingMessage, query as queryDb } from "../src/sqlite.js"
30
+ import { countFlag, numberFlag } from "./flags.mjs"
31
+
32
+ /**
33
+ * The message of a caught value, whatever it turned out to be.
34
+ *
35
+ * `catch` gives `unknown` under strict mode, and the file is not strict -
36
+ * but a message is what these scripts exist to print.
37
+ *
38
+ * @param {unknown} err
39
+ * @returns {string}
40
+ */
41
+ function errorMessage(err) {
42
+ return err instanceof Error ? err.message : String(err)
43
+ }
44
+
45
+ const HERE = dirname(fileURLToPath(import.meta.url))
46
+ const ROOT = join(HERE, "..")
47
+ const DIST = join(ROOT, "dist", "index.js")
48
+
49
+ const args = process.argv.slice(2)
50
+ const flag = (name) => {
51
+ const i = args.indexOf(name)
52
+ if (i >= 0) return args[i + 1]
53
+ const inline = args.find((a) => a.startsWith(`${name}=`))
54
+ return inline === undefined ? undefined : inline.slice(name.length + 1)
55
+ }
56
+ const wantJson = args.includes("--json")
57
+
58
+ const DB = flag("--db") ?? join(homedir(), ".local/share/opencode/opencode.db")
59
+ // A mistyped number is an error, not a disabled gate. `--sessions abc`
60
+ // became NaN and was interpolated straight into the SQL, where it became a
61
+ // column name.
62
+ let SESSIONS
63
+ let DAYS
64
+ let MIN_MESSAGES
65
+ let minSaved
66
+ try {
67
+ SESSIONS = countFlag("--sessions") ?? 20
68
+ DAYS = countFlag("--days") ?? 30
69
+ MIN_MESSAGES = countFlag("--min-messages") ?? 6
70
+ // A gate of 0% is a valid gate, so this one is a plain number.
71
+ minSaved = numberFlag("--min-saved")
72
+ } catch (err) {
73
+ console.error(String(errorMessage(err)))
74
+ process.exit(1)
75
+ }
76
+ // The population: sessions in a time window, with enough assistant messages
77
+ // for the pruner to have anything to prune.
78
+ //
79
+ // The pruner leaves the last `keepTurns + 1` messages alone, so a session
80
+ // with five messages has nothing older than that and the pruner correctly
81
+ // does nothing. Measuring the last N sessions therefore measures whatever ran
82
+ // last - a test suite, a batch of probes - rather than the work.
83
+ //
84
+ // A session needs more than `keepTurns + 1` assistant messages to be
85
+ // prunable at all, so the default is six.
86
+
87
+
88
+
89
+ if (!existsSync(DB)) {
90
+ console.error(`opencode database not found: ${DB}`)
91
+ console.error("pass --db <path> or run opencode at least once first")
92
+ process.exit(1)
93
+ }
94
+ if (!canQuerySqlite()) {
95
+ console.error(sqliteMissingMessage())
96
+ process.exit(1)
97
+ }
98
+ if (!existsSync(DIST)) {
99
+ console.error("dist/index.js is missing. Run: npm run build")
100
+ process.exit(1)
101
+ }
102
+
103
+ // The real pruner, out of the real bundle. A measurement of a re-implementation
104
+ // is a measurement of the re-implementation.
105
+ const { HarnessPlugin } = await import(DIST)
106
+
107
+ /**
108
+ * The messages of a session, in the shape the pruner sees them.
109
+ * @param {string} sessionId
110
+ */
111
+ function sessionMessages(sessionId) {
112
+ const msgs = queryDb(
113
+ DB,
114
+ `select id, time_created, data from message where session_id = '${sessionId.replace(/'/g, "''")}' order by time_created asc`,
115
+ )
116
+ const out = []
117
+ for (const m of msgs) {
118
+ let info
119
+ try {
120
+ info = JSON.parse(m.data)
121
+ } catch {
122
+ continue
123
+ }
124
+ // EVERY message, not only the assistant ones. The pruner computes its
125
+ // floor over the whole list - `messages.length - 1 - keepTurns` - so a
126
+ // replay that drops the user messages measures a different thing, and a
127
+ // lower bound rather than the real effect.
128
+ const parts = queryDb(
129
+ DB,
130
+ `select data from part where message_id = '${String(m.id).replace(/'/g, "''")}' order by time_created asc`,
131
+ )
132
+ const parsed = []
133
+ for (const p of parts) {
134
+ try {
135
+ parsed.push(JSON.parse(p.data))
136
+ } catch {
137
+ // A part that does not parse is a part the pruner cannot touch.
138
+ }
139
+ }
140
+ if (!parsed.length) continue
141
+ out.push({ info, parts: parsed })
142
+ }
143
+ return out
144
+ }
145
+
146
+ // The sessions to measure: in the window, with enough messages, newest first.
147
+ const sessions = queryDb(
148
+ DB,
149
+ `select s.id as id, count(m.id) as msgs
150
+ from session s
151
+ join message m on m.session_id = s.id
152
+ where s.time_created >= ${Date.now() - DAYS * 864e5}
153
+ and json_extract(m.data,'$.role') = 'assistant'
154
+ group by s.id
155
+ having msgs >= ${MIN_MESSAGES}
156
+ order by s.time_created desc
157
+ limit ${Math.floor(SESSIONS)}`,
158
+ )
159
+
160
+ // The pruner has to be ACTIVE for the measurement, whatever the machine's
161
+ // local state says. It finds its profile from `.space/active` in the project,
162
+ // and that file is gitignored - so on a fresh clone there is none, the pruner
163
+ // does nothing, and the gate reports 0% removed.
164
+ //
165
+ // The environment variable is the documented override, and it is set for the
166
+ // measurement only.
167
+ const PREV = process.env.OPENCODE_HARNESS_PROFILE
168
+ process.env.OPENCODE_HARNESS_PROFILE = PREV ?? "fleet-safe"
169
+
170
+ // The measurement must not write into the repository it is measuring: the
171
+ // pruner logs every pass to `.space/prune.jsonl` in the project, and that is
172
+ // the same file the kit's own runtime telemetry uses. A scratch directory
173
+ // keeps the measurement a measurement.
174
+ const scratch = mkdtempSync(join(tmpdir(), "ock-prune-measure-"))
175
+ const hook = await HarnessPlugin(
176
+ {
177
+ client: { app: { get: async () => ({}), log: { on: async () => {} } } },
178
+ project: { id: "measure", worktree: scratch, vcs: "git" },
179
+ directory: scratch,
180
+ worktree: scratch,
181
+ serverUrl: new URL("http://127.0.0.1:0"),
182
+ $: Object.assign(() => ({ pipe: () => ({}) }), { env: () => process.env }),
183
+ },
184
+ { only: ["prune"] },
185
+ )
186
+ const transform = hook["experimental.chat.messages.transform"]
187
+
188
+ /**
189
+ * The size the PRUNER measures.
190
+ *
191
+ * The pruner counts CHARACTERS, not bytes: `part.text.length`. The report
192
+ * labelled that "bytes", which is wrong for anything that is not ASCII - and
193
+ * an agent's output is not ASCII. Both numbers are reported.
194
+ */
195
+ function sizeOf(part) {
196
+ if (typeof part?.text === "string") return part.text.length
197
+ if (typeof part?.state?.output === "string") return part.state.output.length
198
+ return 0
199
+ }
200
+
201
+ /** The same text, in bytes. */
202
+ function byteSizeOf(part) {
203
+ if (typeof part?.text === "string") return Buffer.byteLength(part.text, "utf8")
204
+ if (typeof part?.state?.output === "string") {
205
+ return Buffer.byteLength(part.state.output, "utf8")
206
+ }
207
+ return 0
208
+ }
209
+
210
+ let totalBefore = 0
211
+ let totalAfter = 0
212
+ let bytesBefore = 0
213
+ let bytesAfter = 0
214
+ let measured = 0
215
+ let stubbed = 0
216
+ const perSession = []
217
+
218
+ for (const s of sessions) {
219
+ const messages = sessionMessages(s.id)
220
+ if (!messages.length) continue
221
+ const before = messages.reduce(
222
+ (n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
223
+ 0,
224
+ )
225
+ const beforeBytes = messages.reduce(
226
+ (n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
227
+ 0,
228
+ )
229
+ // The pruner mutates in place, so the copy has to be the real thing.
230
+ const reasoningBefore = messages.flatMap((m) =>
231
+ m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
232
+ )
233
+ const copy = messages.map((m) => ({
234
+ info: m.info,
235
+ parts: m.parts.map((p) => (p.state ? { ...p, state: { ...p.state } } : { ...p })),
236
+ }))
237
+ await transform({}, { messages: copy })
238
+ const after = copy.reduce(
239
+ (n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
240
+ 0,
241
+ )
242
+ if (!before) continue
243
+ totalBefore += before
244
+ totalAfter += after
245
+ bytesBefore += beforeBytes
246
+ bytesAfter += copy.reduce(
247
+ (n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
248
+ 0,
249
+ )
250
+ measured++
251
+ // The pruner marks TOOL parts with `metadata.pruned` and REPLACES reasoning
252
+ // parts, so counting only the marked ones undercounts what was stubbed.
253
+ const toolCut = copy
254
+ .flatMap((m) => m.parts)
255
+ .filter((p) => p.state?.metadata?.pruned).length
256
+ const reasoningAfter = copy.flatMap((m) =>
257
+ m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
258
+ )
259
+ const reasoningCut = reasoningBefore.filter(
260
+ (t, i) => reasoningAfter[i] !== t,
261
+ ).length
262
+ const cut = toolCut + reasoningCut
263
+ stubbed += cut
264
+ perSession.push({
265
+ session: s.id,
266
+ bytes_before: before,
267
+ bytes_after: after,
268
+ saved_pct: Math.round((1 - after / before) * 1000) / 10,
269
+ parts_stubbed: cut,
270
+ tool_parts_stubbed: toolCut,
271
+ reasoning_parts_stubbed: reasoningCut,
272
+ })
273
+ }
274
+
275
+ const savedPct =
276
+ totalBefore > 0 ? Math.round((1 - totalAfter / totalBefore) * 1000) / 10 : null
277
+
278
+ const failures = []
279
+ if (minSaved !== null) {
280
+ if (measured === 0) {
281
+ failures.push("no session had assistant messages to measure")
282
+ } else if (savedPct === null || savedPct < minSaved) {
283
+ failures.push(`the pruner removed ${savedPct ?? "n/a"}% of the context, expected at least ${minSaved}%`)
284
+ }
285
+ }
286
+
287
+ // The measurement's own scratch directory, and the environment it borrowed.
288
+ process.on("exit", () => {
289
+ rmSync(scratch, { recursive: true, force: true })
290
+ if (PREV === undefined) delete process.env.OPENCODE_HARNESS_PROFILE
291
+ else process.env.OPENCODE_HARNESS_PROFILE = PREV
292
+ })
293
+
294
+ const report = {
295
+ db: DB,
296
+ sessions_measured: measured,
297
+ characters_before: totalBefore,
298
+ characters_after: totalAfter,
299
+ bytes_before: bytesBefore,
300
+ bytes_after: bytesAfter,
301
+ saved_pct: savedPct,
302
+ parts_stubbed: stubbed,
303
+ per_session: perSession,
304
+ verdict: failures.length ? "FAIL" : "PASS",
305
+ failures,
306
+ }
307
+
308
+ if (wantJson) {
309
+ console.log(JSON.stringify(report, null, 2))
310
+ process.exit(failures.length ? 1 : 0)
311
+ }
312
+
313
+ console.log("PRUNER EFFECT ON REAL SESSIONS")
314
+ console.log(` db ${DB}`)
315
+ console.log(
316
+ ` sessions ${measured} (last ${DAYS}d, at least ${MIN_MESSAGES} assistant messages each)`,
317
+ )
318
+ console.log("")
319
+ console.log(` context before ${totalBefore.toLocaleString("en-US")} characters`)
320
+ console.log(` context after ${totalAfter.toLocaleString("en-US")} characters`)
321
+ console.log(` in bytes ${bytesBefore.toLocaleString("en-US")} -> ${bytesAfter.toLocaleString("en-US")}`)
322
+ console.log(` removed ${savedPct === null ? "n/a" : savedPct + "%"}`)
323
+ console.log(` parts stubbed ${stubbed}`)
324
+ if (perSession.length) {
325
+ console.log("")
326
+ const worst = [...perSession].sort((a, b) => a.saved_pct - b.saved_pct).slice(0, 5)
327
+ console.log(" the five sessions the pruner helped least:")
328
+ for (const p of worst) {
329
+ console.log(` ${p.saved_pct}% ${p.bytes_before.toLocaleString("en-US")} bytes ${p.session.slice(0, 12)}`)
330
+ }
331
+ }
332
+ if (failures.length) {
333
+ console.log("")
334
+ console.log(` FAIL ${failures.join("; ")}`)
335
+ } else if (minSaved !== null) {
336
+ console.log("")
337
+ console.log(` PASS the pruner removed at least ${minSaved}% of the context.`)
338
+ }
339
+ process.exit(failures.length ? 1 : 0)
@@ -0,0 +1,79 @@
1
+ // metrics.js - the arithmetic behind measure-install.mjs, with no I/O so
2
+ // test/metrics.test.js can pin it without a database.
3
+ //
4
+ // Kept separate from measure-install.mjs on purpose: importing that file runs
5
+ // the whole measurement and calls process.exit, which would make the numbers
6
+ // untestable and the module unusable anywhere else.
7
+
8
+ /** Share of reads that were bounded by offset or limit, as a percentage. */
9
+ export function pct(narrow, total) {
10
+ if (!total) return 0
11
+ return Math.round((narrow / total) * 1000) / 10
12
+ }
13
+
14
+ /** Read calls per search call. Null when there were no searches to divide by. */
15
+ export function readsPerSearch(reads, searches) {
16
+ if (!searches) return null
17
+ return Math.round((reads / searches) * 10) / 10
18
+ }
19
+
20
+ /**
21
+ * Percentage drop in average context per request. Null when there is no
22
+ * baseline, or no measurement on the other side.
23
+ *
24
+ * The other side being NULL means there was no data. Treating that as zero
25
+ * would report a 100% improvement - "context per request: 100% lower" from a
26
+ * window that contained nothing at all. The caller passes null for "no
27
+ * rows", and this returns null rather than a number.
28
+ */
29
+ export function ctxDropPct(before, after) {
30
+ if (!before || after === null || after === undefined) return null
31
+ return Math.round((1 - after / before) * 1000) / 10
32
+ }
33
+
34
+ /** Tokens of accumulated history per assistant turn. Null with no turns to divide by. */
35
+ export function historyPerTurn(history, turns) {
36
+ if (!turns) return null
37
+ return Math.round((history / turns) * 10) / 10
38
+ }
39
+
40
+ /**
41
+ * Wall time between two opencode event timestamps.
42
+ *
43
+ * Those timestamps are milliseconds, and dividing by 100 instead of 1000 made
44
+ * every duration in eval/RESULTS.md ten times too large for one commit - a
45
+ * 4.4 minute run was reported as 44 minutes. The ratios survived because the
46
+ * error is a constant factor, which is exactly why it went unnoticed until the
47
+ * absolute numbers were checked against a wall clock.
48
+ */
49
+ export function elapsedSeconds(fromMs, toMs) {
50
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null
51
+ return Math.round((toMs - fromMs) / 100) / 10
52
+ }
53
+
54
+ /**
55
+ * Median of a list, rounded to one decimal.
56
+ *
57
+ * Not an average. One kit-off run took 262.7s against 42.4s and 7.3s, and a mean
58
+ * over three turns that into an 8x slowdown that never happened. Outliers are
59
+ * the normal case for agent wall time, so every reported figure is a median.
60
+ * Null rather than NaN for an empty list.
61
+ */
62
+ export function median(values) {
63
+ const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b)
64
+ if (!v.length) return null
65
+ const mid = Math.floor(v.length / 2)
66
+ const m = v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2
67
+ return Math.round(m * 10) / 10
68
+ }
69
+
70
+ /**
71
+ * Whether the kit pays for itself: the fixed overhead it adds to every request
72
+ * versus the history it removes per session. Positive means it costs more.
73
+ *
74
+ * Pinned because the whole conclusion in eval/RESULTS.md rests on the sign, and
75
+ * turning `< 0` into `<= 0` would flip the verdict without any visible change.
76
+ */
77
+ export function payback(fixedDelta, historyDelta) {
78
+ return fixedDelta + historyDelta < 0
79
+ }
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ // premises.mjs - check that a suite's premises are true of the subject before
3
+ // anyone spends twenty agent runs finding out.
4
+ //
5
+ // Seven numbers in eval/RESULTS.md were wrong, and every one of them was a
6
+ // grader agreeing with the agent about something that was not there:
7
+ //
8
+ // - a task asked for a total of 100, the profiles summed to 85/80/80/80/80/75,
9
+ // so four agents correctly preserved sums that were never 100 and the
10
+ // grader passed them
11
+ // - a `mustNotContain` check on a string the subject never contained is
12
+ // vacuous: an agent that changes nothing scores a perfect pass
13
+ // - a `mustContain` string already present in the subject means the work was
14
+ // done before the run started
15
+ //
16
+ // A premise that can be checked by machine should never be checked by reading
17
+ // it. This checks every premise in the suite that is mechanically checkable:
18
+ //
19
+ // read task `want` occurs in the subject at least once
20
+ // mutate task mutate.file exists
21
+ // mustNotContain is present in the ORIGINAL file (not vacuous)
22
+ // mustContain / mustContainMin is absent from the original
23
+ //
24
+ // What it cannot check is a premise written in prose -- "each profile has three
25
+ // dimensions". For that the honest answer is the same one the task file already
26
+ // gives: read the code. What this can do is make sure the parts that are
27
+ // checkable are true, so a false premise that is still prose is the only kind
28
+ // that gets through.
29
+ //
30
+ // node eval/premises.mjs --target <dir> --suite eval/tasks.json
31
+ // node eval/premises.mjs --target <dir> --suite eval/tasks.json --json
32
+
33
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
34
+ import { join, relative } from "node:path"
35
+
36
+ const TEXT_FILE = /\.(md|py|ts|tsx|js|json|txt|ya?ml|toml|sh|ini|cfg)$/
37
+
38
+ /**
39
+ * Every text file in the subject, as {rel, lines}.
40
+ * @param {string} root
41
+ * @param {number} [depth]
42
+ * @returns {{rel: string, lines: string[]}[]}
43
+ */
44
+ export function walk(root, depth = 0) {
45
+ if (depth > 8) return []
46
+ /** @type {{rel: string, lines: string[]}[]} */
47
+ const out = []
48
+ let entries
49
+ try {
50
+ entries = readdirSync(root, { withFileTypes: true })
51
+ } catch {
52
+ return []
53
+ }
54
+ for (const e of entries) {
55
+ if (e.name === ".git" || e.name === "node_modules" || e.name === ".space") continue
56
+ const abs = join(root, e.name)
57
+ if (e.isDirectory()) {
58
+ out.push(...walk(abs, depth + 1))
59
+ continue
60
+ }
61
+ if (!TEXT_FILE.test(e.name)) continue
62
+ try {
63
+ out.push({ rel: relative(root, abs), lines: readFileSync(abs, "utf8").split("\n") })
64
+ } catch {
65
+ /* unreadable file is not a premise failure */
66
+ }
67
+ }
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * Every place `needle` occurs, as file:line.
73
+ * @param {{rel: string, lines: string[]}[]} files
74
+ * @param {string} needle
75
+ */
76
+ export function findAll(files, needle) {
77
+ const hits = []
78
+ for (const f of files) {
79
+ for (let i = 0; i < f.lines.length; i++) {
80
+ if (f.lines[i].includes(needle)) hits.push(`${f.rel}:${i + 1}`)
81
+ }
82
+ }
83
+ return hits
84
+ }
85
+
86
+ /**
87
+ * @param {string} target subject root
88
+ * @param {{tasks?: any[], snapshot?: string}} suite
89
+ * @returns {{ok: boolean, fatal: {id: string, problem: string}[], warnings: {id: string, problem: string}[]}}
90
+ */
91
+ export function checkPremises(target, suite) {
92
+ /** @type {{id: string, problem: string}[]} */
93
+ const fatal = []
94
+ /** @type {{id: string, problem: string}[]} */
95
+ const warnings = []
96
+ const files = walk(target)
97
+ if (!files.length) {
98
+ return { ok: false, fatal: [{ id: "(subject)", problem: `no text files found under ${target}` }], warnings }
99
+ }
100
+
101
+ for (const t of suite.tasks ?? []) {
102
+ const id = t.id ?? "(no id)"
103
+
104
+ if (t.mutate) {
105
+ const abs = join(target, t.mutate.file)
106
+ if (!existsSync(abs)) {
107
+ fatal.push({ id, problem: `mutate.file ${t.mutate.file} is not in the subject` })
108
+ continue
109
+ }
110
+ const body = readFileSync(abs, "utf8")
111
+ const count = (s) => body.split(s).length - 1
112
+ for (const s of t.mutate.mustNotContain ?? []) {
113
+ if (count(s) === 0) {
114
+ fatal.push({
115
+ id,
116
+ problem: `mustNotContain ${JSON.stringify(s)} is not in ${t.mutate.file} to begin with, so the check passes no matter what the agent does`,
117
+ })
118
+ }
119
+ }
120
+ for (const s of t.mutate.mustContain ?? []) {
121
+ if (count(s) > 0) {
122
+ fatal.push({
123
+ id,
124
+ problem: `mustContain ${JSON.stringify(s)} is already in ${t.mutate.file}: the task is already done before the run`,
125
+ })
126
+ }
127
+ }
128
+ for (const [s, n] of Object.entries(t.mutate.mustContainMin ?? {})) {
129
+ if (count(s) > 0) {
130
+ fatal.push({
131
+ id,
132
+ problem: `mustContainMin ${JSON.stringify(s)} already appears ${count(s)} time(s) in ${t.mutate.file}`,
133
+ })
134
+ }
135
+ if (typeof n !== "number" || n < 1 || !Number.isInteger(n)) {
136
+ fatal.push({ id, problem: `mustContainMin ${JSON.stringify(s)} needs a whole number of at least 1` })
137
+ }
138
+ }
139
+ continue
140
+ }
141
+
142
+ const hits = findAll(files, String(t.want ?? ""))
143
+ if (hits.length === 0) {
144
+ fatal.push({
145
+ id,
146
+ problem: `want ${JSON.stringify(t.want)} occurs nowhere in the subject, so no answer can be graded correct`,
147
+ })
148
+ continue
149
+ }
150
+ const where = hits.map((h) => h.split(":")[0])
151
+ const spread = new Set(where)
152
+ if (spread.size > 1) {
153
+ // Not fatal: read-multi-out is exactly this shape, and being able to name
154
+ // which of several files answers the question is the work being measured.
155
+ warnings.push({ id, problem: `want occurs in ${spread.size} files: ${[...spread].join(", ")}` })
156
+ }
157
+ if (hits.length > 1 && spread.size === 1) {
158
+ warnings.push({ id, problem: `want occurs ${hits.length} times in ${[...spread][0]}` })
159
+ }
160
+ }
161
+ return { ok: fatal.length === 0, fatal, warnings }
162
+ }
163
+
164
+ // --------------------------------------------------------------------------
165
+
166
+ if (import.meta.url === `file://${process.argv[1]}`) {
167
+ const args = process.argv.slice(2)
168
+ const flag = (...n) => {
169
+ const i = args.findIndex((a) => n.includes(a))
170
+ return i >= 0 ? args[i + 1] : undefined
171
+ }
172
+ const target = flag("--target")
173
+ const suitePath = flag("--suite")
174
+ if (!target || !suitePath) {
175
+ console.error("usage: node eval/premises.mjs --target <dir> --suite <file> [--json]")
176
+ process.exit(1)
177
+ }
178
+ const suite = JSON.parse(readFileSync(suitePath, "utf8"))
179
+ const report = checkPremises(target, suite)
180
+ if (args.includes("--json")) {
181
+ console.log(JSON.stringify(report, null, 2))
182
+ process.exit(report.ok ? 0 : 1)
183
+ }
184
+ const label = suite.snapshot ?? target
185
+ console.log(`premises for ${suitePath} against ${label}`)
186
+ console.log("")
187
+ if (report.warnings.length) {
188
+ console.log(` ${report.warnings.length} warning(s):`)
189
+ for (const w of report.warnings) console.log(` ${w.id}: ${w.problem}`)
190
+ console.log("")
191
+ }
192
+ if (report.fatal.length) {
193
+ console.log(` ${report.fatal.length} FATAL. The suite would score runs against a subject that does not say what it claims:`)
194
+ for (const f of report.fatal) console.log(` ${f.id}: ${f.problem}`)
195
+ console.log("")
196
+ console.log(" Fix the task, or fix the subject. Do not run the suite against this.")
197
+ process.exit(1)
198
+ }
199
+ console.log(` all premises hold against ${label}`)
200
+ }