@nodii/dev 0.6.15 → 0.6.16

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
@@ -343,11 +343,18 @@ nodii-dev design check FILE_KEY|LINK [--nodes 12:34,12:56] [--roles decision.app
343
343
  each instance's main the way Dev's submit gate does, so both reach one verdict:
344
344
 
345
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.
346
+ - A library component is found in the DS file's published listing, or else
347
+ by Figma's lookup of its key. The DS file is the first file the role map
348
+ names, or else the first file registered in Dev as `design-system`.
349
+ - It is read only from a library Dev has registered (`design-system` or
350
+ `surfaces`), at that library's newest "Components published" version
351
+ (DEC-0047), so an unpublished DS edit flags nothing. A library with no
352
+ recent publish is read at its head. `FIGMA_PAT` needs the
353
+ `file_versions:read` scope for this. `--json` gives `libraryVersion` (the
354
+ publish), `libraryRead`, and `libraryFellBack` (Figma couldn't serve the
355
+ publish, so the head was read).
356
+ - An instance from a library Dev hasn't registered is an `unresolvedLibrary`
357
+ warning: nothing under it is flagged, and it never fails the check.
351
358
 
352
359
  It runs the conformance module Dev's submit runs, vendored unchanged in
353
360
  `src/vendor/ds-conformance.mjs`. No `--nodes` checks every page; a link's
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.15";
7
+ export const VERSION = "0.6.16";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
package/dist/design.js CHANGED
@@ -126,53 +126,141 @@ export async function libraryIndex(figmaGet, dsFileKey) {
126
126
  return { fileKey: dsFileKey, byKey, keyByNode };
127
127
  }
128
128
 
129
+ // The label Figma gives a library publish's version (DEC-0047).
130
+ const PUBLISH_LABEL = "Components published";
131
+ const VERSION_PAGES = 5;
132
+ const KEY_LOOKUPS_AT_ONCE = 3;
133
+
134
+ /**
135
+ * A library's newest "Components published" version (DEC-0047): what a
136
+ * consuming file can have. Null when none is in its recent history; its
137
+ * head is read then, as at the gate.
138
+ */
139
+ export async function lastPublish(figmaGet, fileKey) {
140
+ let path = `/files/${encodeURIComponent(fileKey)}/versions?page_size=100`;
141
+ for (let page = 0; page < VERSION_PAGES && path; page++) {
142
+ let payload;
143
+ try {
144
+ payload = await figmaGet(path);
145
+ } catch (error) {
146
+ if (error instanceof CliError && error.code === "FIGMA_FORBIDDEN")
147
+ throw new CliError(error.code, `${error.message}: FIGMA_PAT can't read ${fileKey}'s version history (the file_versions:read scope), which finds the library's newest publish (DEC-0047)`, 1, error.details);
148
+ throw error;
149
+ }
150
+ const hit = (payload?.versions ?? []).find((v) => v.label === PUBLISH_LABEL);
151
+ if (hit) return hit.id;
152
+ // Figma links the next-older page as a full URL.
153
+ const older = payload?.pagination?.prev_page;
154
+ path = typeof older === "string" && older.startsWith(FIGMA_API) ? older.slice(FIGMA_API.length) : null;
155
+ }
156
+ return null;
157
+ }
158
+
129
159
  /**
130
160
  * 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.
161
+ * id for it), resolved as Dev's gate resolves it (Dev issue 92, conformance
162
+ * 1.0.3), so both give one verdict:
163
+ * - a local component (remote: false) is read from this file;
164
+ * - a remote one is found in the DS file's published listing, else by Figma's
165
+ * lookup of its key, and is read only from a library Dev has registered
166
+ * (design-system or surfaces), at that library's newest publish
167
+ * (DEC-0047), or its head when Figma can't serve that version;
168
+ * - one from a library Dev hasn't registered is null and is added to
169
+ * `ctx.unresolved`, for an unresolvedLibrary warning;
170
+ * - anything else (no metadata, a main Figma lacks) is null.
171
+ * A null main makes the check treat everything under the instance as the
172
+ * library's, flagging nothing on a guess. `ctx` carries the DS index, the
173
+ * registered files and what earlier rounds found.
137
174
  */
138
- export async function fetchMains(figmaGet, fileKey, nodes, componentIds, ds = null) {
175
+ export async function fetchMains(figmaGet, fileKey, nodes, componentIds, ctx = {}) {
176
+ const ds = ctx.ds ?? null;
177
+ const registered = ctx.registered ?? new Set(ds ? [ds.fileKey] : []);
178
+ ctx.homes ??= new Map();
179
+ ctx.published ??= new Map();
180
+ ctx.unresolved ??= new Set();
139
181
  const meta = Object.assign({}, ...Object.values(nodes).map((n) => n?.components ?? {}));
140
182
  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]);
183
+ const local = componentIds.filter((id) => meta[id]?.remote === false);
184
+ const remote = componentIds.filter((id) => meta[id]?.remote && meta[id].key);
185
+ // Where each key lives: the DS listing first, then Figma's lookup.
186
+ const lookup = [];
187
+ for (const key of new Set(remote.map((id) => meta[id].key))) {
188
+ if (ctx.homes.has(key)) continue;
189
+ const inDs = ds?.byKey.get(key);
190
+ if (inDs) ctx.homes.set(key, { fileKey: ds.fileKey, nodeId: inDs });
191
+ else lookup.push(key);
148
192
  }
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) => {
193
+ for (const part of chunks(lookup, KEY_LOOKUPS_AT_ONCE))
194
+ await Promise.all(part.map(async (key) => {
195
+ let m = null;
196
+ try {
197
+ m = (await figmaGet(`/components/${encodeURIComponent(key)}`)).meta;
198
+ } catch (error) {
199
+ if (!(error instanceof CliError) || error.code !== "FIGMA_NOT_FOUND") throw error;
200
+ }
201
+ ctx.homes.set(key, m?.file_key && m?.node_id ? { fileKey: String(m.file_key), nodeId: String(m.node_id) } : null);
202
+ }));
203
+ const byFile = new Map(); // library file -> main node -> this file's componentIds
204
+ for (const id of remote) {
205
+ const home = ctx.homes.get(meta[id].key);
206
+ if (!home || !registered.has(home.fileKey)) {
207
+ ctx.unresolved.add(id);
208
+ continue;
209
+ }
210
+ const nodesOf = byFile.get(home.fileKey) ?? new Map();
211
+ nodesOf.set(home.nodeId, [...(nodesOf.get(home.nodeId) ?? []), id]);
212
+ byFile.set(home.fileKey, nodesOf);
213
+ }
214
+ // Each read's /nodes entries, or null when Figma has no such file or
215
+ // version. A token that can't read the file fails: the gate would refuse.
216
+ const read = async (file, ids, version = null) => {
152
217
  const out = {};
153
218
  for (const part of chunks(ids, IDS_PER_CALL)) {
219
+ const query = `ids=${encodeURIComponent(part.join(","))}${version ? `&version=${encodeURIComponent(version)}` : ""}`;
154
220
  try {
155
- Object.assign(out, (await figmaGet(`/files/${encodeURIComponent(file)}/nodes?ids=${encodeURIComponent(part.join(","))}`)).nodes ?? {});
221
+ Object.assign(out, (await figmaGet(`/files/${encodeURIComponent(file)}/nodes?${query}`)).nodes ?? {});
156
222
  } catch (error) {
157
223
  if (error instanceof CliError && error.code === "FIGMA_FORBIDDEN")
158
224
  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;
225
+ if (error instanceof CliError && error.code === "FIGMA_NOT_FOUND") return null;
226
+ throw error;
160
227
  }
161
228
  }
162
229
  return out;
163
230
  };
164
231
  const entry = (e) => (e?.document ? e : null);
232
+ for (const [file, nodesOf] of byFile) {
233
+ if (!ctx.published.has(file)) ctx.published.set(file, await lastPublish(figmaGet, file));
234
+ const version = ctx.published.get(file);
235
+ if (file === ds?.fileKey) ctx.libraryVersion = version;
236
+ // A published version Figma can't serve falls back to the file as it is.
237
+ let got = await read(file, [...nodesOf.keys()], version);
238
+ if (!got && version) {
239
+ got = await read(file, [...nodesOf.keys()]);
240
+ if (file === ds?.fileKey) ctx.libraryFellBack = true;
241
+ }
242
+ for (const [node, ids] of nodesOf) for (const id of ids) mains[id] = entry(got?.[node]);
243
+ }
165
244
  if (local.length) {
166
245
  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]);
