dslinter 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +50 -29
  3. package/bin/dslinter.mjs +26 -5
  4. package/bin/lib/config-hide-component.mjs +44 -0
  5. package/bin/lib/config-hide-component.test.mjs +33 -0
  6. package/bin/lib/constants.mjs +20 -0
  7. package/bin/lib/dev-banner.mjs +16 -51
  8. package/bin/lib/dev-banner.test.mjs +20 -18
  9. package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
  10. package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
  11. package/bin/lib/enrich-report-cli.mjs +14 -0
  12. package/bin/lib/env.mjs +20 -0
  13. package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
  14. package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
  15. package/bin/lib/parse-args.mjs +13 -1
  16. package/bin/lib/parse-args.test.mjs +7 -1
  17. package/bin/lib/paths.mjs +8 -0
  18. package/bin/lib/project-root.mjs +72 -10
  19. package/bin/lib/project-root.test.mjs +32 -1
  20. package/bin/lib/prompt.mjs +31 -0
  21. package/bin/lib/resolve-project.mjs +78 -0
  22. package/bin/lib/resolve-project.test.mjs +74 -0
  23. package/bin/lib/run-scanner.mjs +40 -6
  24. package/bin/lib/scaffold-config.mjs +96 -8
  25. package/bin/lib/scaffold-config.test.mjs +12 -2
  26. package/bin/lib/scan-host.mjs +44 -0
  27. package/bin/lib/scan-host.test.mjs +41 -0
  28. package/bin/lib/setup-readiness.mjs +153 -0
  29. package/bin/lib/setup-readiness.test.mjs +32 -0
  30. package/bin/modes/build.mjs +31 -6
  31. package/bin/modes/dev.mjs +55 -21
  32. package/bin/modes/init.mjs +3 -22
  33. package/bin/modes/init.test.mjs +1 -1
  34. package/bin/modes/mcp.mjs +49 -0
  35. package/bin/modes/report.mjs +29 -4
  36. package/bin/modes/watch.mjs +85 -0
  37. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
  39. package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
  40. package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
  41. package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
  42. package/dashboard-dist/index.html +2 -2
  43. package/index.cjs +53 -52
  44. package/index.d.ts +3 -0
  45. package/package.json +18 -12
  46. package/shared/env.ts +15 -0
  47. package/shared/paths.ts +8 -0
  48. package/shared/reportPath.test.ts +19 -0
  49. package/shared/reportPath.ts +12 -0
  50. package/shared/servePort.ts +16 -0
  51. package/src/components/ComponentInspectPane.tsx +67 -19
  52. package/src/components/ComponentPlaygroundPane.tsx +262 -113
  53. package/src/components/DashboardCommandPalette.tsx +6 -11
  54. package/src/components/GovernancePane.tsx +2 -2
  55. package/src/components/HideFromCatalogButton.tsx +44 -0
  56. package/src/components/OpenInEditorButton.tsx +36 -0
  57. package/src/components/PlaygroundA11yAndCode.tsx +53 -53
  58. package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
  59. package/src/components/PlaygroundControls.tsx +5 -11
  60. package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
  61. package/src/components/PlaygroundUsageCode.tsx +6 -4
  62. package/src/components/PlaygroundVariantMatrix.tsx +101 -34
  63. package/src/components/Section.tsx +5 -2
  64. package/src/components/Sidebar.tsx +131 -46
  65. package/src/components/TruncatedPath.tsx +44 -0
  66. package/src/components/controlApiTable.test.ts +29 -0
  67. package/src/components/controlApiTable.ts +3 -0
  68. package/src/components/playgroundUsageHighlight.ts +14 -3
  69. package/src/components/ui/badge.tsx +1 -1
  70. package/src/components/ui/table.tsx +2 -2
  71. package/src/dashboard/ComponentCatalog.tsx +16 -23
  72. package/src/dashboard/ComponentUsageDetails.tsx +6 -15
  73. package/src/dashboard/DashboardBody.tsx +0 -35
  74. package/src/dashboard/FindingsList.tsx +65 -55
  75. package/src/dashboard/ScannedTokenWall.tsx +3 -3
  76. package/src/dashboard/aggregate.test.ts +74 -0
  77. package/src/dashboard/aggregate.ts +145 -21
  78. package/src/dashboard/catalogVisibility.test.ts +93 -0
  79. package/src/dashboard/catalogVisibility.ts +108 -0
  80. package/src/dashboard/editorLink.test.ts +57 -0
  81. package/src/dashboard/editorLink.ts +71 -0
  82. package/src/dashboard/paths.test.ts +49 -0
  83. package/src/dashboard/paths.ts +51 -3
  84. package/src/dashboard/updateDslintConfig.ts +22 -0
  85. package/src/dashboard/useWorkspaceReport.ts +21 -17
  86. package/src/index.ts +26 -0
  87. package/src/mcp/agent-context.ts +148 -0
  88. package/src/mcp/agent-query.test.ts +89 -0
  89. package/src/mcp/agent-query.ts +373 -0
  90. package/src/mcp/config.ts +53 -0
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/normalize-paths.ts +65 -0
  93. package/src/mcp/report-cache.ts +209 -0
  94. package/src/mcp/rule-catalog.json +156 -0
  95. package/src/mcp/rule-catalog.ts +33 -0
  96. package/src/mcp/schemas.ts +54 -0
  97. package/src/mcp/server.test.ts +44 -0
  98. package/src/mcp/server.ts +343 -0
  99. package/src/mcp/start.ts +29 -0
  100. package/src/mcp/verify-loop.test.ts +49 -0
  101. package/src/mcp/verify-loop.ts +149 -0
  102. package/src/playground/appPreviewTheme.test.ts +148 -0
  103. package/src/playground/appPreviewTheme.ts +137 -0
  104. package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
  105. package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
  106. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
  107. package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
  108. package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
  109. package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
  110. package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
  111. package/src/playground/collectDefinedPlaygrounds.ts +68 -0
  112. package/src/playground/controls.ts +177 -0
  113. package/src/playground/createPlaygroundRegistry.ts +1 -1
  114. package/src/playground/definePlayground.tsx +88 -16
  115. package/src/playground/definePlaygroundFromKit.ts +17 -0
  116. package/src/playground/embedGlobKey.ts +8 -0
  117. package/src/playground/enrichKitControls.test.ts +25 -0
  118. package/src/playground/enrichKitControls.ts +197 -0
  119. package/src/playground/expandPlaygroundControls.test.ts +50 -0
  120. package/src/playground/expandPlaygroundControls.ts +97 -0
  121. package/src/playground/inferKitJsx.test.ts +77 -0
  122. package/src/playground/inferKitJsx.ts +165 -0
  123. package/src/playground/inferKitParams.test.ts +41 -0
  124. package/src/playground/inferKitParams.ts +113 -0
  125. package/src/playground/inferPropTypesFromTs.d.mts +47 -0
  126. package/src/playground/inferPropTypesFromTs.mjs +343 -0
  127. package/src/playground/inferPropTypesFromTs.test.ts +227 -0
  128. package/src/playground/inferPropTypesFromTs.ts +17 -0
  129. package/src/playground/mergePlaygroundEntries.test.ts +32 -0
  130. package/src/playground/mergePlaygroundEntries.ts +28 -0
  131. package/src/playground/playgroundJoin.test.ts +79 -19
  132. package/src/playground/playgroundJoin.ts +47 -22
  133. package/src/playground/playgroundModuleExport.test.ts +42 -0
  134. package/src/playground/playgroundModuleExport.ts +22 -0
  135. package/src/playground/playgroundSpecsKey.ts +8 -0
  136. package/src/playground/propCoerce.ts +91 -0
  137. package/src/playground/scanVariantA11y.test.ts +46 -0
  138. package/src/playground/scanVariantA11y.ts +107 -0
  139. package/src/playground/snippet.ts +83 -0
  140. package/src/playground/usePlaygroundFromReport.test.ts +18 -8
  141. package/src/playground/usePlaygroundFromReport.ts +3 -1
  142. package/src/report/a11yForModule.ts +2 -7
  143. package/src/report/a11yScoring.test.ts +24 -0
  144. package/src/report/a11yScoring.ts +17 -0
  145. package/src/report/index.ts +6 -0
  146. package/src/shell/DashboardLayout.tsx +71 -45
  147. package/src/shell/DashboardLayoutAuto.tsx +0 -4
  148. package/src/shell/hashRoute.test.ts +7 -15
  149. package/src/shell/hashRoute.ts +31 -31
  150. package/src/shell/useHashRoute.ts +38 -13
  151. package/src/styles/dashboard-theme.css +18 -7
  152. package/src/types/controls.ts +11 -0
  153. package/src/types/playground.ts +4 -0
  154. package/src/types/report.ts +32 -9
  155. package/templates/playground/buildRegistry.ts +1 -1
  156. package/templates/vite.dslinter.snippet.ts +15 -4
  157. package/vite/collectScanModules.test.ts +51 -3
  158. package/vite/collectScanModules.ts +85 -29
  159. package/vite/consumer.config.mjs +6 -3
  160. package/vite/consumerAlias.test.ts +47 -0
  161. package/vite/consumerAlias.ts +114 -0
  162. package/vite/embedTailwindSources.test.ts +74 -0
  163. package/vite/embedTailwindSources.ts +97 -0
  164. package/vite/loadConsumerAliases.test.ts +131 -0
  165. package/vite/loadConsumerAliases.ts +155 -0
  166. package/vite/openFileInEditor.mjs +196 -0
  167. package/vite/openFileInEditor.test.mjs +87 -0
  168. package/vite/plugin.resolve.test.ts +72 -0
  169. package/vite/plugin.ts +216 -19
  170. package/vite/reportPath.test.ts +19 -0
  171. package/vite/resolveWayfinderImport.ts +56 -0
  172. package/vite/shims/inertia-react.tsx +85 -0
  173. package/vite/shims/wayfinder-actions.ts +33 -0
  174. package/vite/shims/wayfinder-routes.ts +30 -0
  175. package/vite/shims/ziggy-js.ts +12 -0
  176. package/dashboard-dist/assets/DashboardLayoutAuto-Bm7yfyC-.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
  178. package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
  179. package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Post-scan enrichment: fill playground prop kinds/options from TypeScript.
