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,845 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TERRITORY exams: a .git claims its subtree — conversion in both
|
|
5
|
+
* directions, nested repositories born and buried by doorbell or
|
|
6
|
+
* census, policy (never gitignore) deciding what enrolls — and the law
|
|
7
|
+
* that governs ground no look could read: only present or absent may
|
|
8
|
+
* conclude, so an unsettled read is retried, never forgotten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { followEdges } = require('../observer/edges');
|
|
18
|
+
const {
|
|
19
|
+
cleanup, eventsOf, fakeWatchKit, makeDir, makeObserver, nudgeUntil, realGit, realRepo,
|
|
20
|
+
} = require('./observer.rig');
|
|
21
|
+
|
|
22
|
+
test.after(cleanup);
|
|
23
|
+
|
|
24
|
+
test('a .git claims its subtree: embedded repos are never files territory', () => {
|
|
25
|
+
const dir = makeDir('territory');
|
|
26
|
+
fs.writeFileSync(path.join(dir, 'keep.md'), 'mine');
|
|
27
|
+
fs.mkdirSync(path.join(dir, 'app'));
|
|
28
|
+
fs.writeFileSync(path.join(dir, 'app', 'index.md'), 'also mine');
|
|
29
|
+
fs.mkdirSync(path.join(dir, 'vendor', '.git'), { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(dir, 'vendor', 'readme.md'), 'repo territory');
|
|
31
|
+
|
|
32
|
+
const { observer, events } = makeObserver();
|
|
33
|
+
observer.enrollRoot(dir);
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
eventsOf(events, 'file.created').map((event) => event.path).sort(),
|
|
36
|
+
['app/index.md', 'keep.md'],
|
|
37
|
+
);
|
|
38
|
+
// The embedded repo became its own repo root, not files channels.
|
|
39
|
+
const discovered = eventsOf(events, 'repo.discovered');
|
|
40
|
+
assert.equal(discovered.length, 1);
|
|
41
|
+
assert.equal(discovered[0].path, path.join(dir, 'vendor'));
|
|
42
|
+
assert.ok(observer.listRoots().some(
|
|
43
|
+
(root) => root.kind === 'repo' && root.path === path.join(dir, 'vendor'),
|
|
44
|
+
));
|
|
45
|
+
events.length = 0;
|
|
46
|
+
|
|
47
|
+
// Churn inside the embedded repo never becomes file events — only the
|
|
48
|
+
// vendor repo root's own doorbell speaks for it.
|
|
49
|
+
fs.writeFileSync(path.join(dir, 'vendor', 'readme.md'), 'checkout churn');
|
|
50
|
+
fs.writeFileSync(path.join(dir, 'vendor', 'new.md'), 'more churn');
|
|
51
|
+
observer.scanNow();
|
|
52
|
+
assert.equal(events.filter((event) => event.type.startsWith('file.')).length, 0);
|
|
53
|
+
events.length = 0;
|
|
54
|
+
|
|
55
|
+
// A .git appearing later transfers the territory: channels close, a repo is born.
|
|
56
|
+
fs.mkdirSync(path.join(dir, 'app', '.git'));
|
|
57
|
+
observer.scanNow();
|
|
58
|
+
const fileEvents = events.filter((event) => event.type.startsWith('file.'));
|
|
59
|
+
assert.deepEqual(fileEvents.map((event) => event.type), ['file.deleted']);
|
|
60
|
+
assert.equal(fileEvents[0].path, 'app/index.md');
|
|
61
|
+
assert.ok(eventsOf(events, 'repo.discovered').some(
|
|
62
|
+
(event) => event.path === path.join(dir, 'app'),
|
|
63
|
+
));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a .git vanishing returns its territory to files: channels are reborn', () => {
|
|
67
|
+
const dir = makeDir('territory-back');
|
|
68
|
+
fs.mkdirSync(path.join(dir, 'vendor', '.git'), { recursive: true });
|
|
69
|
+
fs.writeFileSync(path.join(dir, 'vendor', 'readme.md'), 'was repo territory');
|
|
70
|
+
|
|
71
|
+
const { observer, events } = makeObserver();
|
|
72
|
+
observer.enrollRoot(dir);
|
|
73
|
+
assert.equal(eventsOf(events, 'repo.discovered').length, 1);
|
|
74
|
+
events.length = 0;
|
|
75
|
+
|
|
76
|
+
fs.rmSync(path.join(dir, 'vendor', '.git'), { recursive: true });
|
|
77
|
+
observer.scanNow();
|
|
78
|
+
|
|
79
|
+
assert.equal(eventsOf(events, 'repo.removed').length, 1);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
eventsOf(events, 'file.created').map((event) => event.path),
|
|
82
|
+
['vendor/readme.md'],
|
|
83
|
+
);
|
|
84
|
+
assert.ok(observer.listRoots().every((root) => root.kind === 'files'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('territory is decided by disk: enrolling a git repo auto-detects repo kind', () => {
|
|
88
|
+
const dir = makeDir('auto-repo');
|
|
89
|
+
fs.mkdirSync(path.join(dir, '.git'));
|
|
90
|
+
fs.writeFileSync(path.join(dir, 'main.ts'), 'v1');
|
|
91
|
+
|
|
92
|
+
const { observer, events } = makeObserver();
|
|
93
|
+
const root = observer.enrollRoot(dir);
|
|
94
|
+
|
|
95
|
+
assert.equal(root.kind, 'repo');
|
|
96
|
+
assert.equal(eventsOf(events, 'file.created').length, 0);
|
|
97
|
+
assert.deepEqual(eventsOf(events, 'repo.discovered').map((event) => event.path), [dir]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a workspace root growing a .git converts in place: channels close, one repo is born', () => {
|
|
101
|
+
const dir = makeDir('root-convert');
|
|
102
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'a');
|
|
103
|
+
fs.writeFileSync(path.join(dir, 'b.md'), 'b');
|
|
104
|
+
|
|
105
|
+
const { observer, events } = makeObserver();
|
|
106
|
+
const root = observer.enrollRoot(dir);
|
|
107
|
+
assert.equal(eventsOf(events, 'file.created').length, 2);
|
|
108
|
+
events.length = 0;
|
|
109
|
+
|
|
110
|
+
fs.mkdirSync(path.join(dir, '.git'));
|
|
111
|
+
observer.scanNow();
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
eventsOf(events, 'file.deleted').map((event) => event.path).sort(),
|
|
114
|
+
['a.md', 'b.md'],
|
|
115
|
+
);
|
|
116
|
+
assert.deepEqual(eventsOf(events, 'repo.discovered').map((event) => event.path), [dir]);
|
|
117
|
+
assert.equal(observer.listRoots()[0].kind, 'repo');
|
|
118
|
+
events.length = 0;
|
|
119
|
+
|
|
120
|
+
// And back: the .git vanishing rebirths the channels on the same root.
|
|
121
|
+
fs.rmSync(path.join(dir, '.git'), { recursive: true });
|
|
122
|
+
observer.scanNow();
|
|
123
|
+
assert.equal(eventsOf(events, 'repo.removed').length, 1);
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
eventsOf(events, 'file.created').map((event) => event.path).sort(),
|
|
126
|
+
['a.md', 'b.md'],
|
|
127
|
+
);
|
|
128
|
+
assert.equal(observer.listRoots()[0].kind, 'files');
|
|
129
|
+
assert.equal(observer.listRoots()[0].rootId, root.rootId);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- unsettled convergence: an unsettled read is never forgotten -------------
|
|
133
|
+
|
|
134
|
+
test('an unsettled read keeps the entity dirty: one doorbell, the change is still detected', async () => {
|
|
135
|
+
const dir = makeDir('unsettled-converge');
|
|
136
|
+
const beacon = path.join(dir, 'beacon.md');
|
|
137
|
+
const guarded = path.join(dir, 'guarded.md');
|
|
138
|
+
fs.writeFileSync(beacon, 'beacon-v1');
|
|
139
|
+
fs.writeFileSync(guarded, 'guarded-v1');
|
|
140
|
+
const kit = fakeWatchKit();
|
|
141
|
+
const { observer, events } = makeObserver({ settleMs: 25, watch: kit.watch });
|
|
142
|
+
const root = observer.enrollRoot(dir);
|
|
143
|
+
observer.start();
|
|
144
|
+
events.length = 0;
|
|
145
|
+
try {
|
|
146
|
+
// Both files change; the guarded one is unreadable when the scan
|
|
147
|
+
// looks — the deterministic stand-in for any transiently unsettled
|
|
148
|
+
// read — and each doorbell rings exactly ONCE.
|
|
149
|
+
fs.writeFileSync(beacon, 'beacon-v2');
|
|
150
|
+
fs.writeFileSync(guarded, 'guarded-v2');
|
|
151
|
+
fs.chmodSync(guarded, 0o000);
|
|
152
|
+
kit.ring(dir, 'beacon.md');
|
|
153
|
+
kit.ring(dir, 'guarded.md');
|
|
154
|
+
|
|
155
|
+
// The beacon's event proves the rung scan COMPLETED while the
|
|
156
|
+
// guarded ground was unreadable: its read was unsettled, no verdict.
|
|
157
|
+
assert.ok(await nudgeUntil(() => {}, () => eventsOf(events, 'file.changed')
|
|
158
|
+
.some((event) => event.path === 'beacon.md')), 'the rung scan never ran');
|
|
159
|
+
assert.equal(eventsOf(events, 'file.changed').filter((event) => event.path === 'guarded.md').length, 0,
|
|
160
|
+
'unsettled is not a conclusion');
|
|
161
|
+
|
|
162
|
+
// From here NO doorbell ever rings again. Truth settles; only a read
|
|
163
|
+
// the observer scheduled for itself can reach it — an unsettled read
|
|
164
|
+
// means "read again", and detection is who reads again.
|
|
165
|
+
fs.chmodSync(guarded, 0o644);
|
|
166
|
+
assert.ok(await nudgeUntil(() => {}, () => eventsOf(events, 'file.changed')
|
|
167
|
+
.some((event) => event.path === 'guarded.md')),
|
|
168
|
+
'the unsettled read was forgotten: the change was never detected');
|
|
169
|
+
assert.deepEqual(observer.verify(root.rootId), [], 'beliefs converged to disk');
|
|
170
|
+
} finally {
|
|
171
|
+
observer.stop();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// --- only present or absent may conclude -------------------------------------
|
|
176
|
+
// Unsettled preserves existing belief, remains pending, and makes
|
|
177
|
+
// verification inconclusive. Each exam below is a ratified reproduction
|
|
178
|
+
// of a production lie (audit 10): territory demolished, verification
|
|
179
|
+
// declaring unreadable ground clean or ghost, living files "deleted".
|
|
180
|
+
|
|
181
|
+
test('an uninspectable repository never loses its territory', () => {
|
|
182
|
+
const dir = realRepo('sealed-territory');
|
|
183
|
+
const { observer, events } = makeObserver();
|
|
184
|
+
const root = observer.enrollRoot(dir);
|
|
185
|
+
events.length = 0;
|
|
186
|
+
fs.chmodSync(dir, 0o000);
|
|
187
|
+
try {
|
|
188
|
+
observer.scanNow(root.rootId);
|
|
189
|
+
assert.deepEqual(eventsOf(events, 'repo.removed'), [], 'a failed look demolished territory');
|
|
190
|
+
assert.equal(observer.listRoots()[0].kind, 'repo', 'the root stayed a repository');
|
|
191
|
+
} finally {
|
|
192
|
+
fs.chmodSync(dir, 0o755);
|
|
193
|
+
}
|
|
194
|
+
observer.scanNow(root.rootId);
|
|
195
|
+
assert.equal(observer.listRoots()[0].kind, 'repo');
|
|
196
|
+
assert.ok(eventsOf(events, 'repo.changed').length >= 1,
|
|
197
|
+
'the settled look brings the catch-up doorbell');
|
|
198
|
+
observer.stop();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('verification is inconclusive on unreadable ground — never clean', () => {
|
|
202
|
+
const dir = makeDir('verify-unsettled');
|
|
203
|
+
const file = path.join(dir, 'single.txt');
|
|
204
|
+
fs.writeFileSync(file, 'v1');
|
|
205
|
+
const { observer } = makeObserver();
|
|
206
|
+
const root = observer.enrollRoot(file);
|
|
207
|
+
fs.writeFileSync(file, 'v2-diverged');
|
|
208
|
+
fs.chmodSync(file, 0o000);
|
|
209
|
+
try {
|
|
210
|
+
const report = observer.verify(root.rootId);
|
|
211
|
+
assert.equal(report.length, 1);
|
|
212
|
+
assert.match(report[0].problem, /^unsettled:/, 'unreadable ground is inconclusive, not clean');
|
|
213
|
+
} finally {
|
|
214
|
+
fs.chmodSync(file, 0o644);
|
|
215
|
+
}
|
|
216
|
+
assert.deepEqual(observer.verify(root.rootId).map((m) => m.problem.split(':')[0]), ['stale'],
|
|
217
|
+
'the settled look reports the real divergence');
|
|
218
|
+
observer.stop();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('the referee never mistakes unreadable for ghost: one unsettled entry, no false alarm', () => {
|
|
222
|
+
const dir = makeDir('verify-sealed-member');
|
|
223
|
+
fs.writeFileSync(path.join(dir, 'open.txt'), 'fine');
|
|
224
|
+
fs.writeFileSync(path.join(dir, 'sealed.txt'), 'kept');
|
|
225
|
+
const { observer } = makeObserver();
|
|
226
|
+
const root = observer.enrollRoot(dir);
|
|
227
|
+
fs.chmodSync(path.join(dir, 'sealed.txt'), 0o000);
|
|
228
|
+
try {
|
|
229
|
+
const report = observer.verify(root.rootId);
|
|
230
|
+
assert.deepEqual(report.map((m) => [m.path, m.problem.split(':')[0]]),
|
|
231
|
+
[['sealed.txt', 'unsettled']],
|
|
232
|
+
'unchanged-but-unreadable is inconclusive — never ghost, never clean');
|
|
233
|
+
} finally {
|
|
234
|
+
fs.chmodSync(path.join(dir, 'sealed.txt'), 0o644);
|
|
235
|
+
}
|
|
236
|
+
assert.deepEqual(observer.verify(root.rootId), [], 'settled again: clean');
|
|
237
|
+
observer.stop();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('an unlistable directory concludes nothing: no deletes beneath it, beliefs converge', () => {
|
|
241
|
+
const dir = makeDir('sealed-subtree');
|
|
242
|
+
fs.mkdirSync(path.join(dir, 'sub'));
|
|
243
|
+
fs.writeFileSync(path.join(dir, 'sub', 'inner.txt'), 'alive');
|
|
244
|
+
const { observer, events } = makeObserver();
|
|
245
|
+
const root = observer.enrollRoot(dir);
|
|
246
|
+
events.length = 0;
|
|
247
|
+
fs.chmodSync(path.join(dir, 'sub'), 0o000);
|
|
248
|
+
try {
|
|
249
|
+
observer.scanNow(root.rootId);
|
|
250
|
+
assert.deepEqual(events, [], 'a failed look produced conclusions');
|
|
251
|
+
} finally {
|
|
252
|
+
fs.chmodSync(path.join(dir, 'sub'), 0o755);
|
|
253
|
+
}
|
|
254
|
+
observer.scanNow(root.rootId);
|
|
255
|
+
assert.deepEqual(events, [], 'nothing actually changed: settling is silence');
|
|
256
|
+
assert.deepEqual(observer.verify(root.rootId), []);
|
|
257
|
+
observer.stop();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('unsettled retries coalesce and back off; new doorbell evidence resets the delay', async () => {
|
|
261
|
+
const dir = makeDir('backoff');
|
|
262
|
+
const file = path.join(dir, 'slow.txt');
|
|
263
|
+
fs.writeFileSync(file, 'v1');
|
|
264
|
+
let probes = 0;
|
|
265
|
+
const kit = fakeWatchKit();
|
|
266
|
+
const { observer, events } = makeObserver({
|
|
267
|
+
settleMs: 100,
|
|
268
|
+
watch: kit.watch,
|
|
269
|
+
shouldEnroll: () => { probes++; return true; },
|
|
270
|
+
});
|
|
271
|
+
observer.enrollRoot(dir);
|
|
272
|
+
observer.start();
|
|
273
|
+
events.length = 0;
|
|
274
|
+
try {
|
|
275
|
+
fs.writeFileSync(file, 'v2-changed');
|
|
276
|
+
fs.chmodSync(file, 0o000);
|
|
277
|
+
const mark = probes;
|
|
278
|
+
kit.ring(dir, 'slow.txt');
|
|
279
|
+
// The walk asks the policy once per entry, so probes count the
|
|
280
|
+
// scans. Backed off, the first four looks land near 100, 200, 400,
|
|
281
|
+
// and 800ms; a hot loop would have run ~15 by then.
|
|
282
|
+
assert.ok(await nudgeUntil(() => {}, () => probes >= mark + 4, 5_000), 'retries stopped');
|
|
283
|
+
assert.ok(probes - mark <= 6, `retries did not back off: ${probes - mark} scans`);
|
|
284
|
+
// A long backed-off wait is now pending. New evidence must replace
|
|
285
|
+
// it: the change lands on the settle delay, not the backoff's.
|
|
286
|
+
fs.chmodSync(file, 0o644);
|
|
287
|
+
const rangAt = Date.now();
|
|
288
|
+
kit.ring(dir, 'slow.txt');
|
|
289
|
+
assert.ok(await nudgeUntil(() => {}, () => eventsOf(events, 'file.changed').length === 1, 5_000),
|
|
290
|
+
'the settled change was never detected');
|
|
291
|
+
assert.ok(Date.now() - rangAt < 500, 'new evidence did not reset the backoff');
|
|
292
|
+
} finally {
|
|
293
|
+
fs.chmodSync(file, 0o644);
|
|
294
|
+
observer.stop();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('enrollment refuses to guess an unreadable kind', () => {
|
|
299
|
+
const dir = realRepo('sealed-enroll');
|
|
300
|
+
const { observer } = makeObserver();
|
|
301
|
+
fs.chmodSync(dir, 0o000);
|
|
302
|
+
try {
|
|
303
|
+
assert.throws(() => observer.enrollRoot(dir), /unsettled/,
|
|
304
|
+
'an unreadable kind must refuse loudly, never guess');
|
|
305
|
+
assert.deepEqual(observer.listRoots(), [], 'no wrongly typed root was minted');
|
|
306
|
+
} finally {
|
|
307
|
+
fs.chmodSync(dir, 0o755);
|
|
308
|
+
}
|
|
309
|
+
assert.equal(observer.enrollRoot(dir).kind, 'repo',
|
|
310
|
+
'the settled look enrolls the truth');
|
|
311
|
+
observer.stop();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('a repository hidden behind unreadable ground is inconclusive — and the retry finds it when the ground settles', async () => {
|
|
315
|
+
const dir = realRepo('hidden-census');
|
|
316
|
+
const { observer, events } = makeObserver({ settleMs: 25 });
|
|
317
|
+
const root = observer.enrollRoot(dir);
|
|
318
|
+
const hidden = path.join(dir, 'hidden');
|
|
319
|
+
fs.mkdirSync(path.join(hidden, 'inner'), { recursive: true });
|
|
320
|
+
execFileSync('git', ['init', '-q'], { cwd: path.join(hidden, 'inner') });
|
|
321
|
+
fs.chmodSync(hidden, 0o000);
|
|
322
|
+
events.length = 0;
|
|
323
|
+
try {
|
|
324
|
+
observer.scanNow(root.rootId); // the census meets the unreadable ground
|
|
325
|
+
assert.deepEqual(eventsOf(events, 'repo.discovered'), [], 'a failed look concluded a discovery');
|
|
326
|
+
assert.deepEqual(observer.verify(root.rootId).map((m) => [m.path, m.problem.split(':')[0]]),
|
|
327
|
+
[['hidden', 'unsettled']],
|
|
328
|
+
'unlistable ground may hide a boundary — inconclusive, never clean');
|
|
329
|
+
} finally {
|
|
330
|
+
fs.chmodSync(hidden, 0o755);
|
|
331
|
+
}
|
|
332
|
+
// No further doorbell, no further scanNow: the census's own armed
|
|
333
|
+
// retry discovers the hidden repository once the ground settles.
|
|
334
|
+
assert.ok(await nudgeUntil(() => {}, () => eventsOf(events, 'repo.discovered').length === 1, 5_000),
|
|
335
|
+
'the unsettled census was forgotten: the hidden repository was never discovered');
|
|
336
|
+
assert.deepEqual(observer.verify(root.rootId), [], 'settled and complete');
|
|
337
|
+
observer.stop();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('a doorbell about unsettled territory is never forgotten', async () => {
|
|
341
|
+
const dir = realRepo('kept-doorbell');
|
|
342
|
+
const child = path.join(dir, 'vendor');
|
|
343
|
+
fs.mkdirSync(child);
|
|
344
|
+
execFileSync('git', ['init', '-q'], { cwd: child });
|
|
345
|
+
const kit = fakeWatchKit();
|
|
346
|
+
const { observer, events } = makeObserver({ settleMs: 25, watch: kit.watch });
|
|
347
|
+
observer.enrollRoot(dir);
|
|
348
|
+
observer.start();
|
|
349
|
+
events.length = 0;
|
|
350
|
+
// The boundary dies while its ground cannot be inspected:
|
|
351
|
+
fs.rmSync(path.join(child, '.git'), { recursive: true, force: true });
|
|
352
|
+
fs.chmodSync(child, 0o000);
|
|
353
|
+
try {
|
|
354
|
+
kit.ring(dir, 'vendor/readme.md');
|
|
355
|
+
assert.deepEqual(eventsOf(events, 'repo.removed'), [], 'a failed look buried territory');
|
|
356
|
+
} finally {
|
|
357
|
+
fs.chmodSync(child, 0o755);
|
|
358
|
+
}
|
|
359
|
+
// Readability returns with NO second doorbell: only the armed
|
|
360
|
+
// backed-off retry can bury the dead boundary now.
|
|
361
|
+
assert.ok(await nudgeUntil(() => {}, () => eventsOf(events, 'repo.removed').length === 1, 5_000),
|
|
362
|
+
'the doorbell died with the failed look: the dead boundary was never buried');
|
|
363
|
+
observer.stop();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('the edge surface is three-state: readable edges enroll, unreadable ground keeps the follow unsettled', () => {
|
|
367
|
+
const outside = makeDir('sealed-edge-target');
|
|
368
|
+
fs.writeFileSync(path.join(outside, 'shared.md'), 'shared');
|
|
369
|
+
const dir = realRepo('sealed-edges');
|
|
370
|
+
fs.symlinkSync(outside, path.join(dir, 'shared'));
|
|
371
|
+
fs.mkdirSync(path.join(dir, 'sealed'));
|
|
372
|
+
fs.chmodSync(path.join(dir, 'sealed'), 0o000);
|
|
373
|
+
const { observer } = makeObserver();
|
|
374
|
+
const root = observer.enrollRoot(dir);
|
|
375
|
+
try {
|
|
376
|
+
const surface = observer.linkEdges(root.rootId);
|
|
377
|
+
assert.deepEqual(surface.edges, [{ relPath: 'shared', target: outside }]);
|
|
378
|
+
assert.deepEqual(surface.unsettled, ['sealed'], 'unlisted ground may hide an edge');
|
|
379
|
+
const pass = followEdges(observer);
|
|
380
|
+
assert.deepEqual(pass.enrolled, [outside], 'what WAS readable still enrolls');
|
|
381
|
+
assert.equal(pass.settled, false, 'the pass claims no completeness it did not earn');
|
|
382
|
+
} finally {
|
|
383
|
+
fs.chmodSync(path.join(dir, 'sealed'), 0o755);
|
|
384
|
+
}
|
|
385
|
+
const settled = followEdges(observer);
|
|
386
|
+
assert.equal(settled.settled, true);
|
|
387
|
+
assert.deepEqual(settled.enrolled, [], 'idempotent once the ground settles');
|
|
388
|
+
observer.stop();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('census at enrollment: nested repos and their children become territories', () => {
|
|
392
|
+
const dir = realRepo('census');
|
|
393
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
394
|
+
const child = path.join(dir, 'vendor', 'lib');
|
|
395
|
+
fs.mkdirSync(child, { recursive: true });
|
|
396
|
+
realGit(child, 'init', '--quiet');
|
|
397
|
+
const grandchild = path.join(child, 'deeper');
|
|
398
|
+
fs.mkdirSync(grandchild);
|
|
399
|
+
realGit(grandchild, 'init', '--quiet');
|
|
400
|
+
|
|
401
|
+
const { observer, events } = makeObserver();
|
|
402
|
+
observer.enrollRoot(dir);
|
|
403
|
+
|
|
404
|
+
const discovered = eventsOf(events, 'repo.discovered').map((event) => event.path).sort();
|
|
405
|
+
assert.deepEqual(discovered, [dir, child, grandchild].sort(),
|
|
406
|
+
'the census must recurse: a child repo runs its own census');
|
|
407
|
+
assert.equal(observer.listRoots().length, 3);
|
|
408
|
+
assert.ok(observer.listRoots().every((root) => root.kind === 'repo'));
|
|
409
|
+
assert.equal(observer.verify().length, 0, 'the referee must agree the census is complete');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('enrollment is amalgm policy, not gitignore: ignored ground enrolls, excluded ground does not', () => {
|
|
413
|
+
// Entity axiom 2: git's ignore rules never decide entity existence.
|
|
414
|
+
const dir = realRepo('census-policy');
|
|
415
|
+
fs.writeFileSync(path.join(dir, '.gitignore'), 'vendor/\n');
|
|
416
|
+
const ignored = path.join(dir, 'vendor', 'lib');
|
|
417
|
+
fs.mkdirSync(ignored, { recursive: true });
|
|
418
|
+
realGit(ignored, 'init', '--quiet');
|
|
419
|
+
const excluded = path.join(dir, 'node_modules', 'dep');
|
|
420
|
+
fs.mkdirSync(excluded, { recursive: true });
|
|
421
|
+
realGit(excluded, 'init', '--quiet');
|
|
422
|
+
|
|
423
|
+
const { observer, events } = makeObserver();
|
|
424
|
+
observer.enrollRoot(dir);
|
|
425
|
+
|
|
426
|
+
const discovered = eventsOf(events, 'repo.discovered').map((event) => event.path);
|
|
427
|
+
assert.ok(discovered.includes(ignored), 'gitignore never decides: an ignored repo in enrolled ground is an entity');
|
|
428
|
+
assert.equal(discovered.includes(excluded), false, 'amalgm-excluded ground enrolls nothing');
|
|
429
|
+
assert.equal(observer.verify().length, 0, 'the referee applies the same policy');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('a repo born inside a tracked directory is discovered live by its .git doorbell', async () => {
|
|
433
|
+
// The case git itself cannot report: ls-files shows ordinary tracked
|
|
434
|
+
// files, no gitlink, no trailing-slash boundary. Only the walk and the
|
|
435
|
+
// doorbell see it.
|
|
436
|
+
const dir = realRepo('census-tracked');
|
|
437
|
+
fs.mkdirSync(path.join(dir, 'app'));
|
|
438
|
+
fs.writeFileSync(path.join(dir, 'app', 'index.md'), 'tracked\n');
|
|
439
|
+
realGit(dir, 'add', '-A');
|
|
440
|
+
realGit(dir, 'commit', '--quiet', '-m', 'first');
|
|
441
|
+
|
|
442
|
+
const { observer, events } = makeObserver();
|
|
443
|
+
observer.enrollRoot(dir);
|
|
444
|
+
observer.start();
|
|
445
|
+
try {
|
|
446
|
+
const appPath = path.join(dir, 'app');
|
|
447
|
+
const born = await nudgeUntil(
|
|
448
|
+
() => { try { realGit(appPath, 'init', '--quiet'); } catch {} },
|
|
449
|
+
() => eventsOf(events, 'repo.discovered').some((event) => event.path === appPath),
|
|
450
|
+
);
|
|
451
|
+
assert.ok(born, 'the .git doorbell must birth the child territory');
|
|
452
|
+
assert.equal(observer.listRoots().find((root) => root.path === appPath)?.kind, 'repo');
|
|
453
|
+
} finally {
|
|
454
|
+
observer.stop();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('a linked worktree inside the checkout is its own territory', () => {
|
|
459
|
+
const dir = realRepo('census-worktree');
|
|
460
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'v1\n');
|
|
461
|
+
realGit(dir, 'add', '-A');
|
|
462
|
+
realGit(dir, 'commit', '--quiet', '-m', 'first');
|
|
463
|
+
realGit(dir, 'worktree', 'add', '--quiet', path.join(dir, 'trees', 'wt1'), '-b', 'side');
|
|
464
|
+
|
|
465
|
+
const { observer, events } = makeObserver();
|
|
466
|
+
observer.enrollRoot(dir);
|
|
467
|
+
|
|
468
|
+
const wtPath = path.join(dir, 'trees', 'wt1');
|
|
469
|
+
assert.ok(eventsOf(events, 'repo.discovered').some((event) => event.path === wtPath),
|
|
470
|
+
'a .git FILE is a boundary too');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('submodules: initialized is a territory, uninitialized is nothing to follow', () => {
|
|
474
|
+
const inner = realRepo('census-sub-src');
|
|
475
|
+
fs.writeFileSync(path.join(inner, 'lib.md'), 'inner\n');
|
|
476
|
+
realGit(inner, 'add', '-A');
|
|
477
|
+
realGit(inner, 'commit', '--quiet', '-m', 'inner');
|
|
478
|
+
|
|
479
|
+
const outer = realRepo('census-sub-outer');
|
|
480
|
+
fs.writeFileSync(path.join(outer, 'a.md'), 'outer\n');
|
|
481
|
+
realGit(outer, 'add', '-A');
|
|
482
|
+
realGit(outer, 'commit', '--quiet', '-m', 'outer');
|
|
483
|
+
realGit(outer, '-c', 'protocol.file.allow=always', 'submodule', '--quiet', 'add', inner, 'sub');
|
|
484
|
+
realGit(outer, 'commit', '--quiet', '-m', 'add submodule');
|
|
485
|
+
|
|
486
|
+
{
|
|
487
|
+
const { observer, events } = makeObserver();
|
|
488
|
+
observer.enrollRoot(outer);
|
|
489
|
+
assert.ok(eventsOf(events, 'repo.discovered').some((event) => event.path === path.join(outer, 'sub')),
|
|
490
|
+
'an initialized submodule has a .git and is a territory');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const cloneDir = makeDir('census-sub-clone');
|
|
494
|
+
realGit(path.dirname(cloneDir), 'clone', '--quiet', outer, cloneDir);
|
|
495
|
+
{
|
|
496
|
+
const { observer, events } = makeObserver();
|
|
497
|
+
observer.enrollRoot(cloneDir);
|
|
498
|
+
assert.deepEqual(eventsOf(events, 'repo.discovered').map((event) => event.path), [cloneDir],
|
|
499
|
+
'an uninitialized submodule has no .git on disk — nothing to follow until init, which is a doorbell');
|
|
500
|
+
assert.equal(observer.verify().length, 0);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('a nested repo death buries the child — by doorbell or the next catch-up', async () => {
|
|
505
|
+
const dir = realRepo('census-death');
|
|
506
|
+
const child = path.join(dir, 'sub');
|
|
507
|
+
fs.mkdirSync(child);
|
|
508
|
+
realGit(child, 'init', '--quiet');
|
|
509
|
+
fs.writeFileSync(path.join(child, 'inner.md'), 'child ground\n');
|
|
510
|
+
|
|
511
|
+
const { observer, events } = makeObserver();
|
|
512
|
+
const parentRoot = observer.enrollRoot(dir);
|
|
513
|
+
assert.ok(observer.listRoots().some((root) => root.path === child), 'enrollment census must claim the child');
|
|
514
|
+
|
|
515
|
+
observer.start();
|
|
516
|
+
try {
|
|
517
|
+
const buried = await nudgeUntil(
|
|
518
|
+
() => {
|
|
519
|
+
fs.rmSync(path.join(child, '.git'), { recursive: true, force: true });
|
|
520
|
+
observer.scanNow(); // the consumer moment: a lost deletion doorbell is delayed, not lost
|
|
521
|
+
},
|
|
522
|
+
() => eventsOf(events, 'repo.removed').some((event) => event.path === child),
|
|
523
|
+
);
|
|
524
|
+
assert.ok(buried, 'the child must be buried');
|
|
525
|
+
assert.equal(observer.listRoots().some((root) => root.path === child), false);
|
|
526
|
+
assert.ok(
|
|
527
|
+
eventsOf(events, 'repo.changed').some((event) => event.rootId === parentRoot.rootId),
|
|
528
|
+
'the ground must return to the parent: its capture is the reclaim',
|
|
529
|
+
);
|
|
530
|
+
assert.equal(observer.verify().length, 0);
|
|
531
|
+
} finally {
|
|
532
|
+
observer.stop();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('the referee reports unclaimed territory: a .git the observer never registered', () => {
|
|
537
|
+
const dir = makeDir('unclaimed');
|
|
538
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'a');
|
|
539
|
+
|
|
540
|
+
const { observer } = makeObserver();
|
|
541
|
+
const root = observer.enrollRoot(dir);
|
|
542
|
+
|
|
543
|
+
// Appears behind the observer's back — no doorbell, no scan.
|
|
544
|
+
fs.mkdirSync(path.join(dir, 'hidden', '.git'), { recursive: true });
|
|
545
|
+
|
|
546
|
+
const problems = observer.verify(root.rootId);
|
|
547
|
+
assert.equal(problems.length, 1);
|
|
548
|
+
assert.match(problems[0].problem, /unclaimed territory/);
|
|
549
|
+
assert.equal(problems[0].path, 'hidden');
|
|
550
|
+
|
|
551
|
+
// The next scan claims it; the referee falls silent.
|
|
552
|
+
observer.scanNow();
|
|
553
|
+
assert.deepEqual(observer.verify(root.rootId), []);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('the referee names a ghost territory: a registered repo whose .git left disk', () => {
|
|
557
|
+
const dir = realRepo('ghost');
|
|
558
|
+
const child = path.join(dir, 'sub');
|
|
559
|
+
fs.mkdirSync(child);
|
|
560
|
+
realGit(child, 'init', '--quiet');
|
|
561
|
+
|
|
562
|
+
const { observer } = makeObserver();
|
|
563
|
+
observer.enrollRoot(dir);
|
|
564
|
+
assert.ok(observer.listRoots().some((root) => root.path === child));
|
|
565
|
+
|
|
566
|
+
// Vanishes behind the observer's back — no doorbell, no scan.
|
|
567
|
+
fs.rmSync(path.join(child, '.git'), { recursive: true, force: true });
|
|
568
|
+
|
|
569
|
+
const problems = observer.verify();
|
|
570
|
+
assert.equal(problems.length, 1, 'the referee must prove equality in BOTH directions');
|
|
571
|
+
assert.match(problems[0].problem, /ghost territory/);
|
|
572
|
+
|
|
573
|
+
// The next catch-up buries it; the referee falls silent.
|
|
574
|
+
observer.scanNow();
|
|
575
|
+
assert.deepEqual(observer.verify(), []);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('a repository moved in whole is discovered: the doorbell names a directory, disk decides', async () => {
|
|
579
|
+
// A mv delivers ONE event, for the directory that landed — no path ever
|
|
580
|
+
// spells ".git". The prefix walk must find the boundary anyway.
|
|
581
|
+
const staged = realRepo('mv-in-staged');
|
|
582
|
+
fs.writeFileSync(path.join(staged, 'inner.md'), 'inner\n');
|
|
583
|
+
realGit(staged, 'add', '-A');
|
|
584
|
+
realGit(staged, 'commit', '--quiet', '-m', 'inner');
|
|
585
|
+
|
|
586
|
+
const dir = realRepo('mv-in-parent');
|
|
587
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
588
|
+
|
|
589
|
+
const { observer, events } = makeObserver();
|
|
590
|
+
observer.enrollRoot(dir);
|
|
591
|
+
observer.start();
|
|
592
|
+
try {
|
|
593
|
+
const landed = path.join(dir, 'vendor-lib');
|
|
594
|
+
const born = await nudgeUntil(
|
|
595
|
+
() => {
|
|
596
|
+
if (!fs.existsSync(landed)) {
|
|
597
|
+
try { fs.renameSync(staged, landed); } catch { /* raced; retry */ }
|
|
598
|
+
} else {
|
|
599
|
+
fs.writeFileSync(path.join(landed, 'probe.md'), 'probe\n');
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
() => eventsOf(events, 'repo.discovered').some((event) => event.path === landed),
|
|
603
|
+
);
|
|
604
|
+
assert.ok(born, 'a moved-in repository must be born by the doorbell it does ring');
|
|
605
|
+
assert.equal(observer.listRoots().find((root) => root.path === landed)?.kind, 'repo');
|
|
606
|
+
assert.equal(observer.verify().length, 0);
|
|
607
|
+
} finally {
|
|
608
|
+
observer.stop();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('a repository moved out whole is buried: the doorbell names its ground, disk decides', async () => {
|
|
613
|
+
const dir = realRepo('mv-out-parent');
|
|
614
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
615
|
+
const child = path.join(dir, 'sub');
|
|
616
|
+
fs.mkdirSync(child);
|
|
617
|
+
realGit(child, 'init', '--quiet');
|
|
618
|
+
fs.writeFileSync(path.join(child, 'inner.md'), 'inner\n');
|
|
619
|
+
const grave = path.join(makeDir('mv-out-grave'), 'sub');
|
|
620
|
+
|
|
621
|
+
const { observer, events } = makeObserver();
|
|
622
|
+
const parentRoot = observer.enrollRoot(dir);
|
|
623
|
+
assert.ok(observer.listRoots().some((root) => root.path === child));
|
|
624
|
+
observer.start();
|
|
625
|
+
try {
|
|
626
|
+
const buried = await nudgeUntil(
|
|
627
|
+
() => {
|
|
628
|
+
if (fs.existsSync(child)) {
|
|
629
|
+
try { fs.renameSync(child, grave); } catch { /* raced; retry */ }
|
|
630
|
+
} else {
|
|
631
|
+
// Ring the dead territory's ground: existence is the question,
|
|
632
|
+
// and no consumer moment (scanNow) is allowed to answer it here.
|
|
633
|
+
try { fs.mkdirSync(child); fs.rmdirSync(child); } catch { /* raced; retry */ }
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
() => eventsOf(events, 'repo.removed').some((event) => event.path === child),
|
|
637
|
+
);
|
|
638
|
+
assert.ok(buried, 'a moved-out repository must be buried by the doorbell it does ring');
|
|
639
|
+
assert.equal(observer.listRoots().some((root) => root.path === child), false);
|
|
640
|
+
assert.ok(
|
|
641
|
+
eventsOf(events, 'repo.changed').some((event) => event.rootId === parentRoot.rootId),
|
|
642
|
+
'the ground must return to the parent',
|
|
643
|
+
);
|
|
644
|
+
assert.equal(observer.verify().length, 0);
|
|
645
|
+
} finally {
|
|
646
|
+
observer.stop();
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('moving out a folder that contains a territory buries it: the doorbell names only the ancestor', async () => {
|
|
651
|
+
const dir = realRepo('mv-out-ancestor');
|
|
652
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
653
|
+
const folder = path.join(dir, 'vendor');
|
|
654
|
+
const child = path.join(folder, 'lib');
|
|
655
|
+
fs.mkdirSync(child, { recursive: true });
|
|
656
|
+
realGit(child, 'init', '--quiet');
|
|
657
|
+
const grave = path.join(makeDir('mv-out-ancestor-grave'), 'vendor');
|
|
658
|
+
|
|
659
|
+
const { observer, events } = makeObserver();
|
|
660
|
+
observer.enrollRoot(dir);
|
|
661
|
+
assert.ok(observer.listRoots().some((root) => root.path === child));
|
|
662
|
+
observer.start();
|
|
663
|
+
try {
|
|
664
|
+
const buried = await nudgeUntil(
|
|
665
|
+
() => {
|
|
666
|
+
if (fs.existsSync(folder)) {
|
|
667
|
+
try { fs.renameSync(folder, grave); } catch { /* raced; retry */ }
|
|
668
|
+
} else {
|
|
669
|
+
// Ring the ancestor path itself; no consumer moment allowed here.
|
|
670
|
+
try { fs.mkdirSync(folder); fs.rmdirSync(folder); } catch { /* raced; retry */ }
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
() => eventsOf(events, 'repo.removed').some((event) => event.path === child),
|
|
674
|
+
);
|
|
675
|
+
assert.ok(buried, 'a territory below the named path must have its existence checked');
|
|
676
|
+
assert.equal(observer.listRoots().some((root) => root.path === child), false);
|
|
677
|
+
assert.equal(observer.verify().length, 0);
|
|
678
|
+
} finally {
|
|
679
|
+
observer.stop();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('a repository moved in deeper than the doorbell names is discovered: scoped walk under the named folder', async () => {
|
|
684
|
+
const staged = makeDir('mv-deep-staged');
|
|
685
|
+
const stagedPkg = path.join(staged, 'pkg');
|
|
686
|
+
const stagedLib = path.join(stagedPkg, 'lib');
|
|
687
|
+
fs.mkdirSync(stagedLib, { recursive: true });
|
|
688
|
+
realGit(stagedLib, 'init', '--quiet');
|
|
689
|
+
fs.writeFileSync(path.join(stagedLib, 'inner.md'), 'inner\n');
|
|
690
|
+
|
|
691
|
+
const dir = realRepo('mv-deep-parent');
|
|
692
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
693
|
+
|
|
694
|
+
const { observer, events } = makeObserver();
|
|
695
|
+
observer.enrollRoot(dir);
|
|
696
|
+
observer.start();
|
|
697
|
+
try {
|
|
698
|
+
const landedPkg = path.join(dir, 'pkg');
|
|
699
|
+
const landedLib = path.join(landedPkg, 'lib');
|
|
700
|
+
const born = await nudgeUntil(
|
|
701
|
+
() => {
|
|
702
|
+
if (!fs.existsSync(landedPkg)) {
|
|
703
|
+
try { fs.renameSync(stagedPkg, landedPkg); } catch { /* raced; retry */ }
|
|
704
|
+
} else {
|
|
705
|
+
// Re-ring the named folder itself (a touch, idempotent).
|
|
706
|
+
try { fs.utimesSync(landedPkg, new Date(), new Date()); } catch { /* raced; retry */ }
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
() => eventsOf(events, 'repo.discovered').some((event) => event.path === landedLib),
|
|
710
|
+
);
|
|
711
|
+
assert.ok(born, 'the scoped walk under the named directory must find the deeper boundary');
|
|
712
|
+
assert.equal(observer.listRoots().find((root) => root.path === landedLib)?.kind, 'repo');
|
|
713
|
+
assert.equal(observer.verify().length, 0);
|
|
714
|
+
} finally {
|
|
715
|
+
observer.stop();
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test('doorbell births obey prune-at-the-directory policy: excluded ground never enrolls live', () => {
|
|
720
|
+
// Reviewer-caught (scoped walk) plus found-in-fix (prefix walk): both
|
|
721
|
+
// live tiers used to ask the policy point-wise. A manual policy pruning
|
|
722
|
+
// `pkg/private` was invisible — the scoped walk asked with paths relative
|
|
723
|
+
// to the named directory, the prefix walk asked only about the boundary
|
|
724
|
+
// itself — and existence-wins means a wrong birth is never buried.
|
|
725
|
+
const dir = realRepo('policy-doorbell');
|
|
726
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
727
|
+
|
|
728
|
+
const defaultPolicy = require('../workspace/tree/inclusion').shouldEnroll;
|
|
729
|
+
const kit = fakeWatchKit();
|
|
730
|
+
const { observer, events } = makeObserver({
|
|
731
|
+
watch: kit.watch,
|
|
732
|
+
shouldEnroll: (rel) => defaultPolicy(rel) && rel !== 'pkg/private',
|
|
733
|
+
});
|
|
734
|
+
observer.enrollRoot(dir);
|
|
735
|
+
observer.start();
|
|
736
|
+
try {
|
|
737
|
+
// Ground appears while only the fake doorbell listens: one repo on
|
|
738
|
+
// enrollable ground, one under the pruned directory.
|
|
739
|
+
const lib = path.join(dir, 'pkg', 'lib');
|
|
740
|
+
const priv = path.join(dir, 'pkg', 'private', 'repo');
|
|
741
|
+
fs.mkdirSync(lib, { recursive: true });
|
|
742
|
+
realGit(lib, 'init', '--quiet');
|
|
743
|
+
fs.mkdirSync(priv, { recursive: true });
|
|
744
|
+
realGit(priv, 'init', '--quiet');
|
|
745
|
+
|
|
746
|
+
const discovered = () => eventsOf(events, 'repo.discovered').map((event) => event.path);
|
|
747
|
+
|
|
748
|
+
// The mv-in doorbell names only the directory that landed.
|
|
749
|
+
kit.ring(dir, 'pkg');
|
|
750
|
+
assert.ok(discovered().includes(lib), 'enrollable ground is discovered by the scoped walk');
|
|
751
|
+
assert.equal(discovered().includes(priv), false,
|
|
752
|
+
'a policy pruning pkg/private prunes the scoped walk too');
|
|
753
|
+
|
|
754
|
+
// A deep doorbell into pruned ground names a file inside the boundary.
|
|
755
|
+
kit.ring(dir, 'pkg/private/repo/inner.md');
|
|
756
|
+
assert.equal(discovered().includes(priv), false,
|
|
757
|
+
'the prefix walk prunes at the excluded directory, never point-wise at the boundary');
|
|
758
|
+
assert.equal(observer.verify().length, 0, 'the referee applies the same policy');
|
|
759
|
+
} finally {
|
|
760
|
+
observer.stop();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('a null doorbell reconciles the whole root: one ring births the new boundary and buries the dead', () => {
|
|
765
|
+
// fs.watch may deliver a null filename; the only honest reading is a full
|
|
766
|
+
// reconcile. Proven through the production wiring via the injectable
|
|
767
|
+
// doorbell: a birth AND a burial from one null ring.
|
|
768
|
+
const dir = realRepo('null-doorbell');
|
|
769
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'root\n');
|
|
770
|
+
const dying = path.join(dir, 'vendor', 'lib');
|
|
771
|
+
fs.mkdirSync(dying, { recursive: true });
|
|
772
|
+
realGit(dying, 'init', '--quiet');
|
|
773
|
+
|
|
774
|
+
const kit = fakeWatchKit();
|
|
775
|
+
const { observer, events } = makeObserver({ watch: kit.watch });
|
|
776
|
+
observer.enrollRoot(dir);
|
|
777
|
+
observer.start();
|
|
778
|
+
try {
|
|
779
|
+
assert.ok(eventsOf(events, 'repo.discovered').some((event) => event.path === dying),
|
|
780
|
+
'the enrollment census claims the nested boundary');
|
|
781
|
+
|
|
782
|
+
// Offline changes: only the fake doorbell listens, so nothing rings.
|
|
783
|
+
fs.rmSync(path.join(dying, '.git'), { recursive: true, force: true });
|
|
784
|
+
const born = path.join(dir, 'pkg', 'tool');
|
|
785
|
+
fs.mkdirSync(born, { recursive: true });
|
|
786
|
+
realGit(born, 'init', '--quiet');
|
|
787
|
+
|
|
788
|
+
kit.ring(dir, null);
|
|
789
|
+
|
|
790
|
+
assert.ok(eventsOf(events, 'repo.removed').some((event) => event.path === dying),
|
|
791
|
+
'one null ring buries the dead boundary');
|
|
792
|
+
assert.ok(eventsOf(events, 'repo.discovered').some((event) => event.path === born),
|
|
793
|
+
'one null ring births the new boundary');
|
|
794
|
+
assert.deepEqual(eventsOf(events, 'repo.changed').at(-1)?.areas, ['git', 'worktree'],
|
|
795
|
+
'the raw forward still happens, classified as everything');
|
|
796
|
+
assert.equal(observer.verify().length, 0);
|
|
797
|
+
} finally {
|
|
798
|
+
observer.stop();
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test('a gitignored linked worktree is still territory: policy, not gitignore, decides', () => {
|
|
803
|
+
const dir = realRepo('wt-ignored');
|
|
804
|
+
fs.writeFileSync(path.join(dir, '.gitignore'), 'trees/\n');
|
|
805
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'v1\n');
|
|
806
|
+
realGit(dir, 'add', '-A');
|
|
807
|
+
realGit(dir, 'commit', '--quiet', '-m', 'first');
|
|
808
|
+
realGit(dir, 'worktree', 'add', '--quiet', path.join(dir, 'trees', 'wt1'), '-b', 'side');
|
|
809
|
+
|
|
810
|
+
const { observer, events } = makeObserver();
|
|
811
|
+
observer.enrollRoot(dir);
|
|
812
|
+
|
|
813
|
+
assert.ok(eventsOf(events, 'repo.discovered').some((event) => event.path === path.join(dir, 'trees', 'wt1')),
|
|
814
|
+
'an ignored worktree in enrolled ground is a territory');
|
|
815
|
+
assert.equal(observer.verify().length, 0);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test('a gitignored worktree added live is born by its own doorbell', async () => {
|
|
819
|
+
const dir = realRepo('wt-live');
|
|
820
|
+
fs.writeFileSync(path.join(dir, '.gitignore'), 'trees/\n');
|
|
821
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'v1\n');
|
|
822
|
+
realGit(dir, 'add', '-A');
|
|
823
|
+
realGit(dir, 'commit', '--quiet', '-m', 'first');
|
|
824
|
+
|
|
825
|
+
const { observer, events } = makeObserver();
|
|
826
|
+
observer.enrollRoot(dir);
|
|
827
|
+
observer.start();
|
|
828
|
+
try {
|
|
829
|
+
const wt = path.join(dir, 'trees', 'wt2');
|
|
830
|
+
const born = await nudgeUntil(
|
|
831
|
+
() => {
|
|
832
|
+
if (!fs.existsSync(wt)) {
|
|
833
|
+
try { realGit(dir, 'worktree', 'add', '--quiet', wt, '-b', 'side2'); } catch { /* raced; retry */ }
|
|
834
|
+
} else {
|
|
835
|
+
fs.writeFileSync(path.join(wt, 'probe.md'), 'probe\n');
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
() => eventsOf(events, 'repo.discovered').some((event) => event.path === wt),
|
|
839
|
+
);
|
|
840
|
+
assert.ok(born, 'the enrollment policy, not gitignore, decides a live birth');
|
|
841
|
+
assert.equal(observer.verify().length, 0);
|
|
842
|
+
} finally {
|
|
843
|
+
observer.stop();
|
|
844
|
+
}
|
|
845
|
+
});
|