@yschimke/compose-design-map 1.44.0 → 1.46.0

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.
Files changed (2) hide show
  1. package/design-map.mjs +149 -13
  2. package/package.json +1 -1
package/design-map.mjs CHANGED
@@ -68,9 +68,37 @@ const LIGHT_MODE = "Light";
68
68
  /** The tag discovery puts in the id of an `@OverrideVariant` reseed: `…_VARIANT_<name>`. */
69
69
  const VARIANT_TAG = "_VARIANT_";
70
70
 
71
+ /**
72
+ * The head of a capture's id with the `_VARIANT_<name>` reseed suffix removed, and whether there
73
+ * was one — `{ head, reseed }`.
74
+ *
75
+ * `_VARIANT_` is a legal substring of a Kotlin function name, so a plain `includes()` read the BASE
76
+ * capture of a composable called `Icon_VARIANT_Only` as a generated reseed. Every `continue` guarded
77
+ * by that test then dropped the composable outright, including its explicit `noReference` — a
78
+ * record whose whole purpose is to survive into the diagnostics.
79
+ *
80
+ * Discovery appends the tag to the id it has already built (`base.id + "_VARIANT_<name>"`), so the
81
+ * marker of an actual reseed is the LAST one and the function's own name is still in front of it.
82
+ * Splitting there and requiring the `.<functionName>` marker to survive in the head distinguishes
83
+ * the two: `…FooKt.Icon_VARIANT_Only_Light` splits to `…FooKt.Icon`, which no longer contains
84
+ * `.Icon_VARIANT_Only`, so it is a base capture; a real reseed of `Icon`,
85
+ * `…FooKt.Icon_Light_VARIANT_pressed`, splits to `…FooKt.Icon_Light`, which still contains `.Icon`.
86
+ *
87
+ * A capture that names no function cannot be told apart this way, so it keeps the old reading.
88
+ */
89
+ function splitVariantTag(preview) {
90
+ const id = String(preview.id ?? "");
91
+ const at = id.lastIndexOf(VARIANT_TAG);
92
+ if (at < 0) return { head: id, reseed: false };
93
+ const head = id.slice(0, at);
94
+ const marker = preview.functionName ? `.${preview.functionName}` : null;
95
+ if (marker && !head.includes(marker)) return { head: id, reseed: false };
96
+ return { head, reseed: true };
97
+ }
98
+
71
99
  /** Whether a capture is an `@OverrideVariant` reseed rather than a base capture. */
72
100
  function isVariantCapture(preview) {
73
- return String(preview.id ?? "").includes(VARIANT_TAG);
101
+ return splitVariantTag(preview).reseed;
74
102
  }
75
103
 
76
104
  /**
@@ -80,13 +108,14 @@ function isVariantCapture(preview) {
80
108
  * is the `@Preview` name a multipreview gives the capture — `Light` / `Dark` for a themed pair, and
81
109
  * EMPTY for an unnamed single capture. Splitting on the function name rather than pattern-matching
82
110
  * the tail is what lets a dark-first catalog be recognised at all: its ids carry no mode segment to
83
- * match against.
111
+ * match against. The reseed suffix comes off first, via [splitVariantTag] rather than a bare
112
+ * substring split, so a function whose own name contains `_VARIANT_` keeps its whole identity.
84
113
  *
85
114
  * A capture whose id does not contain its own function name is left as its own subject with an
86
115
  * empty mode — it cannot be grouped with anything, so it selects itself.
87
116
  */
