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.
- package/CHANGELOG.md +72 -0
- package/README.md +50 -29
- 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 +72 -10
- package/bin/lib/project-root.test.mjs +32 -1
- 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 +128 -9
- package/bin/lib/scaffold-config.test.mjs +24 -2
- 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 +55 -21
- package/bin/modes/init.mjs +3 -22
- package/bin/modes/init.test.mjs +1 -1
- 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 +212 -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 +91 -3
- package/vite/collectScanModules.ts +94 -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-Bm7yfyC-.css +0 -1
- package/dashboard-dist/assets/DashboardLayoutAuto-DgwO_itB.js +0 -1
- package/dashboard-dist/assets/index-Cbv7vXvH.css +0 -1
- package/dashboard-dist/assets/index-e20cwqnb.js +0 -206
- package/src/components/playgroundUsageTwoslash.ts +0 -69
- 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
|
+
});
|
package/src/dashboard/paths.ts
CHANGED
|
@@ -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
|
|
3
|
-
const
|
|
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 = "/
|
|
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 = "/
|
|
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
|
-
|
|
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 (!
|
|
71
|
+
if (!cancelledRef.current) setReport(r);
|
|
68
72
|
})
|
|
69
73
|
.catch((e: unknown) => {
|
|
70
|
-
if (!
|
|
74
|
+
if (!cancelledRef.current) setError(e instanceof Error ? e.message : "Failed to load report");
|
|
71
75
|
})
|
|
72
76
|
.finally(() => {
|
|
73
|
-
if (!
|
|
77
|
+
if (!cancelledRef.current && showLoading) setLoading(false);
|
|
74
78
|
});
|
|
75
79
|
};
|
|
76
80
|
|
|
77
81
|
// Initial load.
|
|
78
82
|
useEffect(() => {
|
|
79
|
-
|
|
80
|
-
fetchReport(reportUrl,
|
|
83
|
+
initialCancelledRef.current = false;
|
|
84
|
+
fetchReport(reportUrl, initialCancelledRef, { showLoading: true });
|
|
81
85
|
return () => {
|
|
82
|
-
|
|
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
|
-
|
|
95
|
+
sseCancelledRef.current = false;
|
|
92
96
|
|
|
93
97
|
source.onmessage = (e) => {
|
|
94
|
-
if (
|
|
98
|
+
if (sseCancelledRef.current) return;
|
|
95
99
|
if (e.data === "updated") {
|
|
96
|
-
fetchReport(reportUrl,
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
pollCancelledRef.current = false;
|
|
116
120
|
|
|
117
121
|
const id = setInterval(async () => {
|
|
118
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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
|
+
});
|