opencode-harness-kit 0.4.3 → 0.4.5

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 (74) hide show
  1. package/CHANGELOG.md +569 -18
  2. package/CONTRIBUTING.md +249 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +354 -66
  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 +1268 -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 +83 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +20 -5
  29. package/docs/ROUTING.md +191 -31
  30. package/eval/RESULTS.md +457 -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 +331 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/models.mjs +302 -0
  38. package/eval/premises.mjs +200 -0
  39. package/eval/prepare.mjs +233 -0
  40. package/eval/run.mjs +376 -0
  41. package/eval/tasks-self.json +81 -0
  42. package/eval/tasks-ts.json +50 -0
  43. package/eval/tasks.json +83 -0
  44. package/eval/thresholds.mjs +134 -0
  45. package/eval/verify.mjs +287 -0
  46. package/package.json +44 -17
  47. package/profiles/fleet-all.json +6 -1
  48. package/profiles/fleet-safe.json +7 -3
  49. package/profiles/schema.json +182 -51
  50. package/prompts/build.md +4 -2
  51. package/skills/space-build/SKILL.md +4 -2
  52. package/skills/space-finish/SKILL.md +6 -2
  53. package/skills/space-plan/reference/task-template.md +13 -5
  54. package/src/config.js +182 -32
  55. package/src/denylist.js +95 -0
  56. package/src/driver/run.mjs +81 -16
  57. package/src/effort.js +15 -4
  58. package/src/guard.ts +165 -13
  59. package/src/health.js +80 -0
  60. package/src/index.ts +15 -4
  61. package/src/lane-usage.js +72 -18
  62. package/src/plugin-type-check.ts +38 -0
  63. package/src/plugin.ts +39 -0
  64. package/src/profile.js +225 -26
  65. package/src/project.js +47 -0
  66. package/src/prune.ts +110 -31
  67. package/src/router.ts +32 -63
  68. package/src/rules.js +99 -7
  69. package/src/safe.ts +27 -0
  70. package/src/sqlite.js +144 -0
  71. package/src/tasks.js +112 -29
  72. package/src/telemetry-store.ts +62 -0
  73. package/src/telemetry.ts +60 -57
  74. package/src/trust.js +70 -9
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ // models.mjs - measure each model on the live tier.
3
+ //
4
+ // The routing table in docs/ROUTING.md says how fast each lane's model is,
5
+ // and a number in a table that nobody can reproduce is a number nobody can
6
+ // check. This is the script that produced the current one.
7
+ //
8
+ // node eval/models.mjs the table, for the models the profiles use
9
+ // node eval/models.mjs --reps 3 more samples per model
10
+ // node eval/models.mjs --effort also measure the `low` effort variant
11
+ // node eval/models.mjs --json machine-readable
12
+ // node eval/models.mjs --model <id> one model
13
+ //
14
+ // What it measures:
15
+ //
16
+ // wall ms the whole `opencode run`, so it includes the process start
17
+ // and the round trip. A lane is a round trip, so this is the
18
+ // number a person waits for.
19
+ // reasoning the tokens the model spent thinking. A model that reasons
20
+ // for 400 tokens on a question that needs none is a model
21
+ // being asked the wrong way.
22
+ //
23
+ // The prompt is one line and the same for every model, so the comparison is
24
+ // between models and not between prompts. It is not a benchmark of the
25
+ // models: it is a measurement of what a lane would wait for.
26
+
27
+ import { execFileSync, spawnSync } from "node:child_process"
28
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
29
+ import { tmpdir } from "node:os"
30
+ import { join } from "node:path"
31
+
32
+ import { median } from "./metrics.js"
33
+ import { countFlag, stringFlag } from "./flags.mjs"
34
+
35
+ const args = process.argv.slice(2)
36
+ const wantJson = args.includes("--json")
37
+ // The shared flag parser, so `--reps=5` means what `--reps 5` means and a
38
+ // mistyped value is an error rather than a measurement of nothing.
39
+ let reps
40
+ let only
41
+ try {
42
+ reps = countFlag("--reps", args) ?? 3
43
+ only = stringFlag("--model", args)
44
+ } catch (err) {
45
+ console.error(err instanceof Error ? err.message : String(err))
46
+ process.exit(1)
47
+ }
48
+ const withEffort = args.includes("--effort")
49
+
50
+ const PROMPT = "Reply with exactly the word OK and nothing else."
51
+
52
+ // The models the shipped profiles use. The catalogue turns over in days, so
53
+ // this is read from the profiles rather than written down here: a model that
54
+ // has been withdrawn is reported as gone instead of measured.
55
+ async function modelsInUse() {
56
+ const { listProfileIds, loadProfile, modelsInPlay } = await import("../src/profile.js")
57
+ const seen = new Map()
58
+ for (const id of listProfileIds()) {
59
+ let p
60
+ try {
61
+ p = loadProfile(id)
62
+ } catch {
63
+ continue
64
+ }
65
+ for (const m of modelsInPlay(p) ?? []) {
66
+ // The provider is part of the model id as opencode knows it.
67
+ const full = m.includes("/") ? m : `${p.provider}/${m}`
68
+ if (!seen.has(full)) seen.set(full, [])
69
+ seen.get(full).push(id)
70
+ }
71
+ }
72
+ return [...seen.entries()].map(([model, profiles]) => ({ model, profiles }))
73
+ }
74
+
75
+ /**
76
+ * One request to one model.
77
+ *
78
+ * @param {string} model
79
+ * @param {string | null} variant
80
+ * @returns {{ ok: boolean, ms: number, reasoning: number, output: number, input: number, error: string | null }}
81
+ */
82
+ function measure(model, variant) {
83
+ const dir = mkdtempSync(join(tmpdir(), "ock-model-"))
84
+ const argv = ["run", "--format", "json", "--model", model, "--dir", dir]
85
+ if (variant) argv.push("--variant", variant)
86
+ argv.push(PROMPT)
87
+ const t0 = Date.now()
88
+ let res
89
+ try {
90
+ res = spawnSync("opencode", argv, {
91
+ cwd: dir,
92
+ encoding: "utf8",
93
+ maxBuffer: 64 * 1024 * 1024,
94
+ timeout: 180_000,
95
+ killSignal: "SIGKILL",
96
+ })
97
+ } finally {
98
+ // The directory holds a whole opencode project. A run that is killed
99
+ // leaves it behind, and a scratch directory that is never cleaned up is
100
+ // a scratch directory nobody can find again.
101
+ try {
102
+ rmSync(dir, { recursive: true, force: true })
103
+ } catch {}
104
+ }
105
+ const ms = Date.now() - t0
106
+ const out = res.stdout ?? ""
107
+ let reasoning = 0
108
+ let output = 0
109
+ let input = 0
110
+ let finished = false
111
+ for (const line of out.split("\n")) {
112
+ if (!line.trim().startsWith("{")) continue
113
+ let ev
114
+ try {
115
+ ev = JSON.parse(line)
116
+ } catch {
117
+ continue
118
+ }
119
+ if (ev.type !== "step_finish") continue
120
+ finished = true
121
+ const t = ev.part?.tokens ?? {}
122
+ reasoning += Number(t.reasoning ?? 0)
123
+ output += Number(t.output ?? 0)
124
+ input += Number(t.input ?? 0) + Number(t.cache?.read ?? 0)
125
+ }
126
+ if (!finished) {
127
+ // The reason, in the order it is useful: the spawn failed (opencode is
128
+ // not installed), the process was killed (the ceiling), and only then
129
+ // whatever the provider said. "no reply" on its own says nothing about
130
+ // which of the three it was.
131
+ const reason = res.error
132
+ ? `could not run opencode: ${res.error.message}`
133
+ : res.signal
134
+ ? `killed after the ${Math.round(ms / 1000)}s ceiling (${res.signal})`
135
+ : ((res.stderr ?? "").split("\n").find((l) => l.trim()) ?? "no reply")
136
+ return { ok: false, ms, reasoning, output, input, error: reason }
137
+ }
138
+ return { ok: true, ms, reasoning, output, input, error: null }
139
+ }
140
+
141
+ /**
142
+ * The effort levels each model publishes, from the live catalogue.
143
+ *
144
+ * A model that publishes no `low` level cannot be asked for one: the
145
+ * request is ignored and the model runs at its natural rate. Measuring that
146
+ * and putting it in a column called "wall @low" would say a model is
147
+ * faster at a tier it does not have.
148
+ *
149
+ * @returns {Promise<Map<string, string[]>>}
150
+ */
151
+ async function publishedEffort() {
152
+ const out = new Map()
153
+ const url = process.env.OPENCODE_HARNESS_CATALOG_URL ?? "https://models.dev/api.json"
154
+ try {
155
+ const body = url.startsWith("file://")
156
+ ? readFileSync(url.slice("file://".length), "utf8")
157
+ : execFileSync("curl", ["-sL", "--fail", "--max-time", "40", url], {
158
+ encoding: "utf8",
159
+ // The catalogue is over a megabyte, and the default buffer is one
160
+ // megabyte: without this the fetch fails with ENOBUFS, the catch
161
+ // swallows it, and every model looks like it publishes no effort
162
+ // level at all.
163
+ maxBuffer: 64 * 1024 * 1024,
164
+ })
165
+ const catalog = JSON.parse(body)
166
+ for (const [provider, p] of Object.entries(catalog ?? {})) {
167
+ for (const [id, m] of Object.entries(p?.models ?? {})) {
168
+ const levels = []
169
+ for (const ro of m?.reasoning_options ?? []) {
170
+ if (ro?.type === "effort") levels.push(...(ro?.values ?? []))
171
+ }
172
+ out.set(`${provider}/${id}`, levels)
173
+ }
174
+ }
175
+ } catch (err) {
176
+ // A catalogue that cannot be read is not a reason to refuse to measure.
177
+ // The levels are used to mark a column, not to decide anything - but the
178
+ // reason is printed, because a silent failure here marks every model as
179
+ // publishing no effort level, which is a false statement about them.
180
+ console.error(
181
+ ` could not read the catalogue: ${err instanceof Error ? err.message : String(err)}`,
182
+ )
183
+ console.error(" the effort levels are unknown, so the @low column will read n/a.")
184
+ }
185
+ return out
186
+ }
187
+
188
+ const effort = await publishedEffort()
189
+
190
+ const rows = []
191
+ for (const { model, profiles } of await modelsInUse()) {
192
+ // The docs name a model by its bare id, so both spellings work.
193
+ if (only && model !== only && model !== `opencode/${only}` && only !== model.split("/").pop()) continue
194
+ const samples = []
195
+ for (let i = 0; i < reps; i++) {
196
+ let r = measure(model, null)
197
+ // One retry. The free tier drops requests, and a model that failed once
198
+ // is not a model that is gone - reporting it as one would hide the
199
+ // difference between "withdrawn" and "the provider was busy".
200
+ if (!r.ok) r = measure(model, null)
201
+ if (!r.ok) {
202
+ samples.length = 0
203
+ rows.push({ model, profiles, ok: false, error: r.error })
204
+ break
205
+ }
206
+ samples.push(r)
207
+ }
208
+ if (!samples.length) continue
209
+ const ms = samples.map((s) => s.ms)
210
+ // The `low` variant is only measured for a model that PUBLISHES `low`.
211
+ // A model with no effort levels runs at its natural rate whatever is
212
+ // asked for, and a column that calls that "@low" is a lie.
213
+ const levels = effort.get(model) ?? []
214
+ const publishesLow = levels.includes("low")
215
+ const low = []
216
+ if (withEffort && publishesLow) {
217
+ for (let i = 0; i < reps; i++) {
218
+ const r = measure(model, "low")
219
+ if (r.ok) low.push(r)
220
+ }
221
+ }
222
+ rows.push({
223
+ model,
224
+ profiles,
225
+ ok: true,
226
+ requests: samples.length,
227
+ avg_ms: Math.round(ms.reduce((a, b) => a + b, 0) / ms.length),
228
+ median_ms: Math.round(median(ms)),
229
+ max_ms: Math.max(...ms),
230
+ reasoning: Math.round(median(samples.map((s) => s.reasoning))),
231
+ output: Math.round(median(samples.map((s) => s.output))),
232
+ input: Math.round(median(samples.map((s) => s.input))),
233
+ effort_levels: levels,
234
+ publishes_low: publishesLow,
235
+ low_requests: low.length,
236
+ low_reasoning: low.length ? Math.round(median(low.map((r) => r.reasoning))) : null,
237
+ low_avg_ms: low.length ? Math.round(low.reduce((a, r) => a + r.ms, 0) / low.length) : null,
238
+ })
239
+ process.stderr.write(
240
+ ` measured ${model} (${samples.length} request(s), median ${rows.at(-1).median_ms} ms)\n`,
241
+ )
242
+ }
243
+
244
+ const failed = rows.filter((r) => !r.ok).length
245
+ if (wantJson) {
246
+ console.log(
247
+ JSON.stringify(
248
+ { when: new Date().toISOString(), reps, prompt: PROMPT, ok: failed === 0, failed, rows },
249
+ null,
250
+ 2,
251
+ ),
252
+ )
253
+ process.exit(failed === 0 ? 0 : 1)
254
+ }
255
+
256
+ console.log("")
257
+ console.log(`MEASURED ON THE LIVE TIER ${new Date().toISOString().slice(0, 10)}`)
258
+ console.log(` ${reps} request(s) per model, one-line prompt, whole-process wall time`)
259
+ console.log("")
260
+ const head = ["model", "reqs", "avg ms", "median", "max", "reasoning", "output"]
261
+ console.log(" " + head.map((h, i) => (i === 0 ? h.padEnd(30) : h.padStart(10))).join(""))
262
+ console.log(" " + "-".repeat(30 + 10 * (head.length - 1)))
263
+ for (const r of rows) {
264
+ if (!r.ok) {
265
+ console.log(" " + r.model.padEnd(30) + ` FAILED: ${String(r.error).slice(0, 70)}`)
266
+ continue
267
+ }
268
+ console.log(
269
+ " " +
270
+ r.model.padEnd(30) +
271
+ String(r.requests).padStart(10) +
272
+ String(r.avg_ms).padStart(10) +
273
+ String(r.median_ms).padStart(10) +
274
+ String(r.max_ms).padStart(10) +
275
+ String(r.reasoning).padStart(10) +
276
+ String(r.output).padStart(10),
277
+ )
278
+ if (r.publishes_low) {
279
+ console.log(
280
+ " " +
281
+ `${r.model} @low`.padEnd(30) +
282
+ String(r.low_requests).padStart(10) +
283
+ String(r.low_avg_ms).padStart(10) +
284
+ "".padStart(10) +
285
+ "".padStart(10) +
286
+ String(r.low_reasoning).padStart(10),
287
+ )
288
+ } else if (withEffort) {
289
+ console.log(
290
+ " " +
291
+ `${r.model} @low`.padEnd(30) +
292
+ "n/a".padStart(10) +
293
+ " (publishes no low level - it runs at its natural rate)",
294
+ )
295
+ }
296
+ }
297
+ console.log("")
298
+
299
+ if (failed) {
300
+ console.error(` ${failed} model(s) could not be measured - the numbers above are not the whole tier.`)
301
+ process.exit(1)
302
+ }
@@ -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
+ }