dslinter 0.1.13 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +50 -29
  3. package/bin/dslinter.mjs +26 -5
  4. package/bin/lib/config-hide-component.mjs +44 -0
  5. package/bin/lib/config-hide-component.test.mjs +33 -0
  6. package/bin/lib/constants.mjs +20 -0
  7. package/bin/lib/dev-banner.mjs +16 -51
  8. package/bin/lib/dev-banner.test.mjs +20 -18
  9. package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
  10. package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
  11. package/bin/lib/enrich-report-cli.mjs +14 -0
  12. package/bin/lib/env.mjs +20 -0
  13. package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
  14. package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
  15. package/bin/lib/parse-args.mjs +13 -1
  16. package/bin/lib/parse-args.test.mjs +7 -1
  17. package/bin/lib/paths.mjs +8 -0
  18. package/bin/lib/project-root.mjs +72 -10
  19. package/bin/lib/project-root.test.mjs +32 -1
  20. package/bin/lib/prompt.mjs +31 -0
  21. package/bin/lib/resolve-project.mjs +78 -0
  22. package/bin/lib/resolve-project.test.mjs +74 -0
  23. package/bin/lib/run-scanner.mjs +40 -6
  24. package/bin/lib/scaffold-config.mjs +128 -9
  25. package/bin/lib/scaffold-config.test.mjs +24 -2
  26. package/bin/lib/scan-host.mjs +44 -0
  27. package/bin/lib/scan-host.test.mjs +41 -0
  28. package/bin/lib/setup-readiness.mjs +153 -0
  29. package/bin/lib/setup-readiness.test.mjs +32 -0
  30. package/bin/modes/build.mjs +31 -6
  31. package/bin/modes/dev.mjs +55 -21
  32. package/bin/modes/init.mjs +3 -22
  33. package/bin/modes/init.test.mjs +1 -1
  34. package/bin/modes/mcp.mjs +49 -0
  35. package/bin/modes/report.mjs +29 -4
  36. package/bin/modes/watch.mjs +85 -0
  37. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
  39. package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
  40. package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
  41. package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
  42. package/dashboard-dist/index.html +2 -2
  43. package/index.cjs +53 -52
  44. package/index.d.ts +3 -0
  45. package/package.json +18 -12
  46. package/shared/env.ts +15 -0
  47. package/shared/paths.ts +8 -0
  48. package/shared/reportPath.test.ts +19 -0
  49. package/shared/reportPath.ts +12 -0
  50. package/shared/servePort.ts +16 -0
  51. package/src/components/ComponentInspectPane.tsx +67 -19
  52. package/src/components/ComponentPlaygroundPane.tsx +262 -113
  53. package/src/components/DashboardCommandPalette.tsx +6 -11
  54. package/src/components/GovernancePane.tsx +2 -2
  55. package/src/components/HideFromCatalogButton.tsx +44 -0
  56. package/src/components/OpenInEditorButton.tsx +36 -0
  57. package/src/components/PlaygroundA11yAndCode.tsx +53 -53
  58. package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
  59. package/src/components/PlaygroundControls.tsx +5 -11
  60. package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
  61. package/src/components/PlaygroundUsageCode.tsx +6 -4
  62. package/src/components/PlaygroundVariantMatrix.tsx +101 -34
  63. package/src/components/Section.tsx +5 -2
  64. package/src/components/Sidebar.tsx +131 -46
  65. package/src/components/TruncatedPath.tsx +44 -0
  66. package/src/components/controlApiTable.test.ts +29 -0
  67. package/src/components/controlApiTable.ts +3 -0
  68. package/src/components/playgroundUsageHighlight.ts +14 -3
  69. package/src/components/ui/badge.tsx +1 -1
  70. package/src/components/ui/table.tsx +2 -2
  71. package/src/dashboard/ComponentCatalog.tsx +16 -23
  72. package/src/dashboard/ComponentUsageDetails.tsx +6 -15
  73. package/src/dashboard/DashboardBody.tsx +0 -35
  74. package/src/dashboard/FindingsList.tsx +65 -55
  75. package/src/dashboard/ScannedTokenWall.tsx +3 -3
  76. package/src/dashboard/aggregate.test.ts +74 -0
  77. package/src/dashboard/aggregate.ts +145 -21
  78. package/src/dashboard/catalogVisibility.test.ts +93 -0
  79. package/src/dashboard/catalogVisibility.ts +108 -0
  80. package/src/dashboard/editorLink.test.ts +57 -0
  81. package/src/dashboard/editorLink.ts +71 -0
  82. package/src/dashboard/paths.test.ts +49 -0
  83. package/src/dashboard/paths.ts +51 -3
  84. package/src/dashboard/updateDslintConfig.ts +22 -0
  85. package/src/dashboard/useWorkspaceReport.ts +21 -17
  86. package/src/index.ts +26 -0
  87. package/src/mcp/agent-context.ts +148 -0
  88. package/src/mcp/agent-query.test.ts +89 -0
  89. package/src/mcp/agent-query.ts +373 -0
  90. package/src/mcp/config.ts +53 -0
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/normalize-paths.ts +65 -0
  93. package/src/mcp/report-cache.ts +212 -0
  94. package/src/mcp/rule-catalog.json +156 -0
  95. package/src/mcp/rule-catalog.ts +33 -0
  96. package/src/mcp/schemas.ts +54 -0
  97. package/src/mcp/server.test.ts +44 -0
  98. package/src/mcp/server.ts +343 -0
  99. package/src/mcp/start.ts +29 -0
  100. package/src/mcp/verify-loop.test.ts +49 -0
  101. package/src/mcp/verify-loop.ts +149 -0
  102. package/src/playground/appPreviewTheme.test.ts +148 -0
  103. package/src/playground/appPreviewTheme.ts +137 -0
  104. package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
  105. package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
  106. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
  107. package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
  108. package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
  109. package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
  110. package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
  111. package/src/playground/collectDefinedPlaygrounds.ts +68 -0
  112. package/src/playground/controls.ts +177 -0
  113. package/src/playground/createPlaygroundRegistry.ts +1 -1
  114. package/src/playground/definePlayground.tsx +88 -16
  115. package/src/playground/definePlaygroundFromKit.ts +17 -0
  116. package/src/playground/embedGlobKey.ts +8 -0
  117. package/src/playground/enrichKitControls.test.ts +25 -0
  118. package/src/playground/enrichKitControls.ts +197 -0
  119. package/src/playground/expandPlaygroundControls.test.ts +50 -0
  120. package/src/playground/expandPlaygroundControls.ts +97 -0
  121. package/src/playground/inferKitJsx.test.ts +77 -0
  122. package/src/playground/inferKitJsx.ts +165 -0
  123. package/src/playground/inferKitParams.test.ts +41 -0
  124. package/src/playground/inferKitParams.ts +113 -0
  125. package/src/playground/inferPropTypesFromTs.d.mts +47 -0
  126. package/src/playground/inferPropTypesFromTs.mjs +343 -0
  127. package/src/playground/inferPropTypesFromTs.test.ts +227 -0
  128. package/src/playground/inferPropTypesFromTs.ts +17 -0
  129. package/src/playground/mergePlaygroundEntries.test.ts +32 -0
  130. package/src/playground/mergePlaygroundEntries.ts +28 -0
  131. package/src/playground/playgroundJoin.test.ts +79 -19
  132. package/src/playground/playgroundJoin.ts +47 -22
  133. package/src/playground/playgroundModuleExport.test.ts +42 -0
  134. package/src/playground/playgroundModuleExport.ts +22 -0
  135. package/src/playground/playgroundSpecsKey.ts +8 -0
  136. package/src/playground/propCoerce.ts +91 -0
  137. package/src/playground/scanVariantA11y.test.ts +46 -0
  138. package/src/playground/scanVariantA11y.ts +107 -0
  139. package/src/playground/snippet.ts +83 -0
  140. package/src/playground/usePlaygroundFromReport.test.ts +18 -8
  141. package/src/playground/usePlaygroundFromReport.ts +3 -1
  142. package/src/report/a11yForModule.ts +2 -7
  143. package/src/report/a11yScoring.test.ts +24 -0
  144. package/src/report/a11yScoring.ts +17 -0
  145. package/src/report/index.ts +6 -0
  146. package/src/shell/DashboardLayout.tsx +71 -45
  147. package/src/shell/DashboardLayoutAuto.tsx +0 -4
  148. package/src/shell/hashRoute.test.ts +7 -15
  149. package/src/shell/hashRoute.ts +31 -31
  150. package/src/shell/useHashRoute.ts +38 -13
  151. package/src/styles/dashboard-theme.css +18 -7
  152. package/src/types/controls.ts +11 -0
  153. package/src/types/playground.ts +4 -0
  154. package/src/types/report.ts +32 -9
  155. package/templates/playground/buildRegistry.ts +1 -1
  156. package/templates/vite.dslinter.snippet.ts +15 -4
  157. package/vite/collectScanModules.test.ts +91 -3
  158. package/vite/collectScanModules.ts +94 -29
  159. package/vite/consumer.config.mjs +6 -3
  160. package/vite/consumerAlias.test.ts +47 -0
  161. package/vite/consumerAlias.ts +114 -0
  162. package/vite/embedTailwindSources.test.ts +74 -0
  163. package/vite/embedTailwindSources.ts +97 -0
  164. package/vite/loadConsumerAliases.test.ts +131 -0
  165. package/vite/loadConsumerAliases.ts +155 -0
  166. package/vite/openFileInEditor.mjs +196 -0
  167. package/vite/openFileInEditor.test.mjs +87 -0
  168. package/vite/plugin.resolve.test.ts +72 -0
  169. package/vite/plugin.ts +216 -19
  170. package/vite/reportPath.test.ts +19 -0
  171. package/vite/resolveWayfinderImport.ts +56 -0
  172. package/vite/shims/inertia-react.tsx +85 -0
  173. package/vite/shims/wayfinder-actions.ts +33 -0
  174. package/vite/shims/wayfinder-routes.ts +30 -0
  175. package/vite/shims/ziggy-js.ts +12 -0
  176. package/dashboard-dist/assets/DashboardLayoutAuto-Bm7yfyC-.css +0 -1
  177. package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
  178. package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
  179. package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
  180. package/src/components/playgroundUsageTwoslash.ts +0 -69
  181. package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildEditorFileUri,
