@nodii/dev 0.6.17 → 0.6.21

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.
@@ -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.21",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {