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
@@ -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
 
@@ -103,19 +106,78 @@ export function findViteRoot(startDir) {
103
106
  */
104
107
  export function defaultReportPath(scanPath, outputFlag) {
105
108
  if (outputFlag) return resolve(outputFlag);
106
- 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
+ }
107
174
  }
108
175
 
109
176
  /**
110
177
  * @returns {number}
111
178
  */
112
179
  export function defaultServePort() {
113
- const fromEnv = process.env.DSLINT_SERVE_PORT?.trim();
114
- if (fromEnv) {
115
- const n = Number.parseInt(fromEnv, 10);
116
- if (Number.isFinite(n) && n > 0 && n <= 65535) return n;
117
- }
118
- return 7878;
180
+ return resolveServePort();
119
181
  }
120
182
 
121
183
  /**
@@ -137,7 +199,7 @@ function dashboardDirIfReady(dir) {
137
199
  *
138
200
  * Resolution order:
139
201
  * 1. Skip when `DSLINTER_NO_BUNDLED_DASHBOARD=1`
140
- * 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)
141
203
  * 3. `dashboard-dist/` next to the installed `dslinter` package
142
204
  *
143
205
  * @returns {string | null}
@@ -146,7 +208,7 @@ export function resolveBundledDashboardDir() {
146
208
  const optOut = process.env.DSLINTER_NO_BUNDLED_DASHBOARD?.trim();
147
209
  if (optOut === "1" || optOut?.toLowerCase() === "true") return null;
148
210
 
149
- const fromEnv = process.env.DSLINT_DASHBOARD_STATIC?.trim();
211
+ const fromEnv = readEnv("DASHBOARD_STATIC");
150
212
  if (fromEnv) {
151
213
  const dir = isAbsolute(fromEnv) ? normalize(fromEnv) : resolve(process.cwd(), fromEnv);
152
214
  return dashboardDirIfReady(dir);
@@ -2,7 +2,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
- import { ensureDashboardBuilt, hasEmbedDashboard } from "./project-root.mjs";
5
+ import {
6
+ defaultReportPath,
7
+ ensureDashboardBuilt,
8
+ hasEmbedDashboard,
9
+ } from "./project-root.mjs";
6
10
 
7
11
  describe("ensureDashboardBuilt (published install layout)", () => {
8
12
  it("returns prebuilt dashboard-dist without spawning build when embed sources are absent", () => {
@@ -19,3 +23,30 @@ describe("ensureDashboardBuilt (published install layout)", () => {
19
23
  expect(dist).toContain("dashboard-dist");
20
24
  });
21
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
+ });