dslinter 0.2.3 → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bin/lib/infer-prop-types-from-ts.mjs +14 -1
  3. package/bin/lib/infer-prop-types-from-ts.test.mjs +32 -0
  4. package/dashboard-dist/assets/{DashboardLayoutAuto-h0gP_iKd.js → DashboardLayoutAuto-B4P-sy4z.js} +1 -1
  5. package/dashboard-dist/assets/DashboardLayoutAuto-CAO6F6-q.css +1 -0
  6. package/dashboard-dist/assets/{axe-DDaE9JTN.js → axe-CaxTXfM9.js} +1 -1
  7. package/dashboard-dist/assets/index-B432JkIx.js +219 -0
  8. package/dashboard-dist/assets/index-D0O_5w5V.css +1 -0
  9. package/dashboard-dist/dslinter-report.json +23929 -0
  10. package/dashboard-dist/index.html +2 -2
  11. package/index.cjs +52 -52
  12. package/package.json +6 -6
  13. package/src/components/CatalogPane.tsx +94 -0
  14. package/src/components/ComponentInspectPane.tsx +125 -125
  15. package/src/components/ComponentPlaygroundPane.tsx +42 -27
  16. package/src/components/DashboardCommandPalette.tsx +9 -0
  17. package/src/components/GovernanceInventoryTabs.tsx +51 -0
  18. package/src/components/GovernancePane.tsx +18 -5
  19. package/src/components/PlaygroundA11yAndCode.tsx +0 -52
  20. package/src/components/PlaygroundControlField.tsx +2 -0
  21. package/src/components/ScoreGauge.test.ts +22 -0
  22. package/src/components/ScoreGauge.tsx +179 -0
  23. package/src/components/Sidebar.tsx +97 -23
  24. package/src/components/TokensPane.tsx +11 -13
  25. package/src/components/controlApiTable.test.ts +15 -0
  26. package/src/components/controlApiTable.ts +4 -0
  27. package/src/components/ui/badge.tsx +5 -5
  28. package/src/dashboard/ComponentCatalog.tsx +10 -1
  29. package/src/dashboard/ComponentPropUsageDetail.tsx +127 -42
  30. package/src/dashboard/ComponentUsageDetails.tsx +39 -9
  31. package/src/dashboard/DashboardBody.tsx +83 -12
  32. package/src/dashboard/ScannedTokenWall.tsx +9 -6
  33. package/src/dashboard/UnusedComponentsList.tsx +74 -0
  34. package/src/dashboard/aggregate.test.ts +381 -12
  35. package/src/dashboard/aggregate.ts +167 -30
  36. package/src/dashboard/mergeTokenCatalog.ts +5 -0
  37. package/src/dashboard/paths.test.ts +18 -1
  38. package/src/dashboard/paths.ts +8 -0
  39. package/src/mcp/agent-query.ts +1 -1
  40. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +3 -3
  41. package/src/playground/controls.ts +16 -3
  42. package/src/playground/enrichKitControls.ts +5 -5
  43. package/src/playground/inferKitJsx.test.ts +0 -11
  44. package/src/playground/inferPropTypesFromTs.d.mts +1 -1
  45. package/src/playground/inferPropTypesFromTs.mjs +19 -3
  46. package/src/playground/inferPropTypesFromTs.test.ts +32 -0
  47. package/src/playground/inferPropTypesFromTs.ts +1 -1
  48. package/src/playground/playgroundJoin.ts +34 -0
  49. package/src/playground/propCoerce.ts +2 -2
  50. package/src/playground/snippet.ts +1 -0
  51. package/src/shell/DashboardLayout.tsx +21 -4
  52. package/src/shell/hashRoute.test.ts +9 -0
  53. package/src/shell/hashRoute.ts +6 -0
  54. package/src/types/controls.ts +12 -0
  55. package/src/types/report.ts +1 -1
  56. package/vite/embedTailwindSources.ts +8 -6
  57. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +0 -1
  58. package/dashboard-dist/assets/index-B9sZ6wHm.css +0 -1
  59. package/dashboard-dist/assets/index-DIDBt5ed.js +0 -218
