@decocms/start 2.13.0 → 2.15.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.
Files changed (24) hide show
  1. package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +132 -6
  2. package/CLAUDE.md +1 -1
  3. package/package.json +1 -1
  4. package/scripts/migrate/post-cleanup/rules.ts +196 -7
  5. package/scripts/migrate/post-cleanup/runner.test.ts +225 -2
  6. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  7. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  8. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +0 -655
  9. package/.cursor/skills/deco-to-tanstack-migration/references/codemod-commands.md +0 -174
  10. package/.cursor/skills/deco-to-tanstack-migration/references/commerce/README.md +0 -78
  11. package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +0 -174
  12. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +0 -834
  13. package/.cursor/skills/deco-to-tanstack-migration/references/imports/README.md +0 -70
  14. package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +0 -121
  15. package/.cursor/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +0 -231
  16. package/.cursor/skills/deco-to-tanstack-migration/references/signals/README.md +0 -220
  17. package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +0 -103
  18. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +0 -75
  19. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +0 -127
  20. package/.cursor/skills/deco-to-tanstack-migration/templates/router.md +0 -96
  21. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +0 -148
  22. package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +0 -197
  23. package/.cursor/skills/deco-to-tanstack-migration/templates/worker-entry.md +0 -67
  24. /package/{.cursor → .agents}/skills/deco-to-tanstack-migration/references/server-functions/README.md +0 -0
