@descix/frqtl-node 0.6.0-p1.1 → 0.6.0

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 (80) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +6 -4
  3. package/dist/dist-manifest.json +71 -65
  4. package/dist/eyes/index.js +5 -2
  5. package/dist/fat/pkg-node/package.json +2 -10
  6. package/dist/frqtl/compat/providers/wasm-engine.js +3 -4
  7. package/dist/frqtl/compat/renderer/providers/p5-provider.js +1 -1
  8. package/dist/frqtl/objects/FrqtlApp.js +33 -6
  9. package/dist/frqtl/objects/RecipeContext.js +284 -13
  10. package/dist/frqtl/objects/constants/WallBehaviors.js +1 -1
  11. package/dist/frqtl/objects/instruments/capture.js +1 -24
  12. package/dist/frqtl/objects/instruments/hosted-eyes.js +216 -29
  13. package/dist/frqtl/objects/objects/makeControlsForObject.js +5 -5
  14. package/dist/frqtl/objects/record/Record.js +346 -0
  15. package/dist/frqtl/objects/record/open-record.js +22 -0
  16. package/dist/frqtl/objects/schema/engine-scopes.js +2 -2
  17. package/dist/frqtl/objects/schema/scopes.js +2 -2
  18. package/dist/frqtl/objects/setup/_worldInit.js +1 -2
  19. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +1 -2
  20. package/dist/frqtl/parts/CameraControls.js +8 -0
  21. package/dist/frqtl/parts/DetectorWall.js +1 -1
  22. package/dist/frqtl/parts/FrqtlVision.js +21 -6
  23. package/dist/frqtl/parts/GraphingDetectorWall.js +7 -7
  24. package/dist/frqtl/parts/LargeObject.js +2 -2
  25. package/dist/frqtl/parts/LiveChart.js +2 -2
  26. package/dist/frqtl/parts/LogicGate.js +3 -3
  27. package/dist/frqtl/parts/OvenBox.js +1 -1
  28. package/dist/frqtl/parts/PointSource.js +1 -1
  29. package/dist/frqtl/parts/SlitLightSource.js +1 -1
  30. package/dist/frqtl/parts/SlitScreen.js +1 -1
  31. package/dist/frqtl/parts/SphereEmitter.js +12 -12
  32. package/dist/frqtl/parts/Trace.js +1 -1
  33. package/dist/frqtl/parts/Tunnel.js +11 -11
  34. package/dist/frqtl/parts/Wall.js +20 -10
  35. package/dist/frqtl/scenes/circuit-sat.js +1 -0
  36. package/dist/frqtl/scenes/dse-one-emitter.js +32 -34
  37. package/dist/frqtl/scenes/dse-two-emitters.js +33 -34
  38. package/dist/frqtl/scenes/mirror-route.js +96 -79
  39. package/dist/shim/bundle/index.js +118 -96
  40. package/dist/shim/bundle/indexHtml.js +66 -39
  41. package/dist/shim/controls/index.js +1 -1
  42. package/dist/shim/engine/artifact-shell.js +1 -1
  43. package/dist/shim/engine/router.js +3 -3
  44. package/dist/shim/host/host-view.js +228 -0
  45. package/dist/shim/host/index.js +18 -0
  46. package/dist/shim/index.js +3 -5
  47. package/dist/shim/notebook/format.js +1 -1
  48. package/dist/shim/notebook/kernel.js +1 -1
  49. package/dist/shim/notebook/setup-builder.js +2 -2
  50. package/dist/shim/notebook/store.js +2 -3
  51. package/dist/shim/package.json +1 -0
  52. package/dist/shim/page-params/index.js +2 -30
  53. package/dist/shim/project/fork.js +293 -0
  54. package/dist/shim/project/identity.js +3 -1
  55. package/dist/shim/project/index.js +16 -3
  56. package/dist/shim/project/link.js +224 -46
  57. package/dist/shim/project/manifest.js +151 -0
  58. package/dist/shim/project/parts-writer.js +68 -7
  59. package/dist/shim/project/read-text.js +43 -0
  60. package/dist/shim/project/record-format.js +345 -0
  61. package/dist/shim/project/specifiers.js +104 -15
  62. package/dist/shim/registry/exhibits.js +15 -34
  63. package/dist/shim/registry/index.js +3 -3
  64. package/dist/shim/registry/scene-roster.js +35 -0
  65. package/dist/shim/registry/scene-source.js +48 -64
  66. package/dist/shim/registry/walkthrough.js +6 -6
  67. package/dist/shim/render-state/apply.js +66 -31
  68. package/dist/shim/render-state/index.js +6 -2
  69. package/dist/shim/runtime/index.js +4 -4
  70. package/dist/shim/storage/codec-chains.js +88 -0
  71. package/dist/shim/storage/folders.js +205 -0
  72. package/dist/shim/storage/index.js +64 -8
  73. package/dist/shim/storage/make-namespace-store.js +5 -5
  74. package/llms.txt +4 -3
  75. package/package.json +19 -1
  76. package/dist/shim/bundle/frqnb.js +0 -112
  77. package/dist/shim/bundle/manifest.js +0 -95
  78. package/dist/shim/registry/experiments.js +0 -215
  79. package/dist/shim/storage/modules.js +0 -249
  80. package/dist/shim/storage/projects.js +0 -150
