@codyswann/lisa 3.45.9 → 3.46.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 (134) hide show
  1. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +17 -1
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
  3. package/all/copy-overwrite/scripts/lisa-work-item.mjs +54 -12
  4. package/dist/configs/eslint/expo.d.ts.map +1 -1
  5. package/dist/configs/eslint/expo.js +5 -0
  6. package/dist/configs/eslint/expo.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +15 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +70 -17
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/eslint-plugin-ui-standards/README.md +90 -0
  14. package/eslint-plugin-ui-standards/index.js +4 -0
  15. package/eslint-plugin-ui-standards/rules/no-unbound-design-value.js +375 -0
  16. package/expo/copy-overwrite/eslint.expo.ts +5 -0
  17. package/expo/copy-overwrite/scripts/bdd/discover.mjs +20 -4
  18. package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +7 -1
  19. package/expo/copy-overwrite/scripts/bdd/parse.mjs +8 -2
  20. package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +8 -2
  21. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +91 -10
  22. package/package.json +1 -1
  23. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +169 -0
  26. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/agents/openai.yaml +4 -0
  27. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
  28. package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -0
  29. package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -0
  30. package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
  31. package/plugins/lisa/commands/design/intake.md +7 -0
  32. package/plugins/lisa/rules/eager/design-value-binding.md +80 -0
  33. package/plugins/lisa/rules/reference/design-value-binding.md +204 -0
  34. package/plugins/lisa/scripts/design-bindings-probe.mjs +713 -0
  35. package/plugins/lisa/scripts/design-intake-gate.mjs +470 -0
  36. package/plugins/lisa/scripts/design-variable-ids.mjs +565 -0
  37. package/plugins/lisa/skills/lisa-design-intake/SKILL.md +169 -0
  38. package/plugins/lisa/skills/lisa-design-intake/agents/openai.yaml +4 -0
  39. package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
  40. package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -0
  41. package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -0
  42. package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
  43. package/plugins/lisa-agy/commands/lisa/design/intake.md +7 -0
  44. package/plugins/lisa-agy/plugin.json +1 -1
  45. package/plugins/lisa-agy/scripts/design-bindings-probe.mjs +713 -0
  46. package/plugins/lisa-agy/scripts/design-intake-gate.mjs +470 -0
  47. package/plugins/lisa-agy/scripts/design-variable-ids.mjs +565 -0
  48. package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +169 -0
  49. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
  50. package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -0
  51. package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -0
  52. package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
  53. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  56. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-copilot/commands/lisa/design/intake.md +7 -0
  60. package/plugins/lisa-copilot/rules/eager/design-value-binding.md +80 -0
  61. package/plugins/lisa-copilot/rules/reference/design-value-binding.md +204 -0
  62. package/plugins/lisa-copilot/scripts/design-bindings-probe.mjs +713 -0
  63. package/plugins/lisa-copilot/scripts/design-intake-gate.mjs +470 -0
  64. package/plugins/lisa-copilot/scripts/design-variable-ids.mjs +565 -0
  65. package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +169 -0
  66. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
  67. package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -0
  68. package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -0
  69. package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
  70. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-cursor/commands/lisa/design/intake.md +7 -0
  72. package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +209 -0
  73. package/plugins/lisa-cursor/rules/design-value-binding.mdc +85 -0
  74. package/plugins/lisa-cursor/scripts/design-bindings-probe.mjs +713 -0
  75. package/plugins/lisa-cursor/scripts/design-intake-gate.mjs +470 -0
  76. package/plugins/lisa-cursor/scripts/design-variable-ids.mjs +565 -0
  77. package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +169 -0
  78. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
  79. package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -0
  80. package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -0
  81. package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
  82. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo-agy/plugin.json +1 -1
  85. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  90. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  95. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  100. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  105. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails-agy/plugin.json +1 -1
  110. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  114. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  115. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  119. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  120. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  122. package/plugins/src/base/commands/design/intake.md +7 -0
  123. package/plugins/src/base/rules/eager/design-value-binding.md +80 -0
  124. package/plugins/src/base/rules/reference/design-value-binding.md +204 -0
  125. package/plugins/src/base/scripts/design-bindings-probe.mjs +713 -0
  126. package/plugins/src/base/scripts/design-intake-gate.mjs +470 -0
  127. package/plugins/src/base/scripts/design-variable-ids.mjs +565 -0
  128. package/plugins/src/base/skills/lisa-design-intake/SKILL.md +169 -0
  129. package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
  130. package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -0
  131. package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -0
  132. package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
  133. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +22 -6
  134. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +10 -1
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive one-off: build the committed `VariableID → variable name` map
4
+ * that lets `design-bindings-probe.mjs` run headlessly forever after.
5
+ *
6
+ * WHY A COMMITTED MAP EXISTS AT ALL
7
+ * ---------------------------------
8
+ * Enforcing "values come from design variables" headlessly needs variable
9
+ * NAMES. Both routes to a name are unusable on their own, and this is measured:
10
+ *
11
+ * - The Variables REST API (`/v1/files/:key/variables/local`) returns names
12
+ * directly but is **Enterprise-plan only**. The read scope is not offered
13
+ * in the token scope picker on other plans, so no token change unlocks it.
14
+ * - The design-tool MCP (`get_variable_defs`) returns names on every plan but
15
+ * authenticates by **browser OAuth**, which cron and CI cannot perform.
16
+ *
17
+ * What IS available headlessly is `/v1/files/:key/nodes`, which reports each
18
+ * bound property as an opaque `VariableID:106:15`. That id→name mapping is
19
+ * **static**, so it is resolved ONCE here, interactively, and committed. The
20
+ * headless probe then needs only the access token.
21
+ *
22
+ * HOW THE JOIN WORKS
23
+ * ------------------
24
+ * ```
25
+ * MCP get_variable_defs → { name: value } per node, per mode
26
+ * REST /nodes → { VariableID: value } per bound property, per mode
27
+ * ```
28
+ * Joining on value alone is ambiguous wherever two variables share a value — a
29
+ * foreground and a surface variable are both `#ffffff` in light mode. Three
30
+ * signals separate them:
31
+ *
32
+ * 1. **property kind** — a padding can only bind a spacing variable, a radius
33
+ * only a radius variable;
34
+ * 2. **light + dark signature** — same-valued variables in light mode diverge
35
+ * in dark, so the `(light, dark)` pair separates them. This is the signal
36
+ * that takes the map to complete;
37
+ * 3. **single occupancy** — a node containing exactly one tied id and exactly
38
+ * one tied name forces that pairing, because no other candidate is present
39
+ * to claim it.
40
+ *
41
+ * STALENESS IS SELF-DETECTING. If design adds or renames a variable, an
42
+ * unknown id shows up in a headless read and the probe fails loudly telling you
43
+ * to re-run this. It cannot silently drift into resolving the wrong variable —
44
+ * that property is the whole reason a committed map is safe to trust.
45
+ *
46
+ * Usage:
47
+ * FIGMA_ACCESS_TOKEN=… FIGMA_MCP_TOKEN=… node design-variable-ids.mjs \
48
+ * --file <fileKey> --light <n1,n2,…> [--dark <n1,n2,…>] [--out <path>]
49
+ * @module design-variable-ids
50
+ */
51
+ import { writeFileSync } from "node:fs";
52
+ import { realpathSync } from "node:fs";
53
+ import { fileURLToPath } from "node:url";
54
+
55
+ const MCP_URL = "https://mcp.figma.com/mcp";
56
+
57
+ /**
58
+ * Variable namespaces each bound property may legitimately reference. Narrowing
59
+ * the candidate set by property kind is the cheapest of the three signals and
60
+ * removes most collisions before the value join runs at all.
61
+ */
62
+ export const KIND_NAMESPACES = {
63
+ "fills:text": ["content/", "accent/", "status/", "chart/"],
64
+ "fills:box": ["surface/", "accent/", "status/", "chart/", "content/"],
65
+ strokes: ["outline/", "content/", "status/", "accent/", "chart/"],
66
+ paddingLeft: ["space/"],
67
+ paddingRight: ["space/"],
68
+ paddingTop: ["space/"],
69
+ paddingBottom: ["space/"],
70
+ itemSpacing: ["space/"],
71
+ counterAxisSpacing: ["space/"],
72
+ cornerRadius: ["radius/"],
73
+ topLeftRadius: ["radius/"],
74
+ topRightRadius: ["radius/"],
75
+ bottomLeftRadius: ["radius/"],
76
+ bottomRightRadius: ["radius/"],
77
+ rectangleCornerRadii: ["radius/"],
78
+ };
79
+
80
+ /**
81
+ * Score every candidate name for one observed id.
82
+ *
83
+ * A candidate survives only if it agrees with the observed value in **every**
84
+ * mode where both are known — one disagreement eliminates it outright. Among
85
+ * survivors, a match confirmed in both modes outranks a light-only match,
86
+ * because a light-only agreement is exactly the coincidence that produces a
87
+ * wrong map.
88
+ * @param {{
89
+ * slots: Map<string, { light?: string, dark?: string }>,
90
+ * names: readonly string[],
91
+ * valuesLight: Record<string, unknown>,
92
+ * valuesDark: Record<string, unknown>
93
+ * }} input - The join inputs for one id.
94
+ * @returns {[string, number][]} Candidates, best first.
95
+ */
96
+ export function scoreCandidates(input) {
97
+ const scores = new Map();
98
+ const normalise = value =>
99
+ value === undefined ? undefined : String(value).toLowerCase();
100
+
101
+ for (const [slot, observed] of input.slots) {
102
+ const allowed = KIND_NAMESPACES[slot];
103
+ for (const name of input.names) {
104
+ if (allowed && !allowed.some(prefix => name.startsWith(prefix))) continue;
105
+ const nameLight = normalise(input.valuesLight[name]);
106
+ const nameDark = normalise(input.valuesDark[name]);
107
+ if (
108
+ observed.light !== undefined &&
109
+ nameLight !== undefined &&
110
+ observed.light !== nameLight
111
+ )
112
+ continue;
113
+ if (
114
+ observed.dark !== undefined &&
115
+ nameDark !== undefined &&
116
+ observed.dark !== nameDark
117
+ )
118
+ continue;
119
+ if (observed.light === undefined && observed.dark === undefined) continue;
120
+
121
+ const bothModes =
122
+ observed.light !== undefined &&
123
+ observed.dark !== undefined &&
124
+ nameLight !== undefined &&
125
+ nameDark !== undefined;
126
+ scores.set(name, (scores.get(name) ?? 0) + (bothModes ? 10 : 1));
127
+ }
128
+ }
129
+
130
+ return [...scores.entries()].sort((a, b) => b[1] - a[1]);
131
+ }
132
+
133
+ /**
134
+ * Classify one id's ranked candidates into resolved, ambiguous, or unresolved.
135
+ *
136
+ * A tie is **ambiguous**, never resolved by picking the first. Guessing which
137
+ * of two variables a value came from is precisely what the contract forbids,
138
+ * and a map that guessed would be worse than no map — it would resolve
139
+ * confidently and wrongly.
140
+ * @param {readonly [string, number][]} ranked - Scored candidates, best first.
141
+ * @returns {{ kind: "resolved", name: string } | { kind: "ambiguous", names: string[] } | { kind: "unresolved" }} Classification.
142
+ */
143
+ export function classifyCandidates(ranked) {
144
+ if (ranked.length === 0) return { kind: "unresolved" };
145
+ if (ranked.length === 1 || ranked[0][1] > ranked[1][1]) {
146
+ return { kind: "resolved", name: ranked[0][0] };
147
+ }
148
+ const top = ranked[0][1];
149
+ return {
150
+ kind: "ambiguous",
151
+ names: ranked.filter(entry => entry[1] === top).map(entry => entry[0]),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Settle remaining ties by single occupancy.
157
+ *
158
+ * When some node contains exactly one of the tied ids and exactly one of the
159
+ * tied names, the pairing is forced: no other candidate is present to claim the
160
+ * name. Corroboration across nodes makes it evidence rather than inference, and
161
+ * a single contradicting node abandons the attempt rather than voting.
162
+ * @param {Record<string, string[]>} ambiguous - id → tied candidate names.
163
+ * @param {Map<string, Set<string>>} idsPerNode - nodeId → ids present.
164
+ * @param {Map<string, Set<string>>} namesPerNode - nodeId → names present.
165
+ * @returns {{ resolved: Record<string, string>, evidence: Record<string, string[]> }} Forced pairings.
166
+ */
167
+ export function disambiguateBySingleOccupancy(
168
+ ambiguous,
169
+ idsPerNode,
170
+ namesPerNode
171
+ ) {
172
+ const resolved = {};
173
+ const evidence = {};
174
+
175
+ for (const [id, names] of Object.entries(ambiguous)) {
176
+ const tiedIds = Object.entries(ambiguous)
177
+ .filter(
178
+ ([, candidates]) =>
179
+ candidates.length === names.length &&
180
+ candidates.every(name => names.includes(name))
181
+ )
182
+ .map(([tiedId]) => tiedId);
183
+
184
+ const votes = new Map();
185
+ for (const [nodeId, idsHere] of idsPerNode) {
186
+ const idsPresent = tiedIds.filter(tied => idsHere.has(tied));
187
+ const namesPresent = names.filter(name =>
188
+ namesPerNode.get(nodeId)?.has(name)
189
+ );
190
+ if (idsPresent.length !== 1 || namesPresent.length !== 1) continue;
191
+ if (idsPresent[0] !== id) continue;
192
+ votes.set(namesPresent[0], [
193
+ ...(votes.get(namesPresent[0]) ?? []),
194
+ nodeId,
195
+ ]);
196
+ }
197
+
198
+ // More than one distinct winner means the nodes disagree. Abandon, do not
199
+ // vote — a majority among contradictory evidence is still a guess.
200
+ if (votes.size !== 1) continue;
201
+ const [name, nodes] = [...votes][0];
202
+ resolved[id] = name;
203
+ evidence[id] = nodes;
204
+ }
205
+
206
+ return { resolved, evidence };
207
+ }
208
+
209
+ /**
210
+ * Build the full map from already-gathered observations. Pure — no network.
211
+ * @param {{
212
+ * observed: Map<string, Map<string, { light?: string, dark?: string }>>,
213
+ * valuesLight: Record<string, unknown>,
214
+ * valuesDark: Record<string, unknown>,
215
+ * idsPerNode?: Map<string, Set<string>>,
216
+ * namesPerNode?: Map<string, Set<string>>
217
+ * }} input - Observations from both modes.
218
+ * @returns {{ byId: Record<string, string>, ambiguous: Record<string, string[]>, unresolved: string[], evidence: Record<string, string[]> }} The map.
219
+ */
220
+ export function buildIdMap(input) {
221
+ const names = [
222
+ ...new Set([
223
+ ...Object.keys(input.valuesLight),
224
+ ...Object.keys(input.valuesDark),
225
+ ]),
226
+ ];
227
+
228
+ const byId = {};
229
+ const ambiguous = {};
230
+ const unresolved = [];
231
+
232
+ for (const [id, slots] of input.observed) {
233
+ const ranked = scoreCandidates({
234
+ slots,
235
+ names,
236
+ valuesLight: input.valuesLight,
237
+ valuesDark: input.valuesDark,
238
+ });
239
+ const classified = classifyCandidates(ranked);
240
+ if (classified.kind === "resolved") byId[id] = classified.name;
241
+ else if (classified.kind === "ambiguous") ambiguous[id] = classified.names;
242
+ else unresolved.push(id);
243
+ }
244
+
245
+ const { resolved, evidence } = disambiguateBySingleOccupancy(
246
+ ambiguous,
247
+ input.idsPerNode ?? new Map(),
248
+ input.namesPerNode ?? new Map()
249
+ );
250
+ for (const [id, name] of Object.entries(resolved)) {
251
+ byId[id] = name;
252
+ delete ambiguous[id];
253
+ }
254
+
255
+ return { byId, ambiguous, unresolved, evidence };
256
+ }
257
+
258
+ /**
259
+ * @param {{ r: number, g: number, b: number }} color - Colour triple.
260
+ * @returns {string} `#rrggbb`.
261
+ */
262
+ function hexOf(color) {
263
+ const channel = value =>
264
+ Math.round(value * 255)
265
+ .toString(16)
266
+ .padStart(2, "0");
267
+ return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
268
+ }
269
+
270
+ /**
271
+ * Call one design-tool MCP tool over HTTP, parsing the SSE envelope.
272
+ * @param {string} token - OAuth access token from the interactive session.
273
+ * @param {string} tool - Tool name.
274
+ * @param {object} args - Tool arguments.
275
+ * @returns {Promise<string | null>} Concatenated text content, or null.
276
+ */
277
+ async function callMcp(token, tool, args) {
278
+ const response = await fetch(MCP_URL, {
279
+ method: "POST",
280
+ headers: {
281
+ Authorization: `Bearer ${token}`,
282
+ "Content-Type": "application/json",
283
+ Accept: "application/json, text/event-stream",
284
+ },
285
+ body: JSON.stringify({
286
+ jsonrpc: "2.0",
287
+ id: 1,
288
+ method: "tools/call",
289
+ params: { name: tool, arguments: args },
290
+ }),
291
+ });
292
+ const raw = await response.text();
293
+ const dataLines = raw.split("\n").filter(line => line.startsWith("data:"));
294
+ if (dataLines.length === 0) return null;
295
+ try {
296
+ const payload = JSON.parse(dataLines[dataLines.length - 1].slice(5).trim());
297
+ return (payload.result?.content ?? [])
298
+ .map(part => part.text ?? "")
299
+ .join("");
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Fetch variable name → value for one mode's reference nodes.
307
+ * @param {string} token - MCP token.
308
+ * @param {string} fileKey - File key.
309
+ * @param {readonly string[]} nodeIds - Reference nodes for this mode.
310
+ * @returns {Promise<{ values: Record<string, unknown>, perNode: Map<string, Set<string>> }>} Names by mode.
311
+ */
312
+ async function namesForMode(token, fileKey, nodeIds) {
313
+ const values = {};
314
+ const perNode = new Map();
315
+ for (const nodeId of nodeIds) {
316
+ const text = await callMcp(token, "get_variable_defs", { fileKey, nodeId });
317
+ if (!text?.trim().startsWith("{")) continue;
318
+ try {
319
+ const defs = JSON.parse(text);
320
+ Object.assign(values, defs);
321
+ perNode.set(nodeId, new Set(Object.keys(defs)));
322
+ } catch {
323
+ // A node with no variables answers in prose rather than JSON. Skip it.
324
+ }
325
+ }
326
+ return { values, perNode };
327
+ }
328
+
329
+ /**
330
+ * Fetch node documents in chunks.
331
+ * @param {string} token - Access token.
332
+ * @param {string} fileKey - File key.
333
+ * @param {readonly string[]} nodeIds - Node ids.
334
+ * @returns {Promise<Record<string, object>>} Node entries by id.
335
+ */
336
+ async function restNodes(token, fileKey, nodeIds) {
337
+ const out = {};
338
+ for (let index = 0; index < nodeIds.length; index += 8) {
339
+ const chunk = nodeIds.slice(index, index + 8);
340
+ const response = await fetch(
341
+ `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${chunk.join(",")}`,
342
+ { headers: { "X-Figma-Token": token } }
343
+ );
344
+ if (!response.ok) {
345
+ throw new Error(
346
+ `design source responded ${response.status} for ${chunk.join(",")}`
347
+ );
348
+ }
349
+ Object.assign(out, (await response.json()).nodes ?? {});
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /**
355
+ * Resolve the observed value for one bound reference on one node.
356
+ * @param {object} node - The node.
357
+ * @param {string} property - Bound property.
358
+ * @param {number} index - Position within an array-valued property.
359
+ * @returns {{ slot: string, value: string } | null} Observation.
360
+ */
361
+ function observationFor(node, property, index) {
362
+ if (property === "fills" || property === "strokes") {
363
+ const paints = node[property] ?? [];
364
+ const paint = paints[index] ?? paints[0];
365
+ if (!paint?.color) return null;
366
+ const slot =
367
+ property === "fills"
368
+ ? node.type === "TEXT"
369
+ ? "fills:text"
370
+ : "fills:box"
371
+ : "strokes";
372
+ return { slot, value: hexOf(paint.color) };
373
+ }
374
+ if (typeof node[property] === "number") {
375
+ return { slot: property, value: String(node[property]) };
376
+ }
377
+ if (
378
+ property === "rectangleCornerRadii" &&
379
+ Array.isArray(node.rectangleCornerRadii)
380
+ ) {
381
+ const value =
382
+ node.rectangleCornerRadii[index] ?? node.rectangleCornerRadii[0];
383
+ return { slot: property, value: String(value) };
384
+ }
385
+ if (typeof node.cornerRadius === "number" && property.endsWith("Radii")) {
386
+ return { slot: property, value: String(node.cornerRadius) };
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /**
392
+ * Walk fetched documents recording each id's observed value per slot.
393
+ * @param {Record<string, object>} documents - Node entries.
394
+ * @param {"light" | "dark"} mode - Which mode these nodes render.
395
+ * @param {Map<string, Map<string, object>>} observed - Accumulator.
396
+ * @param {Map<string, Set<string>>} idsPerNode - Accumulator.
397
+ * @returns {void}
398
+ */
399
+ function recordObservations(documents, mode, observed, idsPerNode) {
400
+ for (const [rootId, entry] of Object.entries(documents)) {
401
+ idsPerNode.set(rootId, idsPerNode.get(rootId) ?? new Set());
402
+ const stack = [entry?.document];
403
+ while (stack.length > 0) {
404
+ const node = stack.pop();
405
+ if (!node || typeof node !== "object") continue;
406
+ for (const [property, ref] of Object.entries(node.boundVariables ?? {})) {
407
+ const refs = Array.isArray(ref)
408
+ ? ref
409
+ : ref && typeof ref === "object" && !ref.id
410
+ ? Object.values(ref)
411
+ : [ref];
412
+ refs.forEach((one, index) => {
413
+ if (!one?.id) return;
414
+ idsPerNode.get(rootId).add(one.id);
415
+ const observation = observationFor(node, property, index);
416
+ if (!observation) return;
417
+ if (!observed.has(one.id)) observed.set(one.id, new Map());
418
+ const slots = observed.get(one.id);
419
+ const existing = slots.get(observation.slot) ?? {};
420
+ slots.set(observation.slot, {
421
+ ...existing,
422
+ [mode]: observation.value.toLowerCase(),
423
+ });
424
+ });
425
+ }
426
+ for (const child of node.children ?? []) stack.push(child);
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * @param {readonly string[]} argv - Arguments after the script name.
433
+ * @returns {Map<string, string>} Parsed flags.
434
+ */
435
+ function parseArgs(argv) {
436
+ const args = new Map();
437
+ for (let index = 0; index < argv.length; index += 1) {
438
+ const arg = argv[index];
439
+ if (!arg.startsWith("--")) continue;
440
+ const eq = arg.indexOf("=");
441
+ if (eq !== -1) args.set(arg.slice(2, eq), arg.slice(eq + 1));
442
+ else {
443
+ const next = argv[index + 1];
444
+ args.set(arg.slice(2), next && !next.startsWith("--") ? next : "true");
445
+ }
446
+ }
447
+ return args;
448
+ }
449
+
450
+ /**
451
+ * CLI entrypoint.
452
+ * @param {readonly string[]} argv - Arguments after the script name.
453
+ * @param {Record<string, string | undefined>} [env] - Process environment.
454
+ * @returns {Promise<number>} Process exit code.
455
+ */
456
+ export async function runCli(argv, env = process.env) {
457
+ const args = parseArgs(argv);
458
+ const fileKey = args.get("file");
459
+ const light = (args.get("light") ?? "").split(",").filter(Boolean);
460
+ const dark = (args.get("dark") ?? "").split(",").filter(Boolean);
461
+ const out = args.get("out") ?? "docs/design-system/figma-variable-ids.json";
462
+
463
+ if (
464
+ !env.FIGMA_ACCESS_TOKEN ||
465
+ !env.FIGMA_MCP_TOKEN ||
466
+ !fileKey ||
467
+ light.length === 0
468
+ ) {
469
+ process.stderr.write(
470
+ "usage: FIGMA_ACCESS_TOKEN=… FIGMA_MCP_TOKEN=… design-variable-ids.mjs " +
471
+ "--file <key> --light <n1,n2,…> [--dark <n1,n2,…>] [--out <path>]\n" +
472
+ "This step is INTERACTIVE by necessity: the MCP token comes from a browser OAuth session.\n" +
473
+ "Pass --dark as well wherever the library has a dark mode — the light+dark signature is\n" +
474
+ "what separates variables that share a value, and without it more ids stay ambiguous.\n"
475
+ );
476
+ return 2;
477
+ }
478
+
479
+ const observed = new Map();
480
+ const idsPerNode = new Map();
481
+ const namesPerNode = new Map();
482
+
483
+ const lightNames = await namesForMode(env.FIGMA_MCP_TOKEN, fileKey, light);
484
+ recordObservations(
485
+ await restNodes(env.FIGMA_ACCESS_TOKEN, fileKey, light),
486
+ "light",
487
+ observed,
488
+ idsPerNode
489
+ );
490
+ for (const [nodeId, names] of lightNames.perNode)
491
+ namesPerNode.set(nodeId, names);
492
+
493
+ const darkNames =
494
+ dark.length > 0
495
+ ? await namesForMode(env.FIGMA_MCP_TOKEN, fileKey, dark)
496
+ : { values: {}, perNode: new Map() };
497
+ if (dark.length > 0) {
498
+ recordObservations(
499
+ await restNodes(env.FIGMA_ACCESS_TOKEN, fileKey, dark),
500
+ "dark",
501
+ observed,
502
+ idsPerNode
503
+ );
504
+ }
505
+ for (const [nodeId, names] of darkNames.perNode)
506
+ namesPerNode.set(nodeId, names);
507
+
508
+ const map = buildIdMap({
509
+ observed,
510
+ valuesLight: lightNames.values,
511
+ valuesDark: darkNames.values,
512
+ idsPerNode,
513
+ namesPerNode,
514
+ });
515
+
516
+ writeFileSync(
517
+ out,
518
+ `${JSON.stringify(
519
+ {
520
+ $comment:
521
+ "VariableID -> variable name. Generated by design-variable-ids.mjs. Lets " +
522
+ "design-bindings-probe.mjs resolve which variable a layer uses HEADLESSLY with only " +
523
+ "FIGMA_ACCESS_TOKEN — the Variables REST API is Enterprise-only and the design-tool MCP " +
524
+ "needs browser OAuth, so neither works in CI or cron. Regenerate when design adds or " +
525
+ "renames variables; an unknown id makes the probe fail loudly rather than resolve the " +
526
+ "wrong variable.",
527
+ $fileKey: fileKey,
528
+ $generatedFrom: { light, dark },
529
+ $disambiguatedBySingleOccupancy: map.evidence,
530
+ byId: map.byId,
531
+ ambiguous: map.ambiguous,
532
+ unresolved: map.unresolved,
533
+ },
534
+ null,
535
+ 2
536
+ )}\n`
537
+ );
538
+
539
+ process.stderr.write(
540
+ `resolved : ${Object.keys(map.byId).length}\n` +
541
+ `ambiguous : ${Object.keys(map.ambiguous).length}\n` +
542
+ `unresolved : ${map.unresolved.length}\n` +
543
+ `wrote ${out}\n`
544
+ );
545
+ return 0;
546
+ }
547
+
548
+ /**
549
+ * True when `moduleUrl` names the module node was asked to run.
550
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
551
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
552
+ * @returns {boolean} Whether the caller should run its CLI body.
553
+ */
554
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
555
+ if (!argv1) return false;
556
+ try {
557
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+
563
+ if (invokedAsScript(import.meta.url)) {
564
+ process.exit(await runCli(process.argv.slice(2)));
565
+ }