dslinter 0.0.6
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 +76 -0
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/bin/dslinter.mjs +29 -0
- package/components.json +20 -0
- package/package.json +90 -0
- package/src/components/InlineCode.tsx +5 -0
- package/src/components/icons.tsx +121 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/checkbox.tsx +25 -0
- package/src/components/ui/command.tsx +183 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/hover-card.tsx +42 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/select.tsx +149 -0
- package/src/components/ui/table.tsx +118 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/dashboard/ComponentCatalog.tsx +210 -0
- package/src/dashboard/ComponentUsageDetails.tsx +109 -0
- package/src/dashboard/DashboardBody.tsx +71 -0
- package/src/dashboard/FindingsList.tsx +151 -0
- package/src/dashboard/ScoreStrip.tsx +28 -0
- package/src/dashboard/TokenWall.tsx +241 -0
- package/src/dashboard/aggregate.ts +73 -0
- package/src/dashboard/paths.ts +10 -0
- package/src/dashboard/useWorkspaceReport.ts +136 -0
- package/src/index.ts +67 -0
- package/src/lib/utils.ts +6 -0
- package/src/playground/definePlayground.tsx +99 -0
- package/src/playground/enumerateControlCombinations.test.ts +112 -0
- package/src/playground/enumerateControlCombinations.ts +74 -0
- package/src/report/a11yForModule.ts +35 -0
- package/src/report/codeScoreForModule.ts +41 -0
- package/src/report/modulePathMatch.ts +27 -0
- package/src/report/tokenStyleFindingsForModule.ts +24 -0
- package/src/shell/ComponentPlaygroundPane.tsx +438 -0
- package/src/shell/DashboardCommandPalette.tsx +134 -0
- package/src/shell/DashboardLayout.tsx +230 -0
- package/src/shell/EmptyCard.tsx +21 -0
- package/src/shell/GovernancePane.tsx +77 -0
- package/src/shell/PlaygroundA11yAndCode.tsx +387 -0
- package/src/shell/PlaygroundControlField.tsx +213 -0
- package/src/shell/PlaygroundControls.tsx +66 -0
- package/src/shell/PlaygroundUsageCode.tsx +51 -0
- package/src/shell/PlaygroundVariantMatrix.tsx +68 -0
- package/src/shell/Section.tsx +34 -0
- package/src/shell/Sidebar.tsx +203 -0
- package/src/shell/TokensPane.tsx +26 -0
- package/src/shell/controlApiTable.ts +53 -0
- package/src/shell/hashRoute.ts +49 -0
- package/src/shell/playgroundUsageHighlight.ts +53 -0
- package/src/shell/playgroundUsageTwoslash.ts +69 -0
- package/src/shell/useHashRoute.ts +29 -0
- package/src/styles/dashboard-theme.css +188 -0
- package/src/types/controls.ts +62 -0
- package/src/types/defaultTailwindTypography.ts +55 -0
- package/src/types/playground.ts +21 -0
- package/src/types/preview.ts +8 -0
- package/src/types/report.ts +116 -0
- package/src/types/tokenCatalog.ts +54 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { WorkspaceReport } from "../types/report";
|
|
3
|
+
|
|
4
|
+
async function loadReport(url: string): Promise<WorkspaceReport> {
|
|
5
|
+
const res = await fetch(url, { cache: "no-store" });
|
|
6
|
+
if (!res.ok) {
|
|
7
|
+
throw new Error(`${res.status} ${res.statusText}`);
|
|
8
|
+
}
|
|
9
|
+
return res.json() as Promise<WorkspaceReport>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DslinterReportState = {
|
|
13
|
+
report: WorkspaceReport | null;
|
|
14
|
+
error: string | null;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type UseWorkspaceReportOptions = {
|
|
19
|
+
/** URL of the JSON report file. */
|
|
20
|
+
reportUrl?: string;
|
|
21
|
+
/**
|
|
22
|
+
* URL of the SSE endpoint (e.g. `http://localhost:7878/events`) emitted by
|
|
23
|
+
* `dslint --serve`. When provided the hook subscribes and re-fetches the
|
|
24
|
+
* report on every `data: updated` event.
|
|
25
|
+
*/
|
|
26
|
+
watchUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Polling interval in milliseconds (0 = off). Used as a fallback when
|
|
29
|
+
* `watchUrl` is not provided. On each tick the hook issues a `HEAD` request
|
|
30
|
+
* and re-fetches the full JSON only when `Last-Modified` / `ETag` changed.
|
|
31
|
+
*/
|
|
32
|
+
refreshIntervalMs?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function useWorkspaceReport(
|
|
36
|
+
reportUrlOrOptions: string | UseWorkspaceReportOptions = "/dslint-report.json",
|
|
37
|
+
): DslinterReportState {
|
|
38
|
+
// Accept either the legacy string overload or the options object.
|
|
39
|
+
const {
|
|
40
|
+
reportUrl = "/dslint-report.json",
|
|
41
|
+
watchUrl,
|
|
42
|
+
refreshIntervalMs = 0,
|
|
43
|
+
}: UseWorkspaceReportOptions = typeof reportUrlOrOptions === "string"
|
|
44
|
+
? { reportUrl: reportUrlOrOptions }
|
|
45
|
+
: reportUrlOrOptions;
|
|
46
|
+
|
|
47
|
+
const [report, setReport] = useState<WorkspaceReport | null>(null);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const [loading, setLoading] = useState(true);
|
|
50
|
+
|
|
51
|
+
// Ref tracking the last known ETag / Last-Modified for poll-based change detection.
|
|
52
|
+
const etagRef = useRef<string | null>(null);
|
|
53
|
+
|
|
54
|
+
// Core fetch function.
|
|
55
|
+
const fetchReport = (url: string, cancelled: { value: boolean }) => {
|
|
56
|
+
setError(null);
|
|
57
|
+
setLoading(true);
|
|
58
|
+
loadReport(url)
|
|
59
|
+
.then((r) => {
|
|
60
|
+
if (!cancelled.value) setReport(r);
|
|
61
|
+
})
|
|
62
|
+
.catch((e: unknown) => {
|
|
63
|
+
if (!cancelled.value) setError(e instanceof Error ? e.message : "Failed to load report");
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
if (!cancelled.value) setLoading(false);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Initial load.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const cancelled = { value: false };
|
|
73
|
+
fetchReport(reportUrl, cancelled);
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled.value = true;
|
|
76
|
+
};
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [reportUrl]);
|
|
79
|
+
|
|
80
|
+
// SSE subscription (takes priority over polling).
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!watchUrl) return;
|
|
83
|
+
const source = new EventSource(watchUrl);
|
|
84
|
+
const cancelled = { value: false };
|
|
85
|
+
|
|
86
|
+
source.onmessage = (e) => {
|
|
87
|
+
if (cancelled.value) return;
|
|
88
|
+
if (e.data === "updated") {
|
|
89
|
+
fetchReport(reportUrl, cancelled);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
source.onerror = () => {
|
|
94
|
+
// SSE errors are transient; the browser will reconnect automatically.
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
cancelled.value = true;
|
|
99
|
+
source.close();
|
|
100
|
+
};
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [watchUrl, reportUrl]);
|
|
103
|
+
|
|
104
|
+
// Polling fallback (only when watchUrl is absent and refreshIntervalMs > 0).
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (watchUrl || refreshIntervalMs <= 0) return;
|
|
107
|
+
|
|
108
|
+
const cancelled = { value: false };
|
|
109
|
+
|
|
110
|
+
const id = setInterval(async () => {
|
|
111
|
+
if (cancelled.value) return;
|
|
112
|
+
try {
|
|
113
|
+
// Use HEAD to check for changes before fetching the full JSON.
|
|
114
|
+
const head = await fetch(reportUrl, { method: "HEAD", cache: "no-store" });
|
|
115
|
+
const etag = head.headers.get("ETag") ?? head.headers.get("Last-Modified") ?? null;
|
|
116
|
+
if (etag && etag === etagRef.current) {
|
|
117
|
+
// Not changed — skip.
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
etagRef.current = etag;
|
|
121
|
+
fetchReport(reportUrl, cancelled);
|
|
122
|
+
} catch {
|
|
123
|
+
// Network error during poll — ignore silently; the user will see the
|
|
124
|
+
// previous state.
|
|
125
|
+
}
|
|
126
|
+
}, refreshIntervalMs);
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
cancelled.value = true;
|
|
130
|
+
clearInterval(id);
|
|
131
|
+
};
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
+
}, [watchUrl, refreshIntervalMs, reportUrl]);
|
|
134
|
+
|
|
135
|
+
return { report, error, loading };
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSLinter dashboard — React UI for playgrounds, tokens, and governance.
|
|
3
|
+
* Host apps should import styles once: `@import "dslinter/theme.css";` (after Tailwind + `@source` for this package).
|
|
4
|
+
*/
|
|
5
|
+
export type { PlaygroundEntry, PlaygroundMeta } from "./types/playground";
|
|
6
|
+
export type {
|
|
7
|
+
PlaygroundArgs,
|
|
8
|
+
PlaygroundControl,
|
|
9
|
+
PlaygroundBooleanControl,
|
|
10
|
+
PlaygroundStringControl,
|
|
11
|
+
PlaygroundNumberControl,
|
|
12
|
+
PlaygroundSelectControl,
|
|
13
|
+
} from "./types/controls";
|
|
14
|
+
export { defaultArgsFromControls } from "./types/controls";
|
|
15
|
+
export { definePlayground } from "./playground/definePlayground";
|
|
16
|
+
export type { DefinedPlayground } from "./playground/definePlayground";
|
|
17
|
+
export type { PlaygroundPreviewProps, PlaygroundPreviewComponent } from "./types/preview";
|
|
18
|
+
export type {
|
|
19
|
+
WorkspaceReport,
|
|
20
|
+
GovernanceScores,
|
|
21
|
+
LintFinding,
|
|
22
|
+
UsageSummary,
|
|
23
|
+
UsageLocation,
|
|
24
|
+
ComponentDefinition,
|
|
25
|
+
FileScan,
|
|
26
|
+
PlaygroundSpec,
|
|
27
|
+
DeclaredPropKind,
|
|
28
|
+
} from "./types/report";
|
|
29
|
+
export type {
|
|
30
|
+
TokenCatalog,
|
|
31
|
+
TokenCatalogColor,
|
|
32
|
+
TokenCatalogSpacing,
|
|
33
|
+
TokenCatalogRadius,
|
|
34
|
+
TokenCatalogTypography,
|
|
35
|
+
TokenCatalogFontFamily,
|
|
36
|
+
TokenCatalogFontSize,
|
|
37
|
+
TokenCatalogFontWeight,
|
|
38
|
+
} from "./types/tokenCatalog";
|
|
39
|
+
export {
|
|
40
|
+
defaultTailwindFontFamilies,
|
|
41
|
+
defaultTailwindFontSizes,
|
|
42
|
+
defaultTailwindFontWeights,
|
|
43
|
+
} from "./types/defaultTailwindTypography";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
DashboardLayout,
|
|
47
|
+
DashboardThemeProvider,
|
|
48
|
+
useDashboardTheme,
|
|
49
|
+
} from "./shell/DashboardLayout";
|
|
50
|
+
export type {
|
|
51
|
+
DashboardLayoutProps,
|
|
52
|
+
DashboardResolvedTheme,
|
|
53
|
+
DashboardThemePreference,
|
|
54
|
+
} from "./shell/DashboardLayout";
|
|
55
|
+
export { useWorkspaceReport } from "./dashboard/useWorkspaceReport";
|
|
56
|
+
export type { DslinterReportState } from "./dashboard/useWorkspaceReport";
|
|
57
|
+
export { a11ySummaryForModule, resolveModuleSourcePath } from "./report/a11yForModule";
|
|
58
|
+
export type { A11yModuleSummary } from "./report/a11yForModule";
|
|
59
|
+
export { tokenStyleFindingsForModule } from "./report/tokenStyleFindingsForModule";
|
|
60
|
+
export { codeScoreSummaryForModule } from "./report/codeScoreForModule";
|
|
61
|
+
export type { CodeScoreModuleSummary } from "./report/codeScoreForModule";
|
|
62
|
+
export { TokenWall } from "./dashboard/TokenWall";
|
|
63
|
+
export { DashboardBody } from "./dashboard/DashboardBody";
|
|
64
|
+
|
|
65
|
+
export type { HashRoute } from "./shell/hashRoute";
|
|
66
|
+
export { parseHashRoute, formatHashRoute } from "./shell/hashRoute";
|
|
67
|
+
export { useHashRoute } from "./shell/useHashRoute";
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
3
|
+
import type { PlaygroundMeta } from "../types/playground";
|
|
4
|
+
import type { PlaygroundPreviewComponent, PlaygroundPreviewProps } from "../types/preview";
|
|
5
|
+
|
|
6
|
+
export type DefinedPlayground = {
|
|
7
|
+
playgroundMeta: PlaygroundMeta;
|
|
8
|
+
playgroundControls: PlaygroundControl[];
|
|
9
|
+
PlaygroundPreview: PlaygroundPreviewComponent;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type PlaygroundDefinitionBase = {
|
|
13
|
+
/**
|
|
14
|
+
* Stable sidebar / URL id. Defaults to the component’s `displayName` or function `name`
|
|
15
|
+
* (dev builds). Required for `render`-only definitions or when the name is unreliable.
|
|
16
|
+
*/
|
|
17
|
+
id?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
/** Sidebar / report group (e.g. from dslint `playground_groups`). */
|
|
20
|
+
group?: string;
|
|
21
|
+
/** @deprecated Use `group` — kept for older call sites. */
|
|
22
|
+
section?: string;
|
|
23
|
+
controls?: PlaygroundControl[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type DefineWithComponent<P> = PlaygroundDefinitionBase & {
|
|
27
|
+
/** Map control values to props for the preview component. */
|
|
28
|
+
props: (values: PlaygroundArgs) => P;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type DefineWithRender = PlaygroundDefinitionBase & {
|
|
32
|
+
/** Use when the preview is not a straight prop pass (composition, static demos, wrappers). */
|
|
33
|
+
render: (values: PlaygroundArgs) => ReactNode;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function getComponentLabel(Component: unknown): string | undefined {
|
|
37
|
+
if (typeof Component !== "function") return undefined;
|
|
38
|
+
const fn = Component as { displayName?: string; name?: string };
|
|
39
|
+
return fn.displayName ?? fn.name;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inferId(nameFallback: string | undefined): string {
|
|
43
|
+
if (nameFallback && nameFallback.length > 0) return nameFallback;
|
|
44
|
+
throw new Error(
|
|
45
|
+
"definePlayground: set `id` in options when the component has no usable `name` / `displayName`, or when using a `render`-only definition.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveMeta(
|
|
50
|
+
base: PlaygroundDefinitionBase,
|
|
51
|
+
nameFallback: string | undefined,
|
|
52
|
+
): PlaygroundMeta {
|
|
53
|
+
const id = base.id ?? inferId(nameFallback);
|
|
54
|
+
const title = base.title ?? id;
|
|
55
|
+
const group = base.group ?? base.section;
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
title,
|
|
59
|
+
...(group !== undefined && group !== "" ? { group } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional escape hatch: declare dashboard playground exports manually. Prefer driving
|
|
65
|
+
* playgrounds from `dslint-report.json` (`playgrounds`) so component files stay free of
|
|
66
|
+
* dashboard package imports.
|
|
67
|
+
*/
|
|
68
|
+
export function definePlayground<P extends Record<string, unknown>>(
|
|
69
|
+
component: ComponentType<P>,
|
|
70
|
+
options: DefineWithComponent<P>,
|
|
71
|
+
): DefinedPlayground;
|
|
72
|
+
|
|
73
|
+
export function definePlayground(options: DefineWithRender): DefinedPlayground;
|
|
74
|
+
|
|
75
|
+
export function definePlayground<P extends Record<string, unknown>>(
|
|
76
|
+
componentOrOptions: ComponentType<P> | DefineWithRender,
|
|
77
|
+
options?: DefineWithComponent<P>,
|
|
78
|
+
): DefinedPlayground {
|
|
79
|
+
if (options !== undefined) {
|
|
80
|
+
const Component = componentOrOptions as ComponentType<P>;
|
|
81
|
+
const opts = options;
|
|
82
|
+
const meta = resolveMeta(opts, getComponentLabel(Component));
|
|
83
|
+
const controls = opts.controls ?? [];
|
|
84
|
+
function PlaygroundPreview({ values }: PlaygroundPreviewProps) {
|
|
85
|
+
return <Component {...opts.props(values)} />;
|
|
86
|
+
}
|
|
87
|
+
PlaygroundPreview.displayName = `${meta.id}PlaygroundPreview`;
|
|
88
|
+
return { playgroundMeta: meta, playgroundControls: controls, PlaygroundPreview };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const opts = componentOrOptions as DefineWithRender;
|
|
92
|
+
const meta = resolveMeta(opts, undefined);
|
|
93
|
+
const controls = opts.controls ?? [];
|
|
94
|
+
function PlaygroundPreview({ values }: PlaygroundPreviewProps) {
|
|
95
|
+
return <>{opts.render(values)}</>;
|
|
96
|
+
}
|
|
97
|
+
PlaygroundPreview.displayName = `${meta.id}PlaygroundPreview`;
|
|
98
|
+
return { playgroundMeta: meta, playgroundControls: controls, PlaygroundPreview };
|
|
99
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
enumerateControlCombinations,
|
|
4
|
+
PLAYGROUND_VARIANT_MATRIX_CAP,
|
|
5
|
+
} from "./enumerateControlCombinations";
|
|
6
|
+
import type { PlaygroundControl } from "../types/controls";
|
|
7
|
+
|
|
8
|
+
describe("enumerateControlCombinations", () => {
|
|
9
|
+
it("returns empty when there are no boolean or select axes", () => {
|
|
10
|
+
const controls: PlaygroundControl[] = [
|
|
11
|
+
{ key: "label", label: "label", type: "string", default: "Hi" },
|
|
12
|
+
{ key: "count", label: "count", type: "number", default: 1 },
|
|
13
|
+
];
|
|
14
|
+
const base = { label: "X", count: 42 };
|
|
15
|
+
const r = enumerateControlCombinations(controls, base);
|
|
16
|
+
expect(r.combinations).toEqual([]);
|
|
17
|
+
expect(r.totalCount).toBe(0);
|
|
18
|
+
expect(r.capped).toBe(false);
|
|
19
|
+
expect(r.finiteAxisKeys).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("expands booleans and merges string props from base", () => {
|
|
23
|
+
const controls: PlaygroundControl[] = [
|
|
24
|
+
{ key: "open", label: "open", type: "boolean", default: false },
|
|
25
|
+
{ key: "title", label: "title", type: "string", default: "T" },
|
|
26
|
+
];
|
|
27
|
+
const base = { open: false, title: "Hello" };
|
|
28
|
+
const r = enumerateControlCombinations(controls, base);
|
|
29
|
+
expect(r.totalCount).toBe(2);
|
|
30
|
+
expect(r.capped).toBe(false);
|
|
31
|
+
expect(r.finiteAxisKeys).toEqual(["open"]);
|
|
32
|
+
expect(r.combinations).toEqual([
|
|
33
|
+
{ open: false, title: "Hello" },
|
|
34
|
+
{ open: true, title: "Hello" },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("computes Cartesian product for boolean and select", () => {
|
|
39
|
+
const controls: PlaygroundControl[] = [
|
|
40
|
+
{
|
|
41
|
+
key: "variant",
|
|
42
|
+
label: "variant",
|
|
43
|
+
type: "select",
|
|
44
|
+
default: "a",
|
|
45
|
+
options: [
|
|
46
|
+
{ value: "a", label: "a" },
|
|
47
|
+
{ value: "b", label: "b" },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{ key: "disabled", label: "disabled", type: "boolean", default: false },
|
|
51
|
+
];
|
|
52
|
+
const base = { variant: "a", disabled: false, extra: "keep" };
|
|
53
|
+
const r = enumerateControlCombinations(controls, base);
|
|
54
|
+
expect(r.totalCount).toBe(4);
|
|
55
|
+
expect(r.combinations).toHaveLength(4);
|
|
56
|
+
expect(r.finiteAxisKeys).toEqual(["variant", "disabled"]);
|
|
57
|
+
const keys = r.combinations.map((c) => `${c.variant}:${c.disabled}`);
|
|
58
|
+
expect(keys.sort()).toEqual(["a:false", "a:true", "b:false", "b:true"]);
|
|
59
|
+
for (const c of r.combinations) {
|
|
60
|
+
expect(c.extra).toBe("keep");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("skips select with zero options", () => {
|
|
65
|
+
const controls: PlaygroundControl[] = [
|
|
66
|
+
{
|
|
67
|
+
key: "empty",
|
|
68
|
+
label: "empty",
|
|
69
|
+
type: "select",
|
|
70
|
+
default: "x",
|
|
71
|
+
options: [],
|
|
72
|
+
},
|
|
73
|
+
{ key: "on", label: "on", type: "boolean", default: false },
|
|
74
|
+
];
|
|
75
|
+
const r = enumerateControlCombinations(controls, { on: false });
|
|
76
|
+
expect(r.totalCount).toBe(2);
|
|
77
|
+
expect(r.finiteAxisKeys).toEqual(["on"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("caps combinations and reports totalCount", () => {
|
|
81
|
+
const options = Array.from({ length: 20 }, (_, i) => ({
|
|
82
|
+
value: `v${i}`,
|
|
83
|
+
label: `v${i}`,
|
|
84
|
+
}));
|
|
85
|
+
const controls: PlaygroundControl[] = [
|
|
86
|
+
{ key: "a", label: "a", type: "boolean", default: false },
|
|
87
|
+
{ key: "b", label: "b", type: "boolean", default: false },
|
|
88
|
+
{ key: "c", label: "c", type: "boolean", default: false },
|
|
89
|
+
{ key: "d", label: "d", type: "boolean", default: false },
|
|
90
|
+
{ key: "e", label: "e", type: "boolean", default: false },
|
|
91
|
+
{
|
|
92
|
+
key: "s",
|
|
93
|
+
label: "s",
|
|
94
|
+
type: "select",
|
|
95
|
+
default: "v0",
|
|
96
|
+
options,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
const base: Record<string, string | number | boolean> = {
|
|
100
|
+
a: false,
|
|
101
|
+
b: false,
|
|
102
|
+
c: false,
|
|
103
|
+
d: false,
|
|
104
|
+
e: false,
|
|
105
|
+
s: "v0",
|
|
106
|
+
};
|
|
107
|
+
const r = enumerateControlCombinations(controls, base);
|
|
108
|
+
expect(r.totalCount).toBe(2 ** 5 * 20);
|
|
109
|
+
expect(r.capped).toBe(true);
|
|
110
|
+
expect(r.combinations.length).toBe(PLAYGROUND_VARIANT_MATRIX_CAP);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
2
|
+
|
|
3
|
+
/** Max previews in the variant matrix; larger Cartesian products show a cap notice. */
|
|
4
|
+
export const PLAYGROUND_VARIANT_MATRIX_CAP = 200;
|
|
5
|
+
|
|
6
|
+
export type EnumerateControlCombinationsResult = {
|
|
7
|
+
/** Cartesian product of boolean + select axes, merged over `baseValues`, truncated to cap. */
|
|
8
|
+
combinations: PlaygroundArgs[];
|
|
9
|
+
/** Full product size before capping (0 when there are no finite axes). */
|
|
10
|
+
totalCount: number;
|
|
11
|
+
/** True when `totalCount` exceeds the cap. */
|
|
12
|
+
capped: boolean;
|
|
13
|
+
/** Keys that were expanded in the product (stable control order). */
|
|
14
|
+
finiteAxisKeys: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type FiniteAxis = {
|
|
18
|
+
key: string;
|
|
19
|
+
values: Array<string | number | boolean>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function finiteAxesFromControls(controls: PlaygroundControl[]): FiniteAxis[] {
|
|
23
|
+
const axes: FiniteAxis[] = [];
|
|
24
|
+
for (const c of controls) {
|
|
25
|
+
if (c.type === "boolean") {
|
|
26
|
+
axes.push({ key: c.key, values: [false, true] });
|
|
27
|
+
} else if (c.type === "select" && c.options.length > 0) {
|
|
28
|
+
axes.push({
|
|
29
|
+
key: c.key,
|
|
30
|
+
values: c.options.map((o) => o.value),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return axes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds every combination of boolean and select controls, overlaid on `baseValues`
|
|
39
|
+
* (string/number props stay as in the interactive panel).
|
|
40
|
+
*/
|
|
41
|
+
export function enumerateControlCombinations(
|
|
42
|
+
controls: PlaygroundControl[],
|
|
43
|
+
baseValues: PlaygroundArgs,
|
|
44
|
+
): EnumerateControlCombinationsResult {
|
|
45
|
+
const axes = finiteAxesFromControls(controls);
|
|
46
|
+
if (axes.length === 0) {
|
|
47
|
+
return { combinations: [], totalCount: 0, capped: false, finiteAxisKeys: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let totalCount = 1;
|
|
51
|
+
for (const a of axes) {
|
|
52
|
+
totalCount *= a.values.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const finiteAxisKeys = axes.map((a) => a.key);
|
|
56
|
+
const combinations: PlaygroundArgs[] = [];
|
|
57
|
+
const capped = totalCount > PLAYGROUND_VARIANT_MATRIX_CAP;
|
|
58
|
+
|
|
59
|
+
const walk = (i: number, acc: PlaygroundArgs) => {
|
|
60
|
+
if (combinations.length >= PLAYGROUND_VARIANT_MATRIX_CAP) return;
|
|
61
|
+
if (i >= axes.length) {
|
|
62
|
+
combinations.push({ ...acc });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const axis = axes[i]!;
|
|
66
|
+
for (const v of axis.values) {
|
|
67
|
+
walk(i + 1, { ...acc, [axis.key]: v });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
walk(0, { ...baseValues });
|
|
72
|
+
|
|
73
|
+
return { combinations, totalCount, capped, finiteAxisKeys };
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { LintFinding, WorkspaceReport } from "../types/report";
|
|
2
|
+
import { pathsMatch, resolveModuleSourcePath } from "./modulePathMatch";
|
|
3
|
+
|
|
4
|
+
export type A11yModuleSummary = {
|
|
5
|
+
/** 0–100 heuristic from DSLinter `a11y-*` findings on this source file. */
|
|
6
|
+
score: number;
|
|
7
|
+
/** Count of `a11y-*` findings on this file. */
|
|
8
|
+
issueCount: number;
|
|
9
|
+
findings: LintFinding[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export { resolveModuleSourcePath } from "./modulePathMatch";
|
|
13
|
+
|
|
14
|
+
export function a11ySummaryForModule(
|
|
15
|
+
report: WorkspaceReport | null | undefined,
|
|
16
|
+
modulePath: string,
|
|
17
|
+
): A11yModuleSummary {
|
|
18
|
+
if (!report) {
|
|
19
|
+
return { score: 100, issueCount: 0, findings: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const target = resolveModuleSourcePath(report.root, modulePath);
|
|
23
|
+
const all = report.findings.filter((f) => f.rule_id.startsWith("a11y-"));
|
|
24
|
+
const findings = all.filter((f) => pathsMatch(f.path, target));
|
|
25
|
+
|
|
26
|
+
let penalty = 0;
|
|
27
|
+
for (const f of findings) {
|
|
28
|
+
if (f.severity === "error") penalty += 25;
|
|
29
|
+
else if (f.severity === "warning") penalty += 10;
|
|
30
|
+
else penalty += 3;
|
|
31
|
+
}
|
|
32
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - penalty)));
|
|
33
|
+
|
|
34
|
+
return { score, issueCount: findings.length, findings };
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LintFinding, WorkspaceReport } from "../types/report";
|
|
2
|
+
import { pathsMatch, resolveModuleSourcePath } from "./modulePathMatch";
|
|
3
|
+
|
|
4
|
+
function isCodeScoreRule(ruleId: string): boolean {
|
|
5
|
+
return ruleId.startsWith("smell-");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function lineSortKey(line: number | null): number {
|
|
9
|
+
return line == null ? Number.POSITIVE_INFINITY : line;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CodeScoreModuleSummary = {
|
|
13
|
+
score: number;
|
|
14
|
+
issueCount: number;
|
|
15
|
+
findings: LintFinding[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function codeScoreSummaryForModule(
|
|
19
|
+
report: WorkspaceReport | null | undefined,
|
|
20
|
+
modulePath: string,
|
|
21
|
+
): CodeScoreModuleSummary {
|
|
22
|
+
if (!report) {
|
|
23
|
+
return { score: 100, issueCount: 0, findings: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const target = resolveModuleSourcePath(report.root, modulePath);
|
|
27
|
+
const rows = report.findings.filter(
|
|
28
|
+
(f) => isCodeScoreRule(f.rule_id) && pathsMatch(f.path, target),
|
|
29
|
+
);
|
|
30
|
+
const findings = [...rows].sort((a, b) => lineSortKey(a.line) - lineSortKey(b.line));
|
|
31
|
+
|
|
32
|
+
let penalty = 0;
|
|
33
|
+
for (const f of findings) {
|
|
34
|
+
if (f.severity === "error") penalty += 25;
|
|
35
|
+
else if (f.severity === "warning") penalty += 10;
|
|
36
|
+
else penalty += 3;
|
|
37
|
+
}
|
|
38
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - penalty)));
|
|
39
|
+
|
|
40
|
+
return { score, issueCount: findings.length, findings };
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function norm(p: string): string {
|
|
2
|
+
return p.replace(/\\/g, "/");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Resolve `../components/...` (from demo `src/playground`) to path under `report.root`. */
|
|
6
|
+
export function resolveModuleSourcePath(reportRoot: string, modulePath: string): string {
|
|
7
|
+
const rel = norm(modulePath.replace(/^\.\.\//, ""));
|
|
8
|
+
const withSrc = rel.startsWith("components/") ? `src/${rel}` : rel;
|
|
9
|
+
const root = norm(reportRoot).replace(/\/$/, "");
|
|
10
|
+
return `${root}/${withSrc}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Match finding path to source file even when `report.root` differs from machine that generated JSON. */
|
|
14
|
+
function tailSrcComponents(p: string): string | null {
|
|
15
|
+
const m = norm(p).match(/(src\/components\/.+)$/);
|
|
16
|
+
return m ? m[1] : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pathsMatch(reportPath: string, candidate: string): boolean {
|
|
20
|
+
const a = norm(reportPath);
|
|
21
|
+
const b = norm(candidate);
|
|
22
|
+
if (a === b) return true;
|
|
23
|
+
const ta = tailSrcComponents(a);
|
|
24
|
+
const tb = tailSrcComponents(b);
|
|
25
|
+
if (ta && tb) return ta === tb;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LintFinding, WorkspaceReport } from "../types/report";
|
|
2
|
+
import { pathsMatch, resolveModuleSourcePath } from "./modulePathMatch";
|
|
3
|
+
|
|
4
|
+
function isTokenStyleRule(ruleId: string): boolean {
|
|
5
|
+
return ruleId.startsWith("token-");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function lineSortKey(line: number | null): number {
|
|
9
|
+
return line == null ? Number.POSITIVE_INFINITY : line;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Hardcoded / arbitrary token color findings for a playground module path. */
|
|
13
|
+
export function tokenStyleFindingsForModule(
|
|
14
|
+
report: WorkspaceReport | null | undefined,
|
|
15
|
+
modulePath: string,
|
|
16
|
+
): LintFinding[] {
|
|
17
|
+
if (!report) return [];
|
|
18
|
+
|
|
19
|
+
const target = resolveModuleSourcePath(report.root, modulePath);
|
|
20
|
+
const rows = report.findings.filter(
|
|
21
|
+
(f) => isTokenStyleRule(f.rule_id) && pathsMatch(f.path, target),
|
|
22
|
+
);
|
|
23
|
+
return [...rows].sort((a, b) => lineSortKey(a.line) - lineSortKey(b.line));
|
|
24
|
+
}
|