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.
- package/CHANGELOG.md +72 -0
- package/README.md +50 -29
- package/bin/dslinter.mjs +26 -5
- package/bin/lib/config-hide-component.mjs +44 -0
- package/bin/lib/config-hide-component.test.mjs +33 -0
- package/bin/lib/constants.mjs +20 -0
- package/bin/lib/dev-banner.mjs +16 -51
- package/bin/lib/dev-banner.test.mjs +20 -18
- package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
- package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
- package/bin/lib/enrich-report-cli.mjs +14 -0
- package/bin/lib/env.mjs +20 -0
- package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
- package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
- package/bin/lib/parse-args.mjs +13 -1
- package/bin/lib/parse-args.test.mjs +7 -1
- package/bin/lib/paths.mjs +8 -0
- package/bin/lib/project-root.mjs +72 -10
- package/bin/lib/project-root.test.mjs +32 -1
- package/bin/lib/prompt.mjs +31 -0
- package/bin/lib/resolve-project.mjs +78 -0
- package/bin/lib/resolve-project.test.mjs +74 -0
- package/bin/lib/run-scanner.mjs +40 -6
- package/bin/lib/scaffold-config.mjs +128 -9
- package/bin/lib/scaffold-config.test.mjs +24 -2
- package/bin/lib/scan-host.mjs +44 -0
- package/bin/lib/scan-host.test.mjs +41 -0
- package/bin/lib/setup-readiness.mjs +153 -0
- package/bin/lib/setup-readiness.test.mjs +32 -0
- package/bin/modes/build.mjs +31 -6
- package/bin/modes/dev.mjs +55 -21
- package/bin/modes/init.mjs +3 -22
- package/bin/modes/init.test.mjs +1 -1
- package/bin/modes/mcp.mjs +49 -0
- package/bin/modes/report.mjs +29 -4
- package/bin/modes/watch.mjs +85 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
- package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
- package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
- package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
- package/dashboard-dist/index.html +2 -2
- package/index.cjs +53 -52
- package/index.d.ts +3 -0
- package/package.json +18 -12
- package/shared/env.ts +15 -0
- package/shared/paths.ts +8 -0
- package/shared/reportPath.test.ts +19 -0
- package/shared/reportPath.ts +12 -0
- package/shared/servePort.ts +16 -0
- package/src/components/ComponentInspectPane.tsx +67 -19
- package/src/components/ComponentPlaygroundPane.tsx +262 -113
- package/src/components/DashboardCommandPalette.tsx +6 -11
- package/src/components/GovernancePane.tsx +2 -2
- package/src/components/HideFromCatalogButton.tsx +44 -0
- package/src/components/OpenInEditorButton.tsx +36 -0
- package/src/components/PlaygroundA11yAndCode.tsx +53 -53
- package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
- package/src/components/PlaygroundControls.tsx +5 -11
- package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
- package/src/components/PlaygroundUsageCode.tsx +6 -4
- package/src/components/PlaygroundVariantMatrix.tsx +101 -34
- package/src/components/Section.tsx +5 -2
- package/src/components/Sidebar.tsx +131 -46
- package/src/components/TruncatedPath.tsx +44 -0
- package/src/components/controlApiTable.test.ts +29 -0
- package/src/components/controlApiTable.ts +3 -0
- package/src/components/playgroundUsageHighlight.ts +14 -3
- package/src/components/ui/badge.tsx +1 -1
- package/src/components/ui/table.tsx +2 -2
- package/src/dashboard/ComponentCatalog.tsx +16 -23
- package/src/dashboard/ComponentUsageDetails.tsx +6 -15
- package/src/dashboard/DashboardBody.tsx +0 -35
- package/src/dashboard/FindingsList.tsx +65 -55
- package/src/dashboard/ScannedTokenWall.tsx +3 -3
- package/src/dashboard/aggregate.test.ts +74 -0
- package/src/dashboard/aggregate.ts +145 -21
- package/src/dashboard/catalogVisibility.test.ts +93 -0
- package/src/dashboard/catalogVisibility.ts +108 -0
- package/src/dashboard/editorLink.test.ts +57 -0
- package/src/dashboard/editorLink.ts +71 -0
- package/src/dashboard/paths.test.ts +49 -0
- package/src/dashboard/paths.ts +51 -3
- package/src/dashboard/updateDslintConfig.ts +22 -0
- package/src/dashboard/useWorkspaceReport.ts +21 -17
- package/src/index.ts +26 -0
- package/src/mcp/agent-context.ts +148 -0
- package/src/mcp/agent-query.test.ts +89 -0
- package/src/mcp/agent-query.ts +373 -0
- package/src/mcp/config.ts +53 -0
- package/src/mcp/index.ts +18 -0
- package/src/mcp/normalize-paths.ts +65 -0
- package/src/mcp/report-cache.ts +212 -0
- package/src/mcp/rule-catalog.json +156 -0
- package/src/mcp/rule-catalog.ts +33 -0
- package/src/mcp/schemas.ts +54 -0
- package/src/mcp/server.test.ts +44 -0
- package/src/mcp/server.ts +343 -0
- package/src/mcp/start.ts +29 -0
- package/src/mcp/verify-loop.test.ts +49 -0
- package/src/mcp/verify-loop.ts +149 -0
- package/src/playground/appPreviewTheme.test.ts +148 -0
- package/src/playground/appPreviewTheme.ts +137 -0
- package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
- package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
- package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
- package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
- package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
- package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
- package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
- package/src/playground/collectDefinedPlaygrounds.ts +68 -0
- package/src/playground/controls.ts +177 -0
- package/src/playground/createPlaygroundRegistry.ts +1 -1
- package/src/playground/definePlayground.tsx +88 -16
- package/src/playground/definePlaygroundFromKit.ts +17 -0
- package/src/playground/embedGlobKey.ts +8 -0
- package/src/playground/enrichKitControls.test.ts +25 -0
- package/src/playground/enrichKitControls.ts +197 -0
- package/src/playground/expandPlaygroundControls.test.ts +50 -0
- package/src/playground/expandPlaygroundControls.ts +97 -0
- package/src/playground/inferKitJsx.test.ts +77 -0
- package/src/playground/inferKitJsx.ts +165 -0
- package/src/playground/inferKitParams.test.ts +41 -0
- package/src/playground/inferKitParams.ts +113 -0
- package/src/playground/inferPropTypesFromTs.d.mts +47 -0
- package/src/playground/inferPropTypesFromTs.mjs +343 -0
- package/src/playground/inferPropTypesFromTs.test.ts +227 -0
- package/src/playground/inferPropTypesFromTs.ts +17 -0
- package/src/playground/mergePlaygroundEntries.test.ts +32 -0
- package/src/playground/mergePlaygroundEntries.ts +28 -0
- package/src/playground/playgroundJoin.test.ts +79 -19
- package/src/playground/playgroundJoin.ts +47 -22
- package/src/playground/playgroundModuleExport.test.ts +42 -0
- package/src/playground/playgroundModuleExport.ts +22 -0
- package/src/playground/playgroundSpecsKey.ts +8 -0
- package/src/playground/propCoerce.ts +91 -0
- package/src/playground/scanVariantA11y.test.ts +46 -0
- package/src/playground/scanVariantA11y.ts +107 -0
- package/src/playground/snippet.ts +83 -0
- package/src/playground/usePlaygroundFromReport.test.ts +18 -8
- package/src/playground/usePlaygroundFromReport.ts +3 -1
- package/src/report/a11yForModule.ts +2 -7
- package/src/report/a11yScoring.test.ts +24 -0
- package/src/report/a11yScoring.ts +17 -0
- package/src/report/index.ts +6 -0
- package/src/shell/DashboardLayout.tsx +71 -45
- package/src/shell/DashboardLayoutAuto.tsx +0 -4
- package/src/shell/hashRoute.test.ts +7 -15
- package/src/shell/hashRoute.ts +31 -31
- package/src/shell/useHashRoute.ts +38 -13
- package/src/styles/dashboard-theme.css +18 -7
- package/src/types/controls.ts +11 -0
- package/src/types/playground.ts +4 -0
- package/src/types/report.ts +32 -9
- package/templates/playground/buildRegistry.ts +1 -1
- package/templates/vite.dslinter.snippet.ts +15 -4
- package/vite/collectScanModules.test.ts +91 -3
- package/vite/collectScanModules.ts +94 -29
- package/vite/consumer.config.mjs +6 -3
- package/vite/consumerAlias.test.ts +47 -0
- package/vite/consumerAlias.ts +114 -0
- package/vite/embedTailwindSources.test.ts +74 -0
- package/vite/embedTailwindSources.ts +97 -0
- package/vite/loadConsumerAliases.test.ts +131 -0
- package/vite/loadConsumerAliases.ts +155 -0
- package/vite/openFileInEditor.mjs +196 -0
- package/vite/openFileInEditor.test.mjs +87 -0
- package/vite/plugin.resolve.test.ts +72 -0
- package/vite/plugin.ts +216 -19
- package/vite/reportPath.test.ts +19 -0
- package/vite/resolveWayfinderImport.ts +56 -0
- package/vite/shims/inertia-react.tsx +85 -0
- package/vite/shims/wayfinder-actions.ts +33 -0
- package/vite/shims/wayfinder-routes.ts +30 -0
- package/vite/shims/ziggy-js.ts +12 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-Bm7yfyC-.css +0 -1
- package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
- package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
- package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
- package/src/components/playgroundUsageTwoslash.ts +0 -69
- 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
|
+
}
|