dslinter 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +50 -29
  3. package/bin/dslinter.mjs +26 -5
  4. package/bin/lib/config-hide-component.mjs +44 -0
  5. package/bin/lib/config-hide-component.test.mjs +33 -0
  6. package/bin/lib/constants.mjs +20 -0
  7. package/bin/lib/dev-banner.mjs +16 -51
  8. package/bin/lib/dev-banner.test.mjs +20 -18
  9. package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
  10. package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
  11. package/bin/lib/enrich-report-cli.mjs +14 -0
  12. package/bin/lib/env.mjs +20 -0
  13. package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
  14. package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
  15. package/bin/lib/parse-args.mjs +13 -1
  16. package/bin/lib/parse-args.test.mjs +7 -1
  17. package/bin/lib/paths.mjs +8 -0
  18. package/bin/lib/project-root.mjs +72 -10
  19. package/bin/lib/project-root.test.mjs +32 -1
  20. package/bin/lib/prompt.mjs +31 -0
  21. package/bin/lib/resolve-project.mjs +78 -0
  22. package/bin/lib/resolve-project.test.mjs +74 -0
  23. package/bin/lib/run-scanner.mjs +40 -6
  24. package/bin/lib/scaffold-config.mjs +96 -8
  25. package/bin/lib/scaffold-config.test.mjs +12 -2
  26. package/bin/lib/scan-host.mjs +44 -0
  27. package/bin/lib/scan-host.test.mjs +41 -0
  28. package/bin/lib/setup-readiness.mjs +153 -0
  29. package/bin/lib/setup-readiness.test.mjs +32 -0
  30. package/bin/modes/build.mjs +31 -6
  31. package/bin/modes/dev.mjs +55 -21
  32. package/bin/modes/init.mjs +3 -22
  33. package/bin/modes/init.test.mjs +1 -1
  34. package/bin/modes/mcp.mjs +49 -0
  35. package/bin/modes/report.mjs +29 -4
  36. package/bin/modes/watch.mjs +85 -0
  37. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
  39. package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
  40. package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
  41. package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
  42. package/dashboard-dist/index.html +2 -2
  43. package/index.cjs +53 -52
  44. package/index.d.ts +3 -0
  45. package/package.json +18 -12
  46. package/shared/env.ts +15 -0
  47. package/shared/paths.ts +8 -0
  48. package/shared/reportPath.test.ts +19 -0
  49. package/shared/reportPath.ts +12 -0
  50. package/shared/servePort.ts +16 -0
  51. package/src/components/ComponentInspectPane.tsx +67 -19
  52. package/src/components/ComponentPlaygroundPane.tsx +262 -113
  53. package/src/components/DashboardCommandPalette.tsx +6 -11
  54. package/src/components/GovernancePane.tsx +2 -2
  55. package/src/components/HideFromCatalogButton.tsx +44 -0
  56. package/src/components/OpenInEditorButton.tsx +36 -0
  57. package/src/components/PlaygroundA11yAndCode.tsx +53 -53
  58. package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
  59. package/src/components/PlaygroundControls.tsx +5 -11
  60. package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
  61. package/src/components/PlaygroundUsageCode.tsx +6 -4
  62. package/src/components/PlaygroundVariantMatrix.tsx +101 -34
  63. package/src/components/Section.tsx +5 -2
  64. package/src/components/Sidebar.tsx +131 -46
  65. package/src/components/TruncatedPath.tsx +44 -0
  66. package/src/components/controlApiTable.test.ts +29 -0
  67. package/src/components/controlApiTable.ts +3 -0
  68. package/src/components/playgroundUsageHighlight.ts +14 -3
  69. package/src/components/ui/badge.tsx +1 -1
  70. package/src/components/ui/table.tsx +2 -2
  71. package/src/dashboard/ComponentCatalog.tsx +16 -23
  72. package/src/dashboard/ComponentUsageDetails.tsx +6 -15
  73. package/src/dashboard/DashboardBody.tsx +0 -35
  74. package/src/dashboard/FindingsList.tsx +65 -55
  75. package/src/dashboard/ScannedTokenWall.tsx +3 -3
  76. package/src/dashboard/aggregate.test.ts +74 -0
  77. package/src/dashboard/aggregate.ts +145 -21
  78. package/src/dashboard/catalogVisibility.test.ts +93 -0
  79. package/src/dashboard/catalogVisibility.ts +108 -0
  80. package/src/dashboard/editorLink.test.ts +57 -0
  81. package/src/dashboard/editorLink.ts +71 -0
  82. package/src/dashboard/paths.test.ts +49 -0
  83. package/src/dashboard/paths.ts +51 -3
  84. package/src/dashboard/updateDslintConfig.ts +22 -0
  85. package/src/dashboard/useWorkspaceReport.ts +21 -17
  86. package/src/index.ts +26 -0
  87. package/src/mcp/agent-context.ts +148 -0
  88. package/src/mcp/agent-query.test.ts +89 -0
  89. package/src/mcp/agent-query.ts +373 -0
  90. package/src/mcp/config.ts +53 -0
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/normalize-paths.ts +65 -0
  93. package/src/mcp/report-cache.ts +209 -0
  94. package/src/mcp/rule-catalog.json +156 -0
  95. package/src/mcp/rule-catalog.ts +33 -0
  96. package/src/mcp/schemas.ts +54 -0
  97. package/src/mcp/server.test.ts +44 -0
  98. package/src/mcp/server.ts +343 -0
  99. package/src/mcp/start.ts +29 -0
  100. package/src/mcp/verify-loop.test.ts +49 -0
  101. package/src/mcp/verify-loop.ts +149 -0
  102. package/src/playground/appPreviewTheme.test.ts +148 -0
  103. package/src/playground/appPreviewTheme.ts +137 -0
  104. package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
  105. package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
  106. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
  107. package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
  108. package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
  109. package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
  110. package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
  111. package/src/playground/collectDefinedPlaygrounds.ts +68 -0
  112. package/src/playground/controls.ts +177 -0
  113. package/src/playground/createPlaygroundRegistry.ts +1 -1
  114. package/src/playground/definePlayground.tsx +88 -16
  115. package/src/playground/definePlaygroundFromKit.ts +17 -0
  116. package/src/playground/embedGlobKey.ts +8 -0
  117. package/src/playground/enrichKitControls.test.ts +25 -0
  118. package/src/playground/enrichKitControls.ts +197 -0
  119. package/src/playground/expandPlaygroundControls.test.ts +50 -0
  120. package/src/playground/expandPlaygroundControls.ts +97 -0
  121. package/src/playground/inferKitJsx.test.ts +77 -0
  122. package/src/playground/inferKitJsx.ts +165 -0
  123. package/src/playground/inferKitParams.test.ts +41 -0
  124. package/src/playground/inferKitParams.ts +113 -0
  125. package/src/playground/inferPropTypesFromTs.d.mts +47 -0
  126. package/src/playground/inferPropTypesFromTs.mjs +343 -0
  127. package/src/playground/inferPropTypesFromTs.test.ts +227 -0
  128. package/src/playground/inferPropTypesFromTs.ts +17 -0
  129. package/src/playground/mergePlaygroundEntries.test.ts +32 -0
  130. package/src/playground/mergePlaygroundEntries.ts +28 -0
  131. package/src/playground/playgroundJoin.test.ts +79 -19
  132. package/src/playground/playgroundJoin.ts +47 -22
  133. package/src/playground/playgroundModuleExport.test.ts +42 -0
  134. package/src/playground/playgroundModuleExport.ts +22 -0
  135. package/src/playground/playgroundSpecsKey.ts +8 -0
  136. package/src/playground/propCoerce.ts +91 -0
  137. package/src/playground/scanVariantA11y.test.ts +46 -0
  138. package/src/playground/scanVariantA11y.ts +107 -0
  139. package/src/playground/snippet.ts +83 -0
  140. package/src/playground/usePlaygroundFromReport.test.ts +18 -8
  141. package/src/playground/usePlaygroundFromReport.ts +3 -1
  142. package/src/report/a11yForModule.ts +2 -7
  143. package/src/report/a11yScoring.test.ts +24 -0
  144. package/src/report/a11yScoring.ts +17 -0
  145. package/src/report/index.ts +6 -0
  146. package/src/shell/DashboardLayout.tsx +71 -45
  147. package/src/shell/DashboardLayoutAuto.tsx +0 -4
  148. package/src/shell/hashRoute.test.ts +7 -15
  149. package/src/shell/hashRoute.ts +31 -31
  150. package/src/shell/useHashRoute.ts +38 -13
  151. package/src/styles/dashboard-theme.css +18 -7
  152. package/src/types/controls.ts +11 -0
  153. package/src/types/playground.ts +4 -0
  154. package/src/types/report.ts +32 -9
  155. package/templates/playground/buildRegistry.ts +1 -1
  156. package/templates/vite.dslinter.snippet.ts +15 -4
  157. package/vite/collectScanModules.test.ts +51 -3
  158. package/vite/collectScanModules.ts +85 -29
  159. package/vite/consumer.config.mjs +6 -3
  160. package/vite/consumerAlias.test.ts +47 -0
  161. package/vite/consumerAlias.ts +114 -0
  162. package/vite/embedTailwindSources.test.ts +74 -0
  163. package/vite/embedTailwindSources.ts +97 -0
  164. package/vite/loadConsumerAliases.test.ts +131 -0
  165. package/vite/loadConsumerAliases.ts +155 -0
  166. package/vite/openFileInEditor.mjs +196 -0
  167. package/vite/openFileInEditor.test.mjs +87 -0
  168. package/vite/plugin.resolve.test.ts +72 -0
  169. package/vite/plugin.ts +216 -19
  170. package/vite/reportPath.test.ts +19 -0
  171. package/vite/resolveWayfinderImport.ts +56 -0
  172. package/vite/shims/inertia-react.tsx +85 -0
  173. package/vite/shims/wayfinder-actions.ts +33 -0
  174. package/vite/shims/wayfinder-routes.ts +30 -0
  175. package/vite/shims/ziggy-js.ts +12 -0
  176. package/dashboard-dist/assets/DashboardLayoutAuto-Bm7yfyC-.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
  178. package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
  179. package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -0,0 +1,97 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { projectRootForConfig, readIncludeDirs } from "./collectScanModules";
