@nodii/dev 0.6.17 → 0.6.20
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 +23 -0
- package/dist/cli.js +11 -6
- package/dist/core.js +16 -7
- package/dist/design.js +15 -9
- package/dist/figma.js +168 -8
- package/dist/vendor/ds-conformance.mjs +259 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -402,6 +402,29 @@ version. A set never submitted needs `--file`: the body `figma submit --file`
|
|
|
402
402
|
would send. A cold run can outlast the edge's gateway; the CLI then says
|
|
403
403
|
`DEV_TIMEOUT` and to run it again (Dev keeps the mains it read for 5 minutes).
|
|
404
404
|
|
|
405
|
+
### Large sets: submit as a Dev job
|
|
406
|
+
|
|
407
|
+
`figma submit`, `figma resubmit` and `design check --change-set` send
|
|
408
|
+
`Prefer: respond-async`. Dev answers 202 with a job, and the CLI polls it
|
|
409
|
+
(`getDesignSubmitJob`) with backoff, printing progress on stderr, until it
|
|
410
|
+
ends. The output and exit code are the synchronous call's: a succeeded job
|
|
411
|
+
prints Dev's 200 body and warnings, a failed one its error and findings. A
|
|
412
|
+
large set's gate outlasts the edge's 30 s timeout, so this is the default.
|
|
413
|
+
|
|
414
|
+
- `a submit is already running: JOB`: Dev already runs a submit (or a dry run)
|
|
415
|
+
for this set (409 `SUBMIT_IN_PROGRESS` / `CONFORMANCE_IN_PROGRESS`). The CLI
|
|
416
|
+
polls that job instead of sending again, then exits 1 with the 409's code
|
|
417
|
+
and that job's outcome (`--json`: `details.result` or `details.error`):
|
|
418
|
+
this command's body was not sent, so read the set and run it again if it
|
|
419
|
+
still needs to go.
|
|
420
|
+
- `SUBMIT_JOB_LOST`: Dev restarted while the job ran. Run the same command again.
|
|
421
|
+
- `SUBMIT_JOB_UNREACHABLE`: polling lost the edge; the job runs on in Dev.
|
|
422
|
+
Read the set with `figma changeset show` before sending it again.
|
|
423
|
+
- `--sync` sends the old single request. If it (or a submit's 202) meets the
|
|
424
|
+
edge's 502 or 504, the CLI reads the set and says whether the submit landed;
|
|
425
|
+
it never resends. `SUBMIT_UNCONFIRMED` means it hasn't yet: check with
|
|
426
|
+
`figma changeset show` before submitting again.
|
|
427
|
+
|
|
405
428
|
When `figma submit`, `figma resubmit` or the dry run hits
|
|
406
429
|
`DS_CONFORMANCE_FAILED`, the CLI prints each finding and Dev's warnings. A
|
|
407
430
|
submit Dev accepts prints its warnings too. On a stale version it also prints
|
package/dist/cli.js
CHANGED
|
@@ -43,12 +43,13 @@ import {
|
|
|
43
43
|
figmaScopeSet,
|
|
44
44
|
figmaScopeSetText,
|
|
45
45
|
figmaSubmit,
|
|
46
|
+
submitMode,
|
|
46
47
|
isChangeSetId,
|
|
47
48
|
scopeText,
|
|
48
49
|
scopesFrom,
|
|
49
50
|
conformanceLines,
|
|
50
51
|
} from "./figma.js";
|
|
51
|
-
export { figmaLock } from "./figma.js";
|
|
52
|
+
export { figmaLock, submitMode } from "./figma.js";
|
|
52
53
|
import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
|
|
53
54
|
import { changeSetCommand, guidelineCommand } from "./guidelines.js";
|
|
54
55
|
import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
|
|
@@ -96,7 +97,7 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
96
97
|
design check FILE_KEY|LINK [--nodes A,B] [--roles R,S] [--library]
|
|
97
98
|
# the DS conformance check Dev's submit runs, before you submit;
|
|
98
99
|
# 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
|
+
design check --change-set CS [--file SUBMIT.json] [--figma-version V] [--sync]
|
|
100
101
|
# Dev's dry run of the submit gate on a set, with the image guard;
|
|
101
102
|
# a stale version is a warning that prints when to retry
|
|
102
103
|
figma lock FILE_KEY --title TEXT [--scope KIND:ID ...]
|
|
@@ -107,8 +108,12 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
107
108
|
figma publish-plan FILE_KEY # what to publish, and what to leave unchecked in Figma's dialog
|
|
108
109
|
figma published FILE_KEY|CHANGE_SET --figma-version V # a file key marks every awaiting set published
|
|
109
110
|
figma check|log-write|submit|apply|abandon|release
|
|
111
|
+
figma submit CHANGE_SET --file SUBMIT.json [--sync]
|
|
112
|
+
# submit, resubmit and design check --change-set run as a job in Dev
|
|
113
|
+
# and poll it (a large set outlasts the edge's timeout); --sync waits
|
|
114
|
+
# on one request instead. A 502/504 is checked against the set, never resent
|
|
110
115
|
figma withdraw CHANGE_SET # holder takes back a submit: in_review -> editing (or a re-submit -> applying)
|
|
111
|
-
figma resubmit CHANGE_SET PAYLOAD.json
|
|
116
|
+
figma resubmit CHANGE_SET PAYLOAD.json [--sync]
|
|
112
117
|
# holder re-submits every rejected item: applying -> in_review, round + 1
|
|
113
118
|
figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
|
|
114
119
|
figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
|
|
@@ -428,7 +433,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
428
433
|
if (command === "design" && action === "roles") return emit(await designRoles(rest), json, designRolesText);
|
|
429
434
|
if (command === "design" && action === "check") {
|
|
430
435
|
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);
|
|
436
|
+
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] [--sync]", 2);
|
|
432
437
|
const result = await designCheck(rest[0], f);
|
|
433
438
|
emit(result.payload, json, result.human ?? designCheckText);
|
|
434
439
|
if (result.exitCode) {
|
|
@@ -646,7 +651,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
646
651
|
if (action === "submit") {
|
|
647
652
|
const payload = f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") };
|
|
648
653
|
// A 200 carries top-level warnings (STALE_FIGMA_VERSION): print them.
|
|
649
|
-
return emit(await figmaSubmit(rest[0], payload), json, (p) =>
|
|
654
|
+
return emit(await figmaSubmit(rest[0], payload, { sync: submitMode(f) }), json, (p) =>
|
|
650
655
|
[JSON.stringify(p.data, null, 2), ...conformanceLines({ warnings: p.warnings })].join("\n"));
|
|
651
656
|
}
|
|
652
657
|
if (action === "file") {
|
|
@@ -686,7 +691,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
686
691
|
if (action === "withdraw")
|
|
687
692
|
return emit(await figmaWithdraw(rest[0]), json, (p) => `Withdrew ${rest[0]}; it is back in ${p.data?.state ?? "editing"}.`);
|
|
688
693
|
if (action === "resubmit")
|
|
689
|
-
return emit(await figmaResubmit(rest[0], rest[1]), json, (p) =>
|
|
694
|
+
return emit(await figmaResubmit(rest[0], rest[1], { sync: submitMode(f) }), json, (p) =>
|
|
690
695
|
[`Re-submitted ${rest[0]}; it is ${p.data.state}.`, figmaChangeSetText(p.data), ...conformanceLines({ warnings: p.warnings })].join("\n"));
|
|
691
696
|
if (action === "changeset") {
|
|
692
697
|
if (rest[0] !== "show" || !rest[1] || rest.length > 2) throw new CliError("USAGE", "figma changeset show CHANGE_SET", 2);
|
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.20";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
|
@@ -113,7 +113,7 @@ const staleCode = (raw) => {
|
|
|
113
113
|
}
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
async function devFetch(method, path, bodyText, contentType, accept) {
|
|
116
|
+
async function devFetch(method, path, bodyText, contentType, accept, extra = {}) {
|
|
117
117
|
const config = await readConfig();
|
|
118
118
|
const auth = await authProvider();
|
|
119
119
|
const url = requestUrl(config, path);
|
|
@@ -124,7 +124,7 @@ async function devFetch(method, path, bodyText, contentType, accept) {
|
|
|
124
124
|
let refreshed = false;
|
|
125
125
|
let adopted = false;
|
|
126
126
|
for (;;) {
|
|
127
|
-
const headers = { accept };
|
|
127
|
+
const headers = { accept, ...extra };
|
|
128
128
|
if (bodyText !== undefined) headers["content-type"] = contentType;
|
|
129
129
|
Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
|
|
130
130
|
let response;
|
|
@@ -143,8 +143,9 @@ async function devFetch(method, path, bodyText, contentType, accept) {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
/** A JSON call that keeps the status: `{ status, payload }`. Errors throw as devRawRequest's do. */
|
|
147
|
+
export async function devExchange(method, path, bodyText, { contentType = "application/json", headers = {} } = {}) {
|
|
148
|
+
const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json", headers);
|
|
148
149
|
let payload;
|
|
149
150
|
try {
|
|
150
151
|
payload = raw ? JSON.parse(raw) : {};
|
|
@@ -158,7 +159,11 @@ export async function devRawRequest(method, path, bodyText, contentType = "appli
|
|
|
158
159
|
1,
|
|
159
160
|
payload.details,
|
|
160
161
|
);
|
|
161
|
-
return payload;
|
|
162
|
+
return { status: response.status, payload };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
|
|
166
|
+
return (await devExchange(method, path, bodyText, { contentType })).payload;
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
/** A Dev route that answers with text (e.g. a document body as text/markdown). Errors are Dev's JSON. */
|
|
@@ -173,6 +178,10 @@ export async function devTextRequest(method, path) {
|
|
|
173
178
|
export const devRequest = (method, path, body) =>
|
|
174
179
|
devRawRequest(method, path, body === undefined ? undefined : JSON.stringify(body));
|
|
175
180
|
|
|
181
|
+
/** A JSON call sent with `Prefer: respond-async` (RFC 7240): `{ status, payload }`, 202 when Dev took it as a job. */
|
|
182
|
+
export const devAsyncRequest = async (method, path, body) =>
|
|
183
|
+
devExchange(method, path, body === undefined ? undefined : JSON.stringify(body), { headers: { prefer: "respond-async" } });
|
|
184
|
+
|
|
176
185
|
/** Proves the stored OAuth login works: a token refresh, then the agent read once registered. */
|
|
177
186
|
export async function checkLogin() {
|
|
178
187
|
const config = await readConfig();
|
|
@@ -184,7 +193,7 @@ export async function checkLogin() {
|
|
|
184
193
|
export const encode = (value) => encodeURIComponent(value);
|
|
185
194
|
// Switches never take the next token as their value, so
|
|
186
195
|
// `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", "library"]);
|
|
196
|
+
const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync"]);
|
|
188
197
|
export function parseArgs(argv) {
|
|
189
198
|
const flags = {};
|
|
190
199
|
const args = [];
|
package/dist/design.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// library main.
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { CliError, devRequest } from "./core.js";
|
|
11
|
-
import { conformanceLines, figmaConformance, figmaFileKey } from "./figma.js";
|
|
11
|
+
import { conformanceLines, figmaConformance, figmaFileKey, submitMode } from "./figma.js";
|
|
12
12
|
import { checkConformance, neededMains, VERSION as CHECK_VERSION } from "./vendor/ds-conformance.mjs";
|
|
13
13
|
|
|
14
14
|
const FIGMA_API = "https://api.figma.com/v1";
|
|
@@ -159,7 +159,7 @@ export async function lastPublish(figmaGet, fileKey) {
|
|
|
159
159
|
/**
|
|
160
160
|
* The main of each instance, keyed by the instance's componentId (this file's
|
|
161
161
|
* id for it), resolved as Dev's gate resolves it (Dev issue 92, conformance
|
|
162
|
-
* 1.0.
|
|
162
|
+
* 1.0.4), so both give one verdict:
|
|
163
163
|
* - a local component (remote: false) is read from this file;
|
|
164
164
|
* - a remote one is found in the DS file's published listing, else by Figma's
|
|
165
165
|
* lookup of its key, and is read only from a library Dev has registered
|
|
@@ -273,8 +273,10 @@ const nodeId = (x) => (/^\d+-\d+$/.test(x) ? x.replace("-", ":") : x);
|
|
|
273
273
|
* finding fails; stale overrides (a value equal to the main's) are listed but
|
|
274
274
|
* never fail (DEC-0035).
|
|
275
275
|
*/
|
|
276
|
-
export async function designCheck(target, flags = {}, { request = devRequest, env = process.env, figmaGet, read } = {}) {
|
|
277
|
-
|
|
276
|
+
export async function designCheck(target, flags = {}, { request = devRequest, send, progress, env = process.env, figmaGet, read } = {}) {
|
|
277
|
+
submitMode(flags);
|
|
278
|
+
if (flags["change-set"] !== undefined)
|
|
279
|
+
return designCheckChangeSet(target, flags, { request, ...(send ? { send } : {}), ...(progress ? { progress } : {}), ...(read ? { read } : {}) });
|
|
278
280
|
// A --nodes that names nothing must not widen to every page.
|
|
279
281
|
if (flags.nodes !== undefined && ([flags.nodes].flat().includes(true) || !list(flags.nodes).length))
|
|
280
282
|
throw new CliError("USAGE", "--nodes needs node ids, e.g. --nodes 12:34,12:56", 2);
|
|
@@ -345,6 +347,8 @@ export async function designCheck(target, flags = {}, { request = devRequest, en
|
|
|
345
347
|
// Same value through a different variable: the file is behind a library
|
|
346
348
|
// republish. Accept the update in Figma (MW6-17); never a failure.
|
|
347
349
|
libraryUpdatePending: result.libraryUpdatePending ?? [],
|
|
350
|
+
// What the app-UI gate leaves out (email), with its node count (Dev issue 116).
|
|
351
|
+
outOfScope: result.outOfScope ?? [], outOfScopeNodes: result.outOfScopeNodes ?? 0,
|
|
348
352
|
warnings,
|
|
349
353
|
};
|
|
350
354
|
return { payload: { data }, exitCode: data.passed ? 0 : 1 };
|
|
@@ -367,8 +371,9 @@ export function designCheckText({ data }) {
|
|
|
367
371
|
* plus the submit-time guards against the set's items: IMAGE_NOT_TRUECOLOR
|
|
368
372
|
* refuses; STALE_FIGMA_VERSION is a warning. Changes no state.
|
|
369
373
|
*/
|
|
370
|
-
export async function designCheckChangeSet(target, flags, { request = devRequest, read = (f) => readFile(f, "utf8") } = {}) {
|
|
374
|
+
export async function designCheckChangeSet(target, flags, { request = devRequest, send, progress, read = (f) => readFile(f, "utf8") } = {}) {
|
|
371
375
|
const id = flags["change-set"];
|
|
376
|
+
const sync = submitMode(flags);
|
|
372
377
|
if (id === true || !id) throw new CliError("USAGE", "--change-set needs a change set id, e.g. --change-set CS-123", 2);
|
|
373
378
|
if (target !== undefined) throw new CliError("USAGE", "design check --change-set CS takes no file: Dev checks the set's own items", 2);
|
|
374
379
|
if (flags["figma-version"] === true) throw new CliError("USAGE", "--figma-version needs a version id", 2);
|
|
@@ -387,17 +392,18 @@ export async function designCheckChangeSet(target, flags, { request = devRequest
|
|
|
387
392
|
if (flags["figma-version"]) body = { ...body, figmaVersion: String(flags["figma-version"]) };
|
|
388
393
|
let p;
|
|
389
394
|
try {
|
|
390
|
-
p = await figmaConformance(id, body, { request });
|
|
395
|
+
p = await figmaConformance(id, body, { request, sync, ...(send ? { send } : {}), ...(progress ? { progress } : {}) });
|
|
391
396
|
} catch (error) {
|
|
392
397
|
// A cold run reads every root and main from Figma and can outlast the
|
|
393
398
|
// edge's gateway (504, an HTML page). Dev keeps the mains it read for 5
|
|
394
399
|
// minutes, so the same run again usually answers.
|
|
395
|
-
if (error?.code !== "HTTP_504") throw error;
|
|
396
|
-
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);
|
|
400
|
+
if (error?.code !== "HTTP_504" && error?.code !== "HTTP_502") throw error;
|
|
401
|
+
throw new CliError("DEV_TIMEOUT", `Dev's dry run of ${id} ran past the edge's gateway timeout while reading Figma; run it again${sync ? " without --sync, so Dev runs it as a job" : ""} (Dev keeps the library mains it read for 5 minutes)`, 1);
|
|
397
402
|
}
|
|
398
403
|
const r = p.data ?? {};
|
|
399
404
|
const data = { changeSetId: id, checkVersion: r.version, passed: Boolean(r.passed), checked: r.checked, findings: r.findings ?? [],
|
|
400
|
-
staleOverrides: r.staleOverrides ?? [], libraryUpdatePending: r.libraryUpdatePending ?? [],
|
|
405
|
+
staleOverrides: r.staleOverrides ?? [], libraryUpdatePending: r.libraryUpdatePending ?? [],
|
|
406
|
+
outOfScope: r.outOfScope ?? [], outOfScopeNodes: r.outOfScopeNodes ?? 0, warnings: r.warnings ?? [], staleVersion: r.staleVersion ?? null };
|
|
401
407
|
return { payload: { data }, exitCode: data.passed ? 0 : 1, human: designCheckChangeSetText };
|
|
402
408
|
}
|
|
403
409
|
|
package/dist/figma.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { CliError, devAsyncRequest, devRequest, encode } from "./core.js";
|
|
3
4
|
|
|
4
5
|
/** A Figma file key, or the key inside a figma.com file link (a branch link's branch key). */
|
|
5
6
|
export function figmaFileKey(value) {
|
|
@@ -55,7 +56,158 @@ export function resubmitPayload(text, file) {
|
|
|
55
56
|
return body;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
// Async submit, re-submit and dry run (`Prefer: respond-async`): a large set's
|
|
60
|
+
// gate outlasts the edge's 30 s timeout (CS-2470 got a 504 while Dev went on to
|
|
61
|
+
// answer 422), so Dev answers 202 with a job, polled here until it ends. A Dev
|
|
62
|
+
// that ignores the header answers synchronously, which is taken as is.
|
|
63
|
+
const JOB_POLL_MS = [1000, 2000, 3000, 5000];
|
|
64
|
+
// Dev fails a job still running after 20 minutes (SUBMIT_JOB_LOST); stop a little later.
|
|
65
|
+
const JOB_WAIT_MS = 25 * 60_000;
|
|
66
|
+
const JOB_PROGRESS_MS = 15_000;
|
|
67
|
+
const IN_PROGRESS = new Set(["SUBMIT_IN_PROGRESS", "CONFORMANCE_IN_PROGRESS"]);
|
|
68
|
+
const GATEWAY = new Set(["HTTP_502", "HTTP_504"]);
|
|
69
|
+
const TRANSIENT = new Set([...GATEWAY, "HTTP_503", "HTTP_429", "DEV_UNREACHABLE"]);
|
|
70
|
+
const stderrLine = (line) => process.stderr.write(`${line}\n`);
|
|
71
|
+
/** --sync sends the old synchronous call; the default (or --async) runs it as a job in Dev and polls it. */
|
|
72
|
+
export function submitMode(f) {
|
|
73
|
+
if (f.sync && f.async) throw new CliError("USAGE", "pass --sync or --async, not both", 2);
|
|
74
|
+
return Boolean(f.sync);
|
|
75
|
+
}
|
|
76
|
+
const DONE = new Set(["succeeded", "failed"]);
|
|
77
|
+
const jobName = (job) => (job.kind === "conformance" ? "dry run" : job.kind === "resubmit" ? "re-submit" : "submit");
|
|
78
|
+
|
|
79
|
+
export const submitJobPath = (job) => `/api/v1/design/change-sets/${encode(job.changeSetId)}/submit-jobs/${encode(job.id)}`;
|
|
80
|
+
|
|
81
|
+
/** A finished job as the synchronous call would have answered: `succeeded` is its 200 body, `failed` throws its error. */
|
|
82
|
+
export function jobOutcome(job) {
|
|
83
|
+
if (job.state === "succeeded") return job.result ?? {};
|
|
84
|
+
const e = job.error ?? {};
|
|
85
|
+
if (e.code === "SUBMIT_JOB_LOST")
|
|
86
|
+
throw new CliError(e.code, `${e.message ?? "Dev lost this job"} (${jobName(job)} job ${job.id} on ${job.changeSetId}). Run the same command again.`, 1, { job });
|
|
87
|
+
// Never a gateway code: that reads as the edge's gateway on this command's own request.
|
|
88
|
+
throw new CliError(!e.code || GATEWAY.has(e.code) ? "SUBMIT_JOB_FAILED" : e.code, e.message ?? `Dev's ${jobName(job)} job ${job.id} failed with HTTP ${job.httpStatus}`, 1, e.details ?? { httpStatus: job.httpStatus });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Polls a running job (getDesignSubmitJob) with backoff until it ends, printing progress. */
|
|
92
|
+
export async function pollSubmitJob(job, { request = devRequest, sleep = delay, progress = stderrLine, now = Date.now } = {}) {
|
|
93
|
+
const start = now();
|
|
94
|
+
let printed = start;
|
|
95
|
+
let misses = 0;
|
|
96
|
+
for (let n = 0; !DONE.has(job.state); n++) {
|
|
97
|
+
if (now() - start > JOB_WAIT_MS)
|
|
98
|
+
throw new CliError("SUBMIT_JOB_TIMEOUT", `Dev's ${jobName(job)} job ${job.id} is still running after ${Math.round(JOB_WAIT_MS / 60_000)} min; read ${job.changeSetId} with nodii-dev figma changeset show ${job.changeSetId} before running it again.`, 1, { job });
|
|
99
|
+
await sleep(JOB_POLL_MS[Math.min(n, JOB_POLL_MS.length - 1)]);
|
|
100
|
+
try {
|
|
101
|
+
job = { changeSetId: job.changeSetId, ...(await request("GET", submitJobPath(job))).data };
|
|
102
|
+
misses = 0;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error?.code === "SUBMIT_JOB_NOT_FOUND")
|
|
105
|
+
throw new CliError("SUBMIT_JOB_LOST", `Dev no longer knows ${jobName(job)} job ${job.id} on ${job.changeSetId} (it restarted). Read the set with nodii-dev figma changeset show ${job.changeSetId}, then run the same command again.`, 1, { job });
|
|
106
|
+
if (!TRANSIENT.has(error?.code)) throw error;
|
|
107
|
+
// A poll that hit the edge's gateway or a blip retries; the job runs on in Dev.
|
|
108
|
+
if (++misses > 5)
|
|
109
|
+
throw new CliError("SUBMIT_JOB_UNREACHABLE", `Lost touch with Dev (${error.code}) while its ${jobName(job)} job ${job.id} on ${job.changeSetId} ran; the job goes on in Dev. Do not send it again yet: read the set with nodii-dev figma changeset show ${job.changeSetId} in a few minutes.`, 1, { job, cause: error.code });
|
|
110
|
+
progress(`polling ${job.id} again (${error.code})`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!DONE.has(job.state) && now() - printed >= JOB_PROGRESS_MS) {
|
|
114
|
+
printed = now();
|
|
115
|
+
progress(`${job.changeSetId}: ${jobName(job)} job ${job.id} still running (${Math.round((printed - start) / 1000)} s)`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
progress(`${job.changeSetId}: ${jobName(job)} job ${job.id} ${job.state}${job.httpStatus && job.state === "failed" ? ` (HTTP ${job.httpStatus})` : ""} after ${Math.round((now() - start) / 1000)} s`);
|
|
119
|
+
return jobOutcome(job);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sends a change-set call with `Prefer: respond-async` and answers as the
|
|
124
|
+
* synchronous call does. A 409 SUBMIT_IN_PROGRESS or CONFORMANCE_IN_PROGRESS
|
|
125
|
+
* names the job already running for the set: that one is polled instead.
|
|
126
|
+
*/
|
|
127
|
+
export async function changeSetJob(method, path, body, { changeSetId, kind, send = devAsyncRequest, progress = stderrLine, ...poll } = {}) {
|
|
128
|
+
let job;
|
|
129
|
+
let adopted = null;
|
|
130
|
+
try {
|
|
131
|
+
const { status, payload } = await send(method, path, body);
|
|
132
|
+
if (status !== 202) return payload;
|
|
133
|
+
if (!payload?.data?.id)
|
|
134
|
+
throw new CliError("SUBMIT_UNCONFIRMED", `Dev took the ${kind === "conformance" ? "dry run" : kind} on ${changeSetId} as a job (202) but named no job; do not send it again yet: read the set with nodii-dev figma changeset show ${changeSetId}.`, 1, { changeSetId, payload });
|
|
135
|
+
job = { changeSetId, ...payload.data };
|
|
136
|
+
progress(`${job.changeSetId}: Dev is running the ${jobName(job)} as job ${job.id}; polling`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!IN_PROGRESS.has(error?.code) || !error.details?.job?.id) throw error;
|
|
139
|
+
job = { changeSetId, ...error.details.job };
|
|
140
|
+
// A dry run never answers for a submit, nor a submit for a dry run.
|
|
141
|
+
if ((job.kind === "conformance") !== (kind === "conformance")) throw error;
|
|
142
|
+
progress(`a ${jobName(job)} is already running: ${job.id}; polling it`);
|
|
143
|
+
adopted = error;
|
|
144
|
+
}
|
|
145
|
+
if (!adopted) return pollSubmitJob(job, { progress, ...poll });
|
|
146
|
+
// An adopted job ran its own body, so its answer is never this command's: it
|
|
147
|
+
// ends as the 409 it met, with that job's outcome in the details.
|
|
148
|
+
let outcome;
|
|
149
|
+
try {
|
|
150
|
+
outcome = { result: await pollSubmitJob(job, { progress, ...poll }) };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
outcome = { error: { code: error.code, message: error.message, details: error.details } };
|
|
153
|
+
}
|
|
154
|
+
// Polling that stopped (lost the edge, or timed out) leaves the job's end unknown.
|
|
155
|
+
const unknown = !outcome.result && ["SUBMIT_JOB_UNREACHABLE", "SUBMIT_JOB_TIMEOUT"].includes(outcome.error.code);
|
|
156
|
+
const ended = outcome.result ? "succeeded" : unknown ? `has no known outcome yet (${outcome.error.code})` : `failed (${outcome.error.code})`;
|
|
157
|
+
throw new CliError(
|
|
158
|
+
adopted.code,
|
|
159
|
+
`The ${jobName(job)} already running on ${changeSetId} (job ${job.id}) ${ended}; this command's body was not sent. Read the set with nodii-dev figma changeset show ${changeSetId}, then run it again ${unknown ? "only once that job has ended" : "if it still needs to go"}.`,
|
|
160
|
+
1,
|
|
161
|
+
{ job, ...outcome },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A submit lands an editing set in_review, a re-submit an applying one: only that move
|
|
166
|
+
// from the call's own entry state counts, never a set that was in review already.
|
|
167
|
+
const ENTRY = { submit: "editing", resubmit: "applying" };
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A submit or re-submit that met the edge's gateway (502/504) may still have
|
|
171
|
+
* landed in Dev (CS-2468 did): read the set and say which, never send again.
|
|
172
|
+
*/
|
|
173
|
+
async function afterGatewayTimeout(id, kind, before, error, { sync, request, progress }) {
|
|
174
|
+
let after = null;
|
|
175
|
+
try {
|
|
176
|
+
after = (await request("GET", `/api/v1/design/change-sets/${encode(id)}`)).data;
|
|
177
|
+
} catch {}
|
|
178
|
+
const status = error.code.replace("HTTP_", "HTTP ");
|
|
179
|
+
if (before?.state === ENTRY[kind] && after?.state === "in_review") {
|
|
180
|
+
progress(`Dev answered ${status} (the edge's gateway), but the submit landed: ${id} is ${after.state}. Its warnings were not returned; run nodii-dev figma changeset show ${id}.`);
|
|
181
|
+
return { data: after, warnings: [], landedAfterGatewayTimeout: true };
|
|
182
|
+
}
|
|
183
|
+
throw new CliError(
|
|
184
|
+
"SUBMIT_UNCONFIRMED",
|
|
185
|
+
[
|
|
186
|
+
`Dev answered ${status} (the edge's gateway timeout)${after ? ` and ${id} is still ${after.state}` : `, and ${id} could not be read`}.`,
|
|
187
|
+
"Dev may still be working on it: do not send it again yet.",
|
|
188
|
+
`Check with nodii-dev figma changeset show ${id} in a few minutes; if nothing changed, submit again${sync ? " without --sync, so Dev runs it as a job" : ""}.`,
|
|
189
|
+
].join("\n"),
|
|
190
|
+
1,
|
|
191
|
+
{ changeSetId: id, state: after?.state ?? null, gateway: error.code },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A submit or re-submit, async unless `sync`; a gateway timeout is checked against the set, not resent. */
|
|
196
|
+
async function submitCall(id, kind, body, { sync = false, request = devRequest, progress = stderrLine, ...job } = {}) {
|
|
197
|
+
const path = `/api/v1/design/change-sets/${encode(id)}/${kind}`;
|
|
198
|
+
let before = null;
|
|
199
|
+
try {
|
|
200
|
+
before = (await request("GET", `/api/v1/design/change-sets/${encode(id)}`)).data;
|
|
201
|
+
} catch {}
|
|
202
|
+
try {
|
|
203
|
+
return sync ? await request("POST", path, body) : await changeSetJob("POST", path, body, { changeSetId: id, kind, request, progress, ...job });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!GATEWAY.has(error?.code)) throw error;
|
|
206
|
+
return afterGatewayTimeout(id, kind, before, error, { sync, request, progress });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function figmaResubmit(id, file, { request = devRequest, read = (f) => readFile(f, "utf8"), ...options } = {}) {
|
|
59
211
|
if (!id || !file) throw new CliError("USAGE", "figma resubmit CHANGE_SET PAYLOAD.json", 2);
|
|
60
212
|
let text;
|
|
61
213
|
try {
|
|
@@ -65,7 +217,7 @@ export async function figmaResubmit(id, file, { request = devRequest, read = (f)
|
|
|
65
217
|
}
|
|
66
218
|
const body = resubmitPayload(text, file);
|
|
67
219
|
try {
|
|
68
|
-
return await
|
|
220
|
+
return await submitCall(id, "resubmit", body, { request, ...options });
|
|
69
221
|
} catch (error) {
|
|
70
222
|
const say = RESUBMIT_REFUSALS[error?.code];
|
|
71
223
|
if (!say) throw scopeRefusal(error, id) ?? error;
|
|
@@ -185,6 +337,11 @@ export function conformanceLines(r) {
|
|
|
185
337
|
lines.push(`${r.libraryUpdatePending.length} node(s) behind a library republish (not failures; accept the library update in this file, MW6-17):`);
|
|
186
338
|
for (const f of r.libraryUpdatePending) lines.push(` ${f.nodeId}\t${f.check ?? "libraryUpdatePending"}\t${f.detail}`);
|
|
187
339
|
}
|
|
340
|
+
// Email is outside the app-UI gate (Dev issue 116): counted, never silent.
|
|
341
|
+
if (r.outOfScope?.length) {
|
|
342
|
+
lines.push(`out of scope: ${r.outOfScopeNodes ?? "?"} node(s) in ${r.outOfScope.length} part(s), not checked (email: the Email kit, email bodies, the block palette):`);
|
|
343
|
+
for (const f of r.outOfScope) lines.push(` ${f.nodeId}\toutOfScope\t${f.detail}`);
|
|
344
|
+
}
|
|
188
345
|
// Warnings never refuse: STALE_FIGMA_VERSION is one (Dev issue 92, until
|
|
189
346
|
// Dev issue 107). With staleVersion, its wait is printed below instead.
|
|
190
347
|
const warnings = (r.warnings ?? []).filter((f) => !(r.staleVersion && f.check === "STALE_FIGMA_VERSION"));
|
|
@@ -314,12 +471,15 @@ export const figmaScopeSetText = (id, data) =>
|
|
|
314
471
|
...(data?.released?.length ? [`Released for other sets: ${data.released.map(scopeText).join(", ")}`] : []),
|
|
315
472
|
].join("\n");
|
|
316
473
|
|
|
317
|
-
|
|
318
|
-
|
|
474
|
+
/** A submit, async unless `sync` (options: sync, request, send, progress, sleep). */
|
|
475
|
+
export const figmaSubmit = (id, payload, { request = devRequest, ...options } = {}) =>
|
|
476
|
+
changeSetCall(id, (method, path, body) => submitCall(id, "submit", body, { request, ...options }))(
|
|
477
|
+
"POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
|
|
319
478
|
|
|
320
|
-
/** checkDesignConformance: the submit gate's dry run on a change set; changes no state. Body: { figmaVersion?, items? }. */
|
|
321
|
-
export const figmaConformance = (id, body = {}, { request = devRequest } = {}) =>
|
|
322
|
-
changeSetCall(id, request)(
|
|
479
|
+
/** checkDesignConformance: the submit gate's dry run on a change set; changes no state. Body: { figmaVersion?, items? }. Async unless `sync`. */
|
|
480
|
+
export const figmaConformance = (id, body = {}, { request = devRequest, sync = false, ...options } = {}) =>
|
|
481
|
+
changeSetCall(id, (method, path, b) => (sync ? request(method, path, b) : changeSetJob(method, path, b, { changeSetId: id, kind: "conformance", request, ...options })))(
|
|
482
|
+
"POST", `/api/v1/design/change-sets/${encode(id)}/conformance`, body);
|
|
323
483
|
|
|
324
484
|
export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {}) =>
|
|
325
485
|
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/writes`, { tool, nodeIds });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ds-conformance.mjs: the design-system conformance check (DEC-0034 MW6-14 to
|
|
2
|
-
// MW6-17, as amended by DEC-0035, DEC-0036 and DEC-0045
|
|
2
|
+
// MW6-17, as amended by DEC-0035, DEC-0036 and DEC-0045, and scoped by
|
|
3
|
+
// Dev issue 116).
|
|
3
4
|
//
|
|
4
5
|
// Canonical source: design-system `infra/figma/ds-conformance.mjs`. Every copy
|
|
5
6
|
// (Dev's submit gate, nodii-clis' `design check`) vendors these
|
|
@@ -20,13 +21,16 @@
|
|
|
20
21
|
// remote component, from the same file for a local one), or
|
|
21
22
|
// null when it can't be resolved
|
|
22
23
|
// options: { library?: boolean, declaredRoles?: string[] }
|
|
23
|
-
// returns { version, findings, staleOverrides, libraryUpdatePending
|
|
24
|
-
// each a list of { nodeId, check,
|
|
24
|
+
// returns { version, findings, staleOverrides, libraryUpdatePending,
|
|
25
|
+
// outOfScope, outOfScopeNodes }, each list a list of { nodeId, check,
|
|
26
|
+
// detail }; only `findings` fail. `outOfScope` lists the roots of what
|
|
27
|
+
// the app-UI gate doesn't check (the Email kit), `outOfScopeNodes` how
|
|
28
|
+
// many nodes they hold
|
|
25
29
|
// neededMains(nodes, mains, options) -> componentIds still to fetch; call
|
|
26
30
|
// it until it returns [] (each round can reveal instances inside slot
|
|
27
31
|
// content).
|
|
28
32
|
|
|
29
|
-
export const VERSION = "1.0.
|
|
33
|
+
export const VERSION = "1.0.4";
|
|
30
34
|
|
|
31
35
|
/** @typedef {{ nodeId: string, check: string, detail: string }} Finding */
|
|
32
36
|
/**
|
|
@@ -118,13 +122,30 @@ const TEXT_STYLE_FIELDS = new Set([
|
|
|
118
122
|
const STYLE_FIELD = /^(inherit)?(Fill|Stroke|Text|Effect|Grid)StyleId$/i;
|
|
119
123
|
|
|
120
124
|
const SURFACE_NAME = /^Surface \//;
|
|
125
|
+
// Email is out of the app-UI gate's scope (Dev issue 116): the Email kit's
|
|
126
|
+
// twelve registered parts (DESIGN-SYSTEM-33), an email (a frame named Email
|
|
127
|
+
// that places only those parts), and the template editor's block palette.
|
|
128
|
+
const EMAIL_KIT =
|
|
129
|
+
/^Email ?\/ ?(Header|Alert strip|Heading|Amount block|Button|Link fallback|Divider|Key-value row|Security note|Code box|QR pay block|Footer)$/i;
|
|
130
|
+
const EMAIL_FRAME = /^email\b/i;
|
|
131
|
+
const BLOCK_PALETTE = /^(email )?blocks? (panel|palette)$/i;
|
|
132
|
+
// A column of a table, grid or list; a page named "List Page" is no column.
|
|
133
|
+
const TABLE_CONTAINER = /table|list|grid/i;
|
|
134
|
+
const NOT_A_TABLE = /\blist page\b/i;
|
|
121
135
|
const CUSTOMER_HEADER =
|
|
122
136
|
/^(customer|client|contact|lead|applicant|candidate)s?( name)?$/i;
|
|
123
137
|
const EXTERNAL_PLANE = /customer portal|partner room|external/i;
|
|
124
138
|
// The viewer's own record (R5): the App Frame sidebar footer, the user menu
|
|
125
139
|
// and the profile. Not any footer: a dialog or table footer can list others.
|
|
126
140
|
const SELF_CONTAINER =
|
|
127
|
-
/sidebar ?footer|user button|user menu|account menu|profile/i;
|
|
141
|
+
/sidebar ?footer|user button|user menu|account menu|profile|\b(my|own) (account|tokens?|security|sessions?|settings|notification settings|records?)\b/i;
|
|
142
|
+
// A frame the screen marks as the viewer's own: "[self]", "(self)", a
|
|
143
|
+
// trailing "· self", or "(you)".
|
|
144
|
+
const SELF_MARK = /\[self\]|\(self\)|·\s*self\s*$|\(you\)/i;
|
|
145
|
+
// Where the screens show who the viewer is: the App Frame footer, the user
|
|
146
|
+
// and account menus, and frames marked self or named my/own.
|
|
147
|
+
const VIEWER_PLACE =
|
|
148
|
+
/sidebar ?footer|user button|user menu|account menu|\b(my|own) (profile|account|tokens?|security|sessions?|settings)\b/i;
|
|
128
149
|
const SELF_TEXT = /\(you\)|^you$/i;
|
|
129
150
|
const SIGNED_IN = /^(signed in( as\b.*)?|not you\?.*)$/i;
|
|
130
151
|
// The signed-in account card (Account Context, kind=Account) shows the
|
|
@@ -143,6 +164,16 @@ const PHONE = /(?<![\w.+\-/])\+?\(?\d[\d ().-]{7,}\d(?![\w\-/])/g;
|
|
|
143
164
|
const LOCAL_PHONE = /^(\(\d{3,5}\)[ .-]?|\d{3,5}[ .-])\d{3,5}([ .-]\d{2,5})?$/;
|
|
144
165
|
const DATE = /^(\d{4}-\d{2}-\d{2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4})/;
|
|
145
166
|
const ROLE_ANNOTATION = /\[role:([a-z0-9._-]+)\]/gi;
|
|
167
|
+
// otherPersonRevealed is about people (Dev issue 116): a value is judged
|
|
168
|
+
// by the field it fills. A tenant's business contact (support phone or
|
|
169
|
+
// email, caller or sender id, business address) is not personal.
|
|
170
|
+
const BUSINESS_FIELD =
|
|
171
|
+
/\b(support|help ?desk|helpline|hotline|toll[- ]free|caller id|sender( id)?|sending (address|domain|number)|from (address|email|number)|reply[- ]to|business|tenant)\b/i;
|
|
172
|
+
// A person's field wins: "Support agent" is a person.
|
|
173
|
+
const PERSON_FIELD =
|
|
174
|
+
/\b(user|member|customer|recipient|applicant|employee|candidate|assignee|owner|invitee|requester|reviewer|agent|engineer|staff|rep|manager|person|personal|your|my)\b/i;
|
|
175
|
+
// A field's frame names its label: field/Caller ID, select/Sender ID.
|
|
176
|
+
const FIELD_FRAME = /^(field|select|input|textarea|kv|detail|row) ?\/ ?(.+)$/i;
|
|
146
177
|
|
|
147
178
|
/** "I12:34;56:78" -> ["12:34", "56:78"]; "12:34" -> ["12:34"]. */
|
|
148
179
|
const segments = (id) => (id.startsWith("I") ? id.slice(1) : id).split(";");
|
|
@@ -325,6 +356,25 @@ function analyse(entry, mains, library = false) {
|
|
|
325
356
|
};
|
|
326
357
|
const componentLabel = (inst) =>
|
|
327
358
|
setOf(inst)?.name ?? componentOf(inst)?.name ?? inst.name;
|
|
359
|
+
// What the app-UI gate leaves out: each root, outermost first (byId is in
|
|
360
|
+
// document order, so a parent is decided before its children).
|
|
361
|
+
/** @type {Map<string, string>} */
|
|
362
|
+
const scopeRoots = new Map();
|
|
363
|
+
/** @type {Map<string, "kit" | "other" | "none">} */
|
|
364
|
+
const placedMemo = new Map();
|
|
365
|
+
const out = new Set();
|
|
366
|
+
for (const n of byId.values()) {
|
|
367
|
+
const p = parent.get(n.id);
|
|
368
|
+
if (p && out.has(p.id)) {
|
|
369
|
+
out.add(n.id);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const why = outOfScopeRoot(n, parent, componentLabel, placedMemo);
|
|
373
|
+
if (why) {
|
|
374
|
+
out.add(n.id);
|
|
375
|
+
scopeRoots.set(n.id, why);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
328
378
|
return {
|
|
329
379
|
root,
|
|
330
380
|
components,
|
|
@@ -340,7 +390,54 @@ function analyse(entry, mains, library = false) {
|
|
|
340
390
|
componentOf,
|
|
341
391
|
setOf,
|
|
342
392
|
componentLabel,
|
|
393
|
+
out,
|
|
394
|
+
scopeRoots,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const emailKitPart = (n, parent, componentLabel) =>
|
|
399
|
+
(n.type === "INSTANCE" && EMAIL_KIT.test(componentLabel(n) ?? "")) ||
|
|
400
|
+
((n.type === "COMPONENT" || n.type === "COMPONENT_SET") &&
|
|
401
|
+
(EMAIL_KIT.test(n.name ?? "") ||
|
|
402
|
+
(n.type === "COMPONENT" &&
|
|
403
|
+
EMAIL_KIT.test(parent.get(n.id)?.name ?? ""))));
|
|
404
|
+
/**
|
|
405
|
+
* Why a node and everything in it is outside the app-UI gate, or null: an
|
|
406
|
+
* Email kit part, an email made of them (a frame named Email whose only
|
|
407
|
+
* placed parts, at any depth, are the kit's), or the template editor's
|
|
408
|
+
* block palette.
|
|
409
|
+
*/
|
|
410
|
+
function outOfScopeRoot(n, parent, componentLabel, memo) {
|
|
411
|
+
if (emailKitPart(n, parent, componentLabel))
|
|
412
|
+
return `Email kit part ${n.type === "INSTANCE" ? componentLabel(n) : n.name}`;
|
|
413
|
+
if (n.type !== "FRAME" && n.type !== "GROUP" && n.type !== "SECTION")
|
|
414
|
+
return null;
|
|
415
|
+
if (BLOCK_PALETTE.test(n.name ?? "")) return `email block palette ${n.name}`;
|
|
416
|
+
// Every part placed in it, at any depth, is the kit's.
|
|
417
|
+
/** @returns {"kit" | "other" | "none"} */
|
|
418
|
+
const placed = (x) => {
|
|
419
|
+
const hit = memo.get(x.id);
|
|
420
|
+
if (hit) return hit;
|
|
421
|
+
let kind = /** @type {"kit" | "other" | "none"} */ ("none");
|
|
422
|
+
if (emailKitPart(x, parent, componentLabel)) kind = "kit";
|
|
423
|
+
else if (x.type === "INSTANCE") kind = "other";
|
|
424
|
+
else
|
|
425
|
+
for (const c of x.children ?? []) {
|
|
426
|
+
const k = placed(c);
|
|
427
|
+
if (k === "other") {
|
|
428
|
+
kind = "other";
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (k === "kit") kind = "kit";
|
|
432
|
+
}
|
|
433
|
+
memo.set(x.id, kind);
|
|
434
|
+
return kind;
|
|
343
435
|
};
|
|
436
|
+
// An email is named so; an editor's workspace around it, or an app screen
|
|
437
|
+
// holding a kit part, stays in scope.
|
|
438
|
+
return EMAIL_FRAME.test(n.name ?? "") && placed(n) === "kit"
|
|
439
|
+
? `email ${n.name}`
|
|
440
|
+
: null;
|
|
344
441
|
}
|
|
345
442
|
|
|
346
443
|
/** Component ids whose mains the check still needs. */
|
|
@@ -350,7 +447,7 @@ export function neededMains(nodes, mains = {}, options = {}) {
|
|
|
350
447
|
if (!entry?.document) continue;
|
|
351
448
|
const a = analyse(entry, mains, Boolean(options.library));
|
|
352
449
|
for (const n of a.byId.values()) {
|
|
353
|
-
if (n.type !== "INSTANCE" || !n.componentId) continue;
|
|
450
|
+
if (n.type !== "INSTANCE" || !n.componentId || a.out.has(n.id)) continue;
|
|
354
451
|
if (Object.prototype.hasOwnProperty.call(mains, n.componentId)) continue;
|
|
355
452
|
// The screen's own instances (for overrides and slot content), and
|
|
356
453
|
// every table header cell (for its fill).
|
|
@@ -423,7 +520,8 @@ function textOf(n) {
|
|
|
423
520
|
* mains?: Record<string, NodesEntry | null>,
|
|
424
521
|
* options?: { library?: boolean, declaredRoles?: string[] } }} input
|
|
425
522
|
* @returns {{ version: string, findings: Finding[], staleOverrides: Finding[],
|
|
426
|
-
* libraryUpdatePending: Finding[]
|
|
523
|
+
* libraryUpdatePending: Finding[], outOfScope: Finding[],
|
|
524
|
+
* outOfScopeNodes: number }}
|
|
427
525
|
*/
|
|
428
526
|
export function checkConformance({
|
|
429
527
|
nodes,
|
|
@@ -440,6 +538,9 @@ export function checkConformance({
|
|
|
440
538
|
const staleOverrides = [];
|
|
441
539
|
/** @type {Finding[]} */
|
|
442
540
|
const libraryUpdatePending = [];
|
|
541
|
+
/** @type {Finding[]} */
|
|
542
|
+
const outOfScope = [];
|
|
543
|
+
const outNodes = new Set();
|
|
443
544
|
const seen = new Set();
|
|
444
545
|
const push = (list, nodeId, check, detail) => {
|
|
445
546
|
const key = `${nodeId}|${check}|${detail}`;
|
|
@@ -454,12 +555,46 @@ export function checkConformance({
|
|
|
454
555
|
labelRoles.set(label.trim().toLowerCase(), r);
|
|
455
556
|
const librarySetNames = new Set();
|
|
456
557
|
const usedRoles = new Set();
|
|
558
|
+
const analyses = Object.values(nodes ?? {})
|
|
559
|
+
.filter((entry) => entry?.document)
|
|
560
|
+
.map((entry) => analyse(entry, mains, library));
|
|
561
|
+
// The viewer (R5): the email and phone the screens show in the viewer's
|
|
562
|
+
// own places (the App Frame footer, a frame marked self). The same value
|
|
563
|
+
// anywhere else is still the viewer's own.
|
|
564
|
+
const viewer = new Set();
|
|
565
|
+
for (const a of analyses)
|
|
566
|
+
for (const n of a.byId.values()) {
|
|
567
|
+
if (n.type !== "TEXT" || a.out.has(n.id)) continue;
|
|
568
|
+
const ids = identifiers(textOf(n));
|
|
569
|
+
if (ids.length === 0) continue;
|
|
570
|
+
if (
|
|
571
|
+
a
|
|
572
|
+
.ancestors(n)
|
|
573
|
+
.some(
|
|
574
|
+
(x) =>
|
|
575
|
+
VIEWER_PLACE.test(x.name ?? "") || SELF_MARK.test(x.name ?? ""),
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
for (const id of ids) viewer.add(id);
|
|
579
|
+
}
|
|
457
580
|
|
|
458
|
-
for (const
|
|
459
|
-
if (!entry?.document) continue;
|
|
460
|
-
const a = analyse(entry, mains, library);
|
|
581
|
+
for (const a of analyses) {
|
|
461
582
|
for (const set of Object.values(a.componentSets))
|
|
462
583
|
if (set?.remote) librarySetNames.add(set.name);
|
|
584
|
+
for (const [nodeId, why] of a.scopeRoots) {
|
|
585
|
+
let count = 0;
|
|
586
|
+
for (const n of indexTree(a.byId.get(nodeId)).byId.values()) {
|
|
587
|
+
if (!outNodes.has(n.id)) count++;
|
|
588
|
+
outNodes.add(n.id);
|
|
589
|
+
}
|
|
590
|
+
if (count > 0)
|
|
591
|
+
push(
|
|
592
|
+
outOfScope,
|
|
593
|
+
nodeId,
|
|
594
|
+
"outOfScope",
|
|
595
|
+
`${why}: ${count} node${count === 1 ? "" : "s"} outside the app-UI gate (email, Dev issue 116)`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
463
598
|
// Every (node, field) the screen overrode on an instance it placed.
|
|
464
599
|
/** @type {Map<string, { fields: Set<string>, from: any }>} */
|
|
465
600
|
const overrideFields = new Map();
|
|
@@ -481,12 +616,16 @@ export function checkConformance({
|
|
|
481
616
|
// not; one equal to the main's value is a stale mark (DEC-0035).
|
|
482
617
|
for (const [id, { fields, from }] of overrideFields) {
|
|
483
618
|
const node = a.byId.get(id);
|
|
484
|
-
if (!node || !a.shown(node)) continue;
|
|
619
|
+
if (!node || !a.shown(node) || a.out.has(id)) continue;
|
|
485
620
|
const base = baselineInstance(a, mains, from, node);
|
|
486
621
|
if (!base) continue;
|
|
487
622
|
const mainNode = a.mainNodeFor(base, id);
|
|
488
623
|
if (!mainNode) continue;
|
|
489
624
|
const mainStyles = mains[base.componentId]?.styles ?? {};
|
|
625
|
+
// Slot content a library part ships by default: its value there is
|
|
626
|
+
// the library's, not this file's (DEC-0047).
|
|
627
|
+
/** @type {ReturnType<typeof defaultSlotContent> | undefined} */
|
|
628
|
+
let slotDefault;
|
|
490
629
|
const differ = [];
|
|
491
630
|
const equal = [];
|
|
492
631
|
for (const field of [...fields].sort()) {
|
|
@@ -494,7 +633,20 @@ export function checkConformance({
|
|
|
494
633
|
if (exemptPaint(a, from, node, field)) continue;
|
|
495
634
|
const mine = fieldValue(node, field, a.styles);
|
|
496
635
|
const theirs = fieldValue(mainNode, field, mainStyles);
|
|
497
|
-
if (sameValue(mine, theirs))
|
|
636
|
+
if (sameValue(mine, theirs)) {
|
|
637
|
+
equal.push(field);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (slotDefault === undefined)
|
|
641
|
+
slotDefault = defaultSlotContent(a, mains, node);
|
|
642
|
+
if (
|
|
643
|
+
slotDefault &&
|
|
644
|
+
sameValue(
|
|
645
|
+
mine,
|
|
646
|
+
fieldValue(slotDefault.node, field, slotDefault.styles),
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
equal.push(field);
|
|
498
650
|
else
|
|
499
651
|
differ.push(
|
|
500
652
|
`${field} ${shortValue(mine)} (main ${shortValue(theirs)})`,
|
|
@@ -518,7 +670,7 @@ export function checkConformance({
|
|
|
518
670
|
}
|
|
519
671
|
|
|
520
672
|
for (const n of a.byId.values()) {
|
|
521
|
-
if (!a.shown(n)) continue;
|
|
673
|
+
if (!a.shown(n) || a.out.has(n.id)) continue;
|
|
522
674
|
const own = !a.composed(n);
|
|
523
675
|
|
|
524
676
|
if (n.type === "INSTANCE")
|
|
@@ -670,14 +822,17 @@ export function checkConformance({
|
|
|
670
822
|
revealedByPermission(a, n)
|
|
671
823
|
)
|
|
672
824
|
continue;
|
|
673
|
-
|
|
825
|
+
const business = businessField(a, n);
|
|
826
|
+
for (const m of business ? [] : text.matchAll(EMAIL)) {
|
|
674
827
|
const local = m[0].split("@")[0] ?? "";
|
|
675
828
|
if (MASKED.test(local) || ROLE_MAILBOX.test(local)) continue;
|
|
829
|
+
if (viewer.has(m[0].toLowerCase())) continue;
|
|
676
830
|
push(findings, n.id, "otherPersonRevealed", `unmasked email ${m[0]}`);
|
|
677
831
|
}
|
|
678
|
-
for (const m of text.matchAll(PHONE)) {
|
|
832
|
+
for (const m of business ? [] : text.matchAll(PHONE)) {
|
|
679
833
|
const digits = m[0].replace(/\D/g, "");
|
|
680
|
-
if (DATE.test(m[0]) || MASKED.test(m[0]))
|
|
834
|
+
if (DATE.test(m[0]) || MASKED.test(m[0]) || viewer.has(digits))
|
|
835
|
+
continue;
|
|
681
836
|
const phoneShaped =
|
|
682
837
|
m[0].startsWith("+") || LOCAL_PHONE.test(m[0].trim());
|
|
683
838
|
if (phoneShaped && digits.length >= 10 && digits.length <= 15)
|
|
@@ -703,14 +858,19 @@ export function checkConformance({
|
|
|
703
858
|
if (
|
|
704
859
|
header.type !== "TEXT" ||
|
|
705
860
|
!CUSTOMER_HEADER.test(textOf(header)) ||
|
|
706
|
-
!a.shown(header)
|
|
861
|
+
!a.shown(header) ||
|
|
862
|
+
a.out.has(header.id)
|
|
707
863
|
)
|
|
708
864
|
continue;
|
|
709
865
|
// A column header sits in a table, list or grid; a "Customer" form
|
|
710
866
|
// label does not head anything.
|
|
711
867
|
const table = a
|
|
712
868
|
.ancestors(header)
|
|
713
|
-
.find(
|
|
869
|
+
.find(
|
|
870
|
+
(x) =>
|
|
871
|
+
TABLE_CONTAINER.test(x.name ?? "") &&
|
|
872
|
+
!NOT_A_TABLE.test(x.name ?? ""),
|
|
873
|
+
);
|
|
714
874
|
// The header's cell bounds its column, unless the parent is the whole
|
|
715
875
|
// header row: then the header text's own box does.
|
|
716
876
|
const cell = a.parent.get(header.id);
|
|
@@ -723,7 +883,13 @@ export function checkConformance({
|
|
|
723
883
|
: header.absoluteBoundingBox;
|
|
724
884
|
if (!table || !box) continue;
|
|
725
885
|
for (const n of indexTree(table).byId.values()) {
|
|
726
|
-
if (
|
|
886
|
+
if (
|
|
887
|
+
n.type !== "TEXT" ||
|
|
888
|
+
n.id === header.id ||
|
|
889
|
+
!a.shown(n) ||
|
|
890
|
+
a.out.has(n.id)
|
|
891
|
+
)
|
|
892
|
+
continue;
|
|
727
893
|
if (!screenText(a, n, overridden)) continue;
|
|
728
894
|
const b = n.absoluteBoundingBox;
|
|
729
895
|
const text = textOf(n);
|
|
@@ -760,7 +926,74 @@ export function checkConformance({
|
|
|
760
926
|
`declared role ${role.role} has no ${role.componentName} instance`,
|
|
761
927
|
);
|
|
762
928
|
}
|
|
763
|
-
return {
|
|
929
|
+
return {
|
|
930
|
+
version: VERSION,
|
|
931
|
+
findings,
|
|
932
|
+
staleOverrides,
|
|
933
|
+
libraryUpdatePending,
|
|
934
|
+
outOfScope,
|
|
935
|
+
outOfScopeNodes: outNodes.size,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** The emails (lower-cased) and phone digits a text shows. */
|
|
940
|
+
function identifiers(text) {
|
|
941
|
+
return [
|
|
942
|
+
...[...text.matchAll(EMAIL)].map((m) => m[0].toLowerCase()),
|
|
943
|
+
...[...text.matchAll(PHONE)].map((m) => m[0].replace(/\D/g, "")),
|
|
944
|
+
];
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The labels of the field a text fills: its field frame's name
|
|
949
|
+
* (field/Caller ID), a Field instance's label, and its own layer name.
|
|
950
|
+
*/
|
|
951
|
+
function fieldLabels(a, n) {
|
|
952
|
+
const labels = [];
|
|
953
|
+
if (n.name && n.name !== n.characters) labels.push(n.name);
|
|
954
|
+
for (const x of a.ancestors(n).slice(0, 4)) {
|
|
955
|
+
const m = FIELD_FRAME.exec(x.name ?? "");
|
|
956
|
+
if (m) labels.push(m[2]);
|
|
957
|
+
for (const [prop, v] of Object.entries(x.componentProperties ?? {}))
|
|
958
|
+
if (/^label\b/i.test(prop) && typeof v?.value === "string")
|
|
959
|
+
labels.push(v.value);
|
|
960
|
+
}
|
|
961
|
+
return labels;
|
|
962
|
+
}
|
|
963
|
+
/** A business contact field (support, caller id, sender, business address). */
|
|
964
|
+
function businessField(a, n) {
|
|
965
|
+
const labels = fieldLabels(a, n);
|
|
966
|
+
return (
|
|
967
|
+
labels.some((l) => BUSINESS_FIELD.test(l)) &&
|
|
968
|
+
!labels.some((l) => PERSON_FIELD.test(l))
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* The node a library part's main draws, by default, where `node` sits in
|
|
974
|
+
* slot content: the nearest slot above it, in the instance that owns that
|
|
975
|
+
* slot, matched by the layer names from the slot down. Null when the node
|
|
976
|
+
* isn't in slot content, the owner's main can't be read, or its default
|
|
977
|
+
* content has no such layer.
|
|
978
|
+
*/
|
|
979
|
+
function defaultSlotContent(a, mains, node) {
|
|
980
|
+
const up = a.ancestors(node);
|
|
981
|
+
const slotAt = up.findIndex((x) => x.type === "SLOT");
|
|
982
|
+
if (slotAt === -1) return null;
|
|
983
|
+
const slot = up[slotAt];
|
|
984
|
+
const owner = up.slice(slotAt + 1).find((x) => x.type === "INSTANCE");
|
|
985
|
+
const tree = owner ? a.mainTree(owner.componentId) : null;
|
|
986
|
+
if (!owner || !tree) return null;
|
|
987
|
+
const main = mains[owner.componentId];
|
|
988
|
+
const names = [...up.slice(0, slotAt).reverse(), node].map((x) => x.name);
|
|
989
|
+
let level = [...tree.byId.values()].filter(
|
|
990
|
+
(x) => x.type === "SLOT" && x.name === slot.name,
|
|
991
|
+
);
|
|
992
|
+
for (const name of names)
|
|
993
|
+
level = level.flatMap((x) =>
|
|
994
|
+
(x.children ?? []).filter((c) => c.name === name),
|
|
995
|
+
);
|
|
996
|
+
return level[0] ? { node: level[0], styles: main.styles ?? {} } : null;
|
|
764
997
|
}
|
|
765
998
|
|
|
766
999
|
/**
|
|
@@ -865,7 +1098,12 @@ function handDrawnButton(n) {
|
|
|
865
1098
|
}
|
|
866
1099
|
function isSelf(a, n) {
|
|
867
1100
|
const up = a.ancestors(n);
|
|
868
|
-
if (
|
|
1101
|
+
if (
|
|
1102
|
+
up.some(
|
|
1103
|
+
(x) => SELF_CONTAINER.test(x.name ?? "") || SELF_MARK.test(x.name ?? ""),
|
|
1104
|
+
)
|
|
1105
|
+
)
|
|
1106
|
+
return true;
|
|
869
1107
|
const accountCard = (x) => {
|
|
870
1108
|
const set =
|
|
871
1109
|
x.type === "INSTANCE"
|