@vegastack/design 0.8.34 → 0.8.36

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/bin/doctor.mjs CHANGED
@@ -22,6 +22,11 @@
22
22
  // (`prose` · `default` · `full`) and pads with the shared page gutter, so a `max-w-*`, `mx-*`,
23
23
  // `w-*` or padding class on one is a page choosing its own width or gutter, and fails.
24
24
  //
25
+ // And it warns where code works around a DS part instead of using it — a Tailwind `!` on a ui part,
26
+ // a width class on a popover or menu popup, a hand-written `<header>` inside overlay content, an
27
+ // `AppShellPage size` that disagrees with the route map. Those four warn by default and fail under
28
+ // `--strict`, so a consumer can take the release, fix what they report, and then turn the gate on.
29
+ //
25
30
  // Read-only: it never writes, installs, or edits. Exit 0 = all good, 1 = a real problem,
26
31
  // so it composes into CI as `vegastack-design doctor`.
27
32
 
@@ -52,11 +57,19 @@ Usage: vegastack-design doctor [options]
52
57
 
53
58
  Checks a consuming project's VegaStack setup and reports what is wrong and how to fix it.
54
59
  Also scans the project's own source (not node_modules, build output, or the components.json
55
- \`ui\` alias directory) for vocabulary the shadcn reset retired, and for an AppShellPage that sets
56
- its own width or gutter, and reports each as file:line.
60
+ \`ui\` alias directory) and reports each finding as file:line:
61
+
62
+ retired vocabulary a utility, token or component the shadcn reset retired (fails)
63
+ page layout an AppShellPage class that sets the page's width or gutter (fails)
64
+ important on DS parts a Tailwind \`!\` modifier in the className of a ui part (warns)
65
+ popup width w-*/min-w-*/max-w-* on a popover, menu or select popup (warns)
66
+ overlay header a hand-written <header> inside Sheet/Drawer/Dialog content (warns)
67
+ page width vs route map an AppShellPage size in an App Router page or layout that
68
+ disagrees with the definePageWidths route map (warns)
57
69
 
58
70
  Options:
59
71
  --dir <path> Project root to inspect (default: the current directory)
72
+ --strict Fail (exit 1) on the four composition checks instead of warning
60
73
  -h, --help Show this help
61
74
 
62
75
  Exit codes: 0 = no problems · 1 = at least one failure · 2 = bad usage
@@ -384,6 +397,9 @@ function importsIntoUi(specDir, { file, uiAliases, uiDirs }) {
384
397
  * opens only where an expression can begin (after `=`, `(`, `,`, `[`, `{`, `:`, `?`, an operator,
385
398
  * `=>`, or a keyword such as `return`), and a comment only after whitespace or code punctuation,
386
399
  * never after `:` (`https://`) or a word (`src/*.ts`).
400
+ *
401
+ * `blankStrings` blanks EVERY string's content (quotes kept): the structure copy the composition
402
+ * scan finds tags in, where no string may open or close one.
387
403
  */
388
404
  const EXPRESSION_KEYWORDS = new Set([
389
405
  "return",
@@ -419,9 +435,34 @@ function canStartExpression(src, i) {
419
435
  }
420
436
  const canStartComment = (src, i) => i === 0 || /[\s;,{}()[\]]/.test(src[i - 1]);
421
437
 
438
+ /**
439
+ * The index of the quote closing the string that opens at `i` (or of the line end / file end where
440
+ * an unterminated one gives up). A template's `${…}` is skipped whole — braces counted, strings and
441
+ * nested templates inside it skipped — so a backtick in an interpolation does not end the template.
442
+ */
443
+ function stringEnd(src, i) {
444
+ const q = src[i];
445
+ let j = i + 1;
446
+ while (j < src.length && src[j] !== q) {
447
+ if (src[j] === "\\") j += 1;
448
+ else if (src[j] === "\n" && q !== "`") break;
449
+ else if (q === "`" && src[j] === "$" && src[j + 1] === "{") {
450
+ let depth = 0;
451
+ for (j += 1; j < src.length; j += 1) {
452
+ const ch = src[j];
453
+ if (ch === '"' || ch === "'" || ch === "`") j = stringEnd(src, j);
454
+ else if (ch === "{") depth += 1;
455
+ else if (ch === "}" && (depth -= 1) === 0) break;
456
+ }
457
+ }
458
+ j += 1;
459
+ }
460
+ return j;
461
+ }
462
+
422
463
  export function maskNonUsage(
423
464
  src,
424
- { script = true, lineComments = script } = {},
465
+ { script = true, lineComments = script, blankStrings = false } = {},
425
466
  ) {
426
467
  const out = src.split("");
427
468
  const blank = (from, to) => {
@@ -454,13 +495,9 @@ export function maskNonUsage(
454
495
  (c === '"' || c === "'" || c === "`") &&
455
496
  (!script || canStartExpression(src, i))
456
497
  ) {
457
- let j = i + 1;
458
- while (j < src.length && src[j] !== c) {
459
- if (src[j] === "\\") j += 1;
460
- else if (src[j] === "\n" && c !== "`") break;
461
- j += 1;
462
- }
463
- if (script && isProse(src.slice(i + 1, j))) blank(i + 1, j);
498
+ const j = stringEnd(src, i);
499
+ if (script && (blankStrings || isProse(src.slice(i + 1, j))))
500
+ blank(i + 1, j);
464
501
  i = j + 1;
465
502
  } else {
466
503
  i += 1;
@@ -641,6 +678,719 @@ export function scanPageLayout(root, { skipDirs = [] } = {}) {
641
678
  return { findings, deprecated };
642
679
  }
643
680
 
681
+ // ---- composition: DS parts used as designed --------------------------------------------------
682
+ // Four lexical checks over the project's own JSX. No parser: each file is read twice over the same
683
+ // offsets — a STRUCTURE copy with comments and every string's content blanked (quotes kept), where
684
+ // tags, attributes and braces are found, and the source itself, where values are read. Blanking the
685
+ // strings is what keeps a `"</SheetContent>"` or a `'<header>'` in a string from opening or closing
686
+ // anything, and an apostrophe in JSX text ("Don't") from swallowing the rest of a tag.
687
+ // They report as WARNINGS by default and as failures under `--strict`, so a consumer can adopt a
688
+ // new release, fix what it reports, and then turn the gate on.
689
+ //
690
+ // Known limits, all toward silence: a part re-exported through the project's own module is not a
691
+ // ui import; a `<header>` counts only when it sits inside the overlay content in the SAME file (a
692
+ // child component's header is not seen); a JSX attribute string ending in a backslash
693
+ // (`title="C:\"`) or spanning lines can desynchronise that tag's scan; route-map rows count only as
694
+ // string literals; a regex literal holding a brace inside a JSX attribute expression can end that
695
+ // expression early; and an `AppShellPage` outside an App Router page or layout file, or with a
696
+ // `size={…}` expression, is counted rather than compared.
697
+
698
+ /** Popover and menu popups whose width the part owns: `PopoverContent size`, or the longest item. */
699
+ export const WIDTH_OWNING_CONTENT = [
700
+ "PopoverContent",
701
+ "DropdownMenuContent",
702
+ "ContextMenuContent",
703
+ "MenubarContent",
704
+ "SelectContent",
705
+ "ComboboxContent",
706
+ "HoverCardContent",
707
+ ];
708
+
709
+ /** Overlay panels whose header the part owns, with the part that renders it. */
710
+ export const OVERLAY_HEADERS = {
711
+ SheetContent: "SheetHeader",
712
+ DrawerContent: "DrawerHeader",
713
+ DialogContent: "DialogHeader",
714
+ AlertDialogContent: "AlertDialogHeader",
715
+ };
716
+
717
+ /** From a `{` at `j` in the structure copy, the index just past its matching `}`. */
718
+ function skipBraces(scan, j) {
719
+ let depth = 0;
720
+ for (let k = j; k < scan.length; k += 1) {
721
+ const c = scan[k];
722
+ if (c === "{") depth += 1;
723
+ else if (c === "}") {
724
+ depth -= 1;
725
+ if (depth === 0) return k + 1;
726
+ }
727
+ }
728
+ return scan.length;
729
+ }
730
+
731
+ /** From a `<` at `j` (TSX type arguments: `<DataGrid<Row> …>`), the index just past its `>`. */
732
+ function skipTypeArguments(scan, j) {
733
+ let depth = 0;
734
+ let braces = 0; // an object type: `<DataGrid<{ id: string; name: string }>`
735
+ const limit = Math.min(scan.length, j + 2000);
736
+ for (let k = j; k < limit; k += 1) {
737
+ const c = scan[k];
738
+ if (c === "{") braces += 1;
739
+ else if (c === "}") {
740
+ if ((braces -= 1) < 0) return -1;
741
+ } else if (braces > 0) continue;
742
+ else if (c === "<") depth += 1;
743
+ else if (c === ">" && scan[k - 1] !== "=") {
744
+ depth -= 1;
745
+ if (depth === 0) return k + 1;
746
+ } else if (c === ";") return -1;
747
+ }
748
+ return -1;
749
+ }
750
+
751
+ /** The `>` closing an opening tag whose attributes start at `j`, or -1 when it is not a tag. */
752
+ function tagEnd(scan, j) {
753
+ const limit = Math.min(scan.length, j + 20000);
754
+ let k = j;
755
+ while (k < limit) {
756
+ const c = scan[k];
757
+ if (c === '"' || c === "'") {
758
+ const close = scan.indexOf(c, k + 1);
759
+ if (close === -1) return -1;
760
+ k = close + 1;
761
+ continue;
762
+ }
763
+ if (c === "{") {
764
+ k = skipBraces(scan, k);
765
+ continue;
766
+ }
767
+ if (c === ">") return k;
768
+ if (c === "<" || c === ";" || c === "}" || c === ")") return -1;
769
+ k += 1;
770
+ }
771
+ return -1;
772
+ }
773
+
774
+ const OPEN_TAG = /<([A-Za-z][\w.]*)(?=[\s/><])/y;
775
+ const CLOSE_TAG = /<\/\s*([A-Za-z][\w.]*)?\s*>/y;
776
+
777
+ /** Every JSX opening and closing tag in a structure copy, in source order. */
778
+ export function jsxTags(scan) {
779
+ const tags = [];
780
+ for (let i = scan.indexOf("<"); i !== -1; i = scan.indexOf("<", i + 1)) {
781
+ CLOSE_TAG.lastIndex = i;
782
+ const close = CLOSE_TAG.exec(scan);
783
+ if (close) {
784
+ tags.push({ kind: "close", name: close[1] ?? "", index: i });
785
+ continue;
786
+ }
787
+ OPEN_TAG.lastIndex = i;
788
+ const open = OPEN_TAG.exec(scan);
789
+ if (!open) continue;
790
+ let start = i + open[0].length;
791
+ if (scan[start] === "<") {
792
+ start = skipTypeArguments(scan, start);
793
+ if (start === -1) continue;
794
+ }
795
+ const end = tagEnd(scan, start);
796
+ if (end === -1) continue;
797
+ tags.push({
798
+ kind: "open",
799
+ name: open[1],
800
+ attrsAt: start,
801
+ attrsEnd: end,
802
+ selfClosing: scan[end - 1] === "/",
803
+ index: i,
804
+ });
805
+ }
806
+ return tags;
807
+ }
808
+
809
+ /**
810
+ * An opening tag's own attributes — never one on a JSX element nested in an attribute's value
811
+ * (`render={<div className="…" />}`): `name → { from, to, kind }`, `kind` "string" or "expr".
812
+ */
813
+ export function tagAttributes(tag, scan) {
814
+ const out = new Map();
815
+ const spreads = []; // the order of each `{...props}`, which may set any attribute after it
816
+ out.set("...", spreads);
817
+ const NAME = /[^\s=/>{"']+/y;
818
+ let k = tag.attrsAt;
819
+ while (k < tag.attrsEnd) {
820
+ const c = scan[k];
821
+ if (c === "{") {
822
+ spreads.push(out.size);
823
+ k = skipBraces(scan, k);
824
+ continue;
825
+ }
826
+ NAME.lastIndex = k;
827
+ const name = NAME.exec(scan);
828
+ if (!name) {
829
+ k += 1;
830
+ continue;
831
+ }
832
+ let v = k + name[0].length;
833
+ while (v < tag.attrsEnd && /\s/.test(scan[v])) v += 1;
834
+ if (scan[v] !== "=") {
835
+ out.set(name[0], { kind: "flag", order: out.size });
836
+ k = v;
837
+ continue;
838
+ }
839
+ v += 1;
840
+ while (v < tag.attrsEnd && /\s/.test(scan[v])) v += 1;
841
+ const q = scan[v];
842
+ if (q === '"' || q === "'") {
843
+ const close = scan.indexOf(q, v + 1);
844
+ out.set(name[0], {
845
+ kind: "string",
846
+ from: v + 1,
847
+ to: close,
848
+ order: out.size,
849
+ });
850
+ k = close + 1;
851
+ } else if (q === "{") {
852
+ const close = skipBraces(scan, v);
853
+ out.set(name[0], {
854
+ kind: "expr",
855
+ from: v + 1,
856
+ to: close - 1,
857
+ order: out.size,
858
+ });
859
+ k = close;
860
+ } else k = v + 1;
861
+ }
862
+ return out;
863
+ }
864
+
865
+ /** An attribute's string value when it is a literal (`size="full"`, `size={"full"}`), else null. */
866
+ function literalValue(attr, scan, read) {
867
+ if (!attr || attr.kind === "flag") return null;
868
+ if (attr.kind === "string")
869
+ return { text: read.slice(attr.from, attr.to), at: attr.from };
870
+ const m = /^(\s*)(["'`])([^"'`]*)\2\s*$/.exec(scan.slice(attr.from, attr.to));
871
+ if (!m) return null;
872
+ const at = attr.from + m[1].length + 1;
873
+ const text = read.slice(at, at + m[3].length);
874
+ return m[2] === "`" && text.includes("${") ? null : { text, at };
875
+ }
876
+
877
+ /**
878
+ * The literal text a template spells out: its static parts, and inside each `${…}` every string
879
+ * literal and (recursively) every nested template — the classes a conditional interpolation adds.
880
+ */
881
+ function templateFragments(text, at) {
882
+ const out = [];
883
+ let start = 0;
884
+ let k = 0;
885
+ while (k < text.length) {
886
+ if (text[k] === "\\") {
887
+ k += 2;
888
+ continue;
889
+ }
890
+ if (text[k] === "$" && text[k + 1] === "{") {
891
+ out.push({ text: text.slice(start, k), at: at + start });
892
+ let depth = 0;
893
+ let e = k + 1;
894
+ for (; e < text.length; e += 1) {
895
+ const c = text[e];
896
+ if (c === '"' || c === "'" || c === "`") {
897
+ const close = Math.min(stringEnd(text, e), text.length);
898
+ const inner = text.slice(e + 1, close);
899
+ if (c === "`") out.push(...templateFragments(inner, at + e + 1));
900
+ else out.push({ text: inner, at: at + e + 1 });
901
+ e = close;
902
+ } else if (c === "{") depth += 1;
903
+ else if (c === "}" && (depth -= 1) === 0) break;
904
+ }
905
+ k = e + 1;
906
+ start = k;
907
+ continue;
908
+ }
909
+ k += 1;
910
+ }
911
+ out.push({ text: text.slice(start), at: at + start });
912
+ return out;
913
+ }
914
+
915
+ /** The class tokens a `className` value spells out in string literals, each with its offset. */
916
+ function classTokens(attr, scan, read) {
917
+ const spans = [];
918
+ if (attr.kind === "string")
919
+ spans.push({ text: read.slice(attr.from, attr.to), at: attr.from });
920
+ else if (attr.kind === "expr")
921
+ for (let k = attr.from; k < attr.to; k += 1) {
922
+ const q = scan[k];
923
+ if (q !== '"' && q !== "'" && q !== "`") continue;
924
+ const close = scan.indexOf(q, k + 1);
925
+ if (close === -1 || close > attr.to) break;
926
+ const text = read.slice(k + 1, close);
927
+ if (q === "`") spans.push(...templateFragments(text, k + 1));
928
+ else spans.push({ text, at: k + 1 });
929
+ k = close;
930
+ }
931
+ const tokens = [];
932
+ for (const span of spans)
933
+ for (const t of span.text.matchAll(/\S+/g))
934
+ tokens.push({ token: t[0], at: span.at + t.index });
935
+ return tokens;
936
+ }
937
+
938
+ /** The utility a class token applies, with its variants (`md:`, `data-[x]:`) removed. */
939
+ function utilityOf(token) {
940
+ let depth = 0;
941
+ let last = 0;
942
+ for (let k = 0; k < token.length; k += 1) {
943
+ const c = token[k];
944
+ if (c === "[" || c === "(") depth += 1;
945
+ else if (c === "]" || c === ")") depth -= 1;
946
+ else if (c === ":" && depth === 0) last = k + 1;
947
+ }
948
+ return token.slice(last);
949
+ }
950
+
951
+ const isImportant = (utility) =>
952
+ utility.length > 1 &&
953
+ /^!?-?[a-z[(@*]/.test(utility) &&
954
+ (utility.startsWith("!") || utility.endsWith("!"));
955
+ const isWidthClass = (utility) =>
956
+ /^-?(?:w|min-w|max-w)-\S/.test(utility.replace(/^!|!$/g, ""));
957
+
958
+ /**
959
+ * The project's names for parts imported from the ui alias directory: `local → part`, with a
960
+ * namespace import (`import * as Ui from "@/components/ui/popover"`) recorded as `Ui → "*"`.
961
+ */
962
+ export function uiImports(src, file, { uiAliases, uiDirs }) {
963
+ const intoUi = (spec) => {
964
+ if (uiAliases.some((a) => spec === a || spec.startsWith(`${a}/`)))
965
+ return true;
966
+ if (!/^\.\.?(?:\/|$)/.test(spec)) return false;
967
+ const target = resolve(dirname(file), spec);
968
+ return uiDirs.some((d) => target === d || target.startsWith(d + sep));
969
+ };
970
+ const names = new Map();
971
+ for (const m of src.matchAll(
972
+ /\bimport\s+(?:type\s+)?([\w$*{}\s,]+?)\s*from\s*["']([^"']+)["']/g,
973
+ )) {
974
+ if (!intoUi(m[2])) continue;
975
+ const clause = m[1];
976
+ const ns = /\*\s*as\s+([\w$]+)/.exec(clause);
977
+ if (ns) names.set(ns[1], "*");
978
+ const named = /\{([^}]*)\}/.exec(clause);
979
+ for (const part of named ? named[1].split(",") : []) {
980
+ const p = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
981
+ if (p) names.set(p[2] ?? p[1], p[1]);
982
+ }
983
+ const def = /^\s*([\w$]+)\s*(?:,|$)/.exec(clause);
984
+ if (def && def[1] !== "type") names.set(def[1], def[1]);
985
+ }
986
+ return names;
987
+ }
988
+
989
+ /** The ui part a tag renders, or null when the tag is not one. */
990
+ function partOf(name, imports) {
991
+ const dot = name.indexOf(".");
992
+ if (dot !== -1)
993
+ return imports.get(name.slice(0, dot)) === "*" ? name.slice(dot + 1) : null;
994
+ const part = imports.get(name);
995
+ return part && part !== "*" ? part : null;
996
+ }
997
+
998
+ // ---- the page-width route map ----------------------------------------------------------------
999
+ // Copied from the `page-layout` registry lib (packages/ui/registry/lib/page-layout.ts) — the
1000
+ // doctor runs before anything is installed and cannot import TypeScript. Keep them in step.
1001
+
1002
+ const ROUTE_RANK = {
1003
+ static: 0,
1004
+ dynamic: 1,
1005
+ "catch-all": 2,
1006
+ "optional-catch-all": 3,
1007
+ };
1008
+
1009
+ export function routeSegments(path) {
1010
+ return path
1011
+ .split("?")[0]
1012
+ .split("#")[0]
1013
+ .split("/")
1014
+ .filter((s) => s !== "" && !/^\(.*\)$/.test(s) && !s.startsWith("@"));
1015
+ }
1016
+
1017
+ const PARSED = new Map();
1018
+ function parseRoute(route) {
1019
+ let parsed = PARSED.get(route);
1020
+ if (parsed) return parsed;
1021
+ parsed = routeSegments(route).map((s) => {
1022
+ if (/^\[\[\.\.\.[^\]]+\]\]$/.test(s)) return { kind: "optional-catch-all" };
1023
+ if (/^\[\.\.\.[^\]]+\]$/.test(s)) return { kind: "catch-all" };
1024
+ if (/^\[[^\]]+\]$/.test(s)) return { kind: "dynamic" };
1025
+ return { kind: "static", value: s };
1026
+ });
1027
+ if (PARSED.size < 10000) PARSED.set(route, parsed);
1028
+ return parsed;
1029
+ }
1030
+
1031
+ export function matchRoute(route, pathname) {
1032
+ const pattern = parseRoute(route);
1033
+ const path = routeSegments(pathname);
1034
+ for (let i = 0; i < pattern.length; i += 1) {
1035
+ const seg = pattern[i];
1036
+ if (seg.kind === "optional-catch-all") return true;
1037
+ if (seg.kind === "catch-all") return path.length > i;
1038
+ if (i >= path.length) return false;
1039
+ if (seg.kind === "static" && seg.value !== path[i]) return false;
1040
+ }
1041
+ return path.length === pattern.length;
1042
+ }
1043
+
1044
+ function bySpecificity(a, b) {
1045
+ const [ra, rb] = [parseRoute(a), parseRoute(b)];
1046
+ for (let i = 0; i < Math.min(ra.length, rb.length); i += 1) {
1047
+ const d = ROUTE_RANK[ra[i].kind] - ROUTE_RANK[rb[i].kind];
1048
+ if (d !== 0) return d;
1049
+ }
1050
+ return rb.length - ra.length;
1051
+ }
1052
+
1053
+ /** A value no static row names: what a page's `[id]` stands for when it is matched as a path. */
1054
+ const ANY_SEGMENT = "\u0000";
1055
+
1056
+ /**
1057
+ * Concrete sample paths a pattern serves, as segment arrays: `[id]` is one value no static row
1058
+ * names, `[...rest]` one to `depth` of them, `[[...rest]]` zero to `depth`.
1059
+ */
1060
+ function samplePaths(route, depth) {
1061
+ let paths = [[]];
1062
+ for (const seg of parseRoute(route)) {
1063
+ const from = seg.kind === "optional-catch-all" ? 0 : 1;
1064
+ const to = seg.kind.endsWith("catch-all") ? depth : 1;
1065
+ const next = [];
1066
+ for (const p of paths)
1067
+ for (let n = from; n <= to; n += 1)
1068
+ next.push(
1069
+ seg.kind === "static"
1070
+ ? [...p, seg.value]
1071
+ : [...p, ...Array(n).fill(ANY_SEGMENT)],
1072
+ );
1073
+ paths = next;
1074
+ }
1075
+ return paths;
1076
+ }
1077
+
1078
+ /**
1079
+ * The widths the route map gives the paths a page's route pattern renders. A static route with its
1080
+ * own row: that row. Otherwise sample paths — the page pattern's, every map row's, and each pair of
1081
+ * those merged position by position (`/en/[slug]` with `/[lang]/settings` gives `/en/settings`),
1082
+ * with catch-alls one segment longer than the longest pattern — kept when this page, not a more
1083
+ * specific page, renders them by Next's precedence, and resolved to the most specific row serving
1084
+ * each exactly as `widthOfPath` picks, or `default` where none does. `pages` are every App Router
1085
+ * page's route. One width when every sample agrees.
1086
+ */
1087
+ export function widthsForRoute(map, route, pages = []) {
1088
+ const key = `/${routeSegments(route).join("/")}`;
1089
+ if (map.has(key) && parseRoute(key).every((s) => s.kind === "static"))
1090
+ return [{ width: map.get(key).width, row: key }];
1091
+ const pagePatterns = [...new Set([key, ...pages])].sort(bySpecificity);
1092
+ const rows = [...map.keys()].sort(bySpecificity);
1093
+ const depth =
1094
+ Math.min(
1095
+ 8,
1096
+ Math.max(...[key, ...pages, ...rows].map((r) => parseRoute(r).length)),
1097
+ ) + 1;
1098
+ const own = samplePaths(key, depth);
1099
+ const candidates = new Set(own.map((p) => `/${p.join("/")}`));
1100
+ for (const row of rows)
1101
+ for (const r of samplePaths(row, depth)) {
1102
+ candidates.add(`/${r.join("/")}`);
1103
+ for (const p of own) {
1104
+ if (p.length !== r.length) continue;
1105
+ const merged = p.map((seg, i) => (seg === ANY_SEGMENT ? r[i] : seg));
1106
+ if (merged.every((seg, i) => r[i] === ANY_SEGMENT || seg === r[i]))
1107
+ candidates.add(`/${merged.join("/")}`);
1108
+ }
1109
+ }
1110
+ const seen = new Map();
1111
+ for (const path of candidates) {
1112
+ if (!matchRoute(key, path)) continue;
1113
+ if (pagePatterns.find((p) => matchRoute(p, path)) !== key) continue;
1114
+ const row = rows.find((r) => matchRoute(r, path));
1115
+ const width = row ? map.get(row).width : "default";
1116
+ seen.set(`${width} ${row}`, { width, row: row ?? null });
1117
+ }
1118
+ return [...seen.values()];
1119
+ }
1120
+
1121
+ const ROUTE_ROW = /(["'])(\/[^"'\s]*)\1\s*:\s*(["'])(prose|default|full)\3/g;
1122
+
1123
+ /**
1124
+ * Route-map rows a source declares: the `"/route": "prose" | "default" | "full"` rows of the
1125
+ * object literal passed to `definePageWidths(…)`, and of every `widths: { … }` object literal (a
1126
+ * module registry that contributes its own routes to the map).
1127
+ */
1128
+ export function routeMapRows(src, scan = src) {
1129
+ // Calls and object bounds come from the structure copy, so a `"}"` or a quoted example call
1130
+ // neither ends a map early nor starts one; the rows are read from the source.
1131
+ const spans = [];
1132
+ for (const m of scan.matchAll(
1133
+ /\bdefinePageWidths\s*(?:<[^>()]*>)?\s*\(\s*\{|\bwidths\s*:\s*\{/g,
1134
+ )) {
1135
+ const open = m.index + m[0].length - 1;
1136
+ spans.push([open, skipBraces(scan, open)]);
1137
+ }
1138
+ const rows = [];
1139
+ for (const [from, to] of spans)
1140
+ for (const m of src.slice(from, to).matchAll(ROUTE_ROW))
1141
+ rows.push({ route: m[2], width: m[4], index: from + m.index });
1142
+ return rows;
1143
+ }
1144
+
1145
+ /**
1146
+ * A Next.js App Router page or layout's route: `src/app/(app)/tasks/[id]/page.tsx` → `/tasks/[id]`.
1147
+ * Route groups and `@slot`s are not segments; an intercepting segment (`(.)x`, `(..)x`,
1148
+ * `(..)(..)x`, `(...)x`) renders the route it intercepts — the same level, one or two levels up,
1149
+ * or the root.
1150
+ */
1151
+ export function appRouteOf(relPath) {
1152
+ const parts = relPath.split("/");
1153
+ const file = parts.pop();
1154
+ if (!/^(?:page|layout)\.[jt]sx?$/.test(file)) return null;
1155
+ const app = parts.indexOf("app");
1156
+ if (app === -1) return null;
1157
+ const route = [];
1158
+ for (const part of parts.slice(app + 1)) {
1159
+ const intercept = /^((?:\(\.{1,3}\))+)(.+)$/.exec(part);
1160
+ if (intercept) {
1161
+ for (const marker of intercept[1].match(/\(\.{1,3}\)/g)) {
1162
+ if (marker === "(...)") route.length = 0;
1163
+ else if (marker === "(..)") route.pop();
1164
+ }
1165
+ route.push(...routeSegments(intercept[2]));
1166
+ } else route.push(...routeSegments(part));
1167
+ }
1168
+ return `/${route.join("/")}`;
1169
+ }
1170
+
1171
+ const COMPOSITION_EXTENSIONS = /\.[cm]?[jt]sx?$/;
1172
+ const TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
1173
+
1174
+ /** 1-based line of an offset, by binary search over the newline offsets. */
1175
+ function lineIndex(text) {
1176
+ const breaks = [];
1177
+ for (let k = text.indexOf("\n"); k !== -1; k = text.indexOf("\n", k + 1))
1178
+ breaks.push(k);
1179
+ return (index) => {
1180
+ let lo = 0;
1181
+ let hi = breaks.length;
1182
+ while (lo < hi) {
1183
+ const mid = (lo + hi) >> 1;
1184
+ if (breaks[mid] < index) lo = mid + 1;
1185
+ else hi = mid;
1186
+ }
1187
+ return lo + 1;
1188
+ };
1189
+ }
1190
+
1191
+ /**
1192
+ * The four composition rules over the project's own source (the ui alias dirs, node_modules and
1193
+ * build output skipped): `!` on a DS part, a width class on a popover or menu popup, a hand-written
1194
+ * `<header>` inside an overlay panel, and an `AppShellPage size` that disagrees with the route map.
1195
+ */
1196
+ export function scanComposition(root, { skipDirs = [], uiAlias = null } = {}) {
1197
+ const skip = new Set(skipDirs.map((d) => resolve(d)));
1198
+ const uiDirs = [...skip];
1199
+ const uiAliases = [
1200
+ ...new Set(
1201
+ [uiAlias, "@/components/ui"]
1202
+ .filter((a) => typeof a === "string" && a.length > 0)
1203
+ .map((a) => a.replace(/\/+$/, "")),
1204
+ ),
1205
+ ];
1206
+ const files = [];
1207
+ const walk = (dir) => {
1208
+ if (files.length >= SCAN_MAX_FILES) return;
1209
+ let entries;
1210
+ try {
1211
+ entries = readdirSync(dir, { withFileTypes: true });
1212
+ } catch {
1213
+ return;
1214
+ }
1215
+ for (const e of entries) {
1216
+ if (e.name.startsWith(".") || SCAN_SKIP_DIRS.has(e.name)) continue;
1217
+ const full = join(dir, e.name);
1218
+ if (e.isDirectory()) {
1219
+ if (!skip.has(resolve(full))) walk(full);
1220
+ } else if (e.isFile() && COMPOSITION_EXTENSIONS.test(e.name)) {
1221
+ files.push(full);
1222
+ if (files.length >= SCAN_MAX_FILES) return;
1223
+ }
1224
+ }
1225
+ };
1226
+ walk(root);
1227
+ files.sort();
1228
+ // Every App Router page: its folder (a layout wraps the pages under its own folder) and route.
1229
+ const pageFiles = files
1230
+ .map((f) => relative(root, f).split(sep).join("/"))
1231
+ .filter((f) => /(?:^|\/)page\.[jt]sx?$/.test(f))
1232
+ .map((f) => ({ dir: f.slice(0, f.lastIndexOf("/")), route: appRouteOf(f) }))
1233
+ .filter((p) => p.route != null);
1234
+ const pageRoutes = [...new Set(pageFiles.map((p) => p.route))];
1235
+
1236
+ const important = [];
1237
+ const widths = [];
1238
+ const headers = [];
1239
+ const pages = [];
1240
+ const map = new Map();
1241
+ const conflicts = [];
1242
+ for (const full of files) {
1243
+ try {
1244
+ if (statSync(full).size > SCAN_MAX_BYTES) continue;
1245
+ } catch {
1246
+ continue;
1247
+ }
1248
+ const raw = readIfExists(full);
1249
+ if (raw == null) continue;
1250
+ const file = relative(root, full).split(sep).join("/");
1251
+ const read = raw.replace(/\r/g, "");
1252
+ // Comments (and prose strings) blanked: a commented-out row or an example in a doc block is
1253
+ // not part of the map, and an import in a comment imports nothing.
1254
+ const src = maskNonUsage(read, { script: true });
1255
+ const scan = maskNonUsage(read, { script: true, blankStrings: true });
1256
+ const lineAt = lineIndex(read);
1257
+ if (!TEST_FILE.test(file))
1258
+ for (const row of routeMapRows(src, scan)) {
1259
+ const at = { width: row.width, file, line: lineAt(row.index) };
1260
+ const known = map.get(row.route);
1261
+ if (!known) map.set(row.route, at);
1262
+ else if (known.width !== row.width)
1263
+ conflicts.push({
1264
+ file,
1265
+ line: at.line,
1266
+ match: `route-map row "${row.route}": "${row.width}"`,
1267
+ hint: `${known.file}:${known.line} already says "${known.width}"`,
1268
+ });
1269
+ }
1270
+ if (!/\.[jt]sx$/.test(full) || !read.includes("<")) continue;
1271
+
1272
+ const imports = uiImports(src, full, { uiAliases, uiDirs });
1273
+ const overlays = [];
1274
+ const route = appRouteOf(file);
1275
+ for (const tag of jsxTags(scan)) {
1276
+ const part = partOf(tag.name, imports);
1277
+ if (tag.kind === "close") {
1278
+ if (part && part in OVERLAY_HEADERS) {
1279
+ const at = overlays.lastIndexOf(part);
1280
+ if (at !== -1) overlays.splice(at, 1);
1281
+ }
1282
+ continue;
1283
+ }
1284
+ if (tag.name === "header" && overlays.length > 0) {
1285
+ const inside = overlays[overlays.length - 1];
1286
+ headers.push({
1287
+ file,
1288
+ line: lineAt(tag.index),
1289
+ match: `<header> in ${inside}`,
1290
+ hint: OVERLAY_HEADERS[inside],
1291
+ });
1292
+ }
1293
+ const isPage = part === "AppShellPage" || tag.name === "AppShellPage";
1294
+ if (!part && !isPage) continue;
1295
+ const attrs = tagAttributes(tag, scan);
1296
+ if (isPage) {
1297
+ // A spread after `size` (or anywhere, with no `size`) may set it: that size is dynamic.
1298
+ const size = attrs.get("size");
1299
+ const spreads = attrs.get("...");
1300
+ pages.push({
1301
+ file,
1302
+ line: lineAt(tag.index),
1303
+ route,
1304
+ size: spreads.some((at) => at > (size?.order ?? -1))
1305
+ ? null
1306
+ : size == null
1307
+ ? "default"
1308
+ : (literalValue(size, scan, read)?.text ?? null),
1309
+ });
1310
+ }
1311
+ if (!part) continue;
1312
+ if (part in OVERLAY_HEADERS && !tag.selfClosing) overlays.push(part);
1313
+ const cls = attrs.get("className");
1314
+ if (!cls || cls.kind === "flag") continue;
1315
+ for (const { token, at } of classTokens(cls, scan, read)) {
1316
+ const utility = utilityOf(token);
1317
+ if (isImportant(utility))
1318
+ important.push({ file, line: lineAt(at), match: `${part} ${token}` });
1319
+ if (WIDTH_OWNING_CONTENT.includes(part) && isWidthClass(utility))
1320
+ widths.push({ file, line: lineAt(at), match: `${part} ${token}` });
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ // AppShellPage size vs the route map: route files only, literal (or absent) sizes only.
1326
+ const mismatches = [...conflicts];
1327
+ let compared = 0;
1328
+ let dynamic = 0;
1329
+ let outside = 0;
1330
+ for (const page of pages) {
1331
+ if (page.route == null) {
1332
+ outside += 1;
1333
+ continue;
1334
+ }
1335
+ if (page.size == null) {
1336
+ dynamic += 1;
1337
+ continue;
1338
+ }
1339
+ if (map.size === 0) continue;
1340
+ compared += 1;
1341
+ const size = page.size === "narrow" ? "prose" : page.size;
1342
+ // A page answers for its own route; a layout for every page in its folder and below (a route
1343
+ // group's layout wraps only its group), or its own route when it wraps none.
1344
+ const layoutDir = /(?:^|\/)layout\.[jt]sx?$/.test(page.file)
1345
+ ? page.file.slice(0, page.file.lastIndexOf("/"))
1346
+ : null;
1347
+ const wrapped =
1348
+ layoutDir == null
1349
+ ? [page.route]
1350
+ : pageFiles
1351
+ .filter(
1352
+ (p) => p.dir === layoutDir || p.dir.startsWith(`${layoutDir}/`),
1353
+ )
1354
+ .map((p) => p.route);
1355
+ const seen = new Map();
1356
+ for (const route of wrapped.length ? wrapped : [page.route])
1357
+ for (const answer of widthsForRoute(map, route, pageRoutes))
1358
+ seen.set(`${answer.width} ${answer.row}`, answer);
1359
+ const answers = [...seen.values()];
1360
+ if (answers.some((a) => a.width !== size))
1361
+ mismatches.push({
1362
+ file: page.file,
1363
+ line: page.line,
1364
+ match: `size="${page.size}" on ${page.route}`,
1365
+ hint: answers
1366
+ .map(({ width, row }) =>
1367
+ row
1368
+ ? `the map says "${width}" (${row}, ${map.get(row).file}:${map.get(row).line})`
1369
+ : `the map has no row for it, so it is "default"`,
1370
+ )
1371
+ .join("; and for other paths it serves, "),
1372
+ ...(layoutDir == null ? {} : { layout: true }),
1373
+ });
1374
+ }
1375
+
1376
+ const byPlace = (a, b) =>
1377
+ a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line;
1378
+ return {
1379
+ files: files.length,
1380
+ important: important.sort(byPlace),
1381
+ widths: widths.sort(byPlace),
1382
+ headers: headers.sort(byPlace),
1383
+ routeMap: {
1384
+ rows: map.size,
1385
+ files: [...new Set([...map.values()].map((r) => r.file))].sort(),
1386
+ compared,
1387
+ dynamic,
1388
+ outside,
1389
+ mismatches: mismatches.sort(byPlace),
1390
+ },
1391
+ };
1392
+ }
1393
+
644
1394
  export function main(argv = []) {
645
1395
  if (argv.includes("-h") || argv.includes("--help")) {
646
1396
  console.log(USAGE);
@@ -653,6 +1403,7 @@ export function main(argv = []) {
653
1403
  return 2;
654
1404
  }
655
1405
  const root = dirFlag === -1 ? process.cwd() : argv[dirFlag + 1];
1406
+ const strict = argv.includes("--strict");
656
1407
 
657
1408
  if (!existsSync(root)) {
658
1409
  console.error(`doctor: no such directory: ${root}`);
@@ -882,8 +1633,74 @@ export function main(argv = []) {
882
1633
  });
883
1634
  }
884
1635
 
1636
+ // ---- 9–12. DS parts used as designed ------------------------------------------------------
1637
+ // Warnings unless --strict: a consumer adopts the release, fixes what these report, then gates.
1638
+ const composition = scanComposition(root, {
1639
+ skipDirs,
1640
+ uiAlias: componentsJson?.aliases?.ui ?? null,
1641
+ });
1642
+ const level = strict ? "fail" : "warn";
1643
+ const compositionCheck = (name, findings, detail, fix, okDetail) => {
1644
+ if (findings.length === 0) return ok(name, okDetail);
1645
+ results.push({
1646
+ level,
1647
+ name,
1648
+ detail: `${findings.length} ${detail}`,
1649
+ fix,
1650
+ findings: findings.map(
1651
+ (f) =>
1652
+ `${f.file}:${f.line} ${f.match}${f.hint ? ` → ${f.hint}` : ""}`,
1653
+ ),
1654
+ });
1655
+ };
1656
+ compositionCheck(
1657
+ "important on DS parts",
1658
+ composition.important,
1659
+ "Tailwind `!` modifier(s) forcing a ui part's own classes",
1660
+ "use the part's variant or prop for it (ask for one in the design system if none fits) — `!` beats the part's own classes on every state and breaks on the next pull",
1661
+ "no ui part is forced with `!`",
1662
+ );
1663
+ compositionCheck(
1664
+ "popup width",
1665
+ composition.widths,
1666
+ "width class(es) on a popover, menu or select popup",
1667
+ "delete them — PopoverContent takes size (default · md · lg · fit); menus, selects and comboboxes size to their longest item",
1668
+ "no popover or menu popup sets its own width",
1669
+ );
1670
+ compositionCheck(
1671
+ "overlay header",
1672
+ composition.headers,
1673
+ "hand-written <header>(s) inside Sheet, Drawer or Dialog content",
1674
+ "use the overlay's own header part (SheetHeader · DrawerHeader · DialogHeader · AlertDialogHeader), or RecordPeek for a record panel",
1675
+ "no overlay content hand-writes a <header> (same-file nesting only)",
1676
+ );
1677
+ const rm = composition.routeMap;
1678
+ const coverage = `${rm.compared} App Router page/layout size(s) compared${
1679
+ rm.dynamic ? `, ${rm.dynamic} dynamic size={…} skipped` : ""
1680
+ }${
1681
+ rm.outside
1682
+ ? `, ${rm.outside} AppShellPage(s) outside page/layout files not checked (this rule reads route files only)`
1683
+ : ""
1684
+ }`;
1685
+ if (rm.rows === 0) {
1686
+ results.push({
1687
+ level: "skip",
1688
+ name: "page width vs route map",
1689
+ detail:
1690
+ 'skipped (no route map) — no definePageWidths( call or widths: { "/route": … } rows found',
1691
+ });
1692
+ } else {
1693
+ compositionCheck(
1694
+ "page width vs route map",
1695
+ rm.mismatches,
1696
+ `AppShellPage size(s) disagree with the route map (${rm.rows} row(s) in ${rm.files.join(", ")}; ${coverage})`,
1697
+ "set size to the map's width, or change the map's row — a page and its skeleton read the same map (https://design.vegastack.com/docs/foundations/page-layout)",
1698
+ `${rm.rows} route-map row(s) in ${rm.files.join(", ")}; ${coverage}`,
1699
+ );
1700
+ }
1701
+
885
1702
  // ---- report -------------------------------------------------------------------------------
886
- const glyph = { ok: "✓", warn: "!", fail: "✗" };
1703
+ const glyph = { ok: "✓", warn: "!", fail: "✗", skip: "–" };
887
1704
  console.log("");
888
1705
  for (const r of results) {
889
1706
  console.log(