@1agh/maude 0.58.3 → 0.60.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 (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,182 @@
1
+ // TIER 2 — the differential. THE SHIP GATE for the `.fig` door (DDR-221 D7).
2
+ //
3
+ // The same document through both doors must normalize to the same tree. This is
4
+ // the only oracle that proves the decoder is RIGHT rather than merely QUIET, and
5
+ // it exists only because the REST door was built first — which is what
6
+ // retroactively justifies the phase ordering.
7
+ //
8
+ // It earned that billing immediately. Every unit test in fig-decode.test.ts was
9
+ // green while the decoder emitted Figma's INTERNAL node vocabulary
10
+ // (FRAME-with-resizeToFit, ROUNDED_RECTANGLE, SYMBOL) instead of the public REST
11
+ // one the translators are written against. Both sides looked perfectly valid in
12
+ // isolation; only the comparison could see it.
13
+ //
14
+ // Offline by construction: the oracle is a COMMITTED capture of
15
+ // `fetchDocument()` for the same two documents, so CI needs no token and no
16
+ // network. The recorded form catches DECODER regressions; only a live re-capture
17
+ // catches FIGMA changing. They are not the same test — see § Re-capturing.
18
+ //
19
+ // ── Re-capturing the oracle ──────────────────────────────────────────────────
20
+ // Needed when Figma changes its REST projection, or when a fixture document is
21
+ // edited. Requires a stored Figma PAT (`getProviderKey('figma')`):
22
+ //
23
+ // cd apps/studio && bun -e '
24
+ // const { fetchDocument } = await import("./figma/client.ts");
25
+ // for (const [key, surface, out] of [
26
+ // ["dGNzRC2kmrmGnOxaBa0RI7", "design", "design.rest-oracle.json"],
27
+ // ["Em6NOwaOFTYV7NlQT4NK8l", "board", "figjam.rest-oracle.json"],
28
+ // ]) await Bun.write("../../.ai/fixtures/figma/2026-08-03/" + out,
29
+ // JSON.stringify(await fetchDocument({ fileKey: key, surface }), null, 1));'
30
+ //
31
+ // Both documents live on the StudyFi plan (moved there 2026-08-12; the file keys
32
+ // survived the move). Re-export the `.fig`/`.jam` in the same pass or the two
33
+ // halves drift apart.
34
+
35
+ import { describe, expect, test } from 'bun:test';
36
+
37
+ import { decodeFigArchive } from './fig-decode.ts';
38
+ import { type FigmaNode, type NormalizedDocument, walkNodes } from './types.ts';
39
+
40
+ const FIXTURES = new URL('../../../.ai/fixtures/figma/2026-08-03/', import.meta.url).pathname;
41
+
42
+ /**
43
+ * The ONE documented lossy delta, asserted AS lossy rather than tolerated.
44
+ *
45
+ * REST expands an INSTANCE's children and mints synthetic ids for them
46
+ * (`I<instance>;<child>`). A `.fig` stores an instance by reference — only its
47
+ * overrides — so those nodes genuinely do not exist in the local file. This is
48
+ * a property of the two formats, not a decoder defect, and it is the reason the
49
+ * gate compares SHARED nodes plus an explicit allowlist rather than raw counts.
50
+ */
51
+ const REST_ONLY_ID = /^I[0-9]+:[0-9]+;/;
52
+
53
+ /** Sub-pixel float noise would be acceptable; we have never needed the slack. */
54
+ const GEOMETRY_EPSILON = 0.5;
55
+
56
+ interface Case {
57
+ label: string;
58
+ fileKey: string;
59
+ archive: string;
60
+ oracle: string;
61
+ }
62
+
63
+ const CASES: Case[] = [
64
+ {
65
+ label: 'design',
66
+ fileKey: 'dGNzRC2kmrmGnOxaBa0RI7',
67
+ archive: 'design.fig',
68
+ oracle: 'design.rest-oracle.json',
69
+ },
70
+ {
71
+ label: 'figjam',
72
+ fileKey: 'Em6NOwaOFTYV7NlQT4NK8l',
73
+ archive: 'figjam.jam',
74
+ oracle: 'figjam.rest-oracle.json',
75
+ },
76
+ ];
77
+
78
+ function index(root: FigmaNode): Map<string, FigmaNode> {
79
+ const byId = new Map<string, FigmaNode>();
80
+ walkNodes(root, (n) => byId.set(n.id, n));
81
+ return byId;
82
+ }
83
+
84
+ async function load(c: Case) {
85
+ const rest = (await Bun.file(FIXTURES + c.oracle).json()) as NormalizedDocument;
86
+ const bytes = new Uint8Array(await Bun.file(FIXTURES + c.archive).arrayBuffer());
87
+ const { document: fig } = decodeFigArchive(bytes, { fileKey: c.fileKey });
88
+ return { rest: index(rest.root), fig: index(fig.root), restDoc: rest, figDoc: fig };
89
+ }
90
+
91
+ describe.each(CASES)('tier 2 — $label through both doors', (c) => {
92
+ test('the .fig door sees every REST node except the documented instance children', async () => {
93
+ const { rest, fig } = await load(c);
94
+ const missing = [...rest.keys()].filter((id) => !fig.has(id));
95
+ // Every absence must be explained by the ONE known delta.
96
+ expect(missing.filter((id) => !REST_ONLY_ID.test(id))).toEqual([]);
97
+ // And nothing may exist locally that REST does not know about.
98
+ expect([...fig.keys()].filter((id) => !rest.has(id))).toEqual([]);
99
+ });
100
+
101
+ test('node TYPE agrees — the public vocabulary, not Figma internals', async () => {
102
+ const { rest, fig } = await load(c);
103
+ const diffs = [...fig.entries()]
104
+ .filter(([id, n]) => rest.get(id) && rest.get(id)?.type !== n.type)
105
+ .map(([id, n]) => `${id}: REST=${rest.get(id)?.type} fig=${n.type}`);
106
+ expect(diffs).toEqual([]);
107
+ });
108
+
109
+ test('node NAME agrees byte for byte, diacritics and hostile characters included', async () => {
110
+ const { rest, fig } = await load(c);
111
+ const diffs = [...fig.entries()]
112
+ .filter(([id, n]) => rest.get(id) && rest.get(id)?.name !== n.name)
113
+ .map(([id]) => id);
114
+ expect(diffs).toEqual([]);
115
+ });
116
+
117
+ test('GEOMETRY agrees — the parent-chain composition against REST absolute boxes', async () => {
118
+ const { rest, fig } = await load(c);
119
+ const diffs: string[] = [];
120
+ let worst = 0;
121
+ for (const [id, n] of fig) {
122
+ const r = rest.get(id);
123
+ if (!r?.absoluteBoundingBox || !n.absoluteBoundingBox) continue;
124
+ const a = r.absoluteBoundingBox;
125
+ const b = n.absoluteBoundingBox;
126
+ const delta = Math.max(
127
+ Math.abs(a.x - b.x),
128
+ Math.abs(a.y - b.y),
129
+ Math.abs(a.width - b.width),
130
+ Math.abs(a.height - b.height)
131
+ );
132
+ worst = Math.max(worst, delta);
133
+ if (delta > GEOMETRY_EPSILON) diffs.push(`${id}: Δ${delta.toFixed(2)}px`);
134
+ }
135
+ expect(diffs).toEqual([]);
136
+ // Recorded rather than merely bounded: the composition is EXACT today, and a
137
+ // drift into "within tolerance" is worth noticing before it becomes drift
138
+ // out of it. This is the assertion the A4 float trap would have failed.
139
+ expect(worst).toBe(0);
140
+ });
141
+
142
+ test('TEXT content agrees', async () => {
143
+ const { rest, fig } = await load(c);
144
+ const diffs = [...fig.entries()]
145
+ .filter(([id, n]) => {
146
+ const r = rest.get(id);
147
+ return r && (r.characters ?? '') !== (n.characters ?? '');
148
+ })
149
+ .map(([id]) => id);
150
+ expect(diffs).toEqual([]);
151
+ });
152
+
153
+ test('the surface the prelude declared matches the surface REST was asked for', async () => {
154
+ const { restDoc, figDoc } = await load(c);
155
+ expect(figDoc.surface).toBe(restDoc.surface);
156
+ expect(figDoc.origin).toBe('fig');
157
+ expect(restDoc.origin).toBe('rest');
158
+ });
159
+ });
160
+
161
+ describe('tier 2 — the lossy delta is asserted, not assumed', () => {
162
+ test('the design file really does carry instance children only REST expands', async () => {
163
+ const { rest, fig } = await load(CASES[0]);
164
+ const restOnly = [...rest.keys()].filter((id) => !fig.has(id));
165
+ // If this ever becomes empty, either the fixture changed or REST stopped
166
+ // expanding instances — both mean the allowlist above needs re-deriving
167
+ // rather than silently covering nothing.
168
+ expect(restOnly.length).toBeGreaterThan(0);
169
+ expect(restOnly.every((id) => REST_ONLY_ID.test(id))).toBe(true);
170
+ });
171
+
172
+ test('connector endpoints resolve to the same host ids through both doors', async () => {
173
+ const { rest, fig } = await load(CASES[1]);
174
+ const pairs = (m: Map<string, FigmaNode>) =>
175
+ [...m.values()]
176
+ .filter((n) => n.type === 'CONNECTOR')
177
+ .map((n) => `${n.id}:${n.connectorStart}->${n.connectorEnd}`)
178
+ .sort();
179
+ expect(pairs(fig)).toEqual(pairs(rest));
180
+ expect(pairs(fig).length).toBe(6);
181
+ });
182
+ });
@@ -0,0 +1,410 @@
1
+ /**
2
+ * @file figma/fig-kiwi.ts — Kiwi schema + data decoder for the `.fig` door.
3
+ * @scope apps/studio/figma/fig-kiwi.ts
4
+ * @purpose Decode the two chunks of a `canvas.fig`: chunk[0] is the Kiwi
5
+ * SCHEMA the file carries for itself, chunk[1] is the DATA encoded
6
+ * against it. Ported from the documented reference implementation
7
+ * (`evanw/kiwi`) rather than depended on (DDR-221 D1).
8
+ *
9
+ * @invariant THE ATTACKER SUPPLIES THE SCHEMA, NOT JUST THE DATA. This is the
10
+ * property that makes a `.fig` unlike every other parser input in
11
+ * the repo, and the source of most controls below (DDR-221 A8):
12
+ * - decoded objects use `Object.create(null)`, because field
13
+ * names are schema-chosen and `o["__proto__"] = v` would
14
+ * otherwise give a node an attacker-supplied prototype and
15
+ * therefore phantom inherited fields (F3);
16
+ * - enum members decode to schema-chosen NAMES, so every value
17
+ * leaving here is UNTRUSTED text and must be bounded before it
18
+ * reaches a report (F1 — enforced at the sinks, not here);
19
+ * - root-type resolution is the CALLER's job and must be strict
20
+ * (F2 — see `findRootDefinition`).
21
+ *
22
+ * @invariant A LENGTH PREFIX IS A CLAIM ABOUT A BUFFER WE ALREADY HOLD. Every
23
+ * array length is bounded by the bytes actually remaining, so a
24
+ * `varuint` of 4 billion cannot allocate (DDR-221 D4).
25
+ *
26
+ * @invariant DEPENDENCY-FREE and side-effect-free. No `node:*`, no IO.
27
+ */
28
+
29
+ import { MAX_TREE_DEPTH } from './types.ts';
30
+
31
+ // ── Caps (DDR-221 D4, headroom measured in A1) ──────────────────────────────
32
+
33
+ /** Observed in the real schema: 627. */
34
+ export const MAX_DEFINITIONS = 8192;
35
+ /** Observed: 602 (on `NodeChange`). */
36
+ export const MAX_FIELDS_PER_DEF = 4096;
37
+ /** Observed: 38. */
38
+ export const MAX_IDENTIFIER_LEN = 256;
39
+ /** Bounds nested small arrays, which per-array caps alone do not. */
40
+ export const MAX_DECODED_VALUES = 5_000_000;
41
+ /** Absolute ceiling, on top of the remaining-bytes bound. */
42
+ export const MAX_ARRAY_LEN = 1_000_000;
43
+
44
+ export const KIND_ENUM = 0;
45
+ export const KIND_STRUCT = 1;
46
+ export const KIND_MESSAGE = 2;
47
+
48
+ /** Kiwi's builtin type ids are negative; non-negative indexes a definition. */
49
+ const TYPE_BOOL = -1;
50
+ const TYPE_BYTE = -2;
51
+ const TYPE_INT = -3;
52
+ const TYPE_UINT = -4;
53
+ const TYPE_FLOAT = -5;
54
+ const TYPE_STRING = -6;
55
+ const TYPE_INT64 = -7;
56
+ const TYPE_UINT64 = -8;
57
+
58
+ export class FigKiwiError extends Error {
59
+ constructor(message: string) {
60
+ super(message);
61
+ this.name = 'FigKiwiError';
62
+ }
63
+ }
64
+
65
+ export interface KiwiField {
66
+ name: string;
67
+ type: number;
68
+ isArray: boolean;
69
+ /** Enum member value, or MESSAGE field id. Always 0 for a STRUCT field. */
70
+ value: number;
71
+ }
72
+
73
+ export interface KiwiDefinition {
74
+ name: string;
75
+ kind: number;
76
+ fields: KiwiField[];
77
+ /** MESSAGE field lookup by id, built once instead of scanning 602 fields per read. */
78
+ byId?: Map<number, KiwiField>;
79
+ /** ENUM member lookup by value. */
80
+ byValue?: Map<number, string>;
81
+ }
82
+
83
+ export type KiwiSchema = KiwiDefinition[];
84
+
85
+ /** Bounds-checked cursor over the decoded chunk. */
86
+ class Reader {
87
+ offset = 0;
88
+ constructor(readonly bytes: Uint8Array) {}
89
+
90
+ get remaining(): number {
91
+ return this.bytes.length - this.offset;
92
+ }
93
+
94
+ byte(): number {
95
+ if (this.offset >= this.bytes.length) throw new FigKiwiError('unexpected end of Kiwi data');
96
+ return this.bytes[this.offset++];
97
+ }
98
+
99
+ /** LEB128. Refused past 5 bytes rather than shifted past the 32-bit width. */
100
+ varuint(): number {
101
+ let value = 0;
102
+ let shift = 0;
103
+ for (;;) {
104
+ const b = this.byte();
105
+ value |= (b & 0x7f) << shift;
106
+ shift += 7;
107
+ if ((b & 0x80) === 0) break;
108
+ if (shift > 28) throw new FigKiwiError('malformed varuint: more than 5 bytes');
109
+ }
110
+ return value >>> 0;
111
+ }
112
+
113
+ varint(): number {
114
+ const v = this.varuint();
115
+ return v & 1 ? ~(v >>> 1) : v >>> 1;
116
+ }
117
+
118
+ varuint64(): bigint {
119
+ let value = 0n;
120
+ let shift = 0n;
121
+ for (;;) {
122
+ const b = this.byte();
123
+ value |= BigInt(b & 0x7f) << shift;
124
+ shift += 7n;
125
+ if ((b & 0x80) === 0) break;
126
+ if (shift > 63n) throw new FigKiwiError('malformed varuint64: more than 10 bytes');
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /**
132
+ * Kiwi rotates a float's exponent into the low 8 bits so that zero and
133
+ * denormals encode as a single 0 byte. Getting the FRAMING right and this
134
+ * rotation wrong yields a stream that stays perfectly in sync while every
135
+ * coordinate decodes as 0 — see DDR-221 A4, which is why a fixture geometry
136
+ * assertion is a required test and not a nicety.
137
+ */
138
+ float(): number {
139
+ if (this.remaining >= 1 && this.bytes[this.offset] === 0) {
140
+ this.offset++;
141
+ return 0;
142
+ }
143
+ if (this.remaining < 4) throw new FigKiwiError('unexpected end of Kiwi data reading a float');
144
+ const b = this.bytes;
145
+ const o = this.offset;
146
+ let bits = (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
147
+ this.offset += 4;
148
+ bits = ((bits << 23) | (bits >>> 9)) >>> 0;
149
+ FLOAT_VIEW.setUint32(0, bits, true);
150
+ return FLOAT_VIEW.getFloat32(0, true);
151
+ }
152
+
153
+ /** NUL-terminated UTF-8. An unterminated run refuses rather than overreading. */
154
+ string(maxLen = MAX_IDENTIFIER_LEN * 64): string {
155
+ const start = this.offset;
156
+ const limit = Math.min(this.bytes.length, start + maxLen);
157
+ let end = start;
158
+ while (end < limit && this.bytes[end] !== 0) end++;
159
+ if (end >= limit) {
160
+ throw new FigKiwiError(
161
+ end === this.bytes.length ? 'unterminated Kiwi string' : 'Kiwi string exceeds its limit'
162
+ );
163
+ }
164
+ this.offset = end + 1;
165
+ return TEXT_DECODER.decode(this.bytes.subarray(start, end));
166
+ }
167
+ }
168
+
169
+ const FLOAT_VIEW = new DataView(new ArrayBuffer(4));
170
+ const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
171
+
172
+ function checkIdentifier(name: string, what: string): void {
173
+ if (name.length === 0) throw new FigKiwiError(`Kiwi ${what} has an empty name`);
174
+ if (name.length > MAX_IDENTIFIER_LEN) {
175
+ throw new FigKiwiError(
176
+ `Kiwi ${what} name is ${name.length} characters, over the ${MAX_IDENTIFIER_LEN} limit`
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Parse the schema chunk. Note that cyclic and self-referencing definitions are
183
+ * LEGITIMATE — `Message`, `NodeChange` and `MessageType` all self-reference in
184
+ * the real schema, so refusing cycles would refuse every real file (DDR-221
185
+ * A2). Recursion is bounded at decode time by depth instead.
186
+ */
187
+ export function parseKiwiSchema(bytes: Uint8Array): KiwiSchema {
188
+ const r = new Reader(bytes);
189
+ const count = r.varuint();
190
+ if (count > MAX_DEFINITIONS) {
191
+ throw new FigKiwiError(
192
+ `Kiwi schema declares ${count} definitions, over the ${MAX_DEFINITIONS} limit`
193
+ );
194
+ }
195
+
196
+ const defs: KiwiDefinition[] = [];
197
+ for (let i = 0; i < count; i++) {
198
+ const name = r.string();
199
+ checkIdentifier(name, 'definition');
200
+ const kind = r.byte();
201
+ if (kind !== KIND_ENUM && kind !== KIND_STRUCT && kind !== KIND_MESSAGE) {
202
+ throw new FigKiwiError(`Kiwi definition "${name}" has unknown kind ${kind}`);
203
+ }
204
+ const fieldCount = r.varuint();
205
+ if (fieldCount > MAX_FIELDS_PER_DEF) {
206
+ throw new FigKiwiError(
207
+ `Kiwi definition "${name}" declares ${fieldCount} fields, over the ${MAX_FIELDS_PER_DEF} limit`
208
+ );
209
+ }
210
+ const fields: KiwiField[] = [];
211
+ for (let f = 0; f < fieldCount; f++) {
212
+ const fieldName = r.string();
213
+ checkIdentifier(fieldName, 'field');
214
+ fields.push({
215
+ name: fieldName,
216
+ type: r.varint(),
217
+ isArray: r.byte() !== 0,
218
+ value: r.varuint(),
219
+ });
220
+ }
221
+ defs.push({ name, kind, fields });
222
+ }
223
+
224
+ if (r.remaining !== 0) {
225
+ throw new FigKiwiError(`Kiwi schema has ${r.remaining} trailing bytes`);
226
+ }
227
+
228
+ // Type indexes must resolve. A dangling index would otherwise surface as a
229
+ // confusing decode error deep inside the data chunk.
230
+ for (const def of defs) {
231
+ for (const field of def.fields) {
232
+ if (field.type >= 0 && field.type >= defs.length) {
233
+ throw new FigKiwiError(
234
+ `Kiwi field "${def.name}.${field.name}" references type index ${field.type}, out of range`
235
+ );
236
+ }
237
+ if (field.type < TYPE_UINT64) {
238
+ throw new FigKiwiError(
239
+ `Kiwi field "${def.name}.${field.name}" has unknown builtin type ${field.type}`
240
+ );
241
+ }
242
+ }
243
+ if (def.kind === KIND_MESSAGE) {
244
+ def.byId = new Map(def.fields.map((f) => [f.value, f]));
245
+ } else if (def.kind === KIND_ENUM) {
246
+ def.byValue = new Map(def.fields.map((f) => [f.value, f.name]));
247
+ }
248
+ }
249
+
250
+ return defs;
251
+ }
252
+
253
+ /**
254
+ * Resolve the document root STRICTLY (DDR-221 A8/F2).
255
+ *
256
+ * Locating the root by name alone is attacker-steerable: a hostile schema can
257
+ * omit `Message`, define two, or make it a STRUCT. Decoding the data against
258
+ * the wrong root does not necessarily fail — Kiwi MESSAGE framing is
259
+ * self-terminating — so it can yield a structurally valid, semantically WRONG
260
+ * tree, which is precisely the silent wrongness D3 exists to prevent.
261
+ */
262
+ export function findRootDefinition(
263
+ schema: KiwiSchema,
264
+ rootName: string,
265
+ arrayField: string,
266
+ elementName: string
267
+ ): number {
268
+ const matches: number[] = [];
269
+ for (let i = 0; i < schema.length; i++) {
270
+ if (schema[i].name === rootName) matches.push(i);
271
+ }
272
+ if (matches.length === 0) throw new FigKiwiError(`Kiwi schema has no "${rootName}" definition`);
273
+ if (matches.length > 1) {
274
+ throw new FigKiwiError(`Kiwi schema defines "${rootName}" ${matches.length} times`);
275
+ }
276
+
277
+ const index = matches[0];
278
+ const def = schema[index];
279
+ if (def.kind !== KIND_MESSAGE) {
280
+ throw new FigKiwiError(`Kiwi "${rootName}" is not a message`);
281
+ }
282
+ const field = def.fields.find((f) => f.name === arrayField);
283
+ if (!field?.isArray || field.type < 0) {
284
+ throw new FigKiwiError(`Kiwi "${rootName}" has no "${arrayField}" array`);
285
+ }
286
+ if (schema[field.type]?.name !== elementName) {
287
+ throw new FigKiwiError(`Kiwi "${rootName}.${arrayField}" is not an array of "${elementName}"`);
288
+ }
289
+ return index;
290
+ }
291
+
292
+ interface DecodeState {
293
+ values: number;
294
+ }
295
+
296
+ /**
297
+ * Decode the data chunk against the schema. Returns plain data: prototype-less
298
+ * objects, arrays, numbers, bigints, booleans and strings.
299
+ */
300
+ export function decodeKiwi(bytes: Uint8Array, schema: KiwiSchema, rootIndex: number): unknown {
301
+ const r = new Reader(bytes);
302
+ const state: DecodeState = { values: 0 };
303
+ const value = readValue(r, schema, rootIndex, 0, state);
304
+ if (r.remaining !== 0) {
305
+ throw new FigKiwiError(`Kiwi data has ${r.remaining} trailing bytes`);
306
+ }
307
+ return value;
308
+ }
309
+
310
+ function readValue(
311
+ r: Reader,
312
+ schema: KiwiSchema,
313
+ type: number,
314
+ depth: number,
315
+ state: DecodeState
316
+ ): unknown {
317
+ if (++state.values > MAX_DECODED_VALUES) {
318
+ throw new FigKiwiError(`Kiwi data exceeds the ${MAX_DECODED_VALUES}-value budget`);
319
+ }
320
+ if (depth > MAX_TREE_DEPTH) {
321
+ throw new FigKiwiError(`Kiwi data nests deeper than ${MAX_TREE_DEPTH} levels`);
322
+ }
323
+
324
+ if (type < 0) {
325
+ switch (type) {
326
+ case TYPE_BOOL:
327
+ return r.byte() !== 0;
328
+ case TYPE_BYTE:
329
+ return r.byte();
330
+ case TYPE_INT:
331
+ return r.varint();
332
+ case TYPE_UINT:
333
+ return r.varuint();
334
+ case TYPE_FLOAT:
335
+ return r.float();
336
+ case TYPE_STRING:
337
+ return r.string();
338
+ case TYPE_INT64:
339
+ case TYPE_UINT64:
340
+ return r.varuint64();
341
+ default:
342
+ throw new FigKiwiError(`unknown Kiwi builtin type ${type}`);
343
+ }
344
+ }
345
+
346
+ const def = schema[type];
347
+ if (!def) throw new FigKiwiError(`Kiwi type index ${type} is out of range`);
348
+
349
+ if (def.kind === KIND_ENUM) {
350
+ const raw = r.varuint();
351
+ // UNTRUSTED: the member NAME is schema-chosen (DDR-221 A8/F1). Unknown
352
+ // values keep a code-owned shape so nothing attacker-written can pose as a
353
+ // recognised member.
354
+ return def.byValue?.get(raw) ?? `UNKNOWN_${raw}`;
355
+ }
356
+
357
+ // `Object.create(null)`: field names are schema-chosen, and on a plain object
358
+ // `o["__proto__"] = v` would set this node's prototype to attacker data,
359
+ // giving it phantom inherited fields (DDR-221 A8/F3).
360
+ const out = Object.create(null) as Record<string, unknown>;
361
+
362
+ if (def.kind === KIND_STRUCT) {
363
+ for (const field of def.fields) {
364
+ out[field.name] = readField(r, schema, field, depth + 1, state);
365
+ }
366
+ return out;
367
+ }
368
+
369
+ for (;;) {
370
+ const id = r.varuint();
371
+ if (id === 0) break;
372
+ const field = def.byId?.get(id);
373
+ if (!field) {
374
+ throw new FigKiwiError(`Kiwi message "${def.name}" has no field with id ${id}`);
375
+ }
376
+ out[field.name] = readField(r, schema, field, depth + 1, state);
377
+ }
378
+ return out;
379
+ }
380
+
381
+ function readField(
382
+ r: Reader,
383
+ schema: KiwiSchema,
384
+ field: KiwiField,
385
+ depth: number,
386
+ state: DecodeState
387
+ ): unknown {
388
+ if (!field.isArray) return readValue(r, schema, field.type, depth, state);
389
+
390
+ const length = r.varuint();
391
+ if (length > MAX_ARRAY_LEN) {
392
+ throw new FigKiwiError(
393
+ `Kiwi array "${field.name}" declares ${length} elements, over the ${MAX_ARRAY_LEN} limit`
394
+ );
395
+ }
396
+ // A length prefix is a claim about a buffer we already hold. Even a
397
+ // zero-byte element type cannot produce more elements than bytes remain,
398
+ // because every element consumes at least the byte that terminates it.
399
+ if (length > r.remaining) {
400
+ throw new FigKiwiError(
401
+ `Kiwi array "${field.name}" declares ${length} elements with only ${r.remaining} bytes left`
402
+ );
403
+ }
404
+
405
+ const items = new Array(length);
406
+ for (let i = 0; i < length; i++) {
407
+ items[i] = readValue(r, schema, field.type, depth, state);
408
+ }
409
+ return items;
410
+ }