@githolon/testing 0.14.0 → 0.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @githolon/testing — law TDD for Nomos domains
2
2
 
3
3
  Test your law the way the cloud judges it. `testHolon()` boots **the real engine
4
- plane** in-process — the vendored `cloud/holon-host` engine over the one
4
+ plane** in-process — the vendored `cloud/cloudflare-shell` engine over the one
5
5
  wasm32-wasip1 artifact, the exact machinery every cloud DO, heavy container and
6
6
  web client runs — installs your compiled law at genesis, and hands you a holon
7
7
  you drive from vitest. Nothing is simulated: every `dispatch` runs the real
@@ -136,14 +136,14 @@ fixture) and the guestbook example's law tests
136
136
  ## How the runtime arrives (the wasm story, honestly)
137
137
 
138
138
  The engine JS is **vendored into this package at build** (synced verbatim from
139
- `cloud/holon-host/src` — the cli/deploy.sh pattern, so it can't fork). The wasm
139
+ `cloud/cloudflare-shell/src` — the cli/deploy.sh pattern, so it can't fork). The wasm
140
140
  artifact + the baked bootstrap/nomos packages and manifests are **not** in the
141
141
  npm package (the wasm alone is tens of MB); they resolve, first hit wins:
142
142
 
143
143
  1. **`runtimeDir`** option / `GITHOLON_RUNTIME_DIR` — a directory you control
144
144
  holding `{holon.wasm, manifests.json, packages.json}` (the cache shape).
145
145
  Pin this in CI for fully hermetic runs.
146
- 2. **The monorepo** — inside the nomos2 repo, `cloud/holon-host`'s vendored
146
+ 2. **The monorepo** — inside the nomos2 repo, `cloud/cloudflare-shell`'s vendored
147
147
  wasm + domain artifacts are used directly. Never any network.
148
148
  3. **The shared cache** — `~/.holon/runtime` (`HOLON_CONFIG_DIR` overrides),
149
149
  the SAME cache the `githolon` cli populates: any prior `githolon ledger`
package/build.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Vendor THE ENGINE PLANE (cloud/holon-host/src/{engine,git-fs,tree}.mjs — the
1
+ // Vendor THE ENGINE PLANE (cloud/cloudflare-shell/src/{engine,git-fs,tree}.mjs — the
2
2
  // exact machinery the edge DO and the heavy container run) into this package so
3
3
  // `testHolon()` boots holons in-process with the same code path the cloud uses.
4
4
  // Sync-at-build keeps it un-forked (the cli/build.mjs / deploy.sh pattern); the
@@ -9,15 +9,23 @@ import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  const HERE = dirname(fileURLToPath(import.meta.url));
12
- const ENGINE_SRC = join(HERE, "..", "cloud", "holon-host", "src");
12
+ const ENGINE_SRC = join(HERE, "..", "cloud", "cloudflare-shell", "src");
13
+ const ADAPTER_SRC = join(HERE, "..", "adapter"); // git-fs.mjs + tree.mjs are the SHARED adapter now
13
14
  const VENDOR = join(HERE, "vendor", "engine");
14
15
 
15
16
  if (existsSync(ENGINE_SRC)) {
16
17
  mkdirSync(VENDOR, { recursive: true });
17
- for (const f of ["engine.mjs", "git-fs.mjs", "tree.mjs"]) {
18
- const body = readFileSync(join(ENGINE_SRC, f), "utf8");
19
- writeFileSync(join(VENDOR, f), `// AUTO-SYNCED from cloud/holon-host/src/${f} by testing/build.mjs — DO NOT EDIT HERE.\n${body}`, "utf8");
18
+ // The shell's engine plane (engine.mjs + its custody-transport sibling). Rewrite the repo-relative adapter
19
+ // import (../../../adapter/) to local so the vendored copies resolve side-by-side (the deploy.sh pattern).
20
+ for (const f of ["engine.mjs", "custody-transport.mjs"]) {
21
+ const body = readFileSync(join(ENGINE_SRC, f), "utf8").replace(/\.\.\/\.\.\/\.\.\/adapter\//g, "./");
22
+ writeFileSync(join(VENDOR, f), `// AUTO-SYNCED from cloud/cloudflare-shell/src/${f} by testing/build.mjs — DO NOT EDIT HERE.\n${body}`, "utf8");
23
+ }
24
+ // The shared adapter primitives the engine imports.
25
+ for (const f of ["git-fs.mjs", "tree.mjs"]) {
26
+ const body = readFileSync(join(ADAPTER_SRC, f), "utf8");
27
+ writeFileSync(join(VENDOR, f), `// AUTO-SYNCED from adapter/${f} by testing/build.mjs — DO NOT EDIT HERE.\n${body}`, "utf8");
20
28
  }
21
29
  } else if (!existsSync(join(VENDOR, "engine.mjs"))) {
22
- throw new Error("engine plane not found: neither ../cloud/holon-host/src nor vendor/engine exists");
30
+ throw new Error("engine plane not found: neither ../cloud/cloudflare-shell/src nor vendor/engine exists");
23
31
  }
package/index.d.ts CHANGED
@@ -136,7 +136,7 @@ export interface TestHolon {
136
136
 
137
137
  /**
138
138
  * Boot a law-under-test holon: the REAL engine plane (the vendored
139
- * cloud/holon-host engine over the one wasm artifact), your compiled law
139
+ * cloud/cloudflare-shell engine over the one wasm artifact), your compiled law
140
140
  * installed at genesis, fully offline after the one-time runtime acquisition.
141
141
  */
142
142
  export declare function testHolon(opts?: TestHolonOptions): Promise<TestHolon>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/testing",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "@githolon/testing — law TDD for Nomos domains: boot the REAL engine plane in-process (the exact machinery every cloud DO, heavy container and web client runs), dispatch directives, assert rows, assert TYPED REFUSALS, fork for what-ifs — fully offline inside vitest.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // @githolon/testing — LAW TDD for Nomos domains.
2
2
  //
3
3
  // `testHolon({ deploy })` boots THE ENGINE PLANE in-process (the vendored
4
- // cloud/holon-host/src/engine.mjs — the exact machinery every cloud DO, heavy
4
+ // cloud/cloudflare-shell/src/engine.mjs — the exact machinery every cloud DO, heavy
5
5
  // container and web client runs) over the one wasm32-wasip1 artifact, installs
6
6
  // your compiled law at genesis, and hands back a holon you drive from a test:
7
7
  // dispatch directives, read rows/queries/counts/sums, assert TYPED REFUSALS
@@ -33,7 +33,7 @@ import {
33
33
  import { deserializeTree } from "../vendor/engine/tree.mjs";
34
34
 
35
35
  const HERE = dirname(fileURLToPath(import.meta.url));
36
- const HOLON_HOST = join(HERE, "..", "..", "cloud", "holon-host"); // the monorepo sibling
36
+ const HOLON_HOST = join(HERE, "..", "..", "cloud", "cloudflare-shell"); // the monorepo sibling
37
37
  const DEFAULT_CLOUD = "https://nomos.captainapp.co.uk";
38
38
  const WS = "test"; // the in-engine mount name — a chain binds to no workspace name
39
39
  const UNBORN = { remote: "https://unborn.invalid/", headers: {} }; // degrades to a fresh in-memory tree
@@ -63,7 +63,7 @@ export class Refusal extends Error {
63
63
  // Resolution order (first hit wins):
64
64
  // 1. opts.runtimeDir / GITHOLON_RUNTIME_DIR — a cache-shaped dir you control
65
65
  // ({holon.wasm, manifests.json, packages.json});
66
- // 2. the monorepo sibling cloud/holon-host (vendored wasm + baked domains) —
66
+ // 2. the monorepo sibling cloud/cloudflare-shell (vendored wasm + baked domains) —
67
67
  // fully offline for anyone working inside the nomos2 repo;
68
68
  // 3. the shared on-disk cache ~/.holon/runtime (HOLON_CONFIG_DIR overrides) —
69
69
  // the SAME cache the githolon cli populates, so any prior `githolon ledger`
@@ -148,7 +148,7 @@ async function resolveRuntime(opts) {
148
148
  if (offline) {
149
149
  throw new Error(
150
150
  `offline and no runtime artifacts found. Provide one of: (a) runtimeDir pointing at {holon.wasm, manifests.json, packages.json}; ` +
151
- `(b) run inside the nomos2 monorepo (cloud/holon-host vendors the wasm); ` +
151
+ `(b) run inside the nomos2 monorepo (cloud/cloudflare-shell vendors the wasm); ` +
152
152
  `(c) warm the shared cache once while online (any testHolon() run, or \`githolon ledger verify\`, populates ${cacheRoot()}).`,
153
153
  );
154
154
  }
@@ -0,0 +1,167 @@
1
+ // AUTO-SYNCED from cloud/cloudflare-shell/src/custody-transport.mjs by testing/build.mjs — DO NOT EDIT HERE.
2
+ // THE CLOUD SHELL'S DUMB-PIPE CUSTODY TRANSPORT — raw git smart-HTTP over fetch(), ZERO isomorphic-git.
3
+ //
4
+ // Custody (Cloudflare Artifacts) is a git smart-HTTP remote. The KERNEL owns all git semantics (gix builds
5
+ // + ingests conformant packs); this module is the platform-specific BYTE PIPE that shuttles those packs to
6
+ // and from the remote. It frames pkt-lines, POSTs/GETs bytes, and parses the ref advertisement — nothing
7
+ // more. It NEVER touches the object store, never decides admission, never manufactures a verdict: bytes +
8
+ // shell IO only (the offer-kernel doctrine, OFFER_KERNEL_ARCHITECTURE.dot).
9
+ //
10
+ // lsRefs ← replaces git.getRemoteInfo / git.listServerRefs (GET info/refs, parse advertisement)
11
+ // fetchPack ← replaces git.fetch (POST git-upload-pack → raw pack bytes)
12
+ // pushPack ← replaces git.push (generalises the proven dumbPush: many updates, deletes, empty pack)
13
+ //
14
+ // No sha1 anywhere: kernel-built packs carry their own trailer; the empty pack (delete-only push) is a
15
+ // well-known constant. So this file is identical-behaviour on workerd (edge DO) and node (heavy container).
16
+
17
+ const enc = new TextEncoder();
18
+ const dec = new TextDecoder();
19
+ const ZERO_OID = "0".repeat(40);
20
+
21
+ // The canonical EMPTY packfile: `PACK` + version 2 + 0 objects, then its sha1 trailer (a constant).
22
+ // Used for a delete-only receive-pack (a ref update that introduces no objects).
23
+ const EMPTY_PACK = new Uint8Array([
24
+ 0x50, 0x41, 0x43, 0x4b, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, // "PACK", ver 2, 0 objects
25
+ 0x02, 0x9d, 0x08, 0x82, 0x3b, 0xd8, 0xa8, 0xea, 0xb5, 0x10, 0xad, 0x6a, // sha1(the 12 header bytes)
26
+ 0xc7, 0x5c, 0x82, 0x3c, 0xfd, 0x3e, 0xd3, 0x1e,
27
+ ]);
28
+
29
+ function concatBytes(chunks) {
30
+ let n = 0;
31
+ for (const c of chunks) n += c.length;
32
+ const out = new Uint8Array(n);
33
+ let o = 0;
34
+ for (const c of chunks) { out.set(c, o); o += c.length; }
35
+ return out;
36
+ }
37
+
38
+ // pkt-line: 4 hex length (incl. the 4 bytes) + payload. 0000 = flush.
39
+ function pkt(payload) {
40
+ const body = typeof payload === "string" ? enc.encode(payload) : payload;
41
+ const len = (body.length + 4).toString(16).padStart(4, "0");
42
+ return concatBytes([enc.encode(len), body]);
43
+ }
44
+ const FLUSH = enc.encode("0000");
45
+
46
+ // Walk pkt-lines from `buf` starting at `off`. Returns { lines:[Uint8Array payloads], rest:offset }.
47
+ // Stops at a flush (0000) OR at the first non-pkt-line bytes (a raw packfile begins with "PACK", whose
48
+ // first 4 bytes are not valid length-hex) — `rest` then points at the raw remainder (the pack).
49
+ function readPktLines(buf, off = 0) {
50
+ const lines = [];
51
+ while (off + 4 <= buf.length) {
52
+ const hex = dec.decode(buf.subarray(off, off + 4));
53
+ if (!/^[0-9a-f]{4}$/.test(hex)) break; // raw data (pack) starts here
54
+ const len = parseInt(hex, 16);
55
+ if (len === 0) { off += 4; break; } // flush-pkt
56
+ if (len < 4 || off + len > buf.length) break;
57
+ lines.push(buf.subarray(off + 4, off + len));
58
+ off += len;
59
+ }
60
+ return { lines, rest: off };
61
+ }
62
+
63
+ // ── lsRefs: the remote's ref advertisement (oid → name), optionally filtered by prefix. ──
64
+ // GET info/refs?service=git-upload-pack returns: a `# service=...` pkt + flush, then `<oid> <name>\0caps`
65
+ // for HEAD and `<oid> <name>` for the rest, then a flush. Pure v0 parse (the proxy speaks it; the prior
66
+ // dumbPush already relied on this advertisement shape).
67
+ export async function lsRefs(remote, headers = {}, { service = "git-upload-pack", prefix = null } = {}) {
68
+ const r = await fetch(`${remote}/info/refs?service=${service}`, { headers });
69
+ if (!r.ok) {
70
+ if (r.status === 404) return {}; // unborn remote — no refs
71
+ throw new Error(`info/refs ${r.status}: ${(await r.text()).slice(0, 180)}`);
72
+ }
73
+ const buf = new Uint8Array(await r.arrayBuffer());
74
+ // The advertisement is TWO pkt sections: `# service=...\n` + flush, THEN the ref lines + flush.
75
+ // Read the banner (stops at its flush), then the refs from where it left off.
76
+ const banner = readPktLines(buf, 0);
77
+ const { lines } = readPktLines(buf, banner.rest);
78
+ const refs = {};
79
+ for (const line of lines) {
80
+ let s = dec.decode(line).replace(/\n$/, "");
81
+ if (s.startsWith("#")) continue; // `# service=...`
82
+ const nul = s.indexOf("\0");
83
+ if (nul >= 0) s = s.slice(0, nul); // strip caps off the first ref
84
+ const sp = s.indexOf(" ");
85
+ if (sp !== 40) continue; // `<40-hex-oid> <name>`
86
+ const oid = s.slice(0, 40), name = s.slice(41);
87
+ if (!/^[0-9a-f]{40}$/.test(oid)) continue;
88
+ if (prefix && !name.startsWith(prefix)) continue;
89
+ refs[name] = oid;
90
+ }
91
+ return refs;
92
+ }
93
+
94
+ // ── fetchPack: pull a packfile for `wants` (minus anything reachable from `haves`). ──
95
+ // POST git-upload-pack with a v0 want/have/done request, NO side-band (so the response is the ack
96
+ // section as pkt-lines followed by the RAW packfile — simplest conformant parse). Returns the pack bytes
97
+ // (the caller hands them to the kernel's apply_pack). `wants` empty ⇒ null (nothing to fetch).
98
+ export async function fetchPack(remote, headers = {}, { wants = [], haves = [] } = {}) {
99
+ wants = wants.filter((w) => /^[0-9a-f]{40}$/.test(w) && w !== ZERO_OID);
100
+ if (!wants.length) return null;
101
+ const caps = "thin-pack ofs-delta agent=nomos-cloud";
102
+ const parts = [];
103
+ wants.forEach((w, i) => parts.push(pkt(`want ${w}${i === 0 ? " " + caps : ""}\n`)));
104
+ parts.push(FLUSH);
105
+ for (const h of haves) if (/^[0-9a-f]{40}$/.test(h) && h !== ZERO_OID) parts.push(pkt(`have ${h}\n`));
106
+ parts.push(pkt("done\n"));
107
+ const r = await fetch(`${remote}/git-upload-pack`, {
108
+ method: "POST",
109
+ headers: { "content-type": "application/x-git-upload-pack-request", "accept": "application/x-git-upload-pack-result", ...headers },
110
+ body: concatBytes(parts),
111
+ });
112
+ if (!r.ok) throw new Error(`upload-pack ${r.status}: ${(await r.text()).slice(0, 180)}`);
113
+ const buf = new Uint8Array(await r.arrayBuffer());
114
+ // Response = an ack section (NAK / ACK ... pkt-lines) then the pack. The pack is EITHER raw (if the
115
+ // server honoured no-side-band) OR side-band-64k multiplexed (gitty defaults to it): pkt-lines whose
116
+ // first payload byte is the band (1 = pack data, 2 = progress, 3 = fatal). De-mux band 1; fall back to
117
+ // the raw remainder when there's no side-band.
118
+ const { lines, rest } = readPktLines(buf, 0);
119
+ const packChunks = [];
120
+ let sideband = false;
121
+ for (const line of lines) {
122
+ if (line.length === 0) continue;
123
+ const head = dec.decode(line.subarray(0, Math.min(line.length, 3)));
124
+ if (head === "NAK" || head === "ACK" || head === "ERR" || head.startsWith("sha")) continue; // control
125
+ const band = line[0];
126
+ if (band === 1) { packChunks.push(line.subarray(1)); sideband = true; }
127
+ else if (band === 2) { /* progress — ignore */ }
128
+ else if (band === 3) { throw new Error("upload-pack fatal: " + dec.decode(line.subarray(1)).slice(0, 180)); }
129
+ }
130
+ const pack = sideband ? concatBytes(packChunks) : buf.subarray(rest);
131
+ if (pack.length < 12 || dec.decode(pack.subarray(0, 4)) !== "PACK") {
132
+ throw new Error(`upload-pack: no pack in response (${pack.length}B, sideband=${sideband})`);
133
+ }
134
+ return pack;
135
+ }
136
+
137
+ // ── pushPack: advance/delete refs on the remote with a kernel-built pack. ──
138
+ // Generalises the proven dumbPush: many ref updates in one receive-pack, deletes (newOid = zero), and an
139
+ // empty pack when the push introduces no objects (delete-only). `updates`: [{ ref, oldOid, newOid }].
140
+ // `pack`: kernel-built bytes (self-trailered) or null/empty for delete-only. Throws unless EVERY ref
141
+ // update reports `ok` AND the pack unpacked — a silent `ng` (objects accepted, ref refused) is non-durable.
142
+ export async function pushPack(remote, headers = {}, { updates = [], pack = null } = {}) {
143
+ if (!updates.length) return { ok: true, noop: true };
144
+ const cmds = [];
145
+ updates.forEach((u, i) => {
146
+ const old = u.oldOid || ZERO_OID;
147
+ const neu = u.newOid || ZERO_OID;
148
+ const caps = i === 0 ? "\0report-status" : "";
149
+ cmds.push(pkt(`${old} ${neu} ${u.ref}${caps}\n`));
150
+ });
151
+ cmds.push(FLUSH);
152
+ const body = concatBytes([...cmds, pack && pack.length ? pack : EMPTY_PACK]);
153
+ const r = await fetch(`${remote}/git-receive-pack`, {
154
+ method: "POST",
155
+ headers: { "content-type": "application/x-git-receive-pack-request", ...headers },
156
+ body,
157
+ });
158
+ const txt = dec.decode(new Uint8Array(await r.arrayBuffer()));
159
+ const unpacked = /unpack ok/.test(txt);
160
+ const everyRefOk = updates.every((u) => new RegExp(`ok ${u.ref.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(txt));
161
+ if (!(r.ok && unpacked && everyRefOk && !/\bng /.test(txt))) {
162
+ throw new Error(`receive-pack ${r.status}: ${txt.replace(/[^\x20-\x7e]/g, " ").trim().slice(0, 200)}`);
163
+ }
164
+ return { ok: true };
165
+ }
166
+
167
+ export { ZERO_OID };