88
117
  export function captureIdentity(preview) {
89
- const head = String(preview.id ?? "").split(VARIANT_TAG)[0];
118
+ const { head } = splitVariantTag(preview);
90
119
  const marker = preview.functionName ? `.${preview.functionName}` : null;
91
120
  const at = marker ? head.lastIndexOf(marker) : -1;
92
121
  if (at < 0) return { subject: head, mode: "" };
@@ -408,6 +437,33 @@ export function variantSeeds(preview) {
408
437
  }
409
438
 
410
439
  /** The name a variant render goes by, for a report and for the design-map `state` slot. */
440
+ /**
441
+ * How a folded variant names itself in the reference diagnostics: `<parentId> [<axis>=<value> …]`.
442
+ *
443
+ * Not the bare `componentId` — that is the PARENT's id for a VARIANT role, so reporting a variant's
444
+ * stated absence under it would read as a finding about a parent that may carry a perfectly good
445
+ * reference.
446
+ *
447
+ * Not [variantName] either, though that is what the variant is called elsewhere: it narrows to the
448
+ * `state` alone whenever there is one, so two variants of a parent sharing a state and differing
449
+ * only in `props` produce the SAME name. These labels are map keys, so a collision silently drops
450
+ * one of the two stated absences — losing exactly the record this diagnostic exists to keep. The
451
+ * full seed vector is what distinguishes them, so the label is built from that.
452
+ */
453
+ export function variantAbsenceId(preview) {
454
+ const parent = preview.catalog?.componentId ?? "(unnamed)";
455
+ const axes = variantSeeds(preview)
456
+ .map((seed) => `${seed.key}=${seed.raw}`)
457
+ .join(" ");
458
+ // `state` and `props` are both optional, so a variant CAN declare `noReference` and name no axis
459
+ // at all. Falling back to the bare parent id then publishes "Button — <reason>" for a parent that
460
+ // may hold a perfectly good reference: it reads as a finding about the parent, and says nothing
461
+ // about which variant the reason belongs to. The function name is what distinguishes such a
462
+ // variant, so it stands in for the axes it did not give.
463
+ const label = axes || preview.functionName || captureIdentity(preview).subject;
464
+ return label ? `${parent} [${label}]` : parent;
465
+ }
466
+
411
467
  function variantName(preview, seeds) {
412
468
  const catalog = preview.catalog;
413
469
  const cell = preview.overrides?.name;
@@ -492,8 +548,28 @@ export function variantRendersByComponent(previews, selection = selectCaptures(p
492
548
  const seeds = variantSeeds(preview);
493
549
  if (!seeds.length) continue;
494
550
 
551
+ // A `@CatalogVariant` may state its OWN kit correspondence, and either spelling changes what a
552
+ // resolver should do with this render. Without reading them here a variant's declaration is
553
+ // emitted under the parent's `reference` regardless — which is precisely the mispairing the two
554
+ // fields were added to prevent.
555
+ //
556
+ // `noReference` says the kit exports no cell this render could honestly pair with. Handing it
557
+ // to the resolver anyway scores it against the PARENT's picture, so it is dropped: the absence
558
+ // is already reported once, as a stated absence, and a render that says "there is nothing to
559
+ // compare me to" must not then be compared.
560
+ if (isCatalogVariant && catalog.noReference) continue;
561
+
495
562
  const list = byComponent.get(catalog.componentId) ?? [];
496
- list.push({ previewId: preview.id, name: variantName(preview, seeds), seeds });
563
+ list.push({
564
+ previewId: preview.id,
565
+ name: variantName(preview, seeds),
566
+ seeds,
567
+ // `reference` names the variant's own kit cell. Carried onto the render so a resolver pairs
568
+ // that handle instead of deriving one from the parent's by seed. Additive to the sidecar's
569
+ // shape — a resolver that does not read it sees exactly what it saw before, which is why the
570
+ // schema string does not move.
571
+ ...(isCatalogVariant && catalog.reference ? { reference: catalog.reference } : {}),
572
+ });
497
573
  byComponent.set(catalog.componentId, list);
498
574
  }
499
575
  return byComponent;
@@ -529,29 +605,87 @@ export function projectDesignMap(previews, opts = {}) {
529
605
  * however many captures it publishes.
530
606
  */
531
607
  const unmappedIds = new Map();
608
+ /**
609
+ * Stated absences, keyed by a COLLISION-SAFE identity and carrying the display label separately.
610
+ *
611
+ * A component keys on its own id and a variant on its capture subject, each behind its own
612
+ * prefix. Both are free-form strings from different namespaces — a `@CatalogComponent(id = …)`
613
+ * may legally be spelled like a capture subject — so sharing one map without tagging the domain
614
+ * is the same collision one namespace over. Never keyed on the rendered label. The label is built by joining `key=value` pairs, and
615
+ * discovery splits an annotation prop at its FIRST `=` only, so a value may legally contain both
616
+ * a space and an `=`: `props = ["a=b c=d"]` is one prop, and renders identically to the two props
617
+ * `a=b` and `c=d`. Keying on that string would silently drop one of two distinct absences — the
618
+ * same data loss this diagnostic exists to prevent, one level subtler.
619
+ */
532
620
  const statedAbsentIds = new Map();
621
+ /** Capture subjects whose absence is stated — a variant is named by subject, not by component. */
622
+ const referencelessSubjects = new Set();
623
+ /** Component ids whose absence is stated, for the componentId-keyed ambiguity filter below. */
624
+ const statedAbsentComponentIds = new Set();
533
625
  for (const preview of previews) {
534
626
  const catalog = preview.catalog;
535
- if (!catalog || catalog.role !== "COMPONENT" || catalog.reference) continue;
627
+ if (!catalog || catalog.reference) continue;
536
628
  if (isVariantCapture(preview)) continue;
629
+ // A `@CatalogVariant` can now state its own kit correspondence, so its absence is reported
630
+ // like a component's. Scanning components alone meant folding a render under a parent silently
631
+ // dropped its stated absence from this accounting — a catalog could lose an audit signal by
632
+ // restructuring, which is exactly what `statedAbsent` exists to prevent. `--strict` counts a
633
+ // variant's stated absence the same as a component's: someone looked, and wrote down what they
634
+ // found, wherever the render sits.
635
+ //
636
+ // Reported under the variant's own label, not its parent's id (`componentId` is the PARENT for
637
+ // a VARIANT), or a folded variant's reason would be reported against a parent that may have a
638
+ // perfectly good reference of its own. The label is for reading; the KEY is the capture
639
+ // subject, which cannot collide — see the map's own note above.
640
+ if (catalog.role === "VARIANT") {
641
+ if (!catalog.noReference) continue; // silence under a parent is the parent's business
642
+ const subject = captureIdentity(preview).subject;
643
+ statedAbsentIds.set(`subject:${subject}`, {
644
+ label: variantAbsenceId(preview),
645
+ reason: catalog.noReference,
646
+ });
647
+ // The ambiguity filter below matches on componentId, which for a VARIANT is the PARENT's --
648
+ // so a variant's own stated absence could not suppress its own ambiguous-mode record, and a
649
+ // parent carrying a good reference left it unsuppressed. `--strict --allow-stated-absence`
650
+ // then failed on precisely the case that flag exists to accept. A variant render has its own
651
+ // capture subject, so record that instead of trying to name it by component.
652
+ referencelessSubjects.add(subject);
653
+ continue;
654
+ }
655
+ if (catalog.role !== "COMPONENT") continue;
537
656
  const id = catalog.componentId;
538
- if (catalog.noReference) statedAbsentIds.set(id, catalog.noReference);
539
- else if (!statedAbsentIds.has(id)) unmappedIds.set(id, true);
657
+ if (catalog.noReference) {
658
+ statedAbsentIds.set(`component:${id}`, { label: id, reason: catalog.noReference });
659
+ statedAbsentComponentIds.add(id);
660
+ // Unconditional: [unmapped] below filters this set through [statedAbsentComponentIds], which
661
+ // is the only correct place for it — a component's stated absence may be read from a LATER
662
+ // capture than the one that first reports it unmapped, and a guard here can only see what has
663
+ // been read so far. The guard that used to stand here tested a bare `id` against a map now
664
+ // keyed `component:<id>` / `subject:<id>`, so for an ordinary id it never matched, and for a
665
+ // component legally named `component:X` it matched the wrong entry and dropped it from
666
+ // `unmapped` — letting `--strict` pass over a component with no reference and no reason.
667
+ } else unmappedIds.set(id, true);
540
668
  }
541
669
  /** Components carrying neither a reference nor a stated reason for its absence. */
542
- const unmapped = [...unmappedIds.keys()].filter((id) => !statedAbsentIds.has(id));
670
+ const unmapped = [...unmappedIds.keys()].filter((id) => !statedAbsentComponentIds.has(id));
543
671
  /**
544
672
  * Components whose reference is absent for a STATED reason. Reported apart from `unmapped`
545
673
  * because they are the opposite situation: someone looked, and what they found is that the kit
546
674
  * has nothing live to point at. Rolling the two together is what made a retired pattern read as
547
675
  * neglect.
548
676
  */
549
- const statedAbsent = [...statedAbsentIds].map(([componentId, reason]) => ({
550
- componentId,
677
+ const statedAbsent = [...statedAbsentIds.values()].map(({ label, reason }) => ({
678
+ componentId: label,
551
679
  reason,
552
680
  }));
553
- /** Every component that reaches no reference, however its absence was spelled. */
554
- const referencelessIds = new Set([...unmapped, ...statedAbsentIds.keys()]);
681
+ /**
682
+ * Every COMPONENT that reaches no reference, however its absence was spelled — the set the
683
+ * ambiguity filter tests `componentIds` against. A variant's key is a capture subject rather than
684
+ * a component id, so it is deliberately absent here and suppressed through
685
+ * [referencelessSubjects] instead; putting subjects in this set would only add entries no
686
+ * componentId can ever equal.
687
+ */
688
+ const referencelessIds = new Set([...unmapped, ...statedAbsentComponentIds]);
555
689
 
556
690
  for (const preview of previews) {
557
691
  const catalog = preview.catalog;
@@ -614,7 +748,9 @@ export function projectDesignMap(previews, opts = {}) {
614
748
  // absence already reported above, and under --strict it would be a second, unfixable failure
615
749
  // for the same component.
616
750
  ambiguousMode: selection.ambiguous.filter(
617
- (a) => !a.componentIds.length || a.componentIds.some((id) => !referencelessIds.has(id)),
751
+ (a) =>
752
+ !referencelessSubjects.has(a.subject) &&
753
+ (!a.componentIds.length || a.componentIds.some((id) => !referencelessIds.has(id))),
618
754
  ),
619
755
  variantRenders: declarations.reduce((n, d) => n + d.renders.length, 0),
620
756
  withSet: components.filter((c) => c.refSet).length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yschimke/compose-design-map",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "description": "Project a compose-preview discovery manifest into design-parity's design-map.json, plus a sidecar of unresolved variant declarations. Dependency-free.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",