@nodii/dev 0.6.16 → 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 +27 -1
- package/dist/cli.js +12 -7
- package/dist/core.js +16 -7
- package/dist/design.js +15 -9
- package/dist/devops-openapi.json +1 -1
- package/dist/figma.js +172 -12
- package/dist/vendor/ds-conformance.mjs +259 -21
- package/package.json +1 -1
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;
|
|
@@ -106,10 +258,10 @@ export function figmaChangeSetText(cs) {
|
|
|
106
258
|
}
|
|
107
259
|
|
|
108
260
|
// Scoped design locks (DEC-0032, MW1-57.6 to 57.9). A scope is written
|
|
109
|
-
// kind:id on the command line: component:NODE, page:NODE,
|
|
110
|
-
// create:PAGE:NAME. A node id from a figma.com link (12-34) is Figma's 12:34.
|
|
261
|
+
// kind:id on the command line: component:NODE, page:NODE, section:NODE,
|
|
262
|
+
// foundations, or create:PAGE:NAME. A node id from a figma.com link (12-34) is Figma's 12:34.
|
|
111
263
|
const nodeId = (text) => (/^\d+-\d+$/.test(text) ? text.replace("-", ":") : text);
|
|
112
|
-
const SCOPE_USAGE = "--scope component:NODE_ID | page:NODE_ID | foundations | create:PAGE_ID:NAME";
|
|
264
|
+
const SCOPE_USAGE = "--scope component:NODE_ID | page:NODE_ID | section:NODE_ID | foundations | create:PAGE_ID:NAME";
|
|
113
265
|
|
|
114
266
|
export function parseScope(value) {
|
|
115
267
|
const text = typeof value === "string" ? value.trim() : "";
|
|
@@ -119,7 +271,7 @@ export function parseScope(value) {
|
|
|
119
271
|
if (create && create[2].trim() && create[2].trim().length <= 200)
|
|
120
272
|
return { kind: "create", pageId: nodeId(create[1]), name: create[2].trim() };
|
|
121
273
|
// Dev takes a scope at a top-level node: a plain id, never an instance's (I1:2;3:4).
|
|
122
|
-
const node = text.match(/^(component|page):(\d+[:-]\d+)$/);
|
|
274
|
+
const node = text.match(/^(component|page|section):(\d+[:-]\d+)$/);
|
|
123
275
|
if (!node) throw bad();
|
|
124
276
|
return { kind: node[1], nodeId: nodeId(node[2]) };
|
|
125
277
|
}
|
|
@@ -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"
|