@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,713 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Headless probe for the `design-value-binding` contract: does a design node's
4
+ * subtree bind its style values to variables, or paint literals?
5
+ *
6
+ * This is the fact-gathering half. It reads the design source and produces
7
+ * findings; `design-intake-gate.mjs` turns findings into a verdict. Split
8
+ * deliberately — the decision has to be identical whether the facts arrived
9
+ * from this probe, from an interactive session, or from a fixture.
10
+ *
11
+ * WHY IT WORKS THE WAY IT DOES
12
+ * ----------------------------
13
+ * The obvious implementation — ask the design tool "which variable collections
14
+ * are published?" — cannot run headlessly, and that is measured, not assumed:
15
+ *
16
+ * - The Variables REST API (`/v1/files/:key/variables/local`) returns names
17
+ * directly but is **Enterprise-plan only**. The read scope is not offered
18
+ * in the token scope picker on other plans, so no token change unlocks it.
19
+ * - The design-tool MCP returns names on every plan but authenticates by
20
+ * **browser OAuth**, which cron, CI, and a subagent cannot perform.
21
+ *
22
+ * A gate built on either would work in an interactive session and silently
23
+ * no-op everywhere else — the exact defect class of a control that reports
24
+ * success while inert.
25
+ *
26
+ * What IS available headlessly on a plain personal access token is
27
+ * `/v1/files/:key/nodes`, which reports each bound property as an opaque
28
+ * `VariableID:106:15`. That id→name mapping is **static**. So it is resolved
29
+ * once interactively by `design-variable-ids.mjs`, committed to the repo, and
30
+ * this probe runs headlessly against the token alone forever after.
31
+ *
32
+ * That also answers the regime question. An axis is **typed** when the
33
+ * committed map contains at least one variable in that axis's namespace —
34
+ * derived from what the design source really publishes, not from a human being
35
+ * asked, and not from a live query that cannot run.
36
+ *
37
+ * MEASURE THE SUBTREE YOU ARE IMPLEMENTING, NOT THE ENCLOSING SCREEN. A
38
+ * frame-level read counts the chrome behind a modal and over-reports; one
39
+ * measured work item scored 14 bound values at frame level and **zero** inside
40
+ * the modal subtree it actually had to build. Pass the node you will build.
41
+ *
42
+ * FIGMA IS OPTIONAL. A project with no design source configured, no token, or
43
+ * no committed map is SKIPPED — loudly, exit 0. A mandatory gate on an absent
44
+ * integration breaks every project that has no designs, which is most of them.
45
+ *
46
+ * Usage:
47
+ * FIGMA_ACCESS_TOKEN=… node design-bindings-probe.mjs --file <key> --node <id>
48
+ * [--json] [--require color,spacing,radius] [--min 100]
49
+ *
50
+ * Exit codes: 0 PASS or SKIPPED · 1 BLOCK · 2 usage/transport error.
51
+ * @module design-bindings-probe
52
+ */
53
+ import { readFileSync, realpathSync } from "node:fs";
54
+ import { fileURLToPath } from "node:url";
55
+
56
+ /**
57
+ * Bound-property → axis. A property absent here is not a style decision this
58
+ * contract gates, so it is neither counted bound nor counted literal.
59
+ */
60
+ export const PROPERTY_AXES = {
61
+ fills: "color",
62
+ strokes: "color",
63
+ paddingLeft: "spacing",
64
+ paddingRight: "spacing",
65
+ paddingTop: "spacing",
66
+ paddingBottom: "spacing",
67
+ itemSpacing: "spacing",
68
+ counterAxisSpacing: "spacing",
69
+ cornerRadius: "radius",
70
+ topLeftRadius: "radius",
71
+ topRightRadius: "radius",
72
+ bottomLeftRadius: "radius",
73
+ bottomRightRadius: "radius",
74
+ rectangleCornerRadii: "radius",
75
+ };
76
+
77
+ /**
78
+ * Variable-name prefixes that mark an axis as published. Overridable per
79
+ * project through `design.tokens.namespaces`, because the namespace vocabulary
80
+ * is the design system's, not Lisa's.
81
+ */
82
+ export const DEFAULT_NAMESPACES = {
83
+ color: [
84
+ "color/",
85
+ "colour/",
86
+ "content/",
87
+ "surface/",
88
+ "accent/",
89
+ "status/",
90
+ "outline/",
91
+ "chart/",
92
+ ],
93
+ spacing: ["space/", "spacing/", "gap/"],
94
+ radius: ["radius/", "corner/"],
95
+ typography: ["font/", "text/", "type/"],
96
+ elevation: ["elevation/", "shadow/"],
97
+ motion: ["motion/", "duration/", "easing/"],
98
+ };
99
+
100
+ /** The axes this probe can observe through the node payload. */
101
+ export const PROBED_AXES = ["color", "spacing", "radius"];
102
+
103
+ /**
104
+ * Normalise the three shapes a `boundVariables` entry can take.
105
+ *
106
+ * | shape | example | seen on |
107
+ * |---|---|---|
108
+ * | scalar | `{type,id}` | `paddingLeft`, `itemSpacing` |
109
+ * | array | `[{type,id}, …]` | `fills`, `strokes` — one per paint |
110
+ * | keyed object | `{RECTANGLE_TOP_LEFT_CORNER_RADIUS: {type,id}, …}` | `rectangleCornerRadii` |
111
+ *
112
+ * The keyed shape is the trap: it is neither an array nor itself a ref, so a
113
+ * reader that handles only the first two reports **zero bound radii on a file
114
+ * whose radii are fully bound**. Measured, not hypothetical.
115
+ * @param {unknown} ref - A `boundVariables` entry.
116
+ * @returns {{ id: string }[]} Every variable reference it carries.
117
+ */
118
+ export function refsOf(ref) {
119
+ if (!ref || typeof ref !== "object") return [];
120
+ if (Array.isArray(ref)) return ref.filter(entry => entry?.id);
121
+ if (ref.id) return [ref];
122
+ return Object.values(ref).filter(entry => entry?.id);
123
+ }
124
+
125
+ /**
126
+ * @param {{ r: number, g: number, b: number }} color - Figma colour triple.
127
+ * @returns {string} `#rrggbb`.
128
+ */
129
+ function hexOf(color) {
130
+ const channel = value =>
131
+ Math.round(value * 255)
132
+ .toString(16)
133
+ .padStart(2, "0");
134
+ return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
135
+ }
136
+
137
+ /**
138
+ * @param {object} node - A design node.
139
+ * @returns {string} A short human locator for a report.
140
+ */
141
+ function whereOf(node) {
142
+ return `${node.type}:${node.name ?? ""}`.slice(0, 60);
143
+ }
144
+
145
+ /**
146
+ * Record every bound style value on one node.
147
+ *
148
+ * Boundness is read from `boundVariables` **directly, never inferred from a
149
+ * resolved value being present**. Figma omits zero-valued properties from the
150
+ * REST payload, so a padding bound to `space/0` vanishes entirely from the
151
+ * resolved side. Reading it correctly moved one measured frame from 55% to 82%.
152
+ * @param {object} node - A design node.
153
+ * @param {object[]} bound - Accumulator.
154
+ * @returns {void}
155
+ */
156
+ function collectBound(node, bound) {
157
+ const boundVariables = node.boundVariables ?? {};
158
+ for (const [property, ref] of Object.entries(boundVariables)) {
159
+ const axis = PROPERTY_AXES[property];
160
+ if (!axis) continue;
161
+ for (const one of refsOf(ref)) {
162
+ bound.push({ axis, property, where: whereOf(node), id: one.id });
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Record every literal paint on one node, past whatever is already bound.
169
+ * @param {object} node - A design node.
170
+ * @param {string} property - `fills` or `strokes`.
171
+ * @param {number} boundCount - How many paints on this property are bound.
172
+ * @param {object[]} literal - Accumulator.
173
+ * @returns {void}
174
+ */
175
+ function collectLiteralPaints(node, property, boundCount, literal) {
176
+ const paints = (node[property] ?? []).filter(
177
+ paint => paint?.type === "SOLID" && paint.color
178
+ );
179
+ for (const paint of paints.slice(boundCount)) {
180
+ literal.push({
181
+ axis: PROPERTY_AXES[property],
182
+ property,
183
+ where: whereOf(node),
184
+ value: hexOf(paint.color),
185
+ });
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Record every literal numeric value on one node, past whatever is bound.
191
+ * @param {object} node - A design node.
192
+ * @param {string} property - The gated property name.
193
+ * @param {number} boundCount - How many entries on this property are bound.
194
+ * @param {object[]} literal - Accumulator.
195
+ * @returns {void}
196
+ */
197
+ function collectLiteralNumbers(node, property, boundCount, literal) {
198
+ const raw = node[property];
199
+ const values = Array.isArray(raw)
200
+ ? raw
201
+ : typeof raw === "number"
202
+ ? [raw]
203
+ : [];
204
+ for (const value of values.slice(boundCount)) {
205
+ // A zero is the absence of a value, not a design decision worth a variable.
206
+ if (typeof value !== "number" || value === 0) continue;
207
+ literal.push({
208
+ axis: PROPERTY_AXES[property],
209
+ property,
210
+ where: whereOf(node),
211
+ value: `${value}px`,
212
+ });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Walk a node subtree, recording every gated style value as bound or literal.
218
+ * @param {object} root - The subtree root — the node being implemented.
219
+ * @returns {{ bound: object[], literal: object[] }} Observations.
220
+ */
221
+ export function collectValues(root) {
222
+ const bound = [];
223
+ const literal = [];
224
+ const stack = [root];
225
+
226
+ while (stack.length > 0) {
227
+ const node = stack.pop();
228
+ if (!node || typeof node !== "object") continue;
229
+
230
+ collectBound(node, bound);
231
+
232
+ for (const property of Object.keys(PROPERTY_AXES)) {
233
+ const boundCount = refsOf(node.boundVariables?.[property]).length;
234
+ if (property === "fills" || property === "strokes") {
235
+ collectLiteralPaints(node, property, boundCount, literal);
236
+ } else {
237
+ collectLiteralNumbers(node, property, boundCount, literal);
238
+ }
239
+ }
240
+
241
+ for (const child of node.children ?? []) stack.push(child);
242
+ }
243
+
244
+ return { bound, literal };
245
+ }
246
+
247
+ /**
248
+ * Derive the per-axis regime from the committed variable-id map.
249
+ *
250
+ * An axis is `typed` when the map names at least one variable in its namespace
251
+ * — that is the published-collection question, answered from real published
252
+ * variables and answerable headlessly. An axis with no variables is `untyped`,
253
+ * and measuring is its correct source of truth.
254
+ * @param {readonly string[]} names - Variable names from the committed map.
255
+ * @param {Record<string, readonly string[]>} [namespaces] - Axis → name prefixes.
256
+ * @returns {Record<string, "typed" | "untyped">} Regime per axis.
257
+ */
258
+ export function regimeFromVariableNames(
259
+ names,
260
+ namespaces = DEFAULT_NAMESPACES
261
+ ) {
262
+ const regime = {};
263
+ for (const [axis, prefixes] of Object.entries(namespaces)) {
264
+ regime[axis] = names.some(name =>
265
+ prefixes.some(prefix => String(name).startsWith(prefix))
266
+ )
267
+ ? "typed"
268
+ : "untyped";
269
+ }
270
+ return regime;
271
+ }
272
+
273
+ /**
274
+ * Resolve observed variable ids against the committed map.
275
+ *
276
+ * Three outcomes with two different owners, and conflating them sends the wrong
277
+ * person the wrong work:
278
+ *
279
+ * - **named** — resolved. Nothing to do.
280
+ * - **unknown** — *our* map is stale. The value IS bound; we simply cannot say
281
+ * which variable. Regenerate the map.
282
+ * - **ambiguous** — *our* map cannot name it, because two variables share a
283
+ * value. Also not the designer's fault, and still not a pass: guessing which
284
+ * one is exactly what the contract forbids.
285
+ *
286
+ * This is what makes a committed map safe to trust — an id it has never seen
287
+ * fails loudly instead of silently resolving to the wrong variable.
288
+ * @param {readonly { id: string }[]} bound - Observed bound values.
289
+ * @param {{ byId?: Record<string, string>, ambiguous?: Record<string, string[]> }} idMap - Committed map.
290
+ * @returns {{ names: string[], unknownIds: string[], ambiguousIds: Record<string, string[]> }} Resolution.
291
+ */
292
+ export function resolveIds(bound, idMap) {
293
+ const names = new Set();
294
+ const unknownIds = new Set();
295
+ const ambiguousIds = {};
296
+
297
+ for (const entry of bound) {
298
+ const name = idMap?.byId?.[entry.id];
299
+ if (name) {
300
+ names.add(name);
301
+ continue;
302
+ }
303
+ const candidates = idMap?.ambiguous?.[entry.id];
304
+ if (candidates) ambiguousIds[entry.id] = candidates;
305
+ else unknownIds.add(entry.id);
306
+ }
307
+
308
+ return {
309
+ names: [...names].sort(),
310
+ unknownIds: [...unknownIds].sort(),
311
+ ambiguousIds,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Map a variable name onto the repo's token vocabulary.
317
+ *
318
+ * Identity-ish by default (`a/b` → `a-b`) because the mapping is the project's
319
+ * vocabulary, not Lisa's. `design.tokens.nameMap` overrides per name.
320
+ * @param {Record<string, string>} [nameMap] - Configured overrides.
321
+ * @returns {(name: string) => string} The mapper.
322
+ */
323
+ export function tokenNamer(nameMap = {}) {
324
+ return name => nameMap[name] ?? String(name).replaceAll("/", "-");
325
+ }
326
+
327
+ /**
328
+ * Turn observations into findings for `design-intake-gate.mjs`.
329
+ *
330
+ * Every literal becomes a `hardcoded-in-design` finding regardless of axis —
331
+ * the probe reports what it saw, and the gate applies the regime. Keeping the
332
+ * regime decision in one place is what stops the two halves from disagreeing.
333
+ * @param {{ bound: readonly object[], literal: readonly object[] }} observed - Observations.
334
+ * @param {string} component - Human name of the subtree being implemented.
335
+ * @returns {object[]} Findings.
336
+ */
337
+ export function toFindings(observed, component) {
338
+ const findings = observed.literal.map(entry => ({
339
+ kind: "hardcoded-in-design",
340
+ axis: entry.axis,
341
+ component,
342
+ value: entry.value,
343
+ where: entry.where,
344
+ }));
345
+ for (const entry of observed.bound) {
346
+ findings.push({ kind: "bound", axis: entry.axis, component });
347
+ }
348
+ return findings;
349
+ }
350
+
351
+ /**
352
+ * Summarise coverage per axis.
353
+ * @param {{ bound: readonly object[], literal: readonly object[] }} observed - Observations.
354
+ * @returns {Record<string, { bound: number, literal: number, total: number, pct: number | null }>} Per-axis stats.
355
+ */
356
+ export function summarise(observed) {
357
+ const summary = {};
358
+ for (const axis of PROBED_AXES) {
359
+ const bound = observed.bound.filter(entry => entry.axis === axis).length;
360
+ const literal = observed.literal.filter(
361
+ entry => entry.axis === axis
362
+ ).length;
363
+ const total = bound + literal;
364
+ summary[axis] = {
365
+ bound,
366
+ literal,
367
+ total,
368
+ pct: total === 0 ? null : (100 * bound) / total,
369
+ };
370
+ }
371
+ return summary;
372
+ }
373
+
374
+ /**
375
+ * Decide the probe's own verdict from its observations.
376
+ *
377
+ * The default threshold is the contract as written — 100%, so any literal in a
378
+ * required axis fails. `--min` exists so that any relaxation is an explicit,
379
+ * reviewable decision made on the command line, rather than a quiet softening
380
+ * in code, which is the failure this probe was written to prevent.
381
+ * @param {{
382
+ * summary: Record<string, { total: number, pct: number | null }>,
383
+ * unknownIds: readonly string[],
384
+ * ambiguousIds: Record<string, string[]>,
385
+ * required: readonly string[],
386
+ * min?: number
387
+ * }} input - Probe state.
388
+ * @returns {{ verdict: "PASS" | "BLOCK", owner: "design" | "us" | null, failing: string[] }} Verdict.
389
+ */
390
+ export function judgeProbe(input) {
391
+ const min = typeof input.min === "number" ? input.min : 100;
392
+ const failing = input.required.filter(axis => {
393
+ const stats = input.summary?.[axis];
394
+ if (!stats || stats.total === 0) return false;
395
+ return stats.pct < min;
396
+ });
397
+
398
+ const stale = input.unknownIds.length > 0;
399
+ const unnameable = Object.keys(input.ambiguousIds).length > 0;
400
+
401
+ if (failing.length > 0) return { verdict: "BLOCK", owner: "design", failing };
402
+ if (stale || unnameable) return { verdict: "BLOCK", owner: "us", failing };
403
+ return { verdict: "PASS", owner: null, failing };
404
+ }
405
+
406
+ /**
407
+ * Decide whether this project has a design source to probe at all.
408
+ *
409
+ * Figma is optional and this is the single most important property of the
410
+ * probe: most projects have no designs, and a mandatory gate on an absent
411
+ * integration breaks every one of them on upgrade. Absence is SKIPPED, exit 0,
412
+ * and said out loud — never a silent pass and never a block.
413
+ * @param {{ design?: { tokens?: { source?: string } } }} config - Project config.
414
+ * @param {Record<string, string | undefined>} env - Process environment.
415
+ * @param {object | null} idMap - The committed map, or null when absent.
416
+ * @returns {{ skip: true, reason: string } | { skip: false }} Whether to skip.
417
+ */
418
+ export function skipReason(config, env, idMap) {
419
+ if (!config?.design?.tokens?.source) {
420
+ return {
421
+ skip: true,
422
+ reason:
423
+ "no design source is configured (design.tokens.source) — this project has no designs to check against",
424
+ };
425
+ }
426
+ if (!env?.FIGMA_ACCESS_TOKEN) {
427
+ return {
428
+ skip: true,
429
+ reason:
430
+ "FIGMA_ACCESS_TOKEN is not set — the design source cannot be read from here",
431
+ };
432
+ }
433
+ if (!idMap) {
434
+ return {
435
+ skip: true,
436
+ reason:
437
+ "no committed variable-id map was found — run design-variable-ids.mjs once, interactively, to create it",
438
+ };
439
+ }
440
+ return { skip: false };
441
+ }
442
+
443
+ /**
444
+ * Read merged project config, local overriding global per key.
445
+ * @returns {object} Parsed config, or {}.
446
+ */
447
+ export function readProjectConfig() {
448
+ const load = file => {
449
+ try {
450
+ return JSON.parse(readFileSync(file, "utf8"));
451
+ } catch {
452
+ return {};
453
+ }
454
+ };
455
+ const global = load(".lisa.config.json");
456
+ const local = load(".lisa.config.local.json");
457
+ return {
458
+ ...global,
459
+ ...local,
460
+ design: { ...global.design, ...local.design },
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Load the committed variable-id map, or null when absent.
466
+ * @param {string} path - Path to the map.
467
+ * @returns {object | null} The map.
468
+ */
469
+ export function readIdMap(path) {
470
+ try {
471
+ return JSON.parse(readFileSync(path, "utf8"));
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Render the operator-readable report.
479
+ * @param {object} result - Probe result.
480
+ * @returns {string} Report text.
481
+ */
482
+ export function renderReport(result) {
483
+ if (result.verdict === "SKIPPED") {
484
+ return `design-bindings probe: SKIPPED — ${result.reason}`;
485
+ }
486
+
487
+ const lines = [`design-bindings probe: ${result.verdict} ${result.nodeId}`];
488
+ for (const [axis, stats] of Object.entries(result.summary)) {
489
+ const pct = stats.pct === null ? " n/a " : `${stats.pct.toFixed(0)}%`;
490
+ lines.push(
491
+ ` ${axis.padEnd(8)} ${pct.padStart(5)} bound ${stats.bound} / literal ${stats.literal}`
492
+ );
493
+ }
494
+
495
+ if (result.owner === "us") {
496
+ lines.push(
497
+ "",
498
+ " This is OURS, not design's. These values ARE bound; our map cannot name them.",
499
+ ...result.unknownIds.map(id => ` unknown ${id}`),
500
+ ...Object.entries(result.ambiguousIds).map(
501
+ ([id, names]) => ` ambiguous ${id} → one of ${names.join(" | ")}`
502
+ ),
503
+ " Regenerate with design-variable-ids.mjs. Do NOT guess which variable it is."
504
+ );
505
+ }
506
+
507
+ if (result.owner === "design") {
508
+ lines.push(
509
+ "",
510
+ ` BIND THESE (${result.bindList.length} distinct value(s), most frequent first):`,
511
+ ...result.bindList
512
+ .slice(0, 40)
513
+ .map(
514
+ entry =>
515
+ ` ${String(entry.occurrences).padStart(4)}x ${entry.value}`
516
+ ),
517
+ "",
518
+ " This is a block, not a warning. Do NOT snap them to the nearest variable."
519
+ );
520
+ }
521
+
522
+ return lines.join("\n");
523
+ }
524
+
525
+ /**
526
+ * Fetch one node subtree from the design source.
527
+ * @param {string} fileKey - File key.
528
+ * @param {string} nodeId - Node id — the subtree being implemented.
529
+ * @param {string} token - Personal access token.
530
+ * @returns {Promise<{ document: object } | { error: string }>} The node, or the failure.
531
+ */
532
+ export async function fetchNode(fileKey, nodeId, token) {
533
+ const url = `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`;
534
+ const response = await fetch(url, { headers: { "X-Figma-Token": token } });
535
+ if (!response.ok) {
536
+ const body = await response.text();
537
+ return {
538
+ error: `design source responded ${response.status}: ${body.slice(0, 300)}`,
539
+ };
540
+ }
541
+ const payload = await response.json();
542
+ const document = payload.nodes?.[nodeId]?.document;
543
+ return document
544
+ ? { document }
545
+ : { error: `node ${nodeId} not found in file ${fileKey}` };
546
+ }
547
+
548
+ /**
549
+ * Evaluate an already-fetched subtree. Pure — no network, no filesystem.
550
+ * @param {{
551
+ * document: object,
552
+ * idMap: object,
553
+ * component?: string,
554
+ * namespaces?: Record<string, readonly string[]>,
555
+ * nameMap?: Record<string, string>,
556
+ * required?: readonly string[],
557
+ * min?: number
558
+ * }} input - Everything the evaluation needs.
559
+ * @returns {object} The probe result.
560
+ */
561
+ export function evaluateSubtree(input) {
562
+ const observed = collectValues(input.document);
563
+ const { names, unknownIds, ambiguousIds } = resolveIds(
564
+ observed.bound,
565
+ input.idMap
566
+ );
567
+ const summary = summarise(observed);
568
+ const required = input.required ?? PROBED_AXES;
569
+ const { verdict, owner, failing } = judgeProbe({
570
+ summary,
571
+ unknownIds,
572
+ ambiguousIds,
573
+ required,
574
+ min: input.min,
575
+ });
576
+
577
+ const occurrences = {};
578
+ for (const entry of observed.literal) {
579
+ const key = `${entry.axis}:${entry.value}`;
580
+ occurrences[key] = (occurrences[key] ?? 0) + 1;
581
+ }
582
+
583
+ const namer = tokenNamer(input.nameMap);
584
+ return {
585
+ verdict,
586
+ owner,
587
+ failing,
588
+ summary,
589
+ unknownIds,
590
+ ambiguousIds,
591
+ regime: regimeFromVariableNames(
592
+ Object.values(input.idMap?.byId ?? {}),
593
+ input.namespaces
594
+ ),
595
+ findings: toFindings(observed, input.component ?? "this component"),
596
+ tokensUsed: names.map(namer).sort(),
597
+ bindList: Object.entries(occurrences)
598
+ .sort((a, b) => b[1] - a[1])
599
+ .map(([value, count]) => ({ value, occurrences: count })),
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Parse `--flag=value` and `--flag value` argv forms.
605
+ * @param {readonly string[]} argv - Arguments after the script name.
606
+ * @returns {Map<string, string>} Parsed flags.
607
+ */
608
+ function parseArgs(argv) {
609
+ const args = new Map();
610
+ for (let index = 0; index < argv.length; index += 1) {
611
+ const arg = argv[index];
612
+ if (!arg.startsWith("--")) continue;
613
+ const eq = arg.indexOf("=");
614
+ if (eq !== -1) {
615
+ args.set(arg.slice(2, eq), arg.slice(eq + 1));
616
+ continue;
617
+ }
618
+ const next = argv[index + 1];
619
+ args.set(arg.slice(2), next && !next.startsWith("--") ? next : "true");
620
+ }
621
+ return args;
622
+ }
623
+
624
+ /**
625
+ * CLI entrypoint.
626
+ * @param {readonly string[]} argv - Arguments after the script name.
627
+ * @param {Record<string, string | undefined>} [env] - Process environment.
628
+ * @returns {Promise<number>} Process exit code.
629
+ */
630
+ export async function runCli(argv, env = process.env) {
631
+ const args = parseArgs(argv);
632
+ const config = readProjectConfig();
633
+ const mapPath =
634
+ args.get("id-map") ??
635
+ config.design?.tokens?.idMap ??
636
+ "docs/design-system/figma-variable-ids.json";
637
+ const idMap = readIdMap(mapPath);
638
+
639
+ const skip = skipReason(config, env, idMap);
640
+ if (skip.skip) {
641
+ const result = { verdict: "SKIPPED", reason: skip.reason };
642
+ process.stdout.write(
643
+ args.get("json") === "true"
644
+ ? `${JSON.stringify(result, null, 2)}\n`
645
+ : `${renderReport(result)}\n`
646
+ );
647
+ return 0;
648
+ }
649
+
650
+ const fileKey = args.get("file") ?? config.design?.tokens?.source;
651
+ const nodeId = args.get("node");
652
+ if (!fileKey || !nodeId) {
653
+ process.stderr.write(
654
+ "usage: design-bindings-probe.mjs --file <key> --node <id> [--json] [--require color,spacing,radius] [--min 100]\n" +
655
+ "note: --node must be the SUBTREE you are implementing, not the enclosing screen frame.\n"
656
+ );
657
+ return 2;
658
+ }
659
+
660
+ const fetched = await fetchNode(fileKey, nodeId, env.FIGMA_ACCESS_TOKEN);
661
+ if (fetched.error) {
662
+ process.stderr.write(`design-bindings probe: ${fetched.error}\n`);
663
+ return 2;
664
+ }
665
+
666
+ const result = {
667
+ ...evaluateSubtree({
668
+ document: fetched.document,
669
+ idMap,
670
+ component: args.get("component") ?? fetched.document.name,
671
+ namespaces: config.design?.tokens?.namespaces,
672
+ nameMap: config.design?.tokens?.nameMap,
673
+ required: args
674
+ .get("require")
675
+ ?.split(",")
676
+ .map(part => part.trim()),
677
+ min: args.has("min") ? Number(args.get("min")) : undefined,
678
+ }),
679
+ fileKey,
680
+ nodeId,
681
+ };
682
+
683
+ process.stdout.write(
684
+ args.get("json") === "true"
685
+ ? `${JSON.stringify(result, null, 2)}\n`
686
+ : `${renderReport(result)}\n`
687
+ );
688
+ return result.verdict === "PASS" ? 0 : 1;
689
+ }
690
+
691
+ /**
692
+ * True when `moduleUrl` names the module node was asked to run.
693
+ *
694
+ * Both sides are realpath'd. Reached through a symlinked checkout, a git
695
+ * worktree, or a `/tmp` path on macOS the naive comparisons disagree, the body
696
+ * never runs, and the process exits 0 having done nothing — and every
697
+ * Lisa-driven agent runs in a worktree, so that is the routine path.
698
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
699
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
700
+ * @returns {boolean} Whether the caller should run its CLI body.
701
+ */
702
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
703
+ if (!argv1) return false;
704
+ try {
705
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
710
+
711
+ if (invokedAsScript(import.meta.url)) {
712
+ process.exit(await runCli(process.argv.slice(2)));
713
+ }