@1agh/maude 0.58.2 → 0.59.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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -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
|
+
});
|