@mikeargento/bitgraph-player 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,358 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * Tests for the init scaffolder and the play() one-call pipeline.
5
+ *
6
+ * Same discipline as the rest of the suite: synthetic AuditResult
7
+ * fixtures, no ledger writes, no network. The CLI wiring tests spawn the
8
+ * built cli.js against temp files created under os.tmpdir().
9
+ */
10
+
11
+ import { strict as assert } from "node:assert";
12
+ import { spawnSync } from "node:child_process";
13
+ import { createHash } from "node:crypto";
14
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { test } from "node:test";
19
+ import { ORDERING_PLACEHOLDER, roleNameForFile, scaffoldRule } from "../init.js";
20
+ import { play, playAudit, PlayError } from "../play.js";
21
+ import { parseRule, RuleError } from "../rule.js";
22
+ import { serializeVerdict } from "../verdict.js";
23
+ import { digestFor, makeAudit } from "./fixtures.js";
24
+
25
+ const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
26
+
27
+ function hexOf(text: string): string {
28
+ return createHash("sha256").update(text).digest("hex");
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // roleNameForFile
33
+ // ---------------------------------------------------------------------------
34
+
35
+ test("role names: extension dropped, path stripped, disallowed chars folded", () => {
36
+ assert.equal(roleNameForFile("purchase_order.pdf"), "purchase_order");
37
+ assert.equal(roleNameForFile("/some/dir/delivery note.jpg"), "delivery-note");
38
+ assert.equal(roleNameForFile("My Photo (1).jpg"), "My-Photo-1");
39
+ assert.equal(roleNameForFile("archive.tar.gz"), "archive.tar");
40
+ });
41
+
42
+ test("role names: backslash is an ordinary character, never a separator", () => {
43
+ // On POSIX `a\b.jpg` is one legal filename. The CLI hands scaffoldRule a
44
+ // platform-correct basename; this function must not re-split it.
45
+ assert.equal(roleNameForFile(String.raw`a\b.jpg`), "a-b");
46
+ assert.equal(roleNameForFile(String.raw`C:\photos\approval.png`), "C-photos-approval");
47
+ });
48
+
49
+ test("role names: grammar guards — never empty, never pure-integer, dotfiles keep their stem", () => {
50
+ assert.equal(roleNameForFile("2024.jpg"), "file-2024");
51
+ assert.equal(roleNameForFile("™️.jpg"), "file");
52
+ assert.equal(roleNameForFile(".gitignore"), "gitignore");
53
+ assert.equal(roleNameForFile("..."), "file");
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // scaffoldRule
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const ENTRIES = [
61
+ { name: "po.pdf", sha256Hex: hexOf("po") },
62
+ { name: "delivery.jpg", sha256Hex: hexOf("delivery") },
63
+ ];
64
+
65
+ test("scaffold does not parse as written, and the only issue is the ordering placeholder", () => {
66
+ const text = scaffoldRule(ENTRIES);
67
+ assert.ok(text.includes(ORDERING_PLACEHOLDER));
68
+ assert.throws(
69
+ () => parseRule(text),
70
+ (err: unknown) => {
71
+ assert.ok(err instanceof RuleError);
72
+ const ruleErr = err as RuleError;
73
+ assert.equal(ruleErr.issues.length, 1);
74
+ assert.match(ruleErr.issues[0] as string, /requires\.ordering/);
75
+ return true;
76
+ }
77
+ );
78
+ });
79
+
80
+ test("scaffold parses once the floor is chosen; cast in input order; digests normalize", () => {
81
+ const text = scaffoldRule(ENTRIES).replace(ORDERING_PLACEHOLDER, "hash-linked");
82
+ const rule = parseRule(text);
83
+ assert.deepEqual(Object.keys(rule.cast), ["po", "delivery"]);
84
+ assert.equal(rule.cast["po"]?.digest, `sha256:${hexOf("po")}`);
85
+ assert.equal(rule.cast["po"]?.means, "po.pdf");
86
+ assert.equal(rule.requires.ordering, "hash-linked");
87
+ assert.deepEqual(rule.claim, {
88
+ all: [{ exists: "po" }, { exists: "delivery" }],
89
+ });
90
+ });
91
+
92
+ test("scaffold is deterministic; id depends on digests, not entry order", () => {
93
+ assert.equal(scaffoldRule(ENTRIES), scaffoldRule(ENTRIES));
94
+ const reversed = scaffoldRule([...ENTRIES].reverse());
95
+ const idOf = (text: string): string =>
96
+ (JSON.parse(text) as { id: string }).id;
97
+ assert.equal(idOf(scaffoldRule(ENTRIES)), idOf(reversed));
98
+ assert.match(idOf(scaffoldRule(ENTRIES)), /^rule-[0-9a-f]{12}$/);
99
+ });
100
+
101
+ test("scaffold: colliding role names get ordinal suffixes in input order", () => {
102
+ const text = scaffoldRule([
103
+ { name: "a/photo.jpg", sha256Hex: hexOf("one") },
104
+ { name: "b/photo.png", sha256Hex: hexOf("two") },
105
+ { name: "c/photo.webp", sha256Hex: hexOf("three") },
106
+ ]).replace(ORDERING_PLACEHOLDER, "hash-linked");
107
+ assert.deepEqual(Object.keys(parseRule(text).cast), ["photo", "photo-2", "photo-3"]);
108
+ });
109
+
110
+ test("scaffold: a collision suffix never takes another file's natural name", () => {
111
+ // photo.png must NOT become "photo-2" while the real photo-2.jpg is
112
+ // pushed to "photo-2-2": an author editing the claim by role name would
113
+ // silently bind the wrong digest.
114
+ const text = scaffoldRule([
115
+ { name: "photo.jpg", sha256Hex: hexOf("one") },
116
+ { name: "photo.png", sha256Hex: hexOf("two") },
117
+ { name: "photo-2.jpg", sha256Hex: hexOf("three") },
118
+ ]).replace(ORDERING_PLACEHOLDER, "hash-linked");
119
+ const rule = parseRule(text);
120
+ assert.deepEqual(Object.keys(rule.cast), ["photo", "photo-3", "photo-2"]);
121
+ assert.equal(rule.cast["photo-2"]?.digest, `sha256:${hexOf("three")}`);
122
+ assert.equal(rule.cast["photo-3"]?.digest, `sha256:${hexOf("two")}`);
123
+ });
124
+
125
+ test("scaffold: backslash names keep their identity; degenerate names omit means", () => {
126
+ const text = scaffoldRule([
127
+ { name: String.raw`a\b.jpg`, sha256Hex: hexOf("bs") },
128
+ { name: "b.jpg", sha256Hex: hexOf("plain") },
129
+ ]).replace(ORDERING_PLACEHOLDER, "hash-linked");
130
+ const rule = parseRule(text);
131
+ assert.deepEqual(Object.keys(rule.cast), ["a-b", "b"]);
132
+ assert.equal(rule.cast["a-b"]?.means, String.raw`a\b.jpg`);
133
+ assert.equal(rule.cast["b"]?.means, "b.jpg");
134
+
135
+ // A trailing-slash or empty name has no basename to assert: no means
136
+ // key at all, never means: "".
137
+ const degenerate = scaffoldRule([
138
+ { name: "photos/", sha256Hex: hexOf("deg") },
139
+ ]).replace(ORDERING_PLACEHOLDER, "hash-linked");
140
+ const degRule = parseRule(degenerate);
141
+ assert.deepEqual(Object.keys(degRule.cast), ["file"]);
142
+ assert.equal(degRule.cast["file"]?.means, undefined);
143
+ });
144
+
145
+ test("scaffold: __proto__ as a filename stem becomes an ordinary cast key", () => {
146
+ const text = scaffoldRule([{ name: "__proto__.jpg", sha256Hex: hexOf("p") }]).replace(
147
+ ORDERING_PLACEHOLDER,
148
+ "assumption-dependent"
149
+ );
150
+ const rule = parseRule(text);
151
+ assert.deepEqual(Object.keys(rule.cast), ["__proto__"]);
152
+ assert.equal(rule.cast["__proto__"]?.digest, `sha256:${hexOf("p")}`);
153
+ });
154
+
155
+ test("scaffold rejects empty input and malformed hex", () => {
156
+ assert.throws(() => scaffoldRule([]));
157
+ assert.throws(() => scaffoldRule([{ name: "a", sha256Hex: "ABC" }]));
158
+ assert.throws(() => scaffoldRule([{ name: "a", sha256Hex: hexOf("x").toUpperCase() }]));
159
+ });
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // playAudit / play
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const RULE_TEXT = JSON.stringify({
166
+ rule: "bitgraph-player/1",
167
+ id: "play-test",
168
+ cast: {
169
+ first: { digest: digestFor("first") },
170
+ second: { digest: digestFor("second") },
171
+ },
172
+ world: "closed",
173
+ requires: { ordering: "hash-linked" },
174
+ claim: { before: ["first", "second"] },
175
+ then: { label: "ok" },
176
+ });
177
+
178
+ function chainAudit() {
179
+ return makeAudit({
180
+ proofs: [
181
+ { name: "p1", digestB64: digestFor("first"), epochId: "e1", counter: "1" },
182
+ { name: "p2", digestB64: digestFor("second"), epochId: "e1", counter: "2", prev: "p1" },
183
+ ],
184
+ partitions: [{ epochId: "e1", members: ["p1", "p2"], components: [["p1", "p2"]] }],
185
+ });
186
+ }
187
+
188
+ test("playAudit: verdict, bytes, and exit code agree with the pipeline pieces", () => {
189
+ const sha = hexOf(RULE_TEXT);
190
+ const result = playAudit(parseRule(RULE_TEXT), sha, chainAudit());
191
+ assert.equal(result.verdict.result, "TRUE");
192
+ assert.equal(result.exitCode, 0);
193
+ assert.equal(result.verdict.rule.sha256, sha);
194
+ assert.equal(result.bytes, serializeVerdict(result.verdict));
195
+ assert.equal(result.verdict.then?.label, "ok");
196
+ });
197
+
198
+ test("playAudit exit codes: FALSE is 1, UNDETERMINED is 2", () => {
199
+ const falseRule = parseRule(RULE_TEXT.replace('"before"', '"after"'));
200
+ assert.equal(playAudit(falseRule, hexOf("x"), chainAudit()).exitCode, 1);
201
+
202
+ const emptyAudit = makeAudit({ proofs: [] });
203
+ const rule = parseRule(RULE_TEXT);
204
+ assert.equal(playAudit(rule, hexOf("x"), emptyAudit).exitCode, 2);
205
+ });
206
+
207
+ test("play: missing rule file throws PlayError with stage rule-read", async () => {
208
+ await assert.rejects(
209
+ () => play("/nonexistent/rule.json", "/nonexistent/bundle"),
210
+ (err: unknown) => {
211
+ assert.ok(err instanceof PlayError);
212
+ assert.equal((err as PlayError).stage, "rule-read");
213
+ return true;
214
+ }
215
+ );
216
+ });
217
+
218
+ test("play: invalid rule file throws RuleError before any bundle work", async () => {
219
+ const dir = mkdtempSync(join(tmpdir(), "player-play-"));
220
+ try {
221
+ const rulePath = join(dir, "rule.json");
222
+ writeFileSync(rulePath, '{"rule": "bitgraph-player/1"}');
223
+ await assert.rejects(
224
+ () => play(rulePath, "/nonexistent/bundle"),
225
+ (err: unknown) => err instanceof RuleError
226
+ );
227
+ } finally {
228
+ rmSync(dir, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // CLI init wiring
234
+ // ---------------------------------------------------------------------------
235
+
236
+ test("cli init: hashes files, emits skeleton on stdout, exit 0", () => {
237
+ const dir = mkdtempSync(join(tmpdir(), "player-init-"));
238
+ try {
239
+ const po = join(dir, "po.pdf");
240
+ const delivery = join(dir, "delivery.jpg");
241
+ writeFileSync(po, "po-bytes");
242
+ writeFileSync(delivery, "delivery-bytes");
243
+
244
+ const run = spawnSync(process.execPath, [CLI_PATH, "init", po, delivery], {
245
+ encoding: "utf8",
246
+ });
247
+ assert.equal(run.status, 0, run.stderr);
248
+ const skeleton = JSON.parse(run.stdout) as {
249
+ cast: Record<string, { digest: string }>;
250
+ requires: { ordering: string };
251
+ };
252
+ assert.deepEqual(Object.keys(skeleton.cast), ["po", "delivery"]);
253
+ assert.equal(skeleton.cast["po"]?.digest, `sha256:${hexOf("po-bytes")}`);
254
+ assert.equal(skeleton.requires.ordering, ORDERING_PLACEHOLDER);
255
+ assert.match(run.stderr, /next steps:/);
256
+ } finally {
257
+ rmSync(dir, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ test("cli init --out: writes the file, refuses to overwrite it, exit 3", () => {
262
+ const dir = mkdtempSync(join(tmpdir(), "player-init-out-"));
263
+ try {
264
+ const input = join(dir, "photo.jpg");
265
+ writeFileSync(input, "photo-bytes");
266
+ const out = join(dir, "rule.json");
267
+
268
+ const first = spawnSync(process.execPath, [CLI_PATH, "init", input, "--out", out], {
269
+ encoding: "utf8",
270
+ });
271
+ assert.equal(first.status, 0, first.stderr);
272
+ assert.equal(first.stdout, "");
273
+ const written = JSON.parse(readFileSync(out, "utf8")) as {
274
+ cast: Record<string, { digest: string }>;
275
+ };
276
+ assert.equal(written.cast["photo"]?.digest, `sha256:${hexOf("photo-bytes")}`);
277
+
278
+ const second = spawnSync(process.execPath, [CLI_PATH, "init", input, "--out", out], {
279
+ encoding: "utf8",
280
+ });
281
+ assert.equal(second.status, 3);
282
+ assert.match(second.stderr, /refusing to overwrite/);
283
+ } finally {
284
+ rmSync(dir, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ test("cli init: unreadable input is an error, exit 3; no files is usage, exit 3", () => {
289
+ const missing = spawnSync(
290
+ process.execPath,
291
+ [CLI_PATH, "init", "/nonexistent/file.jpg"],
292
+ { encoding: "utf8" }
293
+ );
294
+ assert.equal(missing.status, 3);
295
+ assert.match(missing.stderr, /cannot hash/);
296
+
297
+ const empty = spawnSync(process.execPath, [CLI_PATH, "init"], { encoding: "utf8" });
298
+ assert.equal(empty.status, 3);
299
+ assert.match(empty.stderr, /usage:/);
300
+ });
301
+
302
+ test("cli: a file named init in cwd makes `bitgraph-play init …` a loud error, not a silent mode pick", () => {
303
+ const dir = mkdtempSync(join(tmpdir(), "player-init-shadow-"));
304
+ try {
305
+ writeFileSync(join(dir, "init"), "{}");
306
+ writeFileSync(join(dir, "photo.jpg"), "bytes");
307
+ const run = spawnSync(process.execPath, [CLI_PATH, "init", "photo.jpg"], {
308
+ encoding: "utf8",
309
+ cwd: dir,
310
+ });
311
+ assert.equal(run.status, 3);
312
+ assert.match(run.stderr, /ambiguous/);
313
+ assert.equal(run.stdout, "");
314
+ } finally {
315
+ rmSync(dir, { recursive: true, force: true });
316
+ }
317
+ });
318
+
319
+ test("cli: -- forces evaluate mode, so a rule file named init is still reachable", () => {
320
+ const dir = mkdtempSync(join(tmpdir(), "player-init-dashdash-"));
321
+ try {
322
+ // An invalid rule proves the point: init-mode would hash it and exit 0;
323
+ // evaluate mode must report an invalid rule with exit 3.
324
+ writeFileSync(join(dir, "init"), "{}");
325
+ const run = spawnSync(process.execPath, [CLI_PATH, "--", "init", dir], {
326
+ encoding: "utf8",
327
+ cwd: dir,
328
+ });
329
+ assert.equal(run.status, 3);
330
+ assert.match(run.stderr, /invalid rule/);
331
+ } finally {
332
+ rmSync(dir, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+ test("PLAYER_VERSION matches package.json (the constant must not drift across releases)", async () => {
337
+ const { PLAYER_VERSION } = await import("../verdict.js");
338
+ const pkg = JSON.parse(
339
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8")
340
+ ) as { version: string };
341
+ assert.equal(PLAYER_VERSION, pkg.version);
342
+ });
343
+
344
+ test("cli evaluate: unchanged contract, invalid rule reports issues on stderr, exit 3", () => {
345
+ const dir = mkdtempSync(join(tmpdir(), "player-eval-"));
346
+ try {
347
+ const rulePath = join(dir, "rule.json");
348
+ writeFileSync(rulePath, "{}");
349
+ const run = spawnSync(process.execPath, [CLI_PATH, rulePath, dir], {
350
+ encoding: "utf8",
351
+ });
352
+ assert.equal(run.status, 3);
353
+ assert.match(run.stderr, /invalid rule/);
354
+ assert.equal(run.stdout, "");
355
+ } finally {
356
+ rmSync(dir, { recursive: true, force: true });
357
+ }
358
+ });
package/src/cli.ts CHANGED
@@ -3,32 +3,43 @@
3
3
 
4
4
  /**
5
5
  * bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]
6
+ * bitgraph-play init <file>... [--out <rule.json>]
6
7
  *
7
- * Evaluates a bitgraph-player/1 rule against a proof bundle (directory,
8
- * .tar, .tar.gz, or .tgz) and writes the verdict JSON to stdout (or
9
- * --out). Offline: the audit pipeline and the evaluator make no network
10
- * requests of any kind.
8
+ * Evaluate: runs a bitgraph-player/1 rule against a proof bundle
9
+ * (directory, .tar, .tar.gz, or .tgz) and writes the verdict JSON to
10
+ * stdout (or --out). Offline: the audit pipeline and the evaluator make
11
+ * no network requests of any kind.
11
12
  *
12
- * Exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error.
13
- * Diagnostics go to stderr; stdout carries verdict bytes only.
13
+ * Init: hashes the given files and emits a rule skeleton with the cast
14
+ * filled in. The skeleton does not run as written: requires.ordering is
15
+ * a placeholder the author must replace — the trust floor has no
16
+ * default, and the scaffolder must not choose it either.
17
+ *
18
+ * Exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error (init: 0 or 3).
19
+ * Diagnostics go to stderr; stdout carries the verdict (or skeleton)
20
+ * bytes only.
14
21
  *
15
22
  * The process exits by setting process.exitCode and returning, never by
16
- * process.exit(): exiting early would truncate a verdict still draining
17
- * to a pipe.
23
+ * process.exit(): exiting early would truncate output still draining to
24
+ * a pipe.
18
25
  */
19
26
 
20
27
  import { createHash } from "node:crypto";
21
- import { readFileSync, writeFileSync } from "node:fs";
22
- import { runAudit } from "@mikeargento/bitgraph-audit";
28
+ import { createReadStream, existsSync, writeFileSync } from "node:fs";
29
+ import { basename } from "node:path";
30
+ import { pipeline } from "node:stream/promises";
23
31
  import type { AuditResult } from "@mikeargento/bitgraph-audit";
24
- import { resolveCast } from "./cast.js";
25
- import { evaluate } from "./evaluate.js";
26
- import { parseRule, RuleError } from "./rule.js";
27
- import { buildVerdict, serializeVerdict } from "./verdict.js";
32
+ import { scaffoldRule } from "./init.js";
33
+ import type { ScaffoldEntry } from "./init.js";
34
+ import { play, PlayError } from "./play.js";
35
+ import { RuleError } from "./rule.js";
28
36
 
29
37
  function usage(): number {
30
38
  process.stderr.write(
31
39
  "usage: bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]\n" +
40
+ " bitgraph-play init <file>... [--out <rule.json>]\n" +
41
+ ' "--" ends option parsing; a rule file literally named "init" is\n' +
42
+ " evaluated with: bitgraph-play -- init <bundle>\n" +
32
43
  " exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error\n"
33
44
  );
34
45
  return 3;
@@ -58,78 +69,145 @@ function printSummary(audit: AuditResult): void {
58
69
  process.stderr.write(lines.join("\n") + "\n");
59
70
  }
60
71
 
61
- async function main(): Promise<number> {
62
- const args = process.argv.slice(2);
63
- const positional: string[] = [];
64
- let outFile: string | undefined;
65
- let summary = false;
72
+ async function sha256HexOfFile(path: string): Promise<string> {
73
+ const hash = createHash("sha256");
74
+ await pipeline(createReadStream(path), hash);
75
+ return hash.digest("hex");
76
+ }
77
+
78
+ interface ParsedArgs {
79
+ positional: string[];
80
+ outFile?: string;
81
+ summary: boolean;
82
+ }
83
+
84
+ function parseArgs(args: string[]): ParsedArgs | undefined {
85
+ const parsed: ParsedArgs = { positional: [], summary: false };
66
86
  for (let i = 0; i < args.length; i++) {
67
87
  const arg = args[i] as string;
68
- if (arg === "--out") {
88
+ if (arg === "--") {
89
+ // End of options: everything after is positional, dashes and all.
90
+ parsed.positional.push(...args.slice(i + 1));
91
+ break;
92
+ } else if (arg === "--out") {
69
93
  const next = args[++i];
70
- if (next === undefined || next.startsWith("-")) return usage();
71
- outFile = next;
94
+ if (next === undefined || next.startsWith("-")) return undefined;
95
+ parsed.outFile = next;
72
96
  } else if (arg === "--summary") {
73
- summary = true;
97
+ parsed.summary = true;
74
98
  } else if (arg.startsWith("-")) {
75
- return usage();
99
+ return undefined;
76
100
  } else {
77
- positional.push(arg);
101
+ parsed.positional.push(arg);
78
102
  }
79
103
  }
80
- if (positional.length !== 2) return usage();
81
- const [rulePath, bundlePath] = positional as [string, string];
104
+ return parsed;
105
+ }
82
106
 
83
- let ruleBytes: Buffer;
84
- try {
85
- ruleBytes = readFileSync(rulePath);
86
- } catch (err) {
87
- process.stderr.write(`error: cannot read rule file: ${(err as Error).message}\n`);
107
+ async function runInit(args: ParsedArgs): Promise<number> {
108
+ if (args.summary) return usage();
109
+ const files = args.positional;
110
+ if (files.length === 0) return usage();
111
+ if (args.outFile !== undefined && existsSync(args.outFile)) {
112
+ process.stderr.write(
113
+ `error: refusing to overwrite existing file: ${args.outFile}\n`
114
+ );
88
115
  return 3;
89
116
  }
90
- const ruleSha256Hex = createHash("sha256").update(ruleBytes).digest("hex");
91
117
 
92
- let rule;
118
+ const entries: ScaffoldEntry[] = [];
119
+ for (const file of files) {
120
+ let sha256Hex: string;
121
+ try {
122
+ sha256Hex = await sha256HexOfFile(file);
123
+ } catch (err) {
124
+ process.stderr.write(`error: cannot hash ${file}: ${(err as Error).message}\n`);
125
+ return 3;
126
+ }
127
+ // Platform-correct basename here, at the boundary: scaffoldRule
128
+ // itself splits only on "/" so its output is machine-independent.
129
+ entries.push({ name: basename(file), sha256Hex });
130
+ process.stderr.write(` sha256:${sha256Hex} ${file}\n`);
131
+ }
132
+
133
+ const skeleton = scaffoldRule(entries);
134
+ if (args.outFile !== undefined) {
135
+ // Exclusive write: the pre-hash existsSync is only a fast-fail, and
136
+ // hashing is unbounded in duration, so the refusal must hold at the
137
+ // write itself or a file created mid-hash is silently clobbered.
138
+ try {
139
+ writeFileSync(args.outFile, skeleton, { flag: "wx" });
140
+ } catch (err) {
141
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
142
+ process.stderr.write(
143
+ `error: refusing to overwrite existing file: ${args.outFile}\n`
144
+ );
145
+ return 3;
146
+ }
147
+ throw err;
148
+ }
149
+ process.stderr.write(`wrote ${args.outFile}\n`);
150
+ } else {
151
+ process.stdout.write(skeleton);
152
+ }
153
+ process.stderr.write(
154
+ "\nnext steps:\n" +
155
+ ' 1. set requires.ordering — "hash-linked" accepts only hash-link evidence;\n' +
156
+ ' "assumption-dependent" also accepts counter and anchor evidence\n' +
157
+ " 2. say what each digest means; refine the claim (before / after / between / not)\n" +
158
+ " 3. bitgraph-play <rule.json> <bundle>\n"
159
+ );
160
+ return 0;
161
+ }
162
+
163
+ async function runEvaluate(args: ParsedArgs): Promise<number> {
164
+ if (args.positional.length !== 2) return usage();
165
+ const [rulePath, bundlePath] = args.positional as [string, string];
166
+
167
+ let result;
93
168
  try {
94
- rule = parseRule(ruleBytes.toString("utf8"));
169
+ result = await play(rulePath, bundlePath);
95
170
  } catch (err) {
96
171
  if (err instanceof RuleError) {
97
172
  process.stderr.write("error: invalid rule\n");
98
173
  for (const issue of err.issues) process.stderr.write(` - ${issue}\n`);
174
+ } else if (err instanceof PlayError) {
175
+ process.stderr.write(`error: ${err.message}\n`);
99
176
  } else {
100
177
  process.stderr.write(`error: ${(err as Error).message}\n`);
101
178
  }
102
179
  return 3;
103
180
  }
104
181
 
105
- let audit: AuditResult;
106
- try {
107
- audit = await runAudit(bundlePath);
108
- } catch (err) {
109
- process.stderr.write(`error: bundle audit failed: ${(err as Error).message}\n`);
110
- return 3;
111
- }
112
-
113
- const resolutions = resolveCast(rule.cast, audit);
114
- const evaluation = evaluate(rule, resolutions, audit);
115
- const verdict = buildVerdict(rule, ruleSha256Hex, resolutions, evaluation, audit);
116
- const bytes = serializeVerdict(verdict);
117
-
118
- if (outFile !== undefined) {
119
- writeFileSync(outFile, bytes);
182
+ if (args.outFile !== undefined) {
183
+ writeFileSync(args.outFile, result.bytes);
120
184
  } else {
121
- process.stdout.write(bytes);
185
+ process.stdout.write(result.bytes);
122
186
  }
123
- if (summary) printSummary(audit);
187
+ if (args.summary) printSummary(result.audit);
188
+ return result.exitCode;
189
+ }
124
190
 
125
- switch (evaluation.result) {
126
- case "TRUE":
127
- return 0;
128
- case "FALSE":
129
- return 1;
130
- case "UNDETERMINED":
131
- return 2;
191
+ async function main(): Promise<number> {
192
+ const argv = process.argv.slice(2);
193
+ // "--" as the first token forces evaluate mode: parseArgs treats
194
+ // everything after it as positional, so a rule file literally named
195
+ // "init" is reachable as `bitgraph-play -- init <bundle>`.
196
+ const isInit = argv[0] === "init";
197
+ if (isInit && existsSync("init")) {
198
+ // Both readings are plausible here; a silent pick would hand a
199
+ // 0.1.1 caller a skeleton with exit 0 where the published contract
200
+ // returned a verdict. Refuse loudly instead.
201
+ process.stderr.write(
202
+ 'error: a file named "init" exists here, so this command is ambiguous.\n' +
203
+ ' to evaluate it as a rule: bitgraph-play -- init <bundle> (or ./init)\n' +
204
+ " to scaffold a rule: rename that file or run from another directory\n"
205
+ );
206
+ return 3;
132
207
  }
208
+ const parsed = parseArgs(isInit ? argv.slice(1) : argv);
209
+ if (parsed === undefined) return usage();
210
+ return isInit ? runInit(parsed) : runEvaluate(parsed);
133
211
  }
134
212
 
135
213
  main().then(
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Deterministic evaluation of causal rules over BitGraph proof bundles.
7
7
  *
8
- * BitGraph records. Player executes.
8
+ * BitGraph records. Player evaluates.
9
9
  *
10
10
  * Player is a pure function over `runAudit()`'s output: same rule, same
11
11
  * bundle, same verdict, on anyone's machine, with no network access at
@@ -30,9 +30,13 @@ export type {
30
30
  export { basisTier, meetsFloor } from "./types.js";
31
31
 
32
32
  export { parseRule, normalizeDigest, RuleError } from "./rule.js";
33
+ export { play, playAudit, PlayError } from "./play.js";
34
+ export type { PlayResult, PlayStage } from "./play.js";
35
+ export { scaffoldRule, roleNameForFile, ORDERING_PLACEHOLDER } from "./init.js";
36
+ export type { ScaffoldEntry } from "./init.js";
33
37
  export { resolveCast, resolveRole } from "./cast.js";
34
38
  export { compare } from "./order.js";
35
39
  export { kleeneAll, kleeneAny, kleeneNot } from "./logic.js";
36
40
  export { evaluate } from "./evaluate.js";
37
41
  export type { Evaluation } from "./evaluate.js";
38
- export { buildVerdict, serializeVerdict, playerVersion } from "./verdict.js";
42
+ export { buildVerdict, serializeVerdict, playerVersion, PLAYER_VERSION } from "./verdict.js";