@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.
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +132 -6
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/scripts/migrate/post-cleanup/rules.ts +196 -7
- package/scripts/migrate/post-cleanup/runner.test.ts +225 -2
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +0 -655
- package/.cursor/skills/deco-to-tanstack-migration/references/codemod-commands.md +0 -174
- package/.cursor/skills/deco-to-tanstack-migration/references/commerce/README.md +0 -78
- package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +0 -174
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +0 -834
- package/.cursor/skills/deco-to-tanstack-migration/references/imports/README.md +0 -70
- package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +0 -121
- package/.cursor/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +0 -231
- package/.cursor/skills/deco-to-tanstack-migration/references/signals/README.md +0 -220
- package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +0 -103
- package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +0 -75
- package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +0 -127
- package/.cursor/skills/deco-to-tanstack-migration/templates/router.md +0 -96
- package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +0 -148
- package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +0 -197
- package/.cursor/skills/deco-to-tanstack-migration/templates/worker-entry.md +0 -67
- /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
|
+
}
|