dslinter 0.4.0 → 0.5.1

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 (41) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -1
  3. package/bin/lib/dev-banner.mjs +54 -5
  4. package/bin/lib/dev-banner.test.mjs +17 -4
  5. package/bin/lib/network-hosts.mjs +81 -0
  6. package/bin/lib/network-hosts.test.mjs +57 -0
  7. package/bin/lib/project-root.mjs +25 -2
  8. package/bin/lib/project-root.test.mjs +24 -0
  9. package/bin/lib/scan-host.test.mjs +20 -0
  10. package/bin/modes/dev.mjs +14 -3
  11. package/dashboard-dist/assets/DashboardLayoutAuto-COU-fvpX.css +1 -0
  12. package/dashboard-dist/assets/DashboardLayoutAuto-NfQasneG.js +1 -0
  13. package/dashboard-dist/assets/{axe-DHHCqGjV.js → axe-0qmg8n-4.js} +1 -1
  14. package/dashboard-dist/assets/index-B0oyZAfn.js +219 -0
  15. package/dashboard-dist/assets/index-BjnQAYrx.css +1 -0
  16. package/dashboard-dist/index.html +2 -2
  17. package/embed/App.tsx +22 -0
  18. package/embed/index.css +3 -0
  19. package/embed/main.tsx +10 -0
  20. package/embed/tokenCatalog.ts +48 -0
  21. package/index.cjs +52 -52
  22. package/index.html +12 -0
  23. package/package.json +10 -8
  24. package/src/components/Section.tsx +1 -1
  25. package/src/components/ui/badge.tsx +1 -1
  26. package/src/dashboard/ComponentUsageDetails.tsx +7 -39
  27. package/src/dashboard/DashboardBody.tsx +2 -7
  28. package/src/dashboard/FindingsList.tsx +64 -56
  29. package/src/dashboard/ScannedTokenWall.tsx +40 -25
  30. package/src/dashboard/SourceLocationLink.tsx +40 -0
  31. package/src/mcp/rule-catalog.json +2 -2
  32. package/src/playground/inferKitJsx.ts +141 -14
  33. package/src/playground/inferKitParams.ts +40 -2
  34. package/src/playground/playgroundJoin.test.ts +35 -1
  35. package/src/playground/playgroundJoin.ts +18 -0
  36. package/src/shell/DashboardLayoutAuto.tsx +30 -5
  37. package/vite/embed-serve.config.ts +59 -0
  38. package/dashboard-dist/assets/DashboardLayoutAuto-BWuyjHPD.js +0 -1
  39. package/dashboard-dist/assets/DashboardLayoutAuto-CAO6F6-q.css +0 -1
  40. package/dashboard-dist/assets/index-Bxk7tA3F.js +0 -219
  41. package/dashboard-dist/assets/index-D0O_5w5V.css +0 -1
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from "react";
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 { TruncatedPath } from "../components/TruncatedPath";
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
- <UsageLocationLink root={report.root} loc={loc} />
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 { TruncatedPath } from "../components/TruncatedPath";
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
- () => (filter === "all" ? findings : findings.filter((f) => f.severity === filter)),
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
- <Table className="mt-4">
111
- <TableHeader>
112
- <TableRow>
113
- <TableHead scope="col">Severity</TableHead>
114
- <TableHead scope="col">Rule</TableHead>
115
- <TableHead scope="col">Message</TableHead>
116
- <TableHead scope="col">File</TableHead>
117
- </TableRow>
118
- </TableHeader>
119
- <TableBody className="align-top text-foreground">
120
- {filtered.map((f, i) => (
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
- </TableBody>
158
- </Table>
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
- <ColorSection rows={filtered} />
219
- <ListSection
220
- title="Spacing"
221
- subtitle="--spacing-* custom properties."
222
- rows={filtered}
223
- category="spacing"
224
- />
225
- <ListSection
226
- title="Radius"
227
- subtitle="--radius-* custom properties."
228
- rows={filtered}
229
- category="radius"
230
- />
231
- <ListSection
232
- title="Typography"
233
- subtitle="--font-* custom properties."
234
- rows={filtered}
235
- category="typography"
236
- />
237
- <ListSection
238
- title="Other"
239
- subtitle="Additional CSS variables."
240
- rows={filtered}
241
- category="other"
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
+ }
@@ -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={{}} bypasses token/Tailwind conventions.",
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",
@@ -10,7 +10,53 @@ export type KitRootPropBinding = {
10
10
  };
11
11
 
12
12
  function kitSource(fn: Function): string {
13
- return fn.toString();
13
+ const src = fn.toString();
14
+ const MAX_KIT_SOURCE_LEN = 64_000;
15
+ return src.length > MAX_KIT_SOURCE_LEN ? src.slice(0, MAX_KIT_SOURCE_LEN) : src;
16
+ }
17
+
18
+ function skipWhitespace(src: string, start: number): number {
19
+ let i = start;
20
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
21
+ return i;
22
+ }
23
+
24
+ function isIdentChar(ch: string, first = false): boolean {
25
+ if (first) return /[A-Za-z_$]/.test(ch);
26
+ return /[\w$]/.test(ch);
27
+ }
28
+
29
+ function readIdent(
30
+ src: string,
31
+ start: number,
32
+ firstUpper = false,
33
+ ): { value: string; end: number } | null {
34
+ if (start >= src.length) return null;
35
+ const ch = src[start]!;
36
+ if (firstUpper && !/[A-Z]/.test(ch)) return null;
37
+ if (!isIdentChar(ch, true)) return null;
38
+ let end = start + 1;
39
+ while (end < src.length && isIdentChar(src[end]!, false)) end += 1;
40
+ const value = src.slice(start, end);
41
+ if (firstUpper && !/^[A-Z][A-Za-z0-9]*$/.test(value)) return null;
42
+ return { value, end };
43
+ }
44
+
45
+ function skipUntilChar(src: string, start: number, target: string): number {
46
+ let quote: "'" | '"' | "`" | null = null;
47
+ for (let i = start; i < src.length; i += 1) {
48
+ const ch = src[i]!;
49
+ if (quote) {
50
+ if (ch === quote && src[i - 1] !== "\\") quote = null;
51
+ continue;
52
+ }
53
+ if (ch === "'" || ch === '"' || ch === "`") {
54
+ quote = ch;
55
+ continue;
56
+ }
57
+ if (ch === target) return i;
58
+ }
59
+ return -1;
14
60
  }
15
61
 
16
62
  /** Vite/esbuild wraps JSX as `(0, import.createElement)(Type, props)` (often split across lines). */
@@ -58,9 +104,45 @@ function collectJsxRuntimeSlots(src: string, out: KitJsxSlot[]): void {
58
104
  }
59
105
 
60
106
  function collectSourceJsxSlots(src: string, out: KitJsxSlot[]): void {
61
- const re = /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*<\/\1>/g;
62
- for (const match of src.matchAll(re)) {
63
- out.push({ component: match[1]!, param: match[2]! });
107
+ let i = 0;
108
+ while (i < src.length) {
109
+ const lt = src.indexOf("<", i);
110
+ if (lt === -1) break;
111
+ if (src[lt + 1] === "/") {
112
+ i = lt + 1;
113
+ continue;
114
+ }
115
+ const ident = readIdent(src, lt + 1, true);
116
+ if (!ident) {
117
+ i = lt + 1;
118
+ continue;
119
+ }
120
+ const tagEnd = skipUntilChar(src, ident.end, ">");
121
+ if (tagEnd === -1) break;
122
+ let j = skipWhitespace(src, tagEnd + 1);
123
+ if (src[j] !== "{") {
124
+ i = lt + 1;
125
+ continue;
126
+ }
127
+ j = skipWhitespace(src, j + 1);
128
+ const paramIdent = readIdent(src, j);
129
+ if (!paramIdent) {
130
+ i = lt + 1;
131
+ continue;
132
+ }
133
+ j = skipWhitespace(src, paramIdent.end);
134
+ if (src[j] !== "}") {
135
+ i = lt + 1;
136
+ continue;
137
+ }
138
+ j = skipWhitespace(src, j + 1);
139
+ const closeTag = `</${ident.value}>`;
140
+ if (!src.slice(j).startsWith(closeTag)) {
141
+ i = lt + 1;
142
+ continue;
143
+ }
144
+ out.push({ component: ident.value, param: paramIdent.value });
145
+ i = j + closeTag.length;
64
146
  }
65
147
  }
66
148
 
@@ -118,6 +200,60 @@ function parseJsxPropsObject(raw: string | undefined): Array<{ prop: string; par
118
200
  return out;
119
201
  }
120
202
 
203
+ function parseJsxAttrBindings(attrs: string, component: string, out: KitRootPropBinding[]): void {
204
+ let i = 0;
205
+ while (i < attrs.length) {
206
+ i = skipWhitespace(attrs, i);
207
+ if (i >= attrs.length) break;
208
+ const propIdent = readIdent(attrs, i);
209
+ if (!propIdent) {
210
+ i += 1;
211
+ continue;
212
+ }
213
+ i = skipWhitespace(attrs, propIdent.end);
214
+ if (attrs[i] !== "=") {
215
+ i = propIdent.end;
216
+ continue;
217
+ }
218
+ i = skipWhitespace(attrs, i + 1);
219
+ if (attrs[i] !== "{") {
220
+ i += 1;
221
+ continue;
222
+ }
223
+ i = skipWhitespace(attrs, i + 1);
224
+ const paramIdent = readIdent(attrs, i);
225
+ if (!paramIdent) continue;
226
+ const prop = propIdent.value;
227
+ if (prop === "className" || prop === "style" || prop === "asChild") {
228
+ i = paramIdent.end;
229
+ continue;
230
+ }
231
+ out.push({ component, prop, param: paramIdent.value });
232
+ i = paramIdent.end;
233
+ }
234
+ }
235
+
236
+ function collectSourceRootPropBindings(src: string, out: KitRootPropBinding[]): void {
237
+ let i = 0;
238
+ while (i < src.length) {
239
+ const lt = src.indexOf("<", i);
240
+ if (lt === -1) break;
241
+ if (src[lt + 1] === "/") {
242
+ i = lt + 1;
243
+ continue;
244
+ }
245
+ const ident = readIdent(src, lt + 1, true);
246
+ if (!ident) {
247
+ i = lt + 1;
248
+ continue;
249
+ }
250
+ const tagEnd = skipUntilChar(src, ident.end, ">");
251
+ if (tagEnd === -1) break;
252
+ parseJsxAttrBindings(src.slice(ident.end, tagEnd), ident.value, out);
253
+ i = tagEnd + 1;
254
+ }
255
+ }
256
+
121
257
  function collectRootPropBindings(src: string, out: KitRootPropBinding[]): void {
122
258
  const normalized = normalizeKitSource(src);
123
259
  for (const match of normalized.matchAll(RUNTIME_ROOT_PROPS_RE)) {
@@ -126,16 +262,7 @@ function collectRootPropBindings(src: string, out: KitRootPropBinding[]): void {
126
262
  }
127
263
  }
128
264
 
129
- const sourceRoot = /<([A-Z][A-Za-z0-9]*)([^>]*)>/g;
130
- for (const match of src.matchAll(sourceRoot)) {
131
- const attrs = match[2] ?? "";
132
- const attrRe = /\b([A-Za-z_$][\w$]*)\s*=\s*\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
133
- for (const attr of attrs.matchAll(attrRe)) {
134
- const prop = attr[1]!;
135
- if (prop === "className" || prop === "style" || prop === "asChild") continue;
136
- out.push({ component: match[1]!, prop, param: attr[2]! });
137
- }
138
- }
265
+ collectSourceRootPropBindings(src, out);
139
266
  }
140
267
 
141
268
  /** Root component prop bindings such as `<Alert variant={variant}>`. */
@@ -91,8 +91,46 @@ function findTopLevelChar(input: string, target: string): number {
91
91
 
92
92
  function objectDestructuringSource(fn: Function): string | undefined {
93
93
  const src = fn.toString().trim();
94
- const match = src.match(/^(?:async\s*)?(?:function\s*)?\(\s*\{([\s\S]*?)\}\s*(?::[^)=]*)?\)/);
95
- return match?.[1];
94
+ const MAX_KIT_SOURCE_LEN = 64_000;
95
+ const bounded = src.length > MAX_KIT_SOURCE_LEN ? src.slice(0, MAX_KIT_SOURCE_LEN) : src;
96
+
97
+ let i = 0;
98
+ if (bounded.startsWith("async")) i = "async".length;
99
+ i = skipWhitespace(bounded, i);
100
+ if (bounded.slice(i).startsWith("function")) i += "function".length;
101
+ i = skipWhitespace(bounded, i);
102
+ if (bounded[i] !== "(") return undefined;
103
+ i = skipWhitespace(bounded, i + 1);
104
+ if (bounded[i] !== "{") return undefined;
105
+
106
+ const start = i + 1;
107
+ let depth = 1;
108
+ i += 1;
109
+ let quote: "'" | '"' | "`" | null = null;
110
+ while (i < bounded.length && depth > 0) {
111
+ const ch = bounded[i]!;
112
+ if (quote) {
113
+ if (ch === quote && bounded[i - 1] !== "\\") quote = null;
114
+ i += 1;
115
+ continue;
116
+ }
117
+ if (ch === "'" || ch === '"' || ch === "`") {
118
+ quote = ch;
119
+ i += 1;
120
+ continue;
121
+ }
122
+ if (ch === "{") depth += 1;
123
+ else if (ch === "}") depth -= 1;
124
+ i += 1;
125
+ }
126
+ if (depth !== 0) return undefined;
127
+ return bounded.slice(start, i - 1);
128
+ }
129
+
130
+ function skipWhitespace(src: string, start: number): number {
131
+ let i = start;
132
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
133
+ return i;
96
134
  }
97
135
 
98
136
  /** Read destructured parameter keys (and inline defaults) from a kit callback. */
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { WorkspaceReport } from "../types/report";
3
- import { defaultConsumerGlobKeyFromRelPath, diagnosePlaygroundJoinSkips } from "./playgroundJoin";
3
+ import {
4
+ defaultConsumerGlobKeyFromRelPath,
5
+ diagnosePlaygroundJoinSkips,
6
+ isStaticBundledPreviewUnavailable,
7
+ } from "./playgroundJoin";
4
8
  import { buildPlaygroundEntriesFromReportWithSkips } from "./buildPlaygroundEntriesFromReport";
5
9
 
6
10
  describe("defaultConsumerGlobKeyFromRelPath", () => {
@@ -140,3 +144,33 @@ describe("diagnosePlaygroundJoinSkips", () => {
140
144
  expect(entries[0]?.modulePath).toBe("../Components/Button.tsx");
141
145
  });
142
146
  });
147
+
148
+ describe("isStaticBundledPreviewUnavailable", () => {
149
+ it("returns true in production when all skips are missing embed modules", () => {
150
+ const skipped = [
151
+ {
152
+ export_name: "AppLogo",
153
+ rel_path: "resources/js/components/app-logo.tsx",
154
+ globKey: "@dslinter-scan/resources/js/components/app-logo.tsx",
155
+ reason: "module_not_found" as const,
156
+ },
157
+ ];
158
+ expect(isStaticBundledPreviewUnavailable(skipped, { production: true })).toBe(
159
+ true,
160
+ );
161
+ });
162
+
163
+ it("returns false in dev mode", () => {
164
+ const skipped = [
165
+ {
166
+ export_name: "AppLogo",
167
+ rel_path: "resources/js/components/app-logo.tsx",
168
+ globKey: "@dslinter-scan/resources/js/components/app-logo.tsx",
169
+ reason: "module_not_found" as const,
170
+ },
171
+ ];
172
+ expect(isStaticBundledPreviewUnavailable(skipped, { production: false })).toBe(
173
+ false,
174
+ );
175
+ });
176
+ });