@mikeargento/bitgraph-player 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/SPEC.md +5 -0
- package/dist/__tests__/init-play.test.d.ts +2 -0
- package/dist/__tests__/init-play.test.d.ts.map +1 -0
- package/dist/__tests__/init-play.test.js +302 -0
- package/dist/__tests__/init-play.test.js.map +1 -0
- package/dist/cli.js +127 -60
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +26 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +104 -0
- package/dist/init.js.map +1 -0
- package/dist/play.d.ts +24 -0
- package/dist/play.d.ts.map +1 -0
- package/dist/play.js +61 -0
- package/dist/play.js.map +1 -0
- package/dist/verdict.d.ts +27 -1
- package/dist/verdict.d.ts.map +1 -1
- package/dist/verdict.js +11 -23
- package/dist/verdict.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/init-play.test.ts +358 -0
- package/src/cli.ts +137 -59
- package/src/index.ts +5 -1
- package/src/init.ts +118 -0
- package/src/play.ts +78 -0
- package/src/verdict.ts +12 -8
|
@@ -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
|
-
*
|
|
8
|
-
* .tar, .tar.gz, or .tgz) and writes the verdict JSON to
|
|
9
|
-
* --out). Offline: the audit pipeline and the evaluator make
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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 {
|
|
22
|
-
import {
|
|
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 {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 === "--
|
|
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
|
|
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
|
|
99
|
+
return undefined;
|
|
76
100
|
} else {
|
|
77
|
-
positional.push(arg);
|
|
101
|
+
parsed.positional.push(arg);
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
82
106
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
@@ -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";
|