dslinter 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/bin/lib/dev-banner.mjs +54 -5
- package/bin/lib/dev-banner.test.mjs +17 -4
- package/bin/lib/network-hosts.mjs +81 -0
- package/bin/lib/network-hosts.test.mjs +57 -0
- package/bin/modes/dev.mjs +2 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-BhyC4wjs.css +1 -0
- package/dashboard-dist/assets/{DashboardLayoutAuto-B4P-sy4z.js → DashboardLayoutAuto-C31e4STv.js} +1 -1
- package/dashboard-dist/assets/{axe-CaxTXfM9.js → axe-DaRWENWH.js} +1 -1
- package/dashboard-dist/assets/index-B66dduvs.js +219 -0
- package/dashboard-dist/assets/{index-D0O_5w5V.css → index-BARBi_u7.css} +1 -1
- package/dashboard-dist/index.html +2 -2
- package/index.cjs +52 -52
- package/package.json +8 -8
- package/src/components/Section.tsx +1 -1
- package/src/components/ui/badge.tsx +1 -1
- package/src/dashboard/ComponentUsageDetails.tsx +7 -39
- package/src/dashboard/DashboardBody.tsx +2 -7
- package/src/dashboard/FindingsList.tsx +64 -56
- package/src/dashboard/ScannedTokenWall.tsx +40 -25
- package/src/dashboard/SourceLocationLink.tsx +40 -0
- package/src/mcp/css-color.test.ts +52 -0
- package/src/mcp/css-color.ts +73 -0
- package/src/mcp/rule-catalog.json +5 -5
- package/src/mcp/verify-loop.test.ts +24 -0
- package/src/mcp/verify-loop.ts +28 -6
- package/src/playground/inferKitJsx.ts +141 -14
- package/src/playground/inferKitParams.ts +40 -2
- package/dashboard-dist/assets/DashboardLayoutAuto-CAO6F6-q.css +0 -1
- package/dashboard-dist/assets/index-B432JkIx.js +0 -219
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Table,
|
|
4
4
|
TableBody,
|
|
@@ -9,10 +9,8 @@ import {
|
|
|
9
9
|
} from "../components/ui/table";
|
|
10
10
|
import type { UsageLocation, WorkspaceReport } from "../types/report";
|
|
11
11
|
import { usageMap } from "./aggregate";
|
|
12
|
-
import { openSourceFile } from "./editorLink";
|
|
13
|
-
import { resolveReportAbsolutePath, shortPath } from "./paths";
|
|
14
12
|
import { EmptyCard } from "../components/EmptyCard";
|
|
15
|
-
import {
|
|
13
|
+
import { SourceLocationLink } from "./SourceLocationLink";
|
|
16
14
|
|
|
17
15
|
function formatCallSiteProps(loc: UsageLocation): string {
|
|
18
16
|
if (!loc.props.length) return "—";
|
|
@@ -33,40 +31,6 @@ function sortedLocations(
|
|
|
33
31
|
return list;
|
|
34
32
|
}
|
|
35
33
|
|
|
36
|
-
function UsageLocationLink({
|
|
37
|
-
root,
|
|
38
|
-
loc,
|
|
39
|
-
}: {
|
|
40
|
-
root: string;
|
|
41
|
-
loc: UsageLocation;
|
|
42
|
-
}) {
|
|
43
|
-
const fileText = shortPath(root, loc.path);
|
|
44
|
-
const locationText = `${fileText}:${loc.line}`;
|
|
45
|
-
const absolutePath = resolveReportAbsolutePath(root, loc.path);
|
|
46
|
-
|
|
47
|
-
const handleClick = useCallback(() => {
|
|
48
|
-
void openSourceFile(absolutePath, loc.line).catch((err) => {
|
|
49
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
-
window.alert(`Could not open file: ${message}`);
|
|
51
|
-
});
|
|
52
|
-
}, [absolutePath, loc.line]);
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
<button
|
|
56
|
-
type="button"
|
|
57
|
-
onClick={handleClick}
|
|
58
|
-
className="block min-w-0 w-full text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
|
59
|
-
title={locationText}
|
|
60
|
-
>
|
|
61
|
-
<TruncatedPath
|
|
62
|
-
path={`${fileText}:${loc.line}`}
|
|
63
|
-
className="text-xs"
|
|
64
|
-
title={undefined}
|
|
65
|
-
/>
|
|
66
|
-
</button>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
34
|
export function ComponentUsageDetails({
|
|
71
35
|
report,
|
|
72
36
|
componentId,
|
|
@@ -117,7 +81,11 @@ export function ComponentUsageDetails({
|
|
|
117
81
|
return (
|
|
118
82
|
<TableRow key={`${loc.path}-${loc.line}-${i}`}>
|
|
119
83
|
<TableCell className="min-w-0">
|
|
120
|
-
<
|
|
84
|
+
<SourceLocationLink
|
|
85
|
+
root={report.root}
|
|
86
|
+
path={loc.path}
|
|
87
|
+
line={loc.line}
|
|
88
|
+
/>
|
|
121
89
|
</TableCell>
|
|
122
90
|
<TableCell className="min-w-0">
|
|
123
91
|
<span
|
|
@@ -79,11 +79,7 @@ export function DashboardBody({
|
|
|
79
79
|
|
|
80
80
|
<GovernanceInventoryTabs value={tab} onChange={setTab} counts={counts} />
|
|
81
81
|
|
|
82
|
-
<Section
|
|
83
|
-
id={section.id}
|
|
84
|
-
title={section.title}
|
|
85
|
-
description={section.description}
|
|
86
|
-
>
|
|
82
|
+
<Section id={section.id}>
|
|
87
83
|
{tab === "unused" ? (
|
|
88
84
|
<UnusedComponentsList
|
|
89
85
|
components={unusedComponents}
|
|
@@ -103,8 +99,7 @@ export function DashboardBody({
|
|
|
103
99
|
className="font-medium text-foreground underline decoration-dotted underline-offset-2 transition hover:decoration-solid"
|
|
104
100
|
>
|
|
105
101
|
View all components
|
|
106
|
-
</button>
|
|
107
|
-
{" "}
|
|
102
|
+
</button>{" "}
|
|
108
103
|
for prop usage and app reference details.
|
|
109
104
|
</p>
|
|
110
105
|
) : null}
|
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
TableHeader,
|
|
8
8
|
TableRow,
|
|
9
9
|
} from "../components/ui/table";
|
|
10
|
-
import { shortPath } from "./paths";
|
|
11
10
|
import type { LintFinding, Severity } from "../types/report";
|
|
12
11
|
import { Badge } from "../components/ui/badge";
|
|
12
|
+
import { EmptyCard } from "../components/EmptyCard";
|
|
13
13
|
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
|
14
|
-
import {
|
|
14
|
+
import { SourceLocationLink } from "./SourceLocationLink";
|
|
15
15
|
|
|
16
16
|
type Filter = "all" | Severity;
|
|
17
17
|
|
|
@@ -24,6 +24,12 @@ function isFilter(value: string): value is Filter {
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function emptyFilterMessage(filter: Exclude<Filter, "all">): string {
|
|
28
|
+
if (filter === "error") return "No errors in these findings.";
|
|
29
|
+
if (filter === "warning") return "No warnings in these findings.";
|
|
30
|
+
return "No info findings in these findings.";
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
export function FindingsList({
|
|
28
34
|
findings,
|
|
29
35
|
root,
|
|
@@ -44,16 +50,15 @@ export function FindingsList({
|
|
|
44
50
|
}, [findings]);
|
|
45
51
|
|
|
46
52
|
const filtered = useMemo(
|
|
47
|
-
() =>
|
|
53
|
+
() =>
|
|
54
|
+
filter === "all"
|
|
55
|
+
? findings
|
|
56
|
+
: findings.filter((f) => f.severity === filter),
|
|
48
57
|
[findings, filter],
|
|
49
58
|
);
|
|
50
59
|
|
|
51
60
|
if (findings.length === 0) {
|
|
52
|
-
return
|
|
53
|
-
<p className="rounded-lg border border-dashed border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
|
54
|
-
No findings
|
|
55
|
-
</p>
|
|
56
|
-
);
|
|
61
|
+
return <EmptyCard>No findings</EmptyCard>;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
return (
|
|
@@ -107,55 +112,58 @@ export function FindingsList({
|
|
|
107
112
|
</ToggleGroupItem>
|
|
108
113
|
</ToggleGroup>
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<TableRow
|
|
122
|
-
key={`${f.rule_id}-${f.path}-${f.line ?? "x"}-${i}`}
|
|
123
|
-
className="border-border hover:bg-transparent"
|
|
124
|
-
>
|
|
125
|
-
<TableCell className="px-3 py-2">
|
|
126
|
-
<Badge
|
|
127
|
-
variant={
|
|
128
|
-
f.severity === "error"
|
|
129
|
-
? "destructive"
|
|
130
|
-
: f.severity === "warning"
|
|
131
|
-
? "secondary"
|
|
132
|
-
: "outline"
|
|
133
|
-
}
|
|
134
|
-
>
|
|
135
|
-
{f.severity}
|
|
136
|
-
</Badge>
|
|
137
|
-
</TableCell>
|
|
138
|
-
<TableCell className="px-3 py-2 font-mono text-xs text-muted-foreground">
|
|
139
|
-
{f.rule_id}
|
|
140
|
-
</TableCell>
|
|
141
|
-
<TableCell className="whitespace-normal px-3 py-2 text-sm">
|
|
142
|
-
{f.message}
|
|
143
|
-
</TableCell>
|
|
144
|
-
<TableCell className="min-w-0 px-3 py-2 font-mono text-xs text-muted-foreground">
|
|
145
|
-
<div className="flex min-w-0 items-baseline">
|
|
146
|
-
<TruncatedPath
|
|
147
|
-
path={shortPath(root, f.path)}
|
|
148
|
-
className="min-w-0 flex-1 text-xs"
|
|
149
|
-
/>
|
|
150
|
-
<span className="shrink-0">
|
|
151
|
-
:{f.line != null ? f.line : "—"}
|
|
152
|
-
</span>
|
|
153
|
-
</div>
|
|
154
|
-
</TableCell>
|
|
115
|
+
{filtered.length === 0 ? (
|
|
116
|
+
<EmptyCard className="mt-4">
|
|
117
|
+
{filter === "all" ? "No findings" : emptyFilterMessage(filter)}
|
|
118
|
+
</EmptyCard>
|
|
119
|
+
) : (
|
|
120
|
+
<Table className="mt-4">
|
|
121
|
+
<TableHeader>
|
|
122
|
+
<TableRow>
|
|
123
|
+
<TableHead scope="col">Severity</TableHead>
|
|
124
|
+
<TableHead scope="col">Rule</TableHead>
|
|
125
|
+
<TableHead scope="col">File</TableHead>
|
|
155
126
|
</TableRow>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
127
|
+
</TableHeader>
|
|
128
|
+
<TableBody className="align-top text-foreground">
|
|
129
|
+
{filtered.map((f, i) => (
|
|
130
|
+
<TableRow
|
|
131
|
+
key={`${f.rule_id}-${f.path}-${f.line ?? "x"}-${i}`}
|
|
132
|
+
className="border-border hover:bg-transparent"
|
|
133
|
+
>
|
|
134
|
+
<TableCell className="px-3 py-2">
|
|
135
|
+
<Badge
|
|
136
|
+
variant={
|
|
137
|
+
f.severity === "error"
|
|
138
|
+
? "destructive"
|
|
139
|
+
: f.severity === "warning"
|
|
140
|
+
? "secondary"
|
|
141
|
+
: "outline"
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
{f.severity}
|
|
145
|
+
</Badge>
|
|
146
|
+
</TableCell>
|
|
147
|
+
<TableCell className="min-w-0 whitespace-normal px-3 py-2">
|
|
148
|
+
<div className="font-mono text-xs text-muted-foreground">
|
|
149
|
+
{f.rule_id}
|
|
150
|
+
</div>
|
|
151
|
+
<div className="mt-0.5 text-xs text-pretty text-foreground">
|
|
152
|
+
{f.message}
|
|
153
|
+
</div>
|
|
154
|
+
</TableCell>
|
|
155
|
+
<TableCell className="min-w-0 px-3 py-2">
|
|
156
|
+
<SourceLocationLink
|
|
157
|
+
root={root}
|
|
158
|
+
path={f.path}
|
|
159
|
+
line={f.line}
|
|
160
|
+
/>
|
|
161
|
+
</TableCell>
|
|
162
|
+
</TableRow>
|
|
163
|
+
))}
|
|
164
|
+
</TableBody>
|
|
165
|
+
</Table>
|
|
166
|
+
)}
|
|
159
167
|
</div>
|
|
160
168
|
);
|
|
161
169
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
HoverCardTrigger,
|
|
6
6
|
} from "../components/ui/hover-card";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
|
+
import { EmptyCard } from "../components/EmptyCard";
|
|
8
9
|
import { TruncatedPath } from "../components/TruncatedPath";
|
|
9
10
|
import {
|
|
10
11
|
filterTokenRows,
|
|
@@ -20,6 +21,14 @@ const filterTabs: { id: TokenUsageFilter; label: string }[] = [
|
|
|
20
21
|
{ id: "unused", label: "Unused" },
|
|
21
22
|
];
|
|
22
23
|
|
|
24
|
+
function emptyFilterMessage(filter: TokenUsageFilter): string {
|
|
25
|
+
if (filter === "used") return "No used theme tokens match this filter.";
|
|
26
|
+
if (filter === "unused") {
|
|
27
|
+
return "No unused theme tokens — every scanned token is referenced in the workspace.";
|
|
28
|
+
}
|
|
29
|
+
return "No theme tokens found.";
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
function TokenSection({
|
|
24
33
|
title,
|
|
25
34
|
subtitle,
|
|
@@ -215,31 +224,37 @@ export function ScannedTokenWall({ view }: { view: MergedTokenView }) {
|
|
|
215
224
|
</div>
|
|
216
225
|
</div>
|
|
217
226
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
227
|
+
{filtered.length === 0 ? (
|
|
228
|
+
<EmptyCard>{emptyFilterMessage(filter)}</EmptyCard>
|
|
229
|
+
) : (
|
|
230
|
+
<>
|
|
231
|
+
<ColorSection rows={filtered} />
|
|
232
|
+
<ListSection
|
|
233
|
+
title="Spacing"
|
|
234
|
+
subtitle="--spacing-* custom properties."
|
|
235
|
+
rows={filtered}
|
|
236
|
+
category="spacing"
|
|
237
|
+
/>
|
|
238
|
+
<ListSection
|
|
239
|
+
title="Radius"
|
|
240
|
+
subtitle="--radius-* custom properties."
|
|
241
|
+
rows={filtered}
|
|
242
|
+
category="radius"
|
|
243
|
+
/>
|
|
244
|
+
<ListSection
|
|
245
|
+
title="Typography"
|
|
246
|
+
subtitle="--font-* custom properties."
|
|
247
|
+
rows={filtered}
|
|
248
|
+
category="typography"
|
|
249
|
+
/>
|
|
250
|
+
<ListSection
|
|
251
|
+
title="Other"
|
|
252
|
+
subtitle="Additional CSS variables."
|
|
253
|
+
rows={filtered}
|
|
254
|
+
category="other"
|
|
255
|
+
/>
|
|
256
|
+
</>
|
|
257
|
+
)}
|
|
243
258
|
</section>
|
|
244
259
|
);
|
|
245
260
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { TruncatedPath } from "../components/TruncatedPath";
|
|
3
|
+
import { openSourceFile } from "./editorLink";
|
|
4
|
+
import { resolveReportAbsolutePath, shortPath } from "./paths";
|
|
5
|
+
|
|
6
|
+
export function SourceLocationLink({
|
|
7
|
+
root,
|
|
8
|
+
path,
|
|
9
|
+
line,
|
|
10
|
+
}: {
|
|
11
|
+
root: string;
|
|
12
|
+
path: string;
|
|
13
|
+
line?: number | null;
|
|
14
|
+
}) {
|
|
15
|
+
const fileText = shortPath(root, path);
|
|
16
|
+
const locationText = line != null ? `${fileText}:${line}` : fileText;
|
|
17
|
+
const absolutePath = resolveReportAbsolutePath(root, path);
|
|
18
|
+
|
|
19
|
+
const handleClick = useCallback(() => {
|
|
20
|
+
void openSourceFile(absolutePath, line ?? undefined).catch((err) => {
|
|
21
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
22
|
+
window.alert(`Could not open file: ${message}`);
|
|
23
|
+
});
|
|
24
|
+
}, [absolutePath, line]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={handleClick}
|
|
30
|
+
className="block min-w-0 w-full text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
|
31
|
+
title={locationText}
|
|
32
|
+
>
|
|
33
|
+
<TruncatedPath
|
|
34
|
+
path={locationText}
|
|
35
|
+
className="text-xs"
|
|
36
|
+
title={undefined}
|
|
37
|
+
/>
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { findColorTokenForHex, normalizeHex, parseCssColor } from "./css-color";
|
|
3
|
+
import type { CssTokenDefinition } from "../types/report";
|
|
4
|
+
|
|
5
|
+
function colorDef(name: string, value: string): CssTokenDefinition {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
value,
|
|
9
|
+
category: "color",
|
|
10
|
+
scope: "theme",
|
|
11
|
+
path: "theme.css",
|
|
12
|
+
line: 1,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("css-color", () => {
|
|
17
|
+
it("normalizes shorthand hex", () => {
|
|
18
|
+
expect(normalizeHex("#abc")).toBe("#aabbcc");
|
|
19
|
+
expect(normalizeHex("#DC2626")).toBe("#dc2626");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("parses rgb to hex", () => {
|
|
23
|
+
expect(parseCssColor("rgb(220, 38, 38)")).toBe("#dc2626");
|
|
24
|
+
expect(parseCssColor("rgba(220, 38, 38, 0.5)")).toBe("#dc2626");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("finds token by normalized hex when value is rgb", () => {
|
|
28
|
+
const defs = [colorDef("--color-danger", "rgb(220, 38, 38)")];
|
|
29
|
+
const token = findColorTokenForHex(defs, "#dc2626");
|
|
30
|
+
expect(token?.name).toBe("--color-danger");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("finds token when value is hex and finding uses shorthand", () => {
|
|
34
|
+
const defs = [colorDef("--color-danger", "#dc2626")];
|
|
35
|
+
const token = findColorTokenForHex(defs, "#dc2626");
|
|
36
|
+
expect(token?.name).toBe("--color-danger");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resolves var() chains before matching", () => {
|
|
40
|
+
const defs = [
|
|
41
|
+
colorDef("--primary", "#93c5fd"),
|
|
42
|
+
colorDef("--color-primary", "var(--primary)"),
|
|
43
|
+
];
|
|
44
|
+
const token = findColorTokenForHex(defs, "#93c5fd");
|
|
45
|
+
expect(token?.name).toBe("--color-primary");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns undefined when no token matches", () => {
|
|
49
|
+
const defs = [colorDef("--color-danger", "rgb(220, 38, 38)")];
|
|
50
|
+
expect(findColorTokenForHex(defs, "#ff0066")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CssTokenDefinition } from "../types/report";
|
|
2
|
+
|
|
3
|
+
/** Expand `#abc` → `#aabbcc` (lowercase). */
|
|
4
|
+
export function normalizeHex(raw: string): string | null {
|
|
5
|
+
const s = raw.trim().replace(/^#/, "");
|
|
6
|
+
if (s.length === 3 && /^[0-9a-fA-F]{3}$/.test(s)) {
|
|
7
|
+
return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
if (s.length === 6 && /^[0-9a-fA-F]{6}$/.test(s)) {
|
|
10
|
+
return `#${s}`.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse hex or `rgb()`/`rgba()` into normalized `#rrggbb`. */
|
|
16
|
+
export function parseCssColor(raw: string): string | null {
|
|
17
|
+
const trimmed = raw.trim();
|
|
18
|
+
if (trimmed.startsWith("#")) {
|
|
19
|
+
return normalizeHex(trimmed);
|
|
20
|
+
}
|
|
21
|
+
const rgb = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
22
|
+
if (rgb) {
|
|
23
|
+
const [, r, g, b] = rgb;
|
|
24
|
+
const toHex = (n: string) => Number(n).toString(16).padStart(2, "0");
|
|
25
|
+
return `#${toHex(r!)}${toHex(g!)}${toHex(b!)}`;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const VAR_REF_RE = /var\(\s*(--[a-zA-Z0-9][a-zA-Z0-9_-]*)/;
|
|
31
|
+
const MAX_VAR_DEPTH = 8;
|
|
32
|
+
|
|
33
|
+
function resolveTokenValue(
|
|
34
|
+
value: string,
|
|
35
|
+
varMap: Map<string, string>,
|
|
36
|
+
depth = 0,
|
|
37
|
+
): string | null {
|
|
38
|
+
if (depth > MAX_VAR_DEPTH) return null;
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
const varMatch = trimmed.match(VAR_REF_RE);
|
|
41
|
+
if (varMatch?.[1]) {
|
|
42
|
+
const next = varMap.get(varMatch[1]);
|
|
43
|
+
if (!next) return null;
|
|
44
|
+
return resolveTokenValue(next, varMap, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Find a color token whose resolved value equals the given hex (after normalization). */
|
|
50
|
+
export function findColorTokenForHex(
|
|
51
|
+
definitions: CssTokenDefinition[] | undefined,
|
|
52
|
+
hex: string,
|
|
53
|
+
): CssTokenDefinition | undefined {
|
|
54
|
+
const target = parseCssColor(hex);
|
|
55
|
+
if (!target || !definitions?.length) return undefined;
|
|
56
|
+
|
|
57
|
+
const varMap = new Map(definitions.map((d) => [d.name, d.value]));
|
|
58
|
+
|
|
59
|
+
const matches = definitions.filter((def) => {
|
|
60
|
+
if (def.category !== "color") return false;
|
|
61
|
+
const resolved = resolveTokenValue(def.value, varMap);
|
|
62
|
+
if (!resolved) return false;
|
|
63
|
+
return parseCssColor(resolved) === target;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (matches.length === 0) return undefined;
|
|
67
|
+
|
|
68
|
+
return matches.sort((a, b) => {
|
|
69
|
+
const aPref = a.name.startsWith("--color-") ? 0 : 1;
|
|
70
|
+
const bPref = b.name.startsWith("--color-") ? 0 : 1;
|
|
71
|
+
return aPref - bPref;
|
|
72
|
+
})[0];
|
|
73
|
+
}
|
|
@@ -45,15 +45,15 @@
|
|
|
45
45
|
"rule_id": "token-hardcoded-color",
|
|
46
46
|
"pillar": "token",
|
|
47
47
|
"default_severity": "warning",
|
|
48
|
-
"description": "Hardcoded hex/rgb color
|
|
48
|
+
"description": "Hardcoded hex/rgb color with no matching value in scanned CSS design tokens (className, style, or literals).",
|
|
49
49
|
"fix_hint": "Replace with a CSS variable or Tailwind theme utility from css_tokens."
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"rule_id": "token-tailwind-arbitrary",
|
|
53
53
|
"pillar": "token",
|
|
54
54
|
"default_severity": "warning",
|
|
55
|
-
"description": "Tailwind arbitrary
|
|
56
|
-
"fix_hint": "Use a named theme utility
|
|
55
|
+
"description": "Tailwind arbitrary color or redundant size that duplicates the default scale or @theme spacing tokens.",
|
|
56
|
+
"fix_hint": "Use a named theme utility (e.g. w-20, p-layout-md) instead of an arbitrary bracket value."
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"rule_id": "token-unused-css-var",
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"rule_id": "code-inline-style",
|
|
116
116
|
"pillar": "code",
|
|
117
117
|
"default_severity": "warning",
|
|
118
|
-
"description": "Inline JSX style
|
|
119
|
-
"fix_hint": "Use className with theme utilities."
|
|
118
|
+
"description": "Inline JSX style uses hardcoded colors not in scanned CSS design tokens.",
|
|
119
|
+
"fix_hint": "Use className with theme utilities or CSS variables instead of inline color values."
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
"rule_id": "code-empty-catch",
|
|
@@ -46,4 +46,28 @@ describe("verify-loop", () => {
|
|
|
46
46
|
const fix = suggestFix(report, { rule_id: "token-hardcoded-color" });
|
|
47
47
|
expect(fix?.fix_hint).toBeTruthy();
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
it("matches rgb token values when suggesting hardcoded color fix", () => {
|
|
51
|
+
const report = loadDemoReport();
|
|
52
|
+
const fix = suggestFix(report, {
|
|
53
|
+
rule_id: "token-hardcoded-color",
|
|
54
|
+
message: "Hardcoded color `#dc2626` — no matching design token",
|
|
55
|
+
});
|
|
56
|
+
// Demo theme defines --color-danger as hex; rgb form should also resolve.
|
|
57
|
+
const rgbReport = {
|
|
58
|
+
...report,
|
|
59
|
+
css_tokens: {
|
|
60
|
+
...report.css_tokens!,
|
|
61
|
+
definitions: report.css_tokens!.definitions.map((d) =>
|
|
62
|
+
d.name === "--color-danger" ? { ...d, value: "rgb(220, 38, 38)" } : d,
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const rgbFix = suggestFix(rgbReport, {
|
|
67
|
+
rule_id: "token-hardcoded-color",
|
|
68
|
+
message: "Hardcoded color `#dc2626` — no matching design token",
|
|
69
|
+
});
|
|
70
|
+
expect(rgbFix?.token).toBe("--color-danger");
|
|
71
|
+
expect(fix?.fix_hint).toBeTruthy();
|
|
72
|
+
});
|
|
49
73
|
});
|
package/src/mcp/verify-loop.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { catalogSummary, componentSpec, findingsForPaths } from "./agent-query";
|
|
2
|
+
import { findColorTokenForHex } from "./css-color";
|
|
2
3
|
import { ruleById } from "./rule-catalog";
|
|
3
4
|
import type { LintFinding, WorkspaceReport } from "../types/report";
|
|
4
5
|
|
|
@@ -13,7 +14,7 @@ export type FixSuggestion = {
|
|
|
13
14
|
|
|
14
15
|
export function suggestFix(
|
|
15
16
|
report: WorkspaceReport,
|
|
16
|
-
opts: { rule_id: string; path?: string; component?: string },
|
|
17
|
+
opts: { rule_id: string; path?: string; component?: string; message?: string },
|
|
17
18
|
): FixSuggestion | null {
|
|
18
19
|
const entry = ruleById(opts.rule_id);
|
|
19
20
|
const fix_hint = entry?.fix_hint ?? "Review the finding and align with design system conventions.";
|
|
@@ -42,19 +43,39 @@ export function suggestFix(
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
if (opts.rule_id === "token-hardcoded-color") {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const hexMatch = opts.message?.match(/`(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))`/);
|
|
47
|
+
const hex = hexMatch?.[1]?.toLowerCase();
|
|
48
|
+
const colorToken =
|
|
49
|
+
hex != null
|
|
50
|
+
? findColorTokenForHex(report.css_tokens?.definitions, hex)
|
|
51
|
+
: report.css_tokens?.definitions.find((d) => d.category === "color");
|
|
48
52
|
return {
|
|
49
53
|
rule_id: opts.rule_id,
|
|
50
54
|
fix_hint,
|
|
51
55
|
suggestion: colorToken
|
|
52
|
-
? `Use theme token \`${colorToken.name}\` or Tailwind utility bound to it instead of
|
|
53
|
-
: "Replace hardcoded color with a CSS variable or Tailwind theme utility.",
|
|
56
|
+
? `Use theme token \`${colorToken.name}\` or Tailwind utility bound to it instead of hardcoded \`${hex ?? "color"}\`.`
|
|
57
|
+
: "Replace hardcoded color with a CSS variable or Tailwind theme utility from css_tokens.",
|
|
54
58
|
token: colorToken?.name,
|
|
55
59
|
};
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
if (opts.rule_id === "token-tailwind-arbitrary") {
|
|
63
|
+
const utilityMatch = opts.message?.match(/Use `([^`]+)` instead of arbitrary/);
|
|
64
|
+
if (utilityMatch?.[1]) {
|
|
65
|
+
return {
|
|
66
|
+
rule_id: opts.rule_id,
|
|
67
|
+
fix_hint,
|
|
68
|
+
suggestion: `Replace the arbitrary value with \`${utilityMatch[1]}\` from the Tailwind or @theme spacing scale.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
rule_id: opts.rule_id,
|
|
73
|
+
fix_hint,
|
|
74
|
+
suggestion:
|
|
75
|
+
"Replace the arbitrary bracket value with a named theme utility or CSS custom property.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
if (opts.rule_id === "a11y-img-alt" && opts.path) {
|
|
59
80
|
const decorative =
|
|
60
81
|
/avatar|icon|logo|decorative|spacer/i.test(opts.path) ||
|
|
@@ -141,6 +162,7 @@ export function findingsWithSuggestions(
|
|
|
141
162
|
rule_id: f.rule_id,
|
|
142
163
|
path: f.path,
|
|
143
164
|
component: componentMatch?.[1],
|
|
165
|
+
message: f.message,
|
|
144
166
|
});
|
|
145
167
|
return suggestion ? { ...f, suggestion } : f;
|
|
146
168
|
});
|