246
+ for (const id of local) mains[id] = entry(got?.[id]);
172
247
  }
173
248
  return mains;
174
249
  }
175
250
 
251
+ /** The first instance of a component in the checked trees, to point a warning at. */
252
+ function firstInstance(nodes, componentId) {
253
+ for (const entry of Object.values(nodes)) {
254
+ const stack = entry?.document ? [entry.document] : [];
255
+ while (stack.length) {
256
+ const n = stack.pop();
257
+ if (n.type === "INSTANCE" && n.componentId === componentId) return n.id;
258
+ for (const child of n.children ?? []) stack.push(child);
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
176
264
  // A repeated flag parses as an array: `--nodes a --nodes b,c` is a, b and c.
177
265
  const list = (value) => [value].flat().filter((x) => typeof x === "string").flatMap((x) => x.split(",")).map((x) => x.trim()).filter(Boolean);
178
266
  // A node id as a figma.com link writes it (12-34) or as the API does (12:34).
@@ -223,29 +311,41 @@ export async function designCheck(target, flags = {}, { request = devRequest, en
223
311
  const library = flags.library === true || dsFiles.includes(fileKey);
224
312
  // Each round of mains can reveal instances inside slot content, so fetch
225
313
  // until the check needs no more (the module's contract).
314
+ // Mains may come from any library Dev has registered (the DS and the
315
+ // Surfaces libraries that publish blocks).
316
+ const registered = new Set([...files.filter((f) => ["design-system", "surfaces"].includes(f.kind)).map((f) => f.fileKey), ...(dsFileKey ? [dsFileKey] : [])]);
317
+ const ctx = { ds, registered };
226
318
  const mains = {};
227
319
  for (let round = 0; round < MAX_MAIN_ROUNDS; round++) {
228
320
  const want = neededMains(nodes, mains, { library });
229
321
  if (!want.length) break;
230
- Object.assign(mains, await fetchMains(get, fileKey, nodes, want, ds));
322
+ Object.assign(mains, await fetchMains(get, fileKey, nodes, want, ctx));
231
323
  }
232
324
  const declaredRoles = list(flags.roles);
233
325
  const result = checkConformance({ nodes, roles, mains, options: { library, ...(declaredRoles.length ? { declaredRoles } : {}) } });
234
326
  // A node Figma lacks fails, as at the gate, and the rest are still checked.
235
327
  const missing = Object.keys(nodes).filter((id) => !nodes[id]?.document);
236
328
  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).
329
+ // A library Dev doesn't know is a warning, as at the gate, not silence.
239
330
  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));
331
+ const unresolved = ctx.unresolved ?? new Set();
332
+ const warnings = [...unresolved].map((id) => ({ nodeId: firstInstance(nodes, id) ?? id, check: "unresolvedLibrary",
333
+ detail: `${meta[id]?.name ?? id} comes from a library Dev hasn't registered; its instances were not checked against a main` }));
241
334
  const data = {
242
335
  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,
336
+ // Mains the gate would read that Figma didn't serve.
337
+ unresolvedMains: Object.keys(mains).filter((id) => !mains[id] && !unresolved.has(id) && (meta[id]?.remote === false || Boolean(meta[id]?.remote && meta[id]?.key))),
338
+ // The DS file's newest publish, as the gate reports it (null when it has
339
+ // none, or no DS main was needed); libraryFellBack says the head was read
340
+ // because Figma couldn't serve it.
341
+ libraryVersion: ctx.libraryVersion ?? null,
342
+ libraryRead: Boolean(dsFileKey && ctx.published?.has(dsFileKey)),
343
+ libraryFellBack: Boolean(ctx.libraryFellBack),
245
344
  passed: findings.length === 0, findings, staleOverrides: result.staleOverrides ?? [],
246
345
  // Same value through a different variable: the file is behind a library
247
346
  // republish. Accept the update in Figma (MW6-17); never a failure.
248
347
  libraryUpdatePending: result.libraryUpdatePending ?? [],
348
+ warnings,
249
349
  };
250
350
  return { payload: { data }, exitCode: data.passed ? 0 : 1 };
251
351
  }
@@ -255,7 +355,8 @@ export function designCheckText({ data }) {
255
355
  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
356
  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
357
  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`);
358
+ if (data.libraryRead && data.libraryFellBack) lines.push(`note: Figma couldn't serve ${data.dsFileKey}'s newest publish, version ${data.libraryVersion}, so library mains were read at its head`);
359
+ else if (data.libraryRead) lines.push(data.libraryVersion ? `library mains read at ${data.dsFileKey}'s newest publish, version ${data.libraryVersion} (DEC-0047)` : `note: ${data.dsFileKey} has no "Components published" version in its recent history, so library mains were read at its head`);
259
360
  lines.push(data.passed ? "PASSED: no conformance findings" : `DS_CONFORMANCE_FAILED: ${data.findings.length} finding(s)`);
260
361
  return [...lines, ...conformanceLines(data)].join("\n");
261
362
  }