@1agh/maude 0.60.3 → 0.60.4

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.
@@ -0,0 +1,331 @@
1
+ // The file plane's membership policy — unit coverage plus the two parity pins
2
+ // that make the classifier trustworthy:
3
+ //
4
+ // 1. The DDR-115 runtime-state replica vs `git/service.ts`'s
5
+ // `isMaudeRuntimeState` — the 4th-copy tripwire. Three copies of this
6
+ // list drifted silently before; this test makes the drift a red test.
7
+ // 2. The hub `.mjs` mirror vs this `.ts` implementation over an adversarial
8
+ // corpus (the doc-name precedent — the bun test imports the hub file
9
+ // across the package boundary on purpose).
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ // The HUB's implementation, imported across the package boundary on purpose —
14
+ // this file is what pins the two classifiers to each other.
15
+ import * as hub from '../../hub/src/file-membership.mjs';
16
+ import { isMaudeRuntimeState } from '../git/service.ts';
17
+ import {
18
+ type ClassifyOptions,
19
+ classifyProjectFile,
20
+ type FileClass,
21
+ isFilePlaneClass,
22
+ isProjectFileShape,
23
+ isRuntimeStateRel,
24
+ } from '../sync/file-membership.ts';
25
+
26
+ describe('file-membership — the RCA miss-list flows', () => {
27
+ // The 103-file gap, by shape (RCA
28
+ // issue-fresh-link-gets-canvases-but-not-the-design-system): every file the
29
+ // fresh link lost must classify into a flowing class.
30
+ const missList: Array<[string, FileClass]> = [
31
+ ['system/alligators/assets/logos/logo.svg', 'inert-media'],
32
+ ['system/alligators/assets/logos/logo-mono.svg', 'inert-media'],
33
+ ['system/alligators/assets/fonts/lexend.woff2', 'inert-media'],
34
+ ['system/alligators/assets/fonts/lexend-bold.ttf', 'inert-media'],
35
+ ['system/alligators/assets/photos/P1020428.JPG', 'inert-media'],
36
+ ['system/alligators/assets/photos/team.jpeg', 'inert-media'],
37
+ ['system/alligators/preview/_brand-css.ts', 'code-module'],
38
+ ['system/alligators/preview/_layout.css', 'companion-text'],
39
+ ['system/alligators/preview/_components.css', 'companion-text'],
40
+ ['system/alligators/preview/_marketing.css', 'companion-text'],
41
+ ['system/alligators/brand.css', 'companion-text'],
42
+ ['system/alligators/colors_and_type.css', 'companion-text'],
43
+ ['system/alligators/README.md', 'companion-text'],
44
+ ['system/alligators/SKILL.md', 'companion-text'],
45
+ ['assets/c0fa9c7f.png', 'inert-media'],
46
+ // Found by the Task-12 acceptance on the real tree: Maude's own sidecar
47
+ // vocabulary, suffix-enumerated (never bare .json).
48
+ ['assets/066a990a.photo.json', 'companion-text'],
49
+ ['assets/46a07c5e.audio.json', 'companion-text'],
50
+ ['assets/47d9b6d1.srt', 'companion-text'],
51
+ ];
52
+
53
+ test.each(missList)('%s → %s', (rel, expected) => {
54
+ expect(classifyProjectFile(rel)).toBe(expected);
55
+ });
56
+
57
+ test('every miss-list class is a file-plane class', () => {
58
+ for (const [rel] of missList) {
59
+ expect(isFilePlaneClass(classifyProjectFile(rel))).toBe(true);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('file-membership — canvas-owned is Plane A, never Plane B', () => {
65
+ test('a canvas body and its named sidecars', () => {
66
+ expect(classifyProjectFile('ui/card.tsx')).toBe('canvas-owned');
67
+ expect(classifyProjectFile('ui/card.meta.json')).toBe('canvas-owned');
68
+ expect(classifyProjectFile('ui/card.annotations.svg')).toBe('canvas-owned');
69
+ expect(classifyProjectFile('system/alligators/preview/specimen.tsx')).toBe('canvas-owned');
70
+ // The scan's own quirk is honoured: a leading-underscore `.tsx` inside a
71
+ // group DOES sync as a canvas today (`_kit.tsx` on alligators), so it is
72
+ // canvas-owned here too — the planes must agree on ownership.
73
+ expect(classifyProjectFile('system/alligators/preview/_kit.tsx')).toBe('canvas-owned');
74
+ });
75
+
76
+ test('the sibling .css split needs the tree, and both answers are honoured', () => {
77
+ const withSibling: ClassifyOptions = {
78
+ hasFile: (rel) => rel === 'system/ds/preview/specimen.tsx',
79
+ };
80
+ // `specimen.css` beside `specimen.tsx` IS that canvas's Yjs css lane.
81
+ expect(classifyProjectFile('system/ds/preview/specimen.css', withSibling)).toBe('canvas-owned');
82
+ // `brand.css` has no sibling body — it is the RCA's missing stylesheet.
83
+ expect(classifyProjectFile('system/ds/brand.css', withSibling)).toBe('companion-text');
84
+ // No tree knowledge ⇒ the flowing side; the receiver re-checks anyway.
85
+ expect(classifyProjectFile('system/ds/preview/specimen.css')).toBe('companion-text');
86
+ });
87
+
88
+ test('group membership is case-insensitive, mirroring canvas-path rule 8', () => {
89
+ expect(classifyProjectFile('UI/card.tsx')).toBe('canvas-owned');
90
+ });
91
+
92
+ test('declared canvasGroups replace the default set', () => {
93
+ const opts: ClassifyOptions = { canvasGroups: [{ path: 'mocks' }] };
94
+ expect(classifyProjectFile('mocks/screen.tsx', opts)).toBe('canvas-owned');
95
+ // `ui/` is not a group in THIS project ⇒ a .tsx there is a shared module.
96
+ expect(classifyProjectFile('ui/screen.tsx', opts)).toBe('code-module');
97
+ });
98
+
99
+ test('a .tsx outside every canvas group is a code module, not a canvas', () => {
100
+ expect(classifyProjectFile('lib/helpers.tsx')).toBe('code-module');
101
+ expect(classifyProjectFile('_shared.tsx')).toBe('code-module');
102
+ });
103
+
104
+ test('.meta.json / .annotations.svg outside a group do not leak into the plane', () => {
105
+ // `.json` is not positively enumerated; a stray meta sidecar stays home.
106
+ expect(classifyProjectFile('notes/thing.meta.json')).toBe('never');
107
+ // A stray annotations svg outside a group is just an svg.
108
+ expect(classifyProjectFile('notes/thing.annotations.svg')).toBe('inert-media');
109
+ });
110
+
111
+ test('the sidecar suffixes flow; bare .json stays default-closed', () => {
112
+ expect(classifyProjectFile('assets/066a990a.photo.json')).toBe('companion-text');
113
+ expect(classifyProjectFile('assets/46a07c5e.audio.json')).toBe('companion-text');
114
+ expect(classifyProjectFile('assets/47d9b6d1.srt')).toBe('companion-text');
115
+ expect(classifyProjectFile('assets/data.json')).toBe('never');
116
+ expect(classifyProjectFile('photo.json')).toBe('never'); // the suffix, alone, is not a name
117
+ });
118
+ });
119
+
120
+ describe('file-membership — never means never', () => {
121
+ const nevers = [
122
+ 'config.json', // the design root's trust anchors
123
+ '_server.json',
124
+ '_active.json',
125
+ '_active.sess-abc123.json', // the per-member D3 sibling
126
+ '_sync.json',
127
+ '_preflight.json',
128
+ '_server.lock',
129
+ '_server.log',
130
+ '_untrusted/INDEX.json',
131
+ '_history/ui-card/2026-08-14.tsx',
132
+ '_trash/old-deleted-123/old.tsx',
133
+ '_canvas-state/ui-card.view.json',
134
+ '_export-history.json',
135
+ 'system/ds/_history/x.png', // nested runtime state
136
+ '.kgai/store/log/a.ndjson',
137
+ '.DS_Store', // dotfile
138
+ 'data.json', // unclassified extension — default-closed
139
+ 'archive.zip',
140
+ 'script.sh',
141
+ 'binary.wasm',
142
+ ];
143
+
144
+ test.each(nevers.map((n) => [n]))('%s → never', (rel) => {
145
+ expect(classifyProjectFile(rel)).toBe('never');
146
+ });
147
+ });
148
+
149
+ describe('file-membership — shape gates', () => {
150
+ const malformed = [
151
+ '', // empty
152
+ '../escape.png',
153
+ 'a/../escape.png',
154
+ '/etc/passwd',
155
+ 'a\\b.png',
156
+ 'C:evil.png',
157
+ '..%2f..%2fetc%2fpasswd', // percent shapes never decode here — charset refusal
158
+ 'a/b/c/d/e/f/g/h/i.png', // 9 segments
159
+ 'a//b.png', // empty segment
160
+ 'a/./b.png',
161
+ `${'x'.repeat(513)}.png`, // over the length cap
162
+ 'node_modules/react/index.js',
163
+ 'a\u0000b.png', // control character
164
+ 'dir /file.png', // trailing-space segment
165
+ 'dir/file.png ', // trailing-space final segment
166
+ '_dir/file.css', // leading underscore on a DIRECTORY segment
167
+ 'system/_private/x.css',
168
+ ];
169
+
170
+ test.each(malformed.map((m) => [m]))('%j → never', (rel) => {
171
+ expect(classifyProjectFile(rel)).toBe('never');
172
+ });
173
+
174
+ test('a leading underscore is allowed on the FINAL segment only', () => {
175
+ expect(classifyProjectFile('_brand-css.ts')).toBe('code-module');
176
+ expect(classifyProjectFile('system/ds/preview/_layout.css')).toBe('companion-text');
177
+ expect(classifyProjectFile('_history/x.css')).toBe('never');
178
+ });
179
+
180
+ test('extensions match case-insensitively', () => {
181
+ expect(classifyProjectFile('assets/PHOTO.PNG')).toBe('inert-media');
182
+ expect(classifyProjectFile('system/ds/BRAND.CSS')).toBe('companion-text');
183
+ });
184
+ });
185
+
186
+ describe('file-membership — the DDR-115 replica tripwire (4th copy)', () => {
187
+ // Every shape the runtime-state taxonomy names, plus content that must NOT
188
+ // match. If `git/service.ts` gains a new runtime path and this replica does
189
+ // not, this list makes the drift a failing test instead of a leak.
190
+ const fixtures = [
191
+ '_server.json',
192
+ '_active.json',
193
+ '_active.sess-1.json',
194
+ '_sync.json',
195
+ '_preflight.json',
196
+ '_locator.json',
197
+ '_export-history.json',
198
+ '_generate-history.json',
199
+ '_server.lock',
200
+ '_server.log',
201
+ '_history/ui-card/x.tsx',
202
+ '_trash/gone-123/gone.tsx',
203
+ '_draw/proof.tsx',
204
+ '_photo/edit.json',
205
+ '_smoke/report.json',
206
+ '_reports/r.md',
207
+ '_canvas-state/ui-card.view.json',
208
+ '_state/s.json',
209
+ '_chat/c.json',
210
+ '_comments/ui-card.json',
211
+ '_untrusted/INDEX.json',
212
+ '_export-jobs/j.json',
213
+ '.kgai/store/log/a.ndjson',
214
+ 'nested/_history/x.png',
215
+ 'nested/.kgai/y',
216
+ '_history',
217
+ '_trash',
218
+ // …and the content side, which must NOT be runtime state:
219
+ 'ui/card.tsx',
220
+ 'ui/card.annotations.svg',
221
+ 'system/ds/brand.css',
222
+ 'system/ds/preview/_brand-css.ts',
223
+ 'system/ds/preview/_layout.css',
224
+ 'assets/x.png',
225
+ 'config.json',
226
+ '_brandish.json', // close to `_active.json`'s shape, not in the taxonomy
227
+ 'history/x.png', // no underscore — a real folder named history
228
+ ];
229
+
230
+ test('isRuntimeStateRel agrees with git/service.ts isMaudeRuntimeState', () => {
231
+ for (const p of fixtures) {
232
+ expect(`${p} → ${isRuntimeStateRel(p)}`).toBe(`${p} → ${isMaudeRuntimeState(p)}`);
233
+ }
234
+ });
235
+ });
236
+
237
+ describe('file-membership — studio ↔ hub parity (the doc-name precedent)', () => {
238
+ // Every corpus above, concatenated, plus the fixture set Task 2 names —
239
+ // both classifiers must agree byte-for-byte on every one of them, with and
240
+ // without tree knowledge and group config.
241
+ const corpus = [
242
+ // The RCA miss-list shapes
243
+ 'system/alligators/assets/logos/logo.svg',
244
+ 'system/alligators/assets/fonts/lexend.woff2',
245
+ 'system/alligators/assets/photos/P1020428.JPG',
246
+ 'system/alligators/preview/_brand-css.ts',
247
+ 'system/alligators/preview/_layout.css',
248
+ 'system/alligators/preview/_components.css',
249
+ 'system/alligators/preview/_marketing.css',
250
+ 'system/alligators/brand.css',
251
+ 'system/alligators/colors_and_type.css',
252
+ 'system/alligators/README.md',
253
+ 'system/alligators/SKILL.md',
254
+ 'system/ds/assets/logos/x.svg',
255
+ 'assets/x.png',
256
+ // Canvas-owned
257
+ 'ui/card.tsx',
258
+ 'ui/card.meta.json',
259
+ 'ui/card.annotations.svg',
260
+ 'system/ds/preview/specimen.tsx',
261
+ 'system/ds/preview/specimen.css',
262
+ // Never / adversarial
263
+ 'config.json',
264
+ '_untrusted/INDEX.json',
265
+ '_server.json',
266
+ '_active.sess-1.json',
267
+ '..%2f..%2fetc%2fpasswd',
268
+ '../escape.png',
269
+ 'a/b/c/d/e/f/g/h/i.png',
270
+ 'a\\b.png',
271
+ '/etc/passwd',
272
+ 'C:evil.png',
273
+ 'a\u0000b.png',
274
+ 'node_modules/react/index.js',
275
+ '_brand-css.ts',
276
+ 'preview/_layout.css',
277
+ '_history/x.css',
278
+ '.DS_Store',
279
+ 'data.json',
280
+ 'assets/066a990a.photo.json',
281
+ 'assets/46a07c5e.audio.json',
282
+ 'assets/47d9b6d1.srt',
283
+ 'evil.photo.json.txt', // the suffix must END the name
284
+ '',
285
+ 'dir/file.png ',
286
+ 'system/_private/x.css',
287
+ '.kgai/store/log/a.ndjson',
288
+ ];
289
+
290
+ const optVariants: Array<[string, ClassifyOptions]> = [
291
+ ['no opts', {}],
292
+ ['declared groups', { canvasGroups: [{ path: 'ui' }, { path: 'system' }] }],
293
+ ['custom group', { canvasGroups: [{ path: 'mocks' }] }],
294
+ [
295
+ 'with tree knowledge',
296
+ {
297
+ hasFile: (rel: string) => rel === 'system/ds/preview/specimen.tsx' || rel === 'ui/card.tsx',
298
+ },
299
+ ],
300
+ ];
301
+
302
+ for (const [label, opts] of optVariants) {
303
+ test(`classifyProjectFile agrees (${label})`, () => {
304
+ for (const rel of corpus) {
305
+ const mine = classifyProjectFile(rel, opts);
306
+ const theirs = hub.classifyProjectFile(rel, opts) as FileClass;
307
+ expect(`${rel} → ${mine}`).toBe(`${rel} → ${theirs}`);
308
+ }
309
+ });
310
+ }
311
+
312
+ test('isRuntimeStateRel agrees with the hub replica', () => {
313
+ for (const rel of corpus) {
314
+ expect(`${rel} → ${isRuntimeStateRel(rel)}`).toBe(`${rel} → ${hub.isRuntimeStateRel(rel)}`);
315
+ }
316
+ });
317
+
318
+ test('isProjectFileShape agrees with the hub replica', () => {
319
+ for (const rel of corpus) {
320
+ expect(`${rel} → ${isProjectFileShape(rel)}`).toBe(`${rel} → ${hub.isProjectFileShape(rel)}`);
321
+ }
322
+ });
323
+
324
+ test('a canvas body never classifies into a file-plane class on either side', () => {
325
+ const bodies = ['ui/card.tsx', 'system/ds/preview/specimen.tsx'];
326
+ for (const rel of bodies) {
327
+ expect(isFilePlaneClass(classifyProjectFile(rel))).toBe(false);
328
+ expect(hub.isFilePlaneClass(hub.classifyProjectFile(rel))).toBe(false);
329
+ }
330
+ });
331
+ });
@@ -0,0 +1,333 @@
1
+ // The file plane's downward half — what lands, what is refused, and what a
2
+ // conflict costs (never work, silently).
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ rmSync,
13
+ utimesSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ import { classifyProjectFile } from '../sync/file-membership.ts';
20
+ import { fetchFileManifest, pullFiles, type RemoteFileEntry } from '../sync/file-pull.ts';
21
+
22
+ const sha = (body: string): string => createHash('sha256').update(body).digest('hex');
23
+
24
+ interface FakeFile {
25
+ body: string;
26
+ mtimeMs?: number;
27
+ /** Override the manifest's class hint (for disagreement tests). */
28
+ class?: string;
29
+ }
30
+
31
+ /** A fake hub: one manifest, one file store, a request log. */
32
+ function makeHub(files: Record<string, FakeFile>) {
33
+ const requests: string[] = [];
34
+ const fetchImpl = (async (url: RequestInfo | URL) => {
35
+ const u = String(url);
36
+ requests.push(u);
37
+ if (u.endsWith('/api/files')) {
38
+ const list = Object.entries(files).map(([p, f]) => ({
39
+ path: p,
40
+ sha256: sha(f.body),
41
+ size: Buffer.byteLength(f.body),
42
+ mtimeMs: f.mtimeMs ?? 1_000_000,
43
+ class: f.class ?? classifyProjectFile(p),
44
+ }));
45
+ return new Response(JSON.stringify({ files: list, count: list.length }), { status: 200 });
46
+ }
47
+ const m = u.match(/\/_project-file\/(.+)$/);
48
+ if (m) {
49
+ const rel = decodeURIComponent(m[1] ?? '');
50
+ const f = files[rel];
51
+ if (!f) return new Response('nf', { status: 404 });
52
+ return new Response(f.body, { status: 200 });
53
+ }
54
+ return new Response('nf', { status: 404 });
55
+ }) as typeof fetch;
56
+ return { fetchImpl, requests };
57
+ }
58
+
59
+ const silent = { log: () => {}, warn: () => {} };
60
+
61
+ function makeRoot(): string {
62
+ return mkdtempSync(path.join(tmpdir(), 'maude-filepull-'));
63
+ }
64
+
65
+ function pull(
66
+ designRoot: string,
67
+ fetchImpl: typeof fetch,
68
+ overrides: Partial<Parameters<typeof pullFiles>[0]> = {}
69
+ ) {
70
+ return pullFiles({
71
+ designRoot,
72
+ hubUrl: 'http://hub.test',
73
+ token: () => 'tok',
74
+ allowCodeModules: false,
75
+ fetchImpl,
76
+ log: silent,
77
+ ...overrides,
78
+ });
79
+ }
80
+
81
+ describe('file-pull — the RCA gap closes', () => {
82
+ test('a fresh empty root receives the whole miss-list shape', async () => {
83
+ const root = makeRoot();
84
+ try {
85
+ const { fetchImpl } = makeHub({
86
+ 'system/ds/brand.css': { body: ':root{}' },
87
+ 'system/ds/README.md': { body: '# ds' },
88
+ 'system/ds/preview/_layout.css': { body: '.x{}' },
89
+ 'system/ds/assets/logos/logo.svg': { body: '<svg/>' },
90
+ 'assets/c0fa9c7f.png': { body: 'PNG' },
91
+ 'system/ds/EMPTY.md': { body: '' }, // an empty doc is a real file
92
+ });
93
+ const out = await pull(root, fetchImpl);
94
+ expect(out.pulled.sort()).toEqual([
95
+ 'assets/c0fa9c7f.png',
96
+ 'system/ds/EMPTY.md',
97
+ 'system/ds/README.md',
98
+ 'system/ds/assets/logos/logo.svg',
99
+ 'system/ds/brand.css',
100
+ 'system/ds/preview/_layout.css',
101
+ ]);
102
+ expect(readFileSync(path.join(root, 'system/ds/brand.css'), 'utf8')).toBe(':root{}');
103
+ expect(readFileSync(path.join(root, 'system/ds/EMPTY.md'), 'utf8')).toBe('');
104
+ expect(out.failed).toEqual([]);
105
+ expect(out.dropped).toEqual([]);
106
+ // No half-written residue anywhere.
107
+ expect(existsSync(path.join(root, 'system/ds/brand.css.part'))).toBe(false);
108
+ } finally {
109
+ rmSync(root, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test('code modules are dropped without the owner gate, and land with it', async () => {
114
+ const root = makeRoot();
115
+ try {
116
+ const { fetchImpl } = makeHub({
117
+ 'system/ds/preview/_brand-css.ts': { body: 'export {}' },
118
+ });
119
+ const denied = await pull(root, fetchImpl);
120
+ expect(denied.pulled).toEqual([]);
121
+ expect(denied.dropped.length).toBe(1);
122
+ expect(denied.dropped[0]?.reason).toMatch(/owner/);
123
+ expect(existsSync(path.join(root, 'system/ds/preview/_brand-css.ts'))).toBe(false);
124
+
125
+ const allowed = await pull(root, fetchImpl, { allowCodeModules: true });
126
+ expect(allowed.pulled).toEqual(['system/ds/preview/_brand-css.ts']);
127
+ expect(readFileSync(path.join(root, 'system/ds/preview/_brand-css.ts'), 'utf8')).toBe(
128
+ 'export {}'
129
+ );
130
+ } finally {
131
+ rmSync(root, { recursive: true, force: true });
132
+ }
133
+ });
134
+ });
135
+
136
+ describe('file-pull — the receiver re-validates every path', () => {
137
+ test('a hostile manifest cannot land config, runtime state, or traversal', async () => {
138
+ const root = makeRoot();
139
+ try {
140
+ const { fetchImpl, requests } = makeHub({
141
+ 'config.json': { body: '{"linkedHub":{"url":"http://evil"}}', class: 'companion-text' },
142
+ '_server.json': { body: '{}', class: 'companion-text' },
143
+ '_untrusted/INDEX.json': { body: '{}', class: 'companion-text' },
144
+ '../escape.css': { body: 'x', class: 'companion-text' },
145
+ '_history/old.css': { body: 'x', class: 'companion-text' },
146
+ });
147
+ const out = await pull(root, fetchImpl);
148
+ expect(out.pulled).toEqual([]);
149
+ expect(out.dropped.length).toBe(5);
150
+ expect(existsSync(path.join(root, 'config.json'))).toBe(false);
151
+ expect(existsSync(path.join(root, '_server.json'))).toBe(false);
152
+ // Refused entries cost ZERO byte fetches — only the manifest request.
153
+ expect(requests.filter((u) => u.includes('_project-file'))).toEqual([]);
154
+ } finally {
155
+ rmSync(root, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ test("the hub's class field is a hint — disagreement drops the entry", async () => {
160
+ const root = makeRoot();
161
+ try {
162
+ const { fetchImpl } = makeHub({
163
+ 'system/ds/brand.css': { body: ':root{}', class: 'inert-media' },
164
+ });
165
+ const out = await pull(root, fetchImpl);
166
+ expect(out.pulled).toEqual([]);
167
+ expect(out.dropped[0]?.reason).toMatch(/hub says 'inert-media'/);
168
+ } finally {
169
+ rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test('a css that is a canvas sidecar HERE is refused, whatever the hub offers', async () => {
174
+ const root = makeRoot();
175
+ try {
176
+ // This peer holds the canvas body, so its sibling css is the CRDT css
177
+ // lane — plane B must not write it.
178
+ mkdirSync(path.join(root, 'system/ds/preview'), { recursive: true });
179
+ writeFileSync(path.join(root, 'system/ds/preview/specimen.tsx'), 'export default 1');
180
+ const { fetchImpl } = makeHub({
181
+ 'system/ds/preview/specimen.css': { body: '.pwn{}', class: 'companion-text' },
182
+ });
183
+ const out = await pull(root, fetchImpl);
184
+ expect(out.pulled).toEqual([]);
185
+ expect(out.dropped[0]?.reason).toMatch(/canvas-owned/);
186
+ expect(existsSync(path.join(root, 'system/ds/preview/specimen.css'))).toBe(false);
187
+ } finally {
188
+ rmSync(root, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('a body whose hash is not what the manifest promised never lands', async () => {
193
+ const root = makeRoot();
194
+ try {
195
+ const { fetchImpl } = makeHub({
196
+ 'system/ds/brand.css': { body: ':root{}' },
197
+ });
198
+ // Serve DIFFERENT bytes than the manifest hashed (a racing write, or a
199
+ // hub lying about content) — the landing is refused, retried next pass.
200
+ const lying = (async (url: RequestInfo | URL) => {
201
+ const u = String(url);
202
+ if (u.includes('_project-file')) return new Response('EVIL');
203
+ return fetchImpl(url);
204
+ }) as typeof fetch;
205
+ const out = await pull(root, lying);
206
+ expect(out.pulled).toEqual([]);
207
+ expect(out.failed[0]?.reason).toMatch(/hash mismatch/);
208
+ expect(existsSync(path.join(root, 'system/ds/brand.css'))).toBe(false);
209
+ } finally {
210
+ rmSync(root, { recursive: true, force: true });
211
+ }
212
+ });
213
+ });
214
+
215
+ describe('file-pull — reconciliation', () => {
216
+ test('an equal hash skips, costing no byte fetch (echo-guard by construction)', async () => {
217
+ const root = makeRoot();
218
+ try {
219
+ mkdirSync(path.join(root, 'system/ds'), { recursive: true });
220
+ writeFileSync(path.join(root, 'system/ds/brand.css'), ':root{}');
221
+ const { fetchImpl, requests } = makeHub({
222
+ 'system/ds/brand.css': { body: ':root{}' },
223
+ });
224
+ const out = await pull(root, fetchImpl);
225
+ expect(out.skipped).toBe(1);
226
+ expect(out.pulled).toEqual([]);
227
+ expect(requests.filter((u) => u.includes('_project-file'))).toEqual([]);
228
+ } finally {
229
+ rmSync(root, { recursive: true, force: true });
230
+ }
231
+ });
232
+
233
+ test('a newer remote wins, and the losing local copy parks in _trash — never silent loss', async () => {
234
+ const root = makeRoot();
235
+ try {
236
+ mkdirSync(path.join(root, 'system/ds'), { recursive: true });
237
+ const abs = path.join(root, 'system/ds/brand.css');
238
+ writeFileSync(abs, 'LOCAL');
239
+ utimesSync(abs, new Date(1000), new Date(1000));
240
+ const { fetchImpl } = makeHub({
241
+ 'system/ds/brand.css': { body: 'REMOTE', mtimeMs: 5_000_000 },
242
+ });
243
+ const out = await pull(root, fetchImpl, { now: () => 424242 });
244
+ expect(out.pulled).toEqual(['system/ds/brand.css']);
245
+ expect(readFileSync(abs, 'utf8')).toBe('REMOTE');
246
+ expect(out.conflicts).toEqual([
247
+ {
248
+ rel: 'system/ds/brand.css',
249
+ winner: 'hub',
250
+ trashedTo: '_trash/system/ds/brand.css-conflict-424242',
251
+ },
252
+ ]);
253
+ expect(
254
+ readFileSync(path.join(root, '_trash/system/ds/brand.css-conflict-424242'), 'utf8')
255
+ ).toBe('LOCAL');
256
+ } finally {
257
+ rmSync(root, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ test('an older remote loses: the local copy stays, no bytes move', async () => {
262
+ const root = makeRoot();
263
+ try {
264
+ mkdirSync(path.join(root, 'system/ds'), { recursive: true });
265
+ const abs = path.join(root, 'system/ds/brand.css');
266
+ writeFileSync(abs, 'LOCAL');
267
+ // Local mtime is "now" (fresh write); the remote claims epoch-old.
268
+ const { fetchImpl, requests } = makeHub({
269
+ 'system/ds/brand.css': { body: 'REMOTE', mtimeMs: 1 },
270
+ });
271
+ const out = await pull(root, fetchImpl);
272
+ expect(out.pulled).toEqual([]);
273
+ expect(readFileSync(abs, 'utf8')).toBe('LOCAL');
274
+ expect(out.conflicts).toEqual([{ rel: 'system/ds/brand.css', winner: 'local' }]);
275
+ expect(requests.filter((u) => u.includes('_project-file'))).toEqual([]);
276
+ } finally {
277
+ rmSync(root, { recursive: true, force: true });
278
+ }
279
+ });
280
+
281
+ test('the per-pass cap is loud, and the remainder waits for the next pass', async () => {
282
+ const root = makeRoot();
283
+ try {
284
+ const files: Record<string, FakeFile> = {};
285
+ for (let i = 0; i < 205; i++)
286
+ files[`docs/note-${String(i).padStart(3, '0')}.md`] = { body: `n${i}` };
287
+ const { fetchImpl } = makeHub(files);
288
+ const warns: string[] = [];
289
+ const out = await pull(root, fetchImpl, {
290
+ log: { log: () => {}, warn: (m: string) => warns.push(m) },
291
+ });
292
+ expect(out.pulled.length).toBe(200);
293
+ expect(warns.some((w) => w.includes('205'))).toBe(true);
294
+ } finally {
295
+ rmSync(root, { recursive: true, force: true });
296
+ }
297
+ });
298
+ });
299
+
300
+ describe('fetchFileManifest — never fatal, always filtered', () => {
301
+ test('an unreachable hub, a refusal, and junk all answer null', async () => {
302
+ const down = (async () => {
303
+ throw new Error('ECONNREFUSED');
304
+ }) as unknown as typeof fetch;
305
+ expect(await fetchFileManifest('http://hub.test', 'tok', down)).toBe(null);
306
+ const refusing = (async () => new Response('no', { status: 401 })) as typeof fetch;
307
+ expect(await fetchFileManifest('http://hub.test', 'tok', refusing)).toBe(null);
308
+ const junk = (async () => new Response('{"files": "nope"}')) as typeof fetch;
309
+ expect(await fetchFileManifest('http://hub.test', 'tok', junk)).toBe(null);
310
+ });
311
+
312
+ test('malformed entries are filtered before anything reads them', async () => {
313
+ const sneaky = (async () =>
314
+ new Response(
315
+ JSON.stringify({
316
+ files: [
317
+ { path: 'ok.md', sha256: sha('x'), size: 1, mtimeMs: 1, class: 'companion-text' },
318
+ { path: '', sha256: sha('x'), size: 1, mtimeMs: 1 },
319
+ { path: 'bad-sha.md', sha256: 'nope', size: 1, mtimeMs: 1 },
320
+ { sha256: sha('x') },
321
+ null,
322
+ 'string',
323
+ ],
324
+ })
325
+ )) as typeof fetch;
326
+ const entries = (await fetchFileManifest(
327
+ 'http://hub.test',
328
+ 'tok',
329
+ sneaky
330
+ )) as RemoteFileEntry[];
331
+ expect(entries.map((e) => e.path)).toEqual(['ok.md']);
332
+ });
333
+ });