@@ -16,6 +16,10 @@
16
16
  * import line for Kind (after the last import statement;
17
17
  * `module` from parts-module.js moduleOfKind)
18
18
  * | { op: 'remove', name } the entry's property node, its comma included
19
+ * | { op: 'kind', name, Kind, module } within entry `name`, replace ONLY its Kind identifier's text, plus
20
+ * at most one import line for Kind (the add's line, after the last
21
+ * import); the entry's old import line is the author's and stays,
22
+ * even when nothing uses it now (a fork: exp.fork)
19
23
  *
20
24
  * changes[i] = { op, name, key?, from?, to, literalized? } from/to are source TEXT; `literalized: true`
21
25
  * when the old value was an expression (its text is `from`), now its literal value
@@ -28,7 +32,9 @@
28
32
  * the serializer cannot write (a function, NaN/Infinity, undefined, a class instance), naming its key.
29
33
  *
30
34
  * entryKeys(text, name) → string[] the plain prop keys entry `name` spells (how an entry spells a value)
31
- * entryLiteral(text, name, key) → { literal, value? } what a key declares when it is a literal (read-back)
35
+ * entryLiteral(text, name, key) → { literal, value?, length? } what a key declares when it is a literal
36
+ * (read-back); an array expression's element count otherwise (none when
37
+ * an element is a spread)
32
38
  *
33
39
  * serializeValue(value, key) → text THE one value serializer: numbers, strings (single-quoted),
34
40
  * booleans, null, arrays inline, plain objects. Anything else THROWS
@@ -38,6 +44,14 @@
38
44
  import { parse } from '../../frqtl/assets/vendor/acorn.mjs';
39
45
  import { importSpecifierOf } from './parts-module.js';
40
46
 
47
+ /**
48
+ * parseModule(text) → the acorn syntax tree of an ES module (latest syntax, source ranges) — the vendored parser's
49
+ * ONE entry point in the tree; every other reader of a project file's syntax (fork.js) parses through it.
50
+ */
51
+ export function parseModule(text) {
52
+ return parse(text, { ecmaVersion: 'latest', sourceType: 'module' });
53
+ }
54
+
41
55
  /** The export the writer patches. */
42
56
  export const PARTS_EXPORT = 'parts';
43
57
 
@@ -46,6 +60,14 @@ const _RESERVED = new Set(('break case catch class const continue debugger defau
46
60
  'false finally for function if import in instanceof new null return super switch this throw true try typeof ' +
47
61
  'var void while with yield let static enum await implements package protected interface private public').split(' '));
48
62
 
63
+ /**
64
+ * isIdentifierName(s) → true when `s` can name a binding (an identifier that is not a reserved word) — the one
65
+ * test the writer and the fork transform share.
66
+ */
67
+ export function isIdentifierName(s) {
68
+ return typeof s === 'string' && _IDENT.test(s) && !_RESERVED.has(s);
69
+ }
70
+
49
71
  function _keyText(k) {
50
72
  return (_IDENT.test(k) && !_RESERVED.has(k)) ? k : _quote(k);
51
73
  }
@@ -229,7 +251,7 @@ export function patchParts(text, edits, opts) {
229
251
  throw new TypeError('[parts-writer] patchParts(text, edits): text is the sketch file (got ' + typeof text + '). (FAIL-LOUD.)');
230
252
  }
231
253
  if (!Array.isArray(edits)) {
232
- throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove }. (FAIL-LOUD.)');
254
+ throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove | kind }. (FAIL-LOUD.)');
233
255
  }
234
256
  const changes = [];
235
257
  const refused = [];
@@ -316,6 +338,37 @@ export function patchParts(text, edits, opts) {
316
338
  const ins = _appendProperty(text, obj, appended.join(_multiline(text, obj) ? ',\n' + _indentAt(text, (obj.properties[obj.properties.length - 1] || obj).start) : ', '));
317
339
  patches.push(ins);
318
340
  }
