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.
- package/CHANGELOG.md +17 -0
- package/bin/lib/infer-prop-types-from-ts.mjs +14 -1
- package/bin/lib/infer-prop-types-from-ts.test.mjs +32 -0
- package/dashboard-dist/assets/{DashboardLayoutAuto-h0gP_iKd.js → DashboardLayoutAuto-B4P-sy4z.js} +1 -1
- package/dashboard-dist/assets/DashboardLayoutAuto-CAO6F6-q.css +1 -0
- package/dashboard-dist/assets/{axe-DDaE9JTN.js → axe-CaxTXfM9.js} +1 -1
- package/dashboard-dist/assets/index-B432JkIx.js +219 -0
- package/dashboard-dist/assets/index-D0O_5w5V.css +1 -0
- package/dashboard-dist/dslinter-report.json +23929 -0
- package/dashboard-dist/index.html +2 -2
- package/index.cjs +52 -52
- package/package.json +6 -6
- package/src/components/CatalogPane.tsx +94 -0
- package/src/components/ComponentInspectPane.tsx +125 -125
- package/src/components/ComponentPlaygroundPane.tsx +42 -27
- package/src/components/DashboardCommandPalette.tsx +9 -0
- package/src/components/GovernanceInventoryTabs.tsx +51 -0
- package/src/components/GovernancePane.tsx +18 -5
- package/src/components/PlaygroundA11yAndCode.tsx +0 -52
- package/src/components/PlaygroundControlField.tsx +2 -0
- package/src/components/ScoreGauge.test.ts +22 -0
- package/src/components/ScoreGauge.tsx +179 -0
- package/src/components/Sidebar.tsx +97 -23
- package/src/components/TokensPane.tsx +11 -13
- package/src/components/controlApiTable.test.ts +15 -0
- package/src/components/controlApiTable.ts +4 -0
- package/src/components/ui/badge.tsx +5 -5
- package/src/dashboard/ComponentCatalog.tsx +10 -1
- package/src/dashboard/ComponentPropUsageDetail.tsx +127 -42
- package/src/dashboard/ComponentUsageDetails.tsx +39 -9
- package/src/dashboard/DashboardBody.tsx +83 -12
- package/src/dashboard/ScannedTokenWall.tsx +9 -6
- package/src/dashboard/UnusedComponentsList.tsx +74 -0
- package/src/dashboard/aggregate.test.ts +381 -12
- package/src/dashboard/aggregate.ts +167 -30
- package/src/dashboard/mergeTokenCatalog.ts +5 -0
- package/src/dashboard/paths.test.ts +18 -1
- package/src/dashboard/paths.ts +8 -0
- package/src/mcp/agent-query.ts +1 -1
- package/src/playground/buildPlaygroundEntriesFromReport.test.ts +3 -3
- package/src/playground/controls.ts +16 -3
- package/src/playground/enrichKitControls.ts +5 -5
- package/src/playground/inferKitJsx.test.ts +0 -11
- package/src/playground/inferPropTypesFromTs.d.mts +1 -1
- package/src/playground/inferPropTypesFromTs.mjs +19 -3
- package/src/playground/inferPropTypesFromTs.test.ts +32 -0
- package/src/playground/inferPropTypesFromTs.ts +1 -1
- package/src/playground/playgroundJoin.ts +34 -0
- package/src/playground/propCoerce.ts +2 -2
- package/src/playground/snippet.ts +1 -0
- package/src/shell/DashboardLayout.tsx +21 -4
- package/src/shell/hashRoute.test.ts +9 -0
- package/src/shell/hashRoute.ts +6 -0
- package/src/types/controls.ts +12 -0
- package/src/types/report.ts +1 -1
- package/vite/embedTailwindSources.ts +8 -6
- package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +0 -1
- package/dashboard-dist/assets/index-B9sZ6wHm.css +0 -1
- 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 {
|
|
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
|
|
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: [
|
|
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([
|
|
97
|
+
expect(componentCatalogTreeFromReport(report)).toEqual([
|
|
98
|
+
{ type: "component", name: "Button" },
|
|
99
|
+
]);
|
|
57
100
|
});
|
|
58
101
|
|
|
59
|
-
it("
|
|
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: "
|
|
63
|
-
{ name: "
|
|
201
|
+
{ name: "IconSearch", kind: "wrapped_component", line: 15 },
|
|
202
|
+
{ name: "IconCheck", kind: "wrapped_component", line: 31 },
|
|
64
203
|
],
|
|
65
|
-
"/repo/src/components/
|
|
204
|
+
"/repo/src/components/icons.tsx",
|
|
66
205
|
);
|
|
67
206
|
|
|
68
|
-
|
|
69
|
-
expect(
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 [...
|
|
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,
|