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.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ENTITY oracle — files, folders, links, and repositories — proving
|
|
5
|
+
* itself against the current detector: the judge's expected state is the
|
|
6
|
+
* six-field universal entity record (docs/entity-model.md), and EVERY
|
|
7
|
+
* field of every record is judged
|
|
8
|
+
* at every quiet point: addresses re-derive through the parent chain,
|
|
9
|
+
* lifecycle is EXPLICIT registry law that detection never writes (a
|
|
10
|
+
* disappearance closes the channel and moves no record), git is a lens
|
|
11
|
+
* (type transition on one uuid — including the enrolled root — with
|
|
12
|
+
* entities persisting beneath it and exactly one adapter owning detection
|
|
13
|
+
* at a time), and both halves of the ratified version formula recompute
|
|
14
|
+
* from records + payload evidence alone. The boundary adapter
|
|
15
|
+
* (entity-oracle/adapter.js) compares the slice today's detector claims;
|
|
16
|
+
* when the registry lands, the adapter widens what the detector is held to.
|
|
17
|
+
*
|
|
18
|
+
* Before folder, link, or registry code exists, the judge must be shown
|
|
19
|
+
* able to catch bugs: these tests drive the real observer through the
|
|
20
|
+
* filesystem operation grammar and demand that four independent witnesses
|
|
21
|
+
* (operation model, disk referee, detector slice, event ledger) agree at
|
|
22
|
+
* every proven quiet point — then deliberately feed the judge lies and
|
|
23
|
+
* demand a conviction. A judge that can be fooled here would silently bless
|
|
24
|
+
* everything built after it.
|
|
25
|
+
*
|
|
26
|
+
* Two categories, named for what they prove:
|
|
27
|
+
* - catch-up tests drive detection with explicit scans and judge
|
|
28
|
+
* convergence;
|
|
29
|
+
* - the event-only test rings doorbells through production wiring after
|
|
30
|
+
* start() and never invokes a manual scan — the referee stays
|
|
31
|
+
* read-only while judging.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const test = require('node:test');
|
|
35
|
+
const assert = require('node:assert/strict');
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
const { sha256 } = require('./entity-oracle/graph');
|
|
40
|
+
const {
|
|
41
|
+
cleanup, converged, defaultPolicy, fakeWatchKit, fileIdAt, linkIdAt,
|
|
42
|
+
makeKit, sleep,
|
|
43
|
+
} = require('./entity.rig');
|
|
44
|
+
|
|
45
|
+
test.after(cleanup);
|
|
46
|
+
|
|
47
|
+
// --- the grammar, with the whole oracle agreeing at every quiet point -----
|
|
48
|
+
|
|
49
|
+
test('enrollment is the birth of channels, and all four witnesses agree', () => {
|
|
50
|
+
const kit = makeKit('birth');
|
|
51
|
+
kit.write('a.md', 'alpha v1');
|
|
52
|
+
kit.write('sub/nested/b.txt', 'aaaa');
|
|
53
|
+
kit.write('sub/c.md', 'gamma');
|
|
54
|
+
kit.write('bin/data.bin', Buffer.from([0x00, 0xff, 0x00, 0x7f, 0x80, 0x00]));
|
|
55
|
+
|
|
56
|
+
kit.enroll();
|
|
57
|
+
kit.point();
|
|
58
|
+
|
|
59
|
+
assert.equal(kit.events.filter((e) => e.type === 'file.created').length, 4);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the filesystem grammar converges: edit, same-size, touch, replace, rename, move, swap, duplicate, delete, recreate', () => {
|
|
63
|
+
const kit = makeKit('grammar');
|
|
64
|
+
kit.write('a.md', 'alpha v1');
|
|
65
|
+
kit.write('sub/nested/b.txt', 'aaaa');
|
|
66
|
+
kit.write('sub/c.md', 'gamma');
|
|
67
|
+
kit.enroll();
|
|
68
|
+
kit.point();
|
|
69
|
+
const originalA = fileIdAt(kit, 'a.md');
|
|
70
|
+
|
|
71
|
+
// Content edit.
|
|
72
|
+
kit.write('a.md', 'alpha v2 with more bytes');
|
|
73
|
+
kit.point();
|
|
74
|
+
|
|
75
|
+
// Same-size edit (stats lie about content).
|
|
76
|
+
kit.write('sub/nested/b.txt', 'bbbb');
|
|
77
|
+
kit.point();
|
|
78
|
+
|
|
79
|
+
// A touch with identical bytes is silence.
|
|
80
|
+
kit.touchSame('a.md');
|
|
81
|
+
const before = kit.events.length;
|
|
82
|
+
kit.point();
|
|
83
|
+
assert.equal(kit.events.slice(before).filter((e) => e.type.startsWith('file.')).length, 0,
|
|
84
|
+
'identical bytes must be silence');
|
|
85
|
+
|
|
86
|
+
// Atomic replacement: new physical file, same address, same identity.
|
|
87
|
+
kit.replace('a.md', 'alpha v3 atomically');
|
|
88
|
+
kit.point();
|
|
89
|
+
assert.equal(fileIdAt(kit, 'a.md'), originalA, 'atomic replacement must not re-mint the channel');
|
|
90
|
+
|
|
91
|
+
// Rename into a new folder: exactly one folder-blind rename event today.
|
|
92
|
+
const preRename = kit.events.length;
|
|
93
|
+
kit.mv('a.md', 'docs/a2.md');
|
|
94
|
+
kit.point();
|
|
95
|
+
const renames = kit.events.slice(preRename).filter((e) => e.type === 'file.renamed');
|
|
96
|
+
assert.equal(renames.length, 1);
|
|
97
|
+
assert.equal(fileIdAt(kit, 'docs/a2.md'), originalA, 'the move must preserve identity');
|
|
98
|
+
|
|
99
|
+
// Ancestor move: every descendant is rescued, no identity re-minted.
|
|
100
|
+
const nestedB = fileIdAt(kit, 'sub/nested/b.txt');
|
|
101
|
+
const subC = fileIdAt(kit, 'sub/c.md');
|
|
102
|
+
kit.mvDir('sub', 'moved-sub');
|
|
103
|
+
kit.point();
|
|
104
|
+
assert.equal(fileIdAt(kit, 'moved-sub/nested/b.txt'), nestedB);
|
|
105
|
+
assert.equal(fileIdAt(kit, 'moved-sub/c.md'), subC);
|
|
106
|
+
|
|
107
|
+
// Path swap: neither path is orphaned, so the address claims the channel
|
|
108
|
+
// and the swap is two content changes — identity is never guessed.
|
|
109
|
+
const preSwap = kit.events.length;
|
|
110
|
+
kit.mv('moved-sub/c.md', 'swap-tmp');
|
|
111
|
+
kit.mv('docs/a2.md', 'moved-sub/c.md');
|
|
112
|
+
kit.mv('swap-tmp', 'docs/a2.md');
|
|
113
|
+
kit.point();
|
|
114
|
+
const swapEvents = kit.events.slice(preSwap);
|
|
115
|
+
assert.equal(swapEvents.filter((e) => e.type === 'file.changed').length, 2);
|
|
116
|
+
assert.equal(swapEvents.filter((e) => e.type === 'file.renamed').length, 0);
|
|
117
|
+
|
|
118
|
+
// Duplicate identical content: two channels, one hash — identity is
|
|
119
|
+
// never derived from bytes.
|
|
120
|
+
kit.copy('docs/a2.md', 'docs/dupe.md', 'gamma');
|
|
121
|
+
kit.point();
|
|
122
|
+
assert.notEqual(fileIdAt(kit, 'docs/dupe.md'), fileIdAt(kit, 'docs/a2.md'));
|
|
123
|
+
|
|
124
|
+
// A settled disappearance closes the channel; the returning ground
|
|
125
|
+
// reconnects to the same ENTITY (the slot's active owner) while the
|
|
126
|
+
// channel itself is re-minted evidence.
|
|
127
|
+
const doomed = fileIdAt(kit, 'docs/dupe.md');
|
|
128
|
+
kit.rm('docs/dupe.md');
|
|
129
|
+
kit.point();
|
|
130
|
+
kit.write('docs/dupe.md', 'reborn, reconnected');
|
|
131
|
+
kit.point();
|
|
132
|
+
assert.notEqual(fileIdAt(kit, 'docs/dupe.md'), doomed,
|
|
133
|
+
'the re-minted channel is fresh evidence — the entity underneath reconnects');
|
|
134
|
+
|
|
135
|
+
// Delete + recreate BETWEEN quiet points: the address never observably
|
|
136
|
+
// died, so the channel survives (path claims the channel).
|
|
137
|
+
const survivor = fileIdAt(kit, 'docs/dupe.md');
|
|
138
|
+
kit.rm('docs/dupe.md');
|
|
139
|
+
kit.write('docs/dupe.md', 'same interval, same channel');
|
|
140
|
+
kit.point();
|
|
141
|
+
assert.equal(fileIdAt(kit, 'docs/dupe.md'), survivor);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the universal record: every envelope field moves the version, the uuid never moves', () => {
|
|
145
|
+
const kit = makeKit('record');
|
|
146
|
+
kit.write('nest/doc.txt', 'v1 text');
|
|
147
|
+
kit.write('den/keep.txt', 'keeper');
|
|
148
|
+
kit.enroll();
|
|
149
|
+
let snap = kit.point();
|
|
150
|
+
|
|
151
|
+
const recordOf = (s, id) => s.records.find((r) => r.uuid === id);
|
|
152
|
+
const uuid = snap.files.get('nest/doc.txt').uuid;
|
|
153
|
+
const born = recordOf(snap, uuid);
|
|
154
|
+
assert.equal(born.type, 'file.text');
|
|
155
|
+
assert.equal(born.status, 'active');
|
|
156
|
+
assert.equal(snap.payloads.get(uuid), sha256('v1 text'));
|
|
157
|
+
assert.deepEqual(Object.keys(born).sort(),
|
|
158
|
+
['name', 'parentUUID', 'status', 'type', 'uuid', 'version'],
|
|
159
|
+
'the universal record is EXACTLY six fields — payloads are evidence, not record');
|
|
160
|
+
const nestUuid = born.parentUUID;
|
|
161
|
+
|
|
162
|
+
// name: a rename in place moves the version; payload and parent do not.
|
|
163
|
+
kit.mv('nest/doc.txt', 'nest/memo.txt');
|
|
164
|
+
snap = kit.point();
|
|
165
|
+
const renamed = recordOf(snap, uuid);
|
|
166
|
+
assert.equal(renamed.name, 'memo.txt');
|
|
167
|
+
assert.equal(renamed.parentUUID, nestUuid);
|
|
168
|
+
assert.equal(snap.payloads.get(uuid), sha256('v1 text'));
|
|
169
|
+
assert.notEqual(renamed.version, born.version, 'name is in the envelope');
|
|
170
|
+
|
|
171
|
+
// parentUUID: a move across folders moves the version again.
|
|
172
|
+
kit.mv('nest/memo.txt', 'den/memo.txt');
|
|
173
|
+
snap = kit.point();
|
|
174
|
+
const moved = recordOf(snap, uuid);
|
|
175
|
+
assert.notEqual(moved.parentUUID, nestUuid);
|
|
176
|
+
assert.equal(moved.name, 'memo.txt');
|
|
177
|
+
assert.notEqual(moved.version, renamed.version, 'parentUUID is in the envelope');
|
|
178
|
+
|
|
179
|
+
// type: the injected classification policy flips its answer — a type
|
|
180
|
+
// transition on the SAME uuid (Axiom 1: type change preserves identity).
|
|
181
|
+
kit.write('den/memo.txt', Buffer.from([0x74, 0x00, 0x78]));
|
|
182
|
+
snap = kit.point();
|
|
183
|
+
const flipped = recordOf(snap, uuid);
|
|
184
|
+
assert.equal(flipped.type, 'file.binary');
|
|
185
|
+
assert.equal(flipped.uuid, uuid);
|
|
186
|
+
assert.notEqual(flipped.version, moved.version, 'type is in the envelope');
|
|
187
|
+
|
|
188
|
+
// payload: a content edit moves the version through the evidence alone.
|
|
189
|
+
const payloadBefore = snap.payloads.get(uuid);
|
|
190
|
+
kit.write('den/memo.txt', Buffer.from([0x74, 0x00, 0x79]));
|
|
191
|
+
snap = kit.point();
|
|
192
|
+
const edited = recordOf(snap, uuid);
|
|
193
|
+
assert.equal(edited.type, 'file.binary');
|
|
194
|
+
assert.notEqual(snap.payloads.get(uuid), payloadBefore);
|
|
195
|
+
assert.notEqual(edited.version, flipped.version, 'payload is in the envelope');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('disappearance is evidence, not lifecycle: no record moves, and the slot\'s owner reconnects on return', () => {
|
|
199
|
+
const kit = makeKit('vanish');
|
|
200
|
+
kit.write('den/doomed.txt', 'mortal');
|
|
201
|
+
kit.write('den/keep.txt', 'keeper');
|
|
202
|
+
kit.enroll();
|
|
203
|
+
let snap = kit.point();
|
|
204
|
+
const uuid = snap.files.get('den/doomed.txt').uuid;
|
|
205
|
+
const channel = fileIdAt(kit, 'den/doomed.txt');
|
|
206
|
+
const freeze = (s) => new Map(s.records.map((r) => [r.uuid, JSON.stringify(r)]));
|
|
207
|
+
const movedSince = (before, s) => s.records
|
|
208
|
+
.filter((r) => before.get(r.uuid) !== JSON.stringify(r))
|
|
209
|
+
.map((r) => r.uuid);
|
|
210
|
+
|
|
211
|
+
// rm: the watcher reports absence and the channel closes — but lifecycle
|
|
212
|
+
// moves only by explicit registry action, so NOT ONE record moves. (The
|
|
213
|
+
// registry keeps the entity active and restores local from canonical;
|
|
214
|
+
// trash and delete are its explicit, owner-gated acts.)
|
|
215
|
+
let before = freeze(snap);
|
|
216
|
+
kit.rm('den/doomed.txt');
|
|
217
|
+
snap = kit.point();
|
|
218
|
+
assert.deepEqual(movedSince(before, snap), [],
|
|
219
|
+
'detection never writes lifecycle — a disappearance moves no record');
|
|
220
|
+
assert.equal(snap.records.find((r) => r.uuid === uuid).status, 'active');
|
|
221
|
+
assert.ok(!snap.files.has('den/doomed.txt'), 'the exposed slice reflects ground');
|
|
222
|
+
assert.ok(kit.events.some((e) => e.type === 'file.deleted' && e.fileId === channel),
|
|
223
|
+
'absence IS reported — as channel evidence, never as a lifecycle claim');
|
|
224
|
+
|
|
225
|
+
// Reappearance at the slot: the SAME entity — an active entity owns its
|
|
226
|
+
// (parent, name) slot even while absent, so the returning ground
|
|
227
|
+
// reconnects and the new bytes are its edit. Exactly one record moves
|
|
228
|
+
// (the entity's own version); the channel is fresh evidence.
|
|
229
|
+
before = freeze(snap);
|
|
230
|
+
kit.write('den/doomed.txt', 'reborn');
|
|
231
|
+
snap = kit.point();
|
|
232
|
+
assert.equal(snap.files.get('den/doomed.txt').uuid, uuid,
|
|
233
|
+
'reappearing ground reconnects to the active owner of the slot — never a second entity');
|
|
234
|
+
assert.deepEqual(movedSince(before, snap), [uuid],
|
|
235
|
+
'a reconnect with new bytes moves exactly one record: the entity\'s own version');
|
|
236
|
+
assert.equal(snap.payloads.get(uuid), sha256('reborn'));
|
|
237
|
+
assert.notEqual(fileIdAt(kit, 'den/doomed.txt'), channel,
|
|
238
|
+
'the channel is re-minted evidence; the entity underneath is the same');
|
|
239
|
+
|
|
240
|
+
// A whole subtree vanishing is still nothing: an external rm -rf, a
|
|
241
|
+
// disconnected drive, a permission failure — none may move a record.
|
|
242
|
+
before = freeze(snap);
|
|
243
|
+
kit.rmDir('den');
|
|
244
|
+
snap = kit.point();
|
|
245
|
+
assert.deepEqual(movedSince(before, snap), [],
|
|
246
|
+
'a vanished subtree moves no record — lifecycle is explicit only');
|
|
247
|
+
|
|
248
|
+
// And the subtree returning with the same bytes reconnects folder and
|
|
249
|
+
// file alike: a full round trip through absence in which NOTHING in the
|
|
250
|
+
// canonical graph ever moved — because nothing ever happened to it.
|
|
251
|
+
kit.write('den/doomed.txt', 'reborn');
|
|
252
|
+
snap = kit.point();
|
|
253
|
+
assert.equal(snap.files.get('den/doomed.txt').uuid, uuid);
|
|
254
|
+
assert.deepEqual(movedSince(before, snap), [],
|
|
255
|
+
'a round trip through absence with identical bytes moves nothing at all');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('contested ground is divergence: two identity claims on one address move neither record', () => {
|
|
259
|
+
const kit = makeKit('contested');
|
|
260
|
+
kit.write('a.txt', 'alpha');
|
|
261
|
+
kit.write('b.txt', 'beta');
|
|
262
|
+
kit.enroll();
|
|
263
|
+
let snap = kit.point();
|
|
264
|
+
const uuidA = snap.files.get('a.txt').uuid;
|
|
265
|
+
const uuidB = snap.files.get('b.txt').uuid;
|
|
266
|
+
const channelA = fileIdAt(kit, 'a.txt');
|
|
267
|
+
const freeze = (s) => new Map(s.records.map((r) => [r.uuid, JSON.stringify(r)]));
|
|
268
|
+
const movedSince = (before, s) => s.records
|
|
269
|
+
.filter((r) => before.get(r.uuid) !== JSON.stringify(r))
|
|
270
|
+
.map((r) => r.uuid);
|
|
271
|
+
|
|
272
|
+
// b.txt departs; its active entity keeps the slot.
|
|
273
|
+
kit.rm('b.txt');
|
|
274
|
+
snap = kit.point();
|
|
275
|
+
const before = freeze(snap);
|
|
276
|
+
const regBefore = kit.regEvents.length;
|
|
277
|
+
|
|
278
|
+
// The physical a.txt lands on b.txt's slot. The channel honestly
|
|
279
|
+
// continues (one rename) AND an active entity owns the address: two
|
|
280
|
+
// established identity claims, one address — Amalgm picks no winner.
|
|
281
|
+
// Neither record moves, nothing is written, and the ground is reported
|
|
282
|
+
// as divergence pending resolution.
|
|
283
|
+
kit.mv('a.txt', 'b.txt');
|
|
284
|
+
snap = kit.point();
|
|
285
|
+
assert.deepEqual(movedSince(before, snap), [], 'a contest moves NEITHER record');
|
|
286
|
+
assert.ok(!snap.files.has('b.txt'), 'contested ground is claimed by no entity');
|
|
287
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha'),
|
|
288
|
+
'the contest itself is reported evidence, never hidden');
|
|
289
|
+
assert.equal(fileIdAt(kit, 'b.txt'), channelA,
|
|
290
|
+
'the observer honestly reports the continued channel — identity is never the channel\'s call');
|
|
291
|
+
assert.equal(kit.regEvents.length, regBefore, 'a contest commits nothing to the registry');
|
|
292
|
+
|
|
293
|
+
// The contest survives quiet points: nothing happened, nothing resolves.
|
|
294
|
+
snap = kit.point();
|
|
295
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
|
|
296
|
+
|
|
297
|
+
// An atomic replace at the contested address is an ordinary edit OF THE
|
|
298
|
+
// CONTESTED GROUND: address continuity carries the contest, and the new
|
|
299
|
+
// bytes are never the claimant's payload nor the owner's.
|
|
300
|
+
kit.replace('b.txt', 'alpha v2');
|
|
301
|
+
snap = kit.point();
|
|
302
|
+
assert.deepEqual(movedSince(before, snap), [], 'an edit on contested ground moves no record');
|
|
303
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha v2'));
|
|
304
|
+
assert.equal(kit.regEvents.length, regBefore);
|
|
305
|
+
|
|
306
|
+
// Ground returning at the claimant's old address resolves NOTHING: the
|
|
307
|
+
// new file could be a copy — presence proves nothing about identity.
|
|
308
|
+
// The claimant reconnects at home on a fresh channel; the contest stands.
|
|
309
|
+
kit.write('a.txt', 'returned');
|
|
310
|
+
snap = kit.point();
|
|
311
|
+
assert.equal(snap.files.get('a.txt').uuid, uuidA,
|
|
312
|
+
'the claimant still owns its own slot and reconnects on return');
|
|
313
|
+
assert.notEqual(fileIdAt(kit, 'a.txt'), channelA);
|
|
314
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha v2'),
|
|
315
|
+
'reappearance is not resolution — the contest survives it');
|
|
316
|
+
assert.equal(snap.payloads.get(uuidB), sha256('beta'),
|
|
317
|
+
'the slot\'s owner never absorbed the contested bytes');
|
|
318
|
+
|
|
319
|
+
// The contest ends ONLY when the conflicting ground actually leaves.
|
|
320
|
+
kit.rm('b.txt');
|
|
321
|
+
snap = kit.point();
|
|
322
|
+
assert.equal(snap.divergent.size, 0, 'the ground left; the contest is over');
|
|
323
|
+
|
|
324
|
+
// And the slot's owner reconnects on genuinely fresh ground, as always.
|
|
325
|
+
kit.write('b.txt', 'beta again');
|
|
326
|
+
snap = kit.point();
|
|
327
|
+
assert.equal(snap.files.get('b.txt').uuid, uuidB);
|
|
328
|
+
assert.equal(snap.payloads.get(uuidB), sha256('beta again'));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a contest ends when its ground leaves: the open channel resumes ordinary continuity', () => {
|
|
332
|
+
const kit = makeKit('contest-moves');
|
|
333
|
+
kit.write('a.txt', 'alpha');
|
|
334
|
+
kit.write('b.txt', 'beta');
|
|
335
|
+
kit.enroll();
|
|
336
|
+
let snap = kit.point();
|
|
337
|
+
const uuidA = snap.files.get('a.txt').uuid;
|
|
338
|
+
const uuidB = snap.files.get('b.txt').uuid;
|
|
339
|
+
const channelA = fileIdAt(kit, 'a.txt');
|
|
340
|
+
kit.rm('b.txt');
|
|
341
|
+
kit.point();
|
|
342
|
+
kit.mv('a.txt', 'b.txt');
|
|
343
|
+
snap = kit.point();
|
|
344
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
|
|
345
|
+
|
|
346
|
+
// The contested ground moves on to UNOWNED ground: the contest dissolves
|
|
347
|
+
// — its ground left the owned address — and the still-open channel's
|
|
348
|
+
// continuity simply resumes. One entity finally moves, and no contested
|
|
349
|
+
// question was ever answered by guessing.
|
|
350
|
+
kit.mv('b.txt', 'c.txt');
|
|
351
|
+
snap = kit.point();
|
|
352
|
+
assert.equal(snap.divergent.size, 0);
|
|
353
|
+
assert.equal(snap.files.get('c.txt').uuid, uuidA, 'continuity resumes on unowned ground');
|
|
354
|
+
assert.equal(fileIdAt(kit, 'c.txt'), channelA, 'the same channel carried it the whole way');
|
|
355
|
+
assert.equal(snap.records.find((r) => r.uuid === uuidB).name, 'b.txt',
|
|
356
|
+
'the slot\'s owner is untouched throughout');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('a re-grounded claimant\'s surplus channel stays divergence wherever its ground goes', () => {
|
|
360
|
+
const kit = makeKit('contest-surplus');
|
|
361
|
+
kit.write('a.txt', 'alpha');
|
|
362
|
+
kit.write('b.txt', 'beta');
|
|
363
|
+
kit.enroll();
|
|
364
|
+
let snap = kit.point();
|
|
365
|
+
const uuidA = snap.files.get('a.txt').uuid;
|
|
366
|
+
const uuidB = snap.files.get('b.txt').uuid;
|
|
367
|
+
kit.rm('b.txt');
|
|
368
|
+
kit.point();
|
|
369
|
+
kit.mv('a.txt', 'b.txt');
|
|
370
|
+
kit.point();
|
|
371
|
+
kit.write('a.txt', 'returned');
|
|
372
|
+
snap = kit.point();
|
|
373
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
|
|
374
|
+
|
|
375
|
+
// The claimant already holds canonical ground at home, so when the
|
|
376
|
+
// contested ground moves — even onto an UNOWNED address — its channel is
|
|
377
|
+
// surplus evidence: only one channel may stand as canonical ground, so
|
|
378
|
+
// the ground stays divergence, following the physical file.
|
|
379
|
+
kit.mv('b.txt', 'c.txt');
|
|
380
|
+
snap = kit.point();
|
|
381
|
+
assert.ok(!snap.divergent.has('b.txt'), 'the owned address is contested no longer');
|
|
382
|
+
assert.equal(snap.divergent.get('c.txt'), sha256('alpha'),
|
|
383
|
+
'the surplus channel\'s ground is divergence wherever it sits');
|
|
384
|
+
assert.ok(!snap.files.has('c.txt'), 'no entity claims it');
|
|
385
|
+
assert.equal(snap.files.get('a.txt').uuid, uuidA, 'the claimant keeps its canonical ground');
|
|
386
|
+
assert.equal(snap.records.find((r) => r.uuid === uuidB).name, 'b.txt',
|
|
387
|
+
'the old slot owner is untouched');
|
|
388
|
+
|
|
389
|
+
// The surplus ground leaving closes its channel and ends the divergence;
|
|
390
|
+
// genuinely fresh ground at that address is then an ordinary birth.
|
|
391
|
+
kit.rm('c.txt');
|
|
392
|
+
snap = kit.point();
|
|
393
|
+
assert.equal(snap.divergent.size, 0);
|
|
394
|
+
kit.write('c.txt', 'fresh');
|
|
395
|
+
snap = kit.point();
|
|
396
|
+
const bornUuid = snap.files.get('c.txt').uuid;
|
|
397
|
+
assert.ok(bornUuid !== uuidA && bornUuid !== uuidB,
|
|
398
|
+
'unmarked, unowned ground births a new entity — divergence never resolves by default');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('an active entity owns its slot across kinds: valid type transitions ride one uuid', () => {
|
|
402
|
+
const kit = makeKit('slot-kind');
|
|
403
|
+
kit.write('shed/tool.txt', 'tool bytes');
|
|
404
|
+
kit.write('keep.md', 'bystander');
|
|
405
|
+
kit.enroll();
|
|
406
|
+
let snap = kit.point();
|
|
407
|
+
const toolUuid = snap.files.get('shed/tool.txt').uuid;
|
|
408
|
+
const wsBefore = JSON.stringify(snap.records.find((r) => r.parentUUID === null));
|
|
409
|
+
|
|
410
|
+
// The FILE departs; its active record keeps the (shed, "tool.txt") slot.
|
|
411
|
+
// FOLDER ground lands on it: ownership is identity, not kind, so this is
|
|
412
|
+
// the same entity changing type (Axiom 1) — never a second claimant. A
|
|
413
|
+
// file → folder transition is ALWAYS valid: a file never holds children.
|
|
414
|
+
kit.rm('shed/tool.txt');
|
|
415
|
+
kit.point();
|
|
416
|
+
kit.mkdir('shed/tool.txt');
|
|
417
|
+
snap = kit.point();
|
|
418
|
+
const asFolder = snap.records.find((r) => r.uuid === toolUuid);
|
|
419
|
+
assert.equal(asFolder.type, 'folder',
|
|
420
|
+
'the slot owner reconnects across kinds — folder ground, same entity');
|
|
421
|
+
assert.equal(asFolder.status, 'active');
|
|
422
|
+
|
|
423
|
+
// And back — inside ONE interval this time. The folder is EMPTY (it
|
|
424
|
+
// never held an active child), so folder → file is a valid transition
|
|
425
|
+
// too: same uuid, file again, between two quiet points.
|
|
426
|
+
kit.rmDir('shed/tool.txt');
|
|
427
|
+
kit.write('shed/tool.txt', 'a file again');
|
|
428
|
+
snap = kit.point();
|
|
429
|
+
assert.equal(snap.files.get('shed/tool.txt').uuid, toolUuid,
|
|
430
|
+
'empty folder → file inside one interval still reconnects to the slot\'s owner');
|
|
431
|
+
assert.equal(snap.records.find((r) => r.uuid === toolUuid).type, 'file.text');
|
|
432
|
+
assert.equal(snap.payloads.get(toolUuid), sha256('a file again'));
|
|
433
|
+
|
|
434
|
+
// The workspace never noticed: the same uuids held the same slots
|
|
435
|
+
// through the whole journey, so the root record is byte-for-byte still.
|
|
436
|
+
assert.equal(JSON.stringify(snap.records.find((r) => r.parentUUID === null)), wsBefore,
|
|
437
|
+
'type transitions on a slot move nothing above it');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('only containers hold active children: an occupied folder swapped for a file is divergence, not a transition', () => {
|
|
441
|
+
const kit = makeKit('container');
|
|
442
|
+
kit.write('den/cub.txt', 'cub');
|
|
443
|
+
kit.enroll();
|
|
444
|
+
let snap = kit.point();
|
|
445
|
+
const cubUuid = snap.files.get('den/cub.txt').uuid;
|
|
446
|
+
const denUuid = snap.records.find((r) => r.uuid === cubUuid).parentUUID;
|
|
447
|
+
const freeze = (s) => new Map(s.records.map((r) => [r.uuid, JSON.stringify(r)]));
|
|
448
|
+
const movedSince = (before, s) => s.records
|
|
449
|
+
.filter((r) => before.get(r.uuid) !== JSON.stringify(r))
|
|
450
|
+
.map((r) => r.uuid);
|
|
451
|
+
const before = freeze(snap);
|
|
452
|
+
|
|
453
|
+
// The folder AND its child are swapped for a file at the folder's slot.
|
|
454
|
+
// Accepting that flip would canonicalize the subtree destruction that
|
|
455
|
+
// explicit-only lifecycle refuses to infer — so NOT ONE record moves.
|
|
456
|
+
// The file is honest, entity-less divergence: its channel and events are
|
|
457
|
+
// real, no entity claims it, resolution is explicit (amalgm resets local
|
|
458
|
+
// from canonical, or the owner moves/trashes the children first).
|
|
459
|
+
kit.rmDir('den');
|
|
460
|
+
kit.write('den', 'impostor');
|
|
461
|
+
snap = kit.point();
|
|
462
|
+
assert.deepEqual(movedSince(before, snap), [],
|
|
463
|
+
'an invalid type transition moves nothing — the canonical graph refuses the swap');
|
|
464
|
+
assert.equal(snap.records.find((r) => r.uuid === denUuid).type, 'folder');
|
|
465
|
+
assert.equal(snap.records.find((r) => r.uuid === cubUuid).status, 'active');
|
|
466
|
+
assert.ok(!snap.files.has('den'), 'divergent ground is claimed by no entity');
|
|
467
|
+
assert.equal(snap.divergent.get('den'), sha256('impostor'),
|
|
468
|
+
'the divergence itself is reported evidence, never hidden');
|
|
469
|
+
assert.ok(kit.events.some((e) => e.type === 'file.created' && e.path === 'den'),
|
|
470
|
+
'detection reports what happened — the refusal is canonical, not observational');
|
|
471
|
+
|
|
472
|
+
// Divergence resolves when ground reverts: the impostor goes, the folder
|
|
473
|
+
// and child return with the same bytes — and across the ENTIRE ordeal
|
|
474
|
+
// not one record ever moved.
|
|
475
|
+
kit.rm('den');
|
|
476
|
+
kit.write('den/cub.txt', 'cub');
|
|
477
|
+
snap = kit.point();
|
|
478
|
+
assert.deepEqual(movedSince(before, snap), [],
|
|
479
|
+
'a refused swap plus a full local restore is zero canonical history');
|
|
480
|
+
assert.equal(snap.files.get('den/cub.txt').uuid, cubUuid);
|
|
481
|
+
assert.equal(snap.divergent.size, 0);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('an ancestor rename touches its own record only — descendants stay silent', () => {
|
|
485
|
+
const kit = makeKit('silent');
|
|
486
|
+
kit.write('den/cub.txt', 'cub');
|
|
487
|
+
kit.write('den/deep/pup.txt', 'pup');
|
|
488
|
+
kit.enroll();
|
|
489
|
+
let snap = kit.point();
|
|
490
|
+
const cubUuid = snap.files.get('den/cub.txt').uuid;
|
|
491
|
+
const denUuid = snap.records.find((r) => r.uuid === cubUuid).parentUUID;
|
|
492
|
+
const denBefore = snap.records.find((r) => r.uuid === denUuid);
|
|
493
|
+
const denPayloadBefore = snap.payloads.get(denUuid);
|
|
494
|
+
const wsUuid = snap.records.find((r) => r.type === 'workspace').uuid;
|
|
495
|
+
const wsPayloadBefore = snap.payloads.get(wsUuid);
|
|
496
|
+
const cubBefore = snap.records.find((r) => r.uuid === cubUuid);
|
|
497
|
+
const deepBefore = snap.records.find((r) => r.type === 'folder' && r.name === 'deep');
|
|
498
|
+
const pupBefore = snap.records.find((r) => r.name === 'pup.txt');
|
|
499
|
+
|
|
500
|
+
kit.mvDir('den', 'lair');
|
|
501
|
+
snap = kit.point();
|
|
502
|
+
const lair = snap.records.find((r) => r.uuid === denUuid);
|
|
503
|
+
assert.equal(lair.name, 'lair');
|
|
504
|
+
assert.equal(snap.payloads.get(denUuid), denPayloadBefore,
|
|
505
|
+
'membership is (childUUID, name) pairs — unchanged by the parent\'s own rename');
|
|
506
|
+
assert.notEqual(lair.version, denBefore.version);
|
|
507
|
+
// The workspace membership DOES move: its pair (denUuid, name) changed.
|
|
508
|
+
assert.notEqual(snap.payloads.get(wsUuid), wsPayloadBefore);
|
|
509
|
+
// Every descendant record is byte-for-byte untouched.
|
|
510
|
+
assert.deepEqual(snap.records.find((r) => r.uuid === cubUuid), cubBefore,
|
|
511
|
+
'descendant records must not move when an ancestor renames');
|
|
512
|
+
assert.deepEqual(snap.records.find((r) => r.uuid === deepBefore.uuid), deepBefore);
|
|
513
|
+
assert.deepEqual(snap.records.find((r) => r.uuid === pupBefore.uuid), pupBefore);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// --- folder detection: directories are channels, folders are entities -----
|
|
517
|
+
|
|
518
|
+
test('a folder is an entity: an empty directory is born, dies as evidence only, and reconnects on one uuid', () => {
|
|
519
|
+
const kit = makeKit('hollow');
|
|
520
|
+
kit.mkdir('hollow');
|
|
521
|
+
kit.enroll();
|
|
522
|
+
kit.point();
|
|
523
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
524
|
+
const hollow = kit.registry.activeAt(rootUuid, 'hollow');
|
|
525
|
+
assert.equal(hollow.type, 'folder', 'an empty directory is a folder entity the moment disk holds it');
|
|
526
|
+
|
|
527
|
+
// Disappearance is evidence: the channel closes, the record stays active.
|
|
528
|
+
kit.rmDir('hollow');
|
|
529
|
+
kit.point();
|
|
530
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'hollow').uuid, hollow.uuid,
|
|
531
|
+
'the record keeps its slot while locally absent — lifecycle is explicit, never inferred');
|
|
532
|
+
|
|
533
|
+
kit.mkdir('hollow');
|
|
534
|
+
kit.point();
|
|
535
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'hollow').uuid, hollow.uuid,
|
|
536
|
+
'reappearing dir ground reconnects to the absent owner — one entity, one uuid');
|
|
537
|
+
assert.equal(kit.events.filter((e) => e.type === 'dir.created').length, 2);
|
|
538
|
+
assert.equal(kit.events.filter((e) => e.type === 'dir.deleted').length, 1);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('a folder rename is one recorded fact: same uuid, one commit, descendants silent in the registry too', () => {
|
|
542
|
+
const kit = makeKit('one-commit');
|
|
543
|
+
kit.write('docs/note.md', 'note');
|
|
544
|
+
kit.write('docs/deep/plan.md', 'plan');
|
|
545
|
+
kit.enroll();
|
|
546
|
+
kit.point();
|
|
547
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
548
|
+
const docs = kit.registry.activeAt(rootUuid, 'docs');
|
|
549
|
+
const deep = kit.registry.activeAt(docs.uuid, 'deep');
|
|
550
|
+
const note = kit.registry.activeAt(docs.uuid, 'note.md');
|
|
551
|
+
|
|
552
|
+
const mark = kit.regEvents.length;
|
|
553
|
+
kit.mvDir('docs', 'papers');
|
|
554
|
+
kit.point();
|
|
555
|
+
const committed = kit.regEvents.slice(mark).filter((event) => event.type === 'entity.committed');
|
|
556
|
+
assert.equal(committed.length, 1, 'a folder move is recorded once, on the folder\'s own record');
|
|
557
|
+
assert.equal(committed[0].record.uuid, docs.uuid);
|
|
558
|
+
const papers = kit.registry.activeAt(rootUuid, 'papers');
|
|
559
|
+
assert.equal(papers.uuid, docs.uuid, 'the directory channel carried the folder\'s identity by inode');
|
|
560
|
+
assert.equal(kit.registry.activeAt(papers.uuid, 'note.md').uuid, note.uuid);
|
|
561
|
+
assert.equal(kit.registry.activeAt(papers.uuid, 'deep').uuid, deep.uuid);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('two directories exchange names: identity crosses by inode, exactly like the files inside them', () => {
|
|
565
|
+
const kit = makeKit('dir-swap');
|
|
566
|
+
kit.write('alpha/a.txt', 'in-alpha');
|
|
567
|
+
kit.write('beta/b.txt', 'in-beta');
|
|
568
|
+
kit.enroll();
|
|
569
|
+
kit.point();
|
|
570
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
571
|
+
const alpha = kit.registry.activeAt(rootUuid, 'alpha');
|
|
572
|
+
const beta = kit.registry.activeAt(rootUuid, 'beta');
|
|
573
|
+
const fileA = kit.registry.activeAt(alpha.uuid, 'a.txt');
|
|
574
|
+
|
|
575
|
+
// The swap crosses between two scans; detection sees only the endpoints.
|
|
576
|
+
const mark = kit.regEvents.length;
|
|
577
|
+
kit.mvDir('alpha', 'limbo');
|
|
578
|
+
kit.mvDir('beta', 'alpha');
|
|
579
|
+
kit.mvDir('limbo', 'beta');
|
|
580
|
+
const snap = kit.point();
|
|
581
|
+
assert.equal(snap.divergent.size, 0, 'an exchange is continuity, never contest');
|
|
582
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'beta').uuid, alpha.uuid,
|
|
583
|
+
'the directory once named alpha is the entity now named beta');
|
|
584
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'alpha').uuid, beta.uuid,
|
|
585
|
+
'the directory once named beta is the entity now named alpha');
|
|
586
|
+
assert.equal(kit.registry.activeAt(alpha.uuid, 'a.txt').uuid, fileA.uuid,
|
|
587
|
+
'the file rode its physical parent across the exchange');
|
|
588
|
+
const committed = kit.regEvents.slice(mark).filter((event) => event.type === 'entity.committed');
|
|
589
|
+
assert.equal(committed.length, 2, 'an exchange is two recorded facts — one per folder, descendants silent');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('a directory replacing a vanished one is a contest, never an inheritance', () => {
|
|
593
|
+
const kit = makeKit('dir-replace');
|
|
594
|
+
kit.mkdir('a');
|
|
595
|
+
kit.mkdir('b');
|
|
596
|
+
kit.enroll();
|
|
597
|
+
kit.point();
|
|
598
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
599
|
+
const dirA = kit.registry.activeAt(rootUuid, 'a');
|
|
600
|
+
const dirB = kit.registry.activeAt(rootUuid, 'b');
|
|
601
|
+
|
|
602
|
+
// One interval: b's ground vanishes AND a moves onto its name. The owner
|
|
603
|
+
// parks in the very settle the mover arrives — still a parked ACTIVE
|
|
604
|
+
// owner, still two identity claims on one address.
|
|
605
|
+
kit.rmDir('b');
|
|
606
|
+
kit.mvDir('a', 'b');
|
|
607
|
+
let snap = kit.point();
|
|
608
|
+
assert.deepEqual([...snap.divergent], [['b', null]],
|
|
609
|
+
'the moved directory is divergence on the vanished owner\'s slot');
|
|
610
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'a').uuid, dirA.uuid, 'the mover\'s record did not move');
|
|
611
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'b').uuid, dirB.uuid, 'the vanished owner keeps its slot');
|
|
612
|
+
|
|
613
|
+
kit.mvDir('b', 'c'); // the ground leaves the contested address: resolution
|
|
614
|
+
snap = kit.point();
|
|
615
|
+
assert.equal(snap.divergent.size, 0, 'the contest ended when its ground left');
|
|
616
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'c').uuid, dirA.uuid,
|
|
617
|
+
'the mover\'s never-closed channel resumed ordinary continuity');
|
|
618
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'b').uuid, dirB.uuid,
|
|
619
|
+
'the owner still owns its slot, parked and untouched');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('folder ↔ repo.git rides one uuid: the dir channel yields to territory and returns', () => {
|
|
623
|
+
const kit = makeKit('lens-uuid');
|
|
624
|
+
kit.write('pkg/lib.md', 'lib');
|
|
625
|
+
kit.enroll();
|
|
626
|
+
kit.point();
|
|
627
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
628
|
+
const pkg = kit.registry.activeAt(rootUuid, 'pkg');
|
|
629
|
+
assert.equal(pkg.type, 'folder');
|
|
630
|
+
|
|
631
|
+
kit.gitInit('pkg');
|
|
632
|
+
kit.point();
|
|
633
|
+
const asRepo = kit.registry.activeAt(rootUuid, 'pkg');
|
|
634
|
+
assert.equal(asRepo.uuid, pkg.uuid, 'the boundary is a type transition, never a new entity');
|
|
635
|
+
assert.equal(asRepo.type, 'repo.git');
|
|
636
|
+
assert.equal(kit.observer.dirsForRoot(kit.rootId).length, 0,
|
|
637
|
+
'territory ground carries no dir channel — one adapter owns detection at a time');
|
|
638
|
+
|
|
639
|
+
kit.gitRemove('pkg');
|
|
640
|
+
kit.point();
|
|
641
|
+
const back = kit.registry.activeAt(rootUuid, 'pkg');
|
|
642
|
+
assert.equal(back.uuid, pkg.uuid, 'lifting the lens returns the folder on the same uuid');
|
|
643
|
+
assert.equal(back.type, 'folder');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('contested folder ground: a moved directory onto a parked owner\'s slot is divergence, subtree included', () => {
|
|
647
|
+
const kit = makeKit('dir-contest');
|
|
648
|
+
kit.mkdir('a');
|
|
649
|
+
kit.write('b/f.txt', 'bravo');
|
|
650
|
+
kit.enroll();
|
|
651
|
+
kit.point();
|
|
652
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
653
|
+
const ownerA = kit.registry.activeAt(rootUuid, 'a');
|
|
654
|
+
const claimB = kit.registry.activeAt(rootUuid, 'b');
|
|
655
|
+
const fileF = kit.registry.activeAt(claimB.uuid, 'f.txt');
|
|
656
|
+
|
|
657
|
+
kit.rmDir('a'); // the owner's ground leaves; its record parks, active
|
|
658
|
+
kit.point();
|
|
659
|
+
|
|
660
|
+
kit.mvDir('b', 'a'); // dir continuity arrives on the parked owner's slot
|
|
661
|
+
let snap = kit.point();
|
|
662
|
+
assert.deepEqual([...snap.divergent].sort(), [['a', null], ['a/f.txt', sha256('bravo')]],
|
|
663
|
+
'the contest covers the moved subtree: dir ground with null bytes, file ground with its own');
|
|
664
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'a').uuid, ownerA.uuid, 'the owner\'s record did not move');
|
|
665
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'b').uuid, claimB.uuid, 'the claimant\'s record did not move');
|
|
666
|
+
assert.equal(kit.registry.activeAt(claimB.uuid, 'f.txt').uuid, fileF.uuid, 'the subtree\'s records did not move');
|
|
667
|
+
|
|
668
|
+
kit.mvDir('a', 'c'); // the ground leaves the contested address: resolution
|
|
669
|
+
snap = kit.point();
|
|
670
|
+
assert.equal(snap.divergent.size, 0, 'the contest ended when its ground left');
|
|
671
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'c').uuid, claimB.uuid,
|
|
672
|
+
'the claimant\'s never-closed channel resumed ordinary continuity');
|
|
673
|
+
assert.equal(kit.registry.activeAt(claimB.uuid, 'f.txt').uuid, fileF.uuid);
|
|
674
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'a').uuid, ownerA.uuid,
|
|
675
|
+
'the owner still owns its slot, parked and untouched');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('a contest is kind-strict: file ground swapped for a directory has left, and the owner reconnects across kinds', () => {
|
|
679
|
+
const kit = makeKit('kind-swap');
|
|
680
|
+
kit.write('a.txt', 'alpha');
|
|
681
|
+
kit.write('b.txt', 'beta');
|
|
682
|
+
kit.enroll();
|
|
683
|
+
kit.point();
|
|
684
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
685
|
+
const ownerB = kit.registry.activeAt(rootUuid, 'b.txt');
|
|
686
|
+
|
|
687
|
+
kit.rm('b.txt');
|
|
688
|
+
kit.point();
|
|
689
|
+
kit.mv('a.txt', 'b.txt'); // the claimant's channel lands on the owner's slot
|
|
690
|
+
let snap = kit.point();
|
|
691
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
|
|
692
|
+
|
|
693
|
+
// In one interval the contested FILE leaves and a DIRECTORY lands on the
|
|
694
|
+
// same address. A contest carries by address continuity within its kind:
|
|
695
|
+
// ground of the mark's kind is gone, so the contest is over — and the
|
|
696
|
+
// arriving dir ground reconnects to the slot's owner across kinds,
|
|
697
|
+
// because ownership is identity, not kind.
|
|
698
|
+
kit.rm('b.txt');
|
|
699
|
+
kit.mkdir('b.txt');
|
|
700
|
+
snap = kit.point();
|
|
701
|
+
assert.equal(snap.divergent.size, 0, 'a file mark does not carry on dir ground');
|
|
702
|
+
const back = kit.registry.activeAt(rootUuid, 'b.txt');
|
|
703
|
+
assert.equal(back.uuid, ownerB.uuid, 'the owner returned as a folder on its own uuid');
|
|
704
|
+
assert.equal(back.type, 'folder');
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('event-only: a folder rename through doorbells alone — identity preserved with no manual scan', async () => {
|
|
708
|
+
const bells = fakeWatchKit();
|
|
709
|
+
const kit = makeKit('dir-doorbell', { watch: bells.watch, settleMs: 1 });
|
|
710
|
+
kit.write('den/cub.md', 'cub');
|
|
711
|
+
kit.mkdir('den/empty');
|
|
712
|
+
kit.enroll();
|
|
713
|
+
kit.point();
|
|
714
|
+
kit.observer.start();
|
|
715
|
+
try {
|
|
716
|
+
const rootUuid = kit.registry.roots()[0].uuid;
|
|
717
|
+
const den = kit.registry.activeAt(rootUuid, 'den');
|
|
718
|
+
const empty = kit.registry.activeAt(den.uuid, 'empty');
|
|
719
|
+
|
|
720
|
+
kit.mvDir('den', 'burrow');
|
|
721
|
+
await converged(kit, () => bells.ring(kit.dir, 'den'));
|
|
722
|
+
kit.point({ scan: false });
|
|
723
|
+
assert.equal(kit.registry.activeAt(rootUuid, 'burrow').uuid, den.uuid,
|
|
724
|
+
'the doorbell-driven scan carried the folder identity by inode');
|
|
725
|
+
assert.equal(kit.registry.activeAt(den.uuid, 'empty').uuid, empty.uuid,
|
|
726
|
+
'the empty child folder rode its parent\'s move without its own record moving');
|
|
727
|
+
} finally {
|
|
728
|
+
kit.observer.stop();
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// --- link detection: a pointer is leaf ground; its payload is its written address ---
|
|
733
|
+
|
|
734
|
+
test('link grammar converges: birth, retarget, rename, silent recreation, name exchange, death and reconnection', () => {
|
|
735
|
+
const kit = makeKit('links');
|
|
736
|
+
kit.write('real.md', 'real bytes');
|
|
737
|
+
kit.symlink('ln.md', 'real.md');
|
|
738
|
+
kit.symlink('ghost.md', 'nowhere/said-so'); // dangling is honest ground
|
|
739
|
+
kit.enroll();
|
|
740
|
+
let snap = kit.point();
|
|
741
|
+
const uuid = snap.links.get('ln.md').uuid;
|
|
742
|
+
assert.equal(snap.payloads.get(uuid), 'real.md', 'the payload is the pointer exactly as written');
|
|
743
|
+
|
|
744
|
+
// Retarget IS unlink + recreate under a standing address — the address
|
|
745
|
+
// absorbs the churn; channel and entity both continue.
|
|
746
|
+
const channel = linkIdAt(kit, 'ln.md');
|
|
747
|
+
kit.retarget('ln.md', 'ghost.md');
|
|
748
|
+
snap = kit.point();
|
|
749
|
+
assert.equal(snap.links.get('ln.md').uuid, uuid);
|
|
750
|
+
assert.equal(linkIdAt(kit, 'ln.md'), channel, 'retarget continues the channel: the address claims first');
|
|
751
|
+
assert.equal(snap.payloads.get(uuid), 'ghost.md');
|
|
752
|
+
|
|
753
|
+
// Rename: mv preserves a symlink's inode; exactly-one continuity rescues.
|
|
754
|
+
kit.mvLink('ln.md', 'renamed.md');
|
|
755
|
+
snap = kit.point();
|
|
756
|
+
assert.equal(snap.links.get('renamed.md').uuid, uuid);
|
|
757
|
+
assert.equal(linkIdAt(kit, 'renamed.md'), channel);
|
|
758
|
+
|
|
759
|
+
// Silent recreation: a new inode under an old name with an old target.
|
|
760
|
+
const quiet = kit.events.length;
|
|
761
|
+
kit.retarget('renamed.md', 'ghost.md');
|
|
762
|
+
snap = kit.point();
|
|
763
|
+
assert.equal(kit.events.slice(quiet).filter((e) => e.type.startsWith('link.')).length, 0,
|
|
764
|
+
'an equal pointer is silence');
|
|
765
|
+
assert.equal(snap.links.get('renamed.md').uuid, uuid);
|
|
766
|
+
|
|
767
|
+
// Two links exchanging names read as two retargets: a link's meaning IS
|
|
768
|
+
// its written address, so the path claims first and nobody crosses.
|
|
769
|
+
const ghostUuid = snap.links.get('ghost.md').uuid;
|
|
770
|
+
const preSwap = kit.events.length;
|
|
771
|
+
kit.mvLink('renamed.md', 'swap-tmp');
|
|
772
|
+
kit.mvLink('ghost.md', 'renamed.md');
|
|
773
|
+
kit.mvLink('swap-tmp', 'ghost.md');
|
|
774
|
+
snap = kit.point();
|
|
775
|
+
const swapSpeech = kit.events.slice(preSwap).filter((e) => e.type.startsWith('link.'));
|
|
776
|
+
assert.deepEqual(swapSpeech.map((e) => e.type).sort(), ['link.changed', 'link.changed']);
|
|
777
|
+
assert.equal(snap.links.get('renamed.md').uuid, uuid, 'each address kept its entity');
|
|
778
|
+
assert.equal(snap.links.get('ghost.md').uuid, ghostUuid);
|
|
779
|
+
assert.equal(snap.payloads.get(uuid), 'nowhere/said-so');
|
|
780
|
+
assert.equal(snap.payloads.get(ghostUuid), 'ghost.md');
|
|
781
|
+
|
|
782
|
+
// A settled disappearance closes the channel; the record moves not one
|
|
783
|
+
// field, and returning pointer ground reconnects to the slot's owner.
|
|
784
|
+
kit.rmLink('ghost.md');
|
|
785
|
+
snap = kit.point();
|
|
786
|
+
assert.equal(snap.records.find((r) => r.uuid === ghostUuid).status, 'active');
|
|
787
|
+
assert.ok(!snap.links.has('ghost.md'));
|
|
788
|
+
kit.symlink('ghost.md', 'reborn-target');
|
|
789
|
+
snap = kit.point();
|
|
790
|
+
assert.equal(snap.links.get('ghost.md').uuid, ghostUuid,
|
|
791
|
+
'reappearing pointer ground reconnects to the active owner of the slot');
|
|
792
|
+
assert.equal(snap.payloads.get(ghostUuid), 'reborn-target');
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test('a slot crosses kinds on one uuid: file → link → file is a type transition, never a second entity', () => {
|
|
796
|
+
const kit = makeKit('link-kinds');
|
|
797
|
+
kit.write('spot.md', 'file bytes');
|
|
798
|
+
kit.write('keep.md', 'bystander');
|
|
799
|
+
kit.enroll();
|
|
800
|
+
let snap = kit.point();
|
|
801
|
+
const uuid = snap.files.get('spot.md').uuid;
|
|
802
|
+
|
|
803
|
+
// The file leaves and a pointer lands on its slot inside one interval:
|
|
804
|
+
// ownership is identity, not kind.
|
|
805
|
+
kit.rm('spot.md');
|
|
806
|
+
kit.symlink('spot.md', 'keep.md');
|
|
807
|
+
snap = kit.point();
|
|
808
|
+
assert.equal(snap.links.get('spot.md').uuid, uuid, 'the slot owner returns as link ground on its own uuid');
|
|
809
|
+
assert.equal(snap.records.find((r) => r.uuid === uuid).type, 'link');
|
|
810
|
+
assert.equal(snap.payloads.get(uuid), 'keep.md');
|
|
811
|
+
|
|
812
|
+
// And back — across a settle this time.
|
|
813
|
+
kit.rmLink('spot.md');
|
|
814
|
+
kit.point();
|
|
815
|
+
kit.write('spot.md', 'a file again');
|
|
816
|
+
snap = kit.point();
|
|
817
|
+
assert.equal(snap.files.get('spot.md').uuid, uuid);
|
|
818
|
+
assert.equal(snap.records.find((r) => r.uuid === uuid).type, 'file.text');
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test('links ride the git lens: covered ground closes the channel, the entity persists, the lift re-exposes it', () => {
|
|
822
|
+
const kit = makeKit('link-lens');
|
|
823
|
+
kit.write('pkg/lib.md', 'lib');
|
|
824
|
+
kit.symlink('pkg/ptr.md', 'lib.md');
|
|
825
|
+
kit.enroll();
|
|
826
|
+
let snap = kit.point();
|
|
827
|
+
const uuid = snap.links.get('pkg/ptr.md').uuid;
|
|
828
|
+
const channel = linkIdAt(kit, 'pkg/ptr.md');
|
|
829
|
+
|
|
830
|
+
kit.gitInit('pkg');
|
|
831
|
+
snap = kit.point();
|
|
832
|
+
assert.ok(!snap.links.has('pkg/ptr.md'), 'covered ground leaves the exposed slice');
|
|
833
|
+
assert.equal(snap.records.find((r) => r.uuid === uuid).status, 'active',
|
|
834
|
+
'the entity under the lens keeps existing');
|
|
835
|
+
assert.ok(kit.events.some((e) => e.type === 'link.deleted' && e.linkId === channel),
|
|
836
|
+
'the channel closes when the repo adapter takes ownership');
|
|
837
|
+
|
|
838
|
+
kit.gitRemove('pkg');
|
|
839
|
+
snap = kit.point();
|
|
840
|
+
assert.equal(snap.links.get('pkg/ptr.md').uuid, uuid, 'the lens lifts: same entity, fresh channel');
|
|
841
|
+
assert.notEqual(linkIdAt(kit, 'pkg/ptr.md'), channel);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test('the injected path-dependent policy decides enrollment — not names, not conventions', () => {
|
|
845
|
+
// Deliberately path-dependent: `pkg/private` is excluded ground while
|
|
846
|
+
// `other/private` enrolls. A name-based policy would treat them alike.
|
|
847
|
+
const policy = (rel) => defaultPolicy(rel) && rel !== 'pkg/private' && !rel.startsWith('pkg/private/');
|
|
848
|
+
const kit = makeKit('policy', { policy });
|
|
849
|
+
kit.write('keep.md', 'kept');
|
|
850
|
+
kit.write('pkg/private/hidden.md', 'never an entity');
|
|
851
|
+
kit.write('other/private/seen.md', 'an entity');
|
|
852
|
+
kit.enroll();
|
|
853
|
+
kit.point();
|
|
854
|
+
|
|
855
|
+
const paths = kit.observer.filesForRoot(kit.rootId).map((r) => r.relPath).sort();
|
|
856
|
+
assert.deepEqual(paths, ['keep.md', 'other/private/seen.md']);
|
|
857
|
+
|
|
858
|
+
// Live ops on excluded ground stay invisible; enrolled ground converges.
|
|
859
|
+
kit.write('pkg/private/more.md', 'still not an entity');
|
|
860
|
+
kit.write('keep.md', 'kept v2');
|
|
861
|
+
kit.point();
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test('git is a lens: the folder transitions in place, entities persist beneath it, channels are re-minted evidence', () => {
|
|
865
|
+
const kit = makeKit('territory');
|
|
866
|
+
kit.write('top.md', 'top');
|
|
867
|
+
kit.write('lib/tool.md', 'tool v1');
|
|
868
|
+
kit.enroll();
|
|
869
|
+
let snap = kit.point();
|
|
870
|
+
const closedId = fileIdAt(kit, 'lib/tool.md');
|
|
871
|
+
const toolUuid = snap.files.get('lib/tool.md').uuid;
|
|
872
|
+
const libUuid = snap.records.find((r) => r.uuid === toolUuid).parentUUID;
|
|
873
|
+
assert.equal(snap.records.find((r) => r.uuid === libUuid).type, 'folder');
|
|
874
|
+
|
|
875
|
+
// A .git appears: the SAME folder entity becomes the repo (a type flip on
|
|
876
|
+
// one uuid), the file entities beneath it keep existing — only their
|
|
877
|
+
// detector channels close — and the census sees exactly one boundary.
|
|
878
|
+
kit.gitInit('lib');
|
|
879
|
+
snap = kit.point();
|
|
880
|
+
assert.ok(kit.events.some((e) => e.type === 'repo.discovered' && e.path === kit.abs('lib')));
|
|
881
|
+
assert.ok(kit.events.some((e) => e.type === 'file.deleted' && e.fileId === closedId));
|
|
882
|
+
const libAsRepo = snap.records.find((r) => r.uuid === libUuid);
|
|
883
|
+
assert.equal(libAsRepo.type, 'repo.git', 'the folder becomes the repo — same entity, new type');
|
|
884
|
+
assert.equal(libAsRepo.status, 'active');
|
|
885
|
+
assert.equal(snap.records.find((r) => r.uuid === toolUuid).status, 'active',
|
|
886
|
+
'entities under the lens keep existing');
|
|
887
|
+
assert.ok(!snap.files.has('lib/tool.md'), 'covered ground leaves the exposed slice');
|
|
888
|
+
|
|
889
|
+
// Writes inside the lens are the follower's business, never file events —
|
|
890
|
+
// and covered ground is UNWITNESSED by leaf detection: no leaf entity is
|
|
891
|
+
// born under the lens (the repo adapter owns that ground's meaning).
|
|
892
|
+
const preInner = kit.events.length;
|
|
893
|
+
kit.write('lib/inner.md', 'born under the lens');
|
|
894
|
+
snap = kit.point();
|
|
895
|
+
assert.equal(kit.events.slice(preInner).filter((e) => e.type.startsWith('file.')).length, 0,
|
|
896
|
+
'repo territory must never leak file events');
|
|
897
|
+
assert.ok(!snap.records.some((r) => r.name === 'inner.md'),
|
|
898
|
+
'ground first written under the lens is nobody until it surfaces');
|
|
899
|
+
|
|
900
|
+
// The .git vanishes: the lens lifts and the SAME entities are exposed
|
|
901
|
+
// again — the slot law reconnects each parked owner — while the detector
|
|
902
|
+
// re-mints its channels, and ground first written under the lens
|
|
903
|
+
// surfaces as a NEW entity at the lift.
|
|
904
|
+
kit.gitRemove('lib');
|
|
905
|
+
snap = kit.point();
|
|
906
|
+
assert.ok(kit.events.some((e) => e.type === 'repo.removed' && e.path === kit.abs('lib')));
|
|
907
|
+
assert.equal(snap.records.find((r) => r.uuid === libUuid).type, 'folder',
|
|
908
|
+
'the lens lifts: same entity, folder again');
|
|
909
|
+
assert.equal(snap.files.get('lib/tool.md').uuid, toolUuid,
|
|
910
|
+
'the file entity survived the whole journey under the lens');
|
|
911
|
+
assert.ok(snap.files.get('lib/inner.md'),
|
|
912
|
+
'ground born under the lens surfaces as an entity when the lens lifts');
|
|
913
|
+
assert.notEqual(fileIdAt(kit, 'lib/tool.md'), closedId,
|
|
914
|
+
'the reclaimed CHANNEL is fresh — evidence is re-minted even though the entity persisted');
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test('a git repository is a git repository — including at the enrolled root: same uuid under and after the lens', () => {
|
|
918
|
+
const kit = makeKit('root-lens');
|
|
919
|
+
kit.write('a.md', 'alpha');
|
|
920
|
+
kit.write('sub/b.md', 'beta');
|
|
921
|
+
kit.enroll();
|
|
922
|
+
let snap = kit.point();
|
|
923
|
+
const rootRecord = snap.records.find((r) => r.parentUUID === null);
|
|
924
|
+
assert.equal(rootRecord.type, 'workspace');
|
|
925
|
+
const aUuid = snap.files.get('a.md').uuid;
|
|
926
|
+
const bUuid = snap.files.get('sub/b.md').uuid;
|
|
927
|
+
const aChannel = fileIdAt(kit, 'a.md');
|
|
928
|
+
|
|
929
|
+
// A .git lands at the enrolled root: the SAME root record becomes the
|
|
930
|
+
// repo — no separate workspace-versus-repository problem — and exactly
|
|
931
|
+
// one adapter owns detection: the repo owns everything, so the exposed
|
|
932
|
+
// file slice is empty while every entity beneath persists.
|
|
933
|
+
kit.gitInit('');
|
|
934
|
+
snap = kit.point();
|
|
935
|
+
assert.equal(snap.records.find((r) => r.uuid === rootRecord.uuid).type, 'repo.git',
|
|
936
|
+
'the root transitions in place on its own uuid');
|
|
937
|
+
assert.equal(snap.files.size, 0, 'under a root lens the repo adapter owns everything');
|
|
938
|
+
assert.ok(kit.events.some((e) => e.type === 'repo.discovered' && e.path === kit.dir));
|
|
939
|
+
assert.ok(kit.events.some((e) => e.type === 'file.deleted' && e.fileId === aChannel),
|
|
940
|
+
'file channels close when the repo adapter takes ownership');
|
|
941
|
+
assert.equal(snap.records.find((r) => r.uuid === aUuid).status, 'active');
|
|
942
|
+
assert.equal(snap.records.find((r) => r.uuid === bUuid).status, 'active');
|
|
943
|
+
|
|
944
|
+
// The .git goes: the same root uuid is the workspace again, file
|
|
945
|
+
// detection returns, and the same entities are exposed on fresh channels.
|
|
946
|
+
kit.gitRemove('');
|
|
947
|
+
snap = kit.point();
|
|
948
|
+
assert.equal(snap.records.find((r) => r.uuid === rootRecord.uuid).type, 'workspace',
|
|
949
|
+
'the lens lifts: same root entity, workspace again');
|
|
950
|
+
assert.equal(snap.files.get('a.md').uuid, aUuid);
|
|
951
|
+
assert.equal(snap.files.get('sub/b.md').uuid, bUuid);
|
|
952
|
+
assert.notEqual(fileIdAt(kit, 'a.md'), aChannel,
|
|
953
|
+
'channels are re-minted evidence; identity never moves');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test('a doorbell-named, backdated, same-size edit is caught — timestamps can lie, doorbells cannot', async () => {
|
|
957
|
+
const bells = fakeWatchKit();
|
|
958
|
+
const kit = makeKit('backdate', { watch: bells.watch, settleMs: 1 });
|
|
959
|
+
kit.write('f.md', 'aaaa');
|
|
960
|
+
// Pin the mtime to a SECOND-ALIGNED past instant: whole seconds survive
|
|
961
|
+
// the utimes float conversion exactly, so the forged restore below is
|
|
962
|
+
// deterministic (a millisecond value can shift by 1ms in the round-trip).
|
|
963
|
+
const pinned = new Date(Math.floor(Date.now() / 1000) * 1000 - 10_000);
|
|
964
|
+
fs.utimesSync(kit.abs('f.md'), pinned, pinned);
|
|
965
|
+
kit.enroll();
|
|
966
|
+
kit.point();
|
|
967
|
+
kit.observer.start();
|
|
968
|
+
try {
|
|
969
|
+
const row = kit.observer.filesForRoot(kit.rootId)[0];
|
|
970
|
+
assert.equal(row.mtimeMs, pinned.getTime(),
|
|
971
|
+
'a second-aligned pin must survive the stat round-trip exactly');
|
|
972
|
+
|
|
973
|
+
// Same size, same inode, mtime restored to the pinned value: to stats,
|
|
974
|
+
// nothing happened.
|
|
975
|
+
fs.writeFileSync(kit.abs('f.md'), 'bbbb');
|
|
976
|
+
kit.model.write('f.md', 'bbbb');
|
|
977
|
+
fs.utimesSync(kit.abs('f.md'), pinned, pinned);
|
|
978
|
+
assert.equal(Math.floor(fs.statSync(kit.abs('f.md')).mtimeMs), row.mtimeMs,
|
|
979
|
+
'the disguise must be exact for this test to prove anything');
|
|
980
|
+
|
|
981
|
+
// The honest boundary: without a doorbell, stats hide this edit.
|
|
982
|
+
const blind = kit.events.length;
|
|
983
|
+
kit.observer.scanNow(kit.rootId);
|
|
984
|
+
assert.equal(kit.events.length, blind, 'a perfect stat disguise defeats an unnamed scan');
|
|
985
|
+
|
|
986
|
+
// A delivered doorbell names the file; named files are always hashed.
|
|
987
|
+
await converged(kit, () => bells.ring(kit.dir, 'f.md'));
|
|
988
|
+
assert.ok(kit.events.slice(blind).some(
|
|
989
|
+
(e) => e.type === 'file.changed' && e.hash === sha256('bbbb')),
|
|
990
|
+
'the named doorbell must overrule the stat disguise');
|
|
991
|
+
|
|
992
|
+
kit.point();
|
|
993
|
+
} finally {
|
|
994
|
+
kit.observer.stop();
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('event-only: doorbells alone converge the detector — no manual scan after start()', async () => {
|
|
999
|
+
const bells = fakeWatchKit();
|
|
1000
|
+
const kit = makeKit('event-only', { watch: bells.watch, settleMs: 1 });
|
|
1001
|
+
kit.write('seed.md', 'seed');
|
|
1002
|
+
kit.enroll();
|
|
1003
|
+
kit.point(); // the catch-up era ends here
|
|
1004
|
+
kit.observer.start();
|
|
1005
|
+
try {
|
|
1006
|
+
// From now on: rings through production wiring only, never a manual
|
|
1007
|
+
// scan. The verdict runs in { scan: false } mode — the referee stays
|
|
1008
|
+
// read-only, so a detector that needed help would be convicted stale.
|
|
1009
|
+
const ring = (...names) => converged(kit, () => {
|
|
1010
|
+
for (const name of names) bells.ring(kit.dir, name);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
kit.write('live.md', 'v1');
|
|
1014
|
+
await ring('live.md');
|
|
1015
|
+
kit.point({ scan: false });
|
|
1016
|
+
|
|
1017
|
+
kit.write('live.md', 'v2 with more bytes');
|
|
1018
|
+
await ring('live.md');
|
|
1019
|
+
kit.point({ scan: false });
|
|
1020
|
+
|
|
1021
|
+
const liveId = fileIdAt(kit, 'live.md');
|
|
1022
|
+
kit.mv('live.md', 'docs/live.md');
|
|
1023
|
+
await ring('live.md', 'docs', 'docs/live.md');
|
|
1024
|
+
kit.point({ scan: false });
|
|
1025
|
+
assert.equal(fileIdAt(kit, 'docs/live.md'), liveId,
|
|
1026
|
+
'an event-driven move must preserve identity');
|
|
1027
|
+
|
|
1028
|
+
kit.replace('docs/live.md', 'v3 atomically');
|
|
1029
|
+
await ring('docs/live.md');
|
|
1030
|
+
kit.point({ scan: false });
|
|
1031
|
+
|
|
1032
|
+
kit.rm('seed.md');
|
|
1033
|
+
await ring('seed.md');
|
|
1034
|
+
kit.point({ scan: false });
|
|
1035
|
+
|
|
1036
|
+
// A settled event-only detector says nothing more.
|
|
1037
|
+
const settled = kit.events.length;
|
|
1038
|
+
await sleep(100);
|
|
1039
|
+
assert.equal(kit.events.length, settled,
|
|
1040
|
+
'no timer, no scan, no doorbell — silence must hold');
|
|
1041
|
+
} finally {
|
|
1042
|
+
kit.observer.stop();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// --- the judge must convict when fed lies ---------------------------------
|
|
1047
|
+
|
|
1048
|
+
test('the judge convicts a stale detector', () => {
|
|
1049
|
+
const kit = makeKit('convict-stale');
|
|
1050
|
+
kit.write('f.md', 'v1');
|
|
1051
|
+
kit.enroll();
|
|
1052
|
+
kit.point();
|
|
1053
|
+
|
|
1054
|
+
// Disk moves, detection is never given a chance: the referee must object.
|
|
1055
|
+
fs.writeFileSync(kit.abs('f.md'), 'v2');
|
|
1056
|
+
kit.model.write('f.md', 'v2');
|
|
1057
|
+
assert.throws(() => kit.point({ scan: false }), /stale/);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test('the judge convicts a ghost believer', () => {
|
|
1061
|
+
const kit = makeKit('convict-ghost');
|
|
1062
|
+
kit.write('f.md', 'v1');
|
|
1063
|
+
kit.enroll();
|
|
1064
|
+
kit.point();
|
|
1065
|
+
|
|
1066
|
+
fs.rmSync(kit.abs('f.md'));
|
|
1067
|
+
kit.model.remove('f.md');
|
|
1068
|
+
assert.throws(() => kit.point({ scan: false }), /ghost/);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('the judge convicts a stale link detector', () => {
|
|
1072
|
+
const kit = makeKit('convict-stale-link');
|
|
1073
|
+
kit.symlink('ln.md', 'v1-target');
|
|
1074
|
+
kit.enroll();
|
|
1075
|
+
kit.point();
|
|
1076
|
+
|
|
1077
|
+
// The pointer moves, detection is never given a chance.
|
|
1078
|
+
fs.rmSync(kit.abs('ln.md'));
|
|
1079
|
+
fs.symlinkSync('v2-target', kit.abs('ln.md'));
|
|
1080
|
+
kit.model.writeLink('ln.md', 'v2-target');
|
|
1081
|
+
assert.throws(() => kit.point({ scan: false }), /stale at link/);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('a link channel may not claim a target never written', () => {
|
|
1085
|
+
const kit = makeKit('convict-link-truth');
|
|
1086
|
+
kit.symlink('ln.md', 'true-target');
|
|
1087
|
+
kit.write('base.md', 'bytes');
|
|
1088
|
+
kit.enroll();
|
|
1089
|
+
kit.point();
|
|
1090
|
+
|
|
1091
|
+
// A forged round trip: the chain holds and the store still matches, so
|
|
1092
|
+
// only per-entity emitted-truth can convict it.
|
|
1093
|
+
const channel = linkIdAt(kit, 'ln.md');
|
|
1094
|
+
kit.events.push(
|
|
1095
|
+
{
|
|
1096
|
+
type: 'link.changed', rootId: kit.rootId, linkId: channel, path: 'ln.md',
|
|
1097
|
+
target: 'forged', previousTarget: 'true-target',
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
type: 'link.changed', rootId: kit.rootId, linkId: channel, path: 'ln.md',
|
|
1101
|
+
target: 'true-target', previousTarget: 'forged',
|
|
1102
|
+
},
|
|
1103
|
+
);
|
|
1104
|
+
assert.throws(() => kit.point(), /never written for that entity/);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
test('the judge convicts its own drifting model — the two truths cross-check', () => {
|
|
1108
|
+
const kit = makeKit('convict-model');
|
|
1109
|
+
kit.write('f.md', 'v1');
|
|
1110
|
+
kit.enroll();
|
|
1111
|
+
kit.point();
|
|
1112
|
+
|
|
1113
|
+
// A file appears that the operation model never recorded: the referee
|
|
1114
|
+
// must refuse to bless the run even though the detector saw it fine.
|
|
1115
|
+
fs.writeFileSync(kit.abs('unrecorded.md'), 'the model never heard of me');
|
|
1116
|
+
assert.throws(() => kit.point(), /model missing/);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
test('the judge convicts a version claimed for the wrong channel', () => {
|
|
1120
|
+
const kit = makeKit('convict-channel');
|
|
1121
|
+
kit.write('a.md', 'alpha');
|
|
1122
|
+
kit.write('b.md', 'beta');
|
|
1123
|
+
kit.enroll();
|
|
1124
|
+
kit.point();
|
|
1125
|
+
|
|
1126
|
+
// Forge a round trip: B claims hash(alpha) — true for A, never for B —
|
|
1127
|
+
// then claims its real hash back. The ledger chain holds and the store
|
|
1128
|
+
// still matches, so ONLY per-entity truth can convict this.
|
|
1129
|
+
const bId = fileIdAt(kit, 'b.md');
|
|
1130
|
+
kit.events.push(
|
|
1131
|
+
{
|
|
1132
|
+
type: 'file.changed', rootId: kit.rootId, fileId: bId, path: 'b.md',
|
|
1133
|
+
hash: sha256('alpha'), previousHash: sha256('beta'),
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
type: 'file.changed', rootId: kit.rootId, fileId: bId, path: 'b.md',
|
|
1137
|
+
hash: sha256('beta'), previousHash: sha256('alpha'),
|
|
1138
|
+
},
|
|
1139
|
+
);
|
|
1140
|
+
assert.throws(() => kit.point(), /never true for that entity/);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
test('a short-lived channel may not lie either: transients are judged at their address', async () => {
|
|
1144
|
+
const bells = fakeWatchKit();
|
|
1145
|
+
const kit = makeKit('convict-transient', { watch: bells.watch, settleMs: 1 });
|
|
1146
|
+
kit.write('long.md', 'longlived');
|
|
1147
|
+
kit.enroll();
|
|
1148
|
+
kit.point();
|
|
1149
|
+
kit.observer.start();
|
|
1150
|
+
try {
|
|
1151
|
+
const ring = (name) => converged(kit, () => bells.ring(kit.dir, name));
|
|
1152
|
+
|
|
1153
|
+
// Control: a channel born and dead inside one interval, telling the
|
|
1154
|
+
// truth, passes — its address vouches for every version it claimed.
|
|
1155
|
+
kit.write('honest.md', 'true words');
|
|
1156
|
+
await ring('honest.md');
|
|
1157
|
+
kit.rm('honest.md');
|
|
1158
|
+
await ring('honest.md');
|
|
1159
|
+
kit.point({ scan: false });
|
|
1160
|
+
|
|
1161
|
+
// A transient claiming a hash that was never true AT ITS ADDRESS —
|
|
1162
|
+
// only ever true for the long-lived neighbor — must be convicted, even
|
|
1163
|
+
// though it never settles and so never binds to an entity.
|
|
1164
|
+
kit.write('flash.md', 'flash truth');
|
|
1165
|
+
await ring('flash.md');
|
|
1166
|
+
const flashId = fileIdAt(kit, 'flash.md');
|
|
1167
|
+
kit.events.push(
|
|
1168
|
+
{
|
|
1169
|
+
type: 'file.changed', rootId: kit.rootId, fileId: flashId, path: 'flash.md',
|
|
1170
|
+
hash: sha256('longlived'), previousHash: sha256('flash truth'),
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
type: 'file.changed', rootId: kit.rootId, fileId: flashId, path: 'flash.md',
|
|
1174
|
+
hash: sha256('flash truth'), previousHash: sha256('longlived'),
|
|
1175
|
+
},
|
|
1176
|
+
);
|
|
1177
|
+
kit.rm('flash.md');
|
|
1178
|
+
await ring('flash.md');
|
|
1179
|
+
assert.throws(() => kit.point({ scan: false }), /never true at that address/);
|
|
1180
|
+
} finally {
|
|
1181
|
+
kit.observer.stop();
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('address history is no alibi for a bound channel: only its contested ground answers there', async () => {
|
|
1186
|
+
const bells = fakeWatchKit();
|
|
1187
|
+
const kit = makeKit('convict-waypoint', { watch: bells.watch, settleMs: 1 });
|
|
1188
|
+
kit.write('a.md', 'alpha');
|
|
1189
|
+
kit.enroll();
|
|
1190
|
+
kit.point();
|
|
1191
|
+
kit.observer.start();
|
|
1192
|
+
try {
|
|
1193
|
+
const ring = (...names) => converged(kit, () => {
|
|
1194
|
+
for (const name of names) bells.ring(kit.dir, name);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
// Foreign bytes genuinely occupy way.md this interval: a transient
|
|
1198
|
+
// tenant arrives and leaves without ever settling into an entity.
|
|
1199
|
+
kit.write('way.md', 'foreign tenure');
|
|
1200
|
+
await ring('way.md');
|
|
1201
|
+
kit.rm('way.md');
|
|
1202
|
+
await ring('way.md');
|
|
1203
|
+
|
|
1204
|
+
// The entity's channel then passes THROUGH way.md on its way to z.md —
|
|
1205
|
+
// an honest multi-hop move. While parked there, a forged round trip
|
|
1206
|
+
// claims it cycled through the dead tenant's bytes: true AT the
|
|
1207
|
+
// address this interval, never true for THIS entity. The ledger chain
|
|
1208
|
+
// holds and the store ends up matching, and the address is not
|
|
1209
|
+
// contested ground — so an address escape for bound channels would
|
|
1210
|
+
// swallow the lie whole. Only per-entity truth convicts it.
|
|
1211
|
+
const aId = fileIdAt(kit, 'a.md');
|
|
1212
|
+
kit.mv('a.md', 'way.md');
|
|
1213
|
+
await ring('a.md', 'way.md');
|
|
1214
|
+
kit.events.push(
|
|
1215
|
+
{
|
|
1216
|
+
type: 'file.changed', rootId: kit.rootId, fileId: aId, path: 'way.md',
|
|
1217
|
+
hash: sha256('foreign tenure'), previousHash: sha256('alpha'),
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
type: 'file.changed', rootId: kit.rootId, fileId: aId, path: 'way.md',
|
|
1221
|
+
hash: sha256('alpha'), previousHash: sha256('foreign tenure'),
|
|
1222
|
+
},
|
|
1223
|
+
);
|
|
1224
|
+
kit.mv('way.md', 'z.md');
|
|
1225
|
+
await ring('way.md', 'z.md');
|
|
1226
|
+
assert.throws(() => kit.point({ scan: false }), /never true for that entity/);
|
|
1227
|
+
} finally {
|
|
1228
|
+
kit.observer.stop();
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test('an honest edit on contested ground answers with the boundary it fired under, even after the ground moves', async () => {
|
|
1233
|
+
const bells = fakeWatchKit();
|
|
1234
|
+
const kit = makeKit('contest-boundary', { watch: bells.watch, settleMs: 1 });
|
|
1235
|
+
kit.write('a.txt', 'alpha');
|
|
1236
|
+
kit.write('b.txt', 'beta');
|
|
1237
|
+
kit.enroll();
|
|
1238
|
+
kit.point();
|
|
1239
|
+
kit.observer.start();
|
|
1240
|
+
try {
|
|
1241
|
+
const ring = (...names) => converged(kit, () => {
|
|
1242
|
+
for (const name of names) bells.ring(kit.dir, name);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
// The surplus arc, event-driven: the owner parks, the claimant's
|
|
1246
|
+
// ground lands on its slot, and fresh ground re-grounds the claimant
|
|
1247
|
+
// at home — its contested channel is now surplus evidence.
|
|
1248
|
+
kit.rm('b.txt');
|
|
1249
|
+
await ring('b.txt');
|
|
1250
|
+
kit.point({ scan: false });
|
|
1251
|
+
kit.mv('a.txt', 'b.txt');
|
|
1252
|
+
await ring('a.txt', 'b.txt');
|
|
1253
|
+
kit.point({ scan: false });
|
|
1254
|
+
kit.write('a.txt', 'returned');
|
|
1255
|
+
await ring('a.txt');
|
|
1256
|
+
let snap = kit.point({ scan: false });
|
|
1257
|
+
assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
|
|
1258
|
+
|
|
1259
|
+
// ONE interval: an honest edit at contested b.txt, then the ground
|
|
1260
|
+
// moves on to c.txt. At the settle the contest sits at c.txt — but the
|
|
1261
|
+
// change event fired at b.txt, under the OPENING boundary's contest. A
|
|
1262
|
+
// judge reading only the closing snapshot would falsely convict the
|
|
1263
|
+
// honest event it just watched happen.
|
|
1264
|
+
kit.write('b.txt', 'alpha, edited while contested');
|
|
1265
|
+
await ring('b.txt');
|
|
1266
|
+
kit.mv('b.txt', 'c.txt');
|
|
1267
|
+
await ring('b.txt', 'c.txt');
|
|
1268
|
+
snap = kit.point({ scan: false });
|
|
1269
|
+
assert.equal(snap.divergent.get('c.txt'), sha256('alpha, edited while contested'),
|
|
1270
|
+
'the surplus contest followed the edited ground');
|
|
1271
|
+
assert.ok(!snap.files.has('c.txt'), 'no entity claims it');
|
|
1272
|
+
} finally {
|
|
1273
|
+
kit.observer.stop();
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('the judge convicts tampered event streams', () => {
|
|
1278
|
+
const { createLedger: freshLedger } = require('./entity-oracle/ledger');
|
|
1279
|
+
|
|
1280
|
+
// A fresh identity must be fresh — even after its owner died.
|
|
1281
|
+
let ledger = freshLedger();
|
|
1282
|
+
ledger.ingest([
|
|
1283
|
+
{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' },
|
|
1284
|
+
{ type: 'file.deleted', rootId: 'r', fileId: 'F1', path: 'a.md' },
|
|
1285
|
+
]);
|
|
1286
|
+
assert.throws(() => ledger.ingest([
|
|
1287
|
+
{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'b.md', hash: 'h2' },
|
|
1288
|
+
]), /used before/);
|
|
1289
|
+
|
|
1290
|
+
// The hash chain must hold.
|
|
1291
|
+
ledger = freshLedger();
|
|
1292
|
+
ledger.ingest([{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' }]);
|
|
1293
|
+
assert.throws(() => ledger.ingest([
|
|
1294
|
+
{ type: 'file.changed', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h3', previousHash: 'h2' },
|
|
1295
|
+
]), /chain/);
|
|
1296
|
+
|
|
1297
|
+
// Equal versions are silence.
|
|
1298
|
+
ledger = freshLedger();
|
|
1299
|
+
ledger.ingest([{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' }]);
|
|
1300
|
+
assert.throws(() => ledger.ingest([
|
|
1301
|
+
{ type: 'file.changed', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1', previousHash: 'h1' },
|
|
1302
|
+
]), /silence/);
|
|
1303
|
+
|
|
1304
|
+
// A rename must start where the channel lives.
|
|
1305
|
+
ledger = freshLedger();
|
|
1306
|
+
ledger.ingest([{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' }]);
|
|
1307
|
+
assert.throws(() => ledger.ingest([
|
|
1308
|
+
{ type: 'file.renamed', rootId: 'r', fileId: 'F1', from: 'elsewhere.md', to: 'b.md' },
|
|
1309
|
+
]), /rename from/);
|
|
1310
|
+
|
|
1311
|
+
// Nothing speaks after deletion.
|
|
1312
|
+
ledger = freshLedger();
|
|
1313
|
+
ledger.ingest([
|
|
1314
|
+
{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' },
|
|
1315
|
+
{ type: 'file.deleted', rootId: 'r', fileId: 'F1', path: 'a.md' },
|
|
1316
|
+
]);
|
|
1317
|
+
assert.throws(() => ledger.ingest([
|
|
1318
|
+
{ type: 'file.changed', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h2', previousHash: 'h1' },
|
|
1319
|
+
]), /after deletion/);
|
|
1320
|
+
|
|
1321
|
+
// Territories: discovered twice without a removal is a duplicate claim.
|
|
1322
|
+
ledger = freshLedger();
|
|
1323
|
+
ledger.ingest([{ type: 'repo.discovered', rootId: 'r2', path: '/tmp/x' }]);
|
|
1324
|
+
assert.throws(() => ledger.ingest([
|
|
1325
|
+
{ type: 'repo.discovered', rootId: 'r3', path: '/tmp/x' },
|
|
1326
|
+
]), /twice/);
|
|
1327
|
+
|
|
1328
|
+
// Link channels obey the same stream law, with the target chain.
|
|
1329
|
+
ledger = freshLedger();
|
|
1330
|
+
ledger.ingest([{ type: 'link.created', rootId: 'r', linkId: 'L1', path: 'a', target: 't1' }]);
|
|
1331
|
+
assert.throws(() => ledger.ingest([
|
|
1332
|
+
{ type: 'link.changed', rootId: 'r', linkId: 'L1', path: 'a', target: 't3', previousTarget: 't2' },
|
|
1333
|
+
]), /target chain/);
|
|
1334
|
+
|
|
1335
|
+
ledger = freshLedger();
|
|
1336
|
+
ledger.ingest([{ type: 'link.created', rootId: 'r', linkId: 'L1', path: 'a', target: 't1' }]);
|
|
1337
|
+
assert.throws(() => ledger.ingest([
|
|
1338
|
+
{ type: 'link.changed', rootId: 'r', linkId: 'L1', path: 'a', target: 't1', previousTarget: 't1' },
|
|
1339
|
+
]), /silence/);
|
|
1340
|
+
|
|
1341
|
+
ledger = freshLedger();
|
|
1342
|
+
ledger.ingest([
|
|
1343
|
+
{ type: 'link.created', rootId: 'r', linkId: 'L1', path: 'a', target: 't1' },
|
|
1344
|
+
{ type: 'link.deleted', rootId: 'r', linkId: 'L1', path: 'a' },
|
|
1345
|
+
]);
|
|
1346
|
+
assert.throws(() => ledger.ingest([
|
|
1347
|
+
{ type: 'link.created', rootId: 'r', linkId: 'L1', path: 'b', target: 't2' },
|
|
1348
|
+
]), /used before/);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
// --- independence: the detector and its judge may not share a bug ---------
|
|
1352
|
+
|
|
1353
|
+
test('the oracle imports nothing from the detector', () => {
|
|
1354
|
+
const oracleDir = path.join(__dirname, 'entity-oracle');
|
|
1355
|
+
const allowed = /^(node:)?(assert|crypto|fs|path)(\/.*)?$|^\.\/(?!\.)[\w-]+$/;
|
|
1356
|
+
for (const name of fs.readdirSync(oracleDir).filter((f) => f.endsWith('.js'))) {
|
|
1357
|
+
const source = fs.readFileSync(path.join(oracleDir, name), 'utf8');
|
|
1358
|
+
for (const [, , spec] of source.matchAll(/require\((['"])([^'"]+)\1\)/g)) {
|
|
1359
|
+
assert.match(spec, allowed,
|
|
1360
|
+
`${name} requires "${spec}" — the oracle may only use node built-ins and its own modules`);
|
|
1361
|
+
assert.doesNotMatch(spec, /observer|repocard|workspace|scan|store|follow/,
|
|
1362
|
+
`${name} requires "${spec}" — the judge must not share the detector's code`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
});
|