4
+ resolveModuleAbsolutePath,
5
+ } from "./editorLink";
6
+
7
+ describe("resolveModuleAbsolutePath", () => {
8
+ it("resolves embed scan keys under report root", () => {
9
+ expect(
10
+ resolveModuleAbsolutePath(
11
+ "/Users/dev/demo-inertia",
12
+ "@dslinter-scan/resources/js/components/app-logo-icon.tsx",
13
+ ),
14
+ ).toBe(
15
+ "/Users/dev/demo-inertia/resources/js/components/app-logo-icon.tsx",
16
+ );
17
+ });
18
+
19
+ it("resolves consumer glob keys via src/components convention", () => {
20
+ expect(
21
+ resolveModuleAbsolutePath(
22
+ "/repo",
23
+ "../components/ui/button.tsx",
24
+ ),
25
+ ).toBe("/repo/src/components/ui/button.tsx");
26
+ });
27
+ });
28
+
29
+ describe("buildEditorFileUri", () => {
30
+ it("builds a vscode URI for unix paths", () => {
31
+ expect(
32
+ buildEditorFileUri(
33
+ "/Users/dev/demo-inertia/resources/js/components/app-logo-icon.tsx",
34
+ ),
35
+ ).toBe(
36
+ "vscode://file/Users/dev/demo-inertia/resources/js/components/app-logo-icon.tsx",
37
+ );
38
+ });
39
+
40
+ it("supports cursor scheme", () => {
41
+ expect(
42
+ buildEditorFileUri("/repo/src/Button.tsx", 2, 1, "cursor"),
43
+ ).toBe("cursor://file/repo/src/Button.tsx:2:1");
44
+ });
45
+
46
+ it("includes line and column when provided", () => {
47
+ expect(
48
+ buildEditorFileUri("/repo/src/Button.tsx", 14),
49
+ ).toBe("vscode://file/repo/src/Button.tsx:14:1");
50
+ });
51
+
52
+ it("builds a vscode URI for windows paths", () => {
53
+ expect(
54
+ buildEditorFileUri("C:/project/src/Button.tsx", 3),
55
+ ).toBe("vscode://file/c:/project/src/Button.tsx:3:1");
56
+ });
57
+ });
@@ -0,0 +1,71 @@
1
+ import { resolveModuleSourcePath } from "../report/modulePathMatch";
2
+ import { normalizePath } from "./paths";
3
+
4
+ const EMBED_PREFIX = "@dslinter-scan/";
5
+
6
+ /** Resolve a playground `modulePath` to an absolute file path under `reportRoot`. */
7
+ export function resolveModuleAbsolutePath(
8
+ reportRoot: string,
9
+ modulePath: string,
10
+ ): string {
11
+ const normalized = normalizePath(modulePath);
12
+ if (normalized.startsWith(EMBED_PREFIX)) {
13
+ const root = normalizePath(reportRoot);
14
+ return `${root}/${normalized.slice(EMBED_PREFIX.length)}`;
15
+ }
16
+ return resolveModuleSourcePath(reportRoot, modulePath);
17
+ }
18
+
19
+ const OPEN_FILE_PATH = "/dslinter/open-file";
20
+
21
+ /** Open a local file via the dev server, falling back to editor URI handlers. */
22
+ export async function openSourceFile(
23
+ absolutePath: string,
24
+ line?: number,
25
+ column = 1,
26
+ ): Promise<void> {
27
+ if (typeof window === "undefined") return;
28
+
29
+ const params = new URLSearchParams({ path: absolutePath });
30
+ if (line != null) params.set("line", String(line));
31
+ params.set("column", String(column));
32
+
33
+ try {
34
+ const res = await fetch(`${OPEN_FILE_PATH}?${params}`, { method: "POST" });
35
+ if (res.ok) return;
36
+ } catch {
37
+ // Static build or server unavailable — fall back to protocol links below.
38
+ }
39
+
40
+ window.location.assign(
41
+ buildEditorFileUri(absolutePath, line, column, "cursor"),
42
+ );
43
+ }
44
+
45
+ /** Open a local file in VS Code / Cursor via the editor URI handler. */
46
+ export function buildEditorFileUri(
47
+ absolutePath: string,
48
+ line?: number,
49
+ column = 1,
50
+ scheme: "vscode" | "cursor" = "vscode",
51
+ ): string {
52
+ const path = normalizePath(absolutePath);
53
+ const suffix = line != null ? `:${line}:${column}` : "";
54
+
55
+ if (/^[a-zA-Z]:/.test(path)) {
56
+ const drive = path.slice(0, 2).toLowerCase();
57
+ const rest = path
58
+ .slice(2)
59
+ .replace(/^\//, "")
60
+ .split("/")
61
+ .map(encodeURIComponent)
62
+ .join("/");
63
+ return `${scheme}://file/${drive}/${rest}${suffix}`;
64
+ }
65
+
66
+ const encoded = path
67
+ .split("/")
68
+ .map((segment, index) => (index === 0 && segment === "" ? "" : encodeURIComponent(segment)))
69
+ .join("/");
70
+ return `${scheme}://file${encoded}${suffix}`;
71
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { truncatePathMiddle } from "./paths";
3
+
4
+ const SAMPLE =
5
+ "resources/js/components/manage-two-factor.tsx";
6
+
7
+ describe("truncatePathMiddle", () => {
8
+ it("returns the path unchanged when it fits", () => {
9
+ expect(truncatePathMiddle(SAMPLE, 100)).toBe(SAMPLE);
10
+ expect(truncatePathMiddle(SAMPLE, SAMPLE.length)).toBe(SAMPLE);
11
+ });
12
+
13
+ it("drops middle directories while keeping filename", () => {
14
+ expect(truncatePathMiddle(SAMPLE, 41)).toBe(
15
+ "resources/js/.../manage-two-factor.tsx",
16
+ );
17
+ });
18
+
19
+ it("progressively collapses more leading segments", () => {
20
+ expect(truncatePathMiddle(SAMPLE, 37)).toBe(
21
+ "resources/.../manage-two-factor.tsx",
22
+ );
23
+ expect(truncatePathMiddle(SAMPLE, 27)).toBe(
24
+ ".../manage-two-factor.tsx",
25
+ );
26
+ });
27
+
28
+ it("normalizes backslashes", () => {
29
+ expect(
30
+ truncatePathMiddle(
31
+ "resources\\js\\components\\manage-two-factor.tsx",
32
+ 100,
33
+ ),
34
+ ).toBe(SAMPLE);
35
+ });
36
+
37
+ it("returns a single long filename when there are no directories", () => {
38
+ const filename = "manage-two-factor-with-a-very-long-name.tsx";
39
+ expect(truncatePathMiddle(filename, 10)).toBe(filename);
40
+ });
41
+
42
+ it("prefers filename over prefix when budget is too small for ellipsis", () => {
43
+ expect(truncatePathMiddle(SAMPLE, 5)).toBe("manage-two-factor.tsx");
44
+ });
45
+
46
+ it("handles empty path", () => {
47
+ expect(truncatePathMiddle("", 10)).toBe("");
48
+ });
49
+ });
@@ -1,10 +1,58 @@
1
+ const ELLIPSIS = "/.../";
2
+
3
+ export function normalizePath(p: string): string {
4
+ return p.replace(/\\/g, "/").replace(/\/$/, "");
5
+ }
6
+
1
7
  export function shortPath(root: string, fullPath: string): string {
2
- const norm = (p: string) => p.replace(/\\/g, "/").replace(/\/$/, "");
3
- const r = norm(root);
4
- const f = norm(fullPath);
8
+ const r = normalizePath(root);
9
+ const f = normalizePath(fullPath);
5
10
  if (f.startsWith(r + "/")) {
6
11
  return f.slice(r.length + 1);
7
12
  }
8
13
  const parts = f.split("/");
9
14
  return parts.slice(-3).join("/");
10
15
  }
16
+
17
+ /**
18
+ * Truncate a file path from the middle, preserving the filename and path
19
+ * separators. Leading path segments are kept when space allows.
20
+ */
21
+ export function truncatePathMiddle(path: string, maxLength: number): string {
22
+ const normalized = normalizePath(path);
23
+ if (normalized.length <= maxLength) return normalized;
24
+ if (maxLength <= 0) return normalized;
25
+
26
+ const segments = normalized.split("/");
27
+ const filename = segments.pop() ?? "";
28
+ if (segments.length === 0) return filename;
29
+
30
+ for (let keep = segments.length; keep >= 0; keep--) {
31
+ const prefix = segments.slice(0, keep).join("/");
32
+ const result =
33
+ keep === 0 ? `.../${filename}` : `${prefix}${ELLIPSIS}${filename}`;
34
+ if (result.length <= maxLength) return result;
35
+ }
36
+
37
+ return filename;
38
+ }
39
+
40
+ /** How many monospace characters fit in an element's client width. */
41
+ export function monospaceCharCountThatFits(element: HTMLElement): number {
42
+ const width = element.clientWidth;
43
+ if (width <= 0) return 1;
44
+
45
+ const style = getComputedStyle(element);
46
+ const probe = document.createElement("span");
47
+ probe.textContent = "m";
48
+ probe.style.position = "absolute";
49
+ probe.style.visibility = "hidden";
50
+ probe.style.whiteSpace = "pre";
51
+ probe.style.font = style.font;
52
+ document.body.appendChild(probe);
53
+ const charWidth = probe.getBoundingClientRect().width;
54
+ probe.remove();
55
+
56
+ if (charWidth <= 0) return 1;
57
+ return Math.max(1, Math.floor(width / charWidth));
58
+ }
@@ -0,0 +1,22 @@
1
+ const CONFIG_HIDE_PATH = "/dslinter-config/hide-component";
2
+
3
+ export type HideCatalogComponentResult = {
4
+ ok: boolean;
5
+ hidden_components: string[];
6
+ };
7
+
8
+ /** Append a component name to `hidden_components` in `.dslinter.json` (dev server only). */
9
+ export async function hideCatalogComponent(
10
+ componentName: string,
11
+ ): Promise<HideCatalogComponentResult> {
12
+ const res = await fetch(CONFIG_HIDE_PATH, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify({ name: componentName }),
16
+ });
17
+ if (!res.ok) {
18
+ const text = await res.text().catch(() => res.statusText);
19
+ throw new Error(text || `Failed to hide component (${res.status})`);
20
+ }
21
+ return (await res.json()) as HideCatalogComponentResult;
22
+ }
@@ -33,11 +33,11 @@ export type UseWorkspaceReportOptions = {
33
33
  };
34
34
 
35
35
  export function useWorkspaceReport(
36
- reportUrlOrOptions: string | UseWorkspaceReportOptions = "/dslint-report.json",
36
+ reportUrlOrOptions: string | UseWorkspaceReportOptions = "/dslinter-report.json",
37
37
  ): DslinterReportState {
38
38
  // Accept either the legacy string overload or the options object.
39
39
  const {
40
- reportUrl = "/dslint-report.json",
40
+ reportUrl = "/dslinter-report.json",
41
41
  watchUrl,
42
42
  refreshIntervalMs = 0,
43
43
  }: UseWorkspaceReportOptions = typeof reportUrlOrOptions === "string"
@@ -50,11 +50,15 @@ export function useWorkspaceReport(
50
50
 
51
51
  // Ref tracking the last known ETag / Last-Modified for poll-based change detection.
52
52
  const etagRef = useRef<string | null>(null);
53
+ // Cancellation refs — one per effect, reset at the top of each effect.
54
+ const initialCancelledRef = useRef(false);
55
+ const sseCancelledRef = useRef(false);
56
+ const pollCancelledRef = useRef(false);
53
57
 
54
58
  // Core fetch function.
55
59
  const fetchReport = (
56
60
  url: string,
57
- cancelled: { value: boolean },
61
+ cancelledRef: { current: boolean },
58
62
  options?: { showLoading?: boolean },
59
63
  ) => {
60
64
  const showLoading = options?.showLoading !== false;
@@ -64,22 +68,22 @@ export function useWorkspaceReport(
64
68
  }
65
69
  loadReport(url)
66
70
  .then((r) => {
67
- if (!cancelled.value) setReport(r);
71
+ if (!cancelledRef.current) setReport(r);
68
72
  })
69
73
  .catch((e: unknown) => {
70
- if (!cancelled.value) setError(e instanceof Error ? e.message : "Failed to load report");
74
+ if (!cancelledRef.current) setError(e instanceof Error ? e.message : "Failed to load report");
71
75
  })
72
76
  .finally(() => {
73
- if (!cancelled.value && showLoading) setLoading(false);
77
+ if (!cancelledRef.current && showLoading) setLoading(false);
74
78
  });
75
79
  };
76
80
 
77
81
  // Initial load.
78
82
  useEffect(() => {
79
- const cancelled = { value: false };
80
- fetchReport(reportUrl, cancelled, { showLoading: true });
83
+ initialCancelledRef.current = false;
84
+ fetchReport(reportUrl, initialCancelledRef, { showLoading: true });
81
85
  return () => {
82
- cancelled.value = true;
86
+ initialCancelledRef.current = true;
83
87
  };
84
88
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
89
  }, [reportUrl]);
@@ -88,12 +92,12 @@ export function useWorkspaceReport(
88
92
  useEffect(() => {
89
93
  if (!watchUrl) return;
90
94
  const source = new EventSource(watchUrl);
91
- const cancelled = { value: false };
95
+ sseCancelledRef.current = false;
92
96
 
93
97
  source.onmessage = (e) => {
94
- if (cancelled.value) return;
98
+ if (sseCancelledRef.current) return;
95
99
  if (e.data === "updated") {
96
- fetchReport(reportUrl, cancelled, { showLoading: false });
100
+ fetchReport(reportUrl, sseCancelledRef, { showLoading: false });
97
101
  }
98
102
  };
99
103
 
@@ -102,7 +106,7 @@ export function useWorkspaceReport(
102
106
  };
103
107
 
104
108
  return () => {
105
- cancelled.value = true;
109
+ sseCancelledRef.current = true;
106
110
  source.close();
107
111
  };
108
112
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -112,10 +116,10 @@ export function useWorkspaceReport(
112
116
  useEffect(() => {
113
117
  if (watchUrl || refreshIntervalMs <= 0) return;
114
118
 
115
- const cancelled = { value: false };
119
+ pollCancelledRef.current = false;
116
120
 
117
121
  const id = setInterval(async () => {
118
- if (cancelled.value) return;
122
+ if (pollCancelledRef.current) return;
119
123
  try {
120
124
  // Use HEAD to check for changes before fetching the full JSON.
121
125
  const head = await fetch(reportUrl, { method: "HEAD", cache: "no-store" });
@@ -125,7 +129,7 @@ export function useWorkspaceReport(
125
129
  return;
126
130
  }
127
131
  etagRef.current = etag;
128
- fetchReport(reportUrl, cancelled, { showLoading: false });
132
+ fetchReport(reportUrl, pollCancelledRef, { showLoading: false });
129
133
  } catch {
130
134
  // Network error during poll — ignore silently; the user will see the
131
135
  // previous state.
@@ -133,7 +137,7 @@ export function useWorkspaceReport(
133
137
  }, refreshIntervalMs);
134
138
 
135
139
  return () => {
136
- cancelled.value = true;
140
+ pollCancelledRef.current = true;
137
141
  clearInterval(id);
138
142
  };
139
143
  // eslint-disable-next-line react-hooks/exhaustive-deps
package/src/index.ts CHANGED
@@ -10,9 +10,33 @@ export type {
10
10
  PlaygroundStringControl,
11
11
  PlaygroundNumberControl,
12
12
  PlaygroundSelectControl,
13
+ PlaygroundValuesUpdater,
13
14
  } from "./types/controls";
14
15
  export { defaultArgsFromControls } from "./types/controls";
15
16
  export { definePlayground } from "./playground/definePlayground";
17
+ export type { DefinePlaygroundKitOptions } from "./playground/definePlayground";
18
+ /** @deprecated Use `definePlayground({ kit, controls })` instead. */
19
+ export { definePlaygroundFromKit } from "./playground/definePlaygroundFromKit";
20
+ export {
21
+ expandPlaygroundControls,
22
+ exampleProps,
23
+ propsFromControls,
24
+ type CompactPlaygroundControl,
25
+ type PlaygroundControlKeys,
26
+ type PlaygroundControlsInput,
27
+ } from "./playground/expandPlaygroundControls";
28
+ export {
29
+ hideCatalogComponent,
30
+ type HideCatalogComponentResult,
31
+ } from "./dashboard/updateDslintConfig";
32
+ export {
33
+ isCatalogComponentHidden,
34
+ reportWithExtraHidden,
35
+ pathMatchesPrefix,
36
+ type ReportConfig,
37
+ } from "./dashboard/catalogVisibility";
38
+ export { collectDefinedPlaygrounds } from "./playground/collectDefinedPlaygrounds";
39
+ export { mergePlaygroundEntries } from "./playground/mergePlaygroundEntries";
16
40
  export {
17
41
  buildPlaygroundEntriesFromReport,
18
42
  buildPlaygroundEntriesFromReportWithSkips,
@@ -40,6 +64,7 @@ export {
40
64
  createPlaygroundRegistryEntriesOnly,
41
65
  } from "./playground/createPlaygroundRegistry";
42
66
  export { usePlaygroundFromReport } from "./playground/usePlaygroundFromReport";
67
+ export { playgroundSpecsKey } from "./playground/playgroundSpecsKey";
43
68
  export type { CreatePlaygroundRegistryOptions } from "./playground/createPlaygroundRegistry";
44
69
  export type { DefinedPlayground } from "./playground/definePlayground";
45
70
  export type { PlaygroundPreviewProps, PlaygroundPreviewComponent } from "./types/preview";
@@ -52,6 +77,7 @@ export type {
52
77
  ComponentDefinition,
53
78
  FileScan,
54
79
  PlaygroundSpec,
80
+ ReportConfig,
55
81
  DeclaredPropKind,
56
82
  CssTokenSummary,
57
83
  CssTokenDefinition,
@@ -0,0 +1,148 @@
1
+ import { catalogSummary, governanceSummary, tokenSummary } from "./agent-query";
2
+ import { ruleCatalog } from "./rule-catalog";
3
+ import type { WorkspaceReport } from "../types/report";
4
+
5
+ export type AgentContextOptions = {
6
+ max_components?: number;
7
+ max_findings?: number;
8
+ format?: "markdown" | "json";
9
+ };
10
+
11
+ function topUsageHint(
12
+ propValueFreqs: Record<string, Record<string, number>> | undefined,
13
+ ): string {
14
+ if (!propValueFreqs) return "";
15
+ const parts: string[] = [];
16
+ for (const [prop, values] of Object.entries(propValueFreqs)) {
17
+ const sorted = Object.entries(values).sort((a, b) => b[1] - a[1]);
18
+ const top = sorted[0];
19
+ if (top) parts.push(`${prop}=${top[0]}`);
20
+ }
21
+ return parts.slice(0, 3).join(", ");
22
+ }
23
+
24
+ export function buildAgentContext(
25
+ report: WorkspaceReport,
26
+ opts: AgentContextOptions = {},
27
+ ): string | Record<string, unknown> {
28
+ const maxComponents = opts.max_components ?? 30;
29
+ const maxFindings = opts.max_findings ?? 10;
30
+ const format = opts.format ?? "markdown";
31
+
32
+ const gov = governanceSummary(report);
33
+ const catalog = catalogSummary(report, { limit: maxComponents });
34
+ const tokens = tokenSummary(report);
35
+ const snap = report.config_snapshot ?? {};
36
+
37
+ const deprecated = catalog.filter((c) => c.deprecated).map((c) => c.name);
38
+ const duplicates = catalog.filter((c) => c.duplicate).map((c) => c.name);
39
+
40
+ const topFindings = [...(report.findings ?? [])]
41
+ .sort((a, b) => {
42
+ const order = { error: 0, warning: 1, info: 2 };
43
+ return order[a.severity] - order[b.severity];
44
+ })
45
+ .slice(0, maxFindings)
46
+ .map((f) => ({
47
+ rule_id: f.rule_id,
48
+ severity: f.severity,
49
+ path: f.path,
50
+ line: f.line,
51
+ message: f.message,
52
+ }));
53
+
54
+ const payload = {
55
+ schema_version: report.schema_version ?? 1,
56
+ generated_at: report.generated_at ?? null,
57
+ scores: gov.scores,
58
+ finding_counts: gov.finding_counts,
59
+ top_components: catalog.map((c) => ({
60
+ name: c.name,
61
+ references: c.reference_count,
62
+ import_path: c.import_path,
63
+ usage_hint: topUsageHint(
64
+ report.usage_by_component.find((u) => u.component === c.name)
65
+ ?.prop_value_frequencies,
66
+ ),
67
+ })),
68
+ deprecated_components: deprecated,
69
+ duplicate_components: duplicates,
70
+ top_findings: topFindings,
71
+ token_summary: {
72
+ defined: tokens.tokens.length,
73
+ unused: tokens.unused_count,
74
+ },
75
+ policy: {
76
+ include_dirs: snap.include_dirs ?? [],
77
+ known_tokens: snap.known_tokens ?? [],
78
+ },
79
+ dos: [
80
+ "Use components from the catalog before creating new ones.",
81
+ "Match prop values to repo usage patterns (see top_components.usage_hint).",
82
+ "Use theme tokens and Tailwind utilities from known_tokens / css_tokens.",
83
+ ],
84
+ donts: [
85
+ ...(deprecated.length
86
+ ? [`Do not use deprecated components: ${deprecated.join(", ")}.`]
87
+ : []),
88
+ "Do not hardcode hex colors or Tailwind arbitrary values.",
89
+ "Do not duplicate existing component names.",
90
+ ...(snap.include_dirs?.length
91
+ ? [`Prefer components under: ${snap.include_dirs.join(", ")}.`]
92
+ : []),
93
+ ],
94
+ };
95
+
96
+ if (format === "json") {
97
+ return payload;
98
+ }
99
+
100
+ const lines: string[] = [
101
+ "# DSLinter agent context",
102
+ "",
103
+ `Generated: ${payload.generated_at ?? "unknown"}`,
104
+ "",
105
+ "## Governance scores",
106
+ `- Design system health: ${gov.scores.design_system_health}/100`,
107
+ `- UX consistency: ${gov.scores.ux_consistency}/100`,
108
+ `- Accessibility: ${gov.scores.accessibility}/100`,
109
+ `- Maintainability: ${gov.scores.maintainability}/100`,
110
+ "",
111
+ "## Top components (by usage)",
112
+ ];
113
+
114
+ for (const c of payload.top_components) {
115
+ const hint = c.usage_hint ? ` — ${c.usage_hint}` : "";
116
+ const path = c.import_path ? ` (${c.import_path})` : "";
117
+ lines.push(`- **${c.name}** (${c.references} refs)${path}${hint}`);
118
+ }
119
+
120
+ if (deprecated.length) {
121
+ lines.push("", "## Deprecated (do not use)", deprecated.map((d) => `- ${d}`).join("\n"));
122
+ }
123
+ if (duplicates.length) {
124
+ lines.push("", "## Duplicates (consolidate)", duplicates.map((d) => `- ${d}`).join("\n"));
125
+ }
126
+
127
+ if (topFindings.length) {
128
+ lines.push("", "## Top findings");
129
+ for (const f of topFindings) {
130
+ lines.push(
131
+ `- [${f.severity}] ${f.rule_id} @ ${f.path}${f.line != null ? `:${f.line}` : ""}: ${f.message}`,
132
+ );
133
+ }
134
+ }
135
+
136
+ lines.push(
137
+ "",
138
+ "## Do",
139
+ ...payload.dos.map((d) => `- ${d}`),
140
+ "",
141
+ "## Don't",
142
+ ...payload.donts.map((d) => `- ${d}`),
143
+ );
144
+
145
+ lines.push("", "## Rule catalog", `${ruleCatalog().length} rules documented.`);
146
+
147
+ return lines.join("\n");
148
+ }
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { buildAgentContext } from "./agent-context";
6
+ import {
7
+ catalogSummary,
8
+ componentSpec,
9
+ findingsQuery,
10
+ governanceSummary,
11
+ tokenSummary,
12
+ } from "./agent-query";
13
+ import { normalizeReportPaths } from "./normalize-paths";
14
+ import type { WorkspaceReport } from "../types/report";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const demoReportPath = join(__dirname, "../../../../demo/public/dslinter-report.json");
18
+
19
+ function loadDemoReport(): WorkspaceReport {
20
+ const raw = readFileSync(demoReportPath, "utf8");
21
+ return normalizeReportPaths(JSON.parse(raw) as WorkspaceReport);
22
+ }
23
+
24
+ describe("normalizeReportPaths", () => {
25
+ it("strips root prefix from paths", () => {
26
+ const report = loadDemoReport();
27
+ for (const f of report.files) {
28
+ expect(f.path.startsWith("/")).toBe(false);
29
+ }
30
+ });
31
+ });
32
+
33
+ describe("agent-query", () => {
34
+ it("returns catalog sorted by usage", () => {
35
+ const report = loadDemoReport();
36
+ const catalog = catalogSummary(report, { limit: 10 });
37
+ expect(catalog.length).toBeGreaterThan(0);
38
+ for (let i = 1; i < catalog.length; i++) {
39
+ expect(catalog[i - 1]!.reference_count).toBeGreaterThanOrEqual(
40
+ catalog[i]!.reference_count,
41
+ );
42
+ }
43
+ });
44
+
45
+ it("returns Button spec with example jsx", () => {
46
+ const report = loadDemoReport();
47
+ const spec = componentSpec(report, "Button");
48
+ expect(spec).not.toBeNull();
49
+ expect(spec!.example_jsx).toMatch(/Button/);
50
+ });
51
+
52
+ it("filters findings by rule prefix", () => {
53
+ const report = loadDemoReport();
54
+ const rows = findingsQuery(report, { rule_prefix: "a11y-", limit: 5 });
55
+ expect(rows.every((f) => f.rule_id.startsWith("a11y-"))).toBe(true);
56
+ });
57
+
58
+ it("summarizes governance", () => {
59
+ const report = loadDemoReport();
60
+ const gov = governanceSummary(report);
61
+ expect(gov.scores.design_system_health).toBeGreaterThan(0);
62
+ expect(gov.total_findings).toBeGreaterThan(0);
63
+ });
64
+
65
+ it("lists css tokens", () => {
66
+ const report = loadDemoReport();
67
+ const tokens = tokenSummary(report);
68
+ expect(tokens.tokens.length).toBeGreaterThan(0);
69
+ });
70
+ });
71
+
72
+ describe("agent-context", () => {
73
+ it("builds markdown context", () => {
74
+ const report = loadDemoReport();
75
+ const md = buildAgentContext(report, { format: "markdown" });
76
+ expect(String(md)).toContain("Governance scores");
77
+ expect(String(md)).toContain("Top components");
78
+ });
79
+
80
+ it("builds json context", () => {
81
+ const report = loadDemoReport();
82
+ const json = buildAgentContext(report, { format: "json" }) as Record<
83
+ string,
84
+ unknown
85
+ >;
86
+ expect(json.scores).toBeDefined();
87
+ expect(Array.isArray(json.top_components)).toBe(true);
88
+ });
89
+ });