@decocms/blocks-cli 7.0.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 (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Post-migration cleanup audit — orchestrator.
3
+ *
4
+ * `runAudit` is the testable, FS-injected entry. The CLI in
5
+ * `../../migrate-post-cleanup.ts` wires up the real disk adapter.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { ALL_RULES } from "./rules";
11
+ import type { AuditReport, FsAdapter, FsWriter, Rule, RuleSummary } from "./types";
12
+
13
+ export interface RunAuditOptions {
14
+ /**
15
+ * When set, rules with an `applyFix` implementation will run their
16
+ * fix and the report will include the resulting `FixAction[]`.
17
+ * Without this, the runner is purely read-only.
18
+ */
19
+ writer?: FsWriter;
20
+ /** Restrict to a subset of rules (defaults to all). */
21
+ rules?: Rule[];
22
+ }
23
+
24
+ export function runAudit(
25
+ siteDir: string,
26
+ adapter: FsAdapter,
27
+ options: RunAuditOptions = {},
28
+ ): AuditReport {
29
+ const rules = options.rules ?? ALL_RULES;
30
+ const summaries: RuleSummary[] = rules.map((rule) => {
31
+ const findings = rule.run({ siteDir, fs: adapter });
32
+ const supportsAutoFix = typeof rule.applyFix === "function";
33
+ let fixes: RuleSummary["fixes"];
34
+ if (options.writer && rule.applyFix && findings.length > 0) {
35
+ fixes = rule.applyFix({ siteDir, fs: adapter }, findings, options.writer);
36
+ }
37
+ return {
38
+ rule: rule.id,
39
+ title: rule.title,
40
+ findings,
41
+ supportsAutoFix,
42
+ fixes,
43
+ };
44
+ });
45
+ return {
46
+ site: siteDir,
47
+ rules: summaries,
48
+ totalFindings: summaries.reduce((acc, s) => acc + s.findings.length, 0),
49
+ totalFixActions: summaries.reduce((acc, s) => acc + (s.fixes?.length ?? 0), 0),
50
+ };
51
+ }
52
+
53
+ /* ------------------------------------------------------------------ */
54
+ /* Real disk adapter */
55
+ /* ------------------------------------------------------------------ */
56
+
57
+ /**
58
+ * Minimal recursive-walk glob — intentionally tiny, no external deps.
59
+ * Supports `**`, `*`, and `{a,b,c}` brace expansion. Does NOT support
60
+ * extglob, negation, or `?`. That's fine: every pattern in this audit
61
+ * fits the supported subset.
62
+ */
63
+ function expandBraces(pattern: string): string[] {
64
+ const m = pattern.match(/\{([^{}]+)\}/);
65
+ if (!m) return [pattern];
66
+ const [whole, inner] = m;
67
+ const start = pattern.indexOf(whole);
68
+ const before = pattern.slice(0, start);
69
+ const after = pattern.slice(start + whole.length);
70
+ const branches = inner.split(",");
71
+ return branches.flatMap((b) => expandBraces(`${before}${b}${after}`));
72
+ }
73
+
74
+ function patternToRegex(pattern: string): RegExp {
75
+ const re = pattern
76
+ .replace(/[.+^$()|]/g, "\\$&")
77
+ .replace(/\*\*\//g, "<<DBL>>")
78
+ .replace(/\*\*/g, "<<DBL>>")
79
+ .replace(/\*/g, "[^/]*")
80
+ .replace(/<<DBL>>/g, "(?:.*/)?");
81
+ return new RegExp(`^${re}$`);
82
+ }
83
+
84
+ function walk(dir: string, rootDir: string, excludeDirs: Set<string>, out: string[]): void {
85
+ let entries: fs.Dirent[];
86
+ try {
87
+ entries = fs.readdirSync(dir, { withFileTypes: true });
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const entry of entries) {
92
+ if (entry.name.startsWith(".")) continue;
93
+ const abs = path.join(dir, entry.name);
94
+ if (entry.isDirectory()) {
95
+ if (excludeDirs.has(entry.name)) continue;
96
+ walk(abs, rootDir, excludeDirs, out);
97
+ } else if (entry.isFile()) {
98
+ out.push(abs);
99
+ }
100
+ }
101
+ }
102
+
103
+ export const realFsAdapter: FsAdapter = {
104
+ exists(absPath: string) {
105
+ return fs.existsSync(absPath);
106
+ },
107
+ readText(absPath: string) {
108
+ return fs.readFileSync(absPath, "utf-8");
109
+ },
110
+ glob(siteDir: string, pattern: string, excludeDirs: string[] = []) {
111
+ const allFiles: string[] = [];
112
+ walk(siteDir, siteDir, new Set(excludeDirs), allFiles);
113
+ const patterns = expandBraces(pattern).map(patternToRegex);
114
+ const matches = allFiles.filter((abs) => {
115
+ const rel = abs.slice(siteDir.length + 1).replace(/\\/g, "/");
116
+ return patterns.some((re) => re.test(rel));
117
+ });
118
+ return matches.sort();
119
+ },
120
+ };
121
+
122
+ /**
123
+ * Real disk writer used by the CLI when `--fix` is on. Tests should
124
+ * use a recording adapter and never instantiate this.
125
+ *
126
+ * `deleteFile` is intentionally tolerant: if the file is already gone
127
+ * (race with a previous fix or manual cleanup), we treat that as a
128
+ * no-op rather than crashing the audit.
129
+ */
130
+ export const realFsWriter: FsWriter = {
131
+ deleteFile(absPath: string) {
132
+ if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
133
+ },
134
+ writeText(absPath: string, content: string) {
135
+ fs.writeFileSync(absPath, content, "utf-8");
136
+ },
137
+ };
@@ -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
+ });