@nodii/dev 0.6.14 → 0.6.15

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/README.md CHANGED
@@ -327,6 +327,80 @@ nodii-dev harness check [--json] # 0 current, 1 differs from Dev,
327
327
  nodii-dev harness update --check # the same check
328
328
  ```
329
329
 
330
+ ### DS conformance: `design roles` and `design check` (DEC-0034)
331
+
332
+ Every UI role names exactly one DS component (MW6-14). Resolve your brief's
333
+ roles before drawing, and run the submit gate's conformance check yourself
334
+ before `figma submit`:
335
+
336
+ ```bash
337
+ nodii-dev design roles [ROLE ...] # role, component (and variant), entry, DS file + node; flags unknown roles
338
+ nodii-dev design check FILE_KEY|LINK [--nodes 12:34,12:56] [--roles decision.approve,data.table] [--library] [--json]
339
+ ```
340
+
341
+ `design check` reads the nodes from Figma's REST API with `FIGMA_PAT` (or
342
+ `FIGMA_TOKEN`) and the role map from Dev (`listDesignRoles`). It then reads
343
+ each instance's main the way Dev's submit gate does, so both reach one verdict:
344
+
345
+ - A local component is read from the file.
346
+ - A library component is read through the DS file's published listing, from
347
+ the DS file as it is now. The DS file is the first file the role map names,
348
+ or else the first file registered in Dev as `design-system`.
349
+ - A component from another library, or one the DS hasn't published, isn't
350
+ read, and nothing under it is flagged.
351
+
352
+ It runs the conformance module Dev's submit runs, vendored unchanged in
353
+ `src/vendor/ds-conformance.mjs`. No `--nodes` checks every page; a link's
354
+ `node-id` counts as one.
355
+
356
+ A file registered in Dev as `design-system` checks as the library, so its own
357
+ components are not stand-ins. `--library` forces that for another file, such as
358
+ a branch of the DS file.
359
+
360
+ It lists each finding (node id, check, detail): `roleMismatch`,
361
+ `instanceOverride`, `notFromLibrary`, `otherPersonRevealed`,
362
+ `tableHeaderFill`.
363
+
364
+ - Overrides equal to the main's are listed as stale and never fail
365
+ (DEC-0035).
366
+ - Nodes behind a library republish (the same value through a newer variable)
367
+ are listed as `libraryUpdatePending` and never fail: accept the library
368
+ update in that file (MW6-17).
369
+ - Exit 1 with `DS_CONFORMANCE_FAILED` means Dev's submit would refuse the
370
+ same nodes. An error that stops the check (a Figma read, Dev) also exits 1,
371
+ with its own code. With `--json`, read `data.passed`. Note that `design check` reads the file as it is now, while the
372
+ submit reads the `figmaVersion` you send.
373
+ - A node the file doesn't have is a `FIGMA_NODE_MISSING` finding; the
374
+ other nodes are still checked.
375
+ - A whole page that Figma can't serve in time fails `FIGMA_TIMEOUT`: name
376
+ fewer nodes with `--nodes`.
377
+ - A `FIGMA_PAT` that can't read the DS file fails `FIGMA_FORBIDDEN`, rather
378
+ than passing with the library overrides unchecked.
379
+
380
+ The submit-time guards need the change set's images and versions, so only Dev
381
+ runs them. `IMAGE_NOT_TRUECOLOR` (a palette or quantized review PNG) refuses.
382
+ `STALE_FIGMA_VERSION` (a version older than the lane's last logged write) is a
383
+ warning, never a refusal. Ask Dev for a dry run of its whole gate, which
384
+ changes no state:
385
+
386
+ ```bash
387
+ nodii-dev design check --change-set CS-123 [--file SUBMIT.json] [--figma-version V] [--json]
388
+ ```
389
+
390
+ With no `--file`, Dev checks the set's current items at its last submitted
391
+ version. A set never submitted needs `--file`: the body `figma submit --file`
392
+ would send. A cold run can outlast the edge's gateway; the CLI then says
393
+ `DEV_TIMEOUT` and to run it again (Dev keeps the mains it read for 5 minutes).
394
+
395
+ When `figma submit`, `figma resubmit` or the dry run hits
396
+ `DS_CONFORMANCE_FAILED`, the CLI prints each finding and Dev's warnings. A
397
+ submit Dev accepts prints its warnings too. On a stale version it also prints
398
+ the version's time, the lane's last write, and either "retry after <time>, or
399
+ ask the coordinator to have the operator File > Save to version history", or
400
+ the newer version to submit with. `--json` keeps Dev's fields: `warnings` (on
401
+ an accepted submit too), and, on a refusal or a dry run, `staleVersion` (`figmaVersion`, `figmaVersionAt`, `latestVersion`,
402
+ `latestVersionAt`, `lastWriteAt`, `retryAfter`).
403
+
330
404
  ### Moving an issue: `issues transition`
331
405
 
332
406
  `issues transition` moves an issue along Dev's status map (MW1-16) with
package/dist/cli.js CHANGED
@@ -46,10 +46,12 @@ import {
46
46
  isChangeSetId,
47
47
  scopeText,
48
48
  scopesFrom,
49
+ conformanceLines,
49
50
  } from "./figma.js";
50
51
  export { figmaLock } from "./figma.js";
51
52
  import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
52
53
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
54
+ import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
53
55
  import {
54
56
  ALIASES,
55
57
  GROUPS,
@@ -90,6 +92,13 @@ Usage: nodii-dev <command> [options] [--json]
90
92
  design guideline list [--drafted] [--all] | show ENTRY
91
93
  design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]
92
94
  design changeset list [--entry KEY] [--state STATE] | show ID
95
+ design roles [ROLE ...] # the role map: each UI role's one DS component (MW6-14)
96
+ design check FILE_KEY|LINK [--nodes A,B] [--roles R,S] [--library]
97
+ # the DS conformance check Dev's submit runs, before you submit;
98
+ # reads Figma with FIGMA_PAT; exit 1 = DS_CONFORMANCE_FAILED or an error (--json: data.passed)
99
+ design check --change-set CS [--file SUBMIT.json] [--figma-version V]
100
+ # Dev's dry run of the submit gate on a set, with the image guard;
101
+ # a stale version is a warning that prints when to retry
93
102
  figma lock FILE_KEY --title TEXT [--scope KIND:ID ...]
94
103
  # scopes: component:NODE page:NODE foundations create:PAGE:NAME;
95
104
  # no --scope locks the whole file (DEC-0032)
@@ -416,6 +425,18 @@ export async function main(argv = process.argv.slice(2)) {
416
425
  // Guideline change sets: approving or rejecting one is a person's action in Dev.
417
426
  if (command === "design" && action === "guideline") return emitCommand(await guidelineCommand(rest[0], rest.slice(1), f));
418
427
  if (command === "design" && action === "changeset") return emitCommand(await changeSetCommand(rest[0], rest.slice(1), f));
428
+ if (command === "design" && action === "roles") return emit(await designRoles(rest), json, designRolesText);
429
+ if (command === "design" && action === "check") {
430
+ if ((!rest[0] && f["change-set"] === undefined) || rest.length > 1)
431
+ throw new CliError("USAGE", "design check FILE_KEY|LINK [--nodes A,B] [--roles R,S] [--library] | design check --change-set CS [--file SUBMIT.json] [--figma-version V]", 2);
432
+ const result = await designCheck(rest[0], f);
433
+ emit(result.payload, json, result.human ?? designCheckText);
434
+ if (result.exitCode) {
435
+ if (!json) console.error(`DS_CONFORMANCE_FAILED: ${result.payload.data.findings.length} finding(s)`);
436
+ process.exitCode = result.exitCode;
437
+ }
438
+ return;
439
+ }
419
440
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
420
441
  if (command === "next") return emitCommand(await nextCommand(action, f));
421
442
  if (command === "built") return emitCommand(await builtCommand(action, rest, f));
@@ -624,7 +645,9 @@ export async function main(argv = process.argv.slice(2)) {
624
645
  return emit(await figmaFilePublished(rest[0], f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") }), json, (p) => figmaFilePublishedText(p.data));
625
646
  if (action === "submit") {
626
647
  const payload = f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") };
627
- return emit(await figmaSubmit(rest[0], payload), json);
648
+ // A 200 carries top-level warnings (STALE_FIGMA_VERSION): print them.
649
+ return emit(await figmaSubmit(rest[0], payload), json, (p) =>
650
+ [JSON.stringify(p.data, null, 2), ...conformanceLines({ warnings: p.warnings })].join("\n"));
628
651
  }
629
652
  if (action === "file") {
630
653
  if (rest[0] !== "add") throw new CliError("USAGE", "figma file add FILE_KEY --name TEXT --kind KIND [--module M]", 2);
@@ -664,7 +687,7 @@ export async function main(argv = process.argv.slice(2)) {
664
687
  return emit(await figmaWithdraw(rest[0]), json, (p) => `Withdrew ${rest[0]}; it is back in ${p.data?.state ?? "editing"}.`);
665
688
  if (action === "resubmit")
666
689
  return emit(await figmaResubmit(rest[0], rest[1]), json, (p) =>
667
- [`Re-submitted ${rest[0]}; it is ${p.data.state}.`, figmaChangeSetText(p.data)].join("\n"));
690
+ [`Re-submitted ${rest[0]}; it is ${p.data.state}.`, figmaChangeSetText(p.data), ...conformanceLines({ warnings: p.warnings })].join("\n"));
668
691
  if (action === "changeset") {
669
692
  if (rest[0] !== "show" || !rest[1] || rest.length > 2) throw new CliError("USAGE", "figma changeset show CHANGE_SET", 2);
670
693
  return emit(await figmaChangeSetShow(rest[1]), json, (p) => figmaChangeSetText(p.data));
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.14";
7
+ export const VERSION = "0.6.15";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -184,7 +184,7 @@ export async function checkLogin() {
184
184
  export const encode = (value) => encodeURIComponent(value);
185
185
  // Switches never take the next token as their value, so
186
186
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
187
- const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted"]);
187
+ const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library"]);
188
188
  export function parseArgs(argv) {
189
189
  const flags = {};
190
190
  const args = [];
package/dist/design.js ADDED
@@ -0,0 +1,306 @@
1
+ // `nodii-dev design roles` and `design check` (DEC-0034, MW6-14 to MW6-17;
2
+ // Dev issue 93). `roles` reads Dev's role map: each UI role names exactly
3
+ // one DS component, and a lane resolves its brief's roles through it before
4
+ // drawing. `check` runs the DS conformance check on a Figma file before a
5
+ // submit: the same check Dev's submit gate runs (Dev issue 92), from the
6
+ // module DESIGN-SYSTEM-60 owns, vendored unchanged in ./vendor. This file only
7
+ // fetches what the check reads: the nodes, the role map and each instance's
8
+ // library main.
9
+ import { readFile } from "node:fs/promises";
10
+ import { CliError, devRequest } from "./core.js";
11
+ import { conformanceLines, figmaConformance, figmaFileKey } from "./figma.js";
12
+ import { checkConformance, neededMains, VERSION as CHECK_VERSION } from "./vendor/ds-conformance.mjs";
13
+
14
+ const FIGMA_API = "https://api.figma.com/v1";
15
+ // The Figma nodes endpoint takes many ids per call; keep URLs well short of limits.
16
+ const IDS_PER_CALL = 50;
17
+ // Dev's gate stops after six rounds of mains; so does the check here.
18
+ const MAX_MAIN_ROUNDS = 6;
19
+
20
+ /** GET /design/roles (listDesignRoles): { data: [{ role, summary, labels, entryKey, componentName, fileKey, nodeId, componentKey, variant }], total }. */
21
+ export async function designRoles(roles = [], { request = devRequest } = {}) {
22
+ const payload = await roleMap(request);
23
+ if (!roles.length) return payload;
24
+ const rows = payload.data ?? [];
25
+ const unknown = roles.filter((role) => !rows.some((x) => x.role === role));
26
+ return { ...payload, data: rows.filter((x) => roles.includes(x.role)), total: undefined, unknown };
27
+ }
28
+
29
+ /** listDesignRoles; a Dev or edge without the route says which issue brings it. */
30
+ async function roleMap(request) {
31
+ try {
32
+ return await request("GET", "/api/v1/design/roles");
33
+ } catch (error) {
34
+ // The edge answers an unknown route `unroutable`; an older Dev, HTTP 404.
35
+ if (error instanceof CliError && ["unroutable", "HTTP_404", "NOT_FOUND", "ROUTE_NOT_FOUND"].includes(error.code))
36
+ throw new CliError("ROLE_MAP_UNAVAILABLE", `Dev doesn't serve the role map yet (GET /design/roles, Dev issue 92): ${error.code}: ${error.message}`, 1, error.details);
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ // A role whose guideline names no library component yet (MW6-10).
42
+ const blocked = (x) => !x.nodeId || !x.componentName;
43
+
44
+ // A role played by some variants of a set names them: " [Kind=Approve]".
45
+ const variantText = (v) => (v && Object.keys(v).length ? ` [${Object.entries(v).map(([k, x]) => `${k}=${x}`).join(", ")}]` : "");
46
+
47
+ export function designRolesText(p) {
48
+ const lines = (p.data ?? []).map((x) => blocked(x)
49
+ ? `${x.role}\tblocked: no DS component (MW6-10)\t${x.entryKey ?? ""}`
50
+ : `${x.role}\t${x.componentName}${variantText(x.variant)}\t${x.entryKey}\t${x.fileKey} ${x.nodeId}`);
51
+ if (!lines.length && !p.unknown?.length) lines.push("No roles: Dev's role map is empty (DS guidelines list no roles: yet).");
52
+ for (const role of p.unknown ?? []) lines.push(`UNKNOWN ${role}: no DS component plays this role; ask the DS before drawing it by hand`);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ /** The Figma token: FIGMA_PAT, as the design-system scripts read it, or FIGMA_TOKEN. Never printed. */
57
+ export function figmaToken(env = process.env) {
58
+ const token = env.FIGMA_PAT || env.FIGMA_TOKEN;
59
+ if (!token) throw new CliError("FIGMA_TOKEN_REQUIRED", "design check reads the file from Figma's REST API: set FIGMA_PAT to a Figma personal access token that can read the file.", 2);
60
+ return token;
61
+ }
62
+
63
+ /** A Figma REST GET, retried on 429 and 5xx. */
64
+ export function figmaClient(token, { fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), attempts = 6, timeoutMs = 120_000 } = {}) {
65
+ return async function figmaGet(path) {
66
+ for (let attempt = 1; ; attempt++) {
67
+ let response;
68
+ try {
69
+ response = await fetchImpl(`${FIGMA_API}${path}`, { headers: { "X-Figma-Token": token }, signal: AbortSignal.timeout(timeoutMs) });
70
+ // The clock runs while a large body streams in, too.
71
+ if (response.ok) return await response.json();
72
+ } catch (error) {
73
+ if (error instanceof SyntaxError) throw new CliError("FIGMA_ERROR", `Figma answered ${path.split("?")[0]} with a body that isn't JSON`);
74
+ // A read that runs out the clock is too large, and would again: say how to narrow it.
75
+ if (error?.name === "TimeoutError")
76
+ throw new CliError("FIGMA_TIMEOUT", `Figma took over ${timeoutMs / 1000}s for ${path.split("?")[0]}; check fewer nodes with --nodes`);
77
+ if (attempt >= attempts) throw new CliError("FIGMA_UNREACHABLE", `Cannot reach Figma: ${error.message}`);
78
+ await sleep(1000 * attempt);
79
+ continue;
80
+ }
81
+ const retry = response.status === 429 || response.status >= 500;
82
+ if (retry && attempt < attempts) {
83
+ const after = Number(response.headers?.get?.("retry-after"));
84
+ await sleep(Number.isFinite(after) && after > 0 ? Math.min(after, 60) * 1000 : 1000 * 2 ** attempt);
85
+ continue;
86
+ }
87
+ const code = { 404: "FIGMA_NOT_FOUND", 403: "FIGMA_FORBIDDEN", 429: "FIGMA_RATE_LIMITED" }[response.status] ?? "FIGMA_ERROR";
88
+ const hint = response.status === 400 ? " (a malformed node id, or a request too large? check the ids, or fewer nodes, with --nodes)" : "";
89
+ throw new CliError(code, `Figma answered ${response.status} for ${path.split("?")[0]}${hint}`, 1, { status: response.status });
90
+ }
91
+ };
92
+ }
93
+
94
+ const chunks = (list, size) => Array.from({ length: Math.ceil(list.length / size) }, (_, i) => list.slice(i * size, (i + 1) * size));
95
+
96
+ /** The /nodes `nodes` map for these ids (every page when none are given); a node Figma lacks is null. */
97
+ export async function fetchNodes(figmaGet, fileKey, nodeIds) {
98
+ let ids = nodeIds;
99
+ if (!ids.length) {
100
+ const file = await figmaGet(`/files/${encodeURIComponent(fileKey)}?depth=1`);
101
+ ids = (file.document?.children ?? []).map((page) => page.id);
102
+ }
103
+ const nodes = {};
104
+ // A whole page is a large document: fetch pages one per call.
105
+ for (const part of chunks(ids, nodeIds.length ? IDS_PER_CALL : 1)) {
106
+ const response = await figmaGet(`/files/${encodeURIComponent(fileKey)}/nodes?ids=${encodeURIComponent(part.join(","))}`);
107
+ for (const id of part) nodes[id] = response.nodes?.[id] ?? null;
108
+ }
109
+ return nodes;
110
+ }
111
+
112
+ /**
113
+ * The DS file's published components and sets, key <-> node id: the index
114
+ * Dev's gate resolves remote mains and role keys through.
115
+ */
116
+ export async function libraryIndex(figmaGet, dsFileKey) {
117
+ const file = encodeURIComponent(dsFileKey);
118
+ const [components, sets] = await Promise.all([figmaGet(`/files/${file}/components`), figmaGet(`/files/${file}/component_sets`)]);
119
+ const byKey = new Map();
120
+ const keyByNode = new Map();
121
+ for (const c of [...(components.meta?.components ?? []), ...(sets.meta?.component_sets ?? [])])
122
+ if (c.key) {
123
+ byKey.set(c.key, c.node_id);
124
+ keyByNode.set(c.node_id, c.key);
125
+ }
126
+ return { fileKey: dsFileKey, byKey, keyByNode };
127
+ }
128
+
129
+ /**
130
+ * The main of each instance, keyed by the instance's componentId (this file's
131
+ * id for it), resolved as Dev's gate resolves it (Dev issue 92), so both give
132
+ * one verdict. A local component (remote: false) is read from this file. A
133
+ * remote one is read only through the DS file's published listing, from the
134
+ * DS file as it is now. Anything else (another library, a key the DS hasn't
135
+ * published, no metadata) is null: the check then treats everything under
136
+ * that instance as the library's and flags nothing on a guess.
137
+ */
138
+ export async function fetchMains(figmaGet, fileKey, nodes, componentIds, ds = null) {
139
+ const meta = Object.assign({}, ...Object.values(nodes).map((n) => n?.components ?? {}));
140
+ const mains = Object.fromEntries(componentIds.map((id) => [id, null]));
141
+ const local = [];
142
+ const fromLibrary = new Map(); // DS node id -> this file's componentIds
143
+ for (const id of componentIds) {
144
+ const c = meta[id];
145
+ const libraryNode = c?.key ? ds?.byKey.get(c.key) : undefined;
146
+ if (c?.remote === false) local.push(id);
147
+ else if (c?.remote && libraryNode) fromLibrary.set(libraryNode, [...(fromLibrary.get(libraryNode) ?? []), id]);
148
+ }
149
+ // Each read's /nodes entries. A 404 leaves its mains null, as in Dev's gate;
150
+ // a token that can't read the file fails, since the gate would refuse too.
151
+ const read = async (file, ids) => {
152
+ const out = {};
153
+ for (const part of chunks(ids, IDS_PER_CALL)) {
154
+ try {
155
+ Object.assign(out, (await figmaGet(`/files/${encodeURIComponent(file)}/nodes?ids=${encodeURIComponent(part.join(","))}`)).nodes ?? {});
156
+ } catch (error) {
157
+ if (error instanceof CliError && error.code === "FIGMA_FORBIDDEN")
158
+ throw new CliError(error.code, `${error.message}: FIGMA_PAT can't read ${file}, so its mains (and the overrides on them) can't be checked`, 1, error.details);
159
+ if (!(error instanceof CliError) || error.code !== "FIGMA_NOT_FOUND") throw error;
160
+ }
161
+ }
162
+ return out;
163
+ };
164
+ const entry = (e) => (e?.document ? e : null);
165
+ if (local.length) {
166
+ const got = await read(fileKey, local);
167
+ for (const id of local) mains[id] = entry(got[id]);
168
+ }
169
+ if (fromLibrary.size) {
170
+ const got = await read(ds.fileKey, [...fromLibrary.keys()]);
171
+ for (const [node, ids] of fromLibrary) for (const id of ids) mains[id] = entry(got[node]);
172
+ }
173
+ return mains;
174
+ }
175
+
176
+ // A repeated flag parses as an array: `--nodes a --nodes b,c` is a, b and c.
177
+ const list = (value) => [value].flat().filter((x) => typeof x === "string").flatMap((x) => x.split(",")).map((x) => x.trim()).filter(Boolean);
178
+ // A node id as a figma.com link writes it (12-34) or as the API does (12:34).
179
+ const nodeId = (x) => (/^\d+-\d+$/.test(x) ? x.replace("-", ":") : x);
180
+
181
+ /**
182
+ * `design check <fileKey|link> [--nodes a,b] [--roles r,s] [--library]`: the conformance
183
+ * findings for those nodes (every page when none are named). A link's
184
+ * node-id counts as --nodes. Exit 1 with DS_CONFORMANCE_FAILED when any
185
+ * finding fails; stale overrides (a value equal to the main's) are listed but
186
+ * never fail (DEC-0035).
187
+ */
188
+ export async function designCheck(target, flags = {}, { request = devRequest, env = process.env, figmaGet, read } = {}) {
189
+ if (flags["change-set"] !== undefined) return designCheckChangeSet(target, flags, { request, ...(read ? { read } : {}) });
190
+ // A --nodes that names nothing must not widen to every page.
191
+ if (flags.nodes !== undefined && ([flags.nodes].flat().includes(true) || !list(flags.nodes).length))
192
+ throw new CliError("USAGE", "--nodes needs node ids, e.g. --nodes 12:34,12:56", 2);
193
+ if (flags.roles === true) throw new CliError("USAGE", "--roles needs role names, e.g. --roles decision.approve,data.table", 2);
194
+ const fileKey = figmaFileKey(target);
195
+ let decoded = target;
196
+ try {
197
+ decoded = typeof target === "string" ? decodeURIComponent(target) : target;
198
+ } catch {
199
+ throw new CliError("USAGE", `cannot read the link ${target}: a malformed %-escape`, 2);
200
+ }
201
+ const linkNode = typeof decoded === "string" ? /[?&]node-id=([0-9]+[-:][0-9]+)/.exec(decoded)?.[1] : undefined;
202
+ if (typeof decoded === "string" && /[?&]node-id=/.test(decoded) && !linkNode)
203
+ throw new CliError("USAGE", `the link's node-id isn't a frame or component id (12-34); pass --nodes`, 2);
204
+ const nodeIds = [...new Set([...list(flags.nodes).map(nodeId), ...(linkNode ? [nodeId(linkNode)] : [])])];
205
+ const get = figmaGet ?? figmaClient(figmaToken(env));
206
+ const [rows, files, nodes] = await Promise.all([
207
+ roleMap(request).then((p) => p.data ?? []),
208
+ request("GET", "/api/v1/design/files").then((p) => p.data ?? []),
209
+ fetchNodes(get, fileKey, nodeIds),
210
+ ]);
211
+ // As Dev's gate does: the DS file is the first (by role) the role map
212
+ // names, else the first file registered as design-system; a role row
213
+ // without a key takes it from that file's published listing.
214
+ const dsFiles = files.filter((f) => f.kind === "design-system").map((f) => f.fileKey).sort();
215
+ const dsFileKey = rows.find((r) => r.fileKey)?.fileKey ?? dsFiles[0] ?? null;
216
+ const ds = dsFileKey ? await libraryIndex(get, dsFileKey) : null;
217
+ const roles = rows.map((r) => ({
218
+ ...r,
219
+ componentKey: r.componentKey ?? (ds && r.nodeId && r.fileKey === ds.fileKey ? (ds.keyByNode.get(r.nodeId) ?? null) : null),
220
+ }));
221
+ // A file registered as design-system checks as the library, as at the gate:
222
+ // its own components are not stand-ins. --library forces it (a DS branch).
223
+ const library = flags.library === true || dsFiles.includes(fileKey);
224
+ // Each round of mains can reveal instances inside slot content, so fetch
225
+ // until the check needs no more (the module's contract).
226
+ const mains = {};
227
+ for (let round = 0; round < MAX_MAIN_ROUNDS; round++) {
228
+ const want = neededMains(nodes, mains, { library });
229
+ if (!want.length) break;
230
+ Object.assign(mains, await fetchMains(get, fileKey, nodes, want, ds));
231
+ }
232
+ const declaredRoles = list(flags.roles);
233
+ const result = checkConformance({ nodes, roles, mains, options: { library, ...(declaredRoles.length ? { declaredRoles } : {}) } });
234
+ // A node Figma lacks fails, as at the gate, and the rest are still checked.
235
+ const missing = Object.keys(nodes).filter((id) => !nodes[id]?.document);
236
+ const findings = [...missing.map((id) => ({ nodeId: id, check: "FIGMA_NODE_MISSING", detail: `Figma has no node ${id} in ${fileKey}` })), ...(result.findings ?? [])];
237
+ // Mains the gate reads (local, or in the DS listing) that Figma didn't
238
+ // serve, apart from those it never reads (another library).
239
+ const meta = Object.assign({}, ...Object.values(nodes).map((n) => n?.components ?? {}));
240
+ const gateReads = (id) => meta[id]?.remote === false || Boolean(meta[id]?.remote && ds?.byKey.has(meta[id].key));
241
+ const data = {
242
+ fileKey, nodeIds: Object.keys(nodes), checkVersion: result.version ?? CHECK_VERSION, roles: roles.filter((x) => !blocked(x)).length, library, dsFileKey,
243
+ unresolvedMains: Object.keys(mains).filter((id) => !mains[id] && gateReads(id)),
244
+ outsideLibraryMains: Object.keys(mains).filter((id) => !mains[id] && !gateReads(id)).length,
245
+ passed: findings.length === 0, findings, staleOverrides: result.staleOverrides ?? [],
246
+ // Same value through a different variable: the file is behind a library
247
+ // republish. Accept the update in Figma (MW6-17); never a failure.
248
+ libraryUpdatePending: result.libraryUpdatePending ?? [],
249
+ };
250
+ return { payload: { data }, exitCode: data.passed ? 0 : 1 };
251
+ }
252
+
253
+ export function designCheckText({ data }) {
254
+ const lines = [`${data.fileKey}: ${data.nodeIds.length} node(s), ${data.roles} role(s), check ${data.checkVersion}`];
255
+ if (!data.roles) lines.push("note: no role in Dev's role map names a DS component yet, so roleMismatch had nothing to check against");
256
+ if (!data.dsFileKey) lines.push("note: Dev names no DS file, so no library main was read; overrides on library instances were not checked");
257
+ if (data.unresolvedMains.length) lines.push(`note: ${data.unresolvedMains.length} main component(s) could not be read (${data.unresolvedMains.join(", ")}); overrides under them were not checked`);
258
+ if (data.outsideLibraryMains) lines.push(`note: ${data.outsideLibraryMains} instance main(s) are outside the DS file's published library; as at Dev's submit, nothing under them is checked`);
259
+ lines.push(data.passed ? "PASSED: no conformance findings" : `DS_CONFORMANCE_FAILED: ${data.findings.length} finding(s)`);
260
+ return [...lines, ...conformanceLines(data)].join("\n");
261
+ }
262
+
263
+ /**
264
+ * `design check --change-set CS-1 [--file SUBMIT.json] [--figma-version V]`: Dev's own dry run of
265
+ * the submit gate on a change set (checkDesignConformance): the same checks
266
+ * plus the submit-time guards against the set's items: IMAGE_NOT_TRUECOLOR
267
+ * refuses; STALE_FIGMA_VERSION is a warning. Changes no state.
268
+ */
269
+ export async function designCheckChangeSet(target, flags, { request = devRequest, read = (f) => readFile(f, "utf8") } = {}) {
270
+ const id = flags["change-set"];
271
+ if (id === true || !id) throw new CliError("USAGE", "--change-set needs a change set id, e.g. --change-set CS-123", 2);
272
+ if (target !== undefined) throw new CliError("USAGE", "design check --change-set CS takes no file: Dev checks the set's own items", 2);
273
+ if (flags["figma-version"] === true) throw new CliError("USAGE", "--figma-version needs a version id", 2);
274
+ if (flags.file === true) throw new CliError("USAGE", "--file needs the submit body, e.g. --file submit.json", 2);
275
+ // A set never submitted has no version or items for Dev to check: send the
276
+ // body `figma submit --file` would send. --figma-version overrides its version.
277
+ let body = {};
278
+ if (flags.file) {
279
+ try {
280
+ body = JSON.parse(await read(flags.file));
281
+ } catch (error) {
282
+ throw new CliError("USAGE", `cannot read ${flags.file} as JSON: ${error.message}`, 2);
283
+ }
284
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new CliError("USAGE", `${flags.file}: expected the submit body, a JSON object`, 2);
285
+ }
286
+ if (flags["figma-version"]) body = { ...body, figmaVersion: String(flags["figma-version"]) };
287
+ let p;
288
+ try {
289
+ p = await figmaConformance(id, body, { request });
290
+ } catch (error) {
291
+ // A cold run reads every root and main from Figma and can outlast the
292
+ // edge's gateway (504, an HTML page). Dev keeps the mains it read for 5
293
+ // minutes, so the same run again usually answers.
294
+ if (error?.code !== "HTTP_504") throw error;
295
+ throw new CliError("DEV_TIMEOUT", `Dev's dry run of ${id} ran past the edge's gateway timeout while reading Figma; run it again (Dev keeps the library mains it read for 5 minutes)`, 1);
296
+ }
297
+ const r = p.data ?? {};
298
+ const data = { changeSetId: id, checkVersion: r.version, passed: Boolean(r.passed), checked: r.checked, findings: r.findings ?? [],
299
+ staleOverrides: r.staleOverrides ?? [], libraryUpdatePending: r.libraryUpdatePending ?? [], warnings: r.warnings ?? [], staleVersion: r.staleVersion ?? null };
300
+ return { payload: { data }, exitCode: data.passed ? 0 : 1, human: designCheckChangeSetText };
301
+ }
302
+
303
+ export function designCheckChangeSetText({ data }) {
304
+ const head = `${data.changeSetId}: Dev's submit gate, dry run${data.checkVersion ? `, check ${data.checkVersion}` : ""}`;
305
+ return [head, data.passed ? "PASSED: Dev would accept this submit" : `DS_CONFORMANCE_FAILED: ${data.findings.length} finding(s)`, ...conformanceLines(data)].join("\n");
306
+ }