@@ -1,6 +1,15 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { WorkspaceReport } from "../types/report";
3
- import { componentCatalogFamiliesFromReport, componentCatalogTreeFromReport } from "./aggregate";
3
+ import {
4
+ componentCatalogFamiliesFromReport,
5
+ componentCatalogNamesFromReport,
6
+ componentCatalogTreeFromReport,
7
+ fileStemToCatalogGroupLabel,
8
+ findingsForGovernanceTab,
9
+ governanceTabCounts,
10
+ resolveFamilyNavigationTarget,
11
+ unusedComponentsFromReport,
12
+ } from "./aggregate";
4
13
 
5
14
  function reportWithDefinitions(
6
15
  definitions: WorkspaceReport["files"][number]["definitions"],
@@ -28,8 +37,16 @@ function reportWithDefinitions(
28
37
  };
29
38
  }
30
39
 
40
+ describe("fileStemToCatalogGroupLabel", () => {
41
+ it("converts kebab-case file stems to PascalCase group labels", () => {
42
+ expect(fileStemToCatalogGroupLabel("hover-card")).toBe("HoverCard");
43
+ expect(fileStemToCatalogGroupLabel("dropdown-menu")).toBe("DropdownMenu");
44
+ expect(fileStemToCatalogGroupLabel("icons")).toBe("Icons");
45
+ });
46
+ });
47
+
31
48
  describe("componentCatalogFamiliesFromReport", () => {
32
- it("groups shadcn-style kebab filename exports under the normalized root", () => {
49
+ it("groups multi-export files under the filename stem", () => {
33
50
  const report = reportWithDefinitions([
34
51
  { name: "DropdownMenu", kind: "function", line: 1 },
35
52
  { name: "DropdownMenuContent", kind: "function", line: 2 },
@@ -40,12 +57,36 @@ describe("componentCatalogFamiliesFromReport", () => {
40
57
  expect(componentCatalogFamiliesFromReport(report)).toEqual([
41
58
  {
42
59
  parent: "DropdownMenu",
43
- children: ["DropdownMenuContent", "DropdownMenuItem", "DropdownMenuTrigger"],
60
+ children: [
61
+ "DropdownMenu",
62
+ "DropdownMenuContent",
63
+ "DropdownMenuItem",
64
+ "DropdownMenuTrigger",
65
+ ],
44
66
  path: "/repo/src/components/ui/dropdown-menu.tsx",
45
67
  },
46
68
  ]);
47
69
  });
48
70
 
71
+ it("groups icon-style sibling exports under the filename stem", () => {
72
+ const report = reportWithDefinitions(
73
+ [
74
+ { name: "IconSearch", kind: "wrapped_component", line: 15 },
75
+ { name: "IconCheck", kind: "wrapped_component", line: 31 },
76
+ { name: "IconChevronDown", kind: "wrapped_component", line: 46 },
77
+ ],
78
+ "/repo/src/components/icons.tsx",
79
+ );
80
+
81
+ expect(componentCatalogFamiliesFromReport(report)).toEqual([
82
+ {
83
+ parent: "Icons",
84
+ children: ["IconCheck", "IconChevronDown", "IconSearch"],
85
+ path: "/repo/src/components/icons.tsx",
86
+ },
87
+ ]);
88
+ });
89
+
49
90
  it("leaves single export files flat", () => {
50
91
  const report = reportWithDefinitions(
51
92
  [{ name: "Button", kind: "function", line: 1 }],
@@ -53,22 +94,350 @@ describe("componentCatalogFamiliesFromReport", () => {
53
94
  );
54
95
 
55
96
  expect(componentCatalogFamiliesFromReport(report)).toEqual([]);
56
- expect(componentCatalogTreeFromReport(report)).toEqual([{ type: "component", name: "Button" }]);
97
+ expect(componentCatalogTreeFromReport(report)).toEqual([
98
+ { type: "component", name: "Button" },
99
+ ]);
57
100
  });
58
101
 
59
- it("does not group sibling exports when no export matches the filename root", () => {
102
+ it("folds usage-only root exports into the file group instead of duplicating the label", () => {
103
+ const selectPath = "/repo/src/components/ui/select.tsx";
104
+ const report: WorkspaceReport = {
105
+ root: "/repo",
106
+ files: [
107
+ {
108
+ path: selectPath,
109
+ definitions: [
110
+ { name: "SelectTrigger", kind: "wrapped_component", line: 12 },
111
+ { name: "SelectContent", kind: "wrapped_component", line: 60 },
112
+ { name: "SelectItem", kind: "wrapped_component", line: 104 },
113
+ ],
114
+ usages: [],
115
+ parse_errors: [],
116
+ },
117
+ ],
118
+ findings: [],
119
+ duplicate_components: [],
120
+ usage_by_component: [
121
+ {
122
+ component: "Select",
123
+ reference_count: 2,
124
+ file_count: 1,
125
+ max_props_on_single_use: 1,
126
+ files: ["/repo/src/components/PlaygroundControlField.tsx"],
127
+ },
128
+ {
129
+ component: "SelectValue",
130
+ reference_count: 1,
131
+ file_count: 1,
132
+ max_props_on_single_use: 1,
133
+ files: ["/repo/src/components/PlaygroundControlField.tsx"],
134
+ },
135
+ ],
136
+ scores: {
137
+ design_system_health: 0,
138
+ ux_consistency: 0,
139
+ accessibility: 0,
140
+ maintainability: 0,
141
+ },
142
+ };
143
+
144
+ expect(componentCatalogFamiliesFromReport(report)).toEqual([
145
+ {
146
+ parent: "Select",
147
+ children: [
148
+ "Select",
149
+ "SelectContent",
150
+ "SelectItem",
151
+ "SelectTrigger",
152
+ "SelectValue",
153
+ ],
154
+ path: selectPath,
155
+ },
156
+ ]);
157
+ expect(componentCatalogTreeFromReport(report)).toEqual([
158
+ {
159
+ type: "family",
160
+ parent: "Select",
161
+ children: [
162
+ "Select",
163
+ "SelectContent",
164
+ "SelectItem",
165
+ "SelectTrigger",
166
+ "SelectValue",
167
+ ],
168
+ path: selectPath,
169
+ },
170
+ ]);
171
+ });
172
+
173
+ it("groups sibling exports even when no export matches the filename root", () => {
174
+ const report = reportWithDefinitions([
175
+ { name: "MenuRoot", kind: "function", line: 1 },
176
+ { name: "MenuItem", kind: "function", line: 2 },
177
+ ]);
178
+
179
+ expect(componentCatalogFamiliesFromReport(report)).toEqual([
180
+ {
181
+ parent: "DropdownMenu",
182
+ children: ["MenuItem", "MenuRoot"],
183
+ path: "/repo/src/components/ui/dropdown-menu.tsx",
184
+ },
185
+ ]);
186
+ expect(componentCatalogTreeFromReport(report)).toEqual([
187
+ {
188
+ type: "family",
189
+ parent: "DropdownMenu",
190
+ children: ["MenuItem", "MenuRoot"],
191
+ path: "/repo/src/components/ui/dropdown-menu.tsx",
192
+ },
193
+ ]);
194
+ });
195
+ });
196
+
197
+ describe("componentCatalogTreeFromReport", () => {
198
+ it("includes file-stem families when the parent label is not a catalog name", () => {
60
199
  const report = reportWithDefinitions(
61
200
  [
62
- { name: "MenuRoot", kind: "function", line: 1 },
63
- { name: "MenuItem", kind: "function", line: 2 },
201
+ { name: "IconSearch", kind: "wrapped_component", line: 15 },
202
+ { name: "IconCheck", kind: "wrapped_component", line: 31 },
64
203
  ],
65
- "/repo/src/components/ui/dropdown-menu.tsx",
204
+ "/repo/src/components/icons.tsx",
66
205
  );
67
206
 
68
- expect(componentCatalogFamiliesFromReport(report)).toEqual([]);
69
- expect(componentCatalogTreeFromReport(report)).toEqual([
70
- { type: "component", name: "MenuItem" },
71
- { type: "component", name: "MenuRoot" },
207
+ const names = componentCatalogNamesFromReport(report);
208
+ expect(names).toEqual(["IconCheck", "IconSearch"]);
209
+ expect(names).not.toContain("Icons");
210
+
211
+ const tree = componentCatalogTreeFromReport(report);
212
+ expect(tree).toEqual([
213
+ {
214
+ type: "family",
215
+ parent: "Icons",
216
+ children: ["IconCheck", "IconSearch"],
217
+ path: "/repo/src/components/icons.tsx",
218
+ },
72
219
  ]);
73
220
  });
74
221
  });
222
+
223
+ describe("resolveFamilyNavigationTarget", () => {
224
+ it("prefers a child export whose normalized name matches the file stem", () => {
225
+ const family = {
226
+ parent: "DropdownMenu",
227
+ children: [
228
+ "DropdownMenu",
229
+ "DropdownMenuContent",
230
+ "DropdownMenuItem",
231
+ "DropdownMenuTrigger",
232
+ ],
233
+ path: "/repo/src/components/ui/dropdown-menu.tsx",
234
+ };
235
+ const names = componentCatalogNamesFromReport(reportWithDefinitions([]));
236
+
237
+ expect(
238
+ resolveFamilyNavigationTarget(family, [
239
+ ...names,
240
+ "DropdownMenu",
241
+ "DropdownMenuContent",
242
+ "DropdownMenuItem",
243
+ "DropdownMenuTrigger",
244
+ ]),
245
+ ).toBe("DropdownMenu");
246
+ });
247
+
248
+ it("falls back to the first child when no export matches the file stem", () => {
249
+ const family = {
250
+ parent: "Icons",
251
+ children: ["IconCheck", "IconSearch"],
252
+ path: "/repo/src/components/icons.tsx",
253
+ };
254
+
255
+ expect(resolveFamilyNavigationTarget(family, ["IconCheck", "IconSearch"])).toBe(
256
+ "IconCheck",
257
+ );
258
+ });
259
+ });
260
+
261
+ describe("unusedComponentsFromReport", () => {
262
+ it("returns defined components with zero references", () => {
263
+ const report: WorkspaceReport = {
264
+ root: "/repo",
265
+ files: [
266
+ {
267
+ path: "/repo/src/components/ui/button.tsx",
268
+ definitions: [{ name: "Button", kind: "function", line: 1 }],
269
+ usages: [],
270
+ parse_errors: [],
271
+ },
272
+ {
273
+ path: "/repo/src/components/ui/badge.tsx",
274
+ definitions: [{ name: "Badge", kind: "function", line: 1 }],
275
+ usages: [],
276
+ parse_errors: [],
277
+ },
278
+ ],
279
+ findings: [],
280
+ duplicate_components: [],
281
+ usage_by_component: [
282
+ {
283
+ component: "Button",
284
+ reference_count: 12,
285
+ file_count: 4,
286
+ max_props_on_single_use: 2,
287
+ files: ["/repo/src/pages/home.tsx"],
288
+ },
289
+ ],
290
+ scores: {
291
+ design_system_health: 0,
292
+ ux_consistency: 0,
293
+ accessibility: 0,
294
+ maintainability: 0,
295
+ },
296
+ };
297
+
298
+ expect(unusedComponentsFromReport(report)).toEqual([
299
+ {
300
+ name: "Badge",
301
+ definitionPaths: ["/repo/src/components/ui/badge.tsx"],
302
+ },
303
+ ]);
304
+ });
305
+
306
+ it("excludes hidden components", () => {
307
+ const report: WorkspaceReport = {
308
+ root: "/repo",
309
+ files: [
310
+ {
311
+ path: "/repo/src/components/ui/ghost.tsx",
312
+ definitions: [{ name: "Ghost", kind: "function", line: 1 }],
313
+ usages: [],
314
+ parse_errors: [],
315
+ },
316
+ ],
317
+ findings: [],
318
+ duplicate_components: [],
319
+ usage_by_component: [],
320
+ scores: {
321
+ design_system_health: 0,
322
+ ux_consistency: 0,
323
+ accessibility: 0,
324
+ maintainability: 0,
325
+ },
326
+ config: { hidden_components: ["Ghost"] },
327
+ };
328
+
329
+ expect(unusedComponentsFromReport(report)).toEqual([]);
330
+ });
331
+ });
332
+
333
+ describe("governanceTabCounts", () => {
334
+ it("counts findings by pillar and unused components", () => {
335
+ const report: WorkspaceReport = {
336
+ root: "/repo",
337
+ files: [
338
+ {
339
+ path: "/repo/src/components/ui/badge.tsx",
340
+ definitions: [
341
+ { name: "Badge", kind: "function", line: 1 },
342
+ { name: "Ghost", kind: "function", line: 2 },
343
+ ],
344
+ usages: [],
345
+ parse_errors: [],
346
+ },
347
+ ],
348
+ findings: [
349
+ {
350
+ rule_id: "a11y-missing-alt",
351
+ message: "Missing alt text",
352
+ path: "/repo/src/pages/home.tsx",
353
+ line: 1,
354
+ severity: "error",
355
+ },
356
+ {
357
+ rule_id: "code-console",
358
+ message: "Console statement",
359
+ path: "/repo/src/pages/home.tsx",
360
+ line: 2,
361
+ severity: "warning",
362
+ },
363
+ {
364
+ rule_id: "token-tailwind-arbitrary",
365
+ message: "Arbitrary token",
366
+ path: "/repo/src/pages/home.tsx",
367
+ line: 3,
368
+ severity: "warning",
369
+ },
370
+ ],
371
+ duplicate_components: [],
372
+ usage_by_component: [],
373
+ scores: {
374
+ design_system_health: 0,
375
+ ux_consistency: 0,
376
+ accessibility: 0,
377
+ maintainability: 0,
378
+ },
379
+ };
380
+
381
+ expect(governanceTabCounts(report)).toEqual({
382
+ all: 3,
383
+ a11y: 1,
384
+ code: 1,
385
+ token: 1,
386
+ unused: 2,
387
+ });
388
+ });
389
+ });
390
+
391
+ describe("findingsForGovernanceTab", () => {
392
+ const findings: WorkspaceReport["findings"] = [
393
+ {
394
+ rule_id: "a11y-missing-alt",
395
+ message: "Missing alt text",
396
+ path: "/repo/src/pages/home.tsx",
397
+ line: 1,
398
+ severity: "error",
399
+ },
400
+ {
401
+ rule_id: "code-console",
402
+ message: "Console statement",
403
+ path: "/repo/src/pages/home.tsx",
404
+ line: 2,
405
+ severity: "warning",
406
+ },
407
+ {
408
+ rule_id: "token-tailwind-arbitrary",
409
+ message: "Arbitrary token",
410
+ path: "/repo/src/pages/home.tsx",
411
+ line: 3,
412
+ severity: "warning",
413
+ },
414
+ ];
415
+
416
+ const report: WorkspaceReport = {
417
+ root: "/repo",
418
+ files: [],
419
+ findings,
420
+ duplicate_components: [],
421
+ usage_by_component: [],
422
+ scores: {
423
+ design_system_health: 0,
424
+ ux_consistency: 0,
425
+ accessibility: 0,
426
+ maintainability: 0,
427
+ },
428
+ };
429
+
430
+ it("returns all findings for the all tab", () => {
431
+ expect(findingsForGovernanceTab(report, "all")).toEqual(findings);
432
+ });
433
+
434
+ it("filters findings by pillar", () => {
435
+ expect(findingsForGovernanceTab(report, "a11y")).toEqual([findings[0]]);
436
+ expect(findingsForGovernanceTab(report, "code")).toEqual([findings[1]]);
437
+ expect(findingsForGovernanceTab(report, "token")).toEqual([findings[2]]);
438
+ });
439
+
440
+ it("returns an empty list for the unused tab", () => {
441
+ expect(findingsForGovernanceTab(report, "unused")).toEqual([]);
442
+ });
443
+ });
@@ -1,4 +1,11 @@
1
- import type { ComponentDefinition, FileScan, UsageSummary, WorkspaceReport } from "../types/report";
1
+ import { pillarForRule } from "../mcp/rule-catalog";
2
+ import type {
3
+ ComponentDefinition,
4
+ FileScan,
5
+ LintFinding,
6
+ UsageSummary,
7
+ WorkspaceReport,
8
+ } from "../types/report";
2
9
  import {
3
10
  definitionPathsForName,
4
11
  isCatalogComponentHidden,
@@ -42,6 +49,15 @@ function fileStem(path: string): string {
42
49
  return base.replace(/\.(tsx|jsx)$/i, "");
43
50
  }
44
51
 
52
+ /** `hover-card.tsx` → `HoverCard`, `icons.tsx` → `Icons`. */
53
+ export function fileStemToCatalogGroupLabel(stem: string): string {
54
+ return stem
55
+ .split(/[-_]+/)
56
+ .filter(Boolean)
57
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
58
+ .join("");
59
+ }
60
+
45
61
  function normalizedName(value: string): string {
46
62
  return value
47
63
  .replace(/\.playground$/i, "")
@@ -135,6 +151,70 @@ export function componentCatalogNamesFromReport(
135
151
  return catalogComponentNames(aggregateDefinitions(report), usageMap(report), report);
136
152
  }
137
153
 
154
+ export type UnusedComponent = {
155
+ name: string;
156
+ definitionPaths: string[];
157
+ };
158
+
159
+ /** Defined components with zero JSX references in the scanned workspace. */
160
+ export function unusedComponentsFromReport(
161
+ report: WorkspaceReport | null | undefined,
162
+ ): UnusedComponent[] {
163
+ if (!report) return [];
164
+ const defs = aggregateDefinitions(report);
165
+ const usages = usageMap(report);
166
+ const out: UnusedComponent[] = [];
167
+
168
+ for (const [name, sites] of defs) {
169
+ if (!isVisibleCatalogName(report, name)) continue;
170
+ const usage = usages.get(name);
171
+ if (usage && usage.reference_count > 0) continue;
172
+ out.push({
173
+ name,
174
+ definitionPaths: sites.map((s) => s.path),
175
+ });
176
+ }
177
+
178
+ out.sort((a, b) => a.name.localeCompare(b.name));
179
+ return out;
180
+ }
181
+
182
+ export type GovernanceInventoryTab = "all" | "a11y" | "code" | "token" | "unused";
183
+
184
+ export function governanceTabCounts(
185
+ report: WorkspaceReport | null | undefined,
186
+ ): Record<GovernanceInventoryTab, number> {
187
+ const counts: Record<GovernanceInventoryTab, number> = {
188
+ all: 0,
189
+ a11y: 0,
190
+ code: 0,
191
+ token: 0,
192
+ unused: 0,
193
+ };
194
+ if (!report) return counts;
195
+
196
+ for (const finding of report.findings ?? []) {
197
+ counts.all += 1;
198
+ const pillar = pillarForRule(finding.rule_id);
199
+ if (pillar === "a11y") counts.a11y += 1;
200
+ else if (pillar === "code") counts.code += 1;
201
+ else if (pillar === "token") counts.token += 1;
202
+ }
203
+
204
+ counts.unused = unusedComponentsFromReport(report).length;
205
+ return counts;
206
+ }
207
+
208
+ export function findingsForGovernanceTab(
209
+ report: WorkspaceReport | null | undefined,
210
+ tab: GovernanceInventoryTab,
211
+ ): LintFinding[] {
212
+ if (!report || tab === "unused") return [];
213
+ const findings = report.findings ?? [];
214
+ if (tab === "all") return findings;
215
+ return findings.filter((f) => pillarForRule(f.rule_id) === tab);
216
+ }
217
+
138
218
  function familyFromFile(file: FileScan, report: WorkspaceReport): CatalogFamily | null {
139
219
  const defs = (file.definitions ?? []).filter((d) => {
140
220
  if (!isPlayableDefinition(d)) return false;
@@ -142,40 +222,82 @@ function familyFromFile(file: FileScan, report: WorkspaceReport): CatalogFamily
142
222
  });
143
223
  if (defs.length < 2) return null;
144
224
 
145
- const stem = normalizedName(fileStem(file.path));
146
- const root = defs.find((d) => normalizedName(d.name) === stem);
147
- if (!root) return null;
225
+ const children = defs.map((d) => d.name).sort((a, b) => a.localeCompare(b));
226
+ return { parent: fileStemToCatalogGroupLabel(fileStem(file.path)), children, path: file.path };
227
+ }
148
228
 
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;
229
+ /** `Select` + `Value` yes; `Selector` no. */
230
+ function isCompoundFamilyMember(parent: string, name: string): boolean {
231
+ if (name === parent) return true;
232
+ if (!name.startsWith(parent) || name.length <= parent.length) return false;
233
+ const next = name.charAt(parent.length);
234
+ return next === next.toUpperCase() && next !== next.toLowerCase();
235
+ }
236
+
237
+ function shouldAttachNameToFamily(
238
+ name: string,
239
+ family: CatalogFamily,
240
+ definitionPaths: string[],
241
+ ): boolean {
242
+ if (family.children.includes(name)) return false;
243
+ if (name === family.parent) return true;
244
+ if (definitionPaths.length > 0) {
245
+ return definitionPaths.every((p) => p === family.path);
246
+ }
247
+ return isCompoundFamilyMember(family.parent, name);
248
+ }
154
249
 
155
- return { parent: root.name, children, path: file.path };
250
+ function enrichCatalogFamily(
251
+ family: CatalogFamily,
252
+ catalogNames: string[],
253
+ report: WorkspaceReport,
254
+ ): CatalogFamily {
255
+ const children = new Set(family.children);
256
+ for (const name of catalogNames) {
257
+ if (
258
+ shouldAttachNameToFamily(
259
+ name,
260
+ family,
261
+ definitionPathsForName(report, name),
262
+ )
263
+ ) {
264
+ children.add(name);
265
+ }
266
+ }
267
+ return {
268
+ ...family,
269
+ children: [...children].sort((a, b) => a.localeCompare(b)),
270
+ };
156
271
  }
157
272
 
158
273
  export function componentCatalogFamiliesFromReport(
159
274
  report: WorkspaceReport | null | undefined,
160
275
  ): CatalogFamily[] {
161
276
  if (!report) return [];
162
- const byParent = new Map<string, CatalogFamily>();
277
+ const catalogNames = componentCatalogNamesFromReport(report);
278
+ const byPath = new Map<string, CatalogFamily>();
163
279
  for (const file of report.files ?? []) {
164
280
  const family = familyFromFile(file, report);
165
281
  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
- });
282
+ byPath.set(family.path, enrichCatalogFamily(family, catalogNames, report));
177
283
  }
178
- return [...byParent.values()].sort((a, b) => a.parent.localeCompare(b.parent));
284
+ return [...byPath.values()].sort((a, b) => a.parent.localeCompare(b.parent));
285
+ }
286
+
287
+ /** Best component id when navigating to a file-stem group label. */
288
+ export function resolveFamilyNavigationTarget(
289
+ family: CatalogFamily,
290
+ catalogNames: string[],
291
+ ): string {
292
+ if (catalogNames.includes(family.parent)) return family.parent;
293
+ const stem = normalizedName(fileStem(family.path));
294
+ const stemMatch = family.children.find((c) => normalizedName(c) === stem);
295
+ if (stemMatch) return stemMatch;
296
+ return family.children[0] ?? family.parent;
297
+ }
298
+
299
+ function catalogTreeSortKey(item: CatalogTreeItem): string {
300
+ return item.type === "family" ? item.parent : item.name;
179
301
  }
180
302
 
181
303
  export function componentCatalogTreeFromReport(
@@ -183,21 +305,23 @@ export function componentCatalogTreeFromReport(
183
305
  ): CatalogTreeItem[] {
184
306
  const names = componentCatalogNamesFromReport(report);
185
307
  const families = componentCatalogFamiliesFromReport(report);
186
- const familyByParent = new Map(families.map((f) => [f.parent, f]));
187
308
  const childNames = new Set(families.flatMap((f) => f.children));
309
+ const familyParentLabels = new Set(families.map((f) => f.parent));
188
310
  const items: CatalogTreeItem[] = [];
189
311
 
312
+ for (const family of families) {
313
+ items.push({ type: "family", ...family });
314
+ }
315
+
190
316
  for (const name of names) {
191
- const family = familyByParent.get(name);
192
- if (family) {
193
- items.push({ type: "family", ...family });
194
- continue;
195
- }
196
317
  if (childNames.has(name)) continue;
318
+ if (familyParentLabels.has(name)) continue;
197
319
  items.push({ type: "component", name });
198
320
  }
199
321
 
200
- return items;
322
+ return items.sort((a, b) =>
323
+ catalogTreeSortKey(a).localeCompare(catalogTreeSortKey(b)),
324
+ );
201
325
  }
202
326
 
203
327
  export function componentCatalogFamilyForName(
@@ -208,3 +332,16 @@ export function componentCatalogFamilyForName(
208
332
  (family) => family.parent === name || family.children.includes(name),
209
333
  );
210
334
  }
335
+
336
+ /** Sibling/child exports to show for a compound component family. */
337
+ export function catalogChildComponentsFor(
338
+ family: CatalogFamily | undefined,
339
+ componentId: string,
340
+ ): string[] {
341
+ if (!family) return [];
342
+ if (family.children.includes(componentId)) {
343
+ return family.children.filter((child) => child !== componentId);
344
+ }
345
+ if (family.parent === componentId) return family.children;
346
+ return [];
347
+ }
@@ -139,6 +139,11 @@ export function buildMergedTokenView(
139
139
  };
140
140
  }
141
141
 
142
+ /** Stable React key — cssName alone is not unique across scope/path. */
143
+ export function scannedTokenRowKey(row: ScannedTokenRow): string {
144
+ return `${row.cssName}|${row.scope}|${row.path}|${row.line}`;
145
+ }
146
+
142
147
  export function filterTokenRows(
143
148
  rows: ScannedTokenRow[],
144
149
  filter: TokenUsageFilter,