@@ -0,0 +1,352 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { classifyShimExports, type ClassifiedExport } from "./shim-classify";
3
+
4
+ function classMap(content: string): Record<string, ClassifiedExport["class"]> {
5
+ return Object.fromEntries(
6
+ classifyShimExports(content).map((e) => [e.name, e.class]),
7
+ );
8
+ }
9
+
10
+ describe("classifyShimExports — single statement function bodies", () => {
11
+ it("returns null → stub", () => {
12
+ const code = `
13
+ export function getSegmentFromBag(_req?: any): Record<string, unknown> | null {
14
+ return null;
15
+ }
16
+ `;
17
+ expect(classMap(code)).toEqual({ getSegmentFromBag: "stub" });
18
+ });
19
+
20
+ it("returns {} → stub", () => {
21
+ const code = `
22
+ export function getISCookiesFromBag(_req?: any): Record<string, string> {
23
+ return {};
24
+ }
25
+ `;
26
+ expect(classMap(code)).toEqual({ getISCookiesFromBag: "stub" });
27
+ });
28
+
29
+ it("returns [] → stub", () => {
30
+ const code = `export function emptyList(): string[] { return []; }`;
31
+ expect(classMap(code)).toEqual({ emptyList: "stub" });
32
+ });
33
+
34
+ it("returns empty string → stub", () => {
35
+ const code = `export function emptyStr(): string { return ""; }`;
36
+ expect(classMap(code)).toEqual({ emptyStr: "stub" });
37
+ });
38
+
39
+ it("identity cast (return x as T) → stub", () => {
40
+ const code = `
41
+ import type { Product } from "@decocms/apps/commerce/types";
42
+ export function toProduct(vtexProduct: any): Product {
43
+ return vtexProduct as Product;
44
+ }
45
+ `;
46
+ expect(classMap(code)).toEqual({ toProduct: "stub" });
47
+ });
48
+
49
+ it("identity cast on member (return x.foo as T) → stub", () => {
50
+ const code = `export function toThing(o: any): X { return o.payload as X; }`;
51
+ expect(classMap(code)).toEqual({ toThing: "stub" });
52
+ });
53
+
54
+ it("unconditional throw → stub", () => {
55
+ const code = `
56
+ export function notImplemented(): never {
57
+ throw new Error("not implemented");
58
+ }
59
+ `;
60
+ expect(classMap(code)).toEqual({ notImplemented: "stub" });
61
+ });
62
+
63
+ it("returns non-empty value → functional", () => {
64
+ const code = `export function answer(): number { return 42; }`;
65
+ expect(classMap(code)).toEqual({ answer: "functional" });
66
+ });
67
+
68
+ it("returns non-identity expression → functional", () => {
69
+ const code = `
70
+ export function isFilterParam(key: string): boolean {
71
+ return key.startsWith("filter.");
72
+ }
73
+ `;
74
+ expect(classMap(code)).toEqual({ isFilterParam: "functional" });
75
+ });
76
+
77
+ it("multi-statement body → functional (default safe)", () => {
78
+ const code = `
79
+ export async function fetchSafe(input: string): Promise<Response> {
80
+ const response = await fetch(input);
81
+ if (!response.ok) {
82
+ console.error(response.status);
83
+ }
84
+ return response;
85
+ }
86
+ `;
87
+ expect(classMap(code)).toEqual({ fetchSafe: "functional" });
88
+ });
89
+
90
+ it("body with nested blocks → functional", () => {
91
+ const code = `
92
+ export function withDefaultParams(params: any, defaults?: any): any {
93
+ if (params instanceof URLSearchParams) {
94
+ if (defaults) {
95
+ for (const [key, value] of Object.entries(defaults)) {
96
+ if (!params.has(key)) {
97
+ params.set(key, value);
98
+ }
99
+ }
100
+ }
101
+ return params;
102
+ }
103
+ return { ...params, ...defaults };
104
+ }
105
+ `;
106
+ expect(classMap(code)).toEqual({ withDefaultParams: "functional" });
107
+ });
108
+
109
+ it("comments before return → still classified by content", () => {
110
+ const code = `
111
+ export function stubWithComment(): null {
112
+ // Intentionally a stub — see migration notes.
113
+ return null;
114
+ }
115
+ `;
116
+ expect(classMap(code)).toEqual({ stubWithComment: "stub" });
117
+ });
118
+
119
+ it("trailing semicolon optional", () => {
120
+ const code = `export function noSemi(): null { return null }`;
121
+ expect(classMap(code)).toEqual({ noSemi: "stub" });
122
+ });
123
+ });
124
+
125
+ describe("classifyShimExports — async functions", () => {
126
+ it("async returns null → stub", () => {
127
+ const code = `export async function asyncStub(): Promise<null> { return null; }`;
128
+ expect(classMap(code)).toEqual({ asyncStub: "stub" });
129
+ });
130
+
131
+ it("async with real work → functional", () => {
132
+ const code = `
133
+ export async function fetcher(): Promise<Response> {
134
+ const r = await fetch("/x");
135
+ return r;
136
+ }
137
+ `;
138
+ expect(classMap(code)).toEqual({ fetcher: "functional" });
139
+ });
140
+ });
141
+
142
+ describe("classifyShimExports — const arrow functions", () => {
143
+ it("arrow returns null (block body) → stub", () => {
144
+ const code = `export const noop = (): null => { return null; };`;
145
+ expect(classMap(code)).toEqual({ noop: "stub" });
146
+ });
147
+
148
+ it("arrow returns null (expression body) → stub", () => {
149
+ const code = `export const noop = (): null => null;`;
150
+ expect(classMap(code)).toEqual({ noop: "stub" });
151
+ });
152
+
153
+ it("arrow returns empty object literal expression → stub", () => {
154
+ const code = `export const noop = () => ({});`;
155
+ expect(classMap(code)).toEqual({ noop: "stub" });
156
+ });
157
+
158
+ it("arrow with real expression → functional", () => {
159
+ const code = `export const square = (n: number) => n * n;`;
160
+ expect(classMap(code)).toEqual({ square: "functional" });
161
+ });
162
+
163
+ it("non-arrow const (object literal) → functional", () => {
164
+ const code = `export const config = { account: "x" };`;
165
+ expect(classMap(code)).toEqual({ config: "functional" });
166
+ });
167
+ });
168
+
169
+ describe("classifyShimExports — type/interface declarations", () => {
170
+ it("interface → type-only", () => {
171
+ const code = `
172
+ export interface VTEXCommerceStable {
173
+ account: string;
174
+ environment?: string;
175
+ }
176
+ `;
177
+ expect(classMap(code)).toEqual({ VTEXCommerceStable: "type-only" });
178
+ });
179
+
180
+ it("type alias → type-only", () => {
181
+ const code = `export type AccountId = string;`;
182
+ expect(classMap(code)).toEqual({ AccountId: "type-only" });
183
+ });
184
+ });
185
+
186
+ describe("classifyShimExports — real casaevideo-storefront fixtures", () => {
187
+ it("vtex-segment.ts (mixed: stub + functional)", () => {
188
+ const code = `
189
+ export function getSegmentFromBag(_req?: any): Record<string, unknown> | null {
190
+ return null;
191
+ }
192
+
193
+ export function withSegmentCookie(..._args: any[]): any {
194
+ for (const arg of _args) {
195
+ if (arg instanceof Headers) {
196
+ return arg;
197
+ }
198
+ }
199
+ return new Headers();
200
+ }
201
+ `;
202
+ expect(classMap(code)).toEqual({
203
+ getSegmentFromBag: "stub",
204
+ // Multi-statement / nested block — defaults to functional. This is a
205
+ // *known* false negative (see module docstring): the function looks
206
+ // functional but actually drops the segment cookie. We accept this
207
+ // trade-off rather than risk false positives on real implementations.
208
+ withSegmentCookie: "functional",
209
+ });
210
+ });
211
+
212
+ it("vtex-transform.ts (single identity-cast stub)", () => {
213
+ const code = `
214
+ import type { Product } from "@decocms/apps/commerce/types";
215
+ export function toProduct(vtexProduct: any): Product {
216
+ return vtexProduct as Product;
217
+ }
218
+ `;
219
+ expect(classMap(code)).toEqual({ toProduct: "stub" });
220
+ });
221
+
222
+ it("vtex-client.ts (interface only)", () => {
223
+ const code = `
224
+ export interface VTEXCommerceStable {
225
+ account: string;
226
+ environment?: string;
227
+ }
228
+ `;
229
+ expect(classMap(code)).toEqual({ VTEXCommerceStable: "type-only" });
230
+ });
231
+
232
+ it("vtex-fetch.ts (functional)", () => {
233
+ const code = `
234
+ export async function fetchSafe(
235
+ input: string | URL | Request,
236
+ init?: RequestInit,
237
+ ): Promise<Response> {
238
+ const response = await fetch(input, init);
239
+ if (!response.ok) {
240
+ console.error(\`VTEX fetch failed: \${response.status}\`);
241
+ }
242
+ return response;
243
+ }
244
+ `;
245
+ expect(classMap(code)).toEqual({ fetchSafe: "functional" });
246
+ });
247
+
248
+ it("vtex-id.ts (functional cookie parser)", () => {
249
+ const code = `
250
+ export function parseCookie(cookieStr?: string | null): Record<string, string> {
251
+ if (!cookieStr) return {};
252
+ return Object.fromEntries(
253
+ cookieStr.split(";").map((c) => {
254
+ const [key, ...rest] = c.trim().split("=");
255
+ return [key, rest.join("=")];
256
+ }),
257
+ );
258
+ }
259
+ `;
260
+ expect(classMap(code)).toEqual({ parseCookie: "functional" });
261
+ });
262
+
263
+ it("vtex-intelligent-search.ts (mixed: 1 stub + 4 functional)", () => {
264
+ const code = `
265
+ export function getISCookiesFromBag(_req?: any): Record<string, string> {
266
+ return {};
267
+ }
268
+
269
+ export function isFilterParam(key: string): boolean {
270
+ return key.startsWith("filter.");
271
+ }
272
+
273
+ export function toPath(facets: { key: string; value: string }[]): string {
274
+ return facets.map((f) => \`\${f.key}/\${f.value}\`).join("/");
275
+ }
276
+
277
+ export function withDefaultFacets(
278
+ facets: { key: string; value: string }[],
279
+ defaults?: any,
280
+ ): { key: string; value: string }[] {
281
+ if (Array.isArray(defaults)) {
282
+ return [...defaults, ...facets];
283
+ }
284
+ return [...facets];
285
+ }
286
+
287
+ export function withDefaultParams(
288
+ params: any,
289
+ defaults?: Record<string, string>,
290
+ ): any {
291
+ if (params instanceof URLSearchParams) {
292
+ if (defaults) {
293
+ for (const [key, value] of Object.entries(defaults)) {
294
+ if (!params.has(key)) {
295
+ params.set(key, value);
296
+ }
297
+ }
298
+ }
299
+ return params;
300
+ }
301
+ return { ...params, ...defaults };
302
+ }
303
+ `;
304
+ expect(classMap(code)).toEqual({
305
+ getISCookiesFromBag: "stub",
306
+ isFilterParam: "functional",
307
+ toPath: "functional",
308
+ withDefaultFacets: "functional",
309
+ withDefaultParams: "functional",
310
+ });
311
+ });
312
+ });
313
+
314
+ describe("classifyShimExports — defensive cases", () => {
315
+ it("empty file → no exports", () => {
316
+ expect(classifyShimExports("")).toEqual([]);
317
+ });
318
+
319
+ it("file with only imports → no exports", () => {
320
+ const code = `import { x } from "y";`;
321
+ expect(classifyShimExports(code)).toEqual([]);
322
+ });
323
+
324
+ it("non-export function ignored", () => {
325
+ const code = `function private_(): null { return null; }`;
326
+ expect(classifyShimExports(code)).toEqual([]);
327
+ });
328
+
329
+ it("export default skipped (intentional — only flag named exports)", () => {
330
+ const code = `export default function() { return null; }`;
331
+ expect(classifyShimExports(code)).toEqual([]);
332
+ });
333
+
334
+ it("strings containing braces don't break body extraction", () => {
335
+ const code = `
336
+ export function withBrace(): string {
337
+ return "{not a real brace}";
338
+ }
339
+ `;
340
+ expect(classMap(code)).toEqual({ withBrace: "functional" });
341
+ });
342
+
343
+ it("template literal with brace-like substitution", () => {
344
+ const code = `
345
+ export function withTemplate(): string {
346
+ return \`hello \${1 + 1}\`;
347
+ }
348
+ `;
349
+ // Single statement, but the value is non-empty/non-null → functional.
350
+ expect(classMap(code)).toEqual({ withTemplate: "functional" });
351
+ });
352
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Per-export classifier for `~/lib/vtex-*` shim files.
3
+ *
4
+ * The post-migration audit's `vtex-shim-regression` rule used to flag
5
+ * any import from these shims. That was overconfident — some shims have
6
+ * functional implementations of utility code (cookie parsing, fetch
7
+ * wrapping), while others are silent stubs (`return null`, `return {}`,
8
+ * identity casts) that drop runtime data and cause hard-to-trace bugs.
9
+ *
10
+ * This classifier inspects each export of a shim and labels it as:
11
+ *
12
+ * - **stub**: definitely a silent regression — body is `return null`,
13
+ * `return {}`, `return []`, an identity cast `return x as T`, or an
14
+ * unconditional `throw`. Importing this symbol means the shim is
15
+ * pretending to do work but isn't.
16
+ * - **type-only**: `interface` or `type` declaration — no runtime impact,
17
+ * never a regression.
18
+ * - **functional**: anything else (real implementations, even trivial
19
+ * ones like `return key.startsWith("filter.")`). Default-safe label —
20
+ * if we can't prove it's a stub, treat it as functional and don't flag.
21
+ *
22
+ * Trade-off — by design, we err toward "functional" when uncertain.
23
+ * That means some lossy implementations (e.g. a `withSegmentCookie`
24
+ * that returns `new Headers()` instead of attaching the cookie) classify
25
+ * as functional even though they're effectively stubs. Catching those
26
+ * would need real semantic analysis. False negatives are tolerable; the
27
+ * rule still warns when *any* imported symbol from the shim is a clear
28
+ * stub, which is enough to surface the real-world regressions we've
29
+ * actually seen on production sites (casaevideo: `getSegmentFromBag`,
30
+ * `getISCookiesFromBag`, `toProduct`).
31
+ *
32
+ * Implementation note — string parsing, not a real TypeScript AST. The
33
+ * shim files are tiny by design (the casaevideo ones are 1-39 lines).
34
+ * A balanced-brace body extractor + small set of stub patterns covers
35
+ * every case observed on real sites. If this ever needs to handle
36
+ * decorators, generics on consts, or weirder JSX forms, the right move
37
+ * is to swap in `typescript`'s `createSourceFile` — but we don't pay
38
+ * that dependency cost until it's clearly needed.
39
+ */
40
+
41
+ export type ExportClass = "stub" | "type-only" | "functional";
42
+
43
+ export interface ClassifiedExport {
44
+ name: string;
45
+ class: ExportClass;
46
+ /** Short, human-readable reason for the classification. */
47
+ reason?: string;
48
+ }
49
+
50
+ /**
51
+ * Strip line/block comments from a string before regex-matching the
52
+ * body. Keeps newlines so positions stay roughly aligned for debug.
53
+ */
54
+ function stripComments(s: string): string {
55
+ return s
56
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
57
+ .replace(/\/\/[^\n]*/g, "");
58
+ }
59
+
60
+ /**
61
+ * Find the matching `}` for the `{` at `openIdx`. Returns the index of
62
+ * the closing brace, or -1 if unbalanced. Tolerates strings and template
63
+ * literals that may contain stray braces — we honor the basic quoting
64
+ * rules, not full TS lexer fidelity.
65
+ */
66
+ function findMatchingBrace(content: string, openIdx: number): number {
67
+ let depth = 0;
68
+ let i = openIdx;
69
+ let inStr: string | null = null;
70
+ let esc = false;
71
+ while (i < content.length) {
72
+ const c = content[i];
73
+ if (inStr) {
74
+ if (esc) {
75
+ esc = false;
76
+ } else if (c === "\\") {
77
+ esc = true;
78
+ } else if (c === inStr) {
79
+ inStr = null;
80
+ }
81
+ } else if (c === '"' || c === "'" || c === "`") {
82
+ inStr = c;
83
+ } else if (c === "{") {
84
+ depth++;
85
+ } else if (c === "}") {
86
+ depth--;
87
+ if (depth === 0) return i;
88
+ }
89
+ i++;
90
+ }
91
+ return -1;
92
+ }
93
+
94
+ /**
95
+ * Match `return null` / `return {}` / `return []` / `return X as Y` /
96
+ * `throw new Error(...)` as the only meaningful statement in the body.
97
+ * Comments and whitespace are tolerated.
98
+ */
99
+ function classifyBodyText(body: string): { class: ExportClass; reason?: string } | null {
100
+ const cleaned = stripComments(body).trim();
101
+ // Allow a trailing semicolon, but not multiple statements.
102
+ const single = cleaned.replace(/;\s*$/, "").trim();
103
+ if (single === "") return null;
104
+ // Reject anything that looks like multiple statements.
105
+ if (/[;\n]/.test(single.replace(/\s+/g, " ").trim()) && !/^return\s/.test(single)) {
106
+ return null;
107
+ }
108
+ if (/^return\s+null$/.test(single)) {
109
+ return { class: "stub", reason: "returns null" };
110
+ }
111
+ if (/^return\s+\{\s*\}$/.test(single)) {
112
+ return { class: "stub", reason: "returns empty object" };
113
+ }
114
+ if (/^return\s+\[\s*\]$/.test(single)) {
115
+ return { class: "stub", reason: "returns empty array" };
116
+ }
117
+ if (/^return\s+""$/.test(single) || /^return\s+''$/.test(single)) {
118
+ return { class: "stub", reason: "returns empty string" };
119
+ }
120
+ // Identity cast: `return ident as Type`. The right-hand side must be a
121
+ // bare identifier (or nested member like `obj.prop`) — anything else is
122
+ // probably real work.
123
+ const identityMatch = single.match(
124
+ /^return\s+([A-Za-z_][A-Za-z0-9_.]*)\s+as\s+[A-Za-z_][A-Za-z0-9_<>,\s.|&]*$/,
125
+ );
126
+ if (identityMatch) {
127
+ return { class: "stub", reason: `identity cast (return ${identityMatch[1]} as …)` };
128
+ }
129
+ if (/^throw\s+new\s+\w+\s*\(/.test(single)) {
130
+ return { class: "stub", reason: "unconditional throw" };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Locate `export function NAME(args): RT { body }` and `export async`
137
+ * variants. Returns each export with its body classified.
138
+ */
139
+ function classifyFunctionDecls(content: string): ClassifiedExport[] {
140
+ const out: ClassifiedExport[] = [];
141
+ // Anchored at start-of-line + optional indent — avoids catching
142
+ // `export default function` (which has no name immediately after).
143
+ const re = /(?:^|\n)[ \t]*export\s+(?:async\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)\b/g;
144
+ for (const m of content.matchAll(re)) {
145
+ const name = m[1];
146
+ const startSearchAt = (m.index ?? 0) + m[0].length;
147
+ const openIdx = content.indexOf("{", startSearchAt);
148
+ if (openIdx === -1) {
149
+ out.push({ name, class: "functional", reason: "no body found (declaration only)" });
150
+ continue;
151
+ }
152
+ const closeIdx = findMatchingBrace(content, openIdx);
153
+ if (closeIdx === -1) {
154
+ out.push({ name, class: "functional", reason: "unbalanced braces" });
155
+ continue;
156
+ }
157
+ const body = content.slice(openIdx + 1, closeIdx);
158
+ const verdict = classifyBodyText(body);
159
+ out.push(
160
+ verdict
161
+ ? { name, class: verdict.class, reason: verdict.reason }
162
+ : { name, class: "functional" },
163
+ );
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Locate `export const NAME = (args) => …` and classify the arrow body.
170
+ * Block-bodied arrows reuse the function-body classifier; expression
171
+ * bodies are matched directly.
172
+ */
173
+ function classifyConstArrowDecls(content: string): ClassifiedExport[] {
174
+ const out: ClassifiedExport[] = [];
175
+ const re = /(?:^|\n)[ \t]*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/g;
176
+ for (const m of content.matchAll(re)) {
177
+ const name = m[1];
178
+ const startIdx = (m.index ?? 0) + m[0].length;
179
+ const after = content.slice(startIdx);
180
+ // Look for: optional `async`, `(args)`, optional `: ReturnType`, `=>`.
181
+ const arrowHead = after.match(/^\s*(?:async\s+)?\([^)]*\)\s*(?::[^=]+)?=>\s*/);
182
+ if (!arrowHead) {
183
+ out.push({ name, class: "functional" });
184
+ continue;
185
+ }
186
+ const bodyStart = startIdx + arrowHead[0].length;
187
+ // Block body: classify the inside via the shared body classifier.
188
+ if (content[bodyStart] === "{") {
189
+ const closeIdx = findMatchingBrace(content, bodyStart);
190
+ if (closeIdx === -1) {
191
+ out.push({ name, class: "functional", reason: "unbalanced braces" });
192
+ continue;
193
+ }
194
+ const body = content.slice(bodyStart + 1, closeIdx);
195
+ const verdict = classifyBodyText(body);
196
+ out.push(
197
+ verdict
198
+ ? { name, class: verdict.class, reason: verdict.reason }
199
+ : { name, class: "functional" },
200
+ );
201
+ continue;
202
+ }
203
+ // Expression body: read until line break / semicolon.
204
+ const exprMatch = content.slice(bodyStart).match(/^([^;\n]+?)\s*;?\s*(?:\n|$)/);
205
+ if (!exprMatch) {
206
+ out.push({ name, class: "functional" });
207
+ continue;
208
+ }
209
+ const expr = exprMatch[1].trim();
210
+ if (expr === "null") {
211
+ out.push({ name, class: "stub", reason: "arrow returns null" });
212
+ } else if (/^\(\s*\{\s*\}\s*\)$/.test(expr) || expr === "{}") {
213
+ out.push({ name, class: "stub", reason: "arrow returns empty object" });
214
+ } else if (/^\[\s*\]$/.test(expr)) {
215
+ out.push({ name, class: "stub", reason: "arrow returns empty array" });
216
+ } else {
217
+ out.push({ name, class: "functional" });
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Locate `export interface NAME` / `export type NAME` declarations.
225
+ */
226
+ function classifyTypeDecls(content: string): ClassifiedExport[] {
227
+ const out: ClassifiedExport[] = [];
228
+ const re = /(?:^|\n)[ \t]*export\s+(?:interface|type)\s+([A-Za-z_][A-Za-z0-9_]*)\b/g;
229
+ for (const m of content.matchAll(re)) {
230
+ out.push({ name: m[1], class: "type-only" });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * Classify every top-level export in a shim file. Returns one entry per
237
+ * export, in source order is not guaranteed (we run three passes over
238
+ * the content); callers should look up by `name`.
239
+ */
240
+ export function classifyShimExports(content: string): ClassifiedExport[] {
241
+ return [
242
+ ...classifyFunctionDecls(content),
243
+ ...classifyConstArrowDecls(content),
244
+ ...classifyTypeDecls(content),
245
+ ];
246
+ }