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.
- package/CHANGELOG.md +112 -0
- package/README.md +54 -27
- package/bin/dslinter.mjs +26 -5
- package/bin/lib/config-hide-component.mjs +44 -0
- package/bin/lib/config-hide-component.test.mjs +33 -0
- package/bin/lib/constants.mjs +20 -0
- package/bin/lib/dev-banner.mjs +16 -51
- package/bin/lib/dev-banner.test.mjs +20 -18
- package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
- package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
- package/bin/lib/enrich-report-cli.mjs +14 -0
- package/bin/lib/env.mjs +20 -0
- package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
- package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
- package/bin/lib/parse-args.mjs +13 -1
- package/bin/lib/parse-args.test.mjs +7 -1
- package/bin/lib/paths.mjs +8 -0
- package/bin/lib/project-root.mjs +92 -24
- package/bin/lib/project-root.test.mjs +52 -0
- package/bin/lib/prompt.mjs +31 -0
- package/bin/lib/resolve-project.mjs +78 -0
- package/bin/lib/resolve-project.test.mjs +74 -0
- package/bin/lib/run-scanner.mjs +40 -6
- package/bin/lib/scaffold-config.mjs +163 -0
- package/bin/lib/scaffold-config.test.mjs +43 -0
- package/bin/lib/scan-host.mjs +44 -0
- package/bin/lib/scan-host.test.mjs +41 -0
- package/bin/lib/setup-readiness.mjs +153 -0
- package/bin/lib/setup-readiness.test.mjs +32 -0
- package/bin/modes/build.mjs +31 -6
- package/bin/modes/dev.mjs +56 -13
- package/bin/modes/init.mjs +35 -47
- package/bin/modes/init.test.mjs +16 -0
- package/bin/modes/mcp.mjs +49 -0
- package/bin/modes/report.mjs +29 -4
- package/bin/modes/watch.mjs +85 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
- package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
- package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
- package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
- package/dashboard-dist/index.html +2 -2
- package/index.cjs +53 -52
- package/index.d.ts +3 -0
- package/package.json +18 -12
- package/shared/env.ts +15 -0
- package/shared/paths.ts +8 -0
- package/shared/reportPath.test.ts +19 -0
- package/shared/reportPath.ts +12 -0
- package/shared/servePort.ts +16 -0
- package/src/components/ComponentInspectPane.tsx +67 -19
- package/src/components/ComponentPlaygroundPane.tsx +262 -113
- package/src/components/DashboardCommandPalette.tsx +6 -11
- package/src/components/GovernancePane.tsx +2 -2
- package/src/components/HideFromCatalogButton.tsx +44 -0
- package/src/components/OpenInEditorButton.tsx +36 -0
- package/src/components/PlaygroundA11yAndCode.tsx +53 -53
- package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
- package/src/components/PlaygroundControls.tsx +5 -11
- package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
- package/src/components/PlaygroundUsageCode.tsx +6 -4
- package/src/components/PlaygroundVariantMatrix.tsx +101 -34
- package/src/components/Section.tsx +5 -2
- package/src/components/Sidebar.tsx +131 -46
- package/src/components/TruncatedPath.tsx +44 -0
- package/src/components/controlApiTable.test.ts +29 -0
- package/src/components/controlApiTable.ts +3 -0
- package/src/components/playgroundUsageHighlight.ts +14 -3
- package/src/components/ui/badge.tsx +1 -1
- package/src/components/ui/table.tsx +2 -2
- package/src/dashboard/ComponentCatalog.tsx +16 -23
- package/src/dashboard/ComponentUsageDetails.tsx +6 -15
- package/src/dashboard/DashboardBody.tsx +0 -35
- package/src/dashboard/FindingsList.tsx +65 -55
- package/src/dashboard/ScannedTokenWall.tsx +3 -3
- package/src/dashboard/aggregate.test.ts +74 -0
- package/src/dashboard/aggregate.ts +145 -21
- package/src/dashboard/catalogVisibility.test.ts +93 -0
- package/src/dashboard/catalogVisibility.ts +108 -0
- package/src/dashboard/editorLink.test.ts +57 -0
- package/src/dashboard/editorLink.ts +71 -0
- package/src/dashboard/paths.test.ts +49 -0
- package/src/dashboard/paths.ts +51 -3
- package/src/dashboard/updateDslintConfig.ts +22 -0
- package/src/dashboard/useWorkspaceReport.ts +21 -17
- package/src/index.ts +26 -0
- package/src/mcp/agent-context.ts +148 -0
- package/src/mcp/agent-query.test.ts +89 -0
- package/src/mcp/agent-query.ts +373 -0
- package/src/mcp/config.ts +53 -0
- package/src/mcp/index.ts +18 -0
- package/src/mcp/normalize-paths.ts +65 -0
- package/src/mcp/report-cache.ts +209 -0
- package/src/mcp/rule-catalog.json +156 -0
- package/src/mcp/rule-catalog.ts +33 -0
- package/src/mcp/schemas.ts +54 -0
- package/src/mcp/server.test.ts +44 -0
- package/src/mcp/server.ts +343 -0
- package/src/mcp/start.ts +29 -0
- package/src/mcp/verify-loop.test.ts +49 -0
- package/src/mcp/verify-loop.ts +149 -0
- package/src/playground/appPreviewTheme.test.ts +148 -0
- package/src/playground/appPreviewTheme.ts +137 -0
- package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
- package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
- package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
- package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
- package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
- package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
- package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
- package/src/playground/collectDefinedPlaygrounds.ts +68 -0
- package/src/playground/controls.ts +177 -0
- package/src/playground/createPlaygroundRegistry.ts +1 -1
- package/src/playground/definePlayground.tsx +88 -16
- package/src/playground/definePlaygroundFromKit.ts +17 -0
- package/src/playground/embedGlobKey.ts +8 -0
- package/src/playground/enrichKitControls.test.ts +25 -0
- package/src/playground/enrichKitControls.ts +197 -0
- package/src/playground/expandPlaygroundControls.test.ts +50 -0
- package/src/playground/expandPlaygroundControls.ts +97 -0
- package/src/playground/inferKitJsx.test.ts +77 -0
- package/src/playground/inferKitJsx.ts +165 -0
- package/src/playground/inferKitParams.test.ts +41 -0
- package/src/playground/inferKitParams.ts +113 -0
- package/src/playground/inferPropTypesFromTs.d.mts +47 -0
- package/src/playground/inferPropTypesFromTs.mjs +343 -0
- package/src/playground/inferPropTypesFromTs.test.ts +227 -0
- package/src/playground/inferPropTypesFromTs.ts +17 -0
- package/src/playground/mergePlaygroundEntries.test.ts +32 -0
- package/src/playground/mergePlaygroundEntries.ts +28 -0
- package/src/playground/playgroundJoin.test.ts +79 -19
- package/src/playground/playgroundJoin.ts +47 -22
- package/src/playground/playgroundModuleExport.test.ts +42 -0
- package/src/playground/playgroundModuleExport.ts +22 -0
- package/src/playground/playgroundSpecsKey.ts +8 -0
- package/src/playground/propCoerce.ts +91 -0
- package/src/playground/scanVariantA11y.test.ts +46 -0
- package/src/playground/scanVariantA11y.ts +107 -0
- package/src/playground/snippet.ts +83 -0
- package/src/playground/usePlaygroundFromReport.test.ts +18 -8
- package/src/playground/usePlaygroundFromReport.ts +3 -1
- package/src/report/a11yForModule.ts +2 -7
- package/src/report/a11yScoring.test.ts +24 -0
- package/src/report/a11yScoring.ts +17 -0
- package/src/report/index.ts +6 -0
- package/src/shell/DashboardLayout.tsx +71 -45
- package/src/shell/DashboardLayoutAuto.tsx +0 -4
- package/src/shell/hashRoute.test.ts +7 -15
- package/src/shell/hashRoute.ts +31 -31
- package/src/shell/useHashRoute.ts +38 -13
- package/src/styles/dashboard-theme.css +18 -7
- package/src/types/controls.ts +11 -0
- package/src/types/playground.ts +4 -0
- package/src/types/report.ts +32 -9
- package/templates/playground/buildRegistry.ts +1 -1
- package/templates/vite.dslinter.snippet.ts +15 -4
- package/vite/collectScanModules.test.ts +51 -3
- package/vite/collectScanModules.ts +85 -29
- package/vite/consumer.config.mjs +6 -3
- package/vite/consumerAlias.test.ts +47 -0
- package/vite/consumerAlias.ts +114 -0
- package/vite/embedTailwindSources.test.ts +74 -0
- package/vite/embedTailwindSources.ts +97 -0
- package/vite/loadConsumerAliases.test.ts +131 -0
- package/vite/loadConsumerAliases.ts +155 -0
- package/vite/openFileInEditor.mjs +196 -0
- package/vite/openFileInEditor.test.mjs +87 -0
- package/vite/plugin.resolve.test.ts +72 -0
- package/vite/plugin.ts +216 -19
- package/vite/reportPath.test.ts +19 -0
- package/vite/resolveWayfinderImport.ts +56 -0
- package/vite/shims/inertia-react.tsx +85 -0
- package/vite/shims/wayfinder-actions.ts +33 -0
- package/vite/shims/wayfinder-routes.ts +30 -0
- package/vite/shims/ziggy-js.ts +12 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-BPPtPsYh.css +0 -1
- package/dashboard-dist/assets/DashboardLayoutAuto-Dp3bAQxH.js +0 -1
- package/dashboard-dist/assets/index-DsjwnDdX.js +0 -206
- package/dashboard-dist/assets/index-jaCmZJlW.css +0 -1
- package/src/components/playgroundUsageTwoslash.ts +0 -69
- 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
|
+
});
|