dslinter 0.1.13 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +50 -29
  3. package/bin/dslinter.mjs +26 -5
  4. package/bin/lib/config-hide-component.mjs +44 -0
  5. package/bin/lib/config-hide-component.test.mjs +33 -0
  6. package/bin/lib/constants.mjs +20 -0
  7. package/bin/lib/dev-banner.mjs +16 -51
  8. package/bin/lib/dev-banner.test.mjs +20 -18
  9. package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
  10. package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
  11. package/bin/lib/enrich-report-cli.mjs +14 -0
  12. package/bin/lib/env.mjs +20 -0
  13. package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
  14. package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
  15. package/bin/lib/parse-args.mjs +13 -1
  16. package/bin/lib/parse-args.test.mjs +7 -1
  17. package/bin/lib/paths.mjs +8 -0
  18. package/bin/lib/project-root.mjs +72 -10
  19. package/bin/lib/project-root.test.mjs +32 -1
  20. package/bin/lib/prompt.mjs +31 -0
  21. package/bin/lib/resolve-project.mjs +78 -0
  22. package/bin/lib/resolve-project.test.mjs +74 -0
  23. package/bin/lib/run-scanner.mjs +40 -6
  24. package/bin/lib/scaffold-config.mjs +128 -9
  25. package/bin/lib/scaffold-config.test.mjs +24 -2
  26. package/bin/lib/scan-host.mjs +44 -0
  27. package/bin/lib/scan-host.test.mjs +41 -0
  28. package/bin/lib/setup-readiness.mjs +153 -0
  29. package/bin/lib/setup-readiness.test.mjs +32 -0
  30. package/bin/modes/build.mjs +31 -6
  31. package/bin/modes/dev.mjs +55 -21
  32. package/bin/modes/init.mjs +3 -22
  33. package/bin/modes/init.test.mjs +1 -1
  34. package/bin/modes/mcp.mjs +49 -0
  35. package/bin/modes/report.mjs +29 -4
  36. package/bin/modes/watch.mjs +85 -0
  37. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
  39. package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
  40. package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
  41. package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
  42. package/dashboard-dist/index.html +2 -2
  43. package/index.cjs +53 -52
  44. package/index.d.ts +3 -0
  45. package/package.json +18 -12
  46. package/shared/env.ts +15 -0
  47. package/shared/paths.ts +8 -0
  48. package/shared/reportPath.test.ts +19 -0
  49. package/shared/reportPath.ts +12 -0
  50. package/shared/servePort.ts +16 -0
  51. package/src/components/ComponentInspectPane.tsx +67 -19
  52. package/src/components/ComponentPlaygroundPane.tsx +262 -113
  53. package/src/components/DashboardCommandPalette.tsx +6 -11
  54. package/src/components/GovernancePane.tsx +2 -2
  55. package/src/components/HideFromCatalogButton.tsx +44 -0
  56. package/src/components/OpenInEditorButton.tsx +36 -0
  57. package/src/components/PlaygroundA11yAndCode.tsx +53 -53
  58. package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
  59. package/src/components/PlaygroundControls.tsx +5 -11
  60. package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
  61. package/src/components/PlaygroundUsageCode.tsx +6 -4
  62. package/src/components/PlaygroundVariantMatrix.tsx +101 -34
  63. package/src/components/Section.tsx +5 -2
  64. package/src/components/Sidebar.tsx +131 -46
  65. package/src/components/TruncatedPath.tsx +44 -0
  66. package/src/components/controlApiTable.test.ts +29 -0
  67. package/src/components/controlApiTable.ts +3 -0
  68. package/src/components/playgroundUsageHighlight.ts +14 -3
  69. package/src/components/ui/badge.tsx +1 -1
  70. package/src/components/ui/table.tsx +2 -2
  71. package/src/dashboard/ComponentCatalog.tsx +16 -23
  72. package/src/dashboard/ComponentUsageDetails.tsx +6 -15
  73. package/src/dashboard/DashboardBody.tsx +0 -35
  74. package/src/dashboard/FindingsList.tsx +65 -55
  75. package/src/dashboard/ScannedTokenWall.tsx +3 -3
  76. package/src/dashboard/aggregate.test.ts +74 -0
  77. package/src/dashboard/aggregate.ts +145 -21
  78. package/src/dashboard/catalogVisibility.test.ts +93 -0
  79. package/src/dashboard/catalogVisibility.ts +108 -0
  80. package/src/dashboard/editorLink.test.ts +57 -0
  81. package/src/dashboard/editorLink.ts +71 -0
  82. package/src/dashboard/paths.test.ts +49 -0
  83. package/src/dashboard/paths.ts +51 -3
  84. package/src/dashboard/updateDslintConfig.ts +22 -0
  85. package/src/dashboard/useWorkspaceReport.ts +21 -17
  86. package/src/index.ts +26 -0
  87. package/src/mcp/agent-context.ts +148 -0
  88. package/src/mcp/agent-query.test.ts +89 -0
  89. package/src/mcp/agent-query.ts +373 -0
  90. package/src/mcp/config.ts +53 -0
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/normalize-paths.ts +65 -0
  93. package/src/mcp/report-cache.ts +212 -0
  94. package/src/mcp/rule-catalog.json +156 -0
  95. package/src/mcp/rule-catalog.ts +33 -0
  96. package/src/mcp/schemas.ts +54 -0
  97. package/src/mcp/server.test.ts +44 -0
  98. package/src/mcp/server.ts +343 -0
  99. package/src/mcp/start.ts +29 -0
  100. package/src/mcp/verify-loop.test.ts +49 -0
  101. package/src/mcp/verify-loop.ts +149 -0
  102. package/src/playground/appPreviewTheme.test.ts +148 -0
  103. package/src/playground/appPreviewTheme.ts +137 -0
  104. package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
  105. package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
  106. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
  107. package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
  108. package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
  109. package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
  110. package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
  111. package/src/playground/collectDefinedPlaygrounds.ts +68 -0
  112. package/src/playground/controls.ts +177 -0
  113. package/src/playground/createPlaygroundRegistry.ts +1 -1
  114. package/src/playground/definePlayground.tsx +88 -16
  115. package/src/playground/definePlaygroundFromKit.ts +17 -0
  116. package/src/playground/embedGlobKey.ts +8 -0
  117. package/src/playground/enrichKitControls.test.ts +25 -0
  118. package/src/playground/enrichKitControls.ts +197 -0
  119. package/src/playground/expandPlaygroundControls.test.ts +50 -0
  120. package/src/playground/expandPlaygroundControls.ts +97 -0
  121. package/src/playground/inferKitJsx.test.ts +77 -0
  122. package/src/playground/inferKitJsx.ts +165 -0
  123. package/src/playground/inferKitParams.test.ts +41 -0
  124. package/src/playground/inferKitParams.ts +113 -0
  125. package/src/playground/inferPropTypesFromTs.d.mts +47 -0
  126. package/src/playground/inferPropTypesFromTs.mjs +343 -0
  127. package/src/playground/inferPropTypesFromTs.test.ts +227 -0
  128. package/src/playground/inferPropTypesFromTs.ts +17 -0
  129. package/src/playground/mergePlaygroundEntries.test.ts +32 -0
  130. package/src/playground/mergePlaygroundEntries.ts +28 -0
  131. package/src/playground/playgroundJoin.test.ts +79 -19
  132. package/src/playground/playgroundJoin.ts +47 -22
  133. package/src/playground/playgroundModuleExport.test.ts +42 -0
  134. package/src/playground/playgroundModuleExport.ts +22 -0
  135. package/src/playground/playgroundSpecsKey.ts +8 -0
  136. package/src/playground/propCoerce.ts +91 -0
  137. package/src/playground/scanVariantA11y.test.ts +46 -0
  138. package/src/playground/scanVariantA11y.ts +107 -0
  139. package/src/playground/snippet.ts +83 -0
  140. package/src/playground/usePlaygroundFromReport.test.ts +18 -8
  141. package/src/playground/usePlaygroundFromReport.ts +3 -1
  142. package/src/report/a11yForModule.ts +2 -7
  143. package/src/report/a11yScoring.test.ts +24 -0
  144. package/src/report/a11yScoring.ts +17 -0
  145. package/src/report/index.ts +6 -0
  146. package/src/shell/DashboardLayout.tsx +71 -45
  147. package/src/shell/DashboardLayoutAuto.tsx +0 -4
  148. package/src/shell/hashRoute.test.ts +7 -15
  149. package/src/shell/hashRoute.ts +31 -31
  150. package/src/shell/useHashRoute.ts +38 -13
  151. package/src/styles/dashboard-theme.css +18 -7
  152. package/src/types/controls.ts +11 -0
  153. package/src/types/playground.ts +4 -0
  154. package/src/types/report.ts +32 -9
  155. package/templates/playground/buildRegistry.ts +1 -1
  156. package/templates/vite.dslinter.snippet.ts +15 -4
  157. package/vite/collectScanModules.test.ts +91 -3
  158. package/vite/collectScanModules.ts +94 -29
  159. package/vite/consumer.config.mjs +6 -3
  160. package/vite/consumerAlias.test.ts +47 -0
  161. package/vite/consumerAlias.ts +114 -0
  162. package/vite/embedTailwindSources.test.ts +74 -0
  163. package/vite/embedTailwindSources.ts +97 -0
  164. package/vite/loadConsumerAliases.test.ts +131 -0
  165. package/vite/loadConsumerAliases.ts +155 -0
  166. package/vite/openFileInEditor.mjs +196 -0
  167. package/vite/openFileInEditor.test.mjs +87 -0
  168. package/vite/plugin.resolve.test.ts +72 -0
  169. package/vite/plugin.ts +216 -19
  170. package/vite/reportPath.test.ts +19 -0
  171. package/vite/resolveWayfinderImport.ts +56 -0
  172. package/vite/shims/inertia-react.tsx +85 -0
  173. package/vite/shims/wayfinder-actions.ts +33 -0
  174. package/vite/shims/wayfinder-routes.ts +30 -0
  175. package/vite/shims/ziggy-js.ts +12 -0
  176. package/dashboard-dist/assets/DashboardLayoutAuto-Bm7yfyC-.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
  178. package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
  179. package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -3,14 +3,38 @@ import { createRequire } from "node:module";
