amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The conclusive detection test: seeded storms over the full alphabet of
5
+ * file operations, judged three ways.
6
+ *
7
+ * 1. The referee (observer.verify()) re-hashes every byte on disk and
8
+ * compares against the observer's beliefs — zero mismatches or fail.
9
+ * 2. The ledger replay: the emitted events ALONE must reconstruct the
10
+ * exact final disk state — proving nothing was missed and nothing was
11
+ * invented.
12
+ * 3. Silence rules: no event may ever name an excluded path.
13
+ *
14
+ * Every possible change is a composition of the alphabet below; covering the
15
+ * alphabet under random composition is the completeness claim. Storms are
16
+ * seeded (AMALGM_STORM_SEED) so any failure replays exactly.
17
+ *
18
+ * Echo suppression (noteWrite) is deliberately absent here — storms are
19
+ * all-external by construction; noteWrite has its own tests in
20
+ * observer.test.js.
21
+ */
22
+
23
+ const test = require('node:test');
24
+ const assert = require('node:assert/strict');
25
+ const crypto = require('crypto');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ const Database = require('better-sqlite3');
31
+ const { createObserver } = require('../observer');
32
+ const { walkRoot } = require('../observer/scan');
33
+ const inclusion = require('../workspace/tree/inclusion');
34
+
35
+ /** The judge's own hash — deliberately independent of the production
36
+ * primitive, so a broken detector cannot grade its own homework. */
37
+ const hashFile = (absPath) =>
38
+ crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex');
39
+
40
+ const SEED = Number(process.env.AMALGM_STORM_SEED || 20260729);
41
+
42
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-observer-storm-'));
43
+ test.after(() => {
44
+ fs.rmSync(tempRoot, { recursive: true, force: true });
45
+ });
46
+
47
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
48
+
49
+ function mulberry32(seed) {
50
+ let a = seed >>> 0;
51
+ return () => {
52
+ a = (a + 0x6d2b79f5) >>> 0;
53
+ let t = a;
54
+ t = Math.imul(t ^ (t >>> 15), t | 1);
55
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
56
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
57
+ };
58
+ }
59
+
60
+ let stamp = 0;
61
+
62
+ /** Deterministic, always-distinct content of an exact byte length (>= 12). */
63
+ function bytes(size) {
64
+ const head = `#${(stamp++).toString(36)}-`;
65
+ return (head + 'x'.repeat(size)).slice(0, size);
66
+ }
67
+
68
+ function makeObserver(options = {}) {
69
+ const events = [];
70
+ const observer = createObserver({
71
+ db: new Database(':memory:'),
72
+ emit: (event) => events.push(event),
73
+ ...options,
74
+ });
75
+ return { observer, events };
76
+ }
77
+
78
+ function makeStormRoot(name) {
79
+ const dir = fs.mkdtempSync(path.join(tempRoot, `${name}-`));
80
+ const root = fs.realpathSync(dir);
81
+ fs.mkdirSync(path.join(root, 'node_modules'));
82
+ return root;
83
+ }
84
+
85
+ /**
86
+ * The alphabet. Each op mutates the workspace like a real writer would and
87
+ * keeps its own bookkeeping of what should exist — the judges never use it;
88
+ * only op-picking does.
89
+ */
90
+ function makeStorm(rng, root, { timestampLies }) {
91
+ const dirs = [''];
92
+ const live = new Set();
93
+ const abs = (rel) => path.join(root, rel);
94
+ const join = (dir, name) => (dir ? `${dir}/${name}` : name);
95
+ const dirname = (rel) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
96
+ const pick = (arr) => arr[Math.floor(rng() * arr.length)];
97
+ const pickLive = () => pick([...live]);
98
+ const newName = () => `f${(stamp++).toString(36)}.md`;
99
+ const size = () => 12 + Math.floor(rng() * 1500);
100
+ const write = (rel, n) => fs.writeFileSync(abs(rel), bytes(n));
101
+
102
+ const ops = {
103
+ create() {
104
+ const rel = join(pick(dirs), newName());
105
+ write(rel, size());
106
+ live.add(rel);
107
+ },
108
+ mkdirTree() {
109
+ // New nested dirs with files born instantly inside — the watcher-race case.
110
+ const a = join(pick(dirs), `d${(stamp++).toString(36)}`);
111
+ const b = `${a}/d${(stamp++).toString(36)}`;
112
+ fs.mkdirSync(abs(b), { recursive: true });
113
+ dirs.push(a, b);
114
+ for (let i = 0; i < 2; i += 1) {
115
+ const rel = join(b, newName());
116
+ write(rel, size());
117
+ live.add(rel);
118
+ }
119
+ },
120
+ edit() {
121
+ write(pickLive(), size());
122
+ },
123
+ sameSizeEdit() {
124
+ const rel = pickLive();
125
+ write(rel, fs.statSync(abs(rel)).size); // bytes() is always distinct
126
+ },
127
+ atomicSave() {
128
+ const rel = pickLive();
129
+ const tmp = `${rel}.tmp${(stamp++).toString(36)}`;
130
+ fs.writeFileSync(abs(tmp), bytes(size()));
131
+ fs.renameSync(abs(tmp), abs(rel));
132
+ },
133
+ rename() {
134
+ const rel = pickLive();
135
+ const to = join(dirname(rel), newName());
136
+ fs.renameSync(abs(rel), abs(to));
137
+ live.delete(rel);
138
+ live.add(to);
139
+ },
140
+ move() {
141
+ const rel = pickLive();
142
+ const to = join(pick(dirs), newName());
143
+ fs.renameSync(abs(rel), abs(to));
144
+ live.delete(rel);
145
+ live.add(to);
146
+ },
147
+ swapNames() {
148
+ const [a, b] = [...live];
149
+ if (!a || !b) return ops.create();
150
+ const tmp = join(dirname(a), `swap${(stamp++).toString(36)}.md`);
151
+ fs.renameSync(abs(a), abs(tmp));
152
+ fs.renameSync(abs(b), abs(a));
153
+ fs.renameSync(abs(tmp), abs(b));
154
+ return undefined;
155
+ },
156
+ remove() {
157
+ const rel = pickLive();
158
+ fs.rmSync(abs(rel));
159
+ live.delete(rel);
160
+ },
161
+ recreate() {
162
+ const rel = pickLive();
163
+ fs.rmSync(abs(rel));
164
+ write(rel, size());
165
+ },
166
+ burst() {
167
+ const rel = pickLive();
168
+ const n = fs.statSync(abs(rel)).size;
169
+ for (let i = 0; i < 5; i += 1) write(rel, n); // rapid same-size versions
170
+ },
171
+ excludedChurn() {
172
+ // Must never surface as any event.
173
+ fs.writeFileSync(abs(`node_modules/junk${(stamp++).toString(36)}.js`), 'x');
174
+ fs.writeFileSync(abs('.env'), `SECRET=${stamp}`);
175
+ },
176
+ backdate() {
177
+ // The timestamp lie: same size, same inode, mtime restored. Only the
178
+ // doorbell's word says anything happened.
179
+ const rel = pickLive();
180
+ const before = fs.statSync(abs(rel));
181
+ write(rel, before.size);
182
+ fs.utimesSync(abs(rel), before.atime, before.mtime);
183
+ },
184
+ };
185
+
186
+ const table = [];
187
+ const weigh = (name, weight) => {
188
+ for (let i = 0; i < weight; i += 1) table.push(ops[name]);
189
+ };
190
+ weigh('create', 15);
191
+ weigh('mkdirTree', 6);
192
+ weigh('edit', 20);
193
+ weigh('sameSizeEdit', 12);
194
+ weigh('atomicSave', 10);
195
+ weigh('rename', 8);
196
+ weigh('move', 8);
197
+ weigh('swapNames', 4);
198
+ weigh('remove', 8);
199
+ weigh('recreate', 5);
200
+ weigh('burst', 6);
201
+ weigh('excludedChurn', 8);
202
+ if (timestampLies) weigh('backdate', 10);
203
+
204
+ return {
205
+ step() {
206
+ if (live.size < 5) return ops.create();
207
+ return pick(table)();
208
+ },
209
+ };
210
+ }
211
+
212
+ // --- the judges -------------------------------------------------------------
213
+
214
+ /** Judge 2: the ledger alone must rebuild the exact final disk state. */
215
+ function assertLedgerReconstructsDisk(events, root, label) {
216
+ const model = new Map(); // fileId -> { path, hash }
217
+ for (const event of events) {
218
+ if (event.type === 'file.created') {
219
+ model.set(event.fileId, { path: event.path, hash: event.hash });
220
+ } else if (event.type === 'file.renamed') {
221
+ const file = model.get(event.fileId);
222
+ assert.ok(file, `${label}: rename on a channel the ledger never created`);
223
+ assert.equal(file.path, event.from, `${label}: rename .from disagrees with the ledger`);
224
+ file.path = event.to;
225
+ } else if (event.type === 'file.changed') {
226
+ const file = model.get(event.fileId);
227
+ assert.ok(file, `${label}: change on a channel the ledger never created`);
228
+ assert.equal(event.previousHash, file.hash, `${label}: hash chain broken at ${event.path}`);
229
+ file.hash = event.hash;
230
+ file.path = event.path;
231
+ } else if (event.type === 'file.deleted') {
232
+ assert.ok(model.delete(event.fileId), `${label}: delete on a channel the ledger never created`);
233
+ }
234
+ }
235
+
236
+ const fromLedger = new Map();
237
+ for (const file of model.values()) {
238
+ assert.ok(!fromLedger.has(file.path), `${label}: two live channels claim ${file.path}`);
239
+ fromLedger.set(file.path, file.hash);
240
+ }
241
+ const fromDisk = new Map();
242
+ for (const entry of walkRoot(root, inclusion.shouldEnroll).files) {
243
+ fromDisk.set(entry.relPath, hashFile(path.join(root, entry.relPath)));
244
+ }
245
+ assert.deepEqual(
246
+ [...fromLedger.entries()].sort(),
247
+ [...fromDisk.entries()].sort(),
248
+ `${label}: replaying the ledger does not reproduce disk (seed ${SEED})`,
249
+ );
250
+ }
251
+
252
+ /** Judge 3: excluded territory never surfaces. */
253
+ function assertNoExcludedEvents(events, label) {
254
+ for (const event of events) {
255
+ for (const rel of [event.path, event.from, event.to]) {
256
+ if (!rel) continue;
257
+ assert.ok(
258
+ inclusion.shouldEnroll(rel),
259
+ `${label}: event named excluded path "${rel}" (seed ${SEED})`,
260
+ );
261
+ }
262
+ }
263
+ }
264
+
265
+ // --- sanity: the referee itself ----------------------------------------------
266
+
267
+ test('the referee is not blind: unscanned changes are reported, then heal', () => {
268
+ const root = makeStormRoot('referee');
269
+ fs.writeFileSync(path.join(root, 'a.md'), bytes(20));
270
+ const { observer } = makeObserver();
271
+ observer.enrollRoot(root);
272
+ assert.deepEqual(observer.verify(), []);
273
+
274
+ // Three ways to drift, none scanned yet: the referee must see all three.
275
+ fs.writeFileSync(path.join(root, 'a.md'), bytes(20)); // stale
276
+ fs.writeFileSync(path.join(root, 'b.md'), bytes(20)); // unknown
277
+ const problems = observer.verify().map((m) => m.problem.split(':')[0]).sort();
278
+ assert.deepEqual(problems, ['stale', 'unknown']);
279
+
280
+ observer.scanNow();
281
+ fs.rmSync(path.join(root, 'a.md')); // ghost
282
+ assert.deepEqual(observer.verify().map((m) => m.problem.split(':')[0]), ['ghost']);
283
+
284
+ observer.scanNow();
285
+ assert.deepEqual(observer.verify(), []);
286
+ });
287
+
288
+ // --- storm 1: the compare engine, deterministic -------------------------------
289
+
290
+ test('storm (engine): ~1000 random operations, scans interleaved, judged three ways', () => {
291
+ const rng = mulberry32(SEED);
292
+ const root = makeStormRoot('engine');
293
+ const { observer, events } = makeObserver();
294
+ observer.enrollRoot(root);
295
+ const storm = makeStorm(rng, root, { timestampLies: false });
296
+
297
+ for (let batch = 0; batch < 150; batch += 1) {
298
+ const opCount = 4 + Math.floor(rng() * 6);
299
+ for (let i = 0; i < opCount; i += 1) storm.step();
300
+ if (rng() < 0.85) observer.scanNow(); // sometimes several batches coalesce
301
+ }
302
+ observer.scanNow();
303
+
304
+ assert.deepEqual(observer.verify(), [], `referee found mismatches (seed ${SEED})`);
305
+ assertLedgerReconstructsDisk(events, root, 'engine storm');
306
+ assertNoExcludedEvents(events, 'engine storm');
307
+ });
308
+
309
+ // --- storm 2: live watchers + timestamp lies -----------------------------------
310
+
311
+ test('storm (live): real watchers, bursts, and backdated timestamps', async () => {
312
+ const rng = mulberry32(SEED + 1);
313
+ const root = makeStormRoot('live');
314
+ const { observer, events } = makeObserver({ settleMs: 25 });
315
+ observer.enrollRoot(root);
316
+ observer.start();
317
+ const storm = makeStorm(rng, root, { timestampLies: true });
318
+
319
+ for (let i = 0; i < 200; i += 1) {
320
+ storm.step();
321
+ if (rng() < 0.3) await sleep(1 + Math.floor(rng() * 8));
322
+ }
323
+
324
+ // Event-only convergence: after start() this test NEVER calls scanNow —
325
+ // the doorbells' own settle scans are the only detection allowed to run.
326
+ // verify() is read-only and repairs nothing; we just watch until the
327
+ // referee is satisfied or the deadline rules a miss.
328
+ const deadline = Date.now() + 15_000;
329
+ let mismatches = observer.verify();
330
+ while (mismatches.length > 0 && Date.now() < deadline) {
331
+ await sleep(100);
332
+ mismatches = observer.verify();
333
+ }
334
+ observer.stop();
335
+
336
+ assert.deepEqual(mismatches, [], `referee found mismatches (seed ${SEED + 1})`);
337
+ assertLedgerReconstructsDisk(events, root, 'live storm');
338
+ assertNoExcludedEvents(events, 'live storm');
339
+ });
340
+
341
+ // --- performance ----------------------------------------------------------------
342
+
343
+ test('performance: costs are one walk per scan and hashes only for suspects', async () => {
344
+ const root = makeStormRoot('perf');
345
+ for (let d = 0; d < 40; d += 1) {
346
+ fs.mkdirSync(path.join(root, `d${d}`));
347
+ for (let f = 0; f < 50; f += 1) {
348
+ fs.writeFileSync(path.join(root, `d${d}`, `f${f}.md`), bytes(1024));
349
+ }
350
+ }
351
+ const { observer, events } = makeObserver({ settleMs: 50 });
352
+ const ms = (from) => Number(process.hrtime.bigint() - from) / 1e6;
353
+
354
+ let t = process.hrtime.bigint();
355
+ observer.enrollRoot(root);
356
+ const enrollMs = ms(t);
357
+ assert.equal(events.filter((e) => e.type === 'file.created').length, 2000);
358
+
359
+ events.length = 0;
360
+ t = process.hrtime.bigint();
361
+ observer.scanNow();
362
+ const quietMs = ms(t);
363
+ assert.equal(events.length, 0, 'a quiet scan must be silent');
364
+
365
+ fs.writeFileSync(path.join(root, 'd7', 'f7.md'), bytes(1024));
366
+ t = process.hrtime.bigint();
367
+ observer.scanNow();
368
+ const editMs = ms(t);
369
+ assert.equal(events.length, 1, 'one edit is one event');
370
+
371
+ observer.start();
372
+ events.length = 0;
373
+ t = process.hrtime.bigint();
374
+ fs.writeFileSync(path.join(root, 'd9', 'f9.md'), bytes(1024));
375
+ const deadline = Date.now() + 5_000;
376
+ while (events.length === 0 && Date.now() < deadline) await sleep(5);
377
+ const latencyMs = ms(t);
378
+ observer.stop();
379
+ assert.equal(events.length, 1, 'live edit must arrive as one event');
380
+
381
+ console.log(
382
+ `# perf (2000 files, 1KB): enroll=${enrollMs.toFixed(0)}ms ` +
383
+ `quietScan=${quietMs.toFixed(1)}ms oneEditScan=${editMs.toFixed(1)}ms ` +
384
+ `saveToEventLatency=${latencyMs.toFixed(0)}ms (settle=50ms)`,
385
+ );
386
+ assert.ok(quietMs < 1000, `quiet scan too slow: ${quietMs}ms`);
387
+ assert.ok(editMs < 1000, `one-edit scan too slow: ${editMs}ms`);
388
+ assert.ok(latencyMs < 3000, `save-to-event latency too slow: ${latencyMs}ms`);
389
+ });