@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,509 @@
1
+ /**
2
+ * @file figma/codegen-client.ts — the LOCAL Dev Mode MCP client (DDR-219 D2).
3
+ * @scope apps/studio/figma/codegen-client.ts
4
+ * @purpose Ask Figma's own generator for one frame's resolved DOM, over
5
+ * loopback, with `apps/studio` as the JSON-RPC client.
6
+ *
7
+ * @invariant THIS IS THE ONLY MODULE IN THE REPO ALLOWED TO NAME `:3845`.
8
+ * `cli/lib/figma-codegen-reachability.test.mjs` asserts it, and
9
+ * asserts that Figma's REMOTE MCP host appears in no runtime code
10
+ * path at all — not even here, which is why this comment does not
11
+ * spell it: the guard is a coarse grep and it is only trustworthy if
12
+ * it is absolute. Both halves are controls, not intentions: the
13
+ * remote server
14
+ * is spoken by an AGENT, so its response would transit a model's
15
+ * context by construction — which closes the DDR-130 trifecta inside
16
+ * a single turn (DDR-219 § Security review, chain 1). The local
17
+ * server is spoken by US, so no model is in the path.
18
+ *
19
+ * @invariant NO MODEL READS WHAT THIS RETURNS. The dev-server fetches, parses,
20
+ * converts and writes. The agent that invoked the verb sees only the
21
+ * verb's code-owned stdout (DDR-216 D10). That is the whole reason
22
+ * the channel decision mattered — see the prose note below.
23
+ *
24
+ * @invariant THE HANDSHAKE IS ASSERTED BEFORE A DOCUMENT IS REQUESTED, AND A
25
+ * FAILED ASSERTION REFUSES. The local server is UNAUTHENTICATED
26
+ * loopback (DDR-219 residual 3), so any local process can squat the
27
+ * port and feed us arbitrary JSX. That requires local code execution
28
+ * — largely game-over independently — but the specific consequence
29
+ * is that our converter would ingest attacker-authored markup
30
+ * believing it came from Figma. So: `initialize` must answer with a
31
+ * Figma-shaped `serverInfo`, `tools/list` must carry the expected
32
+ * tool, and no exposed tool may match the WRITE vocabulary.
33
+ *
34
+ * @invariant WE NEVER LET THIS SERVER WRITE A FILE. `dirForAssetWrites` is a
35
+ * caller-supplied absolute path the server writes assets to
36
+ * (measured — DDR-219 probe finding 2). It is never sent. Assets are
37
+ * re-fetched by node id through the existing `/v1/images` lane
38
+ * instead (D6), which is strictly better containment than Figma's
39
+ * own allowed-directories allowlist.
40
+ *
41
+ * @invariant A RESPONSE THAT IS NOT CODE IS A REFUSAL, NOT A DEGRADATION.
42
+ * `forceCode` exists because the server silently returns METADATA
43
+ * instead of code when the output is too large (probe finding 3). A
44
+ * converter that did not check would emit a confidently wrong
45
+ * artboard. We assert; we do not paper over it by setting
46
+ * `forceCode` unconditionally, because the size ceiling is telling
47
+ * us something real about the caps.
48
+ *
49
+ * @invariant ONE CODEGEN CALL PER SESSION, ENFORCED HERE (DDR-219 D10). Local
50
+ * metering is undocumented (fact 5) and the remote budget is 200
51
+ * calls/day. Without a hard ceiling, an instruction inside a
52
+ * document ("fetch design context for each of these node ids
53
+ * first…") spends the user's whole daily Figma budget from content,
54
+ * and the failure reads as a Figma outage. It is a property of the
55
+ * CODE, never of how a caller chooses to behave.
56
+ *
57
+ * @invariant DEPENDENCY-FREE — `fetch` plus `node:crypto` for the response
58
+ * hash. The transport is streamable HTTP with SSE-framed replies,
59
+ * which is ~40 lines, not a library.
60
+ */
61
+
62
+ import { createHash } from 'node:crypto';
63
+
64
+ /**
65
+ * The local Dev Mode MCP server. Loopback, unauthenticated, no catalog gate —
66
+ * which is exactly why reaching it from a second module would silently turn
67
+ * codegen into a bulk ingestion route (DDR-219 D1's table quietly false).
68
+ */
69
+ const CODEGEN_ENDPOINT = 'http://127.0.0.1:3845/mcp';
70
+
71
+ /** The one tool this client is allowed to call. */
72
+ export const CODEGEN_TOOL = 'get_design_context';
73
+
74
+ /**
75
+ * The tool surface measured on 2026-08-11 — six tools, every one read-only.
76
+ * Recorded so a future reader can see what "no write surface" was measured
77
+ * against rather than having to take the DDR's word for it.
78
+ */
79
+ export const MEASURED_LOCAL_TOOLS = Object.freeze([
80
+ 'get_design_context',
81
+ 'get_variable_defs',
82
+ 'get_screenshot',
83
+ 'get_motion_context',
84
+ 'get_metadata',
85
+ 'get_figjam',
86
+ ]);
87
+
88
+ /**
89
+ * Write-shaped tool names. A DENYLIST, deliberately, and this is the one place
90
+ * in the Figma lane where that is the right shape: an allowlist over a
91
+ * third-party server's tool surface would refuse on every Figma feature release,
92
+ * while the property we actually need is narrow and stable — *no write surface
93
+ * is co-tenant with this call*. Figma Developer Terms §4.f pushes toward
94
+ * write-back tools existing somewhere; this asserts they are not on this wire.
95
+ */
96
+ const WRITE_TOOL_RE = /^(use_figma|create_|upload_|add_|send_|set_|update_|delete_|write_)/i;
97
+
98
+ /** Handshake: the server must at least claim to be Figma's. Weak on its own —
99
+ * a squatter can say anything — which is why it is one assertion of several
100
+ * and why the parser contract (DDR-219 D5) is what actually bounds the damage. */
101
+ const SERVER_NAME_RE = /^figma\b/i;
102
+
103
+ /** Protocol version we speak. Sent as-is; the server answers with its own. */
104
+ const PROTOCOL_VERSION = '2025-06-18';
105
+
106
+ /**
107
+ * INPUT byte cap (DDR-219 D5 rule 2). Distinct from the 512 KB per-artboard
108
+ * OUTPUT cap and from `client.ts`'s 8 MB REST cap, which this response never
109
+ * traverses. Measured: a real 375×812 screen is 33 KB.
110
+ */
111
+ export const MAX_CODEGEN_RESPONSE_BYTES = 1024 * 1024;
112
+
113
+ /** A codegen call is slow-ish (measured ~344 ms) but never minutes. */
114
+ const CALL_TIMEOUT_MS = 60_000;
115
+ /** The handshake is local and instant; a hang here means nothing is listening. */
116
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
117
+
118
+ /** D10 — one `get_design_context` per user invocation, full stop. */
119
+ export const MAX_CODEGEN_CALLS_PER_INVOCATION = 1;
120
+
121
+ export type CodegenErrorKind =
122
+ /** Nothing listening, or the transport failed. The COMMON case (residual 2). */
123
+ | 'unavailable'
124
+ /** `initialize` did not answer like Figma's server. */
125
+ | 'handshake'
126
+ /** `get_design_context` absent, or a write-shaped tool is co-tenant. */
127
+ | 'tool_surface'
128
+ /** The node is not in the open document, or Figma refused it. */
129
+ | 'node_unavailable'
130
+ /** The server returned metadata instead of code (probe finding 3). */
131
+ | 'not_code'
132
+ /** Over the input byte cap. */
133
+ | 'too_large'
134
+ /** Malformed JSON-RPC / SSE. */
135
+ | 'bad_response'
136
+ /** The per-invocation ceiling already spent. */
137
+ | 'ceiling';
138
+
139
+ /**
140
+ * Fixed, code-owned messages. NOTHING upstream is ever interpolated — this
141
+ * string reaches verb stdout, which D10 declares entirely code-owned, and the
142
+ * one place an error message would otherwise become a prompt-injection channel.
143
+ */
144
+ const MESSAGE_BY_KIND: Readonly<Record<CodegenErrorKind, string>> = {
145
+ unavailable:
146
+ 'Figma Dev Mode MCP server is not reachable — open the Figma desktop app, switch to Dev Mode, and enable the MCP server (needs a Dev or Full seat).',
147
+ handshake: 'Something is listening on the Dev Mode port but it is not Figma — refusing.',
148
+ tool_surface: 'The Dev Mode server did not expose the expected read-only codegen tool.',
149
+ node_unavailable:
150
+ 'Figma has no such node in the OPEN document — make the right file the active tab.',
151
+ not_code: 'Figma returned metadata instead of code for this frame — it is too large to explode.',
152
+ too_large: 'The codegen response exceeded this lane’s input cap.',
153
+ bad_response: 'The Dev Mode server returned a reply this client could not read.',
154
+ ceiling: 'One codegen call per invocation (DDR-219 D10) — already spent.',
155
+ };
156
+
157
+ export class CodegenError extends Error {
158
+ readonly kind: CodegenErrorKind;
159
+ constructor(kind: CodegenErrorKind) {
160
+ super(MESSAGE_BY_KIND[kind]);
161
+ this.name = 'CodegenError';
162
+ this.kind = kind;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The response's code half, plus what provenance needs (DDR-219 D7).
168
+ *
169
+ * `responseSha256` does NOT make the artboard reproducible — nothing can, there
170
+ * is no second door (fact 1). It makes *"did these two artboards come from the
171
+ * same generator state"* answerable, which is the minimum an incident needs.
172
+ */
173
+ export interface CodegenResponse {
174
+ /** The module source, truncated at the code/prose boundary. */
175
+ code: string;
176
+ /** `sha256` of the FULL response text, prose included — the generator state. */
177
+ responseSha256: string;
178
+ /** Always `'local'`. Recorded so a stored artifact names its own channel. */
179
+ endpoint: 'local';
180
+ tool: string;
181
+ /** Bytes of imperative prose that were discarded. Reported, never carried. */
182
+ proseBytes: number;
183
+ }
184
+
185
+ /**
186
+ * The imperative tail Figma appends to every response — verbatim
187
+ * *"SUPER CRITICAL: The generated React+Tailwind code MUST be converted…"*,
188
+ * *"IMPORTANT: After you call this tool, you MUST call get_screenshot…"*.
189
+ * 1 648 B on the measured frame, issued by **Figma itself**, not an attacker.
190
+ *
191
+ * On this channel it is inert bytes a parser discards. Carrying it into an
192
+ * artifact would write Figma's instructions into a canvas that agents later
193
+ * read — so the boundary is cut HERE, before the converter ever sees it, rather
194
+ * than being left as a property the converter is trusted to preserve.
195
+ *
196
+ * Anchored to line starts and bounded: no `s` flag, no unbounded capture
197
+ * (DDR-172 Decision 4 discipline, which D5 rule 6 carries into this lane).
198
+ */
199
+ const PROSE_MARKER_RE =
200
+ /^[ \t>*-]{0,8}(?:\d{1,2}[.)]\s*)?(?:SUPER CRITICAL|IMPORTANT|CRITICAL|NOTE|DO NOT|Analyze the target|After you call this tool)\b/im;
201
+
202
+ /**
203
+ * Cut the response at the code/prose boundary.
204
+ *
205
+ * The rule is positional rather than semantic: the prose is a TRAILING block, so
206
+ * the first line that reads as an imperative directive ends the code. A response
207
+ * with no such line is all code, which is also the measured shape when the frame
208
+ * is small enough that Figma skips its advice.
209
+ */
210
+ export function splitCodeAndProse(raw: string): { code: string; prose: string } {
211
+ const m = PROSE_MARKER_RE.exec(raw);
212
+ let cut = m ? m.index : -1;
213
+ if (cut < 0) {
214
+ // Secondary rule, so the boundary does not depend on Figma's current
215
+ // wording: a generated module ends with a `}` in column 0, and anything
216
+ // after the LAST such line is not part of it. Applied only when there is
217
+ // trailing content — otherwise the module already ends cleanly.
218
+ const lastBrace = raw.lastIndexOf('\n}');
219
+ if (lastBrace >= 0 && raw.slice(lastBrace + 2).trim().length > 0) cut = lastBrace + 2;
220
+ }
221
+ if (cut < 0) return { code: raw, prose: '' };
222
+ return { code: raw.slice(0, cut), prose: raw.slice(cut) };
223
+ }
224
+
225
+ /**
226
+ * Strip a fenced code block if the tool wrapped one. Figma returns bare source
227
+ * on the local channel, but a fence is cheap to tolerate and expensive to
228
+ * discover in production — and an unstripped ``` is a parse error, i.e. a
229
+ * refused frame, for a reason that has nothing to do with the frame.
230
+ */
231
+ function unfence(text: string): string {
232
+ const fence = /^\s*```(?:[a-z]{0,16})\n([\s\S]*?)\n```\s*$/;
233
+ const m = fence.exec(text);
234
+ return m ? m[1] : text;
235
+ }
236
+
237
+ // ── Transport ───────────────────────────────────────────────────────────────
238
+
239
+ interface JsonRpcReply {
240
+ result?: unknown;
241
+ error?: unknown;
242
+ }
243
+
244
+ /**
245
+ * The streamable-HTTP transport answers `event: message\ndata: {json}` even for
246
+ * a single reply, so pull the LAST `data:` line rather than assuming raw JSON.
247
+ * Measured on the live server 2026-08-11.
248
+ */
249
+ export function parseRpcBody(text: string): JsonRpcReply {
250
+ if (!/^\s*(?:event|data):/m.test(text)) return JSON.parse(text) as JsonRpcReply;
251
+ const lines = text.split('\n').filter((l) => l.startsWith('data:'));
252
+ if (lines.length === 0) throw new CodegenError('bad_response');
253
+ return JSON.parse(lines[lines.length - 1].slice(5).trim()) as JsonRpcReply;
254
+ }
255
+
256
+ /** Injected so the client is testable with no server and no network. */
257
+ export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
258
+
259
+ export interface CodegenClientOptions {
260
+ fetchImpl?: FetchLike;
261
+ /** Overrides the per-invocation ceiling. Tests only — never a user flag. */
262
+ maxCalls?: number;
263
+ }
264
+
265
+ /**
266
+ * One invocation's worth of conversation with the local server.
267
+ *
268
+ * Deliberately a class with a spent-call counter rather than a free function:
269
+ * D10's ceiling is only real if it lives somewhere a second call has to get
270
+ * past. A module-level counter would leak across invocations in the long-lived
271
+ * dev-server process; a per-session one is scoped to exactly the unit the
272
+ * ceiling is defined over.
273
+ */
274
+ export class CodegenSession {
275
+ private readonly fetchImpl: FetchLike;
276
+ private readonly maxCalls: number;
277
+ private sessionId: string | null = null;
278
+ private rpcId = 0;
279
+ private ready = false;
280
+ /** D10's ceiling, spent. */
281
+ private calls = 0;
282
+
283
+ constructor(opts: CodegenClientOptions = {}) {
284
+ this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
285
+ this.maxCalls = opts.maxCalls ?? MAX_CODEGEN_CALLS_PER_INVOCATION;
286
+ }
287
+
288
+ private async rpc(
289
+ method: string,
290
+ params: unknown,
291
+ { notify = false, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}
292
+ ): Promise<JsonRpcReply | null> {
293
+ const headers: Record<string, string> = {
294
+ 'content-type': 'application/json',
295
+ accept: 'application/json, text/event-stream',
296
+ };
297
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
298
+ const body = notify
299
+ ? { jsonrpc: '2.0', method, params }
300
+ : { jsonrpc: '2.0', id: ++this.rpcId, method, params };
301
+
302
+ let res: Response;
303
+ try {
304
+ res = await this.fetchImpl(CODEGEN_ENDPOINT, {
305
+ method: 'POST',
306
+ headers,
307
+ body: JSON.stringify(body),
308
+ // Loopback never legitimately redirects, and following one would be the
309
+ // one way this call could leave the machine.
310
+ redirect: 'error',
311
+ signal: AbortSignal.timeout(timeoutMs),
312
+ });
313
+ } catch {
314
+ // Swallow the cause deliberately — a fetch error can carry the URL and,
315
+ // on some runtimes, request detail (D10: output is code-owned).
316
+ throw new CodegenError('unavailable');
317
+ }
318
+
319
+ const sid = res.headers.get('mcp-session-id');
320
+ if (sid) this.sessionId = sid;
321
+ if (notify) {
322
+ // A notification has no reply worth reading; drain so the socket closes.
323
+ await res.text().catch(() => '');
324
+ return null;
325
+ }
326
+ if (!res.ok) throw new CodegenError('unavailable');
327
+
328
+ const text = await readCapped(res);
329
+ try {
330
+ return parseRpcBody(text);
331
+ } catch (err) {
332
+ if (err instanceof CodegenError) throw err;
333
+ throw new CodegenError('bad_response');
334
+ }
335
+ }
336
+
337
+ /**
338
+ * `initialize` → assert → `tools/list` → assert. Runs once per session and
339
+ * REFUSES rather than falling through, because everything after it trusts the
340
+ * peer to be Figma's server (DDR-219 D2's named new threat).
341
+ */
342
+ async handshake(): Promise<void> {
343
+ if (this.ready) return;
344
+
345
+ const init = await this.rpc('initialize', {
346
+ protocolVersion: PROTOCOL_VERSION,
347
+ capabilities: {},
348
+ clientInfo: { name: 'maude', version: '1' },
349
+ });
350
+ const initResult = asRecord(init?.result);
351
+ const serverInfo = asRecord(initResult?.serverInfo);
352
+ const serverName = typeof serverInfo?.name === 'string' ? serverInfo.name : '';
353
+ if (!initResult || !SERVER_NAME_RE.test(serverName)) throw new CodegenError('handshake');
354
+
355
+ // The transport requires this before any other call.
356
+ await this.rpc('notifications/initialized', {}, { notify: true }).catch(() => null);
357
+
358
+ const list = await this.rpc('tools/list', {});
359
+ const listResult = asRecord(list?.result);
360
+ const tools = Array.isArray(listResult?.tools) ? listResult.tools : [];
361
+ const names: string[] = [];
362
+ for (const t of tools) {
363
+ const name = asRecord(t)?.name;
364
+ if (typeof name === 'string') names.push(name);
365
+ }
366
+ if (!names.includes(CODEGEN_TOOL)) throw new CodegenError('tool_surface');
367
+ // §4.f is answered by the CHANNEL, not by argument (DDR-219 D2): our code
368
+ // calls read tools only, and the write surface is measured absent from this
369
+ // endpoint. If it ever appears, that measurement has expired — refuse.
370
+ if (names.some((n) => WRITE_TOOL_RE.test(n))) throw new CodegenError('tool_surface');
371
+
372
+ this.ready = true;
373
+ }
374
+
375
+ /**
376
+ * The one call. Returns the CODE half of the response plus its provenance.
377
+ *
378
+ * `nodeId` is charset-checked by the caller (`^\d+[:-]\d+$` is the server's own
379
+ * pattern) and is the ONLY addressing parameter the tool takes — it reads the
380
+ * currently open document, with no file key anywhere (probe finding 1). The
381
+ * open-document coupling that follows from that is the caller's problem to
382
+ * close, not this module's: see `--explode`'s frame cross-check.
383
+ */
384
+ async fetchDesignContext(nodeId: string): Promise<CodegenResponse> {
385
+ if (this.calls >= this.maxCalls) throw new CodegenError('ceiling');
386
+ this.calls += 1;
387
+
388
+ await this.handshake();
389
+
390
+ const reply = await this.rpc(
391
+ 'tools/call',
392
+ {
393
+ name: CODEGEN_TOOL,
394
+ arguments: {
395
+ nodeId,
396
+ clientLanguages: 'typescript',
397
+ clientFrameworks: 'react',
398
+ // `dirForAssetWrites` is DELIBERATELY ABSENT — see the file header.
399
+ // Every asset is re-fetched by node id through the REST lane (D6), so
400
+ // this server never writes a byte on this machine.
401
+ },
402
+ },
403
+ { timeoutMs: CALL_TIMEOUT_MS }
404
+ );
405
+
406
+ if (reply?.error) throw new CodegenError('node_unavailable');
407
+ const result = asRecord(reply?.result);
408
+ if (!result) throw new CodegenError('bad_response');
409
+ if (result.isError === true) throw new CodegenError('node_unavailable');
410
+
411
+ const raw = collectText(result.content);
412
+ if (raw === null) throw new CodegenError('bad_response');
413
+
414
+ // Hash the FULL response — prose included. The provenance question is "same
415
+ // generator state?", and the advice block is part of that state.
416
+ const responseSha256 = createHash('sha256').update(raw, 'utf8').digest('hex');
417
+
418
+ // The tool answers a missing node with a human sentence rather than an
419
+ // error object; that sentence is not code and must not become an artboard.
420
+ if (/No node could be found for the provided nodeId/i.test(raw)) {
421
+ throw new CodegenError('node_unavailable');
422
+ }
423
+
424
+ const { code, prose } = splitCodeAndProse(raw);
425
+ const source = unfence(code).trim();
426
+ if (!looksLikeCode(source)) throw new CodegenError('not_code');
427
+
428
+ return {
429
+ code: source,
430
+ responseSha256,
431
+ endpoint: 'local',
432
+ tool: CODEGEN_TOOL,
433
+ proseBytes: prose.length,
434
+ };
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Probe finding 3, as a check rather than as a `forceCode: true` that would hide
440
+ * it. A code response always declares a component; a metadata response is XML-ish
441
+ * or prose and declares nothing.
442
+ */
443
+ export function looksLikeCode(source: string): boolean {
444
+ if (source.length === 0) return false;
445
+ return /^\s*(?:export\s+default\s+)?function\s+[A-Za-z_$]/m.test(source);
446
+ }
447
+
448
+ /** MCP tool results are `content: [{ type: 'text', text }]`. Join the text parts. */
449
+ function collectText(content: unknown): string | null {
450
+ if (typeof content === 'string') return content;
451
+ if (!Array.isArray(content)) return null;
452
+ const parts: string[] = [];
453
+ for (const item of content) {
454
+ const rec = asRecord(item);
455
+ if (rec?.type === 'text' && typeof rec.text === 'string') parts.push(rec.text);
456
+ }
457
+ return parts.length > 0 ? parts.join('\n') : null;
458
+ }
459
+
460
+ /**
461
+ * `Object.create(null)`-safe record narrowing. Nothing here indexes by an
462
+ * upstream key, but the shape check is the thing that keeps `result.isError`
463
+ * from throwing on a primitive (D5 rule 5's neighbourhood).
464
+ */
465
+ function asRecord(v: unknown): Record<string, unknown> | null {
466
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
467
+ }
468
+
469
+ /**
470
+ * Read a body, aborting the moment it exceeds the INPUT cap. Streaming rather
471
+ * than `res.text()` so an unbounded body is never fully materialized before the
472
+ * check — the same rule `client.ts` states for the REST lane, for the same
473
+ * reason: a missing or lying `Content-Length` must not be what stands between a
474
+ * cap and the heap.
475
+ */
476
+ async function readCapped(res: Response): Promise<string> {
477
+ const body = res.body;
478
+ if (!body) {
479
+ // Some fetch stubs (and `Response` in a few runtimes) expose no stream.
480
+ const text = await res.text();
481
+ if (text.length > MAX_CODEGEN_RESPONSE_BYTES) throw new CodegenError('too_large');
482
+ return text;
483
+ }
484
+ const reader = body.getReader();
485
+ const chunks: Uint8Array[] = [];
486
+ let total = 0;
487
+ try {
488
+ while (true) {
489
+ const { done, value } = await reader.read();
490
+ if (done) break;
491
+ if (!value) continue;
492
+ total += value.byteLength;
493
+ if (total > MAX_CODEGEN_RESPONSE_BYTES) {
494
+ await reader.cancel().catch(() => {});
495
+ throw new CodegenError('too_large');
496
+ }
497
+ chunks.push(value);
498
+ }
499
+ } finally {
500
+ reader.releaseLock?.();
501
+ }
502
+ const out = new Uint8Array(total);
503
+ let offset = 0;
504
+ for (const chunk of chunks) {
505
+ out.set(chunk, offset);
506
+ offset += chunk.byteLength;
507
+ }
508
+ return new TextDecoder().decode(out);
509
+ }
@@ -0,0 +1,103 @@
1
+ // figma/codegen-fonts.ts — plan T18.
2
+ //
3
+ // The thing under test is not really the mapping, it is the REPORT. A font that
4
+ // silently falls back looks fine and is not the design, and this import has
5
+ // already shipped three "success" reports over lost content. So: every
6
+ // substitution produces an entry, the entry is BOUNDED (DDR-219 D9), and a
7
+ // family the project genuinely has produces none.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import {
12
+ FontSubstitutions,
13
+ MAX_FAMILY_DETAIL,
14
+ resolveFontFamily,
15
+ SYSTEM_STACK,
16
+ splitFamilyAndStyle,
17
+ styleToWeight,
18
+ } from './codegen-fonts.ts';
19
+ import { ImportReport } from './sanitize.ts';
20
+
21
+ const DS = [{ name: '--font-body', value: "'hanken grotesk','inter',sans-serif" }];
22
+
23
+ describe('splitting Figma’s Family:Style', () => {
24
+ test.each([
25
+ ['SF Pro:Bold', 'SF Pro', 'Bold'],
26
+ ['SF Pro Display:Semibold', 'SF Pro Display', 'Semibold'],
27
+ ['Inter:Regular', 'Inter', 'Regular'],
28
+ ['Inter', 'Inter', null],
29
+ ])('%s', (raw, family, style) => {
30
+ expect(splitFamilyAndStyle(raw)).toEqual({ family, style });
31
+ });
32
+
33
+ test('the style half is a WEIGHT and must not become part of the family', () => {
34
+ expect(styleToWeight('Bold')).toBe(700);
35
+ expect(styleToWeight('Semibold')).toBe(600);
36
+ expect(styleToWeight('Regular')).toBe(400);
37
+ expect(styleToWeight('NotAWeight')).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe('resolveFontFamily', () => {
42
+ test('a family the DS already declares resolves to the token, and is NOT a substitution', () => {
43
+ const r = resolveFontFamily('Inter:Regular', DS);
44
+ expect(r.css).toBe('var(--font-body)');
45
+ expect(r.substituted).toBe(false);
46
+ });
47
+
48
+ test('an absent family lands on the DS body token AND reports', () => {
49
+ // Measured on the dogfood machine: SF Pro is not installed, and the DS loads
50
+ // no webfont at all — so copying the name through lands on a serif fallback
51
+ // that looks fine and is not the design.
52
+ const r = resolveFontFamily('SF Pro:Bold', DS);
53
+ expect(r.css).toBe('var(--font-body)');
54
+ expect(r.substituted).toBe(true);
55
+ expect(r.requested).toBe('SF Pro');
56
+ });
57
+
58
+ test('with no DS at all it lands on a SANS stack, never a serif default', () => {
59
+ const r = resolveFontFamily('Nunito:Bold', []);
60
+ expect(r.css).toBe(SYSTEM_STACK);
61
+ expect(r.substituted).toBe(true);
62
+ });
63
+
64
+ test('the requested family NEVER reaches the artifact verbatim', () => {
65
+ const hostile = resolveFontFamily("Evil';}\n.x{color:red};:Bold", DS);
66
+ expect(hostile.css).not.toContain('color:red');
67
+ expect(hostile.requested).not.toContain(';');
68
+ expect(hostile.requested.length).toBeLessThanOrEqual(MAX_FAMILY_DETAIL);
69
+ });
70
+ });
71
+
72
+ describe('the report', () => {
73
+ test('one entry per FAMILY with a count — not one per element', () => {
74
+ const subs = new FontSubstitutions();
75
+ for (let i = 0; i < 40; i += 1) subs.note(resolveFontFamily('SF Pro:Bold', DS));
76
+ subs.note(resolveFontFamily('Nunito:Regular', DS));
77
+ const report = new ImportReport();
78
+ subs.flush(report, '425:2939');
79
+
80
+ // Forty identical entries would bury every other disposition and blow the
81
+ // summary's 200-line cap for no information.
82
+ expect(report.entries).toEqual([
83
+ { nodeId: '425:2939', type: 'FONT', disposition: 'font-substituted', detail: 'Nunito x1' },
84
+ { nodeId: '425:2939', type: 'FONT', disposition: 'font-substituted', detail: 'SF Pro x40' },
85
+ ]);
86
+ });
87
+
88
+ test('a family that survived produces NO entry', () => {
89
+ const subs = new FontSubstitutions();
90
+ subs.note(resolveFontFamily('Inter:Regular', DS));
91
+ const report = new ImportReport();
92
+ subs.flush(report, '1:1');
93
+ expect(report.entries).toEqual([]);
94
+ });
95
+
96
+ test('a hostile family cannot blow the detail bound — ImportReport would throw', () => {
97
+ const subs = new FontSubstitutions();
98
+ subs.note(resolveFontFamily(`${'A'.repeat(400)}:Bold`, DS));
99
+ const report = new ImportReport();
100
+ expect(() => subs.flush(report, '1:1')).not.toThrow();
101
+ expect(report.entries[0].detail?.length).toBeLessThanOrEqual(64);
102
+ });
103
+ });