4
+
5
+ const FALLBACK_INCLUDE_DIRS = ["resources/js", "src", "app"];
6
+
7
+ const DASHBOARD_SRC_MARKER = `${join("packages", "dashboard", "src")}`;
8
+
9
+ function normalizePosixPath(path: string): string {
10
+ return path.replace(/\\/g, "/").replace(/\/$/, "");
11
+ }
12
+
13
+ function isDashboardPackageSrc(absPath: string): boolean {
14
+ return normalizePosixPath(absPath).endsWith(DASHBOARD_SRC_MARKER);
15
+ }
16
+
17
+ function resolveConsumerSourceAbsDirs(
18
+ scanRoot: string,
19
+ packageRoot: string,
20
+ ): string[] {
21
+ const scanAbs = resolve(scanRoot);
22
+ const projectRoot = projectRootForConfig(scanAbs);
23
+ let dirs = readIncludeDirs(projectRoot);
24
+
25
+ if (!dirs?.length) {
26
+ dirs = FALLBACK_INCLUDE_DIRS.filter((dir) =>
27
+ existsSync(join(projectRoot, dir)),
28
+ );
29
+ }
30
+
31
+ const unique = new Set<string>();
32
+ for (const dir of dirs) {
33
+ const norm = dir.trim().replace(/\\/g, "/").replace(/\/$/, "");
34
+ if (!norm) continue;
35
+ const abs = resolve(projectRoot, norm);
36
+ if (!existsSync(abs)) continue;
37
+ if (isDashboardPackageSrc(abs)) continue;
38
+ unique.add(abs);
39
+ }
40
+
41
+ if (unique.size === 0 && normalizePosixPath(scanAbs) !== normalizePosixPath(packageRoot)) {
42
+ for (const dir of FALLBACK_INCLUDE_DIRS) {
43
+ const abs = resolve(scanAbs, dir);
44
+ if (!existsSync(abs)) continue;
45
+ if (isDashboardPackageSrc(abs)) continue;
46
+ unique.add(abs);
47
+ }
48
+ }
49
+
50
+ return [...unique].sort();
51
+ }
52
+
53
+ /** Absolute consumer include dirs to register with Tailwind `@source`. */
54
+ export function resolveEmbedConsumerSourceDirs(
55
+ scanRoot: string,
56
+ packageRoot: string,
57
+ ): string[] {
58
+ return resolveConsumerSourceAbsDirs(scanRoot, packageRoot);
59
+ }
60
+
61
+ /** `@source` paths relative to `embed/index.css`. */
62
+ export function embedSourcePathsRelativeToCss(
63
+ scanRoot: string,
64
+ packageRoot: string,
65
+ embedCssPath: string,
66
+ ): string[] {
67
+ const embedCssDir = dirname(resolve(embedCssPath));
68
+ return resolveEmbedConsumerSourceDirs(scanRoot, packageRoot).map((abs) => {
69
+ let rel = relative(embedCssDir, abs).replace(/\\/g, "/");
70
+ if (!rel.startsWith(".")) rel = `./${rel}`;
71
+ return rel;
72
+ });
73
+ }
74
+
75
+ export function buildEmbedIndexCss(
76
+ base: string,
77
+ consumerSources: string[],
78
+ ): string {
79
+ if (consumerSources.length === 0) return base;
80
+ const injected = consumerSources.map((p) => `@source "${p}";`).join("\n");
81
+ return base.replace(
82
+ '@source "../src";',
83
+ `@source "../src";\n${injected}`,
84
+ );
85
+ }
86
+
87
+ export function shouldInjectEmbedConsumerSources(
88
+ scanRoot: string,
89
+ packageRoot: string,
90
+ ): boolean {
91
+ const scanAbs = resolve(scanRoot);
92
+ const pkgAbs = resolve(packageRoot);
93
+ if (normalizePosixPath(scanAbs) !== normalizePosixPath(pkgAbs)) {
94
+ return true;
95
+ }
96
+ return resolveEmbedConsumerSourceDirs(scanRoot, packageRoot).length > 0;
97
+ }
@@ -0,0 +1,131 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ flattenTsconfigPaths,
7
+ loadConsumerAliases,
8
+ stripJsonComments,
9
+ } from "./loadConsumerAliases";
10
+ import { resolveWithConsumerAliases } from "./consumerAlias";
11
+ import {
12
+ isWayfinderActionsImport,
13
+ isWayfinderRoutesImport,
14
+ resolveExistingModule,
15
+ resolveWayfinderShim,
16
+ } from "./resolveWayfinderImport";
17
+
18
+ describe("stripJsonComments", () => {
19
+ it("removes line and block comments outside strings", () => {
20
+ const input = `{
21
+ // line comment
22
+ "compilerOptions": {
23
+ /* block */
24
+ "paths": { "@/*": ["./resources/js/*"] }
25
+ }
26
+ }`;
27
+ const stripped = stripJsonComments(input);
28
+ expect(JSON.parse(stripped)).toEqual({
29
+ compilerOptions: {
30
+ paths: { "@/*": ["./resources/js/*"] },
31
+ },
32
+ });
33
+ });
34
+ });
35
+
36
+ describe("flattenTsconfigPaths", () => {
37
+ it("maps @/* to resources/js", () => {
38
+ const root = mkdtempSync(join(tmpdir(), "dslinter-tsconfig-"));
39
+ const aliases = flattenTsconfigPaths(
40
+ { "@/*": ["./resources/js/*"] },
41
+ root,
42
+ );
43
+ const resolved = resolveWithConsumerAliases(
44
+ "@/components/ui/sidebar",
45
+ aliases,
46
+ );
47
+ expect(resolved?.replace(/\\/g, "/")).toContain("resources/js/components/ui/sidebar");
48
+ });
49
+ });
50
+
51
+ describe("loadConsumerAliases", () => {
52
+ it("reads @/* from tsconfig.json when vite alias is empty", () => {
53
+ const root = mkdtempSync(join(tmpdir(), "dslinter-load-alias-"));
54
+ mkdirSync(join(root, "resources", "js", "components", "ui"), {
55
+ recursive: true,
56
+ });
57
+ writeFileSync(
58
+ join(root, "resources", "js", "components", "ui", "sidebar.tsx"),
59
+ "export {}",
60
+ );
61
+ writeFileSync(
62
+ join(root, "tsconfig.json"),
63
+ JSON.stringify({
64
+ compilerOptions: {
65
+ baseUrl: ".",
66
+ paths: { "@/*": ["./resources/js/*"] },
67
+ },
68
+ }),
69
+ );
70
+
71
+ const aliases = loadConsumerAliases(root, undefined);
72
+ const resolved = resolveExistingModule(
73
+ "@/components/ui/sidebar",
74
+ aliases,
75
+ );
76
+ expect(resolved?.replace(/\\/g, "/")).toContain(
77
+ "resources/js/components/ui/sidebar.tsx",
78
+ );
79
+ });
80
+
81
+ it("falls back to resources/js when no tsconfig", () => {
82
+ const root = mkdtempSync(join(tmpdir(), "dslinter-laravel-fallback-"));
83
+ mkdirSync(join(root, "resources", "js", "lib"), { recursive: true });
84
+ writeFileSync(join(root, "resources", "js", "lib", "utils.ts"), "export {}");
85
+
86
+ const aliases = loadConsumerAliases(root, undefined);
87
+ const resolved = resolveExistingModule("@/lib/utils", aliases);
88
+ expect(resolved?.replace(/\\/g, "/")).toContain("resources/js/lib/utils.ts");
89
+ });
90
+ });
91
+
92
+ describe("resolveWayfinderImport", () => {
93
+ it("detects routes and actions prefixes", () => {
94
+ expect(isWayfinderRoutesImport("@/routes")).toBe(true);
95
+ expect(isWayfinderRoutesImport("@/routes/two-factor")).toBe(true);
96
+ expect(isWayfinderActionsImport("@/actions/App/Http/Controllers/Foo")).toBe(
97
+ true,
98
+ );
99
+ expect(isWayfinderRoutesImport("@/components/foo")).toBe(false);
100
+ });
101
+
102
+ it("returns shim paths when generated files are missing", () => {
103
+ const shim = resolveWayfinderShim(
104
+ "@/routes/two-factor",
105
+ "/shims/wayfinder-routes.ts",
106
+ "/shims/wayfinder-actions.ts",
107
+ );
108
+ expect(shim).toBe("/shims/wayfinder-routes.ts");
109
+
110
+ const actionShim = resolveWayfinderShim(
111
+ "@/actions/App/Http/Controllers/Settings/ProfileController",
112
+ "/shims/wayfinder-routes.ts",
113
+ "/shims/wayfinder-actions.ts",
114
+ );
115
+ expect(actionShim).toBe("/shims/wayfinder-actions.ts");
116
+ });
117
+
118
+ it("resolves directory imports to index.ts", () => {
119
+ const root = mkdtempSync(join(tmpdir(), "dslinter-routes-dir-"));
120
+ mkdirSync(join(root, "resources", "js", "routes"), { recursive: true });
121
+ writeFileSync(
122
+ join(root, "resources", "js", "routes", "index.ts"),
123
+ "export const dashboard = () => '/';",
124
+ );
125
+ const aliases = loadConsumerAliases(root, undefined);
126
+ const resolved = resolveExistingModule("@/routes", aliases);
127
+ expect(resolved?.replace(/\\/g, "/")).toContain(
128
+ "resources/js/routes/index.ts",
129
+ );
130
+ });
131
+ });
@@ -0,0 +1,155 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import type { AliasOptions } from "vite";
4
+ import { flattenViteAlias, type FlatAlias } from "./consumerAlias";
5
+
6
+ const TSCONFIG_NAMES = ["tsconfig.json", "jsconfig.json"] as const;
7
+
8
+ /** Strip line and block comments so JSONC tsconfig files parse. */
9
+ export function stripJsonComments(text: string): string {
10
+ let out = "";
11
+ let i = 0;
12
+ while (i < text.length) {
13
+ if (text[i] === '"' || text[i] === "'") {
14
+ const quote = text[i]!;
15
+ out += quote;
16
+ i++;
17
+ while (i < text.length) {
18
+ if (text[i] === "\\") {
19
+ out += text[i]!;
20
+ i++;
21
+ if (i < text.length) {
22
+ out += text[i]!;
23
+ i++;
24
+ }
25
+ continue;
26
+ }
27
+ if (text[i] === quote) {
28
+ out += text[i]!;
29
+ i++;
30
+ break;
31
+ }
32
+ out += text[i]!;
33
+ i++;
34
+ }
35
+ continue;
36
+ }
37
+ if (text[i] === "/" && text[i + 1] === "/") {
38
+ i += 2;
39
+ while (i < text.length && text[i] !== "\n") i++;
40
+ continue;
41
+ }
42
+ if (text[i] === "/" && text[i + 1] === "*") {
43
+ i += 2;
44
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
45
+ i += 2;
46
+ continue;
47
+ }
48
+ out += text[i]!;
49
+ i++;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ type TsPaths = Record<string, string[]>;
55
+
56
+ function readTsPaths(consumerRoot: string): TsPaths | null {
57
+ const root = resolve(consumerRoot);
58
+ for (const name of TSCONFIG_NAMES) {
59
+ const filePath = join(root, name);
60
+ if (!existsSync(filePath)) continue;
61
+ try {
62
+ const raw = readFileSync(filePath, "utf8");
63
+ const parsed = JSON.parse(stripJsonComments(raw)) as {
64
+ compilerOptions?: { paths?: TsPaths };
65
+ };
66
+ const paths = parsed.compilerOptions?.paths;
67
+ if (paths && typeof paths === "object") return paths;
68
+ } catch {
69
+ // try next file
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Convert tsconfig paths (e.g. @/* → ./resources/js/*) into Vite-style aliases. */
76
+ export function flattenTsconfigPaths(
77
+ paths: TsPaths,
78
+ consumerRoot: string,
79
+ ): FlatAlias[] {
80
+ const root = resolve(consumerRoot);
81
+ const out: FlatAlias[] = [];
82
+
83
+ for (const [find, targets] of Object.entries(paths)) {
84
+ if (!Array.isArray(targets) || targets.length === 0) continue;
85
+ const target = targets[0]!;
86
+ if (typeof target !== "string") continue;
87
+
88
+ if (find.endsWith("/*")) {
89
+ const prefix = find.slice(0, -2);
90
+ const targetBase = target.endsWith("/*")
91
+ ? target.slice(0, -2)
92
+ : target.replace(/\*$/, "");
93
+ out.push({
94
+ find: prefix,
95
+ replacement: resolve(root, targetBase),
96
+ });
97
+ continue;
98
+ }
99
+
100
+ out.push({
101
+ find,
102
+ replacement: resolve(root, target),
103
+ });
104
+ }
105
+
106
+ return out.sort((a, b) => {
107
+ const al = typeof a.find === "string" ? a.find.length : 0;
108
+ const bl = typeof b.find === "string" ? b.find.length : 0;
109
+ return bl - al;
110
+ });
111
+ }
112
+
113
+ function hasAtAlias(aliases: FlatAlias[]): boolean {
114
+ return aliases.some(
115
+ (a) =>
116
+ typeof a.find === "string" &&
117
+ (a.find === "@" || a.find === "@/"),
118
+ );
119
+ }
120
+
121
+ function laravelResourcesJsAlias(consumerRoot: string): FlatAlias | null {
122
+ const jsDir = join(resolve(consumerRoot), "resources", "js");
123
+ if (!existsSync(jsDir)) return null;
124
+ return { find: "@", replacement: jsDir };
125
+ }
126
+
127
+ /** Build consumer aliases: static Vite config, tsconfig paths, then Laravel @ fallback. */
128
+ export function loadConsumerAliases(
129
+ consumerRoot: string,
130
+ viteAlias: AliasOptions | undefined,
131
+ ): FlatAlias[] {
132
+ const root = resolve(consumerRoot);
133
+ const merged: FlatAlias[] = [];
134
+
135
+ const fromVite = flattenViteAlias(viteAlias, root);
136
+ merged.push(...fromVite);
137
+
138
+ if (!hasAtAlias(merged)) {
139
+ const paths = readTsPaths(root);
140
+ if (paths) {
141
+ merged.push(...flattenTsconfigPaths(paths, root));
142
+ }
143
+ }
144
+
145
+ if (!hasAtAlias(merged)) {
146
+ const laravel = laravelResourcesJsAlias(root);
147
+ if (laravel) merged.push(laravel);
148
+ }
149
+
150
+ return merged.sort((a, b) => {
151
+ const al = typeof a.find === "string" ? a.find.length : 0;
152
+ const bl = typeof b.find === "string" ? b.find.length : 0;
153
+ return bl - al;
154
+ });
155
+ }
@@ -0,0 +1,196 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ const CONFIG_NAMES = [".dslinter.json", "dslinter.json"];
6
+
7
+ /** @typedef {{ file: string; line?: number; column?: number; scanRoot: string }} OpenFileOptions */
8
+
9
+ /**
10
+ * @param {string} startDir
11
+ * @returns {string | null}
12
+ */
13
+ function findConfigPath(startDir) {
14
+ let dir = resolve(startDir);
15
+ for (;;) {
16
+ for (const name of CONFIG_NAMES) {
17
+ const candidate = join(dir, name);
18
+ if (existsSync(candidate)) return candidate;
19
+ }
20
+ const parent = dirname(dir);
21
+ if (parent === dir) break;
22
+ dir = parent;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /**
28
+ * @param {unknown} value
29
+ * @returns {string | null}
30
+ */
31
+ function readEditorOpenCommand(value) {
32
+ if (typeof value !== "string") return null;
33
+ const trimmed = value.trim();
34
+ return trimmed.length > 0 ? trimmed : null;
35
+ }
36
+
37
+ /**
38
+ * @param {string} projectRoot
39
+ * @returns {string | null}
40
+ */
41
+ export function loadEditorOpenCommand(projectRoot) {
42
+ const fromEnv = process.env.DSLINTER_EDITOR?.trim();
43
+ if (fromEnv) return fromEnv;
44
+
45
+ const configPath = findConfigPath(projectRoot);
46
+ if (!configPath) return null;
47
+ try {
48
+ const raw = readFileSync(configPath, "utf8");
49
+ const parsed = JSON.parse(raw);
50
+ return readEditorOpenCommand(parsed.editor_open_command);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {string} absPath
58
+ * @param {string} root
59
+ */
60
+ export function isPathUnderRoot(absPath, root) {
61
+ const normalized = resolve(absPath);
62
+ const normalizedRoot = resolve(root);
63
+ if (normalized === normalizedRoot) return true;
64
+ const prefix = normalizedRoot.endsWith("/")
65
+ ? normalizedRoot
66
+ : `${normalizedRoot}/`;
67
+ return normalized.startsWith(prefix);
68
+ }
69
+
70
+ /**
71
+ * @param {string} template
72
+ * @param {{ file: string; line: number; column: number }} ctx
73
+ * @returns {string[]}
74
+ */
75
+ export function expandEditorOpenCommand(template, ctx) {
76
+ const withPlaceholders = template
77
+ .replaceAll("{file}", ctx.file)
78
+ .replaceAll("{line}", String(ctx.line))
79
+ .replaceAll("{column}", String(ctx.column));
80
+ return splitShellArgs(withPlaceholders);
81
+ }
82
+
83
+ /**
84
+ * Minimal shell-like split (no nested quotes); good enough for editor commands.
85
+ * @param {string} input
86
+ * @returns {string[]}
87
+ */
88
+ function splitShellArgs(input) {
89
+ const args = [];
90
+ let current = "";
91
+ let quote = null;
92
+ for (let i = 0; i < input.length; i += 1) {
93
+ const ch = input[i];
94
+ if (quote) {
95
+ if (ch === quote) {
96
+ quote = null;
97
+ } else {
98
+ current += ch;
99
+ }
100
+ continue;
101
+ }
102
+ if (ch === '"' || ch === "'") {
103
+ quote = ch;
104
+ continue;
105
+ }
106
+ if (/\s/.test(ch)) {
107
+ if (current.length > 0) {
108
+ args.push(current);
109
+ current = "";
110
+ }
111
+ continue;
112
+ }
113
+ current += ch;
114
+ }
115
+ if (current.length > 0) args.push(current);
116
+ return args;
117
+ }
118
+
119
+ /**
120
+ * @param {string} command
121
+ * @returns {boolean}
122
+ */
123
+ function commandExists(command) {
124
+ const probe =
125
+ process.platform === "win32" ? "where" : "command";
126
+ const args =
127
+ process.platform === "win32" ? [command] : ["-v", command];
128
+ const result = spawnSync(probe, args, { stdio: "ignore" });
129
+ return result.status === 0;
130
+ }
131
+
132
+ /**
133
+ * @param {{ file: string; line: number; column: number }} ctx
134
+ * @param {string | null} configured
135
+ * @returns {string[] | null}
136
+ */
137
+ export function resolveEditorOpenArgv(ctx, configured) {
138
+ if (configured) {
139
+ const argv = expandEditorOpenCommand(configured, ctx);
140
+ return argv.length > 0 ? argv : null;
141
+ }
142
+
143
+ const templates = [
144
+ "cursor --goto {file}:{line}:{column}",
145
+ "code --goto {file}:{line}:{column}",
146
+ "codium --goto {file}:{line}:{column}",
147
+ "subl {file}:{line}:{column}",
148
+ "webstorm --line {line} {file}",
149
+ "idea --line {line} {file}",
150
+ ];
151
+
152
+ for (const template of templates) {
153
+ const argv = expandEditorOpenCommand(template, ctx);
154
+ if (argv.length > 0 && commandExists(argv[0])) return argv;
155
+ }
156
+
157
+ if (process.platform === "darwin") {
158
+ return ["open", "-t", ctx.file];
159
+ }
160
+ if (process.platform === "win32") {
161
+ return ["cmd", "/c", "start", "", ctx.file];
162
+ }
163
+ return ["xdg-open", ctx.file];
164
+ }
165
+
166
+ /**
167
+ * @param {OpenFileOptions} options
168
+ */
169
+ export function openFileInEditor(options) {
170
+ const file = resolve(options.file);
171
+ const scanRoot = resolve(options.scanRoot);
172
+ if (!isPathUnderRoot(file, scanRoot)) {
173
+ throw new Error("Refusing to open path outside scan root");
174
+ }
175
+ if (!existsSync(file)) {
176
+ throw new Error(`File not found: ${file}`);
177
+ }
178
+
179
+ const ctx = {
180
+ file,
181
+ line: options.line ?? 1,
182
+ column: options.column ?? 1,
183
+ };
184
+ const configured = loadEditorOpenCommand(scanRoot);
185
+ const argv = resolveEditorOpenArgv(ctx, configured);
186
+ if (!argv) {
187
+ throw new Error("No editor command configured");
188
+ }
189
+
190
+ const [command, ...args] = argv;
191
+ const child = spawn(command, args, {
192
+ detached: true,
193
+ stdio: "ignore",
194
+ });
195
+ child.unref();
196
+ }
@@ -0,0 +1,87 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ expandEditorOpenCommand,
7
+ isPathUnderRoot,
8
+ resolveEditorOpenArgv,
9
+ } from "./openFileInEditor.mjs";
10
+
11
+ describe("expandEditorOpenCommand", () => {
12
+ it("replaces file, line, and column placeholders", () => {
13
+ expect(
14
+ expandEditorOpenCommand("cursor --goto {file}:{line}:{column}", {
15
+ file: "/repo/Button.tsx",
16
+ line: 4,
17
+ column: 2,
18
+ }),
19
+ ).toEqual(["cursor", "--goto", "/repo/Button.tsx:4:2"]);
20
+ });
21
+
22
+ it("supports quoted paths with spaces", () => {
23
+ expect(
24
+ expandEditorOpenCommand('code -g "{file}:{line}:{column}"', {
25
+ file: "/repo/My Button.tsx",
26
+ line: 1,
27
+ column: 1,
28
+ }),
29
+ ).toEqual(["code", "-g", "/repo/My Button.tsx:1:1"]);
30
+ });
31
+ });
32
+
33
+ describe("isPathUnderRoot", () => {
34
+ it("accepts files under the scan root", () => {
35
+ expect(
36
+ isPathUnderRoot("/repo/src/Button.tsx", "/repo"),
37
+ ).toBe(true);
38
+ });
39
+
40
+ it("rejects paths outside the scan root", () => {
41
+ expect(
42
+ isPathUnderRoot("/etc/passwd", "/repo"),
43
+ ).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("resolveEditorOpenArgv", () => {
48
+ const ctx = {
49
+ file: "/repo/src/Button.tsx",
50
+ line: 10,
51
+ column: 1,
52
+ };
53
+
54
+ it("uses configured command when provided", () => {
55
+ expect(
56
+ resolveEditorOpenArgv(
57
+ ctx,
58
+ "my-editor --jump {file}:{line}:{column}",
59
+ ),
60
+ ).toEqual(["my-editor", "--jump", "/repo/src/Button.tsx:10:1"]);
61
+ });
62
+ });
63
+
64
+ describe("loadEditorOpenCommand", () => {
65
+ let tempDir = "";
66
+
67
+ afterEach(() => {
68
+ tempDir = "";
69
+ });
70
+
71
+ it("reads editor_open_command from .dslinter.json", async () => {
72
+ tempDir = mkdtempSync(join(tmpdir(), "dslinter-open-"));
73
+ writeFileSync(
74
+ join(tempDir, ".dslinter.json"),
75
+ JSON.stringify({ editor_open_command: "zed {file}:{line}" }),
76
+ );
77
+ const previous = process.env.DSLINTER_EDITOR;
78
+ delete process.env.DSLINTER_EDITOR;
79
+ try {
80
+ const { loadEditorOpenCommand } = await import("./openFileInEditor.mjs");
81
+ expect(loadEditorOpenCommand(tempDir)).toBe("zed {file}:{line}");
82
+ } finally {
83
+ if (previous === undefined) delete process.env.DSLINTER_EDITOR;
84
+ else process.env.DSLINTER_EDITOR = previous;
85
+ }
86
+ });
87
+ });