3
3
  import { existsSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { readEnv, envIs } from "./env.mjs";
6
7
 
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  const packageRoot = join(__dirname, "../..");
9
10
  const binScript = join(__dirname, "../dslinter.mjs");
11
+ const enrichScript = join(__dirname, "enrich-report-cli.mjs");
10
12
  const require = createRequire(import.meta.url);
11
13
 
12
14
  const SCANNER_VERSION_MARKER = "design system linting";
13
15
 
16
+ /** Env vars for the Rust watch post-write TS enrich hook. */
17
+ export function scannerEnrichEnv(projectRoot) {
18
+ if (process.env.DSLINTER_SKIP_TS_ENRICH === "1") {
19
+ return {};
20
+ }
21
+ /** @type {Record<string, string>} */
22
+ const env = {
23
+ DSLINTER_ENRICH_SCRIPT: enrichScript,
24
+ DSLINTER_NODE: process.execPath,
25
+ };
26
+ if (projectRoot) {
27
+ env.DSLINTER_PROJECT_ROOT = projectRoot;
28
+ }
29
+ return env;
30
+ }
31
+
32
+ function applyEnrichEnv(projectRoot) {
33
+ for (const [key, value] of Object.entries(scannerEnrichEnv(projectRoot))) {
34
+ process.env[key] = value;
35
+ }
36
+ }
37
+
14
38
  function isOurScanner(binary) {
15
39
  const help = spawnSync(binary, ["--help"], { encoding: "utf8" });
16
40
  const out = `${help.stdout ?? ""}${help.stderr ?? ""}`;
@@ -21,17 +45,24 @@ function isOurScanner(binary) {
21
45
  * @returns {Promise<import("node:child_process").ChildProcess>}
22
46
  */
23
47
  export async function spawnScanner(scannerArgs, options = {}) {
24
- const fromEnv = process.env.DSLINT_BIN?.trim();
48
+ const projectRoot = options.projectRoot ?? process.env.DSLINTER_PROJECT_ROOT;
49
+ const enrichEnv = scannerEnrichEnv(projectRoot);
50
+ const fromEnv = readEnv("BIN");
25
51
  if (fromEnv) {
26
52
  if (!existsSync(fromEnv)) {
27
- throw new Error(`dslinter: DSLINT_BIN not found: ${fromEnv}`);
53
+ throw new Error(`dslinter: DSLINTER_BIN not found: ${fromEnv}`);
28
54
  }
29
55
  if (!isOurScanner(fromEnv)) {
30
- throw new Error("dslinter: DSLINT_BIN does not look like the DSLint scanner");
56
+ throw new Error("dslinter: DSLINTER_BIN does not look like the DSLinter scanner");
31
57
  }
32
58
  return spawn(fromEnv, scannerArgs, {
33
59
  stdio: "inherit",
34
60
  ...options,
61
+ env: {
62
+ ...process.env,
63
+ ...enrichEnv,
64
+ ...options.env,
65
+ },
35
66
  });
36
67
  }
37
68
 
@@ -41,6 +72,7 @@ export async function spawnScanner(scannerArgs, options = {}) {
41
72
  env: {
42
73
  ...process.env,
43
74
  DSLINTER_INTERNAL: "1",
75
+ ...enrichEnv,
44
76
  ...options.env,
45
77
  },
46
78
  });
@@ -53,7 +85,8 @@ export async function spawnScanner(scannerArgs, options = {}) {
53
85
  * @returns {number}
54
86
  */
55
87
  export function runScannerSync(scannerArgs, opts = {}) {
56
- const fromEnv = process.env.DSLINT_BIN?.trim();
88
+ applyEnrichEnv(opts.projectRoot);
89
+ const fromEnv = readEnv("BIN");
57
90
  if (fromEnv) {
58
91
  const child = spawnSync(fromEnv, scannerArgs, {
59
92
  stdio: opts.captureStdout ? ["ignore", "pipe", "inherit"] : "inherit",
@@ -105,13 +138,14 @@ export function runScannerSync(scannerArgs, opts = {}) {
105
138
  * @param {string[]} args
106
139
  */
107
140
  export function runScannerInternal(args) {
108
- const fromEnv = process.env.DSLINT_BIN?.trim();
141
+ applyEnrichEnv(process.env.DSLINTER_PROJECT_ROOT);
142
+ const fromEnv = readEnv("BIN");
109
143
  if (fromEnv) {
110
144
  const child = spawnSync(fromEnv, args, { stdio: "inherit" });
111
145
  process.exit(child.status === null ? 1 : child.status);
112
146
  }
113
147
 
114
- if (process.env.DSLINT_ALLOW_PATH === "1") {
148
+ if (envIs("ALLOW_PATH")) {
115
149
  const onPath = spawnSync("dslinter", ["--help"], { encoding: "utf8" });
116
150
  const out = `${onPath.stdout ?? ""}${onPath.stderr ?? ""}`;
117
151
  if (onPath.status === 0 && out.includes(SCANNER_VERSION_MARKER)) {
@@ -1,14 +1,25 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import {
4
+ CONFIG_FILE_NAMES,
5
+ DEFAULT_CONFIG_FILE_NAME,
6
+ } from "./paths.mjs";
3
7
 
4
- const CONFIG_NAMES = [".dslint.json", "dslint.json"];
8
+ /**
9
+ * @param {string} targetDir
10
+ * @returns {"laravel" | "default"}
11
+ */
12
+ export function detectInitLayout(targetDir) {
13
+ if (existsSync(join(targetDir, "resources", "js"))) return "laravel";
14
+ return "default";
15
+ }
5
16
 
6
17
  /**
7
18
  * @param {string} targetDir
8
19
  * @returns {string | null}
9
20
  */
10
21
  export function findDslintConfigPath(targetDir) {
11
- for (const name of CONFIG_NAMES) {
22
+ for (const name of CONFIG_FILE_NAMES) {
12
23
  const candidate = join(targetDir, name);
13
24
  if (existsSync(candidate)) return candidate;
14
25
  }
@@ -25,26 +36,102 @@ function existingPaths(targetDir, candidates) {
25
36
  }
26
37
 
27
38
  /**
39
+ * Prefer lowercase `resources/js/components` for playground_groups when both casings exist.
28
40
  * @param {string} targetDir
29
41
  * @param {"laravel" | "default"} layout
42
+ * @param {string[]} includeDirs
43
+ * @returns {string | undefined}
30
44
  */
45
+ function pickPlaygroundGroupPrefix(targetDir, layout, includeDirs) {
46
+ if (layout === "laravel") {
47
+ const lower = resolvePathOnDisk(targetDir, "resources/js/components");
48
+ const upper = resolvePathOnDisk(targetDir, "resources/js/Components");
49
+ if (lower) return lower;
50
+ if (upper) return upper;
51
+ }
52
+ return includeDirs[0];
53
+ }
54
+
55
+ /**
56
+ * @param {string} targetDir
57
+ * @param {"laravel" | "default"} layout
58
+ */
59
+ /**
60
+ * Resolve a repo-relative path using on-disk directory casing.
61
+ * @param {string} targetDir
62
+ * @param {string} relPath
63
+ * @returns {string | null}
64
+ */
65
+ export function resolvePathOnDisk(targetDir, relPath) {
66
+ const parts = relPath.split("/").filter(Boolean);
67
+ let current = targetDir;
68
+ const resolved = [];
69
+ for (const part of parts) {
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(current);
73
+ } catch {
74
+ return null;
75
+ }
76
+ const match = entries.find((entry) => entry.toLowerCase() === part.toLowerCase());
77
+ if (!match) return null;
78
+ resolved.push(match);
79
+ current = join(current, match);
80
+ }
81
+ try {
82
+ if (!statSync(current).isDirectory()) return null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ return resolved.join("/");
87
+ }
88
+
89
+ /**
90
+ * Pick the narrowest existing directory from ordered candidates (first match wins).
91
+ * @param {string} targetDir
92
+ * @param {string[]} candidates ordered narrow → broad
93
+ * @returns {string[]}
94
+ */
95
+ function narrowestIncludeDir(targetDir, candidates) {
96
+ for (const rel of candidates) {
97
+ const resolved = resolvePathOnDisk(targetDir, rel);
98
+ if (resolved) return [resolved];
99
+ }
100
+ return [];
101
+ }
102
+
103
+ /**
104
+ * @param {string} targetDir
105
+ * @param {"laravel" | "default"} layout
106
+ * @returns {string | undefined}
107
+ */
108
+ export function detectDefaultIncludeDir(targetDir, layout) {
109
+ const candidates =
110
+ layout === "laravel"
111
+ ? ["resources/js/components", "resources/js/Components", "resources/js"]
112
+ : ["src/components", "src/ui", "src"];
113
+ return narrowestIncludeDir(targetDir, candidates)[0];
114
+ }
115
+
31
116
  function buildStarterConfig(targetDir, layout) {
32
117
  const includeCandidates =
33
118
  layout === "laravel"
34
- ? ["resources/js/Components", "resources/js/components", "resources/js"]
119
+ ? ["resources/js/components", "resources/js/Components", "resources/js"]
35
120
  : ["src/components", "src/ui", "src"];
36
121
  const cssCandidates =
37
122
  layout === "laravel"
38
123
  ? ["resources/css/app.css", "src/index.css"]
39
124
  : ["src/index.css", "src/styles.css", "src/app.css", "app/globals.css"];
40
125
 
41
- const includeDirs = existingPaths(targetDir, includeCandidates);
126
+ const includeDirs = narrowestIncludeDir(targetDir, includeCandidates);
42
127
  const cssEntrypoints = existingPaths(targetDir, cssCandidates);
43
- const groupPrefix = includeDirs[0];
128
+ const groupPrefix = pickPlaygroundGroupPrefix(targetDir, layout, includeDirs);
44
129
 
45
130
  return {
46
131
  include_dirs: includeDirs,
47
132
  ignore_globs: [],
133
+ hidden_components: [],
134
+ hidden_paths: [],
48
135
  css_entrypoints: cssEntrypoints,
49
136
  ...(groupPrefix
50
137
  ? {
@@ -57,19 +144,51 @@ function buildStarterConfig(targetDir, layout) {
57
144
  }
58
145
 
59
146
  /**
60
- * @param {{ targetDir: string; layout: "laravel" | "default" }} opts
147
+ * @param {string} targetDir
148
+ * @returns {{ exists: boolean; path: string | null }}
149
+ */
150
+ export function assessDslintConfig(targetDir) {
151
+ const existing = findDslintConfigPath(resolve(targetDir));
152
+ return { exists: Boolean(existing), path: existing };
153
+ }
154
+
155
+ /**
156
+ * @param {{
157
+ * targetDir: string;
158
+ * layout: "laravel" | "default";
159
+ * includeDir?: string;
160
+ * }} opts
61
161
  * @returns {{ created: boolean; path: string; existed: boolean }}
62
162
  */
63
- export function ensureDslintConfig(opts) {
163
+ export function writeDslintConfig(opts) {
64
164
  const targetDir = resolve(opts.targetDir);
65
165
  const existing = findDslintConfigPath(targetDir);
66
166
  if (existing) {
67
167
  return { created: false, path: existing, existed: true };
68
168
  }
69
169
 
70
- const configPath = join(targetDir, ".dslint.json");
170
+ const configPath = join(targetDir, DEFAULT_CONFIG_FILE_NAME);
71
171
  mkdirSync(targetDir, { recursive: true });
72
172
  const payload = buildStarterConfig(targetDir, opts.layout);
173
+ if (opts.includeDir) {
174
+ payload.include_dirs = [opts.includeDir];
175
+ const groupPrefix = pickPlaygroundGroupPrefix(
176
+ targetDir,
177
+ opts.layout,
178
+ payload.include_dirs,
179
+ );
180
+ if (groupPrefix) {
181
+ payload.playground_groups = { components: [groupPrefix] };
182
+ }
183
+ }
73
184
  writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`);
74
185
  return { created: true, path: configPath, existed: false };
75
186
  }
187
+
188
+ /**
189
+ * @param {{ targetDir: string; layout: "laravel" | "default" }} opts
190
+ * @returns {{ created: boolean; path: string; existed: boolean }}
191
+ */
192
+ export function ensureDslintConfig(opts) {
193
+ return writeDslintConfig(opts);
194
+ }
@@ -15,14 +15,36 @@ describe("ensureDslintConfig", () => {
15
15
 
16
16
  const raw = readFileSync(result.path, "utf8");
17
17
  const parsed = JSON.parse(raw);
18
- expect(parsed.include_dirs).toContain("src/components");
18
+ expect(parsed.include_dirs).toEqual(["src/components"]);
19
19
  expect(parsed.css_entrypoints).toContain("src/index.css");
20
20
  expect(parsed.ignore_globs).toEqual([]);
21
21
  });
22
22
 
23
+ it("prefers narrowest laravel components dir", () => {
24
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scaffold-laravel-"));
25
+ mkdirSync(join(root, "resources", "js", "components"), { recursive: true });
26
+ mkdirSync(join(root, "resources", "js", "layouts"), { recursive: true });
27
+
28
+ const result = ensureDslintConfig({ targetDir: root, layout: "laravel" });
29
+ const parsed = JSON.parse(readFileSync(result.path, "utf8"));
30
+ expect(parsed.include_dirs).toEqual(["resources/js/components"]);
31
+ });
32
+
33
+ it("uses on-disk casing for laravel Components dir", () => {
34
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scaffold-laravel-case-"));
35
+ mkdirSync(join(root, "resources", "js", "Components"), { recursive: true });
36
+
37
+ const result = ensureDslintConfig({ targetDir: root, layout: "laravel" });
38
+ const parsed = JSON.parse(readFileSync(result.path, "utf8"));
39
+ expect(parsed.include_dirs).toEqual(["resources/js/Components"]);
40
+ expect(parsed.playground_groups.components).toEqual([
41
+ "resources/js/Components",
42
+ ]);
43
+ });
44
+
23
45
  it("does not overwrite an existing config", () => {
24
46
  const root = mkdtempSync(join(tmpdir(), "dslinter-scaffold-existing-"));
25
- const existing = join(root, ".dslint.json");
47
+ const existing = join(root, ".dslinter.json");
26
48
  writeFileSync(existing, "{\n \"ignore_globs\": [\"custom/**\"]\n}\n");
27
49
 
28
50
  const result = ensureDslintConfig({ targetDir: root, layout: "default" });
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { detectInitLayout } from "./scaffold-config.mjs";
4
+ import { envIs } from "./env.mjs";
5
+
6
+ const HOST_APP_CANDIDATES = [
7
+ "src/App.tsx",
8
+ "src/App.jsx",
9
+ "src/app.tsx",
10
+ "resources/js/app.tsx",
11
+ ];
12
+
13
+ /**
14
+ * True when the scan root already embeds DashboardLayout (e.g. demo app).
15
+ * @param {string} scanRoot
16
+ */
17
+ export function scanProjectHostsDashboard(scanRoot) {
18
+ const root = resolve(scanRoot);
19
+ for (const rel of HOST_APP_CANDIDATES) {
20
+ const p = join(root, rel);
21
+ if (!existsSync(p)) continue;
22
+ try {
23
+ const text = readFileSync(p, "utf8");
24
+ if (/DashboardLayout/.test(text) && /dslinter/.test(text)) {
25
+ return true;
26
+ }
27
+ } catch {
28
+ // ignore
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Prefer consumer Vite dev (host app) vs embed dashboard SPA.
36
+ * @param {string} scanRoot
37
+ */
38
+ export function shouldUseConsumerViteDev(scanRoot) {
39
+ if (envIs("USE_CONSUMER_VITE")) return true;
40
+ if (envIs("NO_CONSUMER_VITE")) return false;
41
+ if (scanProjectHostsDashboard(scanRoot)) return true;
42
+ if (detectInitLayout(scanRoot) === "laravel") return false;
43
+ return false;
44
+ }
@@ -0,0 +1,41 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ scanProjectHostsDashboard,
7
+ shouldUseConsumerViteDev,
8
+ } from "./scan-host.mjs";
9
+
10
+ describe("scanProjectHostsDashboard", () => {
11
+ it("detects DashboardLayout in src/App.tsx", () => {
12
+ const root = mkdtempSync(join(tmpdir(), "dslinter-host-"));
13
+ mkdirSync(join(root, "src"), { recursive: true });
14
+ writeFileSync(
15
+ join(root, "src", "App.tsx"),
16
+ `import { DashboardLayout } from "dslinter";\nexport default function App() { return <DashboardLayout />; }\n`,
17
+ );
18
+ expect(scanProjectHostsDashboard(root)).toBe(true);
19
+ });
20
+
21
+ it("returns false for laravel app entry", () => {
22
+ const root = mkdtempSync(join(tmpdir(), "dslinter-laravel-app-"));
23
+ mkdirSync(join(root, "resources", "js"), { recursive: true });
24
+ writeFileSync(
25
+ join(root, "resources", "js", "app.tsx"),
26
+ `import { createInertiaApp } from "@inertiajs/react";\n`,
27
+ );
28
+ expect(scanProjectHostsDashboard(root)).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("shouldUseConsumerViteDev", () => {
33
+ it("prefers embed for laravel layout", () => {
34
+ const root = mkdtempSync(join(tmpdir(), "dslinter-laravel-vite-"));
35
+ mkdirSync(join(root, "resources", "js"), { recursive: true });
36
+ const prev = process.env.DSLINTER_USE_CONSUMER_VITE;
37
+ delete process.env.DSLINTER_USE_CONSUMER_VITE;
38
+ expect(shouldUseConsumerViteDev(root)).toBe(false);
39
+ process.env.DSLINTER_USE_CONSUMER_VITE = prev;
40
+ });
41
+ });
@@ -0,0 +1,153 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import {
4
+ detectDefaultIncludeDir,
5
+ detectInitLayout,
6
+ findDslintConfigPath,
7
+ writeDslintConfig,
8
+ } from "./scaffold-config.mjs";
9
+ import { resolveProjectRoot } from "./resolve-project.mjs";
10
+ import { readEnv } from "./env.mjs";
11
+ import { createInterface } from "node:readline/promises";
12
+ import { stdin as input, stdout as output } from "node:process";
13
+ import { confirmYesNo, isInteractiveTTY } from "./prompt.mjs";
14
+ import { defaultReportPath } from "./project-root.mjs";
15
+
16
+ /**
17
+ * @typedef {"missing_config" | "missing_public"} SetupIssueKind
18
+ */
19
+
20
+ /**
21
+ * @typedef {{ kind: SetupIssueKind; label: string }} SetupIssue
22
+ */
23
+
24
+ /**
25
+ * @param {string} targetDir project / vite root
26
+ * @param {string} reportPath absolute report file path
27
+ * @returns {SetupIssue[]}
28
+ */
29
+ export function assessSetupReadiness(targetDir, reportPath) {
30
+ const root = resolve(targetDir);
31
+ const issues = [];
32
+
33
+ if (!findDslintConfigPath(root)) {
34
+ issues.push({ kind: "missing_config", label: ".dslinter.json" });
35
+ }
36
+
37
+ const publicDir = dirname(resolve(reportPath));
38
+ if (!existsSync(publicDir)) {
39
+ issues.push({ kind: "missing_public", label: "public/" });
40
+ }
41
+
42
+ return issues;
43
+ }
44
+
45
+ /**
46
+ * @param {string} reportPath
47
+ */
48
+ export function ensurePublicDir(reportPath) {
49
+ const publicDir = dirname(resolve(reportPath));
50
+ if (!existsSync(publicDir)) {
51
+ mkdirSync(publicDir, { recursive: true });
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {{
57
+ * targetDir: string;
58
+ * reportPath: string;
59
+ * yes?: boolean;
60
+ * interactive?: boolean;
61
+ * }} opts
62
+ * @returns {Promise<{ applied: string[]; skipped: boolean }>}
63
+ */
64
+ export async function ensureMinimalSetup(opts) {
65
+ const targetDir = resolve(opts.targetDir);
66
+ const reportPath = resolve(opts.reportPath);
67
+ const noScaffold = readEnv("NO_SCAFFOLD") === "1";
68
+
69
+ const issues = assessSetupReadiness(targetDir, reportPath);
70
+ if (!issues.length || noScaffold) {
71
+ if (issues.length && noScaffold) {
72
+ process.stderr.write(
73
+ `dslinter: setup incomplete (${issues.map((i) => i.label).join(", ")}). Set DSLINTER_NO_SCAFFOLD=0 or run with --yes to create.\n`,
74
+ );
75
+ }
76
+ return { applied: [], skipped: noScaffold && issues.length > 0 };
77
+ }
78
+
79
+ const ci = process.env.CI === "true" || process.env.CI === "1";
80
+ const autoYes = opts.yes === true || ci;
81
+ const interactive = opts.interactive ?? isInteractiveTTY();
82
+
83
+ let shouldApply = autoYes;
84
+ if (!shouldApply && interactive) {
85
+ process.stderr.write("dslinter: setup incomplete for live previews.\n");
86
+ for (const issue of issues) {
87
+ process.stderr.write(` Missing: ${issue.label}\n`);
88
+ }
89
+ shouldApply = await confirmYesNo("Create these files now?");
90
+ } else if (!shouldApply && !interactive) {
91
+ process.stderr.write(
92
+ `dslinter: setup incomplete (${issues.map((i) => i.label).join(", ")}). Run with --yes or use an interactive terminal.\n`,
93
+ );
94
+ return { applied: [], skipped: true };
95
+ }
96
+
97
+ if (!shouldApply) {
98
+ process.stderr.write(
99
+ "dslinter: continuing without scaffold — previews and governance may be limited.\n",
100
+ );
101
+ return { applied: [], skipped: true };
102
+ }
103
+
104
+ /** @type {string[]} */
105
+ const applied = [];
106
+
107
+ if (issues.some((i) => i.kind === "missing_config")) {
108
+ const layout = detectInitLayout(targetDir);
109
+ let includeDir = detectDefaultIncludeDir(targetDir, layout);
110
+ if (interactive && includeDir) {
111
+ const rl = createInterface({ input, output });
112
+ try {
113
+ const answer = (
114
+ await rl.question(`Components directory [${includeDir}]: `)
115
+ ).trim();
116
+ if (answer) includeDir = answer;
117
+ } finally {
118
+ rl.close();
119
+ }
120
+ }
121
+ const result = writeDslintConfig({
122
+ targetDir,
123
+ layout,
124
+ ...(includeDir ? { includeDir } : {}),
125
+ });
126
+ applied.push(result.path);
127
+ process.stderr.write(`dslinter: created ${result.path}\n`);
128
+ }
129
+
130
+ if (issues.some((i) => i.kind === "missing_public")) {
131
+ ensurePublicDir(reportPath);
132
+ applied.push(dirname(reportPath));
133
+ process.stderr.write(`dslinter: created ${dirname(reportPath)}/\n`);
134
+ }
135
+
136
+ return { applied, skipped: false };
137
+ }
138
+
139
+ /**
140
+ * Convenience: assess using default report path for a scan root.
141
+ * @param {string} scanPath
142
+ * @param {{ yes?: boolean }} [opts]
143
+ */
144
+ export async function ensureMinimalSetupForScan(scanPath, opts = {}) {
145
+ const scanAbs = resolve(scanPath);
146
+ const reportPath = defaultReportPath(scanAbs, null);
147
+ const targetDir = resolveProjectRoot(scanAbs);
148
+ return ensureMinimalSetup({
149
+ targetDir,
150
+ reportPath,
151
+ yes: opts.yes,
152
+ });
153
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { assessSetupReadiness, ensurePublicDir } from "./setup-readiness.mjs";
6
+
7
+ describe("assessSetupReadiness", () => {
8
+ it("reports missing config and public", () => {
9
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ready-"));
10
+ const reportPath = join(root, "public", "dslinter-report.json");
11
+ const issues = assessSetupReadiness(root, reportPath);
12
+ expect(issues.map((i) => i.kind)).toContain("missing_config");
13
+ expect(issues.map((i) => i.kind)).toContain("missing_public");
14
+ });
15
+
16
+ it("passes when config and public exist", () => {
17
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ready-ok-"));
18
+ mkdirSync(join(root, "public"), { recursive: true });
19
+ const reportPath = join(root, "public", "dslinter-report.json");
20
+ writeFileSync(join(root, ".dslinter.json"), "{}\n");
21
+ expect(assessSetupReadiness(root, reportPath)).toHaveLength(0);
22
+ });
23
+ });
24
+
25
+ describe("ensurePublicDir", () => {
26
+ it("creates public directory", () => {
27
+ const root = mkdtempSync(join(tmpdir(), "dslinter-public-"));
28
+ const reportPath = join(root, "public", "dslinter-report.json");
29
+ ensurePublicDir(reportPath);
30
+ expect(existsSync(join(root, "public"))).toBe(true);
31
+ });
32
+ });
@@ -1,25 +1,51 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
- import { defaultReportPath, findViteRoot, resolveViteBin } from "../lib/project-root.mjs";
3
+ import {
4
+ defaultReportPath,
5
+ findViteRoot,
6
+ resolveViteBin,
7
+ } from "../lib/project-root.mjs";
8
+ import { enrichPlaygroundsFromTs } from "../lib/enrich-playgrounds-from-ts.mjs";
9
+ import { ensureMinimalSetup } from "../lib/setup-readiness.mjs";
4
10
  import { runScannerSync } from "../lib/run-scanner.mjs";
5
11
 
6
12
  /**
7
13
  * @param {{
8
14
  * scanPath: string;
15
+ * projectRoot: string;
9
16
  * outputPath: string | null;
10
17
  * scannerArgs: string[];
18
+ * yes?: boolean;
11
19
  * }}
12
20
  */
13
- export function runBuildMode({ scanPath, outputPath, scannerArgs }) {
14
- const reportPath = defaultReportPath(scanPath, outputPath);
21
+ export async function runBuildMode({
22
+ scanPath,
23
+ projectRoot,
24
+ outputPath,
25
+ scannerArgs,
26
+ yes = false,
27
+ }) {
28
+ const scanAbs = resolve(scanPath);
29
+ const projectAbs = resolve(projectRoot);
30
+ const reportPath = defaultReportPath(scanAbs, outputPath);
31
+ await ensureMinimalSetup({
32
+ targetDir: projectAbs,
33
+ reportPath,
34
+ yes,
35
+ });
15
36
  const args = ["--report", ...scannerArgs];
16
37
  if (!args.some((a) => a === "--output" || a.startsWith("--output="))) {
17
38
  args.push("--output", reportPath);
18
39
  }
19
40
 
20
- const code = runScannerSync(args);
41
+ const code = runScannerSync(args, { projectRoot: projectAbs });
21
42
  if (code !== 0) process.exit(code);
22
43
 
44
+ await enrichPlaygroundsFromTs({
45
+ projectRoot: projectAbs,
46
+ reportPath,
47
+ });
48
+
23
49
  const viteRoot = findViteRoot(process.cwd());
24
50
  if (!viteRoot) {
25
51
  process.stderr.write(
@@ -33,13 +59,12 @@ export function runBuildMode({ scanPath, outputPath, scannerArgs }) {
33
59
  process.stderr.write(`dslinter: vite not installed in ${viteRoot}. Run npm install.\n`);
34
60
  process.exit(1);
35
61
  }
36
- const scanAbs = resolve(scanPath);
37
62
  const child = spawnSync(process.execPath, [viteBin, "build"], {
38
63
  cwd: viteRoot,
39
64
  stdio: "inherit",
40
65
  env: {
41
66
  ...process.env,
42
- DSLINT_SCAN_ROOT: scanAbs,
67
+ DSLINTER_SCAN_ROOT: scanAbs,
43
68
  },
44
69
  });
45
70
  process.exit(child.status === null ? 1 : child.status);