@hegemonart/get-design-done 1.30.5 → 1.31.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +5 -2
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +22 -1
  5. package/SKILL.md +1 -0
  6. package/agents/design-integration-checker.md +1 -1
  7. package/agents/design-planner.md +1 -1
  8. package/agents/gdd-graph-refresh.md +90 -0
  9. package/bin/gdd-graph +261 -0
  10. package/connections/connections.md +10 -9
  11. package/connections/graphify.md +65 -54
  12. package/package.json +8 -3
  13. package/reference/capability-gap-stage-gate.md +7 -4
  14. package/reference/model-tiers.md +2 -2
  15. package/reference/start-interview.md +1 -1
  16. package/scripts/detect-stale-refs.cjs +6 -0
  17. package/scripts/lib/figma-extract/digest.cjs +430 -0
  18. package/scripts/lib/figma-extract/parse-url.cjs +87 -0
  19. package/scripts/lib/figma-extract/payload-schema.json +108 -0
  20. package/scripts/lib/figma-extract/pull.cjs +394 -0
  21. package/scripts/lib/figma-extract/receiver.cjs +273 -0
  22. package/scripts/lib/figma-extract/render-md.cjs +143 -0
  23. package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
  24. package/scripts/lib/figma-extract/walk.cjs +100 -0
  25. package/scripts/lib/graph/atomic-write.mjs +68 -0
  26. package/scripts/lib/graph/build.mjs +124 -0
  27. package/scripts/lib/graph/diff.mjs +90 -0
  28. package/scripts/lib/graph/index.mjs +14 -0
  29. package/scripts/lib/graph/query.mjs +155 -0
  30. package/scripts/lib/graph/schema.json +69 -0
  31. package/scripts/lib/graph/schema.mjs +47 -0
  32. package/scripts/lib/graph/status.mjs +88 -0
  33. package/scripts/lib/graph/token-estimate.mjs +27 -0
  34. package/scripts/lib/graph/upsert.mjs +210 -0
  35. package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +89 -2
  36. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
  37. package/skills/connections/connections-onboarding.md +6 -6
  38. package/skills/figma-extract/SKILL.md +64 -0
  39. package/skills/graphify/SKILL.md +11 -10
  40. package/skills/health/SKILL.md +10 -0
  41. package/skills/scan/scan-procedure.md +9 -8
  42. package/agents/gdd-graphify-sync.md +0 -110
  43. /package/scripts/lib/{gsd-health-mirror → health-mirror}/index.d.cts +0 -0
