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,289 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * LINK exams: a pointer is a channel whose whole payload is its written
5
+ * address — recorded verbatim, never followed — plus the enrollment
6
+ * policy's edge-following, which turns a pointer into a reason to
7
+ * enroll the ground it names (and only that ground).
8
+ */
9
+
10
+ const test = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const { execFileSync } = require('child_process');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const { followEdges } = require('../observer/edges');
17
+ const {
18
+ cleanup, eventsOf, makeDir, makeObserver,
19
+ } = require('./observer.rig');
20
+
21
+ test.after(cleanup);
22
+
23
+ // --- links: a pointer is a channel whose payload is its written address ---
24
+
25
+ test('a symlink is a channel whose payload is its written address — recorded verbatim, never followed', () => {
26
+ const dir = makeDir('links');
27
+ fs.mkdirSync(path.join(dir, 'real'));
28
+ fs.writeFileSync(path.join(dir, 'real', 'doc.md'), 'doc');
29
+ fs.symlinkSync('real', path.join(dir, 'shortcut')); // relative, into enrolled ground
30
+ fs.symlinkSync('no/such/path', path.join(dir, 'dangling')); // broken: a full citizen
31
+ fs.symlinkSync('/outside/absolute', path.join(dir, 'away')); // absolute, outside
32
+
33
+ const { observer, events } = makeObserver();
34
+ const root = observer.enrollRoot(dir);
35
+
36
+ assert.deepEqual(eventsOf(events, 'link.created').map((event) => [event.path, event.target]).sort(),
37
+ [['away', '/outside/absolute'], ['dangling', 'no/such/path'], ['shortcut', 'real']],
38
+ 'every pointer is a channel — broken or absolute included — its address verbatim, never stat\'d');
39
+ assert.deepEqual(eventsOf(events, 'file.created').map((event) => event.path), ['real/doc.md'],
40
+ 'the pointer was never traversed: real ground is walked once, at its real address');
41
+ assert.deepEqual(observer.verify(root.rootId), []);
42
+ });
43
+
44
+ test('a retarget is the same channel: the address absorbs the inode churn, like an atomic save', () => {
45
+ const dir = makeDir('retarget');
46
+ fs.mkdirSync(path.join(dir, 'v1'));
47
+ fs.mkdirSync(path.join(dir, 'v2'));
48
+ fs.symlinkSync('v1', path.join(dir, 'current'));
49
+ const { observer, events } = makeObserver();
50
+ const root = observer.enrollRoot(dir);
51
+ const linkId = eventsOf(events, 'link.created')[0].linkId;
52
+
53
+ // A retarget IS unlink + recreate: a new inode under the old name with a
54
+ // new written address, and the address is the whole change.
55
+ fs.unlinkSync(path.join(dir, 'current'));
56
+ fs.symlinkSync('v2', path.join(dir, 'current'));
57
+ const mark = events.length;
58
+ observer.scanNow(root.rootId);
59
+ assert.deepEqual(eventsOf(events.slice(mark), 'link.changed')
60
+ .map((event) => [event.linkId, event.target, event.previousTarget]),
61
+ [[linkId, 'v2', 'v1']], 'same channel, new address, previous address named');
62
+ assert.equal(events.length - mark, 1, 'nothing else spoke');
63
+
64
+ // Recreation with the SAME address: the same pointer wearing a new inode.
65
+ fs.unlinkSync(path.join(dir, 'current'));
66
+ fs.symlinkSync('v2', path.join(dir, 'current'));
67
+ const quiet = events.length;
68
+ observer.scanNow(root.rootId);
69
+ assert.equal(events.length, quiet, 'recreating the same pointer is silence');
70
+ assert.equal(observer.linksForRoot(root.rootId).find((row) => row.relPath === 'current').linkId,
71
+ linkId, 'the recreated pointer wears the same channel');
72
+ assert.deepEqual(observer.verify(root.rootId), []);
73
+ });
74
+
75
+ test('mv moves the channel by inode; an exchange of names reads as two retargets', () => {
76
+ const dir = makeDir('link-moves');
77
+ fs.mkdirSync(path.join(dir, 'a'));
78
+ fs.mkdirSync(path.join(dir, 'b'));
79
+ fs.symlinkSync('a', path.join(dir, 'to-a'));
80
+ fs.symlinkSync('b', path.join(dir, 'to-b'));
81
+ const { observer, events } = makeObserver();
82
+ const root = observer.enrollRoot(dir);
83
+ const before = new Map(observer.linksForRoot(root.rootId).map((row) => [row.relPath, row.linkId]));
84
+
85
+ fs.renameSync(path.join(dir, 'to-a'), path.join(dir, 'renamed'));
86
+ let mark = events.length;
87
+ observer.scanNow(root.rootId);
88
+ assert.deepEqual(eventsOf(events.slice(mark), 'link.renamed')
89
+ .map((event) => [event.from, event.to, event.linkId]),
90
+ [['to-a', 'renamed', before.get('to-a')]], 'mv preserves the inode, and the inode rescues the orphan');
91
+ assert.equal(events.length - mark, 1);
92
+
93
+ // Two links exchanging names: the address claims first, so each address
94
+ // keeps its channel and reports a new pointer — a link's meaning IS its
95
+ // written address, exactly like two files exchanging content.
96
+ fs.renameSync(path.join(dir, 'renamed'), path.join(dir, 'limbo'));
97
+ fs.renameSync(path.join(dir, 'to-b'), path.join(dir, 'renamed'));
98
+ fs.renameSync(path.join(dir, 'limbo'), path.join(dir, 'to-b'));
99
+ mark = events.length;
100
+ observer.scanNow(root.rootId);
101
+ assert.deepEqual(eventsOf(events.slice(mark), 'link.changed')
102
+ .map((event) => [event.path, event.target, event.previousTarget, event.linkId]).sort(),
103
+ [['renamed', 'b', 'a', before.get('to-a')], ['to-b', 'a', 'b', before.get('to-b')]].sort(),
104
+ 'path-first: each address keeps its channel and reports the new pointer');
105
+ assert.equal(events.length - mark, 2, 'no rename spoke: an exchange of names is two retargets');
106
+ assert.deepEqual(observer.verify(root.rootId), []);
107
+ });
108
+
109
+ test('link channels close when the root becomes repo territory', () => {
110
+ const dir = makeDir('links-repo');
111
+ fs.mkdirSync(path.join(dir, 'real'));
112
+ fs.symlinkSync('real', path.join(dir, 'shortcut'));
113
+ const { observer, events } = makeObserver();
114
+ const root = observer.enrollRoot(dir);
115
+ const linkId = eventsOf(events, 'link.created')[0].linkId;
116
+
117
+ fs.mkdirSync(path.join(dir, '.git'));
118
+ const mark = events.length;
119
+ observer.scanNow(root.rootId);
120
+ assert.deepEqual(eventsOf(events.slice(mark), 'link.deleted').map((event) => event.linkId), [linkId],
121
+ 'repo territory travels as one card: the pointer channel closes with the rest');
122
+ assert.equal(observer.linksForRoot(root.rootId).length, 0);
123
+ });
124
+
125
+ test('the referee catches a retarget behind the observer\'s back', () => {
126
+ const dir = makeDir('link-referee');
127
+ fs.mkdirSync(path.join(dir, 'v1'));
128
+ fs.mkdirSync(path.join(dir, 'v2'));
129
+ fs.symlinkSync('v1', path.join(dir, 'current'));
130
+ const { observer } = makeObserver();
131
+ const root = observer.enrollRoot(dir);
132
+ assert.deepEqual(observer.verify(root.rootId), []);
133
+
134
+ fs.unlinkSync(path.join(dir, 'current'));
135
+ fs.symlinkSync('v2', path.join(dir, 'current'));
136
+ assert.deepEqual(observer.verify(root.rootId).map((m) => [m.path, m.problem]),
137
+ [['current', 'stale: the link was retargeted, the observer did not notice']],
138
+ 'the referee re-reads every pointer with none of the observer\'s machinery');
139
+ });
140
+
141
+ // --- edges: enrollment follows pointers to real ground (ratified policy) --
142
+
143
+ test('enrollment follows edges: an outside target becomes enrolled ground, exactly once', () => {
144
+ const outside = makeDir('edge-target');
145
+ fs.writeFileSync(path.join(outside, 'shared.md'), 'shared');
146
+ const dir = makeDir('edge-ws');
147
+ fs.symlinkSync(outside, path.join(dir, 'shared'));
148
+ fs.symlinkSync(outside, path.join(dir, 'also-shared'));
149
+
150
+ const { observer } = makeObserver();
151
+ observer.enrollRoot(dir);
152
+ const { enrolled, reported } = followEdges(observer);
153
+ assert.deepEqual(enrolled, [outside], 'two pointers, one real ground, one enrollment');
154
+ assert.deepEqual(reported, []);
155
+ const target = observer.listRoots().find((root) => root.path === outside);
156
+ assert.ok(target, 'the real target is enrolled ground now');
157
+ assert.ok(observer.filesForRoot(target.rootId).some((row) => row.relPath === 'shared.md'),
158
+ 'its tree got channels at its real address');
159
+ assert.deepEqual(followEdges(observer), { enrolled: [], reported: [], settled: true },
160
+ 'idempotent: nothing left to follow');
161
+ });
162
+
163
+ test('edge cascade terminates: a cycle of grounds enrolls each exactly once', () => {
164
+ const a = makeDir('cycle-a');
165
+ const b = makeDir('cycle-b');
166
+ fs.symlinkSync(b, path.join(a, 'to-b'));
167
+ fs.symlinkSync(a, path.join(b, 'to-a'));
168
+
169
+ const { observer } = makeObserver();
170
+ observer.enrollRoot(a);
171
+ const { enrolled } = followEdges(observer);
172
+ assert.deepEqual(enrolled, [b], 'b via a\'s edge; a was already ground when b\'s edge came up');
173
+ assert.equal(observer.listRoots().length, 2, 'the cycle added no third root and never looped');
174
+ });
175
+
176
+ test('edge verdicts: broken is silence, would-swallow is refused, a file target enrolls itself alone', () => {
177
+ const holder = makeDir('edge-holder');
178
+ const ws = path.join(holder, 'inner', 'ws');
179
+ fs.mkdirSync(ws, { recursive: true });
180
+ const filePath = path.join(makeDir('edge-file'), 'single.txt');
181
+ fs.writeFileSync(filePath, 'one');
182
+ fs.symlinkSync(path.join(holder, 'gone'), path.join(ws, 'dangling'));
183
+ fs.symlinkSync(filePath, path.join(ws, 'one-file'));
184
+ fs.symlinkSync(holder, path.join(ws, 'up')); // contains the enrolled ws
185
+
186
+ const { observer } = makeObserver();
187
+ observer.enrollRoot(ws);
188
+ const { enrolled, reported } = followEdges(observer);
189
+ assert.deepEqual(enrolled, [filePath],
190
+ 'a real file target is real ground: it enrolls as its own single-file root');
191
+ assert.deepEqual(reported.map((entry) => [entry.path, entry.via]),
192
+ [[holder, 'up']],
193
+ 'the would-swallow edge is refused by name; a broken pointer is silence, not a refusal');
194
+ const fileRoot = observer.listRoots().find((root) => root.path === filePath);
195
+ assert.equal(fileRoot.kind, 'file', 'the smallest root there is');
196
+ assert.ok(!observer.listRoots().some((root) => root.path === path.dirname(filePath)),
197
+ 'never its parent: siblings the edge never named stay unshared');
198
+ });
199
+
200
+ test('a retarget enrolls the new target; following only ever adds', () => {
201
+ const first = makeDir('edge-old');
202
+ const second = makeDir('edge-new');
203
+ const dir = makeDir('edge-retarget');
204
+ fs.symlinkSync(first, path.join(dir, 'ptr'));
205
+
206
+ const { observer } = makeObserver();
207
+ const root = observer.enrollRoot(dir);
208
+ followEdges(observer);
209
+ fs.unlinkSync(path.join(dir, 'ptr'));
210
+ fs.symlinkSync(second, path.join(dir, 'ptr'));
211
+ observer.scanNow(root.rootId);
212
+ const { enrolled } = followEdges(observer);
213
+ assert.deepEqual(enrolled, [second], 'the new ground is followed');
214
+ assert.ok(observer.listRoots().some((other) => other.path === first),
215
+ 'the old target stays enrolled — removing coverage is an explicit act, never a side effect');
216
+ });
217
+
218
+ test('a repository exposes its edges too: the census reads the worktree, and following enrolls the target', () => {
219
+ const outside = makeDir('repo-edge-target');
220
+ fs.writeFileSync(path.join(outside, 'shared.md'), 'shared');
221
+ const repo = makeDir('repo-edge');
222
+ execFileSync('git', ['init', '-q'], { cwd: repo });
223
+ fs.mkdirSync(path.join(repo, 'docs'));
224
+ fs.symlinkSync(outside, path.join(repo, 'docs', 'shared'));
225
+ fs.symlinkSync('../elsewhere', path.join(repo, 'inward')); // broken: silence
226
+
227
+ const { observer } = makeObserver();
228
+ const root = observer.enrollRoot(repo);
229
+ assert.equal(root.kind, 'repo');
230
+ assert.deepEqual(observer.linkEdges(root.rootId),
231
+ { edges: [{ relPath: 'docs/shared', target: outside }, { relPath: 'inward', target: '../elsewhere' }], unsettled: [] },
232
+ 'a repo\'s edges come from a fresh worktree census — stored nowhere, git owns the repo\'s memory');
233
+ const { enrolled, reported } = followEdges(observer);
234
+ assert.deepEqual(enrolled, [outside],
235
+ 'a link inside repo territory reaches real ground exactly like one in files territory');
236
+ assert.deepEqual(reported, []);
237
+ const target = observer.listRoots().find((other) => other.path === outside);
238
+ assert.ok(observer.filesForRoot(target.rootId).some((row) => row.relPath === 'shared.md'),
239
+ 'the external target is watched ground now');
240
+ });
241
+
242
+ test('exactly the named target enrolls: a link authorizes its ground, never the repository around it', () => {
243
+ const elsewhere = makeDir('exact-holder');
244
+ const repo = path.join(elsewhere, 'lib-repo');
245
+ fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
246
+ execFileSync('git', ['init', '-q'], { cwd: repo });
247
+ fs.writeFileSync(path.join(repo, 'src', 'util.js'), 'x');
248
+ const ws = makeDir('exact-ws');
249
+ fs.symlinkSync(path.join(repo, 'src'), path.join(ws, 'lib-src'));
250
+
251
+ const { observer } = makeObserver();
252
+ observer.enrollRoot(ws);
253
+ const { enrolled } = followEdges(observer);
254
+ assert.deepEqual(enrolled, [path.join(repo, 'src')],
255
+ 'the edge named src, so src is the ground that enrolls — an enclosing .git the user never named authorizes nothing');
256
+ assert.equal(observer.listRoots().find((root) => root.path === path.join(repo, 'src')).kind, 'files');
257
+ assert.ok(!observer.listRoots().some((root) => root.path === repo),
258
+ 'the repository around the named ground stays unshared');
259
+
260
+ // Named directly, a repository is its own ground: the exact target IS
261
+ // the repo root, so it enrolls as one card.
262
+ const ws2 = makeDir('exact-ws2');
263
+ const repo2 = makeDir('exact-repo2');
264
+ execFileSync('git', ['init', '-q'], { cwd: repo2 });
265
+ fs.symlinkSync(repo2, path.join(ws2, 'lib'));
266
+ const second = makeObserver().observer;
267
+ second.enrollRoot(ws2);
268
+ followEdges(second);
269
+ assert.equal(second.listRoots().find((root) => root.path === repo2).kind, 'repo',
270
+ 'a target that is itself a repository root enrolls as the repository');
271
+ });
272
+
273
+ test('the enrollment policy gates every route: an excluded edge target is refused and reported', () => {
274
+ const holder = makeDir('policy-holder');
275
+ const envPath = path.join(holder, '.env');
276
+ fs.writeFileSync(envPath, 'SECRET=1');
277
+ const ws = makeDir('policy-ws');
278
+ fs.symlinkSync(envPath, path.join(ws, 'config'));
279
+
280
+ const { observer } = makeObserver(); // the DEFAULT policy — the one production runs
281
+ observer.enrollRoot(ws);
282
+ const { enrolled, reported } = followEdges(observer);
283
+ assert.deepEqual(enrolled, []);
284
+ assert.deepEqual(reported.map((entry) => [entry.path, entry.via]), [[envPath, 'config']],
285
+ 'the refusal is loud, never silent');
286
+ assert.match(reported[0].reason, /enrollment policy/);
287
+ assert.ok(!observer.listRoots().some((root) => root.path === envPath),
288
+ 'a secret stays unwatched: an edge is never a way around the policy');
289
+ });
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The shared rig for the observer suites — a sandbox with a real
5
+ * observer over an in-memory store, real git repositories, and the
6
+ * deterministic doorbell. The exams live beside it, one file per
7
+ * seam: observer.test.js (enrollment and the compare function),
8
+ * observer.links.test.js (pointers and edge-following),
9
+ * observer.roots.test.js (root continuity and the smallest ground),
10
+ * observer.territory.test.js (repositories and unsettled ground).
11
+ *
12
+ * This file may require production code — it drives the system under
13
+ * exam.
14
+ */
15
+
16
+ const { execFileSync } = require('child_process');
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ const Database = require('better-sqlite3');
22
+ const { createObserver } = require('../observer');
23
+
24
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-observer-test-'));
25
+
26
+ function cleanup() {
27
+ fs.rmSync(tempRoot, { recursive: true, force: true });
28
+ }
29
+
30
+ /** A fresh observer over an in-memory db, capturing every emitted event. */
31
+ function makeObserver(options = {}) {
32
+ const events = [];
33
+ const observer = createObserver({
34
+ db: new Database(':memory:'),
35
+ emit: (event) => events.push(event),
36
+ ...options,
37
+ });
38
+ return { observer, events };
39
+ }
40
+
41
+ function makeDir(name) {
42
+ const dir = fs.mkdtempSync(path.join(tempRoot, `${name}-`));
43
+ return fs.realpathSync(dir);
44
+ }
45
+
46
+ function eventsOf(events, type) {
47
+ return events.filter((event) => event.type === type);
48
+ }
49
+
50
+ /**
51
+ * OS watchers are lossy by contract — a write landing in a just-started
52
+ * stream's warm-up window can vanish without a doorbell (axiom 2; the
53
+ * system heals this with catch-up scans). The live tests here claim
54
+ * routing and classification, not first-write delivery, so the write is
55
+ * repeated until a doorbell answers. Writes must be idempotent (same
56
+ * content) so repetition never changes what the test asserts.
57
+ */
58
+ async function nudgeUntil(write, arrived, deadlineMs = 10_000) {
59
+ const deadline = Date.now() + deadlineMs;
60
+ write();
61
+ let lastNudge = Date.now();
62
+ while (!arrived() && Date.now() < deadline) {
63
+ await new Promise((resolve) => setTimeout(resolve, 25));
64
+ if (!arrived() && Date.now() - lastNudge > 300) {
65
+ write();
66
+ lastNudge = Date.now();
67
+ }
68
+ }
69
+ return arrived();
70
+ }
71
+ function realGit(dir, ...args) {
72
+ return execFileSync('git', args, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }).toString('utf8').trim();
73
+ }
74
+
75
+ function realRepo(name) {
76
+ const dir = makeDir(name);
77
+ realGit(dir, 'init', '--quiet');
78
+ realGit(dir, 'config', 'user.email', 'obs@amalgm.test');
79
+ realGit(dir, 'config', 'user.name', 'Obs');
80
+ realGit(dir, 'config', 'commit.gpgsign', 'false');
81
+ return dir;
82
+ }
83
+
84
+ /**
85
+ * A deterministic doorbell: delivers nothing on its own and hands the test
86
+ * each root's bell, so it can ring exactly what fs.watch rarely delivers
87
+ * (null filenames, chosen paths) through the production wiring.
88
+ */
89
+ function fakeWatchKit() {
90
+ const bells = new Map(); // rootPath -> onDirty
91
+ return {
92
+ watch: (rootPath, onDirty) => {
93
+ bells.set(rootPath, onDirty);
94
+ return { degraded: false, close: () => bells.delete(rootPath) };
95
+ },
96
+ ring: (rootPath, relPath) => bells.get(rootPath)(relPath),
97
+ };
98
+ }
99
+
100
+
101
+ module.exports = {
102
+ cleanup, eventsOf, fakeWatchKit, makeDir, makeObserver, nudgeUntil, realGit, realRepo,
103
+ };