@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.
- package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +17 -1
- package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +54 -12
- package/dist/configs/eslint/expo.d.ts.map +1 -1
- package/dist/configs/eslint/expo.js +5 -0
- package/dist/configs/eslint/expo.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +15 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +70 -17
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/eslint-plugin-ui-standards/README.md +90 -0
- package/eslint-plugin-ui-standards/index.js +4 -0
- package/eslint-plugin-ui-standards/rules/no-unbound-design-value.js +375 -0
- package/expo/copy-overwrite/eslint.expo.ts +5 -0
- package/expo/copy-overwrite/scripts/bdd/discover.mjs +20 -4
- package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +7 -1
- package/expo/copy-overwrite/scripts/bdd/parse.mjs +8 -2
- package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +8 -2
- package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +91 -10
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa/commands/design/intake.md +7 -0
- package/plugins/lisa/rules/eager/design-value-binding.md +80 -0
- package/plugins/lisa/rules/reference/design-value-binding.md +204 -0
- package/plugins/lisa/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa/skills/lisa-design-intake/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-agy/commands/lisa/design/intake.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-agy/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-agy/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/lisa/design/intake.md +7 -0
- package/plugins/lisa-copilot/rules/eager/design-value-binding.md +80 -0
- package/plugins/lisa-copilot/rules/reference/design-value-binding.md +204 -0
- package/plugins/lisa-copilot/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-copilot/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-copilot/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/design/intake.md +7 -0
- package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +209 -0
- package/plugins/lisa-cursor/rules/design-value-binding.mdc +85 -0
- package/plugins/lisa-cursor/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-cursor/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-cursor/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/design/intake.md +7 -0
- package/plugins/src/base/rules/eager/design-value-binding.md +80 -0
- package/plugins/src/base/rules/reference/design-value-binding.md +204 -0
- package/plugins/src/base/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/src/base/scripts/design-intake-gate.mjs +470 -0
- package/plugins/src/base/scripts/design-variable-ids.mjs +565 -0
- package/plugins/src/base/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +22 -6
- 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
|
+
}
|