341
+ } else if (e.op === 'kind') {
342
+ const p = entryOf(e);
343
+ if (!p) { continue; }
344
+ const v = p.value;
345
+ if (v.type !== 'ArrayExpression' || v.elements.length !== 2 || !v.elements[0] || v.elements[0].type !== 'Identifier') {
346
+ refuse(e, 'the entry "' + e.name + '" is not written as [Kind, { …props }] — the writer patches only that form.');
347
+ continue;
348
+ }
349
+ if (typeof e.Kind !== 'string' || !_IDENT.test(e.Kind)) {
350
+ throw new TypeError('[parts-writer] kind: Kind is the new kind\'s class name (got ' + JSON.stringify(e.Kind) + '). (FAIL-LOUD.)');
351
+ }
352
+ if (e.module === null || e.module === undefined) {
353
+ refuse(e, 'the kind ' + e.Kind + ' has no importable module, so a sketch cannot import it.');
354
+ continue;
355
+ }
356
+ const spec = importSpecifierOf(e.module, entry);
357
+ const b = bindings.get(e.Kind);
358
+ if (b && !(b.kind === 'import' && b.source === spec && b.imported === e.Kind)) {
359
+ refuse(e, 'the identifier ' + e.Kind + ' is already bound in this sketch to ' +
360
+ (b.kind === 'import' ? '"' + (b.imported || '*') + '" from \'' + b.source + '\'' : 'its own declaration') +
361
+ ' — the writer cannot import the kind ' + e.Kind + ' under that name.');
362
+ continue;
363
+ }
364
+ const id = v.elements[0];
365
+ if (id.name === e.Kind) { continue; }
366
+ patches.push({ start: id.start, end: id.end, text: e.Kind });
367
+ const line = 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';';
368
+ if (!b && !importLines.some(function (l) { return l.Kind === e.Kind; })) {
369
+ importLines.push({ Kind: e.Kind, line: line });
370
+ }
371
+ changes.push({ op: 'kind', name: e.name, from: id.name, to: e.Kind, import: (!b ? line : null) });
319
372
  } else if (e.op === 'remove') {
320
373
  const p = entryOf(e);
321
374
  if (!p) { continue; }
@@ -352,7 +405,7 @@ export function patchParts(text, edits, opts) {
352
405
  changes.push({ op: 'add', name: e.name, from: null, to: _keyText(e.name) + ': [' + e.Kind + ', ' + lit + ']',
353
406
  import: (!b ? 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';' : null) });
354
407
  } else {
355
- throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove. (FAIL-LOUD.)');
408
+ throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove | kind. (FAIL-LOUD.)');
356
409
  }
357
410
  }
