dslinter 0.1.13 → 0.2.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 (181) hide show
  1. package/CHANGELOG.md +43 -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 +96 -8
  25. package/bin/lib/scaffold-config.test.mjs +12 -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 +209 -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 +51 -3
  158. package/vite/collectScanModules.ts +85 -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
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildAppPreviewTheme,
4
+ cssVariablesForPreviewTheme,
5
+ isConsumerThemeDefinition,
6
+ isDashboardThemePath,
7
+ } from "./appPreviewTheme";
8
+ import type { CssTokenDefinition, CssTokenSummary } from "../types/report";
9
+
10
+ const root = "/Users/dev/demo-inertia";
11
+
12
+ function def(
13
+ partial: Partial<CssTokenDefinition> & Pick<CssTokenDefinition, "name" | "value" | "scope">,
14
+ ): CssTokenDefinition {
15
+ return {
16
+ category: "other",
17
+ path: `${root}/resources/css/app.css`,
18
+ line: 1,
19
+ ...partial,
20
+ };
21
+ }
22
+
23
+ describe("isDashboardThemePath", () => {
24
+ it("flags dslinter dashboard theme files", () => {
25
+ expect(
26
+ isDashboardThemePath(
27
+ "/repo/packages/dashboard/src/styles/dashboard-theme.css",
28
+ ),
29
+ ).toBe(true);
30
+ expect(isDashboardThemePath("/repo/node_modules/dslinter/theme.css")).toBe(
31
+ true,
32
+ );
33
+ });
34
+ });
35
+
36
+ describe("isConsumerThemeDefinition", () => {
37
+ it("accepts app css under report root", () => {
38
+ expect(
39
+ isConsumerThemeDefinition(
40
+ def({ name: "--primary", value: "black", scope: "root" }),
41
+ root,
42
+ ),
43
+ ).toBe(true);
44
+ });
45
+
46
+ it("rejects dashboard theme paths", () => {
47
+ expect(
48
+ isConsumerThemeDefinition(
49
+ def({
50
+ name: "--primary",
51
+ value: "blue",
52
+ scope: "root",
53
+ path: "/repo/packages/dashboard/src/styles/dashboard-theme.css",
54
+ }),
55
+ root,
56
+ ),
57
+ ).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe("buildAppPreviewTheme", () => {
62
+ it("extracts light root and dark selector tokens from consumer css", () => {
63
+ const summary: CssTokenSummary = {
64
+ definitions: [
65
+ def({
66
+ name: "--primary",
67
+ value: "oklch(0.205 0 0)",
68
+ scope: "root",
69
+ line: 71,
70
+ }),
71
+ def({
72
+ name: "--background",
73
+ value: "oklch(1 0 0)",
74
+ scope: "root",
75
+ line: 65,
76
+ }),
77
+ def({
78
+ name: "--color-primary",
79
+ value: "var(--primary)",
80
+ scope: "theme",
81
+ line: 29,
82
+ }),
83
+ def({
84
+ name: "--primary",
85
+ value: "oklch(0.985 0 0)",
86
+ scope: "selector",
87
+ line: 107,
88
+ }),
89
+ def({
90
+ name: "--background",
91
+ value: "oklch(0.145 0 0)",
92
+ scope: "selector",
93
+ line: 101,
94
+ }),
95
+ def({
96
+ name: "--primary",
97
+ value: "oklch(0.488 0.243 264.376)",
98
+ scope: "root",
99
+ path: "/repo/packages/dashboard/src/styles/dashboard-theme.css",
100
+ line: 100,
101
+ }),
102
+ ],
103
+ usage_by_token: [],
104
+ };
105
+
106
+ const theme = buildAppPreviewTheme(summary, root);
107
+ expect(theme).not.toBeNull();
108
+ expect(theme!.light["--primary"]).toBe("oklch(0.205 0 0)");
109
+ expect(theme!.light["--color-primary"]).toBe("oklch(0.205 0 0)");
110
+ expect(theme!.dark["--primary"]).toBe("oklch(0.985 0 0)");
111
+ expect(theme!.sourcePaths).toEqual([`${root}/resources/css/app.css`]);
112
+ });
113
+
114
+ it("returns null when only dashboard theme tokens exist", () => {
115
+ const summary: CssTokenSummary = {
116
+ definitions: [
117
+ def({
118
+ name: "--primary",
119
+ value: "blue",
120
+ scope: "root",
121
+ path: "/repo/packages/dashboard/src/styles/dashboard-theme.css",
122
+ }),
123
+ ],
124
+ usage_by_token: [],
125
+ };
126
+
127
+ expect(buildAppPreviewTheme(summary, root)).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe("cssVariablesForPreviewTheme", () => {
132
+ it("merges dark overrides on top of light tokens", () => {
133
+ const theme = {
134
+ light: { "--background": "white", "--primary": "black" },
135
+ dark: { "--background": "black", "--primary": "white" },
136
+ sourcePaths: [],
137
+ };
138
+
139
+ expect(cssVariablesForPreviewTheme(theme, "light")).toEqual({
140
+ "--background": "white",
141
+ "--primary": "black",
142
+ });
143
+ expect(cssVariablesForPreviewTheme(theme, "dark")).toEqual({
144
+ "--background": "black",
145
+ "--primary": "white",
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,137 @@
1
+ import type { CssTokenDefinition, CssTokenSummary, WorkspaceReport } from "../types/report";
2
+
3
+ const DASHBOARD_THEME_PATH_MARKERS = [
4
+ "dashboard-theme.css",
5
+ "dslinter/theme.css",
6
+ ] as const;
7
+
8
+ export type AppPreviewTheme = {
9
+ light: Record<string, string>;
10
+ dark: Record<string, string>;
11
+ sourcePaths: string[];
12
+ };
13
+
14
+ export function isDashboardThemePath(path: string): boolean {
15
+ const normalized = path.replace(/\\/g, "/");
16
+ return DASHBOARD_THEME_PATH_MARKERS.some((marker) =>
17
+ normalized.includes(marker),
18
+ );
19
+ }
20
+
21
+ export function isConsumerThemeDefinition(
22
+ def: CssTokenDefinition,
23
+ reportRoot?: string,
24
+ ): boolean {
25
+ if (isDashboardThemePath(def.path)) return false;
26
+
27
+ const normalizedPath = def.path.replace(/\\/g, "/");
28
+ if (normalizedPath.includes("/packages/dashboard/")) return false;
29
+
30
+ if (reportRoot) {
31
+ const normalizedRoot = reportRoot.replace(/\\/g, "/").replace(/\/$/, "");
32
+ if (normalizedPath.startsWith(normalizedRoot)) return true;
33
+ }
34
+
35
+ return /(?:^|\/)(resources\/css|src\/|app\/|styles\/)/.test(normalizedPath);
36
+ }
37
+
38
+ function definitionsForMode(
39
+ definitions: CssTokenDefinition[],
40
+ mode: "light" | "dark",
41
+ reportRoot?: string,
42
+ ): Map<string, string> {
43
+ const scopes =
44
+ mode === "light"
45
+ ? new Set<CssTokenDefinition["scope"]>(["root", "theme"])
46
+ : new Set<CssTokenDefinition["scope"]>(["selector"]);
47
+
48
+ const vars = new Map<string, string>();
49
+ for (const def of definitions) {
50
+ if (!isConsumerThemeDefinition(def, reportRoot)) continue;
51
+ if (!scopes.has(def.scope)) continue;
52
+ if (!vars.has(def.name)) {
53
+ vars.set(def.name, def.value);
54
+ }
55
+ }
56
+ return vars;
57
+ }
58
+
59
+ function resolveCssVariables(vars: Map<string, string>): Record<string, string> {
60
+ const resolved = new Map<string, string>();
61
+ const varRefRe = /var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,[^)]+)?\s*\)/g;
62
+
63
+ const resolveOne = (name: string, seen: Set<string>): string => {
64
+ const cached = resolved.get(name);
65
+ if (cached != null) return cached;
66
+
67
+ const raw = vars.get(name);
68
+ if (raw == null) return `var(${name})`;
69
+ if (seen.has(name)) return raw;
70
+
71
+ seen.add(name);
72
+ const next = raw.replace(varRefRe, (_match, ref: string) => {
73
+ if (!vars.has(ref)) return `var(${ref})`;
74
+ return resolveOne(ref, seen);
75
+ });
76
+ seen.delete(name);
77
+ resolved.set(name, next);
78
+ return next;
79
+ };
80
+
81
+ for (const name of vars.keys()) {
82
+ resolveOne(name, new Set());
83
+ }
84
+
85
+ return Object.fromEntries(resolved);
86
+ }
87
+
88
+ export function buildAppPreviewTheme(
89
+ summary: CssTokenSummary | null | undefined,
90
+ reportRoot?: string,
91
+ ): AppPreviewTheme | null {
92
+ const definitions = summary?.definitions;
93
+ if (!definitions?.length) return null;
94
+
95
+ const light = resolveCssVariables(
96
+ definitionsForMode(definitions, "light", reportRoot),
97
+ );
98
+ const dark = resolveCssVariables(
99
+ definitionsForMode(definitions, "dark", reportRoot),
100
+ );
101
+
102
+ const sourcePaths = [
103
+ ...new Set(
104
+ definitions
105
+ .filter((def) => isConsumerThemeDefinition(def, reportRoot))
106
+ .map((def) => def.path.replace(/\\/g, "/")),
107
+ ),
108
+ ].sort();
109
+
110
+ const hasLightSemantic = Object.keys(light).some(
111
+ (name) => !name.startsWith("--color-") && !name.startsWith("--spacing-"),
112
+ );
113
+ const hasDarkSemantic = Object.keys(dark).some(
114
+ (name) => !name.startsWith("--color-") && !name.startsWith("--spacing-"),
115
+ );
116
+
117
+ if (!hasLightSemantic && !hasDarkSemantic) return null;
118
+
119
+ return { light, dark, sourcePaths };
120
+ }
121
+
122
+ export function buildAppPreviewThemeFromReport(
123
+ report: WorkspaceReport | null | undefined,
124
+ ): AppPreviewTheme | null {
125
+ if (!report?.css_tokens) return null;
126
+ return buildAppPreviewTheme(report.css_tokens, report.root);
127
+ }
128
+
129
+ export function cssVariablesForPreviewTheme(
130
+ theme: AppPreviewTheme,
131
+ mode: "light" | "dark",
132
+ ): Record<string, string> {
133
+ if (mode === "dark" && Object.keys(theme.dark).length > 0) {
134
+ return { ...theme.light, ...theme.dark };
135
+ }
136
+ return theme.light;
137
+ }
@@ -0,0 +1,348 @@
1
+ import { createElement, forwardRef, type ReactNode } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { WorkspaceReport } from "../types/report";
5
+ import {
6
+ buildCompoundPlaygroundEntries,
7
+ detectCompoundFamily,
8
+ findCompoundFamilies,
9
+ } from "./buildCompoundPlaygroundEntries";
10
+ import { buildPlaygroundEntriesFromReportWithSkips } from "./buildPlaygroundEntriesFromReport";
11
+ import { resolvePlaygroundEntry } from "./buildPlaygroundEntriesFromReport";
12
+
13
+ function dropdownModule() {
14
+ return {
15
+ Dropdown: ({ children }: { children?: ReactNode }) =>
16
+ createElement("div", { "data-root": "dropdown" }, children),
17
+ DropdownTrigger: ({ children }: { children?: ReactNode }) =>
18
+ createElement("button", { type: "button" }, children),
19
+ DropdownMenu: forwardRef(
20
+ (
21
+ {
22
+ children,
23
+ align,
24
+ }: {
25
+ children?: ReactNode;
26
+ align?: string;
27
+ },
28
+ _ref,
29
+ ) => createElement("div", { "data-menu": true, "data-align": align }, children),
30
+ ),
31
+ DropdownItem: forwardRef(({ children }: { children?: ReactNode }, _ref) =>
32
+ createElement("div", { "data-item": true }, children),
33
+ ),
34
+ DropdownSeparator: () => createElement("hr"),
35
+ };
36
+ }
37
+
38
+ describe("detectCompoundFamily", () => {
39
+ it("detects Dropdown compound family", () => {
40
+ const exports = new Map([
41
+ [
42
+ "Dropdown",
43
+ {
44
+ name: "Dropdown",
45
+ kind: "function" as const,
46
+ line: 1,
47
+ declared_props: ["open"],
48
+ },
49
+ ],
50
+ [
51
+ "DropdownTrigger",
52
+ {
53
+ name: "DropdownTrigger",
54
+ kind: "function" as const,
55
+ line: 2,
56
+ declared_props: ["children"],
57
+ },
58
+ ],
59
+ [
60
+ "DropdownMenu",
61
+ {
62
+ name: "DropdownMenu",
63
+ kind: "wrapped_component" as const,
64
+ line: 3,
65
+ },
66
+ ],
67
+ [
68
+ "DropdownItem",
69
+ {
70
+ name: "DropdownItem",
71
+ kind: "wrapped_component" as const,
72
+ line: 4,
73
+ },
74
+ ],
75
+ ]);
76
+
77
+ const family = detectCompoundFamily("resources/js/Components/Dropdown.jsx", exports);
78
+ expect(family).toMatchObject({
79
+ root: "Dropdown",
80
+ trigger: "DropdownTrigger",
81
+ content: "DropdownMenu",
82
+ });
83
+ });
84
+
85
+ it("detects shadcn-style kebab filenames with PascalCase root exports", () => {
86
+ const exports = new Map([
87
+ [
88
+ "DropdownMenu",
89
+ {
90
+ name: "DropdownMenu",
91
+ kind: "function" as const,
92
+ line: 1,
93
+ },
94
+ ],
95
+ [
96
+ "DropdownMenuTrigger",
97
+ {
98
+ name: "DropdownMenuTrigger",
99
+ kind: "function" as const,
100
+ line: 2,
101
+ },
102
+ ],
103
+ [
104
+ "DropdownMenuContent",
105
+ {
106
+ name: "DropdownMenuContent",
107
+ kind: "function" as const,
108
+ line: 3,
109
+ },
110
+ ],
111
+ [
112
+ "DropdownMenuItem",
113
+ {
114
+ name: "DropdownMenuItem",
115
+ kind: "function" as const,
116
+ line: 4,
117
+ },
118
+ ],
119
+ ]);
120
+
121
+ const family = detectCompoundFamily(
122
+ "resources/js/components/ui/dropdown-menu.tsx",
123
+ exports,
124
+ "ui",
125
+ );
126
+ expect(family).toMatchObject({
127
+ root: "DropdownMenu",
128
+ trigger: "DropdownMenuTrigger",
129
+ content: "DropdownMenuContent",
130
+ group: "ui",
131
+ });
132
+ });
133
+
134
+ it("returns null for single-export file", () => {
135
+ const exports = new Map([
136
+ [
137
+ "Button",
138
+ {
139
+ name: "Button",
140
+ kind: "function" as const,
141
+ line: 1,
142
+ },
143
+ ],
144
+ ]);
145
+ expect(detectCompoundFamily("src/components/Button.tsx", exports)).toBeNull();
146
+ });
147
+ });
148
+
149
+ describe("buildCompoundPlaygroundEntries", () => {
150
+ const report: WorkspaceReport = {
151
+ root: "/repo",
152
+ files: [
153
+ {
154
+ path: "/repo/resources/js/Components/Dropdown.jsx",
155
+ definitions: [
156
+ {
157
+ name: "Dropdown",
158
+ kind: "function",
159
+ line: 1,
160
+ declared_props: ["open"],
161
+ },
162
+ {
163
+ name: "DropdownTrigger",
164
+ kind: "function",
165
+ line: 2,
166
+ declared_props: ["children"],
167
+ },
168
+ {
169
+ name: "DropdownMenu",
170
+ kind: "wrapped_component",
171
+ line: 3,
172
+ },
173
+ {
174
+ name: "DropdownItem",
175
+ kind: "wrapped_component",
176
+ line: 4,
177
+ },
178
+ {
179
+ name: "DropdownSeparator",
180
+ kind: "wrapped_component",
181
+ line: 5,
182
+ },
183
+ ],
184
+ usages: [],
185
+ parse_errors: [],
186
+ },
187
+ ],
188
+ findings: [],
189
+ scores: {
190
+ design_system_health: 0,
191
+ ux_consistency: 0,
192
+ accessibility: 0,
193
+ maintainability: 0,
194
+ },
195
+ duplicate_components: [],
196
+ usage_by_component: [
197
+ {
198
+ component: "DropdownMenu",
199
+ reference_count: 10,
200
+ file_count: 5,
201
+ max_props_on_single_use: 2,
202
+ files: [],
203
+ prop_frequencies: { align: 10, children: 10 },
204
+ prop_value_frequencies: { align: { end: 8, start: 2 } },
205
+ },
206
+ ],
207
+ playgrounds: [
208
+ {
209
+ id: "Dropdown",
210
+ export_name: "Dropdown",
211
+ rel_path: "resources/js/Components/Dropdown.jsx",
212
+ declared_props: ["open"],
213
+ },
214
+ ],
215
+ };
216
+
217
+ const modules = {
218
+ "@dslinter-scan/resources/js/Components/Dropdown.jsx": dropdownModule(),
219
+ };
220
+
221
+ it("finds compound families from report", () => {
222
+ const families = findCompoundFamilies(report);
223
+ expect(families).toHaveLength(1);
224
+ expect(families[0]?.root).toBe("Dropdown");
225
+ });
226
+
227
+ it("builds DropdownMenu compound entry with align select control", () => {
228
+ const entries = buildCompoundPlaygroundEntries(report, modules, {
229
+ globKeyFromRelPath: (rel) => `@dslinter-scan/${rel}`,
230
+ existingIds: new Set(["Dropdown"]),
231
+ });
232
+ const menu = entries.find((e) => e.id === "DropdownMenu");
233
+ expect(menu).toBeDefined();
234
+ const align = menu!.controls.find((c) => c.key === "align");
235
+ expect(align?.type).toBe("select");
236
+ if (align?.type === "select") {
237
+ expect(align.options.map((o) => o.value)).toEqual(["end", "start"]);
238
+ }
239
+ });
240
+
241
+ it("renders compound tree for DropdownMenu", () => {
242
+ const entries = buildCompoundPlaygroundEntries(report, modules, {
243
+ globKeyFromRelPath: (rel) => `@dslinter-scan/${rel}`,
244
+ existingIds: new Set(["Dropdown"]),
245
+ });
246
+ const menu = entries.find((e) => e.id === "DropdownMenu")!;
247
+ const html = renderToStaticMarkup(createElement(menu.Preview, { values: { align: "end" } }));
248
+ expect(html).toContain('data-root="dropdown"');
249
+ expect(html).toContain("Open menu");
250
+ expect(html).toContain('data-menu="true"');
251
+ expect(html).toContain('data-align="end"');
252
+ });
253
+
254
+ it("merges compound entries in buildPlaygroundEntriesFromReportWithSkips", () => {
255
+ const { entries } = buildPlaygroundEntriesFromReportWithSkips(report, modules, {
256
+ logJoinSkips: false,
257
+ });
258
+ expect(resolvePlaygroundEntry(entries, "Dropdown")).toBeDefined();
259
+ expect(resolvePlaygroundEntry(entries, "DropdownMenu")).toBeDefined();
260
+ expect(resolvePlaygroundEntry(entries, "DropdownItem")).toBeDefined();
261
+ });
262
+
263
+ it("upgrades shadcn DropdownMenu root to composed content preview", () => {
264
+ const relPath = "resources/js/components/ui/dropdown-menu.tsx";
265
+ const shadcnReport: WorkspaceReport = {
266
+ root: "/repo",
267
+ files: [
268
+ {
269
+ path: `/repo/${relPath}`,
270
+ definitions: [
271
+ { name: "DropdownMenu", kind: "function", line: 1, declared_props: ["open"] },
272
+ {
273
+ name: "DropdownMenuTrigger",
274
+ kind: "function",
275
+ line: 2,
276
+ declared_props: ["children"],
277
+ },
278
+ {
279
+ name: "DropdownMenuContent",
280
+ kind: "function",
281
+ line: 3,
282
+ declared_props: ["align"],
283
+ },
284
+ { name: "DropdownMenuItem", kind: "function", line: 4, declared_props: [] },
285
+ ],
286
+ usages: [],
287
+ parse_errors: [],
288
+ },
289
+ ],
290
+ findings: [],
291
+ scores: {
292
+ design_system_health: 0,
293
+ ux_consistency: 0,
294
+ accessibility: 0,
295
+ maintainability: 0,
296
+ },
297
+ duplicate_components: [],
298
+ usage_by_component: [
299
+ {
300
+ component: "DropdownMenuContent",
301
+ reference_count: 5,
302
+ file_count: 2,
303
+ max_props_on_single_use: 1,
304
+ files: [],
305
+ prop_frequencies: { align: 5 },
306
+ prop_value_frequencies: { align: { end: 4, start: 1 } },
307
+ },
308
+ ],
309
+ playgrounds: [
310
+ {
311
+ id: "DropdownMenu",
312
+ export_name: "DropdownMenu",
313
+ rel_path: relPath,
314
+ declared_props: ["open"],
315
+ },
316
+ ],
317
+ };
318
+ const shadcnModules = {
319
+ "@dslinter-scan/resources/js/components/ui/dropdown-menu.tsx": {
320
+ DropdownMenu: ({ children }: { children?: ReactNode }) =>
321
+ createElement("div", { "data-root": "dropdown-menu" }, children),
322
+ DropdownMenuTrigger: ({ children }: { children?: ReactNode }) =>
323
+ createElement("button", { type: "button" }, children),
324
+ DropdownMenuContent: ({
325
+ children,
326
+ align,
327
+ }: {
328
+ children?: ReactNode;
329
+ align?: string;
330
+ }) => createElement("div", { "data-content": true, "data-align": align }, children),
331
+ DropdownMenuItem: ({ children }: { children?: ReactNode }) =>
332
+ createElement("div", { "data-item": true }, children),
333
+ },
334
+ };
335
+ const { entries } = buildPlaygroundEntriesFromReportWithSkips(shadcnReport, shadcnModules, {
336
+ globKeyFromRelPath: (rel) => `@dslinter-scan/${rel}`,
337
+ logJoinSkips: false,
338
+ });
339
+ const root = resolvePlaygroundEntry(entries, "DropdownMenu");
340
+ expect(root?.id).toBe("DropdownMenu");
341
+ const align = root!.controls.find((c) => c.key === "align");
342
+ expect(align?.type).toBe("select");
343
+ const html = renderToStaticMarkup(createElement(root!.Preview, { values: { align: "end" } }));
344
+ expect(html).toContain('data-root="dropdown-menu"');
345
+ expect(html).toContain('data-content="true"');
346
+ expect(html).toContain('data-align="end"');
347
+ });
348
+ });