@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 +12 -5
- package/dist/core.js +1 -1
- package/dist/design.js +132 -31
- package/dist/devops-openapi.json +1 -1
- package/dist/vendor/ds-conformance.mjs +55 -12
- package/package.json +1 -1
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
|
|
347
|
-
|
|
348
|
-
or else the first file registered in Dev as `design-system`.
|
|
349
|
-
-
|
|
350
|
-
|
|
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.
|
|
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
|
|
132
|
-
*
|
|
133
|
-
* remote
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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,
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
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.
|
|
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
|
}
|