358
411
  if (adds.length) {
@@ -440,14 +493,22 @@ export function entryKeys(text, name) {
440
493
  }
441
494
 
442
495
  /**
443
- * entryLiteral(text, name, key) → { literal: true, value } | { literal: false } — what entry `name`'s `key`
444
- * declares when its value is a literal (the read-back after a write: the text a patch produced, parsed again).
445
- * THROWS on no such entry or no such key.
496
+ * entryLiteral(text, name, key) → { literal: true, value } | { literal: false, length? } — what entry `name`'s
497
+ * `key` declares when its value is a literal (the read-back after a write: the text a patch produced, parsed
498
+ * again). A non-literal ARRAY expression answers its element count as `length` (the arity it spells); any
499
+ * other expression answers no length. THROWS on no such entry or no such key.
446
500
  */
447
501
  export function entryLiteral(text, name, key) {
448
502
  for (const q of _entryProps(text, name, 'entryLiteral').properties) {
449
503
  if (_plainKey(q) !== key) { continue; }
450
- return _isLiteral(q.value) ? { literal: true, value: _literalValue(q.value) } : { literal: false };
504
+ if (_isLiteral(q.value)) { return { literal: true, value: _literalValue(q.value) }; }
505
+ // An array expression still spells its arity (`center: [srcX, cy]` is two numbers) — unless an element is a
506
+ // spread (`center: [...c]`), which spells no arity: the caller applies the live one.
507
+ const arr = q.value.type === 'ArrayExpression';
508
+ if (arr && !q.value.elements.some(function (x) { return x && x.type === 'SpreadElement'; })) {
509
+ return { literal: false, length: q.value.elements.length };
510
+ }
511
+ return { literal: false };
451
512
  }
452
513
  throw new Error('[parts-writer] entryLiteral: the entry "' + name + '" spells no key "' + key + '". (FAIL-LOUD.)');
453
514
  }
@@ -0,0 +1,43 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * read-text.js — THE reader of a text file by URL, for every host: a `file:` URL is read through node's fs
5
+ * (`process.getBuiltinModule`, Node 20.16+ / 22.3+); any other URL is fetched. The split is a CAPABILITY of the
6
+ * resolved URL, never a host sniff.
7
+ *
8
+ * readText(url[, { fetch }]) → Promise<string> REJECTS naming the URL: a file it cannot read ("reading <url>
9
+ * failed: …"), a non-200 ("GET <url> answered <status>"), no
10
+ * fs for a file: URL, no fetch. `fetch` is injectable (tests).
11
+ *
12
+ * Callers prefix their own subject: lib/shim/registry/scene-source.js (a registry scene's sketch) and
13
+ * RecipeContext.fork (a package part's exact bytes).
14
+ */
15
+
16
+ /**
17
+ * @param {string} url
18
+ * @param {{fetch?: function}} [opts]
19
+ * @returns {Promise<string>}
20
+ */
21
+ export function readText(url, opts) {
22
+ return Promise.resolve().then(function () {
23
+ if (typeof url !== 'string' || !url) { throw new TypeError('readText(url): a URL string (got ' + JSON.stringify(url) + ')'); }
24
+ if (url.indexOf('file:') === 0) {
25
+ var getBuiltin = (typeof process !== 'undefined' && process.getBuiltinModule) ? process.getBuiltinModule : null;
26
+ if (typeof getBuiltin !== 'function') {
27
+ throw new Error(url + ' is a file: URL; reading one needs node\'s process.getBuiltinModule (Node 20.16+ / ' +
28
+ '22.3+). In a page, serve the package over http');
29
+ }
30
+ var fs = getBuiltin('node:fs');
31
+ var fileURLToPath = getBuiltin('node:url').fileURLToPath;
32
+ return fs.promises.readFile(fileURLToPath(url), 'utf8').catch(function (e) {
33
+ throw new Error('reading ' + url + ' failed: ' + (e && e.message));
34
+ });
35
+ }
36
+ var f = (opts && opts.fetch) || (typeof fetch === 'function' ? fetch : null);
37
+ if (!f) { throw new Error('no fetch available to load ' + url); }
38
+ return Promise.resolve(f(url)).then(function (res) {
39
+ if (!res || !res.ok) { throw new Error('GET ' + url + ' answered ' + (res ? res.status : 'nothing')); }
40
+ return res.text();
41
+ });
42
+ });
43
+ }
@@ -0,0 +1,345 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * record-format.js — THE owner of the Record folder's shape (the sketch-projects design §6), and its ONE writer.
5
+ *
6
+ * A Record is its own folder, and it contains the project it recorded:
7
+ *
8
+ * record.json { tickRange: [first, last], clips: ['clip-01.mp4', …], changes: [{tick, name, op, by, from, to}] }
9
+ * clip-01.mp4 … the FrqtlVision footage, one file per CLIP_SECONDS segment, in the order filmed
10
+ * parts.before.json { "<name>": ["<registered kind name>", {props}] } at Record start, in key order
11
+ * parts.after.json the same at stop; their diff IS the code change
12
+ * project/… the project's files at stop (frqtl.json, sketch.js, parts/*, README), replayable
13
+ * through bootProject
14
+ *
15
+ * A host's own files (a lab's lab.json) may sit beside these; this module never reads or checks them.
16
+ *
17
+ * RECORD_FILES the folder's §6 paths (record, partsBefore, partsAfter, project)
18
+ * RECORD_KEYS record.json's keys, exactly — there is no record-level who
19
+ * CHANGE_KEYS one change row's keys, exactly
20
+ * OPS the change ops: a placement is `move` whatever hand made it
21
+ * BY who acted, per row
22
+ * CLIP_SECONDS { min, max } — the length of one clip, in seconds of playback
23
+ * CLIP_NAME the clip file pattern; clipName(n) → 'clip-NN.mp4' (n from 1)
24
+ * jsonText(v) JSON.stringify(v, null, 2) + '\n' — every JSON file of the folder
25
+ * recordFolderFaults(files) → string[] [] for a complete §6 folder, every fault by name otherwise
26
+ * serializeRecordFolder(parts) → { files, record, clips } | { empty: true } THE writer (Record.js calls it;
27
+ * it runs recordFolderFaults on its own output and THROWS on any fault)
28
+ *
29
+ * `files` is a tree { path → string | Uint8Array }: JSON and project text as strings (or UTF-8 bytes), clips
30
+ * as bytes. Values in §6 files are strict JSON, in record.json's own vocabulary; a placeholder ("*",
31
+ * {unavailable}) never sits in a §6 field.
32
+ */
33
+
34
+ import { MANIFEST_FILE } from './manifest.js';
35
+ import { entryOf } from './link.js';
36
+
37
+ /** The folder's §6 paths. */
38
+ export const RECORD_FILES = Object.freeze({
39
+ record: 'record.json',
40
+ partsBefore: 'parts.before.json',
41
+ partsAfter: 'parts.after.json',
42
+ project: 'project/'
43
+ });
44
+
45
+ /** record.json's keys, exactly. */
46
+ export const RECORD_KEYS = Object.freeze(['tickRange', 'clips', 'changes']);
47
+
48
+ /** One change row's keys, exactly. */
49
+ export const CHANGE_KEYS = Object.freeze(['tick', 'name', 'op', 'by', 'from', 'to']);
50
+
51
+ /** The change ops. */
52
+ export const OPS = Object.freeze(['move', 'setDeclared', 'add', 'remove', 'fork']);
53
+
54
+ /** Who acted (each row's `by`). */
55
+ export const BY = Object.freeze(['human', 'maxi']);
56
+
57
+ /** The length of one clip, in seconds of playback (frames / fps). */
58
+ export const CLIP_SECONDS = Object.freeze({ min: 5, max: 10 });
59
+
60
+ /** The clip file pattern. */
61
+ export const CLIP_NAME = /^clip-(\d{2})\.mp4$/;
62
+
63
+ /** @param {number} n 1-based @returns {string} 'clip-NN.mp4' */
64
+ export function clipName(n) {
65
+ if (!Number.isInteger(n) || n < 1 || n > 99) {
66
+ throw new Error('[record-format] a clip number is a whole number 1–99, got ' + JSON.stringify(n) + '. (FAIL-LOUD.)');
67
+ }
68
+ return 'clip-' + String(n).padStart(2, '0') + '.mp4';
69
+ }
70
+
71
+ /** THE text of every JSON file of a Record folder. */
72
+ export function jsonText(v) {
73
+ return JSON.stringify(v, null, 2) + '\n';
74
+ }
75
+
76
+ // ── value shapes ────────────────────────────────────────────────────────────────
77
+
78
+ const POSITION_KEYS = Object.freeze({ center: [3], rect: [4, 6] });
79
+ const FORK_FILE = /^parts\/[^/]+\.js$/;
80
+
81
+ function _isPlain(v) {
82
+ return v !== null && typeof v === 'object' && !Array.isArray(v) &&
83
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
84
+ }
85
+
86
+ /** The first path inside `v` that is not strict JSON (a function, NaN, undefined, a class instance …), else null. */
87
+ function _strictJsonFault(v, at) {
88
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') { return null; }
89
+ if (typeof v === 'number') { return Number.isFinite(v) ? null : at + ' is ' + String(v); }
90
+ if (Array.isArray(v)) {
91
+ for (let i = 0; i < v.length; i++) { const f = _strictJsonFault(v[i], at + '[' + i + ']'); if (f) { return f; } }
92
+ return null;
93
+ }
94
+ if (_isPlain(v)) {
95
+ for (const k of Object.keys(v)) { const f = _strictJsonFault(v[k], at + '.' + k); if (f) { return f; } }
96
+ return null;
97
+ }
98
+ return at + ' is ' + (v === undefined ? 'undefined' : typeof v === 'object' ? 'a ' + ((v.constructor && v.constructor.name) || 'class') + ' instance' : 'a ' + typeof v);
99
+ }
100
+
101
+ /** Every placeholder inside `v`: an {unavailable} object or a "*" string. */
102
+ function _placeholders(v, at, out) {
103
+ if (v === '*') { out.push(at + ' is the placeholder "*"'); return out; }
104
+ if (Array.isArray(v)) { v.forEach(function (x, i) { _placeholders(x, at + '[' + i + ']', out); }); return out; }
105
+ if (v && typeof v === 'object') {
106
+ if (Object.prototype.hasOwnProperty.call(v, 'unavailable')) { out.push(at + ' is the placeholder {unavailable}'); return out; }
107
+ Object.keys(v).forEach(function (k) { _placeholders(v[k], at + '.' + k, out); });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function _isEntry(e) {
113
+ return Array.isArray(e) && e.length === 2 && typeof e[0] === 'string' && e[0].length > 0 && _isPlain(e[1]) && !_strictJsonFault(e[1], '');
114
+ }
115
+
116
+ /** { key, length } of a move value `{center: [3]}` / `{rect: [4|6]}`, else null. */
117
+ function _placement(v) {
118
+ if (!_isPlain(v)) { return null; }
119
+ const ks = Object.keys(v);
120
+ if (ks.length !== 1 || !POSITION_KEYS[ks[0]]) { return null; }
121
+ const a = v[ks[0]];
122
+ if (!Array.isArray(a) || POSITION_KEYS[ks[0]].indexOf(a.length) === -1 || !a.every(function (n) { return typeof n === 'number' && Number.isFinite(n); })) { return null; }
123
+ return { key: ks[0], length: a.length };
124
+ }
125
+
126
+ const MOVE_SHAPE = 'move carries from/to as ONE key, {center: [x, y, z]} or {rect: [4 numbers flat | 6 with a z-extent]}, the same key and length in both';
127
+
128
+ /** The faults of one change row against its op. */
129
+ function _rowFaults(c, at) {
130
+ const bad = [];
131
+ if (!_isPlain(c)) { return [at + ' is not a change {' + CHANGE_KEYS.join(', ') + '}']; }
132
+ const keys = Object.keys(c);
133
+ if (keys.length !== CHANGE_KEYS.length || !CHANGE_KEYS.every(function (k) { return keys.indexOf(k) !== -1; })) {
134
+ bad.push(at + ' carries exactly {' + CHANGE_KEYS.join(', ') + '}, got {' + keys.join(', ') + '}');
135
+ }
136
+ if (!Number.isInteger(c.tick) || c.tick < 0) { bad.push(at + '.tick is a whole tick ≥ 0, got ' + JSON.stringify(c.tick)); }
137
+ if (typeof c.name !== 'string' || !c.name) { bad.push(at + '.name is the declared name, got ' + JSON.stringify(c.name)); }
138
+ if (BY.indexOf(c.by) === -1) { bad.push(at + '.by is one of ' + BY.join('|') + ', got ' + JSON.stringify(c.by)); }
139
+ ['from', 'to'].forEach(function (k) {
140
+ const f = _strictJsonFault(c[k], at + '.' + k);
141
+ if (f) { bad.push(f + ' — from/to are strict JSON'); }
142
+ });
143
+ if (c.op === 'move') {
144
+ const a = _placement(c.from), b = _placement(c.to);
145
+ if (!a || !b || a.key !== b.key || a.length !== b.length) { bad.push(at + ': ' + MOVE_SHAPE + ', got ' + JSON.stringify([c.from, c.to])); }
146
+ else if (JSON.stringify(c.from) === JSON.stringify(c.to)) { bad.push(at + ' is a zero move of "' + c.name + '" — an act that moved nothing writes no row'); }
147
+ } else if (c.op === 'setDeclared') {
148
+ if (!_isPlain(c.from) || !_isPlain(c.to) || !Object.keys(c.to).length ||
149
+ JSON.stringify(Object.keys(c.from).sort()) !== JSON.stringify(Object.keys(c.to).sort())) {
150
+ bad.push(at + ': setDeclared carries from/to as {key: value} of the patched keys, the same keys before and after, got ' + JSON.stringify([c.from, c.to]));
151
+ }
152
+ } else if (c.op === 'add') {
153
+ if (c.from !== null || !_isEntry(c.to)) { bad.push(at + ': add carries from: null and to: ["<registered kind name>", {props}], got ' + JSON.stringify([c.from, c.to])); }
154
+ } else if (c.op === 'remove') {
155
+ if (c.to !== null || !_isEntry(c.from)) { bad.push(at + ': remove carries from: ["<registered kind name>", {props}] and to: null, got ' + JSON.stringify([c.from, c.to])); }
156
+ } else if (c.op === 'fork') {
157
+ const ok = typeof c.from === 'string' && c.from.length > 0 && _isPlain(c.to) &&
158
+ JSON.stringify(Object.keys(c.to).sort()) === '["file","kind"]' &&
159
+ typeof c.to.kind === 'string' && c.to.kind.length > 0 && typeof c.to.file === 'string' && FORK_FILE.test(c.to.file);
160
+ if (!ok) { bad.push(at + ': fork carries from: "<source kind name>" and to: {"kind": "<new kind name>", "file": "parts/<File>.js"}, got ' + JSON.stringify([c.from, c.to])); }
161
+ } else {
162
+ bad.push(at + '.op ' + JSON.stringify(c.op) + ' is not one of ' + OPS.join('|') + ' (a placement is "move" whatever hand made it)');
163
+ }
164
+ return bad;
165
+ }
166
+
167
+ function _text(v) {
168
+ return typeof v === 'string' ? v : new TextDecoder('utf-8', { fatal: true }).decode(v);
169
+ }
170
+
171
+ function _isBytes(v) { return v instanceof Uint8Array; }
172
+
173
+ /** Parse a JSON file of the tree: { value } | { fault }. */
174
+ function _json(files, path) {
175
+ if (!Object.prototype.hasOwnProperty.call(files, path)) { return { fault: 'the folder has no ' + path }; }
176
+ try { return { value: JSON.parse(_text(files[path])) }; }
177
+ catch (e) { return { fault: path + ' is not JSON: ' + ((e && e.message) || String(e)) }; }
178
+ }
179
+
180
+ /**
181
+ * recordFolderFaults(files) → string[] — [] for a COMPLETE §6 Record folder, otherwise every fault by name.
182
+ * §6 checks only: record.json's keys and value shapes; each row against its op (the fork shape included);
183
+ * ticks inside tickRange and never decreasing; no zero move; one shape per name's moves; `clips` equal to the
184
+ * clip files present, in order; the parts.*.json entries; `project/frqtl.json` naming an entry present under
185
+ * project/. A host's own files (lab.json …) are never read.
186
+ * @param {Object<string, string|Uint8Array>} files
187
+ * @returns {string[]}
188
+ */
189
+ export function recordFolderFaults(files) {
190
+ if (!_isPlain(files)) { return ['a Record folder is a tree { path: string | Uint8Array }']; }
191
+ const bad = [];
192
+ Object.keys(files).forEach(function (p) {
193
+ if (typeof files[p] !== 'string' && !_isBytes(files[p])) { bad.push(p + ' is neither text nor bytes'); }
194
+ });
195
+ // record.json
196
+ const r = _json(files, RECORD_FILES.record);
197
+ if (r.fault) { bad.push(r.fault); }
198
+ const rec = r.value;
199
+ let range = null;
200
+ let clips = [];
201
+ if (rec !== undefined) {
202
+ if (!_isPlain(rec)) { bad.push(RECORD_FILES.record + ' is an object {' + RECORD_KEYS.join(', ') + '}'); }
203
+ else {
204
+ const keys = Object.keys(rec);
205
+ if (keys.length !== RECORD_KEYS.length || !RECORD_KEYS.every(function (k) { return keys.indexOf(k) !== -1; })) {
206
+ bad.push(RECORD_FILES.record + ' carries exactly {' + RECORD_KEYS.join(', ') + '} (no record-level who: each change names its `by`), got {' + keys.join(', ') + '}');
207
+ }
208
+ _placeholders(rec, RECORD_FILES.record, []).forEach(function (f) { bad.push(f + ' — a placeholder never sits in a §6 field'); });
209
+ const tr = rec.tickRange;
210
+ if (!Array.isArray(tr) || tr.length !== 2 || !tr.every(function (t) { return Number.isInteger(t) && t >= 0; }) || tr[1] < tr[0]) {
211
+ bad.push(RECORD_FILES.record + ' tickRange is [first, last] whole ticks, first ≤ last, got ' + JSON.stringify(tr));
212
+ } else { range = tr; }
213
+ if (!Array.isArray(rec.clips)) { bad.push(RECORD_FILES.record + ' clips is the list of clip files, got ' + JSON.stringify(rec.clips)); }
214
+ else {
215
+ clips = rec.clips;
216
+ clips.forEach(function (c, i) {
217
+ const want = clipName(i + 1);
218
+ if (c !== want) { bad.push(RECORD_FILES.record + ' clips[' + i + '] is ' + JSON.stringify(c) + ', not ' + want + ' (clips in the order filmed)'); }
219
+ else if (!_isBytes(files[c]) || !files[c].length) { bad.push(c + ' is listed in ' + RECORD_FILES.record + ' clips but is not in the folder as bytes'); }
220
+ });
221
+ }
222
+ if (!Array.isArray(rec.changes)) { bad.push(RECORD_FILES.record + ' changes is the list of change rows, got ' + JSON.stringify(rec.changes)); }
223
+ else {
224
+ const shapeOf = {};
225
+ let last = -Infinity;
226
+ rec.changes.forEach(function (c, i) {
227
+ const at = RECORD_FILES.record + ' changes[' + i + ']';
228
+ _rowFaults(c, at).forEach(function (f) { bad.push(f); });
229
+ if (!_isPlain(c)) { return; }
230
+ if (Number.isInteger(c.tick)) {
231
+ if (range && (c.tick < range[0] || c.tick > range[1])) { bad.push(at + ' at tick ' + c.tick + ' lies outside tickRange ' + JSON.stringify(range)); }
232
+ if (c.tick < last) { bad.push(at + ' at tick ' + c.tick + ' comes after a row at tick ' + last + ' — rows are in the order acted, so ticks only climb'); }
233
+ last = Math.max(last, c.tick);
234
+ }
235
+ if (c.op === 'move') {
236
+ const p = _placement(c.from);
237
+ if (p) {
238
+ const s = p.key + '/' + p.length;
239
+ if (shapeOf[c.name] && shapeOf[c.name] !== s) {
240
+ bad.push(at + ' moves "' + c.name + '" as {' + p.key + ': ' + p.length + ' numbers} after a row that moved it as {' +
241
+ shapeOf[c.name].replace('/', ': ') + ' numbers} — one part moves in the one shape of its position authority');
242
+ }
243
+ shapeOf[c.name] = shapeOf[c.name] || s;
244
+ }
245
+ }
246
+ });
247
+ }
248
+ }
249
+ }
250
+ Object.keys(files).filter(function (f) { return CLIP_NAME.test(f) && clips.indexOf(f) === -1; }).sort().forEach(function (f) {
251
+ bad.push(f + ' is in the folder but not listed in ' + RECORD_FILES.record + ' clips');
252
+ });
253
+ // parts.before.json / parts.after.json
254
+ [RECORD_FILES.partsBefore, RECORD_FILES.partsAfter].forEach(function (p) {
255
+ const j = _json(files, p);
256
+ if (j.fault) { bad.push(j.fault); return; }
257
+ if (!_isPlain(j.value)) { bad.push(p + ' is {"<name>": ["<registered kind name>", {props}]}, got ' + JSON.stringify(j.value)); return; }
258
+ _placeholders(j.value, p, []).forEach(function (f) { bad.push(f + ' — a placeholder never sits in a §6 field'); });
259
+ Object.keys(j.value).forEach(function (n) {
260
+ if (!_isEntry(j.value[n])) { bad.push(p + ' "' + n + '" is not ["<registered kind name>", {props}]: ' + JSON.stringify(j.value[n])); }
261
+ });
262
+ });
263
+ // project/
264
+ const mp = RECORD_FILES.project + MANIFEST_FILE;
265
+ if (!Object.prototype.hasOwnProperty.call(files, mp)) { bad.push('project/ has no ' + MANIFEST_FILE + ' naming its entry (a Record snapshots a project folder)'); }
266
+ else {
267
+ let entry = null;
268
+ try { entry = entryOf({ [MANIFEST_FILE]: _text(files[mp]) }); }
269
+ catch (e) { bad.push(mp + ' is refused by the entry rule (link.js entryOf): ' + ((e && e.message) || String(e))); }
270
+ if (entry !== null && !Object.prototype.hasOwnProperty.call(files, RECORD_FILES.project + entry)) {
271
+ bad.push('project/ has no ' + entry + ' (the entry ' + MANIFEST_FILE + ' names)');
272
+ }
273
+ }
274
+ return bad;
275
+ }
276
+
277
+ // ── the writer ──────────────────────────────────────────────────────────────────
278
+
279
+ /** A parts block → its §6 JSON value, refusing a value that is not strict JSON by name. */
280
+ function _partsValue(block, which) {
281
+ if (!_isPlain(block)) { throw new TypeError('[record-format] ' + which + ' is { "<name>": ["<registered kind name>", {props}] }. (FAIL-LOUD.)'); }
282
+ const out = {};
283
+ Object.keys(block).forEach(function (n) {
284
+ const e = block[n];
285
+ if (!Array.isArray(e) || e.length !== 2 || typeof e[0] !== 'string' || !e[0] || !_isPlain(e[1])) {
286
+ throw new TypeError('[record-format] ' + which + ' "' + n + '" is not ["<registered kind name>", {props}]. (FAIL-LOUD.)');
287
+ }
288
+ const f = _strictJsonFault(e[1], n);
289
+ if (f) {
290
+ throw new TypeError('[record-format] ' + which + ': ' + f + ' — a Record writes props as strict JSON; declare that value ' +
291
+ 'as a literal in `parts`. (FAIL-LOUD.)');
292
+ }
293
+ out[n] = [e[0], JSON.parse(JSON.stringify(e[1]))];
294
+ });
295
+ return out;
296
+ }
297
+
298
+ export const FILM_AFTER_LAST_CHANGE = 'film after your last change: the footage must show every change';
299
+
300
+ /**
301
+ * serializeRecordFolder({ changes, clips, partsBefore, partsAfter, project }) — THE writer of a Record folder.
302
+ * changes the rows, in the order acted
303
+ * clips [{ bytes: Uint8Array, firstTick, lastTick }], in the order filmed (Record ticks)
304
+ * partsBefore / partsAfter { name: [registered kind name, props] }
305
+ * project { path: text } — the held files at stop
306
+ * → { empty: true } when there is no change and no clip (a state, with no folder);
307
+ * → { files, record, clips: [{ file, firstTick, lastTick }] } otherwise.
308
+ * REFUSES (throws): a change and no clip, or a change after the last clip ends (FILM_AFTER_LAST_CHANGE); a
309
+ * props value that is not strict JSON (naming its key); and any recordFolderFaults of its own output.
310
+ */
311
+ export function serializeRecordFolder(p) {
312
+ const changes = p.changes, clips = p.clips;
313
+ if (!changes.length && !clips.length) { return { empty: true }; }
314
+ if (!clips.length) {
315
+ throw new Error('[record-format] this Record has ' + changes.length + ' change(s) and no clip — ' + FILM_AFTER_LAST_CHANGE + '. (FAIL-LOUD.)');
316
+ }
317
+ const end = clips[clips.length - 1].lastTick;
318
+ const late = changes.filter(function (c) { return c.tick > end; });
319
+ if (late.length) {
320
+ throw new Error('[record-format] ' + late.length + ' change(s) after the last clip ends (Record tick ' + end + '): ' +
321
+ late.map(function (c) { return c.op + ' ' + c.name + ' at tick ' + c.tick; }).join('; ') + ' — ' + FILM_AFTER_LAST_CHANGE + '. (FAIL-LOUD.)');
322
+ }
323
+ const first = changes.reduce(function (m, c) { return Math.min(m, c.tick); }, clips[0].firstTick);
324
+ const record = {
325
+ tickRange: [first, end],
326
+ clips: clips.map(function (c, i) { return clipName(i + 1); }),
327
+ changes: changes.map(function (c) { const o = {}; CHANGE_KEYS.forEach(function (k) { o[k] = c[k]; }); return o; })
328
+ };
329
+ // Check the rows as HELD, before any text: JSON.stringify writes NaN/Infinity as null and drops an undefined key,
330
+ // so a check of the written text would read a different value from the one the Record held.
331
+ const held = [];
332
+ record.changes.forEach(function (c, i) { held.push.apply(held, _rowFaults(c, 'changes[' + i + ']')); });
333
+ if (held.length) { throw new Error('[record-format] a change row is not strict JSON: ' + held.join('; ') + ' (FAIL-LOUD.)'); }
334
+ const files = {};
335
+ files[RECORD_FILES.record] = jsonText(record);
336
+ clips.forEach(function (c, i) { files[clipName(i + 1)] = c.bytes; });
337
+ files[RECORD_FILES.partsBefore] = jsonText(_partsValue(p.partsBefore, RECORD_FILES.partsBefore));
338
+ files[RECORD_FILES.partsAfter] = jsonText(_partsValue(p.partsAfter, RECORD_FILES.partsAfter));
339
+ Object.keys(p.project).sort().forEach(function (f) { files[RECORD_FILES.project + f] = p.project[f]; });
340
+ const faults = recordFolderFaults(files);
341
+ if (faults.length) {
342
+ throw new Error('[record-format] the Record folder this producer wrote is not a complete §6 Record:\n- ' + faults.join('\n- ') + '\n(FAIL-LOUD.)');
343
+ }
344
+ return { files: files, record: record, clips: clips.map(function (c, i) { return { file: clipName(i + 1), firstTick: c.firstTick, lastTick: c.lastTick }; }) };
345
+ }