@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 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.17";
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
- export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
147
- const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json");
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.3), so both give one verdict:
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
- if (flags["change-set"] !== undefined) return designCheckChangeSet(target, flags, { request, ...(read ? { read } : {}) });
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 ?? [], warnings: r.warnings ?? [], staleVersion: r.staleVersion ?? null };
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 { CliError, devRequest, encode } from "./core.js";
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
- export async function figmaResubmit(id, file, { request = devRequest, read = (f) => readFile(f, "utf8") } = {}) {
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 request("POST", `/api/v1/design/change-sets/${encode(id)}/resubmit`, body);
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
- export const figmaSubmit = (id, payload, { request = devRequest } = {}) =>
318
- changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
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)("POST", `/api/v1/design/change-sets/${encode(id)}/conformance`, body);
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, detail }; only `findings` fail
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.3";
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 entry of Object.values(nodes ?? {})) {
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)) equal.push(field);
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
- for (const m of text.matchAll(EMAIL)) {
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])) continue;
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((x) => /table|list|grid/i.test(x.name ?? ""));
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 (n.type !== "TEXT" || n.id === header.id || !a.shown(n)) continue;
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 { version: VERSION, findings, staleOverrides, libraryUpdatePending };
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 (up.some((x) => SELF_CONTAINER.test(x.name ?? ""))) return true;
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.17",
3
+ "version": "0.6.20",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {