dslinter 0.1.13 → 0.2.2

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 (181) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +50 -29
  3. package/bin/dslinter.mjs +26 -5
  4. package/bin/lib/config-hide-component.mjs +44 -0
  5. package/bin/lib/config-hide-component.test.mjs +33 -0
  6. package/bin/lib/constants.mjs +20 -0
  7. package/bin/lib/dev-banner.mjs +16 -51
  8. package/bin/lib/dev-banner.test.mjs +20 -18
  9. package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
  10. package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
  11. package/bin/lib/enrich-report-cli.mjs +14 -0
  12. package/bin/lib/env.mjs +20 -0
  13. package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
  14. package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
  15. package/bin/lib/parse-args.mjs +13 -1
  16. package/bin/lib/parse-args.test.mjs +7 -1
  17. package/bin/lib/paths.mjs +8 -0
  18. package/bin/lib/project-root.mjs +72 -10
  19. package/bin/lib/project-root.test.mjs +32 -1
  20. package/bin/lib/prompt.mjs +31 -0
  21. package/bin/lib/resolve-project.mjs +78 -0
  22. package/bin/lib/resolve-project.test.mjs +74 -0
  23. package/bin/lib/run-scanner.mjs +40 -6
  24. package/bin/lib/scaffold-config.mjs +128 -9
  25. package/bin/lib/scaffold-config.test.mjs +24 -2
  26. package/bin/lib/scan-host.mjs +44 -0
  27. package/bin/lib/scan-host.test.mjs +41 -0
  28. package/bin/lib/setup-readiness.mjs +153 -0
  29. package/bin/lib/setup-readiness.test.mjs +32 -0
  30. package/bin/modes/build.mjs +31 -6
  31. package/bin/modes/dev.mjs +55 -21
  32. package/bin/modes/init.mjs +3 -22
  33. package/bin/modes/init.test.mjs +1 -1
  34. package/bin/modes/mcp.mjs +49 -0
  35. package/bin/modes/report.mjs +29 -4
  36. package/bin/modes/watch.mjs +85 -0
  37. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
  39. package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
  40. package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
  41. package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
  42. package/dashboard-dist/index.html +2 -2
  43. package/index.cjs +53 -52
  44. package/index.d.ts +3 -0
  45. package/package.json +18 -12
  46. package/shared/env.ts +15 -0
  47. package/shared/paths.ts +8 -0
  48. package/shared/reportPath.test.ts +19 -0
  49. package/shared/reportPath.ts +12 -0
  50. package/shared/servePort.ts +16 -0
  51. package/src/components/ComponentInspectPane.tsx +67 -19
  52. package/src/components/ComponentPlaygroundPane.tsx +262 -113
  53. package/src/components/DashboardCommandPalette.tsx +6 -11
  54. package/src/components/GovernancePane.tsx +2 -2
  55. package/src/components/HideFromCatalogButton.tsx +44 -0
  56. package/src/components/OpenInEditorButton.tsx +36 -0
  57. package/src/components/PlaygroundA11yAndCode.tsx +53 -53
  58. package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
  59. package/src/components/PlaygroundControls.tsx +5 -11
  60. package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
  61. package/src/components/PlaygroundUsageCode.tsx +6 -4
  62. package/src/components/PlaygroundVariantMatrix.tsx +101 -34
  63. package/src/components/Section.tsx +5 -2
  64. package/src/components/Sidebar.tsx +131 -46
  65. package/src/components/TruncatedPath.tsx +44 -0
  66. package/src/components/controlApiTable.test.ts +29 -0
  67. package/src/components/controlApiTable.ts +3 -0
  68. package/src/components/playgroundUsageHighlight.ts +14 -3
  69. package/src/components/ui/badge.tsx +1 -1
  70. package/src/components/ui/table.tsx +2 -2
  71. package/src/dashboard/ComponentCatalog.tsx +16 -23
  72. package/src/dashboard/ComponentUsageDetails.tsx +6 -15
  73. package/src/dashboard/DashboardBody.tsx +0 -35
  74. package/src/dashboard/FindingsList.tsx +65 -55
  75. package/src/dashboard/ScannedTokenWall.tsx +3 -3
  76. package/src/dashboard/aggregate.test.ts +74 -0
  77. package/src/dashboard/aggregate.ts +145 -21
  78. package/src/dashboard/catalogVisibility.test.ts +93 -0
  79. package/src/dashboard/catalogVisibility.ts +108 -0
  80. package/src/dashboard/editorLink.test.ts +57 -0
  81. package/src/dashboard/editorLink.ts +71 -0
  82. package/src/dashboard/paths.test.ts +49 -0
  83. package/src/dashboard/paths.ts +51 -3
  84. package/src/dashboard/updateDslintConfig.ts +22 -0
  85. package/src/dashboard/useWorkspaceReport.ts +21 -17
  86. package/src/index.ts +26 -0
  87. package/src/mcp/agent-context.ts +148 -0
  88. package/src/mcp/agent-query.test.ts +89 -0
  89. package/src/mcp/agent-query.ts +373 -0
  90. package/src/mcp/config.ts +53 -0
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/normalize-paths.ts +65 -0
  93. package/src/mcp/report-cache.ts +212 -0
  94. package/src/mcp/rule-catalog.json +156 -0
  95. package/src/mcp/rule-catalog.ts +33 -0
  96. package/src/mcp/schemas.ts +54 -0
  97. package/src/mcp/server.test.ts +44 -0
  98. package/src/mcp/server.ts +343 -0
  99. package/src/mcp/start.ts +29 -0
  100. package/src/mcp/verify-loop.test.ts +49 -0
  101. package/src/mcp/verify-loop.ts +149 -0
  102. package/src/playground/appPreviewTheme.test.ts +148 -0
  103. package/src/playground/appPreviewTheme.ts +137 -0
  104. package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
  105. package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
  106. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
  107. package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
  108. package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
  109. package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
  110. package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
  111. package/src/playground/collectDefinedPlaygrounds.ts +68 -0
  112. package/src/playground/controls.ts +177 -0
  113. package/src/playground/createPlaygroundRegistry.ts +1 -1
  114. package/src/playground/definePlayground.tsx +88 -16
  115. package/src/playground/definePlaygroundFromKit.ts +17 -0
  116. package/src/playground/embedGlobKey.ts +8 -0
  117. package/src/playground/enrichKitControls.test.ts +25 -0
  118. package/src/playground/enrichKitControls.ts +197 -0
  119. package/src/playground/expandPlaygroundControls.test.ts +50 -0
  120. package/src/playground/expandPlaygroundControls.ts +97 -0
  121. package/src/playground/inferKitJsx.test.ts +77 -0
  122. package/src/playground/inferKitJsx.ts +165 -0
  123. package/src/playground/inferKitParams.test.ts +41 -0
  124. package/src/playground/inferKitParams.ts +113 -0
  125. package/src/playground/inferPropTypesFromTs.d.mts +47 -0
  126. package/src/playground/inferPropTypesFromTs.mjs +343 -0
  127. package/src/playground/inferPropTypesFromTs.test.ts +227 -0
  128. package/src/playground/inferPropTypesFromTs.ts +17 -0
  129. package/src/playground/mergePlaygroundEntries.test.ts +32 -0
  130. package/src/playground/mergePlaygroundEntries.ts +28 -0
  131. package/src/playground/playgroundJoin.test.ts +79 -19
  132. package/src/playground/playgroundJoin.ts +47 -22
  133. package/src/playground/playgroundModuleExport.test.ts +42 -0
  134. package/src/playground/playgroundModuleExport.ts +22 -0
  135. package/src/playground/playgroundSpecsKey.ts +8 -0
  136. package/src/playground/propCoerce.ts +91 -0
  137. package/src/playground/scanVariantA11y.test.ts +46 -0
  138. package/src/playground/scanVariantA11y.ts +107 -0
  139. package/src/playground/snippet.ts +83 -0
  140. package/src/playground/usePlaygroundFromReport.test.ts +18 -8
  141. package/src/playground/usePlaygroundFromReport.ts +3 -1
  142. package/src/report/a11yForModule.ts +2 -7
  143. package/src/report/a11yScoring.test.ts +24 -0
  144. package/src/report/a11yScoring.ts +17 -0
  145. package/src/report/index.ts +6 -0
  146. package/src/shell/DashboardLayout.tsx +71 -45
  147. package/src/shell/DashboardLayoutAuto.tsx +0 -4
  148. package/src/shell/hashRoute.test.ts +7 -15
  149. package/src/shell/hashRoute.ts +31 -31
  150. package/src/shell/useHashRoute.ts +38 -13
  151. package/src/styles/dashboard-theme.css +18 -7
  152. package/src/types/controls.ts +11 -0
  153. package/src/types/playground.ts +4 -0
  154. package/src/types/report.ts +32 -9
  155. package/templates/playground/buildRegistry.ts +1 -1
  156. package/templates/vite.dslinter.snippet.ts +15 -4
  157. package/vite/collectScanModules.test.ts +91 -3
  158. package/vite/collectScanModules.ts +94 -29
  159. package/vite/consumer.config.mjs +6 -3
  160. package/vite/consumerAlias.test.ts +47 -0
  161. package/vite/consumerAlias.ts +114 -0
  162. package/vite/embedTailwindSources.test.ts +74 -0
  163. package/vite/embedTailwindSources.ts +97 -0
  164. package/vite/loadConsumerAliases.test.ts +131 -0
  165. package/vite/loadConsumerAliases.ts +155 -0
  166. package/vite/openFileInEditor.mjs +196 -0
  167. package/vite/openFileInEditor.test.mjs +87 -0
  168. package/vite/plugin.resolve.test.ts +72 -0
  169. package/vite/plugin.ts +216 -19
  170. package/vite/reportPath.test.ts +19 -0
  171. package/vite/resolveWayfinderImport.ts +56 -0
  172. package/vite/shims/inertia-react.tsx +85 -0
  173. package/vite/shims/wayfinder-actions.ts +33 -0
  174. package/vite/shims/wayfinder-routes.ts +30 -0
  175. package/vite/shims/ziggy-js.ts +12 -0
  176. package/dashboard-dist/assets/DashboardLayoutAuto-Bm7yfyC-.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
  178. package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
  179. package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -11,6 +11,7 @@ import { shortPath } from "./paths";
11
11
  import type { LintFinding, Severity } from "../types/report";
12
12
  import { Badge } from "../components/ui/badge";
13
13
  import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
14
+ import { TruncatedPath } from "../components/TruncatedPath";
14
15
 
15
16
  type Filter = "all" | Severity;
16
17
 
@@ -42,70 +43,71 @@ export function FindingsList({
42
43
  return c;
43
44
  }, [findings]);
44
45
 
45
- const filtered =
46
- filter === "all" ? findings : findings.filter((f) => f.severity === filter);
46
+ const filtered = useMemo(
47
+ () => (filter === "all" ? findings : findings.filter((f) => f.severity === filter)),
48
+ [findings, filter],
49
+ );
47
50
 
48
51
  if (findings.length === 0) {
49
52
  return (
50
53
  <p className="rounded-lg border border-dashed border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
51
- No findings — rules are quiet on this snapshot.
54
+ No findings
52
55
  </p>
53
56
  );
54
57
  }
55
58
 
56
59
  return (
57
60
  <div className="space-y-3">
58
- <div className="flex flex-wrap gap-2">
59
- <ToggleGroup
60
- type="single"
61
- value={filter}
62
- onValueChange={(value) => {
63
- if (isFilter(value)) setFilter(value);
64
- }}
65
- variant="outline"
66
- size="sm"
67
- aria-label="Filter findings by severity"
68
- className="contents"
61
+ <ToggleGroup
62
+ type="single"
63
+ value={filter}
64
+ onValueChange={(value) => {
65
+ if (isFilter(value)) setFilter(value);
66
+ }}
67
+ variant="outline"
68
+ size="sm"
69
+ aria-label="Filter findings by severity"
70
+ className="contents"
71
+ >
72
+ <ToggleGroupItem
73
+ value="all"
74
+ className="rounded-full px-2.5 text-xs font-medium"
75
+ >
76
+ All
77
+ <span className="ml-1 tabular-nums text-muted-foreground">
78
+ {findings.length}
79
+ </span>
80
+ </ToggleGroupItem>
81
+ <ToggleGroupItem
82
+ value="warning"
83
+ className="rounded-full px-2.5 text-xs font-medium"
84
+ >
85
+ Warnings
86
+ <span className="ml-1 tabular-nums text-muted-foreground">
87
+ {counts.warning}
88
+ </span>
89
+ </ToggleGroupItem>
90
+ <ToggleGroupItem
91
+ value="error"
92
+ className="rounded-full px-2.5 text-xs font-medium"
69
93
  >
70
- <ToggleGroupItem
71
- value="all"
72
- className="rounded-full px-2.5 text-xs font-medium"
73
- >
74
- All
75
- <span className="ml-1 tabular-nums text-muted-foreground">
76
- {findings.length}
77
- </span>
78
- </ToggleGroupItem>
79
- <ToggleGroupItem
80
- value="warning"
81
- className="rounded-full px-2.5 text-xs font-medium"
82
- >
83
- Warnings
84
- <span className="ml-1 tabular-nums text-muted-foreground">
85
- {counts.warning}
86
- </span>
87
- </ToggleGroupItem>
88
- <ToggleGroupItem
89
- value="error"
90
- className="rounded-full px-2.5 text-xs font-medium"
91
- >
92
- Errors
93
- <span className="ml-1 tabular-nums text-muted-foreground">
94
- {counts.error}
95
- </span>
96
- </ToggleGroupItem>
97
- <ToggleGroupItem
98
- value="info"
99
- className="rounded-full px-2.5 text-xs font-medium"
100
- >
101
- Info
102
- <span className="ml-1 tabular-nums text-muted-foreground">
103
- {counts.info}
104
- </span>
105
- </ToggleGroupItem>
106
- </ToggleGroup>
107
- </div>
108
- <Table>
94
+ Errors
95
+ <span className="ml-1 tabular-nums text-muted-foreground">
96
+ {counts.error}
97
+ </span>
98
+ </ToggleGroupItem>
99
+ <ToggleGroupItem
100
+ value="info"
101
+ className="rounded-full px-2.5 text-xs font-medium"
102
+ >
103
+ Info
104
+ <span className="ml-1 tabular-nums text-muted-foreground">
105
+ {counts.info}
106
+ </span>
107
+ </ToggleGroupItem>
108
+ </ToggleGroup>
109
+
110
+ <Table className="mt-4">
109
111
  <TableHeader>
110
112
  <TableRow>
111
113
  <TableHead scope="col">Severity</TableHead>
@@ -139,8 +141,16 @@ export function FindingsList({
139
141
  <TableCell className="whitespace-normal px-3 py-2 text-sm">
140
142
  {f.message}
141
143
  </TableCell>
142
- <TableCell className="whitespace-normal px-3 py-2 font-mono text-xs text-muted-foreground">
143
- {shortPath(root, f.path)}:{f.line != null ? f.line : "—"}
144
+ <TableCell className="min-w-0 px-3 py-2 font-mono text-xs text-muted-foreground">
145
+ <div className="flex min-w-0 items-baseline">
146
+ <TruncatedPath
147
+ path={shortPath(root, f.path)}
148
+ className="min-w-0 flex-1 text-xs"
149
+ />
150
+ <span className="shrink-0">
151
+ :{f.line != null ? f.line : "—"}
152
+ </span>
153
+ </div>
144
154
  </TableCell>
145
155
  </TableRow>
146
156
  ))}
@@ -5,7 +5,7 @@ import {
5
5
  HoverCardTrigger,
6
6
  } from "../components/ui/hover-card";
7
7
  import { cn } from "../lib/utils";
8
- import { shortPath } from "./paths";
8
+ import { TruncatedPath } from "../components/TruncatedPath";
9
9
  import {
10
10
  filterTokenRows,
11
11
  type MergedTokenView,
@@ -71,8 +71,8 @@ function TokenUsageHover({ row }: { row: ScannedTokenRow }) {
71
71
  </p>
72
72
  <ul className="mt-2 max-h-40 space-y-1 overflow-y-auto text-muted-foreground">
73
73
  {row.usageFiles.slice(0, 12).map((f) => (
74
- <li key={f} className="truncate font-mono">
75
- {shortPath(f)}
74
+ <li key={f} className="min-w-0">
75
+ <TruncatedPath path={f} className="text-xs" />
76
76
  </li>
77
77
  ))}
78
78
  {row.usageFiles.length > 12 ? (
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { WorkspaceReport } from "../types/report";
3
+ import { componentCatalogFamiliesFromReport, componentCatalogTreeFromReport } from "./aggregate";
4
+
5
+ function reportWithDefinitions(
6
+ definitions: WorkspaceReport["files"][number]["definitions"],
7
+ path = "/repo/src/components/ui/dropdown-menu.tsx",
8
+ ): WorkspaceReport {
9
+ return {
10
+ root: "/repo",
11
+ files: [
12
+ {
13
+ path,
14
+ definitions,
15
+ usages: [],
16
+ parse_errors: [],
17
+ },
18
+ ],
19
+ findings: [],
20
+ duplicate_components: [],
21
+ usage_by_component: [],
22
+ scores: {
23
+ design_system_health: 0,
24
+ ux_consistency: 0,
25
+ accessibility: 0,
26
+ maintainability: 0,
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("componentCatalogFamiliesFromReport", () => {
32
+ it("groups shadcn-style kebab filename exports under the normalized root", () => {
33
+ const report = reportWithDefinitions([
34
+ { name: "DropdownMenu", kind: "function", line: 1 },
35
+ { name: "DropdownMenuContent", kind: "function", line: 2 },
36
+ { name: "DropdownMenuItem", kind: "function", line: 3 },
37
+ { name: "DropdownMenuTrigger", kind: "function", line: 4 },
38
+ ]);
39
+
40
+ expect(componentCatalogFamiliesFromReport(report)).toEqual([
41
+ {
42
+ parent: "DropdownMenu",
43
+ children: ["DropdownMenuContent", "DropdownMenuItem", "DropdownMenuTrigger"],
44
+ path: "/repo/src/components/ui/dropdown-menu.tsx",
45
+ },
46
+ ]);
47
+ });
48
+
49
+ it("leaves single export files flat", () => {
50
+ const report = reportWithDefinitions(
51
+ [{ name: "Button", kind: "function", line: 1 }],
52
+ "/repo/src/components/ui/button.tsx",
53
+ );
54
+
55
+ expect(componentCatalogFamiliesFromReport(report)).toEqual([]);
56
+ expect(componentCatalogTreeFromReport(report)).toEqual([{ type: "component", name: "Button" }]);
57
+ });
58
+
59
+ it("does not group sibling exports when no export matches the filename root", () => {
60
+ const report = reportWithDefinitions(
61
+ [
62
+ { name: "MenuRoot", kind: "function", line: 1 },
63
+ { name: "MenuItem", kind: "function", line: 2 },
64
+ ],
65
+ "/repo/src/components/ui/dropdown-menu.tsx",
66
+ );
67
+
68
+ expect(componentCatalogFamiliesFromReport(report)).toEqual([]);
69
+ expect(componentCatalogTreeFromReport(report)).toEqual([
70
+ { type: "component", name: "MenuItem" },
71
+ { type: "component", name: "MenuRoot" },
72
+ ]);
73
+ });
74
+ });
@@ -1,4 +1,8 @@
1
- import type { ComponentDefinition, UsageSummary, WorkspaceReport } from "../types/report";
1
+ import type { ComponentDefinition, FileScan, UsageSummary, WorkspaceReport } from "../types/report";
2
+ import {
3
+ definitionPathsForName,
4
+ isCatalogComponentHidden,
5
+ } from "./catalogVisibility";
2
6
 
3
7
  export interface DefinitionSite {
4
8
  kind: ComponentDefinition["kind"];
@@ -6,13 +10,51 @@ export interface DefinitionSite {
6
10
  line: number;
7
11
  }
8
12
 
9
- const HIDDEN_COMPONENTS = new Set(["App", "React.StrictMode"]);
13
+ export type CatalogFamily = {
14
+ parent: string;
15
+ children: string[];
16
+ path: string;
17
+ };
18
+
19
+ export type CatalogTreeItem = { type: "component"; name: string } | CatalogTreeFamily;
20
+
21
+ export type CatalogTreeFamily = {
22
+ type: "family";
23
+ parent: string;
24
+ children: string[];
25
+ path: string;
26
+ };
27
+
28
+ const PLAYABLE_KINDS = new Set<ComponentDefinition["kind"]>([
29
+ "function",
30
+ "class",
31
+ "const_arrow",
32
+ "const_function",
33
+ "wrapped_component",
34
+ ]);
35
+
36
+ function isPlayableDefinition(def: ComponentDefinition): boolean {
37
+ return PLAYABLE_KINDS.has(def.kind);
38
+ }
39
+
40
+ function fileStem(path: string): string {
41
+ const base = path.split("/").pop() ?? path;
42
+ return base.replace(/\.(tsx|jsx)$/i, "");
43
+ }
44
+
45
+ function normalizedName(value: string): string {
46
+ return value
47
+ .replace(/\.playground$/i, "")
48
+ .split("")
49
+ .filter((c) => c !== "-" && c !== "_")
50
+ .join("")
51
+ .toLowerCase();
52
+ }
10
53
 
11
54
  export function aggregateDefinitions(report: WorkspaceReport): Map<string, DefinitionSite[]> {
12
55
  const map = new Map<string, DefinitionSite[]>();
13
56
  for (const file of report.files ?? []) {
14
57
  for (const d of file.definitions ?? []) {
15
- if (HIDDEN_COMPONENTS.has(d.name)) continue;
16
58
  const list = map.get(d.name) ?? [];
17
59
  list.push({ kind: d.kind, path: file.path, line: d.line });
18
60
  map.set(d.name, list);
@@ -26,19 +68,16 @@ export function aggregateDefinitions(report: WorkspaceReport): Map<string, Defin
26
68
 
27
69
  /** Merges `declared_props` from scan definitions and playground rows (source order, then deduped). */
28
70
  export function aggregateDeclaredProps(report: WorkspaceReport): Map<string, string[]> {
29
- const map = new Map<string, string[]>();
71
+ const sets = new Map<string, Set<string>>();
30
72
 
31
73
  const add = (name: string, props: readonly string[] | undefined) => {
32
- if (HIDDEN_COMPONENTS.has(name)) return;
33
74
  if (!props?.length) return;
34
- let list = map.get(name);
35
- if (!list) {
36
- list = [];
37
- map.set(name, list);
38
- }
39
- for (const p of props) {
40
- if (!list.includes(p)) list.push(p);
75
+ let set = sets.get(name);
76
+ if (!set) {
77
+ set = new Set();
78
+ sets.set(name, set);
41
79
  }
80
+ for (const p of props) set.add(p);
42
81
  };
43
82
 
44
83
  for (const file of report.files ?? []) {
@@ -50,37 +89,122 @@ export function aggregateDeclaredProps(report: WorkspaceReport): Map<string, str
50
89
  add(pg.export_name, pg.declared_props);
51
90
  }
52
91
 
92
+ const map = new Map<string, string[]>();
93
+ for (const [name, set] of sets) {
94
+ map.set(name, [...set]);
95
+ }
53
96
  return map;
54
97
  }
55
98
 
56
99
  export function usageMap(report: WorkspaceReport): Map<string, UsageSummary> {
57
100
  const m = new Map<string, UsageSummary>();
58
101
  for (const row of report.usage_by_component ?? []) {
59
- if (HIDDEN_COMPONENTS.has(row.component)) continue;
60
102
  m.set(row.component, row);
61
103
  }
62
104
  return m;
63
105
  }
64
106
 
107
+ function isVisibleCatalogName(report: WorkspaceReport, name: string): boolean {
108
+ return !isCatalogComponentHidden(
109
+ name,
110
+ report,
111
+ definitionPathsForName(report, name),
112
+ );
113
+ }
114
+
65
115
  export function catalogComponentNames(
66
116
  defs: Map<string, DefinitionSite[]>,
67
117
  usages: Map<string, UsageSummary>,
118
+ report: WorkspaceReport,
68
119
  ): string[] {
69
120
  const names = new Set<string>();
70
- for (const k of defs.keys()) names.add(k);
71
- for (const k of usages.keys()) names.add(k);
121
+ for (const k of defs.keys()) {
122
+ if (isVisibleCatalogName(report, k)) names.add(k);
123
+ }
124
+ for (const k of usages.keys()) {
125
+ if (isVisibleCatalogName(report, k)) names.add(k);
126
+ }
72
127
  return [...names].sort((a, b) => a.localeCompare(b));
73
128
  }
74
129
 
75
- /** Stable DOM id for a catalog table row (used for hash deep-links). */
76
- export function catalogRowDomId(name: string): string {
77
- return `catalog-row-${encodeURIComponent(name)}`;
78
- }
79
-
80
130
  /** Unique component names for sidebar / command palette (definitions ∪ usage). */
81
131
  export function componentCatalogNamesFromReport(
82
132
  report: WorkspaceReport | null | undefined,
83
133
  ): string[] {
84
134
  if (!report) return [];
85
- return catalogComponentNames(aggregateDefinitions(report), usageMap(report));
135
+ return catalogComponentNames(aggregateDefinitions(report), usageMap(report), report);
136
+ }
137
+
138
+ function familyFromFile(file: FileScan, report: WorkspaceReport): CatalogFamily | null {
139
+ const defs = (file.definitions ?? []).filter((d) => {
140
+ if (!isPlayableDefinition(d)) return false;
141
+ return !isCatalogComponentHidden(d.name, report, [file.path]);
142
+ });
143
+ if (defs.length < 2) return null;
144
+
145
+ const stem = normalizedName(fileStem(file.path));
146
+ const root = defs.find((d) => normalizedName(d.name) === stem);
147
+ if (!root) return null;
148
+
149
+ const children = defs
150
+ .map((d) => d.name)
151
+ .filter((name) => name !== root.name && name.startsWith(root.name))
152
+ .sort((a, b) => a.localeCompare(b));
153
+ if (children.length === 0) return null;
154
+
155
+ return { parent: root.name, children, path: file.path };
156
+ }
157
+
158
+ export function componentCatalogFamiliesFromReport(
159
+ report: WorkspaceReport | null | undefined,
160
+ ): CatalogFamily[] {
161
+ if (!report) return [];
162
+ const byParent = new Map<string, CatalogFamily>();
163
+ for (const file of report.files ?? []) {
164
+ const family = familyFromFile(file, report);
165
+ if (!family) continue;
166
+ if (!isVisibleCatalogName(report, family.parent)) continue;
167
+ const existing = byParent.get(family.parent);
168
+ if (!existing) {
169
+ byParent.set(family.parent, family);
170
+ continue;
171
+ }
172
+ const children = new Set([...existing.children, ...family.children]);
173
+ byParent.set(family.parent, {
174
+ ...existing,
175
+ children: [...children].sort((a, b) => a.localeCompare(b)),
176
+ });
177
+ }
178
+ return [...byParent.values()].sort((a, b) => a.parent.localeCompare(b.parent));
179
+ }
180
+
181
+ export function componentCatalogTreeFromReport(
182
+ report: WorkspaceReport | null | undefined,
183
+ ): CatalogTreeItem[] {
184
+ const names = componentCatalogNamesFromReport(report);
185
+ const families = componentCatalogFamiliesFromReport(report);
186
+ const familyByParent = new Map(families.map((f) => [f.parent, f]));
187
+ const childNames = new Set(families.flatMap((f) => f.children));
188
+ const items: CatalogTreeItem[] = [];
189
+
190
+ for (const name of names) {
191
+ const family = familyByParent.get(name);
192
+ if (family) {
193
+ items.push({ type: "family", ...family });
194
+ continue;
195
+ }
196
+ if (childNames.has(name)) continue;
197
+ items.push({ type: "component", name });
198
+ }
199
+
200
+ return items;
201
+ }
202
+
203
+ export function componentCatalogFamilyForName(
204
+ report: WorkspaceReport | null | undefined,
205
+ name: string,
206
+ ): CatalogFamily | undefined {
207
+ return componentCatalogFamiliesFromReport(report).find(
208
+ (family) => family.parent === name || family.children.includes(name),
209
+ );
86
210
  }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { WorkspaceReport } from "../types/report";
3
+ import {
4
+ componentCatalogNamesFromReport,
5
+ componentCatalogTreeFromReport,
6
+ } from "./aggregate";
7
+ import { isCatalogComponentHidden, pathMatchesPrefix, reportWithExtraHidden } from "./catalogVisibility";
8
+
9
+ function minimalReport(overrides: Partial<WorkspaceReport> = {}): WorkspaceReport {
10
+ return {
11
+ root: "/repo",
12
+ files: [],
13
+ findings: [],
14
+ duplicate_components: [],
15
+ usage_by_component: [],
16
+ scores: {
17
+ design_system_health: 0,
18
+ ux_consistency: 0,
19
+ accessibility: 0,
20
+ maintainability: 0,
21
+ },
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("catalogVisibility", () => {
27
+ it("pathMatchesPrefix treats repo-relative paths", () => {
28
+ expect(pathMatchesPrefix("resources/js/components/ui/button.tsx", "resources/js/components")).toBe(
29
+ true,
30
+ );
31
+ expect(pathMatchesPrefix("resources/js/pages/foo.tsx", "resources/js/components")).toBe(false);
32
+ });
33
+
34
+ it("hides by component name from report config", () => {
35
+ const report = minimalReport({
36
+ config: { hidden_components: ["Secret"] },
37
+ files: [
38
+ {
39
+ path: "resources/js/components/secret.tsx",
40
+ definitions: [{ name: "Secret", kind: "function", line: 1 }],
41
+ usages: [],
42
+ parse_errors: [],
43
+ },
44
+ {
45
+ path: "resources/js/components/button.tsx",
46
+ definitions: [{ name: "Button", kind: "function", line: 1 }],
47
+ usages: [],
48
+ parse_errors: [],
49
+ },
50
+ ],
51
+ });
52
+ expect(isCatalogComponentHidden("Secret", report, ["resources/js/components/secret.tsx"])).toBe(
53
+ true,
54
+ );
55
+ expect(componentCatalogNamesFromReport(report)).toEqual(["Button"]);
56
+ });
57
+
58
+ it("hides by path prefix", () => {
59
+ const report = minimalReport({
60
+ config: { hidden_paths: ["resources/js/components/legacy"] },
61
+ files: [
62
+ {
63
+ path: "resources/js/components/legacy/old.tsx",
64
+ definitions: [{ name: "Old", kind: "function", line: 1 }],
65
+ usages: [],
66
+ parse_errors: [],
67
+ },
68
+ ],
69
+ });
70
+ expect(componentCatalogNamesFromReport(report)).toEqual([]);
71
+ });
72
+
73
+ it("reportWithExtraHidden merges optimistic names", () => {
74
+ const report = minimalReport({
75
+ files: [
76
+ {
77
+ path: "a.tsx",
78
+ definitions: [{ name: "A", kind: "function", line: 1 }],
79
+ usages: [],
80
+ parse_errors: [],
81
+ },
82
+ {
83
+ path: "b.tsx",
84
+ definitions: [{ name: "B", kind: "function", line: 1 }],
85
+ usages: [],
86
+ parse_errors: [],
87
+ },
88
+ ],
89
+ });
90
+ const merged = reportWithExtraHidden(report, ["B"]);
91
+ expect(componentCatalogTreeFromReport(merged)).toEqual([{ type: "component", name: "A" }]);
92
+ });
93
+ });
@@ -0,0 +1,108 @@
1
+ import type { WorkspaceReport } from "../types/report";
2
+
3
+ export type ReportConfig = NonNullable<WorkspaceReport["config"]>;
4
+
5
+ const BUILTIN_HIDDEN = new Set(["App", "React.StrictMode"]);
6
+
7
+ function trimLeadingSlashes(value: string): string {
8
+ let i = 0;
9
+ while (i < value.length && value.charCodeAt(i) === 47) i += 1;
10
+ return value.slice(i);
11
+ }
12
+
13
+ function trimTrailingSlashes(value: string): string {
14
+ let end = value.length;
15
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
16
+ return value.slice(0, end);
17
+ }
18
+
19
+ function normalizePath(path: string): string {
20
+ return trimLeadingSlashes(path.replaceAll("\\", "/"));
21
+ }
22
+
23
+ function normalizePrefix(prefix: string): string {
24
+ return trimTrailingSlashes(trimLeadingSlashes(prefix.trim()));
25
+ }
26
+
27
+ export function pathMatchesPrefix(rel: string, prefix: string): boolean {
28
+ const pre = normalizePrefix(prefix);
29
+ if (!pre) return false;
30
+ const r = normalizePath(rel);
31
+ return r === pre || r.startsWith(`${pre}/`);
32
+ }
33
+
34
+ export function reportConfig(
35
+ report: WorkspaceReport | null | undefined,
36
+ ): ReportConfig {
37
+ return report?.config ?? {};
38
+ }
39
+
40
+ export function hiddenComponentNames(
41
+ report: WorkspaceReport | null | undefined,
42
+ extraHidden?: Iterable<string>,
43
+ ): Set<string> {
44
+ const names = new Set(BUILTIN_HIDDEN);
45
+ for (const n of reportConfig(report).hidden_components ?? []) {
46
+ names.add(n);
47
+ }
48
+ if (extraHidden) {
49
+ for (const n of extraHidden) names.add(n);
50
+ }
51
+ return names;
52
+ }
53
+
54
+ export function hiddenPathPrefixes(
55
+ report: WorkspaceReport | null | undefined,
56
+ ): string[] {
57
+ return (reportConfig(report).hidden_paths ?? []).map(normalizePrefix).filter(Boolean);
58
+ }
59
+
60
+ /** True when the component should not appear in sidebar / palette / playground list. */
61
+ export function isCatalogComponentHidden(
62
+ name: string,
63
+ report: WorkspaceReport | null | undefined,
64
+ definitionPaths: string[] | undefined,
65
+ extraHidden?: Iterable<string>,
66
+ ): boolean {
67
+ if (hiddenComponentNames(report, extraHidden).has(name)) return true;
68
+ const prefixes = hiddenPathPrefixes(report);
69
+ if (!prefixes.length || !definitionPaths?.length) return false;
70
+ for (const path of definitionPaths) {
71
+ const rel = normalizePath(path);
72
+ for (const pre of prefixes) {
73
+ if (pathMatchesPrefix(rel, pre)) return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
79
+ export function definitionPathsForName(
80
+ report: WorkspaceReport | null | undefined,
81
+ name: string,
82
+ ): string[] {
83
+ if (!report) return [];
84
+ const paths: string[] = [];
85
+ for (const file of report.files ?? []) {
86
+ for (const d of file.definitions ?? []) {
87
+ if (d.name === name) paths.push(file.path);
88
+ }
89
+ }
90
+ return paths;
91
+ }
92
+
93
+ /** Merge optimistic hides until the next report refresh includes them. */
94
+ export function reportWithExtraHidden(
95
+ report: WorkspaceReport | null | undefined,
96
+ extraHidden: readonly string[],
97
+ ): WorkspaceReport | null | undefined {
98
+ if (!report || extraHidden.length === 0) return report;
99
+ const existing = report.config?.hidden_components ?? [];
100
+ const merged = [...new Set([...existing, ...extraHidden])];
101
+ return {
102
+ ...report,
103
+ config: {
104
+ ...report.config,
105
+ hidden_components: merged,
106
+ },
107
+ };
108
+ }