dslinter 0.1.5 → 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 +112 -0
  2. package/README.md +54 -27
  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 +92 -24
  19. package/bin/lib/project-root.test.mjs +52 -0
  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 +163 -0
  25. package/bin/lib/scaffold-config.test.mjs +43 -0
  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 +56 -13
  32. package/bin/modes/init.mjs +35 -47
  33. package/bin/modes/init.test.mjs +16 -0
  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-BPPtPsYh.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-Dp3bAQxH.js +0 -1
  178. package/dashboard-dist/assets/index-DsjwnDdX.js +0 -206
  179. package/dashboard-dist/assets/index-jaCmZJlW.css +0 -1
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -0,0 +1,343 @@
1
+ import { resolve } from "node:path";
2
+ import ts from "typescript";
3
+
4
+ /** @typedef {"boolean" | "string" | "number"} PropKind */
5
+
6
+ const MAX_UNION_OPTIONS = 32;
7
+
8
+ /** @param {string} projectRoot */
9
+ export function createCheckerProgram(projectRoot) {
10
+ const configPath = resolve(projectRoot, "tsconfig.json");
11
+ const readJson = ts.readConfigFile(configPath, ts.sys.readFile);
12
+ if (readJson.error) return null;
13
+
14
+ const parsed = ts.parseJsonConfigFileContent(
15
+ readJson.config,
16
+ ts.sys,
17
+ projectRoot,
18
+ undefined,
19
+ configPath,
20
+ );
21
+ if (parsed.errors.length) return null;
22
+
23
+ const program = ts.createProgram({
24
+ rootNames: parsed.fileNames,
25
+ options: { ...parsed.options, noCheck: false },
26
+ });
27
+ return { program, checker: program.getTypeChecker() };
28
+ }
29
+
30
+ /** @param {string} p */
31
+ function normalizePath(p) {
32
+ return p.replace(/\\/g, "/");
33
+ }
34
+
35
+ /** @param {ts.Node} node */
36
+ function hasExportModifier(node) {
37
+ return (
38
+ ts.canHaveModifiers(node) &&
39
+ (ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ??
40
+ false)
41
+ );
42
+ }
43
+
44
+ /** @param {ts.SourceFile} sf */
45
+ function collectNamedExportLocalNames(sf) {
46
+ /** @type {Set<string>} */
47
+ const names = new Set();
48
+ for (const stmt of sf.statements) {
49
+ if (
50
+ !ts.isExportDeclaration(stmt) ||
51
+ !stmt.exportClause ||
52
+ !ts.isNamedExports(stmt.exportClause)
53
+ ) {
54
+ continue;
55
+ }
56
+ for (const el of stmt.exportClause.elements) {
57
+ names.add((el.propertyName ?? el.name).text);
58
+ }
59
+ }
60
+ return names;
61
+ }
62
+
63
+ /** @param {ts.Expression} init */
64
+ function unwrapComponentInitializer(init) {
65
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) return init;
66
+ if (ts.isCallExpression(init)) {
67
+ for (const arg of init.arguments) {
68
+ const inner = unwrapComponentInitializer(arg);
69
+ if (inner) return inner;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /**
76
+ * @param {ts.TypeChecker} checker
77
+ * @param {ts.SourceFile} sf
78
+ * @param {string} exportName
79
+ * @returns {ts.Type | undefined}
80
+ */
81
+ export function findComponentParamType(checker, sf, exportName) {
82
+ const namedExports = collectNamedExportLocalNames(sf);
83
+ /** @type {ts.Type | undefined} */
84
+ let found;
85
+
86
+ /** @param {ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration} fn */
87
+ function paramTypeFromFn(fn) {
88
+ const p0 = fn.parameters[0];
89
+ return p0 ? checker.getTypeAtLocation(p0) : undefined;
90
+ }
91
+
92
+ /** @param {ts.Node} node */
93
+ function visit(node) {
94
+ if (found !== undefined) return;
95
+
96
+ if (
97
+ ts.isFunctionDeclaration(node) &&
98
+ node.name?.text === exportName &&
99
+ (hasExportModifier(node) || namedExports.has(exportName))
100
+ ) {
101
+ found = paramTypeFromFn(node);
102
+ return;
103
+ }
104
+
105
+ if (ts.isVariableStatement(node) && hasExportModifier(node)) {
106
+ for (const decl of node.declarationList.declarations) {
107
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== exportName || !decl.initializer) {
108
+ continue;
109
+ }
110
+ const inner = unwrapComponentInitializer(decl.initializer);
111
+ if (inner) {
112
+ found = paramTypeFromFn(inner);
113
+ return;
114
+ }
115
+ }
116
+ }
117
+
118
+ ts.forEachChild(node, visit);
119
+ }
120
+
121
+ visit(sf);
122
+ return found;
123
+ }
124
+
125
+ /**
126
+ * @param {ts.TypeChecker} checker
127
+ * @param {ts.Type} type
128
+ * @returns {PropKind | null}
129
+ */
130
+ export function classifyPropType(checker, type) {
131
+ const nn = checker.getNonNullableType(type);
132
+ if (nn.isUnion()) {
133
+ const parts = nn.types.map((u) =>
134
+ classifyPropType(checker, checker.getNonNullableType(u)),
135
+ );
136
+ const ok = parts.filter((p) => p !== null);
137
+ if (!ok.length) return null;
138
+ const set = new Set(ok);
139
+ if (set.size === 1) return [...set][0];
140
+ return null;
141
+ }
142
+ if (nn.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean";
143
+ if (nn.flags & (ts.TypeFlags.Enum | ts.TypeFlags.EnumLiteral)) return "string";
144
+ if (nn.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLike)) return "number";
145
+ if (nn.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLike)) return "string";
146
+ return null;
147
+ }
148
+
149
+ /** @param {ts.TypeChecker} checker @param {ts.Type} type */
150
+ function followTypeAlias(checker, type) {
151
+ if (type.aliasSymbol) {
152
+ return followTypeAlias(checker, checker.getDeclaredTypeOfSymbol(type.aliasSymbol));
153
+ }
154
+ return type;
155
+ }
156
+
157
+ /** @param {ts.TypeChecker} checker @param {ts.Type} type */
158
+ function isBrandedStringCatchAll(checker, type) {
159
+ const nn = checker.getNonNullableType(type);
160
+ if (!nn.isIntersection()) return false;
161
+ let hasOpenString = false;
162
+ let hasEmptyObject = false;
163
+ for (const part of nn.types) {
164
+ const t = checker.getNonNullableType(part);
165
+ if (t.flags & ts.TypeFlags.String && !(t.flags & ts.TypeFlags.StringLiteral)) {
166
+ hasOpenString = true;
167
+ continue;
168
+ }
169
+ if (
170
+ t.flags & ts.TypeFlags.Object &&
171
+ !t.isUnion() &&
172
+ !t.isIntersection() &&
173
+ t.getProperties().length === 0
174
+ ) {
175
+ hasEmptyObject = true;
176
+ }
177
+ }
178
+ return hasOpenString && hasEmptyObject;
179
+ }
180
+
181
+ /** @param {ts.TypeChecker} checker @param {ts.Type} type */
182
+ function isRejectUnionMember(checker, type) {
183
+ const nn = checker.getNonNullableType(type);
184
+ if (isBrandedStringCatchAll(checker, nn)) return false;
185
+ if (nn.isUnion()) return false;
186
+ if (nn.isStringLiteral()) return false;
187
+ if (nn.flags & ts.TypeFlags.Undefined) return false;
188
+ if (nn.flags & ts.TypeFlags.Null) return false;
189
+ if (nn.flags & ts.TypeFlags.Never) return false;
190
+
191
+ if (nn.flags & ts.TypeFlags.String && !(nn.flags & ts.TypeFlags.StringLiteral)) {
192
+ return true;
193
+ }
194
+ if (nn.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLike)) return true;
195
+ if (nn.isIntersection()) return true;
196
+ if (nn.flags & ts.TypeFlags.Object) return true;
197
+ if (nn.flags & ts.TypeFlags.Enum) return true;
198
+ if (nn.flags & ts.TypeFlags.TemplateLiteral) return true;
199
+
200
+ return false;
201
+ }
202
+
203
+ /**
204
+ * @param {ts.TypeChecker} checker
205
+ * @param {ts.Type} type
206
+ * @returns {string[] | null}
207
+ */
208
+ export function extractFiniteStringUnion(checker, type) {
209
+ const nn = checker.getNonNullableType(followTypeAlias(checker, type));
210
+
211
+ if (nn.isUnion()) {
212
+ /** @type {string[]} */
213
+ const literals = [];
214
+ for (const member of nn.types) {
215
+ const memberNn = checker.getNonNullableType(followTypeAlias(checker, member));
216
+ if (isBrandedStringCatchAll(checker, memberNn)) continue;
217
+ if (isRejectUnionMember(checker, memberNn)) return null;
218
+ if (memberNn.isStringLiteral()) {
219
+ literals.push(memberNn.value);
220
+ continue;
221
+ }
222
+ const nested = extractFiniteStringUnion(checker, memberNn);
223
+ if (nested === null) return null;
224
+ literals.push(...nested);
225
+ }
226
+ const unique = [...new Set(literals)].sort();
227
+ if (unique.length < 2) return null;
228
+ return unique.slice(0, MAX_UNION_OPTIONS);
229
+ }
230
+
231
+ if (nn.isStringLiteral()) {
232
+ return null;
233
+ }
234
+
235
+ return null;
236
+ }
237
+
238
+ /** @param {ts.Program} program @param {string} projectRoot @param {string} relPath */
239
+ function sourceFileForRelPath(program, projectRoot, relPath) {
240
+ const abs = normalizePath(resolve(projectRoot, relPath));
241
+ return program.getSourceFiles().find((f) => normalizePath(f.fileName) === abs);
242
+ }
243
+
244
+ /**
245
+ * @param {ts.TypeChecker} checker
246
+ * @param {ts.Program} program
247
+ * @param {string} projectRoot
248
+ * @param {string} relPath
249
+ * @param {string} exportName
250
+ * @param {string[]} declaredProps
251
+ */
252
+ export function inferDeclaredPropKindsFromTs(
253
+ checker,
254
+ program,
255
+ projectRoot,
256
+ relPath,
257
+ exportName,
258
+ declaredProps,
259
+ ) {
260
+ const sf = sourceFileForRelPath(program, projectRoot, relPath);
261
+ if (!sf) return {};
262
+
263
+ const paramType = findComponentParamType(checker, sf, exportName);
264
+ if (!paramType) return {};
265
+
266
+ /** @type {Record<string, string>} */
267
+ const kinds = {};
268
+ for (const key of declaredProps) {
269
+ if (key === "key" || key === "ref") continue;
270
+ const sym = checker.getPropertyOfType(paramType, key);
271
+ if (!sym) continue;
272
+ const t = checker.getTypeOfSymbol(sym);
273
+ const k = classifyPropType(checker, t);
274
+ if (k !== null) kinds[key] = k;
275
+ }
276
+ return kinds;
277
+ }
278
+
279
+ /**
280
+ * @param {import("../types/report.js").PlaygroundSpec} spec
281
+ * @param {ts.TypeChecker} checker
282
+ * @param {ts.Program} program
283
+ * @param {string} projectRoot
284
+ */
285
+ export function enrichPlaygroundSpecFromTs(spec, checker, program, projectRoot) {
286
+ const sf = sourceFileForRelPath(program, projectRoot, spec.rel_path);
287
+ if (!sf) return spec;
288
+
289
+ const paramType = findComponentParamType(checker, sf, spec.export_name);
290
+ if (!paramType) return spec;
291
+
292
+ const declaredPropOptions = { ...spec.declared_prop_options };
293
+ const declaredPropKinds = { ...spec.declared_prop_kinds };
294
+ const declaredPropDefaults = { ...spec.declared_prop_defaults };
295
+
296
+ for (const prop of spec.declared_props) {
297
+ if (prop === "key" || prop === "ref") continue;
298
+ const sym = checker.getPropertyOfType(paramType, prop);
299
+ if (!sym) continue;
300
+ const propType = checker.getTypeOfSymbol(sym);
301
+
302
+ if (!declaredPropOptions[prop]) {
303
+ const literals = extractFiniteStringUnion(checker, propType);
304
+ if (literals && literals.length >= 2) {
305
+ declaredPropOptions[prop] = literals;
306
+ if (prop === "type" && literals.includes("text") && !declaredPropDefaults[prop]) {
307
+ declaredPropDefaults[prop] = "text";
308
+ }
309
+ }
310
+ }
311
+
312
+ if (!declaredPropKinds[prop]) {
313
+ const kind = classifyPropType(checker, propType);
314
+ if (kind !== null) declaredPropKinds[prop] = kind;
315
+ }
316
+ }
317
+
318
+ /** @type {import("../types/report.js").PlaygroundSpec} */
319
+ const next = { ...spec };
320
+ if (Object.keys(declaredPropOptions).length) {
321
+ next.declared_prop_options = declaredPropOptions;
322
+ }
323
+ if (Object.keys(declaredPropKinds).length) {
324
+ next.declared_prop_kinds = declaredPropKinds;
325
+ }
326
+ if (Object.keys(declaredPropDefaults).length) {
327
+ next.declared_prop_defaults = declaredPropDefaults;
328
+ }
329
+ return next;
330
+ }
331
+
332
+ /**
333
+ * @param {{ playgrounds?: import("../types/report.js").PlaygroundSpec[] }} report
334
+ * @param {ts.TypeChecker} checker
335
+ * @param {ts.Program} program
336
+ * @param {string} projectRoot
337
+ */
338
+ export function enrichPlaygroundsFromReport(report, checker, program, projectRoot) {
339
+ if (!report.playgrounds?.length) return;
340
+ report.playgrounds = report.playgrounds.map((spec) =>
341
+ enrichPlaygroundSpecFromTs(spec, checker, program, projectRoot),
342
+ );
343
+ }
@@ -0,0 +1,227 @@
1
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { describe, expect, it } from "vitest";
6
+ import type { PlaygroundSpec } from "../types/report";
7
+ import {
8
+ createCheckerProgram,
9
+ enrichPlaygroundSpecFromTs,
10
+ extractFiniteStringUnion,
11
+ findComponentParamType,
12
+ } from "./inferPropTypesFromTs";
13
+
14
+ const dashboardRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
15
+
16
+ function writeProject(
17
+ dir: string,
18
+ files: Record<string, string>,
19
+ extraCompilerOptions: Record<string, unknown> = {},
20
+ ) {
21
+ for (const [rel, content] of Object.entries(files)) {
22
+ const abs = join(dir, rel);
23
+ mkdirSync(dirname(abs), { recursive: true });
24
+ writeFileSync(abs, content);
25
+ }
26
+ writeFileSync(
27
+ join(dir, "tsconfig.json"),
28
+ `${JSON.stringify(
29
+ {
30
+ compilerOptions: {
31
+ jsx: "react-jsx",
32
+ strict: true,
33
+ noEmit: true,
34
+ skipLibCheck: true,
35
+ esModuleInterop: true,
36
+ moduleResolution: "bundler",
37
+ module: "ESNext",
38
+ types: ["react"],
39
+ ...extraCompilerOptions,
40
+ },
41
+ include: ["**/*.tsx", "**/*.ts"],
42
+ },
43
+ null,
44
+ 2,
45
+ )}\n`,
46
+ );
47
+ }
48
+
49
+ function tempProject(files: Record<string, string>) {
50
+ const dir = mkdtempSync(join(tmpdir(), "infer-props-"));
51
+ writeProject(dir, files, {
52
+ typeRoots: [
53
+ join(dashboardRoot, "node_modules/@types"),
54
+ join(dashboardRoot, "../../node_modules/@types"),
55
+ ],
56
+ });
57
+ return dir;
58
+ }
59
+
60
+ describe("extractFiniteStringUnion", () => {
61
+ it("extracts string literal unions from a minimal component", () => {
62
+ const root = tempProject({
63
+ "src/Widget.tsx": `
64
+ type Mode = "text" | "email" | "password";
65
+ export function Widget({ mode }: { mode?: Mode }) {
66
+ return null;
67
+ }
68
+ `,
69
+ });
70
+ const bundle = createCheckerProgram(root);
71
+ expect(bundle).not.toBeNull();
72
+ const sf = bundle!.program.getSourceFile(join(root, "src/Widget.tsx"));
73
+ expect(sf).toBeDefined();
74
+ const paramType = findComponentParamType(bundle!.checker, sf!, "Widget");
75
+ expect(paramType).toBeDefined();
76
+ const modeSym = bundle!.checker.getPropertyOfType(paramType!, "mode");
77
+ expect(modeSym).toBeDefined();
78
+ const modeType = bundle!.checker.getTypeOfSymbol(modeSym!);
79
+ expect(extractFiniteStringUnion(bundle!.checker, modeType)).toEqual([
80
+ "email",
81
+ "password",
82
+ "text",
83
+ ]);
84
+ });
85
+
86
+ it("extracts HTMLInputTypeAttribute-style unions with a branded string catch-all", () => {
87
+ const root = tempProject({
88
+ "src/input.tsx": `
89
+ type InputType = "text" | "email" | (string & {});
90
+ export function Input({ type }: { type?: InputType }) {
91
+ return null;
92
+ }
93
+ `,
94
+ });
95
+ const bundle = createCheckerProgram(root)!;
96
+ const sf = bundle.program.getSourceFile(join(root, "src/input.tsx"))!;
97
+ const paramType = findComponentParamType(bundle.checker, sf, "Input")!;
98
+ const typeType = bundle.checker.getTypeOfSymbol(
99
+ bundle.checker.getPropertyOfType(paramType, "type")!,
100
+ );
101
+ const options = extractFiniteStringUnion(bundle.checker, typeType);
102
+ expect(options).toEqual(["email", "text"]);
103
+ });
104
+
105
+ it("returns null when union contains plain string", () => {
106
+ const root = tempProject({
107
+ "src/Widget.tsx": `
108
+ export function Widget({ mode }: { mode?: "text" | string }) {
109
+ return null;
110
+ }
111
+ `,
112
+ });
113
+ const bundle = createCheckerProgram(root)!;
114
+ const sf = bundle.program.getSourceFile(join(root, "src/Widget.tsx"))!;
115
+ const paramType = findComponentParamType(bundle.checker, sf, "Widget")!;
116
+ const modeType = bundle.checker.getTypeOfSymbol(
117
+ bundle.checker.getPropertyOfType(paramType, "mode")!,
118
+ );
119
+ expect(extractFiniteStringUnion(bundle.checker, modeType)).toBeNull();
120
+ });
121
+ });
122
+
123
+ describe("findComponentParamType export lookup", () => {
124
+ it("finds param type for export { Input } function declaration", () => {
125
+ const root = tempProject({
126
+ "src/input.tsx": `
127
+ function Input({ type }: { type?: "text" | "email" | "password" }) {
128
+ return null;
129
+ }
130
+ export { Input };
131
+ `,
132
+ });
133
+ const bundle = createCheckerProgram(root)!;
134
+ const sf = bundle.program.getSourceFile(join(root, "src/input.tsx"))!;
135
+ const paramType = findComponentParamType(bundle.checker, sf, "Input");
136
+ expect(paramType).toBeDefined();
137
+ const typeSym = bundle.checker.getPropertyOfType(paramType!, "type");
138
+ expect(typeSym).toBeDefined();
139
+ });
140
+
141
+ it("finds param type for export const forwardRef component", () => {
142
+ const root = tempProject({
143
+ "src/input.tsx": `
144
+ import * as React from "react";
145
+ export const Input = React.forwardRef(function Input(
146
+ { type }: { type?: "text" | "email" },
147
+ ref: React.Ref<HTMLInputElement>,
148
+ ) {
149
+ return null;
150
+ });
151
+ `,
152
+ });
153
+ const bundle = createCheckerProgram(root)!;
154
+ const sf = bundle.program.getSourceFile(join(root, "src/input.tsx"))!;
155
+ const paramType = findComponentParamType(bundle.checker, sf, "Input");
156
+ expect(paramType).toBeDefined();
157
+ });
158
+ });
159
+
160
+ describe("enrichPlaygroundSpecFromTs", () => {
161
+ it("does not overwrite CVA declared_prop_options from Rust", () => {
162
+ const root = tempProject({
163
+ "src/button.tsx": `
164
+ export function Button({ variant }: { variant?: "default" | "ghost" | "outline" }) {
165
+ return null;
166
+ }
167
+ `,
168
+ });
169
+ const bundle = createCheckerProgram(root)!;
170
+ const spec: PlaygroundSpec = {
171
+ id: "Button",
172
+ export_name: "Button",
173
+ rel_path: "src/button.tsx",
174
+ declared_props: ["variant"],
175
+ declared_prop_options: {
176
+ variant: ["default", "destructive"],
177
+ },
178
+ };
179
+ const enriched = enrichPlaygroundSpecFromTs(
180
+ spec,
181
+ bundle.checker,
182
+ bundle.program,
183
+ root,
184
+ );
185
+ expect(enriched.declared_prop_options?.variant).toEqual(["default", "destructive"]);
186
+ });
187
+
188
+ it("fills type options and default for ComponentProps<input> style destructuring", () => {
189
+ const root = tempProject({
190
+ "src/input.tsx": `
191
+ import * as React from "react";
192
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
193
+ return null;
194
+ }
195
+ export { Input };
196
+ `,
197
+ });
198
+ const bundle = createCheckerProgram(root)!;
199
+ const spec: PlaygroundSpec = {
200
+ id: "Input",
201
+ export_name: "Input",
202
+ rel_path: "src/input.tsx",
203
+ declared_props: ["className", "type", "placeholder"],
204
+ };
205
+ const enriched = enrichPlaygroundSpecFromTs(
206
+ spec,
207
+ bundle.checker,
208
+ bundle.program,
209
+ root,
210
+ );
211
+ const typeOptions = enriched.declared_prop_options?.type;
212
+ expect(typeOptions).toBeDefined();
213
+ expect(typeOptions!.length).toBeGreaterThanOrEqual(2);
214
+ expect(typeOptions).toContain("text");
215
+ expect(typeOptions).toContain("email");
216
+ expect(typeOptions).toContain("password");
217
+ expect(enriched.declared_prop_defaults?.type).toBe("text");
218
+ expect(enriched.declared_prop_kinds?.type).toBe("string");
219
+ });
220
+ });
221
+
222
+ describe("createCheckerProgram", () => {
223
+ it("returns null when tsconfig.json is missing", () => {
224
+ const dir = mkdtempSync(join(tmpdir(), "no-tsconfig-"));
225
+ expect(createCheckerProgram(dir)).toBeNull();
226
+ });
227
+ });
@@ -0,0 +1,17 @@
1
+ export type { DeclaredPropKind, PlaygroundSpec } from "../types/report";
2
+ export type PropKind = "boolean" | "string" | "number";
3
+
4
+ export type CheckerProgram = {
5
+ program: import("typescript").Program;
6
+ checker: import("typescript").TypeChecker;
7
+ };
8
+
9
+ export {
10
+ classifyPropType,
11
+ createCheckerProgram,
12
+ enrichPlaygroundSpecFromTs,
13
+ enrichPlaygroundsFromReport,
14
+ extractFiniteStringUnion,
15
+ findComponentParamType,
16
+ inferDeclaredPropKindsFromTs,
17
+ } from "./inferPropTypesFromTs.mjs";
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PlaygroundEntry } from "../types/playground";
3
+ import { mergePlaygroundEntries } from "./mergePlaygroundEntries";
4
+
5
+ function entry(id: string, title = id, group?: string): PlaygroundEntry {
6
+ return {
7
+ id,
8
+ meta: { id, title, ...(group ? { group } : {}) },
9
+ modulePath: `../components/${id}.tsx`,
10
+ controls: [],
11
+ renderPreview: () => null,
12
+ Preview: () => null,
13
+ };
14
+ }
15
+
16
+ describe("mergePlaygroundEntries", () => {
17
+ it("manual entries override auto entries with the same id", () => {
18
+ const auto = [entry("DropdownMenu", "DropdownMenu-auto")];
19
+ const manual = [entry("DropdownMenu", "DropdownMenu-manual", "ui")];
20
+ const merged = mergePlaygroundEntries(auto, manual);
21
+ expect(merged).toHaveLength(1);
22
+ expect(merged[0]?.meta.title).toBe("DropdownMenu-manual");
23
+ expect(merged[0]?.meta.group).toBe("ui");
24
+ });
25
+
26
+ it("merges distinct entries and sorts by group then title", () => {
27
+ const auto = [entry("Button"), entry("Input", "Input", "forms")];
28
+ const manual = [entry("Dialog", "Dialog", "ui")];
29
+ const merged = mergePlaygroundEntries(auto, manual);
30
+ expect(merged.map((e) => e.id)).toEqual(["Button", "Input", "Dialog"]);
31
+ });
32
+ });
@@ -0,0 +1,28 @@
1
+ import type { PlaygroundEntry } from "../types/playground";
2
+
3
+ function sortPlaygroundEntries(entries: PlaygroundEntry[]): PlaygroundEntry[] {
4
+ return [...entries].sort((a, b) => {
5
+ const ga = a.meta.group ?? "";
6
+ const gb = b.meta.group ?? "";
7
+ if (ga !== gb) return ga.localeCompare(gb);
8
+ return a.meta.title.localeCompare(b.meta.title);
9
+ });
10
+ }
11
+
12
+ /**
13
+ * Merge auto-generated and manual playground entries.
14
+ * Manual entries (from `definePlayground()`) override auto entries with the same `id`.
15
+ */
16
+ export function mergePlaygroundEntries(
17
+ autoEntries: PlaygroundEntry[],
18
+ manualEntries: PlaygroundEntry[],
19
+ ): PlaygroundEntry[] {
20
+ const byId = new Map<string, PlaygroundEntry>();
21
+ for (const entry of autoEntries) {
22
+ byId.set(entry.id, entry);
23
+ }
24
+ for (const entry of manualEntries) {
25
+ byId.set(entry.id, entry);
26
+ }
27
+ return sortPlaygroundEntries([...byId.values()]);
28
+ }