@@ -0,0 +1,430 @@
1
+ 'use strict';
2
+ /**
3
+ * Plan 31-02 — productionized from spike 001 digest.mjs (orchestration + token extraction).
4
+ *
5
+ * DIGEST stage of the two-stage pipeline (decision D-01: extract → digest stay
6
+ * separated). This module reads ONLY the raw/ cache that pull.cjs (31-01) wrote;
7
+ * it performs ZERO network calls, so it can re-run against an existing cache
8
+ * without re-pulling (idempotent / off-line).
9
+ *
10
+ * Three-path token assembly (decision D-04):
11
+ * Path A — Variables API body (rawDir/variables.json without the plugin marker)
12
+ * Path B — styles resolver (rawDir/styles.json) — pluggable seam; 31-03 ships
13
+ * the real two-step /styles + /nodes?ids= resolver
14
+ * Path C — plugin sync (rawDir/variables.json WITH the receiver marker, written
15
+ * by 31-06's localhost receiver)
16
+ * Resolution priority on name collision: Variables > plugin sync > styles.
17
+ * The --prefer-styles escape inverts the chain to prefer styles.
18
+ *
19
+ * Pure CommonJS, no external deps, no network.
20
+ *
21
+ * Per-component slicing (decision D-08):
22
+ * digest({..., component}) — when `component` is a name or glob (e.g.
23
+ * 'Sample/Button', 'Sample/*', 'Form/Butt?'), the digest renders ONLY
24
+ * the matching component(s) — a ~500-token slice instead of the full
25
+ * ~16K digest. The filter is strictly ADDITIVE: omitting `component`
26
+ * reproduces 31-02's full-digest behavior unchanged. Glob support is a
27
+ * few lines of in-file string work (no external glob dependency).
28
+ *
29
+ * Exports:
30
+ * digest(opts) — async orchestrator (reads raw/, writes digest/)
31
+ * assembleTokens(opts) — pure three-path merge by priority
32
+ * DEFAULT_TOKEN_PRIORITY — ['variables','plugin','styles'] (D-04)
33
+ * globToRegExp(pattern) — minimal glob→RegExp (D-08 component filter)
34
+ */
35
+
36
+ const fs = require('node:fs/promises');
37
+ const path = require('node:path');
38
+ const { collectComponents } = require('./walk.cjs');
39
+ const { renderDesignMd } = require('./render-md.cjs');
40
+
41
+ // D-04: Variables > plugin sync > styles.
42
+ const DEFAULT_TOKEN_PRIORITY = ['variables', 'plugin', 'styles'];
43
+
44
+ // Receiver-written payload marker (31-06 contract). A rawDir/variables.json that
45
+ // carries this top-level field is the plugin's Path-C payload, NOT the Figma
46
+ // Variables API body.
47
+ const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
48
+
49
+ // ── component filter (D-08) ────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Translate a minimal glob pattern into an anchored, case-sensitive RegExp.
53
+ *
54
+ * Supported wildcards (the only two the acceptance criterion needs):
55
+ * `*` → `.*` (zero or more of any char)
56
+ * `?` → `.` (exactly one char)
57
+ * Every other character — including regex metacharacters like `.`/`/`/`(`/`+`
58
+ * and the Figma `Sample/Path/Name` separators — is treated LITERALLY. We escape
59
+ * the whole pattern first, then re-activate the escaped `\*`/`\?` placeholders,
60
+ * so e.g. the `.` in 'Sample.Icon' is literal and does NOT over-match 'Sample/Icon'.
61
+ *
62
+ * Matching is exact (anchored `^...$`) and case-sensitive — Figma component
63
+ * names are case-significant, and an exact name with no wildcard must match only
64
+ * that one component.
65
+ *
66
+ * @param {string} pattern a component name or glob (e.g. 'Button*', 'Form/*')
67
+ * @returns {RegExp}
68
+ */
69
+ function globToRegExp(pattern) {
70
+ // Escape ALL regex metacharacters (incl. * and ? for now).
71
+ const escaped = String(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ // Re-activate the wildcards: escaped '\*' → '.*', escaped '\?' → '.'.
73
+ const body = escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
74
+ return new RegExp(`^${body}$`);
75
+ }
76
+
77
+ /**
78
+ * Collect the set of token names that are RELEVANT to a set of components, so a
79
+ * slice can carry just those tokens instead of the full ~hundreds-of-tokens
80
+ * catalog (which would blow past the ~500-token budget). Relevance = a token
81
+ * whose name is referenced by a component's prop default/options, variant name,
82
+ * or the component name itself. When nothing is determinable we return an empty
83
+ * set and the slice renders component shape only — keeping the slice bounded
84
+ * regardless of catalog size (D-08 size guarantee).
85
+ *
86
+ * @param {Array} matched matched component entries (from collectComponents)
87
+ * @param {Array} tokens the full assembled token list
88
+ * @returns {Array} the subset of `tokens` referenced by the matched components
89
+ */
90
+ function tokensForComponents(matched, tokens) {
91
+ if (!Array.isArray(tokens) || tokens.length === 0) return [];
92
+ // Build a haystack of strings drawn from the matched components.
93
+ const haystack = [];
94
+ for (const c of matched) {
95
+ if (c.name) haystack.push(c.name);
96
+ for (const v of c.variants || []) haystack.push(v);
97
+ for (const p of c.props || []) {
98
+ if (p.name) haystack.push(p.name);
99
+ if (p.default !== undefined) haystack.push(String(p.default));
100
+ for (const o of p.options || []) haystack.push(String(o));
101
+ }
102
+ }
103
+ const blob = haystack.join('\n');
104
+ return tokens.filter((t) => t && t.name !== undefined && blob.includes(t.name));
105
+ }
106
+
107
+ // ── helpers (Path A) ─────────────────────────────────────────────────────────
108
+
109
+ /** Convert a Figma {r,g,b,a?} (0..1 floats) colour to a hex string. */
110
+ function rgbToHex({ r, g, b, a }) {
111
+ const to = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
112
+ const hex = `#${to(r)}${to(g)}${to(b)}`;
113
+ return a !== undefined && a < 1 ? `${hex}${to(a)}` : hex;
114
+ }
115
+
116
+ /**
117
+ * Path A — extract tokens from a Figma Variables API body
118
+ * (`/v1/files/:key/variables/local`). Mirrors the spike's extractTokensFromVariables.
119
+ * @param {object|null} vars the Variables API response (has .meta.{variables,variableCollections})
120
+ * @returns {Array<{name,type,collection?,modes?}>}
121
+ */
122
+ function extractTokensFromVariables(vars) {
123
+ if (!vars || !vars.meta) return [];
124
+ const collections = vars.meta.variableCollections || {};
125
+ const variables = vars.meta.variables || {};
126
+ const tokens = [];
127
+ for (const v of Object.values(variables)) {
128
+ const collection = collections[v.variableCollectionId];
129
+ const modes = collection?.modes || [];
130
+ const valuesByMode = {};
131
+ for (const mode of modes) {
132
+ const raw = v.valuesByMode?.[mode.modeId];
133
+ if (raw && typeof raw === 'object' && 'r' in raw) {
134
+ valuesByMode[mode.name] = rgbToHex(raw);
135
+ } else if (raw && raw.type === 'VARIABLE_ALIAS') {
136
+ valuesByMode[mode.name] = `{${variables[raw.id]?.name || raw.id}}`;
137
+ } else {
138
+ valuesByMode[mode.name] = raw;
139
+ }
140
+ }
141
+ tokens.push({
142
+ name: v.name,
143
+ type: v.resolvedType,
144
+ collection: collection?.name,
145
+ modes: valuesByMode,
146
+ });
147
+ }
148
+ return tokens;
149
+ }
150
+
151
+ /**
152
+ * Path C — normalize a receiver-written plugin payload into the common token
153
+ * shape. The plugin (D-13) emits ALL local variables; we accept either a
154
+ * pre-shaped `tokens[]` array or the raw `variables`/`meta` form and pass it
155
+ * through extractTokensFromVariables when needed.
156
+ * @param {object|null} payload variables.json carrying source:'gdd-plugin'
157
+ * @returns {Array}
158
+ */
159
+ function normalizePluginPayload(payload) {
160
+ if (!payload) return [];
161
+ // Preferred shape: the plugin already emits a flat tokens[] array.
162
+ if (Array.isArray(payload.tokens)) return payload.tokens;
163
+ // Fallback: it carries a Variables-API-like body — reuse Path A extraction.
164
+ if (payload.meta) return extractTokensFromVariables(payload);
165
+ return [];
166
+ }
167
+
168
+ // ── three-path merge (D-04) ──────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Merge the three token sources by priority. On a NAME collision the
172
+ * higher-priority source wins.
173
+ *
174
+ * Implementation note: we iterate the priority chain HIGHEST-first and only set
175
+ * a name the first time we see it (skip-if-present), so the highest-priority
176
+ * source's entry is the one that survives.
177
+ *
178
+ * @param {object} opts
179
+ * @param {Array} [opts.variables] Path A tokens
180
+ * @param {Array} [opts.pluginVariables] Path C tokens
181
+ * @param {Array} [opts.styleTokens] Path B tokens
182
+ * @param {boolean} [opts.preferStyles] D-04 escape — move styles to the front
183
+ * @returns {Array} merged tokens (insertion order follows the priority chain)
184
+ */
185
+ function assembleTokens({ variables, pluginVariables, styleTokens, preferStyles } = {}) {
186
+ const bySource = {
187
+ variables: Array.isArray(variables) ? variables : [],
188
+ plugin: Array.isArray(pluginVariables) ? pluginVariables : [],
189
+ styles: Array.isArray(styleTokens) ? styleTokens : [],
190
+ };
191
+ const priority = preferStyles
192
+ ? ['styles', 'variables', 'plugin']
193
+ : DEFAULT_TOKEN_PRIORITY;
194
+
195
+ const merged = new Map();
196
+ for (const source of priority) {
197
+ for (const tok of bySource[source]) {
198
+ if (!tok || tok.name === undefined) continue;
199
+ if (!merged.has(tok.name)) merged.set(tok.name, tok);
200
+ }
201
+ }
202
+ return [...merged.values()];
203
+ }
204
+
205
+ // ── orchestrator ─────────────────────────────────────────────────────────────
206
+
207
+ /** Read+parse a JSON file from the raw cache; return null if absent/unreadable. */
208
+ async function readJson(rawDir, name) {
209
+ try {
210
+ const body = await fs.readFile(path.join(rawDir, `${name}.json`), 'utf8');
211
+ return JSON.parse(body);
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Run the digest: read raw/ cache → walk (variant rollup) → 3-path token
219
+ * assembly → render DESIGN.md + write tokens.json + components.json.
220
+ *
221
+ * @param {object} opts
222
+ * @param {string} opts.rawDir raw/ cache dir produced by pull.cjs (31-01) — REQUIRED
223
+ * @param {string} opts.outDir dir to write DESIGN.md/tokens.json/components.json — REQUIRED for writes
224
+ * @param {Function} [opts.stylesResolver] fn(file, styles) → styleTokens[] (Path B; 31-03 provides real impl)
225
+ * @param {boolean} [opts.preferStyles] D-04 escape hatch
226
+ * @param {string} [opts.fetchedAtOverride] deterministic provenance header for tests
227
+ * @param {string} [opts.component] D-08 — name or glob; when set, render a
228
+ * per-component SLICE (~500 tokens) of only
229
+ * the matching component(s). Additive: when
230
+ * absent, the full-digest path is unchanged.
231
+ * @returns {Promise<object>}
232
+ * full: { ok:true, counts, bytes, outDir }
233
+ * sliced: { ok:true, sliced:true, matched:[names], counts:{components,tokens}, bytes, outDir, note? }
234
+ * error: { ok:false, error }
235
+ */
236
+ async function digest({ rawDir, outDir, stylesResolver, preferStyles, fetchedAtOverride, component } = {}) {
237
+ if (!rawDir) {
238
+ return { ok: false, error: 'rawDir is required — run pull.cjs first' };
239
+ }
240
+
241
+ // (1) Required input — graceful guard (mirrors spike). NEVER throws.
242
+ const file = await readJson(rawDir, 'file');
243
+ if (!file) {
244
+ return { ok: false, error: 'raw/file.json not found — run pull.cjs first' };
245
+ }
246
+
247
+ // (2) Optional inputs.
248
+ const variablesRaw = await readJson(rawDir, 'variables');
249
+ const styles = await readJson(rawDir, 'styles');
250
+ const meta = await readJson(rawDir, '_meta');
251
+
252
+ // Distinguish Path A (Variables API body) from Path C (receiver plugin payload)
253
+ // by the receiver marker. Only one of the two is populated from variables.json.
254
+ let apiVariables = null;
255
+ let pluginPayload = null;
256
+ if (variablesRaw && variablesRaw.source === PLUGIN_PAYLOAD_MARKER) {
257
+ pluginPayload = variablesRaw; // Path C
258
+ } else {
259
+ apiVariables = variablesRaw; // Path A (may be null)
260
+ }
261
+
262
+ // (3) Components + widgets — variant rollup is default-on (D-02).
263
+ const { components, widgets } = collectComponents(file.document);
264
+
265
+ // (4) Three token paths.
266
+ const pathATokens = extractTokensFromVariables(apiVariables);
267
+ const styleTokens = stylesResolver ? await stylesResolver(file, styles) : [];
268
+ const pluginVariables = normalizePluginPayload(pluginPayload);
269
+
270
+ // (5) Merge by priority (D-04).
271
+ const tokens = assembleTokens({
272
+ variables: pathATokens,
273
+ pluginVariables,
274
+ styleTokens,
275
+ preferStyles,
276
+ });
277
+
278
+ // (6) Provenance — fetched_at is injectable for deterministic output.
279
+ const fileMeta = {
280
+ file_key: meta?.file_key,
281
+ fetched_at: fetchedAtOverride !== undefined ? fetchedAtOverride : meta?.fetched_at,
282
+ name: file.name,
283
+ };
284
+
285
+ // (6b) D-08 — per-component SLICE. When `component` is provided we short-circuit
286
+ // the full digest and render only the matching component(s) + their relevant
287
+ // tokens. This is additive: the block below is skipped entirely when `component`
288
+ // is undefined, so the full-digest path (step 7) stays byte-identical.
289
+ if (component !== undefined && component !== null && component !== '') {
290
+ const rx = globToRegExp(component);
291
+ const matched = components.filter((c) => rx.test(c.name));
292
+ // Only tokens referenced by the matched components — keeps the slice ~500
293
+ // tokens instead of dumping the whole catalog.
294
+ const sliceTokens = tokensForComponents(matched, tokens);
295
+ const sliceMd = renderDesignMd({
296
+ tokens: sliceTokens,
297
+ components: matched,
298
+ widgets: [], // a per-component slice omits page/widget noise
299
+ fileMeta,
300
+ });
301
+ if (outDir) {
302
+ await fs.mkdir(outDir, { recursive: true });
303
+ // Write the slice to DESIGN.md (the SKILL/e2e read whatever digest writes).
304
+ await fs.writeFile(path.join(outDir, 'DESIGN.md'), sliceMd);
305
+ }
306
+ const result = {
307
+ ok: true,
308
+ sliced: true,
309
+ matched: matched.map((c) => c.name),
310
+ counts: { components: matched.length, tokens: sliceTokens.length },
311
+ bytes: { designMd: Buffer.byteLength(sliceMd, 'utf8') },
312
+ outDir,
313
+ };
314
+ if (matched.length === 0) result.note = `no component matched ${component}`;
315
+ return result;
316
+ }
317
+
318
+ // (7) Render + write artifacts (D-09: digest/ is commit-able).
319
+ const designMd = renderDesignMd({ tokens, components, widgets, fileMeta });
320
+ const tokensJson = JSON.stringify(tokens, null, 2);
321
+ const componentsJson = JSON.stringify(components, null, 2);
322
+
323
+ if (outDir) {
324
+ await fs.mkdir(outDir, { recursive: true });
325
+ await fs.writeFile(path.join(outDir, 'DESIGN.md'), designMd);
326
+ await fs.writeFile(path.join(outDir, 'tokens.json'), tokensJson);
327
+ await fs.writeFile(path.join(outDir, 'components.json'), componentsJson);
328
+ }
329
+
330
+ return {
331
+ ok: true,
332
+ counts: {
333
+ tokens: tokens.length,
334
+ components: components.length,
335
+ widgets: widgets.length,
336
+ },
337
+ bytes: {
338
+ designMd: Buffer.byteLength(designMd, 'utf8'),
339
+ tokensJson: Buffer.byteLength(tokensJson, 'utf8'),
340
+ componentsJson: Buffer.byteLength(componentsJson, 'utf8'),
341
+ },
342
+ outDir,
343
+ };
344
+ }
345
+
346
+ module.exports = {
347
+ digest,
348
+ assembleTokens,
349
+ DEFAULT_TOKEN_PRIORITY,
350
+ // exported for unit reuse / downstream (31-08 --component, 31-03 normalization parity)
351
+ extractTokensFromVariables,
352
+ normalizePluginPayload,
353
+ PLUGIN_PAYLOAD_MARKER,
354
+ // D-08 component filter — exported for unit reuse / downstream slicing tools.
355
+ globToRegExp,
356
+ tokensForComponents,
357
+ };
358
+
359
+ // ── CLI entry (31-08) ──────────────────────────────────────────────────────────
360
+ //
361
+ // Thin argv wrapper invoked by the figma-extract SKILL (31-07) and the e2e test
362
+ // (31-10). The callable API (digest/assembleTokens/…) above is the import surface;
363
+ // this block runs ONLY when the file is executed directly. Flag → option map
364
+ // (contract from 31-02 SUMMARY, extended with --component for D-08):
365
+ //
366
+ // --raw <dir> rawDir (required) raw cache dir written by pull.cjs
367
+ // --out <dir> outDir (required) digest artifact output dir
368
+ // --prefer-styles preferStyles:true D-04 escape — styles-first priority
369
+ // --component <name> component D-08 per-component slice (name or glob)
370
+ //
371
+ // D-10: this block NEVER reads, logs, or persists FIGMA_TOKEN — digest is offline
372
+ // and token-free by construction; the CLI only echoes counts/paths.
373
+
374
+ /** Minimal flag parser for the four supported options (no external dep). */
375
+ function parseArgv(argv) {
376
+ const opts = {};
377
+ for (let i = 0; i < argv.length; i++) {
378
+ const a = argv[i];
379
+ if (a === '--raw') opts.rawDir = argv[++i];
380
+ else if (a === '--out') opts.outDir = argv[++i];
381
+ else if (a === '--prefer-styles') opts.preferStyles = true;
382
+ else if (a === '--component') opts.component = argv[++i];
383
+ else if (a === '--help' || a === '-h') opts.help = true;
384
+ }
385
+ return opts;
386
+ }
387
+
388
+ const CLI_USAGE =
389
+ 'Usage: node digest.cjs --raw <dir> --out <dir> [--prefer-styles] [--component <name|glob>]';
390
+
391
+ if (require.main === module) {
392
+ (async () => {
393
+ const opts = parseArgv(process.argv.slice(2));
394
+ if (opts.help) {
395
+ process.stdout.write(`${CLI_USAGE}\n`);
396
+ return;
397
+ }
398
+ if (!opts.rawDir || !opts.outDir) {
399
+ process.stderr.write(`${CLI_USAGE}\n--raw and --out are required.\n`);
400
+ process.exitCode = 2;
401
+ return;
402
+ }
403
+ const res = await digest({
404
+ rawDir: opts.rawDir,
405
+ outDir: opts.outDir,
406
+ preferStyles: opts.preferStyles,
407
+ component: opts.component,
408
+ });
409
+ if (!res.ok) {
410
+ process.stderr.write(`digest failed: ${res.error}\n`);
411
+ process.exitCode = 1;
412
+ return;
413
+ }
414
+ if (res.sliced) {
415
+ const summary =
416
+ res.matched.length > 0
417
+ ? `sliced ${res.counts.components} component(s): ${res.matched.join(', ')} (${res.counts.tokens} tokens) → ${res.outDir}`
418
+ : `${res.note} → ${res.outDir} (empty slice)`;
419
+ process.stdout.write(`${summary}\n`);
420
+ } else {
421
+ process.stdout.write(
422
+ `digest ok: ${res.counts.components} components, ${res.counts.tokens} tokens, ${res.counts.widgets} widgets → ${res.outDir}\n`
423
+ );
424
+ }
425
+ })().catch((err) => {
426
+ // Never leak a token; surface only the message.
427
+ process.stderr.write(`digest error: ${err && err.message ? err.message : err}\n`);
428
+ process.exitCode = 1;
429
+ });
430
+ }
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ // scripts/lib/figma-extract/parse-url.cjs — Plan 31-01 (Wave A.1)
3
+ //
4
+ // Figma file URL/key parser. Accepts either a bare file key or a full Figma
5
+ // file URL and returns the canonical file key. Both legacy `/file/<key>/...`
6
+ // and newer `/design/<key>/...` URL forms are supported.
7
+ //
8
+ // CommonJS, zero external dependencies. Pure function — no I/O, no logging.
9
+ //
10
+ // Examples:
11
+ // parseFigmaFileKey('IAHNrYoqIh56SCxgv3PjCS')
12
+ // → 'IAHNrYoqIh56SCxgv3PjCS' (bare-key passthrough)
13
+ // parseFigmaFileKey('https://www.figma.com/file/IAHNrYoqIh56SCxgv3PjCS/My-DS?node-id=0-1')
14
+ // → 'IAHNrYoqIh56SCxgv3PjCS'
15
+ // parseFigmaFileKey('https://www.figma.com/design/IAHNrYoqIh56SCxgv3PjCS/My-DS')
16
+ // → 'IAHNrYoqIh56SCxgv3PjCS'
17
+ // parseFigmaFileKey('') → throws TypeError
18
+ // parseFigmaFileKey('https://example.com/no-key') → throws Error
19
+
20
+ // Matches the key segment after `/file/` or `/design/` in a Figma URL.
21
+ // Figma file keys are URL-safe base62-ish tokens ([A-Za-z0-9]).
22
+ const URL_KEY_RE = /(?:file|design)\/([A-Za-z0-9]+)/;
23
+
24
+ /**
25
+ * Resolve a Figma file key from a bare key or a full Figma file URL.
26
+ *
27
+ * @param {string} input - bare key | https://www.figma.com/file/<key>/... | .../design/<key>/...
28
+ * @returns {string} the extracted file key
29
+ * @throws {TypeError} when input is missing / empty / not a string
30
+ * @throws {Error} when input looks like a URL but no file key can be extracted
31
+ */
32
+ function parseFigmaFileKey(input) {
33
+ if (typeof input !== 'string') {
34
+ throw new TypeError('parseFigmaFileKey: non-empty input (file key or URL) required');
35
+ }
36
+ const trimmed = input.trim();
37
+ if (trimmed === '') {
38
+ throw new TypeError('parseFigmaFileKey: non-empty input (file key or URL) required');
39
+ }
40
+
41
+ // URL form carrying an http(s) scheme: validate the HOST is figma.com via the
42
+ // URL parser — NOT a substring check. A substring test (`includes('figma.com')`)
43
+ // is unsafe: `https://figma.com.evil.test/...` and `https://evil.test/figma.com`
44
+ // both contain the literal but are not Figma (CodeQL js/incomplete-url-substring-
45
+ // sanitization). new URL().hostname gives the real authority to compare exactly.
46
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
47
+ if (hasScheme) {
48
+ let host;
49
+ try {
50
+ host = new URL(trimmed).hostname.toLowerCase();
51
+ } catch {
52
+ throw new Error('parseFigmaFileKey: malformed URL: ' + trimmed);
53
+ }
54
+ const isFigmaHost =
55
+ host === 'figma.com' || host === 'www.figma.com' || host.endsWith('.figma.com');
56
+ if (!isFigmaHost) {
57
+ throw new Error('parseFigmaFileKey: not a figma.com URL: ' + trimmed);
58
+ }
59
+ const match = trimmed.match(URL_KEY_RE);
60
+ if (!match) {
61
+ throw new Error(
62
+ 'parseFigmaFileKey: could not extract a Figma file key from URL: ' + trimmed
63
+ );
64
+ }
65
+ return match[1];
66
+ }
67
+
68
+ // Scheme-less path form (e.g. `www.figma.com/file/<key>` or `/design/<key>/...`):
69
+ // there is no parseable authority, so extract the key from the path segment.
70
+ if (trimmed.includes('/')) {
71
+ const match = trimmed.match(URL_KEY_RE);
72
+ if (!match) {
73
+ throw new Error(
74
+ 'parseFigmaFileKey: could not extract a Figma file key from URL: ' + trimmed
75
+ );
76
+ }
77
+ return match[1];
78
+ }
79
+
80
+ // Bare key — a single URL-safe base62-ish token (no scheme, no path).
81
+ if (!/^[A-Za-z0-9]+$/.test(trimmed)) {
82
+ throw new Error('parseFigmaFileKey: invalid Figma file key: ' + trimmed);
83
+ }
84
+ return trimmed;
85
+ }
86
+
87
+ module.exports = { parseFigmaFileKey };
@@ -0,0 +1,108 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://get-design-done/figma-extract/payload-schema.json",
4
+ "title": "GDD Sync plugin variables payload (Path C contract — D-04, D-13)",
5
+ "description": "The JSON body the Figma plugin 'GDD Sync' (31-05) POSTs to the localhost receiver (31-06) at 127.0.0.1:5179/variables. Carries ALL local variables (D-13); filtering happens later in digest.cjs (31-02). The top-level `source` const is the marker digest.cjs keys on to route this file to Path C. This schema is the source of truth that figma-plugin/src/payload-schema.ts mirrors.",
6
+ "type": "object",
7
+ "required": ["source", "collections", "variables"],
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "source": {
11
+ "const": "gdd-plugin",
12
+ "description": "Path C marker. digest.cjs (31-02) routes a variables.json carrying this top-level field to the plugin path (NOT the Variables-API path)."
13
+ },
14
+ "fileKey": {
15
+ "type": "string",
16
+ "description": "Optional Figma file key the variables were read from (provenance only)."
17
+ },
18
+ "exportedAt": {
19
+ "type": "string",
20
+ "description": "Optional ISO-8601 timestamp of when the plugin exported the payload."
21
+ },
22
+ "collections": {
23
+ "type": "array",
24
+ "description": "Local variable collections. Each carries its modes so digest can label valuesByMode.",
25
+ "items": {
26
+ "type": "object",
27
+ "required": ["id", "name", "modes"],
28
+ "additionalProperties": true,
29
+ "properties": {
30
+ "id": { "type": "string" },
31
+ "name": { "type": "string" },
32
+ "modes": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "required": ["modeId", "name"],
37
+ "additionalProperties": true,
38
+ "properties": {
39
+ "modeId": { "type": "string" },
40
+ "name": { "type": "string" }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ },
47
+ "variables": {
48
+ "type": "array",
49
+ "description": "ALL local variables (D-13 — published flag is NOT required; digest filters). Each value per mode is either a resolved primitive/color OR an alias reference.",
50
+ "items": {
51
+ "type": "object",
52
+ "required": ["id", "name", "resolvedType", "collectionId", "valuesByMode"],
53
+ "additionalProperties": true,
54
+ "properties": {
55
+ "id": { "type": "string" },
56
+ "name": { "type": "string" },
57
+ "resolvedType": {
58
+ "type": "string",
59
+ "enum": ["COLOR", "FLOAT", "STRING", "BOOLEAN"]
60
+ },
61
+ "collectionId": { "type": "string" },
62
+ "valuesByMode": {
63
+ "type": "object",
64
+ "description": "Keyed by modeId. A value is a resolved primitive (number/string/boolean), a resolved color object {r,g,b,a?}, or an alias marker ({type:'VARIABLE_ALIAS', id} or {alias:<name>}).",
65
+ "additionalProperties": {
66
+ "oneOf": [
67
+ { "type": "number" },
68
+ { "type": "string" },
69
+ { "type": "boolean" },
70
+ {
71
+ "type": "object",
72
+ "description": "Resolved color (Figma 0..1 floats).",
73
+ "required": ["r", "g", "b"],
74
+ "additionalProperties": true,
75
+ "properties": {
76
+ "r": { "type": "number" },
77
+ "g": { "type": "number" },
78
+ "b": { "type": "number" },
79
+ "a": { "type": "number" }
80
+ }
81
+ },
82
+ {
83
+ "type": "object",
84
+ "description": "Alias reference to another variable id.",
85
+ "required": ["type", "id"],
86
+ "additionalProperties": true,
87
+ "properties": {
88
+ "type": { "const": "VARIABLE_ALIAS" },
89
+ "id": { "type": "string" }
90
+ }
91
+ },
92
+ {
93
+ "type": "object",
94
+ "description": "Alias reference by name.",
95
+ "required": ["alias"],
96
+ "additionalProperties": true,
97
+ "properties": {
98
+ "alias": { "type": "string" }
99
+ }
100
+ }
101
+ ]
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }