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
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { PlaygroundArgs, PlaygroundControl } from "./controls";
2
3
  import type { PlaygroundPreviewComponent } from "./preview";
3
4
 
@@ -17,5 +18,8 @@ export type PlaygroundEntry = {
17
18
  controls: PlaygroundControl[];
18
19
  /** Optional JSX-ish snippet from current `values` (consumer-defined). */
19
20
  usageSnippet?: (values: PlaygroundArgs) => string;
21
+ /** Render live preview from control values (preferred — avoids unstable component types). */
22
+ renderPreview: (values: PlaygroundArgs) => ReactNode;
23
+ /** @deprecated Use `renderPreview` — kept for manual `definePlayground` call sites. */
20
24
  Preview: PlaygroundPreviewComponent;
21
25
  };
@@ -17,6 +17,11 @@ export interface ComponentDefinition {
17
17
  line: number;
18
18
  /** Props destructured from the first parameter, when detectable. */
19
19
  declared_props?: string[];
20
+ /** CVA variant option keys per prop (playground select controls). */
21
+ declared_prop_options?: Record<string, string[]>;
22
+ /** Default values from CVA `defaultVariants`. */
23
+ declared_prop_defaults?: Record<string, string>;
24
+ cva_binding_name?: string;
20
25
  }
21
26
 
22
27
  export interface JsxUsage {
@@ -33,6 +38,8 @@ export interface LintFinding {
33
38
  path: string;
34
39
  line: number | null;
35
40
  severity: Severity;
41
+ /** Prop combo label from CVA matrix scan (CI or playground). */
42
+ variant_label?: string;
36
43
  }
37
44
 
38
45
  export interface FileScan {
@@ -78,12 +85,6 @@ export interface UsageSummary {
78
85
  usage_locations?: UsageLocation[];
79
86
  }
80
87
 
81
- export interface OwnershipSummary {
82
- owner: string;
83
- files: number;
84
- definitions: number;
85
- }
86
-
87
88
  /**
88
89
  * Simplified prop kind from TypeScript (e.g. demo `merge-playgrounds.mjs`).
89
90
  * Dashboard falls back to name heuristics when a key is missing or kind is `unknown`.
@@ -98,10 +99,14 @@ export interface PlaygroundSpec {
98
99
  declared_props: string[];
99
100
  group?: string | null;
100
101
  /**
101
- * Optional map from prop name to simplified TS kind, filled by tooling that runs the TS checker
102
- * (demo: `merge-playgrounds.mjs`). Omitted when empty or unavailable.
102
+ * Optional map from prop name to simplified TS kind, filled by the TS enrich step
103
+ * after scanning (`bin/lib/enrich-playgrounds-from-ts.mjs`). Omitted when empty or unavailable.
103
104
  */
104
105
  declared_prop_kinds?: Partial<Record<string, DeclaredPropKind>>;
106
+ /** CVA variant option keys per prop (dashboard renders as `<Select>`). */
107
+ declared_prop_options?: Record<string, string[]>;
108
+ /** Default values from CVA `defaultVariants`. */
109
+ declared_prop_defaults?: Record<string, string>;
105
110
  }
106
111
 
107
112
  export type CssTokenCategory =
@@ -136,14 +141,32 @@ export interface CssTokenSummary {
136
141
  unused_tokens?: string[];
137
142
  }
138
143
 
144
+ /** Dashboard-relevant slice of `.dslinter.json` embedded in each scan report. */
145
+ export interface ReportConfig {
146
+ hidden_components?: string[];
147
+ hidden_paths?: string[];
148
+ }
149
+
150
+ /** Agent/MCP-friendly slice of `.dslinter.json` embedded in each scan report. */
151
+ export interface ConfigSnapshot {
152
+ deprecated_components?: string[];
153
+ known_tokens?: string[];
154
+ include_dirs?: string[];
155
+ }
156
+
139
157
  export interface WorkspaceReport {
158
+ /** Report JSON schema version (1+). Omitted in legacy reports. */
159
+ schema_version?: number;
160
+ /** ISO 8601 UTC timestamp when the report was generated. */
161
+ generated_at?: string;
140
162
  root: string;
141
163
  files: FileScan[];
142
164
  findings: LintFinding[];
143
165
  duplicate_components: DuplicateComponent[];
144
166
  usage_by_component: UsageSummary[];
145
- ownership: OwnershipSummary[];
146
167
  scores: GovernanceScores;
147
168
  playgrounds?: PlaygroundSpec[];
148
169
  css_tokens?: CssTokenSummary;
170
+ config?: ReportConfig;
171
+ config_snapshot?: ConfigSnapshot;
149
172
  }
@@ -2,7 +2,7 @@ import type { PlaygroundEntry, WorkspaceReport } from "dslinter";
2
2
  import { createPlaygroundRegistry } from "dslinter";
3
3
 
4
4
  /**
5
- * Eager Vite glob — must cover every path in `dslint-report.json` → `playgrounds[].rel_path`.
5
+ * Eager Vite glob — must cover every path in `dslinter-report.json` → `playgrounds[].rel_path`.
6
6
  * Nested paths (e.g. `src/components/ui/button.tsx`) require `**`, not a single `*`.
7
7
  */
8
8
  const modules = import.meta.glob("../components/**/*.{tsx,jsx}", {
@@ -2,7 +2,18 @@
2
2
  * Legacy snippet — prefer `import dslinter from "dslinter/vite"` (one plugin line).
3
3
  * `npx dslinter` merges the plugin automatically when a consumer Vite app exists.
4
4
  */
5
- const DSLINT_SERVE_PORT = Number(process.env.DSLINT_SERVE_PORT ?? "7878");
5
+ function resolveServePort(): number {
6
+ const rawPort = process.env.DSLINTER_SERVE_PORT ?? process.env.PORT;
7
+ const parsedPort = Number(rawPort);
8
+
9
+ if (Number.isInteger(parsedPort) && parsedPort > 0) {
10
+ return parsedPort;
11
+ }
12
+
13
+ return 3210;
14
+ }
15
+
16
+ const DSLINTER_SERVE_PORT = resolveServePort();
6
17
 
7
18
  export const dslinterViteSnippet = {
8
19
  resolve: {
@@ -13,12 +24,12 @@ export const dslinterViteSnippet = {
13
24
  },
14
25
  server: {
15
26
  proxy: {
16
- "/dslint-report.json": {
17
- target: `http://127.0.0.1:${DSLINT_SERVE_PORT}`,
27
+ "/dslinter-report.json": {
28
+ target: `http://127.0.0.1:${DSLINTER_SERVE_PORT}`,
18
29
  changeOrigin: true,
19
30
  },
20
31
  "/events": {
21
- target: `http://127.0.0.1:${DSLINT_SERVE_PORT}`,
32
+ target: `http://127.0.0.1:${DSLINTER_SERVE_PORT}`,
22
33
  changeOrigin: true,
23
34
  },
24
35
  },
@@ -8,23 +8,26 @@ import {
8
8
  } from "./collectScanModules";
9
9
 
10
10
  describe("embedGlobKeyFromRelPath", () => {
11
- it("maps Laravel rel_path to @dslint-scan key", () => {
11
+ it("maps Laravel rel_path to @dslinter-scan key", () => {
12
12
  expect(
13
13
  embedGlobKeyFromRelPath(
14
14
  "resources/js/Components/Billing/AdditionalEventLimitModal.tsx",
15
15
  ),
16
16
  ).toBe(
17
- "@dslint-scan/resources/js/Components/Billing/AdditionalEventLimitModal.tsx",
17
+ "@dslinter-scan/resources/js/Components/Billing/AdditionalEventLimitModal.tsx",
18
18
  );
19
19
  });
20
20
 
21
21
  it("strips leading slashes", () => {
22
22
  expect(embedGlobKeyFromRelPath("/src/components/Foo.tsx")).toBe(
23
- "@dslint-scan/src/components/Foo.tsx",
23
+ "@dslinter-scan/src/components/Foo.tsx",
24
24
  );
25
25
  });
26
26
  });
27
27
 
28
+ const caseInsensitiveFs =
29
+ process.platform === "darwin" || process.platform === "win32";
30
+
28
31
  describe("collectScanModuleRelPaths", () => {
29
32
  it("collects tsx/jsx and skips node_modules", () => {
30
33
  const root = mkdtempSync(join(tmpdir(), "dslinter-scan-"));
@@ -39,4 +42,89 @@ describe("collectScanModuleRelPaths", () => {
39
42
  const paths = collectScanModuleRelPaths(root);
40
43
  expect(paths).toEqual(["resources/js/Components/Button.tsx"]);
41
44
  });
45
+
46
+ it("respects .dslinter.json include_dirs", () => {
47
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-inc-"));
48
+ mkdirSync(join(root, "resources", "js", "components", "ui"), {
49
+ recursive: true,
50
+ });
51
+ mkdirSync(join(root, "resources", "js", "pages"), { recursive: true });
52
+ writeFileSync(
53
+ join(root, "resources", "js", "components", "ui", "button.tsx"),
54
+ "export function Button() { return null; }",
55
+ );
56
+ writeFileSync(
57
+ join(root, "resources", "js", "pages", "Home.tsx"),
58
+ "export default function Home() { return null; }",
59
+ );
60
+ writeFileSync(
61
+ join(root, ".dslinter.json"),
62
+ JSON.stringify({ include_dirs: ["resources/js/components"] }),
63
+ );
64
+
65
+ const paths = collectScanModuleRelPaths(root);
66
+ expect(paths).toEqual(["resources/js/components/ui/button.tsx"]);
67
+ });
68
+
69
+ it.skipIf(!caseInsensitiveFs)(
70
+ "matches include_dirs case-insensitively on case-insensitive filesystems",
71
+ () => {
72
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-case-"));
73
+ mkdirSync(join(root, "resources", "js", "Components"), { recursive: true });
74
+ writeFileSync(
75
+ join(root, "resources", "js", "Components", "Button.tsx"),
76
+ "export function Button() { return null; }",
77
+ );
78
+ writeFileSync(
79
+ join(root, ".dslinter.json"),
80
+ JSON.stringify({ include_dirs: ["resources/js/components"] }),
81
+ );
82
+
83
+ const paths = collectScanModuleRelPaths(root);
84
+ expect(paths).toEqual(["resources/js/Components/Button.tsx"]);
85
+ },
86
+ );
87
+
88
+ it.skipIf(caseInsensitiveFs)(
89
+ "requires include_dirs casing to match on case-sensitive filesystems",
90
+ () => {
91
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-case-"));
92
+ mkdirSync(join(root, "resources", "js", "Components"), { recursive: true });
93
+ writeFileSync(
94
+ join(root, "resources", "js", "Components", "Button.tsx"),
95
+ "export function Button() { return null; }",
96
+ );
97
+ writeFileSync(
98
+ join(root, ".dslinter.json"),
99
+ JSON.stringify({ include_dirs: ["resources/js/components"] }),
100
+ );
101
+
102
+ expect(collectScanModuleRelPaths(root)).toEqual([]);
103
+ },
104
+ );
105
+
106
+ it("scopes collection to scanRoot subdirectory", () => {
107
+ const root = mkdtempSync(join(tmpdir(), "dslinter-scan-sub-"));
108
+ mkdirSync(join(root, "resources", "js", "components"), { recursive: true });
109
+ mkdirSync(join(root, "resources", "js", "layouts", "auth"), {
110
+ recursive: true,
111
+ });
112
+ writeFileSync(
113
+ join(root, "resources", "js", "components", "Button.tsx"),
114
+ "export function Button() { return null; }",
115
+ );
116
+ writeFileSync(
117
+ join(root, "resources", "js", "layouts", "auth", "Split.tsx"),
118
+ "export function Split() { return null; }",
119
+ );
120
+ writeFileSync(
121
+ join(root, ".dslinter.json"),
122
+ JSON.stringify({ include_dirs: ["resources/js"] }),
123
+ );
124
+
125
+ const paths = collectScanModuleRelPaths(
126
+ join(root, "resources", "js", "components"),
127
+ );
128
+ expect(paths).toEqual(["resources/js/components/Button.tsx"]);
129
+ });
42
130
  });
@@ -1,5 +1,8 @@
1
- import { readdirSync } from "node:fs";
2
- import { join, relative, resolve } from "node:path";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { embedGlobKeyFromRelPath } from "../src/playground/embedGlobKey";
4
+
5
+ const CONFIG_NAMES = [".dslinter.json", "dslinter.json"];
3
6
 
4
7
  const SKIP_DIR_NAMES = new Set([
5
8
  "node_modules",
@@ -20,41 +23,103 @@ const SKIP_DIR_NAMES = new Set([
20
23
 
21
24
  const SOURCE_EXT = /\.(tsx|jsx)$/;
22
25
 
26
+ function findConfigPath(startDir: string): string | null {
27
+ let dir = resolve(startDir);
28
+ for (;;) {
29
+ for (const name of CONFIG_NAMES) {
30
+ const candidate = join(dir, name);
31
+ if (existsSync(candidate)) return candidate;
32
+ }
33
+ const parent = dirname(dir);
34
+ if (parent === dir) break;
35
+ dir = parent;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ export function projectRootForConfig(startDir: string): string {
41
+ const configPath = findConfigPath(startDir);
42
+ return configPath ? dirname(configPath) : resolve(startDir);
43
+ }
44
+
45
+ export function readIncludeDirs(projectRoot: string): string[] | null {
46
+ const configPath = findConfigPath(projectRoot);
47
+ if (!configPath) return null;
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(configPath, "utf8")) as {
50
+ include_dirs?: string[];
51
+ };
52
+ if (Array.isArray(parsed.include_dirs) && parsed.include_dirs.length > 0) {
53
+ return parsed.include_dirs;
54
+ }
55
+ } catch {
56
+ return null;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function pathMatchesIncludePrefix(relFromProject: string, prefix: string): boolean {
62
+ const norm = prefix.trim().replace(/\\/g, "/").replace(/\/$/, "");
63
+ if (!norm) return false;
64
+ if (process.platform === "darwin" || process.platform === "win32") {
65
+ const rel = relFromProject.toLowerCase();
66
+ const pref = norm.toLowerCase();
67
+ return rel === pref || rel.startsWith(`${pref}/`);
68
+ }
69
+ return relFromProject === norm || relFromProject.startsWith(`${norm}/`);
70
+ }
71
+
72
+ function matchesIncludeDirs(
73
+ relFromProject: string,
74
+ includeDirs: string[] | null,
75
+ ): boolean {
76
+ if (!includeDirs) return true;
77
+ return includeDirs.some((dir) =>
78
+ pathMatchesIncludePrefix(relFromProject, dir),
79
+ );
80
+ }
81
+
82
+ function walkDir(
83
+ dir: string,
84
+ projectRoot: string,
85
+ includeDirs: string[] | null,
86
+ out: string[],
87
+ ): void {
88
+ let entries;
89
+ try {
90
+ entries = readdirSync(dir, { withFileTypes: true });
91
+ } catch {
92
+ return;
93
+ }
94
+ for (const ent of entries) {
95
+ if (ent.name.startsWith(".") && ent.name !== ".") continue;
96
+ const full = join(dir, ent.name);
97
+ if (ent.isDirectory()) {
98
+ if (SKIP_DIR_NAMES.has(ent.name)) continue;
99
+ walkDir(full, projectRoot, includeDirs, out);
100
+ } else if (ent.isFile() && SOURCE_EXT.test(ent.name)) {
101
+ const relFromProject = relative(projectRoot, full).replace(/\\/g, "/");
102
+ if (!matchesIncludeDirs(relFromProject, includeDirs)) continue;
103
+ out.push(relFromProject);
104
+ }
105
+ }
106
+ }
107
+
23
108
  /**
24
109
  * Collect repo-relative posix paths for `.tsx`/`.jsx` files under `scanRoot`.
110
+ * Config and `include_dirs` resolve from the nearest project root; only files
111
+ * under `scanRoot` are walked.
25
112
  */
26
113
  export function collectScanModuleRelPaths(scanRoot: string): string[] {
27
- const root = resolve(scanRoot);
114
+ const scanAbs = resolve(scanRoot);
115
+ const projectRoot = projectRootForConfig(scanAbs);
116
+ const includeDirs = readIncludeDirs(projectRoot);
28
117
  const out: string[] = [];
29
118
 
30
- function walk(dir: string): void {
31
- let entries;
32
- try {
33
- entries = readdirSync(dir, { withFileTypes: true });
34
- } catch {
35
- return;
36
- }
37
- for (const ent of entries) {
38
- if (ent.name.startsWith(".") && ent.name !== ".") continue;
39
- const full = join(dir, ent.name);
40
- if (ent.isDirectory()) {
41
- if (SKIP_DIR_NAMES.has(ent.name)) continue;
42
- walk(full);
43
- } else if (ent.isFile() && SOURCE_EXT.test(ent.name)) {
44
- out.push(relative(root, full).replace(/\\/g, "/"));
45
- }
46
- }
47
- }
119
+ walkDir(scanAbs, projectRoot, includeDirs, out);
48
120
 
49
- walk(root);
50
121
  out.sort();
51
122
  return out;
52
123
  }
53
124
 
54
- /**
55
- * Virtual module map key for a scanner `rel_path` (embed convention).
56
- */
57
- export function embedGlobKeyFromRelPath(relPath: string): string {
58
- const trimmed = relPath.replace(/^\/+/, "");
59
- return `@dslint-scan/${trimmed}`;
60
- }
125
+ export { embedGlobKeyFromRelPath };
@@ -2,10 +2,10 @@ import { defineConfig, loadConfigFromFile, mergeConfig } from "vite";
2
2
 
3
3
  /**
4
4
  * Merges the consumer's vite.config with the dslinter plugin.
5
- * Used by `npx dslinter` when a host Vite app is detected (`DSLINT_VITE_ROOT`).
5
+ * Used by `npx dslinter` when a host Vite app is detected (`DSLINTER_VITE_ROOT`).
6
6
  */
7
7
  export default defineConfig(async ({ command, mode }) => {
8
- const viteRoot = process.env.DSLINT_VITE_ROOT?.trim() || process.cwd();
8
+ const viteRoot = process.env.DSLINTER_VITE_ROOT?.trim() || process.cwd();
9
9
  const loaded = await loadConfigFromFile(
10
10
  { command, mode },
11
11
  undefined,
@@ -13,10 +13,13 @@ export default defineConfig(async ({ command, mode }) => {
13
13
  );
14
14
  const userConfig = loaded?.config ?? {};
15
15
  const { default: dslinter } = await import("./plugin.ts");
16
+ const scanRoot = process.env.DSLINTER_SCAN_ROOT?.trim() || viteRoot;
17
+ const consumerViteRoot =
18
+ process.env.DSLINTER_CONSUMER_VITE_ROOT?.trim() || viteRoot;
16
19
  return mergeConfig(
17
20
  userConfig,
18
21
  defineConfig({
19
- plugins: [dslinter()],
22
+ plugins: [dslinter({ scanRoot, consumerViteRoot })],
20
23
  }),
21
24
  );
22
25
  });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ flattenViteAlias,
4
+ importerUnderScanRoot,
5
+ resolveWithConsumerAliases,
6
+ } from "./consumerAlias";
7
+
8
+ describe("flattenViteAlias", () => {
9
+ it("resolves @/ prefix from record alias", () => {
10
+ const aliases = flattenViteAlias(
11
+ { "@": "/app/resources/js", "@/": "/app/resources/js/" },
12
+ "/app",
13
+ );
14
+ const resolved = resolveWithConsumerAliases(
15
+ "@/Components/Button",
16
+ aliases,
17
+ );
18
+ expect(resolved?.replace(/\\/g, "/")).toContain("resources/js");
19
+ expect(resolved?.replace(/\\/g, "/")).toContain("Components/Button");
20
+ });
21
+ });
22
+
23
+ describe("importerUnderScanRoot", () => {
24
+ it("matches files under scan root", () => {
25
+ expect(
26
+ importerUnderScanRoot(
27
+ "/repo/resources/js/Components/Foo.tsx",
28
+ "/repo",
29
+ ),
30
+ ).toBe(true);
31
+ expect(
32
+ importerUnderScanRoot(
33
+ "/other/pkg/Button.tsx",
34
+ "/repo",
35
+ ),
36
+ ).toBe(false);
37
+ });
38
+
39
+ it("matches paths with Vite query suffixes", () => {
40
+ expect(
41
+ importerUnderScanRoot(
42
+ "/repo/resources/js/hooks/use-current-url.ts?v=abc",
43
+ "/repo",
44
+ ),
45
+ ).toBe(true);
46
+ });
47
+ });
@@ -0,0 +1,114 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
2
+ import type { Alias, AliasOptions } from "vite";
3
+
4
+ export type FlatAlias = {
5
+ find: string | RegExp;
6
+ replacement: string;
7
+ };
8
+
9
+ function normalizeFind(find: string | RegExp): string | RegExp {
10
+ if (typeof find !== "string") return find;
11
+ if (find.endsWith("/") && !find.includes("*")) {
12
+ return find.slice(0, -1);
13
+ }
14
+ return find;
15
+ }
16
+
17
+ /**
18
+ * Flatten Vite `resolve.alias` for use in a custom resolveId hook.
19
+ */
20
+ export function flattenViteAlias(
21
+ alias: AliasOptions | undefined,
22
+ consumerRoot: string,
23
+ ): FlatAlias[] {
24
+ if (!alias) return [];
25
+ const root = resolve(consumerRoot);
26
+ const out: FlatAlias[] = [];
27
+
28
+ const push = (find: string | RegExp, replacement: string) => {
29
+ const rep = isAbsolute(replacement)
30
+ ? replacement
31
+ : resolve(root, replacement);
32
+ out.push({ find: normalizeFind(find), replacement: rep });
33
+ };
34
+
35
+ if (Array.isArray(alias)) {
36
+ for (const entry of alias) {
37
+ if (typeof entry === "object" && entry !== null && "find" in entry) {
38
+ const e = entry as Alias;
39
+ push(e.find, String(e.replacement));
40
+ }
41
+ }
42
+ return out.sort((a, b) => {
43
+ const al = typeof a.find === "string" ? a.find.length : 0;
44
+ const bl = typeof b.find === "string" ? b.find.length : 0;
45
+ return bl - al;
46
+ });
47
+ }
48
+
49
+ for (const [find, replacement] of Object.entries(alias)) {
50
+ push(find, String(replacement));
51
+ }
52
+ return out.sort((a, b) => {
53
+ const al = typeof a.find === "string" ? a.find.length : 0;
54
+ const bl = typeof b.find === "string" ? b.find.length : 0;
55
+ return bl - al;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Resolve `id` using consumer aliases (longest prefix first for strings).
61
+ */
62
+ export function resolveWithConsumerAliases(
63
+ id: string,
64
+ aliases: FlatAlias[],
65
+ ): string | null {
66
+ for (const { find, replacement } of aliases) {
67
+ if (typeof find === "string") {
68
+ if (find.endsWith("/")) {
69
+ if (id.startsWith(find)) {
70
+ const sub = id.slice(find.length);
71
+ return join(replacement, sub);
72
+ }
73
+ const findNoSlash = find.slice(0, -1);
74
+ if (id === findNoSlash || id.startsWith(`${findNoSlash}/`)) {
75
+ const sub = id === findNoSlash ? "" : id.slice(findNoSlash.length + 1);
76
+ return sub ? join(replacement, sub) : replacement;
77
+ }
78
+ } else if (id === find) {
79
+ return replacement;
80
+ } else if (id.startsWith(`${find}/`)) {
81
+ const sub = id.slice(find.length + 1);
82
+ return join(replacement, sub);
83
+ }
84
+ } else {
85
+ const m = id.match(find);
86
+ if (m) {
87
+ return id.replace(find, replacement);
88
+ }
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** True when `importer` is a file under `scanRoot`. */
95
+ export function importerUnderScanRoot(
96
+ importer: string | undefined,
97
+ scanRoot: string,
98
+ ): boolean {
99
+ if (!importer || importer === "\0virtual") return false;
100
+ const root = resolve(scanRoot).replace(/\\/g, "/");
101
+ let norm = importer.replace(/\\/g, "/");
102
+ if (norm.startsWith("\0")) norm = norm.slice(1);
103
+ const q = norm.indexOf("?");
104
+ if (q !== -1) norm = norm.slice(0, q);
105
+ const rootWithSlash = root.endsWith("/") ? root : `${root}/`;
106
+ return norm === root || norm.startsWith(rootWithSlash);
107
+ }
108
+
109
+ export const INERTIA_SHIM_IDS = new Set([
110
+ "@inertiajs/react",
111
+ "@inertiajs/react/server",
112
+ ]);
113
+
114
+ export const ZIGGY_SHIM_ID = "ziggy-js";
@@ -0,0 +1,74 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { mkdtempSync } from "node:fs";
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ buildEmbedIndexCss,
8
+ embedSourcePathsRelativeToCss,
9
+ resolveEmbedConsumerSourceDirs,
10
+ shouldInjectEmbedConsumerSources,
11
+ } from "./embedTailwindSources";
12
+
13
+ const packageRoot = resolve(import.meta.dirname, "..");
14
+ const embedCssPath = join(packageRoot, "embed", "index.css");
15
+
16
+ describe("embedTailwindSources", () => {
17
+ it("maps Laravel include_dirs to paths relative to embed/index.css", () => {
18
+ const root = mkdtempSync(join(tmpdir(), "dslinter-embed-src-"));
19
+ mkdirSync(join(root, "resources", "js", "components"), { recursive: true });
20
+ writeFileSync(
21
+ join(root, ".dslinter.json"),
22
+ JSON.stringify({ include_dirs: ["resources/js/components"] }),
23
+ );
24
+
25
+ const paths = embedSourcePathsRelativeToCss(root, packageRoot, embedCssPath);
26
+ expect(paths.length).toBe(1);
27
+ expect(paths[0]).toMatch(/resources\/js\/components$/);
28
+ expect(resolve(join(packageRoot, "embed"), paths[0]!)).toBe(
29
+ join(root, "resources", "js", "components"),
30
+ );
31
+ });
32
+
33
+ it("falls back to src when include_dirs is absent", () => {
34
+ const root = mkdtempSync(join(tmpdir(), "dslinter-embed-fallback-"));
35
+ mkdirSync(join(root, "src", "components"), { recursive: true });
36
+
37
+ const absDirs = resolveEmbedConsumerSourceDirs(root, packageRoot);
38
+ expect(absDirs).toEqual([join(root, "src")]);
39
+ });
40
+
41
+ it("skips include_dirs that do not exist on disk", () => {
42
+ const root = mkdtempSync(join(tmpdir(), "dslinter-embed-missing-"));
43
+ writeFileSync(
44
+ join(root, ".dslinter.json"),
45
+ JSON.stringify({ include_dirs: ["resources/js/components"] }),
46
+ );
47
+
48
+ expect(resolveEmbedConsumerSourceDirs(root, packageRoot)).toEqual([]);
49
+ });
50
+
51
+ it("buildEmbedIndexCss splices consumer @source after dashboard src", () => {
52
+ const base = `@import "tailwindcss";\n@source "../src";\n@import "../src/styles/dashboard-theme.css";\n`;
53
+ const out = buildEmbedIndexCss(base, ["../../../demo/src/components"]);
54
+ expect(out).toContain('@source "../src";');
55
+ expect(out).toContain('@source "../../../demo/src/components";');
56
+ expect(out.indexOf('../src";')).toBeLessThan(
57
+ out.indexOf("demo/src/components"),
58
+ );
59
+ });
60
+
61
+ it("shouldInjectEmbedConsumerSources when scan root differs from package root", () => {
62
+ const root = mkdtempSync(join(tmpdir(), "dslinter-embed-inject-"));
63
+ mkdirSync(join(root, "resources", "js", "components"), { recursive: true });
64
+ writeFileSync(
65
+ join(root, ".dslinter.json"),
66
+ JSON.stringify({ include_dirs: ["resources/js/components"] }),
67
+ );
68
+
69
+ expect(shouldInjectEmbedConsumerSources(root, packageRoot)).toBe(true);
70
+ expect(shouldInjectEmbedConsumerSources(packageRoot, packageRoot)).toBe(
71
+ false,
72
+ );
73
+ });
74
+ });