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
@@ -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
1
  import { existsSync, mkdirSync, 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
  }
@@ -24,27 +35,72 @@ function existingPaths(targetDir, candidates) {
24
35
  return candidates.filter((rel) => existsSync(join(targetDir, rel)));
25
36
  }
26
37
 
38
+ /**
39
+ * Prefer lowercase `resources/js/components` for playground_groups when both casings exist.
40
+ * @param {string} targetDir
41
+ * @param {"laravel" | "default"} layout
42
+ * @param {string[]} includeDirs
43
+ * @returns {string | undefined}
44
+ */
45
+ function pickPlaygroundGroupPrefix(targetDir, layout, includeDirs) {
46
+ if (layout === "laravel") {
47
+ const lower = "resources/js/components";
48
+ const upper = "resources/js/Components";
49
+ if (existsSync(join(targetDir, lower))) return lower;
50
+ if (existsSync(join(targetDir, upper))) return upper;
51
+ }
52
+ return includeDirs[0];
53
+ }
54
+
27
55
  /**
28
56
  * @param {string} targetDir
29
57
  * @param {"laravel" | "default"} layout
30
58
  */
59
+ /**
60
+ * Pick the narrowest existing directory from ordered candidates (first match wins).
61
+ * @param {string} targetDir
62
+ * @param {string[]} candidates ordered narrow → broad
63
+ * @returns {string[]}
64
+ */
65
+ function narrowestIncludeDir(targetDir, candidates) {
66
+ for (const rel of candidates) {
67
+ if (existsSync(join(targetDir, rel))) return [rel];
68
+ }
69
+ return [];
70
+ }
71
+
72
+ /**
73
+ * @param {string} targetDir
74
+ * @param {"laravel" | "default"} layout
75
+ * @returns {string | undefined}
76
+ */
77
+ export function detectDefaultIncludeDir(targetDir, layout) {
78
+ const candidates =
79
+ layout === "laravel"
80
+ ? ["resources/js/components", "resources/js/Components", "resources/js"]
81
+ : ["src/components", "src/ui", "src"];
82
+ return narrowestIncludeDir(targetDir, candidates)[0];
83
+ }
84
+
31
85
  function buildStarterConfig(targetDir, layout) {
32
86
  const includeCandidates =
33
87
  layout === "laravel"
34
- ? ["resources/js/Components", "resources/js/components", "resources/js"]
88
+ ? ["resources/js/components", "resources/js/Components", "resources/js"]
35
89
  : ["src/components", "src/ui", "src"];
36
90
  const cssCandidates =
37
91
  layout === "laravel"
38
92
  ? ["resources/css/app.css", "src/index.css"]
39
93
  : ["src/index.css", "src/styles.css", "src/app.css", "app/globals.css"];
40
94
 
41
- const includeDirs = existingPaths(targetDir, includeCandidates);
95
+ const includeDirs = narrowestIncludeDir(targetDir, includeCandidates);
42
96
  const cssEntrypoints = existingPaths(targetDir, cssCandidates);
43
- const groupPrefix = includeDirs[0];
97
+ const groupPrefix = pickPlaygroundGroupPrefix(targetDir, layout, includeDirs);
44
98
 
45
99
  return {
46
100
  include_dirs: includeDirs,
47
101
  ignore_globs: [],
102
+ hidden_components: [],
103
+ hidden_paths: [],
48
104
  css_entrypoints: cssEntrypoints,
49
105
  ...(groupPrefix
50
106
  ? {
@@ -57,19 +113,51 @@ function buildStarterConfig(targetDir, layout) {
57
113
  }
58
114
 
59
115
  /**
60
- * @param {{ targetDir: string; layout: "laravel" | "default" }} opts
116
+ * @param {string} targetDir
117
+ * @returns {{ exists: boolean; path: string | null }}
118
+ */
119
+ export function assessDslintConfig(targetDir) {
120
+ const existing = findDslintConfigPath(resolve(targetDir));
121
+ return { exists: Boolean(existing), path: existing };
122
+ }
123
+
124
+ /**
125
+ * @param {{
126
+ * targetDir: string;
127
+ * layout: "laravel" | "default";
128
+ * includeDir?: string;
129
+ * }} opts
61
130
  * @returns {{ created: boolean; path: string; existed: boolean }}
62
131
  */
63
- export function ensureDslintConfig(opts) {
132
+ export function writeDslintConfig(opts) {
64
133
  const targetDir = resolve(opts.targetDir);
65
134
  const existing = findDslintConfigPath(targetDir);
66
135
  if (existing) {
67
136
  return { created: false, path: existing, existed: true };
68
137
  }
69
138
 
70
- const configPath = join(targetDir, ".dslint.json");
139
+ const configPath = join(targetDir, DEFAULT_CONFIG_FILE_NAME);
71
140
  mkdirSync(targetDir, { recursive: true });
72
141
  const payload = buildStarterConfig(targetDir, opts.layout);
142
+ if (opts.includeDir) {
143
+ payload.include_dirs = [opts.includeDir];
144
+ const groupPrefix = pickPlaygroundGroupPrefix(
145
+ targetDir,
146
+ opts.layout,
147
+ payload.include_dirs,
148
+ );
149
+ if (groupPrefix) {
150
+ payload.playground_groups = { components: [groupPrefix] };
151
+ }
152
+ }
73
153
  writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`);
74
154
  return { created: true, path: configPath, existed: false };
75
155
  }
156
+
157
+ /**
158
+ * @param {{ targetDir: string; layout: "laravel" | "default" }} opts
159
+ * @returns {{ created: boolean; path: string; existed: boolean }}
160
+ */
161
+ export function ensureDslintConfig(opts) {
162
+ return writeDslintConfig(opts);
163
+ }
@@ -15,14 +15,24 @@ 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
+
23
33
  it("does not overwrite an existing config", () => {
24
34
  const root = mkdtempSync(join(tmpdir(), "dslinter-scaffold-existing-"));
25
- const existing = join(root, ".dslint.json");
35
+ const existing = join(root, ".dslinter.json");
26
36
  writeFileSync(existing, "{\n \"ignore_globs\": [\"custom/**\"]\n}\n");
27
37
 
28
38
  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);