3
+ */
4
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import {
7
+ createCheckerProgram,
8
+ inferDeclaredPropsFromTsx,
9
+ inferPlaygroundPropMetadata,
10
+ } from "./infer-prop-types-from-ts.mjs";
11
+
12
+ /**
13
+ * @param {Record<string, unknown>} report
14
+ * @param {string} projectRoot
15
+ * @returns {boolean} true when any playground row changed
16
+ */
17
+ export function enrichWorkspaceReport(report, projectRoot) {
18
+ const playgrounds = report.playgrounds;
19
+ if (!Array.isArray(playgrounds) || playgrounds.length === 0) return false;
20
+
21
+ const checkerBundle = createCheckerProgram(projectRoot);
22
+ if (!checkerBundle) return false;
23
+
24
+ const { program, checker } = checkerBundle;
25
+ let changed = false;
26
+
27
+ for (const spec of playgrounds) {
28
+ if (!spec || typeof spec !== "object") continue;
29
+
30
+ let declaredProps = Array.isArray(spec.declared_props) ? [...spec.declared_props] : [];
31
+ const relPath = typeof spec.rel_path === "string" ? spec.rel_path : "";
32
+ const exportName = typeof spec.export_name === "string" ? spec.export_name : spec.id;
33
+
34
+ if (typeof exportName !== "string" || !relPath) continue;
35
+
36
+ const inferred = inferDeclaredPropsFromTsx(projectRoot, relPath, exportName);
37
+ if (inferred.length && (!declaredProps.length || inferred.length > declaredProps.length)) {
38
+ declaredProps = inferred;
39
+ spec.declared_props = declaredProps;
40
+ changed = true;
41
+ }
42
+
43
+ const before = JSON.stringify({
44
+ kinds: spec.declared_prop_kinds ?? {},
45
+ options: spec.declared_prop_options ?? {},
46
+ defaults: spec.declared_prop_defaults ?? {},
47
+ });
48
+
49
+ const meta = inferPlaygroundPropMetadata(
50
+ checker,
51
+ program,
52
+ projectRoot,
53
+ relPath,
54
+ exportName,
55
+ declaredProps,
56
+ {
57
+ declared_prop_kinds: spec.declared_prop_kinds,
58
+ declared_prop_options: spec.declared_prop_options,
59
+ declared_prop_defaults: spec.declared_prop_defaults,
60
+ },
61
+ );
62
+
63
+ if (Object.keys(meta.declared_prop_kinds).length) {
64
+ spec.declared_prop_kinds = meta.declared_prop_kinds;
65
+ }
66
+ if (Object.keys(meta.declared_prop_options).length) {
67
+ spec.declared_prop_options = meta.declared_prop_options;
68
+ }
69
+ if (Object.keys(meta.declared_prop_defaults).length) {
70
+ spec.declared_prop_defaults = meta.declared_prop_defaults;
71
+ }
72
+
73
+ const after = JSON.stringify({
74
+ kinds: spec.declared_prop_kinds ?? {},
75
+ options: spec.declared_prop_options ?? {},
76
+ defaults: spec.declared_prop_defaults ?? {},
77
+ });
78
+ if (before !== after) changed = true;
79
+ }
80
+
81
+ return changed;
82
+ }
83
+
84
+ /**
85
+ * @param {string} reportPath
86
+ * @param {string} projectRoot
87
+ * @returns {boolean}
88
+ */
89
+ export function enrichReportFile(reportPath, projectRoot) {
90
+ let report;
91
+ try {
92
+ report = JSON.parse(readFileSync(reportPath, "utf8"));
93
+ } catch {
94
+ return false;
95
+ }
96
+
97
+ const changed = enrichWorkspaceReport(report, projectRoot);
98
+ if (!changed) return false;
99
+
100
+ const json = `${JSON.stringify(report, null, 2)}\n`;
101
+ const dir = dirname(reportPath);
102
+ const tmp = join(dir, `.dslinter-enrich-${process.pid}.tmp`);
103
+ writeFileSync(tmp, json);
104
+ renameSync(tmp, reportPath);
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * @param {string} reportPath
110
+ * @param {string} projectRoot
111
+ */
112
+ export function enrichReportFileBestEffort(reportPath, projectRoot) {
113
+ try {
114
+ enrichReportFile(reportPath, projectRoot);
115
+ } catch (err) {
116
+ if (process.env.DSLINTER_QUIET !== "1") {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ process.stderr.write(`dslinter: TS playground enrich skipped (${msg})\n`);
119
+ }
120
+ }
121
+ }
122
+
123
+ /** @typedef {{ projectRoot: string; reportPath: string; logPrefix?: string }} EnrichOptions */
124
+
125
+ /**
126
+ * CLI-facing enrichment used by report, build, dev, and watch modes.
127
+ *
128
+ * @param {EnrichOptions} opts
129
+ * @returns {Promise<boolean>} true when enrichment ran and wrote the report
130
+ */
131
+ export async function enrichPlaygroundsFromTs({
132
+ projectRoot,
133
+ reportPath,
134
+ logPrefix = "dslinter",
135
+ }) {
136
+ if (!createCheckerProgram(projectRoot)) {
137
+ if (process.env.DSLINTER_DEBUG?.trim() === "1") {
138
+ process.stderr.write(
139
+ `${logPrefix}: skip playground TS enrichment (no tsconfig.json)\n`,
140
+ );
141
+ }
142
+ return false;
143
+ }
144
+
145
+ try {
146
+ return enrichReportFile(reportPath, projectRoot);
147
+ } catch (err) {
148
+ if (process.env.DSLINTER_DEBUG?.trim() === "1") {
149
+ process.stderr.write(
150
+ `${logPrefix}: skip playground TS enrichment (${err instanceof Error ? err.message : err})\n`,
151
+ );
152
+ }
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Poll the report file and re-run TS enrichment after the scanner writes JSON.
159
+ *
160
+ * @param {EnrichOptions & { pollMs?: number }} opts
161
+ * @returns {() => void} stop function
162
+ */
163
+ export function watchEnrichPlaygroundsFromTs({
164
+ projectRoot,
165
+ reportPath,
166
+ logPrefix = "dslinter",
167
+ pollMs = 300,
168
+ }) {
169
+ let lastMtimeMs = 0;
170
+ let running = false;
171
+ let stopped = false;
172
+
173
+ const tick = async () => {
174
+ if (stopped || running) return;
175
+ running = true;
176
+ try {
177
+ const { statSync } = await import("node:fs");
178
+ let mtimeMs;
179
+ try {
180
+ mtimeMs = statSync(reportPath).mtimeMs;
181
+ } catch {
182
+ return;
183
+ }
184
+ if (mtimeMs <= lastMtimeMs) return;
185
+ lastMtimeMs = mtimeMs;
186
+ await enrichPlaygroundsFromTs({ projectRoot, reportPath, logPrefix });
187
+ } finally {
188
+ running = false;
189
+ }
190
+ };
191
+
192
+ const interval = setInterval(() => {
193
+ void tick();
194
+ }, pollMs);
195
+ void tick();
196
+
197
+ return () => {
198
+ stopped = true;
199
+ clearInterval(interval);
200
+ };
201
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { enrichReportFile } from "./enrich-playgrounds-from-ts.mjs";
7
+
8
+ describe("enrich-playgrounds-from-ts", () => {
9
+ it("writes declared_prop_options for Input.type into report file", () => {
10
+ const root = mkdtempSync(join(tmpdir(), "dslinter-enrich-"));
11
+ const reportPath = join(root, "report.json");
12
+ try {
13
+ writeFileSync(
14
+ reportPath,
15
+ JSON.stringify(
16
+ {
17
+ root,
18
+ playgrounds: [
19
+ {
20
+ id: "Input",
21
+ export_name: "Input",
22
+ rel_path: "input.tsx",
23
+ declared_props: ["type"],
24
+ },
25
+ ],
26
+ },
27
+ null,
28
+ 2,
29
+ ),
30
+ );
31
+
32
+ writeFileSync(
33
+ join(root, "tsconfig.json"),
34
+ JSON.stringify(
35
+ {
36
+ compilerOptions: {
37
+ strict: true,
38
+ jsx: "react-jsx",
39
+ module: "ESNext",
40
+ moduleResolution: "bundler",
41
+ noEmit: true,
42
+ skipLibCheck: true,
43
+ typeRoots: [
44
+ join(process.cwd(), "node_modules/@types"),
45
+ ],
46
+ },
47
+ include: ["**/*.tsx"],
48
+ },
49
+ null,
50
+ 2,
51
+ ),
52
+ );
53
+
54
+ writeFileSync(
55
+ join(root, "input.tsx"),
56
+ `
57
+ import * as React from "react";
58
+ function Input({ type }: React.ComponentProps<"input">) {
59
+ return <input type={type} />;
60
+ }
61
+ export { Input };
62
+ `,
63
+ );
64
+
65
+ expect(enrichReportFile(reportPath, root)).toBe(true);
66
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
67
+ const input = report.playgrounds[0];
68
+ expect(input.declared_prop_options.type).toContain("email");
69
+ expect(input.declared_prop_defaults.type).toBe("text");
70
+ } finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry for Rust watch post-write hook and manual enrichment.
4
+ * Usage: node enrich-report-cli.mjs <reportPath> <projectRoot>
5
+ */
6
+ import { enrichReportFileBestEffort } from "./enrich-playgrounds-from-ts.mjs";
7
+
8
+ const [reportPath, projectRoot] = process.argv.slice(2);
9
+ if (!reportPath || !projectRoot) {
10
+ process.stderr.write("usage: enrich-report-cli.mjs <reportPath> <projectRoot>\n");
11
+ process.exit(1);
12
+ }
13
+
14
+ enrichReportFileBestEffort(reportPath, projectRoot);
@@ -0,0 +1,20 @@
1
+ /** @typedef {import("node:process").ProcessEnv} ProcessEnv */
2
+
3
+ /**
4
+ * Read `DSLINTER_*` environment variables.
5
+ * @param {string} name Suffix after the prefix, e.g. `"SERVE_PORT"`.
6
+ * @param {ProcessEnv} [env]
7
+ * @returns {string | undefined}
8
+ */
9
+ export function readEnv(name, env = process.env) {
10
+ return env[`DSLINTER_${name}`]?.trim() || undefined;
11
+ }
12
+
13
+ /**
14
+ * @param {string} name
15
+ * @param {string} [value]
16
+ * @param {ProcessEnv} [env]
17
+ */
18
+ export function envIs(name, value = "1", env = process.env) {
19
+ return readEnv(name, env) === value;
20
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * TypeScript checker helpers for playground prop kinds and finite string unions.
3
+ */
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import ts from "typescript";
7
+
8
+ /** @typedef {"boolean" | "string" | "number"} DeclaredPropKind */
9
+
10
+ /**
11
+ * @param {string} projectRoot
12
+ * @returns {{ program: import("typescript").Program; checker: import("typescript").TypeChecker } | null}
13
+ */
14
+ export function createCheckerProgram(projectRoot) {
15
+ const configPath = resolve(projectRoot, "tsconfig.json");
16
+ if (!existsSync(configPath)) return null;
17
+
18
+ const readJson = ts.readConfigFile(configPath, ts.sys.readFile);
19
+ if (readJson.error) return null;
20
+
21
+ const parsed = ts.parseJsonConfigFileContent(
22
+ readJson.config,
23
+ ts.sys,
24
+ projectRoot,
25
+ undefined,
26
+ configPath,
27
+ );
28
+ if (parsed.errors.length) return null;
29
+
30
+ const program = ts.createProgram({
31
+ rootNames: parsed.fileNames,
32
+ options: { ...parsed.options, noCheck: false },
33
+ });
34
+
35
+ return { program, checker: program.getTypeChecker() };
36
+ }
37
+
38
+ /**
39
+ * @param {import("typescript").Node} node
40
+ */
41
+ function hasExportModifier(node) {
42
+ return node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
43
+ }
44
+
45
+ /**
46
+ * @param {import("typescript").SourceFile} sf
47
+ * @returns {Set<string>}
48
+ */
49
+ function collectNamedExportNames(sf) {
50
+ /** @type {Set<string>} */
51
+ const names = new Set();
52
+
53
+ function visit(node) {
54
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
55
+ for (const el of node.exportClause.elements) {
56
+ names.add(el.name.text);
57
+ }
58
+ }
59
+ ts.forEachChild(node, visit);
60
+ }
61
+
62
+ visit(sf);
63
+ return names;
64
+ }
65
+
66
+ /**
67
+ * @param {import("typescript").CallExpression} call
68
+ * @returns {import("typescript").ParameterDeclaration | undefined}
69
+ */
70
+ function firstParamFromForwardRefCall(call) {
71
+ const callee = call.expression;
72
+ const isForwardRef =
73
+ ts.isIdentifier(callee) &&
74
+ (callee.text === "forwardRef" || callee.text.endsWith(".forwardRef"));
75
+ if (!isForwardRef) return undefined;
76
+
77
+ const arg = call.arguments[0];
78
+ if (!arg || (!ts.isArrowFunction(arg) && !ts.isFunctionExpression(arg))) {
79
+ return undefined;
80
+ }
81
+ return arg.parameters[0];
82
+ }
83
+
84
+ /**
85
+ * Resolve the first parameter type for a component export.
86
+ *
87
+ * @param {import("typescript").TypeChecker} checker
88
+ * @param {import("typescript").SourceFile} sf
89
+ * @param {string} exportName
90
+ * @returns {import("typescript").Type | undefined}
91
+ */
92
+ export function findComponentParamType(checker, sf, exportName) {
93
+ const namedExports = collectNamedExportNames(sf);
94
+
95
+ /** @type {import("typescript").Type | undefined} */
96
+ let found;
97
+
98
+ function isTargetExport(hasDirectExportModifier) {
99
+ return hasDirectExportModifier || namedExports.has(exportName);
100
+ }
101
+
102
+ function visit(node) {
103
+ if (found !== undefined) return;
104
+
105
+ if (
106
+ ts.isFunctionDeclaration(node) &&
107
+ node.name?.text === exportName &&
108
+ isTargetExport(hasExportModifier(node))
109
+ ) {
110
+ const p0 = node.parameters[0];
111
+ if (p0) found = checker.getTypeAtLocation(p0);
112
+ return;
113
+ }
114
+
115
+ if (ts.isVariableStatement(node)) {
116
+ if (!isTargetExport(hasExportModifier(node))) return;
117
+
118
+ for (const decl of node.declarationList.declarations) {
119
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== exportName || !decl.initializer) {
120
+ continue;
121
+ }
122
+
123
+ const init = decl.initializer;
124
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
125
+ const p0 = init.parameters[0];
126
+ if (p0) {
127
+ found = checker.getTypeAtLocation(p0);
128
+ return;
129
+ }
130
+ }
131
+
132
+ if (ts.isCallExpression(init)) {
133
+ const p0 = firstParamFromForwardRefCall(init);
134
+ if (p0) {
135
+ found = checker.getTypeAtLocation(p0);
136
+ return;
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ ts.forEachChild(node, visit);
143
+ }
144
+
145
+ visit(sf);
146
+ return found;
147
+ }
148
+
149
+ /**
150
+ * @param {import("typescript").TypeChecker} checker
151
+ * @param {import("typescript").Type} type
152
+ * @returns {DeclaredPropKind | null}
153
+ */
154
+ export function classifyPropType(checker, type) {
155
+ const nn = checker.getNonNullableType(type);
156
+ if (nn.isUnion()) {
157
+ const parts = nn.types.map((u) =>
158
+ classifyPropType(checker, checker.getNonNullableType(u)),
159
+ );
160
+ const ok = parts.filter((p) => p !== null);
161
+ if (!ok.length) return null;
162
+ const set = new Set(ok);
163
+ if (set.size === 1) return [...set][0];
164
+ if ([...set].every((x) => x === "string")) return "string";
165
+ if ([...set].every((x) => x === "number")) return "number";
166
+ return null;
167
+ }
168
+ if (nn.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean";
169
+ if (nn.flags & (ts.TypeFlags.Enum | ts.TypeFlags.EnumLiteral)) return "string";
170
+ if (nn.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLike)) return "number";
171
+ if (nn.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLike)) return "string";
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * @param {import("typescript").TypeChecker} checker
177
+ * @param {import("typescript").Type} type
178
+ */
179
+ function isPlainStringType(checker, type) {
180
+ const nn = checker.getNonNullableType(type);
181
+ return (
182
+ (nn.flags & ts.TypeFlags.String) !== 0 &&
183
+ (nn.flags & ts.TypeFlags.StringLiteral) === 0
184
+ );
185
+ }
186
+
187
+ /**
188
+ * React and other libs use `string & {}` as an open-string catch-all alongside literals.
189
+ *
190
+ * @param {import("typescript").TypeChecker} checker
191
+ * @param {import("typescript").Type} type
192
+ */
193
+ function isOpenStringCatchAllType(checker, type) {
194
+ const nn = checker.getNonNullableType(type);
195
+ if (isPlainStringType(checker, nn)) return true;
196
+ if (!nn.isIntersection?.()) return false;
197
+ const parts = nn.types.map((t) => checker.getNonNullableType(t));
198
+ const hasString = parts.some((p) => (p.flags & ts.TypeFlags.String) !== 0);
199
+ const hasEmptyObject = parts.some((p) => checker.typeToString(p) === "{}");
200
+ return hasString && hasEmptyObject;
201
+ }
202
+
203
+ /**
204
+ * Extract a finite union of string literals suitable for a select control.
205
+ *
206
+ * @param {import("typescript").TypeChecker} checker
207
+ * @param {import("typescript").Type} type
208
+ * @param {{ max?: number; seen?: Set<number> }} [opts]
209
+ * @returns {string[] | null}
210
+ */
211
+ export function extractFiniteStringUnion(checker, type, opts = {}) {
212
+ const max = opts.max ?? 32;
213
+ const seen = opts.seen ?? new Set();
214
+
215
+ const nn = checker.getNonNullableType(type);
216
+ if (seen.has(nn.id)) return null;
217
+ seen.add(nn.id);
218
+
219
+ if (nn.isUnion?.()) {
220
+ /** @type {string[]} */
221
+ const literals = [];
222
+ for (const member of nn.types) {
223
+ if (isOpenStringCatchAllType(checker, member)) continue;
224
+ const part = extractFiniteStringUnion(checker, member, { max, seen });
225
+ if (part === null) {
226
+ if (isPlainStringType(checker, member)) return null;
227
+ continue;
228
+ }
229
+ literals.push(...part);
230
+ }
231
+ const unique = [...new Set(literals)];
232
+ if (unique.length < 2) return null;
233
+ unique.sort();
234
+ return unique.slice(0, max);
235
+ }
236
+
237
+ if (nn.aliasSymbol) {
238
+ const aliasType = checker.getDeclaredTypeOfSymbol(nn.aliasSymbol);
239
+ if (aliasType.id !== nn.id) {
240
+ return extractFiniteStringUnion(checker, aliasType, { max, seen });
241
+ }
242
+ }
243
+
244
+ if (nn.isStringLiteral()) {
245
+ return [nn.value];
246
+ }
247
+
248
+ if (nn.flags & ts.TypeFlags.EnumLiteral) {
249
+ const value = nn.value;
250
+ if (typeof value === "string") return [value];
251
+ }
252
+
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * @param {string} projectRoot
258
+ * @param {string} relPath
259
+ * @returns {import("typescript").SourceFile | undefined}
260
+ */
261
+ function sourceFileForRelPath(program, projectRoot, relPath) {
262
+ const abs = resolve(projectRoot, relPath);
263
+ const norm = (p) => p.replace(/\\/g, "/");
264
+ const want = norm(abs);
265
+ return program.getSourceFiles().find((f) => norm(f.fileName) === want);
266
+ }
267
+
268
+ /**
269
+ * @param {import("typescript").TypeChecker} checker
270
+ * @param {import("typescript").Program} program
271
+ * @param {string} projectRoot
272
+ * @param {string} relPath
273
+ * @param {string} exportName
274
+ * @param {string[]} declaredProps
275
+ * @param {{
276
+ * declared_prop_options?: Record<string, string[]>;
277
+ * declared_prop_defaults?: Record<string, string>;
278
+ * declared_prop_kinds?: Record<string, string>;
279
+ * }} [existing]
280
+ */
281
+ export function inferPlaygroundPropMetadata(
282
+ checker,
283
+ program,
284
+ projectRoot,
285
+ relPath,
286
+ exportName,
287
+ declaredProps,
288
+ existing = {},
289
+ ) {
290
+ const sf = sourceFileForRelPath(program, projectRoot, relPath);
291
+ if (!sf) {
292
+ return {
293
+ declared_prop_kinds: existing.declared_prop_kinds ?? {},
294
+ declared_prop_options: existing.declared_prop_options ?? {},
295
+ declared_prop_defaults: existing.declared_prop_defaults ?? {},
296
+ };
297
+ }
298
+
299
+ const paramType = findComponentParamType(checker, sf, exportName);
300
+ if (!paramType) {
301
+ return {
302
+ declared_prop_kinds: existing.declared_prop_kinds ?? {},
303
+ declared_prop_options: existing.declared_prop_options ?? {},
304
+ declared_prop_defaults: existing.declared_prop_defaults ?? {},
305
+ };
306
+ }
307
+
308
+ /** @type {Record<string, DeclaredPropKind>} */
309
+ const kinds = { ...existing.declared_prop_kinds };
310
+ /** @type {Record<string, string[]>} */
311
+ const options = { ...existing.declared_prop_options };
312
+ /** @type {Record<string, string>} */
313
+ const defaults = { ...existing.declared_prop_defaults };
314
+
315
+ for (const key of declaredProps) {
316
+ if (key === "key" || key === "ref") continue;
317
+
318
+ const sym = checker.getPropertyOfType(paramType, key);
319
+ if (!sym) continue;
320
+ const propType = checker.getTypeOfSymbol(sym);
321
+
322
+ if (!kinds[key]) {
323
+ const kind = classifyPropType(checker, propType);
324
+ if (kind !== null) kinds[key] = kind;
325
+ }
326
+
327
+ if (!options[key]) {
328
+ const literals = extractFiniteStringUnion(checker, propType);
329
+ if (literals && literals.length >= 2) {
330
+ options[key] = literals;
331
+ if (key === "type" && literals.includes("text") && !defaults[key]) {
332
+ defaults[key] = "text";
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ return {
339
+ declared_prop_kinds: kinds,
340
+ declared_prop_options: options,
341
+ declared_prop_defaults: defaults,
342
+ };
343
+ }
344
+
345
+ export function inferDeclaredPropsFromTsx(projectRoot, relPath, exportName) {
346
+ const abs = resolve(projectRoot, relPath);
347
+ let text;
348
+ try {
349
+ text = readFileSync(abs, "utf8");
350
+ } catch {
351
+ return [];
352
+ }
353
+
354
+ const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
355
+ /** @type {string[]} */
356
+ const names = [];
357
+
358
+ function visit(node) {
359
+ if (
360
+ ts.isFunctionDeclaration(node) &&
361
+ node.name?.text === exportName &&
362
+ (hasExportModifier(node) || collectNamedExportNames(sf).has(exportName))
363
+ ) {
364
+ const p0 = node.parameters[0];
365
+ if (p0 && ts.isObjectBindingPattern(p0.name)) {
366
+ for (const el of p0.name.elements) {
367
+ if (!ts.isBindingElement(el)) continue;
368
+ if (el.propertyName && ts.isIdentifier(el.propertyName)) {
369
+ names.push(el.propertyName.text);
370
+ } else if (ts.isIdentifier(el.name)) {
371
+ names.push(el.name.text);
372
+ }
373
+ }
374
+ }
375
+ }
376
+ ts.forEachChild(node, visit);
377
+ }
378
+
379
+ visit(sf);
380
+ return [...new Set(names)].sort();
381
+ }