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
@@ -1,9 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { WorkspaceReport } from "../types/report";
3
- import {
4
- defaultConsumerGlobKeyFromRelPath,
5
- diagnosePlaygroundJoinSkips,
6
- } from "./playgroundJoin";
3
+ import { defaultConsumerGlobKeyFromRelPath, diagnosePlaygroundJoinSkips } from "./playgroundJoin";
7
4
  import { buildPlaygroundEntriesFromReportWithSkips } from "./buildPlaygroundEntriesFromReport";
8
5
 
9
6
  describe("defaultConsumerGlobKeyFromRelPath", () => {
@@ -20,11 +17,9 @@ describe("defaultConsumerGlobKeyFromRelPath", () => {
20
17
  });
21
18
 
22
19
  it("maps Laravel resources/js paths", () => {
23
- expect(
24
- defaultConsumerGlobKeyFromRelPath(
25
- "resources/js/Components/Icons/Activity.tsx",
26
- ),
27
- ).toBe("../Components/Icons/Activity.tsx");
20
+ expect(defaultConsumerGlobKeyFromRelPath("resources/js/Components/Icons/Activity.tsx")).toBe(
21
+ "../Components/Icons/Activity.tsx",
22
+ );
28
23
  });
29
24
  });
30
25
 
@@ -34,12 +29,11 @@ describe("diagnosePlaygroundJoinSkips", () => {
34
29
  files: [],
35
30
  findings: [],
36
31
  scores: {
37
- system_health: 0,
38
- token_adoption: 0,
39
- component_adoption: 0,
32
+ design_system_health: 0,
40
33
  ux_consistency: 0,
34
+ accessibility: 0,
35
+ maintainability: 0,
41
36
  },
42
- ownership: [],
43
37
  duplicate_components: [],
44
38
  usage_by_component: [],
45
39
  playgrounds: [
@@ -53,9 +47,13 @@ describe("diagnosePlaygroundJoinSkips", () => {
53
47
  };
54
48
 
55
49
  it("flags module_not_found when glob key is missing", () => {
56
- const skipped = diagnosePlaygroundJoinSkips(report, {}, {
57
- globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
58
- });
50
+ const skipped = diagnosePlaygroundJoinSkips(
51
+ report,
52
+ {},
53
+ {
54
+ globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
55
+ },
56
+ );
59
57
  expect(skipped).toHaveLength(1);
60
58
  expect(skipped[0]?.reason).toBe("module_not_found");
61
59
  expect(skipped[0]?.globKey).toBe("../components/ui/button.tsx");
@@ -70,13 +68,75 @@ describe("diagnosePlaygroundJoinSkips", () => {
70
68
  },
71
69
  },
72
70
  };
71
+ const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(report, modules, {
72
+ globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
73
+ logJoinSkips: false,
74
+ });
75
+ expect(skipped).toHaveLength(0);
76
+ expect(entries).toHaveLength(1);
77
+ expect(entries[0]?.id).toBe("Button");
78
+ });
79
+
80
+ it("joins default-exported components by export_name", () => {
81
+ const defaultExportReport: WorkspaceReport = {
82
+ ...report,
83
+ playgrounds: [
84
+ {
85
+ id: "AppLogoIcon",
86
+ export_name: "AppLogoIcon",
87
+ rel_path: "resources/js/components/app-logo-icon.tsx",
88
+ declared_props: [],
89
+ },
90
+ ],
91
+ };
92
+ const key = defaultConsumerGlobKeyFromRelPath(
93
+ "resources/js/components/app-logo-icon.tsx",
94
+ );
95
+ const modules = {
96
+ [key]: {
97
+ default: function AppLogoIcon() {
98
+ return null;
99
+ },
100
+ },
101
+ };
73
102
  const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(
74
- report,
103
+ defaultExportReport,
75
104
  modules,
76
- { globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath, logJoinSkips: false },
105
+ {
106
+ globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
107
+ logJoinSkips: false,
108
+ },
77
109
  );
78
110
  expect(skipped).toHaveLength(0);
79
111
  expect(entries).toHaveLength(1);
80
- expect(entries[0]?.id).toBe("Button");
112
+ expect(entries[0]?.id).toBe("AppLogoIcon");
113
+ });
114
+
115
+ it("joins bare rel_path via unique suffix when subdirectory scan shortened the path", () => {
116
+ const subdirReport: WorkspaceReport = {
117
+ ...report,
118
+ playgrounds: [
119
+ {
120
+ id: "Button",
121
+ export_name: "Button",
122
+ rel_path: "Button.tsx",
123
+ declared_props: [],
124
+ },
125
+ ],
126
+ };
127
+ const modules = {
128
+ "../Components/Button.tsx": {
129
+ Button: function Button() {
130
+ return null;
131
+ },
132
+ },
133
+ };
134
+ const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(subdirReport, modules, {
135
+ globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
136
+ logJoinSkips: false,
137
+ });
138
+ expect(skipped).toHaveLength(0);
139
+ expect(entries).toHaveLength(1);
140
+ expect(entries[0]?.modulePath).toBe("../Components/Button.tsx");
81
141
  });
82
142
  });
@@ -1,5 +1,10 @@
1
1
  import type { PlaygroundSpec, WorkspaceReport } from "../types/report";
2
- import type { BuildPlaygroundModules, BuildPlaygroundOptions } from "./buildPlaygroundEntriesFromReport";
2
+ import type {
3
+ BuildPlaygroundModules,
4
+ BuildPlaygroundOptions,
5
+ } from "./buildPlaygroundEntriesFromReport";
6
+ import { defaultEmbedGlobKeyFromRelPath } from "./embedGlobKey";
7
+ import { getModuleExport } from "./playgroundModuleExport";
3
8
 
4
9
  export type PlaygroundJoinSkipReason = "module_not_found" | "export_not_found";
5
10
 
@@ -12,6 +17,10 @@ export type PlaygroundJoinSkip = {
12
17
 
13
18
  const DEFAULT_CONSUMER_STRIP_PREFIXES = ["src/", "resources/js/"] as const;
14
19
 
20
+ export function viteDevMode(): boolean {
21
+ return Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
22
+ }
23
+
15
24
  /**
16
25
  * Build `globKeyFromRelPath` for a registry file one level below a source root
17
26
  * (e.g. `src/playground/` or `resources/js/playground/`).
@@ -50,17 +59,36 @@ export function defaultConsumerGlobKeyFromRelPath(relPath: string): string {
50
59
  return createConsumerGlobKeyFromRelPath()(relPath);
51
60
  }
52
61
 
53
- export function defaultEmbedGlobKeyFromRelPath(relPath: string): string {
62
+ export { defaultEmbedGlobKeyFromRelPath } from "./embedGlobKey";
63
+
64
+ /**
65
+ * When `rel_path` is a bare filename (subdirectory scan), find a unique module key
66
+ * whose path ends with `/${relPath}` (consumer glob or embed keys).
67
+ */
68
+ export function findUniqueModuleKeyBySuffix(
69
+ relPath: string,
70
+ modules: BuildPlaygroundModules,
71
+ ): string | undefined {
54
72
  const trimmed = relPath.replace(/^\/+/, "");
55
- return `@dslint-scan/${trimmed}`;
73
+ if (trimmed.includes("/")) return undefined;
74
+
75
+ const suffix = `/${trimmed}`;
76
+ const candidates = Object.keys(modules).filter((key) => key.endsWith(suffix));
77
+ const unique = [...new Set(candidates)];
78
+ return unique.length === 1 ? unique[0] : undefined;
56
79
  }
57
80
 
58
- function getExport(
59
- mod: Record<string, unknown>,
60
- exportName: string,
61
- ): unknown {
62
- const x = mod[exportName];
63
- return typeof x === "function" ? x : undefined;
81
+ /**
82
+ * Resolve a module map key for a report `rel_path` (exact glob key, then suffix fallback).
83
+ */
84
+ export function resolveModuleKeyForRelPath(
85
+ relPath: string,
86
+ modules: BuildPlaygroundModules,
87
+ globKeyFromRelPath: (relPath: string) => string,
88
+ ): string | undefined {
89
+ const primary = globKeyFromRelPath(relPath);
90
+ if (modules[primary]) return primary;
91
+ return findUniqueModuleKeyBySuffix(relPath, modules);
64
92
  }
65
93
 
66
94
  /**
@@ -74,13 +102,13 @@ export function diagnosePlaygroundJoinSkips(
74
102
  const specs = report?.playgrounds;
75
103
  if (!specs?.length) return [];
76
104
 
77
- const globKeyFromRelPath =
78
- options.globKeyFromRelPath ?? defaultEmbedGlobKeyFromRelPath;
105
+ const globKeyFromRelPath = options.globKeyFromRelPath ?? defaultEmbedGlobKeyFromRelPath;
79
106
 
80
107
  const skipped: PlaygroundJoinSkip[] = [];
81
108
  for (const spec of specs) {
82
109
  const globKey = globKeyFromRelPath(spec.rel_path);
83
- const mod = modules[globKey];
110
+ const resolvedKey = resolveModuleKeyForRelPath(spec.rel_path, modules, globKeyFromRelPath);
111
+ const mod = resolvedKey ? modules[resolvedKey] : undefined;
84
112
  if (!mod) {
85
113
  skipped.push({
86
114
  export_name: spec.export_name,
@@ -90,11 +118,11 @@ export function diagnosePlaygroundJoinSkips(
90
118
  });
91
119
  continue;
92
120
  }
93
- if (!getExport(mod, spec.export_name)) {
121
+ if (!getModuleExport(mod, spec.export_name)) {
94
122
  skipped.push({
95
123
  export_name: spec.export_name,
96
124
  rel_path: spec.rel_path,
97
- globKey,
125
+ globKey: resolvedKey ?? globKey,
98
126
  reason: "export_not_found",
99
127
  });
100
128
  }
@@ -103,19 +131,18 @@ export function diagnosePlaygroundJoinSkips(
103
131
  }
104
132
 
105
133
  /**
106
- * Dev-only: log skipped playground joins (module glob / export name mismatches).
134
+ * Log skipped playground joins (module glob / export name mismatches).
135
+ * Opt-in via `logJoinSkips: true` on {@link buildPlaygroundEntriesFromReportWithSkips}.
107
136
  */
108
137
  export function logPlaygroundJoinSkips(
109
138
  skipped: PlaygroundJoinSkip[],
110
139
  options?: { label?: string },
111
140
  ): void {
112
141
  if (!skipped.length) return;
113
- if (typeof import.meta !== "undefined" && !import.meta.env?.DEV) return;
142
+ if (!viteDevMode()) return;
114
143
 
115
144
  const label = options?.label ?? "[dslinter] playground preview";
116
- console.warn(
117
- `${label}: ${skipped.length} component(s) have a scan row but no live preview.`,
118
- );
145
+ console.warn(`${label}: ${skipped.length} component(s) have a scan row but no live preview.`);
119
146
  for (const s of skipped.slice(0, 12)) {
120
147
  const hint =
121
148
  s.reason === "module_not_found"
@@ -132,9 +159,7 @@ export function findPlaygroundSpec(
132
159
  report: WorkspaceReport | null | undefined,
133
160
  componentId: string,
134
161
  ): PlaygroundSpec | undefined {
135
- return report?.playgrounds?.find(
136
- (p) => p.export_name === componentId || p.id === componentId,
137
- );
162
+ return report?.playgrounds?.find((p) => p.export_name === componentId || p.id === componentId);
138
163
  }
139
164
 
140
165
  export function findPlaygroundJoinSkip(
@@ -0,0 +1,42 @@
1
+ import { forwardRef } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { getModuleExport, isPlaygroundComponent } from "./playgroundModuleExport";
4
+
5
+ describe("playgroundModuleExport", () => {
6
+ it("accepts forwardRef components", () => {
7
+ const Forward = forwardRef(() => null);
8
+ expect(typeof Forward).toBe("object");
9
+ expect(isPlaygroundComponent(Forward)).toBe(true);
10
+ });
11
+
12
+ it("getModuleExport returns forwardRef export", () => {
13
+ const Forward = forwardRef(() => null);
14
+ Forward.displayName = "DropdownMenu";
15
+ const mod = { DropdownMenu: Forward };
16
+ expect(getModuleExport(mod, "DropdownMenu")).toBe(Forward);
17
+ });
18
+
19
+ it("getModuleExport falls back to default export when named export is missing", () => {
20
+ function AppLogoIcon() {
21
+ return null;
22
+ }
23
+ const mod = { default: AppLogoIcon };
24
+ expect(getModuleExport(mod, "AppLogoIcon")).toBe(AppLogoIcon);
25
+ });
26
+
27
+ it("getModuleExport prefers named export over default", () => {
28
+ function Named() {
29
+ return null;
30
+ }
31
+ function DefaultOnly() {
32
+ return null;
33
+ }
34
+ const mod = { Button: Named, default: DefaultOnly };
35
+ expect(getModuleExport(mod, "Button")).toBe(Named);
36
+ });
37
+
38
+ it("rejects non-components", () => {
39
+ expect(isPlaygroundComponent(null)).toBe(false);
40
+ expect(isPlaygroundComponent({ foo: 1 })).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,22 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ /** True for function components and exotic types (forwardRef, memo). */
4
+ export function isPlaygroundComponent(value: unknown): value is ComponentType<
5
+ Record<string, unknown>
6
+ > {
7
+ if (typeof value === "function") return true;
8
+ if (typeof value !== "object" || value === null) return false;
9
+ const o = value as Record<string, unknown>;
10
+ return typeof o.render === "function" || typeof o.$$typeof === "symbol";
11
+ }
12
+
13
+ export function getModuleExport(
14
+ mod: Record<string, unknown>,
15
+ exportName: string,
16
+ ): ComponentType<Record<string, unknown>> | undefined {
17
+ const named = mod[exportName];
18
+ if (isPlaygroundComponent(named)) return named;
19
+ const fallback = mod.default;
20
+ if (isPlaygroundComponent(fallback)) return fallback;
21
+ return undefined;
22
+ }
@@ -0,0 +1,8 @@
1
+ import type { WorkspaceReport } from "../types/report";
2
+
3
+ /** Stable memo key — ignores report object identity when playground specs are unchanged. */
4
+ export function playgroundSpecsKey(
5
+ report: WorkspaceReport | null | undefined,
6
+ ): string {
7
+ return JSON.stringify(report?.playgrounds ?? []);
8
+ }
@@ -0,0 +1,91 @@
1
+ import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
2
+ import type { DeclaredPropKind, PlaygroundSpec } from "../types/report";
3
+ import {
4
+ childrenPropForPreview,
5
+ isLikelyBooleanProp,
6
+ isPassthroughStringProp,
7
+ SKIP_PLAYGROUND_PROPS,
8
+ } from "./controls";
9
+
10
+ export function coerceDeclaredPropKind(v: unknown): DeclaredPropKind | undefined {
11
+ if (v === "boolean" || v === "string" || v === "number" || v === "unknown")
12
+ return v;
13
+ return undefined;
14
+ }
15
+
16
+ export function normalizedPropKinds(
17
+ raw: PlaygroundSpec["declared_prop_kinds"],
18
+ ): Partial<Record<string, DeclaredPropKind>> | undefined {
19
+ if (!raw || typeof raw !== "object") return undefined;
20
+ const out: Partial<Record<string, DeclaredPropKind>> = {};
21
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
22
+ const ck = coerceDeclaredPropKind(v);
23
+ if (ck && ck !== "unknown") out[k] = ck;
24
+ }
25
+ return Object.keys(out).length ? out : undefined;
26
+ }
27
+
28
+ function propKeysForPreview(
29
+ controls: PlaygroundControl[],
30
+ declaredProps: string[],
31
+ ): string[] {
32
+ if (controls.length > 0) return controls.map((c) => c.key);
33
+ return declaredProps.filter((k) => k !== "key" && k !== "ref");
34
+ }
35
+
36
+ export function valuesToComponentProps(
37
+ controls: PlaygroundControl[],
38
+ declaredProps: string[],
39
+ values: PlaygroundArgs,
40
+ propKinds?: Partial<Record<string, DeclaredPropKind>>,
41
+ exportName?: string,
42
+ ): Record<string, unknown> {
43
+ const o: Record<string, unknown> = {};
44
+ for (const key of propKeysForPreview(controls, declaredProps)) {
45
+ if (SKIP_PLAYGROUND_PROPS.has(key)) continue;
46
+ if (isPassthroughStringProp(key)) {
47
+ const raw = values[key];
48
+ if (raw === undefined || raw === null || String(raw).length === 0) continue;
49
+ o[key] = String(raw);
50
+ continue;
51
+ }
52
+ if (key === "children") {
53
+ const coerced = childrenPropForPreview(exportName, values.children);
54
+ if (coerced !== undefined) o[key] = coerced;
55
+ continue;
56
+ }
57
+ const kind = propKinds?.[key];
58
+ if (kind === "boolean") {
59
+ o[key] = Boolean(values[key]);
60
+ continue;
61
+ }
62
+ if (kind === "number") {
63
+ const raw = values[key];
64
+ const n = typeof raw === "number" ? raw : Number(raw);
65
+ o[key] = Number.isFinite(n) ? n : 0;
66
+ continue;
67
+ }
68
+ if (kind === "string") {
69
+ o[key] = values[key];
70
+ continue;
71
+ }
72
+ if (isLikelyBooleanProp(key)) {
73
+ o[key] = Boolean(values[key]);
74
+ continue;
75
+ }
76
+ o[key] = values[key];
77
+ }
78
+ return o;
79
+ }
80
+
81
+ export function mergeStaticDefaults(
82
+ fromValues: Record<string, unknown>,
83
+ staticDefaults: Record<string, unknown>,
84
+ ): Record<string, unknown> {
85
+ const o = { ...fromValues };
86
+ for (const [k, v] of Object.entries(staticDefaults)) {
87
+ const cur = o[k];
88
+ if (cur === undefined || (cur === "" && k !== "children")) o[k] = v;
89
+ }
90
+ return o;
91
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatVariantLabel,
4
+ mergePlaygroundA11yFindings,
5
+ playgroundA11yScore,
6
+ } from "./scanVariantA11y";
7
+
8
+ describe("formatVariantLabel", () => {
9
+ it("joins axis keys and values", () => {
10
+ expect(
11
+ formatVariantLabel(
12
+ { variant: "destructive", size: "default", asChild: false },
13
+ ["variant", "size"],
14
+ ),
15
+ ).toBe("variant=destructive size=default");
16
+ });
17
+ });
18
+
19
+ describe("playgroundA11yScore", () => {
20
+ it("combines static and variant findings", () => {
21
+ const staticFindings = [
22
+ {
23
+ rule_id: "a11y-button-name",
24
+ message: "missing name",
25
+ path: "Button.tsx",
26
+ line: 1,
27
+ severity: "warning" as const,
28
+ },
29
+ ];
30
+ const variantFindings = [
31
+ {
32
+ rule_id: "a11y-playground-color-contrast",
33
+ message: "contrast too low",
34
+ path: "",
35
+ line: null,
36
+ severity: "error" as const,
37
+ variant_label: "variant=destructive size=default",
38
+ },
39
+ ];
40
+
41
+ expect(
42
+ mergePlaygroundA11yFindings(staticFindings, variantFindings),
43
+ ).toHaveLength(2);
44
+ expect(playgroundA11yScore(staticFindings, variantFindings)).toBe(65);
45
+ });
46
+ });
@@ -0,0 +1,107 @@
1
+ import type { PlaygroundArgs } from "../types/controls";
2
+ import type { LintFinding, Severity } from "../types/report";
3
+ import { a11yScoreFromFindings } from "../report/a11yScoring";
4
+
5
+ export type PlaygroundA11yFinding = LintFinding;
6
+
7
+ export type VariantPreviewTarget = {
8
+ element: Element;
9
+ combo: PlaygroundArgs;
10
+ axisKeys: string[];
11
+ };
12
+
13
+ const SCAN_CONCURRENCY = 4;
14
+
15
+ function formatAxisValue(value: string | number | boolean | undefined): string {
16
+ if (value === undefined) return "?";
17
+ if (typeof value === "string") return value;
18
+ return JSON.stringify(value);
19
+ }
20
+
21
+ export function formatVariantLabel(
22
+ combo: PlaygroundArgs,
23
+ axisKeys: string[],
24
+ ): string {
25
+ return axisKeys
26
+ .map((key) => `${key}=${formatAxisValue(combo[key])}`)
27
+ .join(" ");
28
+ }
29
+
30
+ function axeImpactToSeverity(
31
+ impact: string | null | undefined,
32
+ ): Severity {
33
+ if (impact === "critical" || impact === "serious") return "error";
34
+ if (impact === "moderate") return "warning";
35
+ return "info";
36
+ }
37
+
38
+ async function loadAxe() {
39
+ const mod = await import("axe-core");
40
+ return mod.default;
41
+ }
42
+
43
+ export async function scanElementA11y(
44
+ element: Element,
45
+ variantLabel: string,
46
+ ): Promise<PlaygroundA11yFinding[]> {
47
+ const axe = await loadAxe();
48
+ const results = await axe.run(element, {
49
+ runOnly: {
50
+ type: "rule",
51
+ values: ["color-contrast"],
52
+ },
53
+ });
54
+
55
+ const findings: PlaygroundA11yFinding[] = [];
56
+ for (const violation of results.violations) {
57
+ const severity = axeImpactToSeverity(violation.impact);
58
+ const summary = violation.help || violation.description;
59
+ for (const node of violation.nodes) {
60
+ findings.push({
61
+ rule_id: `a11y-playground-${violation.id}`,
62
+ message: node.failureSummary
63
+ ? `${summary} (${node.failureSummary})`
64
+ : summary,
65
+ path: "",
66
+ line: null,
67
+ severity,
68
+ variant_label: variantLabel,
69
+ });
70
+ }
71
+ }
72
+ return findings;
73
+ }
74
+
75
+ export async function scanVariantPreviews(
76
+ targets: VariantPreviewTarget[],
77
+ ): Promise<PlaygroundA11yFinding[]> {
78
+ const findings: PlaygroundA11yFinding[] = [];
79
+
80
+ for (let i = 0; i < targets.length; i += SCAN_CONCURRENCY) {
81
+ const chunk = targets.slice(i, i + SCAN_CONCURRENCY);
82
+ const chunkFindings = await Promise.all(
83
+ chunk.map(({ element, combo, axisKeys }) =>
84
+ scanElementA11y(element, formatVariantLabel(combo, axisKeys)),
85
+ ),
86
+ );
87
+ findings.push(...chunkFindings.flat());
88
+ }
89
+
90
+ return findings;
91
+ }
92
+
93
+ export function mergePlaygroundA11yFindings(
94
+ staticFindings: LintFinding[],
95
+ variantFindings: PlaygroundA11yFinding[],
96
+ ): PlaygroundA11yFinding[] {
97
+ return [...staticFindings, ...variantFindings];
98
+ }
99
+
100
+ export function playgroundA11yScore(
101
+ staticFindings: LintFinding[],
102
+ variantFindings: PlaygroundA11yFinding[],
103
+ ): number {
104
+ return a11yScoreFromFindings(
105
+ mergePlaygroundA11yFindings(staticFindings, variantFindings),
106
+ );
107
+ }
@@ -0,0 +1,83 @@
1
+ import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
2
+
3
+ function jsxTextOrStringifyExpression(text: string): string {
4
+ if (!/[<>{}&]/.test(text)) return text;
5
+ return `{JSON.stringify(${JSON.stringify(text)})}`;
6
+ }
7
+
8
+ function valueMatchesPlaygroundDefault(
9
+ control: PlaygroundControl,
10
+ value: string | number | boolean | undefined,
11
+ ): boolean {
12
+ switch (control.type) {
13
+ case "boolean":
14
+ return Boolean(value) === control.default;
15
+ case "number": {
16
+ const n = typeof value === "number" ? value : Number(value);
17
+ return Number.isFinite(n) && n === control.default;
18
+ }
19
+ case "string":
20
+ case "select":
21
+ return String(value ?? "") === String(control.default);
22
+ default:
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export function genericUsageSnippet(
28
+ exportName: string,
29
+ values: PlaygroundArgs,
30
+ controls: PlaygroundControl[],
31
+ ): string {
32
+ const controlByKey = new Map(controls.map((c) => [c.key, c] as const));
33
+
34
+ const emitPropKey = (key: string): boolean => {
35
+ const c = controlByKey.get(key);
36
+ if (!c) return true;
37
+ return !valueMatchesPlaygroundDefault(c, values[key]);
38
+ };
39
+
40
+ const hasChildrenKey = Object.prototype.hasOwnProperty.call(
41
+ values,
42
+ "children",
43
+ );
44
+ const childVal = hasChildrenKey ? values.children : undefined;
45
+
46
+ const propKeys = Object.keys(values)
47
+ .filter((k) => k !== "children")
48
+ .filter(emitPropKey)
49
+ .sort((a, b) => a.localeCompare(b));
50
+ const propsStr = propKeys
51
+ .map((k) => `${k}={${JSON.stringify(values[k])}}`)
52
+ .join(" ");
53
+
54
+ const openWithProps =
55
+ propKeys.length === 0 ? `<${exportName}` : `<${exportName} ${propsStr}`;
56
+
57
+ if (!hasChildrenKey) {
58
+ return propKeys.length === 0 ? `<${exportName} />` : `${openWithProps} />`;
59
+ }
60
+
61
+ if (typeof childVal === "boolean") {
62
+ const allKeys = Object.keys(values)
63
+ .filter(emitPropKey)
64
+ .sort((a, b) => a.localeCompare(b));
65
+ const allProps = allKeys
66
+ .map((k) => `${k}={${JSON.stringify(values[k])}}`)
67
+ .join(" ");
68
+ return allKeys.length === 0
69
+ ? `<${exportName} />`
70
+ : `<${exportName} ${allProps} />`;
71
+ }
72
+
73
+ const asText =
74
+ typeof childVal === "number" ? String(childVal) : String(childVal ?? "");
75
+ if (asText.length === 0) {
76
+ return propKeys.length === 0 ? `<${exportName} />` : `${openWithProps} />`;
77
+ }
78
+
79
+ const body = jsxTextOrStringifyExpression(asText);
80
+ return propKeys.length === 0
81
+ ? `<${exportName}>${body}</${exportName}>`
82
+ : `${openWithProps}>${body}</${exportName}>`;
83
+ }