dslinter 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +54 -27
  3. package/bin/dslinter.mjs +26 -5
  4. package/bin/lib/config-hide-component.mjs +44 -0
  5. package/bin/lib/config-hide-component.test.mjs +33 -0
  6. package/bin/lib/constants.mjs +20 -0
  7. package/bin/lib/dev-banner.mjs +16 -51
  8. package/bin/lib/dev-banner.test.mjs +20 -18
  9. package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
  10. package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
  11. package/bin/lib/enrich-report-cli.mjs +14 -0
  12. package/bin/lib/env.mjs +20 -0
  13. package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
  14. package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
  15. package/bin/lib/parse-args.mjs +13 -1
  16. package/bin/lib/parse-args.test.mjs +7 -1
  17. package/bin/lib/paths.mjs +8 -0
  18. package/bin/lib/project-root.mjs +92 -24
  19. package/bin/lib/project-root.test.mjs +52 -0
  20. package/bin/lib/prompt.mjs +31 -0
  21. package/bin/lib/resolve-project.mjs +78 -0
  22. package/bin/lib/resolve-project.test.mjs +74 -0
  23. package/bin/lib/run-scanner.mjs +40 -6
  24. package/bin/lib/scaffold-config.mjs +163 -0
  25. package/bin/lib/scaffold-config.test.mjs +43 -0
  26. package/bin/lib/scan-host.mjs +44 -0
  27. package/bin/lib/scan-host.test.mjs +41 -0
  28. package/bin/lib/setup-readiness.mjs +153 -0
  29. package/bin/lib/setup-readiness.test.mjs +32 -0
  30. package/bin/modes/build.mjs +31 -6
  31. package/bin/modes/dev.mjs +56 -13
  32. package/bin/modes/init.mjs +35 -47
  33. package/bin/modes/init.test.mjs +16 -0
  34. package/bin/modes/mcp.mjs +49 -0
  35. package/bin/modes/report.mjs +29 -4
  36. package/bin/modes/watch.mjs +85 -0
  37. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
  39. package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
  40. package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
  41. package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
  42. package/dashboard-dist/index.html +2 -2
  43. package/index.cjs +53 -52
  44. package/index.d.ts +3 -0
  45. package/package.json +18 -12
  46. package/shared/env.ts +15 -0
  47. package/shared/paths.ts +8 -0
  48. package/shared/reportPath.test.ts +19 -0
  49. package/shared/reportPath.ts +12 -0
  50. package/shared/servePort.ts +16 -0
  51. package/src/components/ComponentInspectPane.tsx +67 -19
  52. package/src/components/ComponentPlaygroundPane.tsx +262 -113
  53. package/src/components/DashboardCommandPalette.tsx +6 -11
  54. package/src/components/GovernancePane.tsx +2 -2
  55. package/src/components/HideFromCatalogButton.tsx +44 -0
  56. package/src/components/OpenInEditorButton.tsx +36 -0
  57. package/src/components/PlaygroundA11yAndCode.tsx +53 -53
  58. package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
  59. package/src/components/PlaygroundControls.tsx +5 -11
  60. package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
  61. package/src/components/PlaygroundUsageCode.tsx +6 -4
  62. package/src/components/PlaygroundVariantMatrix.tsx +101 -34
  63. package/src/components/Section.tsx +5 -2
  64. package/src/components/Sidebar.tsx +131 -46
  65. package/src/components/TruncatedPath.tsx +44 -0
  66. package/src/components/controlApiTable.test.ts +29 -0
  67. package/src/components/controlApiTable.ts +3 -0
  68. package/src/components/playgroundUsageHighlight.ts +14 -3
  69. package/src/components/ui/badge.tsx +1 -1
  70. package/src/components/ui/table.tsx +2 -2
  71. package/src/dashboard/ComponentCatalog.tsx +16 -23
  72. package/src/dashboard/ComponentUsageDetails.tsx +6 -15
  73. package/src/dashboard/DashboardBody.tsx +0 -35
  74. package/src/dashboard/FindingsList.tsx +65 -55
  75. package/src/dashboard/ScannedTokenWall.tsx +3 -3
  76. package/src/dashboard/aggregate.test.ts +74 -0
  77. package/src/dashboard/aggregate.ts +145 -21
  78. package/src/dashboard/catalogVisibility.test.ts +93 -0
  79. package/src/dashboard/catalogVisibility.ts +108 -0
  80. package/src/dashboard/editorLink.test.ts +57 -0
  81. package/src/dashboard/editorLink.ts +71 -0
  82. package/src/dashboard/paths.test.ts +49 -0
  83. package/src/dashboard/paths.ts +51 -3
  84. package/src/dashboard/updateDslintConfig.ts +22 -0
  85. package/src/dashboard/useWorkspaceReport.ts +21 -17
  86. package/src/index.ts +26 -0
  87. package/src/mcp/agent-context.ts +148 -0
  88. package/src/mcp/agent-query.test.ts +89 -0
  89. package/src/mcp/agent-query.ts +373 -0
  90. package/src/mcp/config.ts +53 -0
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/normalize-paths.ts +65 -0
  93. package/src/mcp/report-cache.ts +209 -0
  94. package/src/mcp/rule-catalog.json +156 -0
  95. package/src/mcp/rule-catalog.ts +33 -0
  96. package/src/mcp/schemas.ts +54 -0
  97. package/src/mcp/server.test.ts +44 -0
  98. package/src/mcp/server.ts +343 -0
  99. package/src/mcp/start.ts +29 -0
  100. package/src/mcp/verify-loop.test.ts +49 -0
  101. package/src/mcp/verify-loop.ts +149 -0
  102. package/src/playground/appPreviewTheme.test.ts +148 -0
  103. package/src/playground/appPreviewTheme.ts +137 -0
  104. package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
  105. package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
  106. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
  107. package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
  108. package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
  109. package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
  110. package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
  111. package/src/playground/collectDefinedPlaygrounds.ts +68 -0
  112. package/src/playground/controls.ts +177 -0
  113. package/src/playground/createPlaygroundRegistry.ts +1 -1
  114. package/src/playground/definePlayground.tsx +88 -16
  115. package/src/playground/definePlaygroundFromKit.ts +17 -0
  116. package/src/playground/embedGlobKey.ts +8 -0
  117. package/src/playground/enrichKitControls.test.ts +25 -0
  118. package/src/playground/enrichKitControls.ts +197 -0
  119. package/src/playground/expandPlaygroundControls.test.ts +50 -0
  120. package/src/playground/expandPlaygroundControls.ts +97 -0
  121. package/src/playground/inferKitJsx.test.ts +77 -0
  122. package/src/playground/inferKitJsx.ts +165 -0
  123. package/src/playground/inferKitParams.test.ts +41 -0
  124. package/src/playground/inferKitParams.ts +113 -0
  125. package/src/playground/inferPropTypesFromTs.d.mts +47 -0
  126. package/src/playground/inferPropTypesFromTs.mjs +343 -0
  127. package/src/playground/inferPropTypesFromTs.test.ts +227 -0
  128. package/src/playground/inferPropTypesFromTs.ts +17 -0
  129. package/src/playground/mergePlaygroundEntries.test.ts +32 -0
  130. package/src/playground/mergePlaygroundEntries.ts +28 -0
  131. package/src/playground/playgroundJoin.test.ts +79 -19
  132. package/src/playground/playgroundJoin.ts +47 -22
  133. package/src/playground/playgroundModuleExport.test.ts +42 -0
  134. package/src/playground/playgroundModuleExport.ts +22 -0
  135. package/src/playground/playgroundSpecsKey.ts +8 -0
  136. package/src/playground/propCoerce.ts +91 -0
  137. package/src/playground/scanVariantA11y.test.ts +46 -0
  138. package/src/playground/scanVariantA11y.ts +107 -0
  139. package/src/playground/snippet.ts +83 -0
  140. package/src/playground/usePlaygroundFromReport.test.ts +18 -8
  141. package/src/playground/usePlaygroundFromReport.ts +3 -1
  142. package/src/report/a11yForModule.ts +2 -7
  143. package/src/report/a11yScoring.test.ts +24 -0
  144. package/src/report/a11yScoring.ts +17 -0
  145. package/src/report/index.ts +6 -0
  146. package/src/shell/DashboardLayout.tsx +71 -45
  147. package/src/shell/DashboardLayoutAuto.tsx +0 -4
  148. package/src/shell/hashRoute.test.ts +7 -15
  149. package/src/shell/hashRoute.ts +31 -31
  150. package/src/shell/useHashRoute.ts +38 -13
  151. package/src/styles/dashboard-theme.css +18 -7
  152. package/src/types/controls.ts +11 -0
  153. package/src/types/playground.ts +4 -0
  154. package/src/types/report.ts +32 -9
  155. package/templates/playground/buildRegistry.ts +1 -1
  156. package/templates/vite.dslinter.snippet.ts +15 -4
  157. package/vite/collectScanModules.test.ts +51 -3
  158. package/vite/collectScanModules.ts +85 -29
  159. package/vite/consumer.config.mjs +6 -3
  160. package/vite/consumerAlias.test.ts +47 -0
  161. package/vite/consumerAlias.ts +114 -0
  162. package/vite/embedTailwindSources.test.ts +74 -0
  163. package/vite/embedTailwindSources.ts +97 -0
  164. package/vite/loadConsumerAliases.test.ts +131 -0
  165. package/vite/loadConsumerAliases.ts +155 -0
  166. package/vite/openFileInEditor.mjs +196 -0
  167. package/vite/openFileInEditor.test.mjs +87 -0
  168. package/vite/plugin.resolve.test.ts +72 -0
  169. package/vite/plugin.ts +216 -19
  170. package/vite/reportPath.test.ts +19 -0
  171. package/vite/resolveWayfinderImport.ts +56 -0
  172. package/vite/shims/inertia-react.tsx +85 -0
  173. package/vite/shims/wayfinder-actions.ts +33 -0
  174. package/vite/shims/wayfinder-routes.ts +30 -0
  175. package/vite/shims/ziggy-js.ts +12 -0
  176. package/dashboard-dist/assets/DashboardLayoutAuto-BPPtPsYh.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-Dp3bAQxH.js +0 -1
  178. package/dashboard-dist/assets/index-DsjwnDdX.js +0 -206
  179. package/dashboard-dist/assets/index-jaCmZJlW.css +0 -1
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -0,0 +1,174 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
8
+ const reactTypesRoot = join(packageRoot, "node_modules/@types");
9
+ import {
10
+ classifyPropType,
11
+ createCheckerProgram,
12
+ extractFiniteStringUnion,
13
+ findComponentParamType,
14
+ inferPlaygroundPropMetadata,
15
+ } from "./infer-prop-types-from-ts.mjs";
16
+
17
+ /**
18
+ * @param {string} root
19
+ * @param {Record<string, string>} files
20
+ */
21
+ function writeProject(root, files) {
22
+ mkdirSync(root, { recursive: true });
23
+ writeFileSync(
24
+ join(root, "tsconfig.json"),
25
+ JSON.stringify(
26
+ {
27
+ compilerOptions: {
28
+ strict: true,
29
+ jsx: "react-jsx",
30
+ module: "ESNext",
31
+ moduleResolution: "bundler",
32
+ noEmit: true,
33
+ skipLibCheck: true,
34
+ typeRoots: [reactTypesRoot],
35
+ },
36
+ include: ["**/*.tsx"],
37
+ },
38
+ null,
39
+ 2,
40
+ ),
41
+ );
42
+ for (const [rel, content] of Object.entries(files)) {
43
+ const abs = join(root, rel);
44
+ mkdirSync(join(abs, ".."), { recursive: true });
45
+ writeFileSync(abs, content);
46
+ }
47
+ }
48
+
49
+ describe("infer-prop-types-from-ts", () => {
50
+ it("extracts finite string union literals", () => {
51
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ts-"));
52
+ try {
53
+ writeProject(root, {
54
+ "Union.tsx": `
55
+ export function Union({ mode }: { mode: "text" | "email" | "password" }) {
56
+ return <input type={mode} />;
57
+ }
58
+ `,
59
+ });
60
+ const bundle = createCheckerProgram(root);
61
+ expect(bundle).not.toBeNull();
62
+ const sf = bundle.program.getSourceFile(join(root, "Union.tsx"));
63
+ expect(sf).toBeDefined();
64
+ const paramType = findComponentParamType(bundle.checker, sf, "Union");
65
+ expect(paramType).toBeDefined();
66
+ const sym = bundle.checker.getPropertyOfType(paramType, "mode");
67
+ expect(sym).toBeDefined();
68
+ const propType = bundle.checker.getTypeOfSymbol(sym);
69
+ expect(extractFiniteStringUnion(bundle.checker, propType)).toEqual([
70
+ "email",
71
+ "password",
72
+ "text",
73
+ ]);
74
+ } finally {
75
+ rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it("rejects unions that include plain string", () => {
80
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ts-"));
81
+ try {
82
+ writeProject(root, {
83
+ "Wide.tsx": `
84
+ export function Wide({ label }: { label: string | "foo" }) {
85
+ return <span>{label}</span>;
86
+ }
87
+ `,
88
+ });
89
+ const bundle = createCheckerProgram(root);
90
+ const sf = bundle.program.getSourceFile(join(root, "Wide.tsx"));
91
+ const paramType = findComponentParamType(bundle.checker, sf, "Wide");
92
+ const sym = bundle.checker.getPropertyOfType(paramType, "label");
93
+ const propType = bundle.checker.getTypeOfSymbol(sym);
94
+ expect(extractFiniteStringUnion(bundle.checker, propType)).toBeNull();
95
+ } finally {
96
+ rmSync(root, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ it("finds param type for function + export { Name }", () => {
101
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ts-"));
102
+ try {
103
+ writeProject(root, {
104
+ "Input.tsx": `
105
+ function Input({ type }: { type?: "text" | "password" }) {
106
+ return <input type={type} />;
107
+ }
108
+ export { Input };
109
+ `,
110
+ });
111
+ const bundle = createCheckerProgram(root);
112
+ const sf = bundle.program.getSourceFile(join(root, "Input.tsx"));
113
+ expect(findComponentParamType(bundle.checker, sf, "Input")).toBeDefined();
114
+ } finally {
115
+ rmSync(root, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ it("infers type select options for ComponentProps input wrapper", () => {
120
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ts-"));
121
+ try {
122
+ writeProject(root, {
123
+ "input.tsx": `
124
+ import * as React from "react";
125
+
126
+ function Input({ className, type, placeholder, ...props }: React.ComponentProps<"input">) {
127
+ return <input type={type} placeholder={placeholder} className={className} {...props} />;
128
+ }
129
+
130
+ export { Input };
131
+ `,
132
+ });
133
+ const bundle = createCheckerProgram(root);
134
+ const meta = inferPlaygroundPropMetadata(
135
+ bundle.checker,
136
+ bundle.program,
137
+ root,
138
+ "input.tsx",
139
+ "Input",
140
+ ["className", "type", "placeholder"],
141
+ );
142
+ expect(meta.declared_prop_kinds.type).toBe("string");
143
+ expect(meta.declared_prop_options.type).toBeDefined();
144
+ expect(meta.declared_prop_options.type.length).toBeGreaterThanOrEqual(2);
145
+ expect(meta.declared_prop_options.type).toContain("text");
146
+ expect(meta.declared_prop_options.type).toContain("password");
147
+ expect(meta.declared_prop_defaults.type).toBe("text");
148
+ } finally {
149
+ rmSync(root, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ it("classifies boolean props", () => {
154
+ const root = mkdtempSync(join(tmpdir(), "dslinter-ts-"));
155
+ try {
156
+ writeProject(root, {
157
+ "Toggle.tsx": `
158
+ export function Toggle({ disabled }: { disabled?: boolean }) {
159
+ return <button disabled={disabled} />;
160
+ }
161
+ `,
162
+ });
163
+ const bundle = createCheckerProgram(root);
164
+ const sf = bundle.program.getSourceFile(join(root, "Toggle.tsx"));
165
+ const paramType = findComponentParamType(bundle.checker, sf, "Toggle");
166
+ const sym = bundle.checker.getPropertyOfType(paramType, "disabled");
167
+ expect(classifyPropType(bundle.checker, bundle.checker.getTypeOfSymbol(sym))).toBe(
168
+ "boolean",
169
+ );
170
+ } finally {
171
+ rmSync(root, { recursive: true, force: true });
172
+ }
173
+ });
174
+ });
@@ -2,7 +2,10 @@
2
2
  * Parse dslinter CLI argv into mode + scanner args (no subprocess).
3
3
  */
4
4
 
5
+ import { resolveScanAndProjectRoots } from "./resolve-project.mjs";
6
+
5
7
  const MODE_FLAGS = new Set(["--report", "--watch", "--build"]);
8
+ const YES_FLAGS = new Set(["--yes", "-y"]);
6
9
 
7
10
  /** @param {string | undefined} raw */
8
11
  function parseServePort(raw) {
@@ -17,16 +20,21 @@ function parseServePort(raw) {
17
20
  * mode: "dev" | "report" | "watch" | "build" | "scanner";
18
21
  * scannerArgs: string[];
19
22
  * scanPath: string;
23
+ * projectRoot: string;
20
24
  * outputPath: string | null;
21
25
  * servePort: number | null;
26
+ * yes: boolean;
27
+ * explicitScanPath: string | null;
22
28
  * }}
23
29
  */
24
30
  export function parseDslinterArgs(argv) {
25
31
  const modes = [];
26
32
  const scannerArgs = [];
33
+ let yes = false;
27
34
 
28
35
  for (const arg of argv) {
29
36
  if (MODE_FLAGS.has(arg)) modes.push(arg.slice(2));
37
+ else if (YES_FLAGS.has(arg)) yes = true;
30
38
  else scannerArgs.push(arg);
31
39
  }
32
40
 
@@ -67,13 +75,17 @@ export function parseDslinterArgs(argv) {
67
75
  mode = "dev";
68
76
  }
69
77
 
70
- const scanPath = positional ?? ".";
78
+ const explicitScanPath = positional ?? null;
79
+ const { scanPath, projectRoot } = resolveScanAndProjectRoots(explicitScanPath);
71
80
 
72
81
  return {
73
82
  mode,
74
83
  scannerArgs,
75
84
  scanPath,
85
+ projectRoot,
76
86
  outputPath,
77
87
  servePort,
88
+ yes,
89
+ explicitScanPath,
78
90
  };
79
91
  }
@@ -24,9 +24,15 @@ describe("parseDslinterArgs", () => {
24
24
  const p = parseDslinterArgs(["--report", "demo", "-p", "--json"]);
25
25
  expect(p.mode).toBe("report");
26
26
  expect(p.scannerArgs).toEqual(["demo", "-p", "--json"]);
27
- expect(p.scanPath).toBe("demo");
27
+ expect(p.scanPath).toContain("demo");
28
28
  });
29
29
 
30
+ it("parses --yes flag", () => {
31
+ const p = parseDslinterArgs(["--yes"]);
32
+ expect(p.yes).toBe(true);
33
+ });
34
+
35
+
30
36
  it("uses scanner mode for --serve only", () => {
31
37
  expect(parseDslinterArgs([".", "--serve", "7878"]).mode).toBe("scanner");
32
38
  });
@@ -0,0 +1,8 @@
1
+ export const REPORT_FILE_NAME = "dslinter-report.json";
2
+ export const REPORT_URL_PATH = "/dslinter-report.json";
3
+
4
+ export const CONFIG_FILE_NAMES = [".dslinter.json", "dslinter.json"];
5
+
6
+ export const DEFAULT_CONFIG_FILE_NAME = ".dslinter.json";
7
+
8
+ export const IGNORE_FILE_NAME = ".dslinterignore";
@@ -1,8 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { resolveServePort } from "./constants.mjs";
7
+ import { readEnv } from "./env.mjs";
8
+ import { REPORT_FILE_NAME } from "./paths.mjs";
6
9
 
7
10
  const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
8
11
 
@@ -27,12 +30,26 @@ function maxMtimeInDir(dir, latest = 0) {
27
30
  return latest;
28
31
  }
29
32
 
33
+ /**
34
+ * @param {string} [root]
35
+ * @returns {boolean} true when embed SPA sources exist (monorepo / git checkout).
36
+ */
37
+ export function hasEmbedDashboard(root = packageRoot) {
38
+ return existsSync(join(root, "index.html"));
39
+ }
40
+
30
41
  /**
31
42
  * Rebuild `dashboard-dist/` when embed sources are newer than the bundle (or dist is missing).
43
+ * Published npm installs omit embed sources; use prebuilt `dashboard-dist/` only.
32
44
  * @param {string} root
33
45
  */
34
46
  export function ensureDashboardBuilt(root = packageRoot) {
35
- const distIndex = join(root, "dashboard-dist", "index.html");
47
+ const distDir = join(root, "dashboard-dist");
48
+ if (!hasEmbedDashboard(root)) {
49
+ return dashboardDirIfReady(distDir);
50
+ }
51
+
52
+ const distIndex = join(distDir, "index.html");
36
53
  const force =
37
54
  process.env.DSLINTER_REBUILD_DASHBOARD === "1" ||
38
55
  process.env.DSLINTER_REBUILD_DASHBOARD?.toLowerCase() === "true";
@@ -40,12 +57,9 @@ export function ensureDashboardBuilt(root = packageRoot) {
40
57
  let needsBuild = force || !existsSync(distIndex);
41
58
  if (!needsBuild) {
42
59
  const distMtime = statSync(distIndex).mtimeMs;
43
- for (const sub of ["src", "embed"]) {
44
- const dir = join(root, sub);
45
- if (existsSync(dir) && maxMtimeInDir(dir) > distMtime) {
46
- needsBuild = true;
47
- break;
48
- }
60
+ const embedDir = join(root, "embed");
61
+ if (existsSync(embedDir) && maxMtimeInDir(embedDir) > distMtime) {
62
+ needsBuild = true;
49
63
  }
50
64
  const configPath = join(root, "vite.config.ts");
51
65
  if (existsSync(configPath) && statSync(configPath).mtimeMs > distMtime) {
@@ -53,7 +67,7 @@ export function ensureDashboardBuilt(root = packageRoot) {
53
67
  }
54
68
  }
55
69
 
56
- if (!needsBuild) return dashboardDirIfReady(join(root, "dashboard-dist"));
70
+ if (!needsBuild) return dashboardDirIfReady(distDir);
57
71
 
58
72
  process.stderr.write("[dslinter] Building dashboard bundle (dashboard-dist)…\n");
59
73
  const result = spawnSync("npm", ["run", "build:dashboard"], {
@@ -64,12 +78,7 @@ export function ensureDashboardBuilt(root = packageRoot) {
64
78
  if (result.status !== 0) {
65
79
  throw new Error("dslinter: dashboard build failed");
66
80
  }
67
- return dashboardDirIfReady(join(root, "dashboard-dist"));
68
- }
69
-
70
- /** @returns {boolean} */
71
- export function hasEmbedDashboard() {
72
- return existsSync(join(packageRoot, "index.html"));
81
+ return dashboardDirIfReady(distDir);
73
82
  }
74
83
 
75
84
  const VITE_CONFIG_NAMES = ["vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.cjs"];
@@ -97,19 +106,78 @@ export function findViteRoot(startDir) {
97
106
  */
98
107
  export function defaultReportPath(scanPath, outputFlag) {
99
108
  if (outputFlag) return resolve(outputFlag);
100
- return resolve(scanPath, "public", "dslint-report.json");
109
+ const scanAbs = resolve(scanPath);
110
+ const viteRoot = findViteRoot(scanAbs);
111
+ if (viteRoot && resolve(viteRoot) !== scanAbs) {
112
+ return resolve(viteRoot, "public", REPORT_FILE_NAME);
113
+ }
114
+ return resolve(scanAbs, "public", REPORT_FILE_NAME);
115
+ }
116
+
117
+ /**
118
+ * Log when scan was promoted from a subdirectory to the project root.
119
+ * @param {{ promoted: boolean; originalPath?: string; scanPath: string }} info
120
+ * @deprecated Subdirectory scans are no longer promoted; use {@link logScanScopeHint}.
121
+ */
122
+ export function logScanRootPromotion(info) {
123
+ if (!info.promoted || !info.originalPath) return;
124
+ process.stderr.write(
125
+ `dslinter: using project root ${info.scanPath} (was ${info.originalPath}).\n`,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Log when scanning a subdirectory while config/report use the project root.
131
+ * @param {{
132
+ * scanPath: string;
133
+ * projectRoot: string;
134
+ * explicitScanPath: string | null;
135
+ * }} info
136
+ */
137
+ export function logScanScopeHint(info) {
138
+ const scanAbs = resolve(info.scanPath);
139
+ const projectAbs = resolve(info.projectRoot);
140
+ if (scanAbs === projectAbs) return;
141
+
142
+ const implicit =
143
+ info.explicitScanPath == null ||
144
+ info.explicitScanPath === "" ||
145
+ info.explicitScanPath === ".";
146
+ if (!implicit) return;
147
+
148
+ const rel =
149
+ relative(projectAbs, scanAbs).replace(/\\/g, "/") || scanAbs;
150
+ process.stderr.write(
151
+ `dslinter: scanning ${rel} (project root: ${projectAbs}). Run from repo root for a full-repo scan.\n`,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * @deprecated Use {@link logScanRootPromotion} after {@link promoteScanToProjectRoot}.
157
+ * @param {string} scanPath absolute or relative scan path
158
+ * @param {{ outputPath?: string | null }} [opts]
159
+ */
160
+ export function warnIfSubdirectoryScan(scanPath, opts = {}) {
161
+ const scanAbs = resolve(scanPath);
162
+ const viteRoot = findViteRoot(scanAbs);
163
+ if (!viteRoot) return;
164
+ const viteAbs = resolve(viteRoot);
165
+ if (scanAbs === viteAbs) return;
166
+
167
+ process.stderr.write(
168
+ "dslinter: using project root for scan (subdirectory paths shorten playground rel_path).\n",
169
+ );
170
+ if (!opts.outputPath) {
171
+ const reportAt = defaultReportPath(viteAbs, null);
172
+ process.stderr.write(`dslinter: report → ${reportAt}\n`);
173
+ }
101
174
  }
102
175
 
103
176
  /**
104
177
  * @returns {number}
105
178
  */
106
179
  export function defaultServePort() {
107
- const fromEnv = process.env.DSLINT_SERVE_PORT?.trim();
108
- if (fromEnv) {
109
- const n = Number.parseInt(fromEnv, 10);
110
- if (Number.isFinite(n) && n > 0 && n <= 65535) return n;
111
- }
112
- return 7878;
180
+ return resolveServePort();
113
181
  }
114
182
 
115
183
  /**
@@ -131,7 +199,7 @@ function dashboardDirIfReady(dir) {
131
199
  *
132
200
  * Resolution order:
133
201
  * 1. Skip when `DSLINTER_NO_BUNDLED_DASHBOARD=1`
134
- * 2. `DSLINT_DASHBOARD_STATIC` — absolute or cwd-relative (temp/gitignored dirs ok)
202
+ * 2. `DSLINTER_DASHBOARD_STATIC` — absolute or cwd-relative (temp/gitignored dirs ok)
135
203
  * 3. `dashboard-dist/` next to the installed `dslinter` package
136
204
  *
137
205
  * @returns {string | null}
@@ -140,7 +208,7 @@ export function resolveBundledDashboardDir() {
140
208
  const optOut = process.env.DSLINTER_NO_BUNDLED_DASHBOARD?.trim();
141
209
  if (optOut === "1" || optOut?.toLowerCase() === "true") return null;
142
210
 
143
- const fromEnv = process.env.DSLINT_DASHBOARD_STATIC?.trim();
211
+ const fromEnv = readEnv("DASHBOARD_STATIC");
144
212
  if (fromEnv) {
145
213
  const dir = isAbsolute(fromEnv) ? normalize(fromEnv) : resolve(process.cwd(), fromEnv);
146
214
  return dashboardDirIfReady(dir);
@@ -0,0 +1,52 @@
1
+ import { 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 {
6
+ defaultReportPath,
7
+ ensureDashboardBuilt,
8
+ hasEmbedDashboard,
9
+ } from "./project-root.mjs";
10
+
11
+ describe("ensureDashboardBuilt (published install layout)", () => {
12
+ it("returns prebuilt dashboard-dist without spawning build when embed sources are absent", () => {
13
+ const root = mkdtempSync(join(tmpdir(), "dslinter-published-"));
14
+ mkdirSync(join(root, "dashboard-dist"), { recursive: true });
15
+ writeFileSync(join(root, "dashboard-dist", "index.html"), "<!doctype html>");
16
+ mkdirSync(join(root, "src"), { recursive: true });
17
+ writeFileSync(join(root, "src", "index.ts"), "export {};\n");
18
+
19
+ expect(hasEmbedDashboard(root)).toBe(false);
20
+
21
+ const dist = ensureDashboardBuilt(root);
22
+ expect(dist).toBeTruthy();
23
+ expect(dist).toContain("dashboard-dist");
24
+ });
25
+ });
26
+
27
+ describe("defaultReportPath", () => {
28
+ it("writes to project public/ when scan path is a subdirectory of vite root", () => {
29
+ const root = mkdtempSync(join(tmpdir(), "dslinter-laravel-"));
30
+ const components = join(root, "resources", "js", "Components");
31
+ mkdirSync(components, { recursive: true });
32
+ writeFileSync(join(root, "vite.config.js"), "export default {};\n");
33
+
34
+ const reportPath = defaultReportPath(components, null);
35
+ expect(reportPath).toBe(join(root, "public", "dslinter-report.json"));
36
+ });
37
+
38
+ it("uses scan path public/ when scan path is the vite root", () => {
39
+ const root = mkdtempSync(join(tmpdir(), "dslinter-vite-root-"));
40
+ mkdirSync(join(root, "src"), { recursive: true });
41
+ writeFileSync(join(root, "vite.config.ts"), "export default {};\n");
42
+
43
+ const reportPath = defaultReportPath(root, null);
44
+ expect(reportPath).toBe(join(root, "public", "dslinter-report.json"));
45
+ });
46
+
47
+ it("honors explicit --output", () => {
48
+ const root = mkdtempSync(join(tmpdir(), "dslinter-out-"));
49
+ const custom = join(root, "custom-report.json");
50
+ expect(defaultReportPath(root, custom)).toBe(custom);
51
+ });
52
+ });
@@ -0,0 +1,31 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { envIs } from "./env.mjs";
4
+
5
+ /**
6
+ * @returns {boolean}
7
+ */
8
+ export function isInteractiveTTY() {
9
+ const ci = process.env.CI === "true" || process.env.CI === "1";
10
+ if (ci) return false;
11
+ if (envIs("NO_PROMPT")) return false;
12
+ return Boolean(input.isTTY && output.isTTY);
13
+ }
14
+
15
+ /**
16
+ * @param {string} question
17
+ * @param {{ defaultYes?: boolean }} [opts]
18
+ * @returns {Promise<boolean>}
19
+ */
20
+ export async function confirmYesNo(question, opts = {}) {
21
+ const defaultYes = opts.defaultYes !== false;
22
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
23
+ const rl = createInterface({ input, output });
24
+ try {
25
+ const answer = (await rl.question(`${question} ${hint} `)).trim().toLowerCase();
26
+ if (!answer) return defaultYes;
27
+ return answer === "y" || answer === "yes";
28
+ } finally {
29
+ rl.close();
30
+ }
31
+ }
@@ -0,0 +1,78 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { findViteRoot } from "./project-root.mjs";
4
+
5
+ /**
6
+ * Walk up from `cwd` and return the best project root for scanning.
7
+ * 1. Nearest ancestor with vite.config.*
8
+ * 2. Else nearest ancestor with resources/js/ (Laravel)
9
+ * 3. Else cwd
10
+ * @param {string} [cwd]
11
+ * @returns {string} absolute path
12
+ */
13
+ export function resolveProjectRoot(cwd = process.cwd()) {
14
+ let dir = resolve(cwd);
15
+ let laravelCandidate = null;
16
+
17
+ for (;;) {
18
+ const viteRoot = findViteRoot(dir);
19
+ if (viteRoot) return resolve(viteRoot);
20
+
21
+ if (!laravelCandidate && existsSync(join(dir, "resources", "js"))) {
22
+ laravelCandidate = dir;
23
+ }
24
+
25
+ const parent = dirname(dir);
26
+ if (parent === dir) break;
27
+ dir = parent;
28
+ }
29
+
30
+ return laravelCandidate ?? resolve(cwd);
31
+ }
32
+
33
+ /**
34
+ * Resolve the scan path (file-walk boundary): explicit positional relative to cwd;
35
+ * otherwise cwd. `"."` is literal cwd, not project root.
36
+ * @param {string | null | undefined} explicitPath user positional or null for default
37
+ * @param {string} [cwd]
38
+ * @returns {string} absolute path
39
+ */
40
+ export function resolveScanPath(explicitPath, cwd = process.cwd()) {
41
+ if (explicitPath != null && explicitPath !== "") {
42
+ return resolve(cwd, explicitPath);
43
+ }
44
+ return resolve(cwd);
45
+ }
46
+
47
+ /**
48
+ * Scan root (walk boundary) and project root (config, CSS, report parent).
49
+ * @param {string | null | undefined} explicitPath
50
+ * @param {string} [cwd]
51
+ * @returns {{ scanPath: string; projectRoot: string }}
52
+ */
53
+ export function resolveScanAndProjectRoots(explicitPath, cwd = process.cwd()) {
54
+ const scanPath = resolveScanPath(explicitPath, cwd);
55
+ const projectRoot = resolveProjectRoot(cwd);
56
+ return { scanPath, projectRoot };
57
+ }
58
+
59
+ /**
60
+ * Replace or insert the scanner positional path in argv.
61
+ * @param {string[]} scannerArgs
62
+ * @param {string} scanPath absolute scan path
63
+ * @returns {string[]}
64
+ */
65
+ export function withScannerScanPath(scannerArgs, scanPath) {
66
+ const out = [];
67
+ let replaced = false;
68
+ for (const arg of scannerArgs) {
69
+ if (!replaced && !arg.startsWith("-")) {
70
+ out.push(scanPath);
71
+ replaced = true;
72
+ continue;
73
+ }
74
+ out.push(arg);
75
+ }
76
+ if (!replaced) out.unshift(scanPath);
77
+ return out;
78
+ }
@@ -0,0 +1,74 @@
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
+ resolveProjectRoot,
7
+ resolveScanAndProjectRoots,
8
+ resolveScanPath,
9
+ } from "./resolve-project.mjs";
10
+
11
+ describe("resolveProjectRoot", () => {
12
+ it("prefers vite root over laravel layout when both exist", () => {
13
+ const root = mkdtempSync(join(tmpdir(), "dslinter-resolve-vite-"));
14
+ mkdirSync(join(root, "resources", "js"), { recursive: true });
15
+ writeFileSync(join(root, "vite.config.js"), "export default {};\n");
16
+ expect(resolveProjectRoot(root)).toBe(root);
17
+ });
18
+
19
+ it("uses resources/js ancestor when no vite config", () => {
20
+ const root = mkdtempSync(join(tmpdir(), "dslinter-resolve-laravel-"));
21
+ mkdirSync(join(root, "resources", "js", "Components"), { recursive: true });
22
+ const sub = join(root, "resources", "js", "Components");
23
+ expect(resolveProjectRoot(sub)).toBe(root);
24
+ });
25
+ });
26
+
27
+ describe("resolveScanPath", () => {
28
+ it("defaults to cwd when no explicit path", () => {
29
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-default-"));
30
+ const sub = join(root, "src", "components");
31
+ mkdirSync(sub, { recursive: true });
32
+ expect(resolveScanPath(null, sub)).toBe(sub);
33
+ });
34
+
35
+ it("treats '.' as literal cwd", () => {
36
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-dot-"));
37
+ const sub = join(root, "resources", "js", "components");
38
+ mkdirSync(sub, { recursive: true });
39
+ writeFileSync(join(root, "vite.config.js"), "export default {};\n");
40
+ expect(resolveScanPath(".", sub)).toBe(sub);
41
+ expect(resolveScanPath(".", sub)).not.toBe(root);
42
+ });
43
+
44
+ it("honors explicit positional path", () => {
45
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-explicit-"));
46
+ const demo = join(root, "demo");
47
+ mkdirSync(demo, { recursive: true });
48
+ expect(resolveScanPath("demo", root)).toBe(demo);
49
+ });
50
+ });
51
+
52
+ describe("resolveScanAndProjectRoots", () => {
53
+ it("keeps scan at cwd and project at vite root from subdirectory", () => {
54
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-project-"));
55
+ const components = join(root, "resources", "js", "components");
56
+ mkdirSync(components, { recursive: true });
57
+ writeFileSync(join(root, "vite.config.js"), "export default {};\n");
58
+
59
+ const result = resolveScanAndProjectRoots(null, components);
60
+ expect(result.scanPath).toBe(components);
61
+ expect(result.projectRoot).toBe(root);
62
+ });
63
+
64
+ it("uses cwd for '.' while project root is vite root", () => {
65
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-dot-project-"));
66
+ const components = join(root, "resources", "js", "components");
67
+ mkdirSync(components, { recursive: true });
68
+ writeFileSync(join(root, "vite.config.js"), "export default {};\n");
69
+
70
+ const result = resolveScanAndProjectRoots(".", components);
71
+ expect(result.scanPath).toBe(components);
72
+ expect(result.projectRoot).toBe(root);
73
+ });
74
+ });