@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,310 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { resolveSectionConventions } from "./config";
4
+ import type { MigrationContext, TransformResult, SectionMeta } from "./types";
5
+ import { log, logPhase } from "./types";
6
+ import { transformImports } from "./transforms/imports";
7
+ import { transformJsx } from "./transforms/jsx";
8
+ import { transformFreshApis } from "./transforms/fresh-apis";
9
+ import { transformDenoIsms } from "./transforms/deno-isms";
10
+ import { transformTailwind } from "./transforms/tailwind";
11
+ import { transformDeadCode } from "./transforms/dead-code";
12
+ import { transformHtmxOnEvents } from "./transforms/htmx-on-events";
13
+ import { createSectionConventionsTransform } from "./transforms/section-conventions";
14
+
15
+ /** Map of section path → metadata, populated per-run */
16
+ let sectionMetaMap: Map<string, SectionMeta> | null = null;
17
+
18
+ function getSectionMeta(ctx: MigrationContext, relPath: string): SectionMeta | undefined {
19
+ if (!sectionMetaMap) {
20
+ sectionMetaMap = new Map();
21
+ for (const m of ctx.sectionMetas) {
22
+ sectionMetaMap.set(m.path, m);
23
+ }
24
+ }
25
+ return sectionMetaMap.get(relPath);
26
+ }
27
+
28
+ /**
29
+ * Cached per-run section-conventions closure. Built once from the
30
+ * resolved config sets (`ctx.config.sectionConventions`), so casaevideo
31
+ * defaults still apply when no config file exists.
32
+ */
33
+ let cachedSectionTransform:
34
+ | ReturnType<typeof createSectionConventionsTransform>
35
+ | null = null;
36
+
37
+ function getSectionConventionsTransform(ctx: MigrationContext) {
38
+ if (cachedSectionTransform) return cachedSectionTransform;
39
+ const sets = resolveSectionConventions(ctx.config ?? null);
40
+ cachedSectionTransform = createSectionConventionsTransform(sets);
41
+ return cachedSectionTransform;
42
+ }
43
+
44
+ /**
45
+ * Apply all transforms to a file's content in the correct order.
46
+ */
47
+ function applyTransforms(content: string, filePath: string, ctx?: MigrationContext, relPath?: string): TransformResult {
48
+ const allNotes: string[] = [];
49
+ let currentContent = content;
50
+ let anyChanged = false;
51
+
52
+ // Only transform code files
53
+ const ext = path.extname(filePath);
54
+ if (![".ts", ".tsx"].includes(ext)) {
55
+ return { content, changed: false, notes: [] };
56
+ }
57
+
58
+ // Pipeline: imports → jsx → htmx-on-events → fresh-apis → dead-code → deno-isms → tailwind
59
+ // htmx-on-events runs after jsx (which renames class/onChange) and
60
+ // before fresh-apis (which removes useScript imports the htmx
61
+ // codemod's TODO might still reference). The codemod is a no-op on
62
+ // files without hx-on, so it never adds latency to non-htmx sites.
63
+ const pipeline: Array<{ name: string; fn: (content: string) => TransformResult }> = [
64
+ { name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
65
+ { name: "jsx", fn: transformJsx },
66
+ { name: "htmx-on-events", fn: transformHtmxOnEvents },
67
+ { name: "fresh-apis", fn: transformFreshApis },
68
+ { name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
69
+ { name: "deno-isms", fn: transformDenoIsms },
70
+ { name: "tailwind", fn: transformTailwind },
71
+ ];
72
+
73
+ for (const step of pipeline) {
74
+ const result = step.fn(currentContent);
75
+ if (result.changed) {
76
+ anyChanged = true;
77
+ currentContent = result.content;
78
+ allNotes.push(...result.notes.map((n) => `[${step.name}] ${n}`));
79
+ }
80
+ }
81
+
82
+ // Section conventions (sync/eager/layout/cache) — only for section files
83
+ if (ctx && relPath && relPath.startsWith("sections/")) {
84
+ const meta = getSectionMeta(ctx, relPath);
85
+ // Build the closure once per ctx, cache it on the context.
86
+ const sectionTransform = getSectionConventionsTransform(ctx);
87
+ const result = sectionTransform(currentContent, meta);
88
+ if (result.changed) {
89
+ anyChanged = true;
90
+ currentContent = result.content;
91
+ allNotes.push(...result.notes.map((n) => `[section-conventions] ${n}`));
92
+ }
93
+ }
94
+
95
+ return { content: currentContent, changed: anyChanged, notes: allNotes };
96
+ }
97
+
98
+ export function transform(ctx: MigrationContext): void {
99
+ logPhase("Transform");
100
+
101
+ const toTransform = ctx.files.filter((f) => f.action === "transform");
102
+ console.log(` Files to transform: ${toTransform.length}`);
103
+
104
+ for (const record of toTransform) {
105
+ const { absPath, targetPath } = record;
106
+ if (!targetPath) continue;
107
+
108
+ // Read source
109
+ const content = fs.readFileSync(absPath, "utf-8");
110
+
111
+ // Apply transforms
112
+ const result = applyTransforms(content, absPath, ctx, record.path);
113
+
114
+ // Fix section re-exports from wrapper islands — point to the wrapped component
115
+ const resolvedTarget = (record as any).__resolvedReExportTarget;
116
+ if (resolvedTarget && result.content.includes("~/components/")) {
117
+ // The import transform rewrote $store/islands/X → ~/components/X
118
+ // but for wrapper islands, the actual component is at a different path
119
+ const reExportRe = /from\s+"~\/components\/[^"]+"/g;
120
+ result.content = result.content.replace(reExportRe, `from "${resolvedTarget}"`);
121
+ result.notes.push(`Re-export resolved to wrapper target: ${resolvedTarget}`);
122
+ result.changed = true;
123
+ }
124
+
125
+ // Add manual review items
126
+ for (const note of result.notes) {
127
+ if (note.startsWith("[") && note.includes("MANUAL:")) {
128
+ ctx.manualReviewItems.push({
129
+ file: targetPath,
130
+ reason: note,
131
+ severity: "warning",
132
+ });
133
+ }
134
+ }
135
+
136
+ // Flag files with HTMX patterns for manual React migration
137
+ if (/\bhx-(?:get|post|put|delete|trigger|target|swap|on|indicator|sync|select)\b/.test(result.content)) {
138
+ ctx.manualReviewItems.push({
139
+ file: targetPath,
140
+ reason: "HTMX attributes (hx-*) found — needs manual migration to React state/effects. HTMX server-side rendering (hx-get/hx-post with useSection) must be converted to React components with useState/useEffect or server functions.",
141
+ severity: "warning",
142
+ });
143
+ }
144
+
145
+ // Flag files with hx-on:click that use useScript (simpler pattern)
146
+ if (/hx-on:click=\{useScript/.test(result.content)) {
147
+ ctx.manualReviewItems.push({
148
+ file: targetPath,
149
+ reason: "hx-on:click with useScript found — convert to onClick with React event handler. The useScript serialization won't work as onClick value.",
150
+ severity: "warning",
151
+ });
152
+ }
153
+
154
+ // Flag the legacy sections/Component.tsx dynamic-section loader.
155
+ // This file uses Deno-specific APIs (toFileUrl, import.meta.resolve)
156
+ // and the HTMX-driven `useComponent(component, props)` pattern, which
157
+ // do not run on Cloudflare Workers and have no equivalent in
158
+ // @decocms/start. The whole file must be deleted.
159
+ if (
160
+ /sections\/Component\.tsx?$/.test(record.path) ||
161
+ /sections\/Component\.tsx?$/.test(targetPath)
162
+ ) {
163
+ ctx.manualReviewItems.push({
164
+ file: targetPath,
165
+ reason:
166
+ "sections/Component.tsx (Deno HTMX dynamic-section loader) is incompatible with TanStack Start / Cloudflare Workers. " +
167
+ "DELETE this file and migrate every `useComponent(...)` call site to one of: " +
168
+ "(a) local React state for client-side toggles, " +
169
+ "(b) `createServerFn` + `useMutation` for server actions, or " +
170
+ "(c) a direct `invoke` call (`~/server/invoke`) for ad-hoc loaders. " +
171
+ "See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section.",
172
+ severity: "error",
173
+ });
174
+ }
175
+
176
+ // Flag any import of useComponent — typically `import { useComponent } from "site/sections/Component.tsx"`.
177
+ // We also catch `from "../../sections/Component"` and similar relative variants.
178
+ if (
179
+ /\buseComponent\b/.test(result.content) &&
180
+ /from\s+["'][^"']*sections\/Component(?:\.tsx?)?["']/.test(result.content)
181
+ ) {
182
+ ctx.manualReviewItems.push({
183
+ file: targetPath,
184
+ reason:
185
+ "useComponent({ ... }) call site detected. This is the HTMX-style dynamic-section render pattern " +
186
+ "that ships HTML fragments and swaps them client-side. It does not work on TanStack Start. " +
187
+ "Recipes: " +
188
+ "(1) Self-contained UI toggles → keep state in React (`useState` + event handlers); " +
189
+ "(2) Form submissions / mutations → `createServerFn` + `useMutation` (see casaevideo-storefront for canonical examples); " +
190
+ "(3) Ad-hoc data fetches → call the loader/action via `~/server/invoke` and store results in `useState`. " +
191
+ "Remove the import after refactoring, then delete `src/sections/Component.tsx`.",
192
+ severity: "error",
193
+ });
194
+ }
195
+
196
+ if (ctx.dryRun) {
197
+ if (result.changed) {
198
+ log(ctx, `[DRY] Would transform: ${record.path} → ${targetPath}`);
199
+ for (const note of result.notes) {
200
+ log(ctx, ` ${note}`);
201
+ }
202
+ }
203
+ ctx.transformedFiles.push(targetPath);
204
+ continue;
205
+ }
206
+
207
+ // Write to target path
208
+ const fullTargetPath = path.join(ctx.sourceDir, targetPath);
209
+ const dir = path.dirname(fullTargetPath);
210
+ fs.mkdirSync(dir, { recursive: true });
211
+ fs.writeFileSync(fullTargetPath, result.content, "utf-8");
212
+
213
+ ctx.transformedFiles.push(targetPath);
214
+ if (result.changed) {
215
+ log(
216
+ ctx,
217
+ `Transformed: ${record.path} → ${targetPath} (${result.notes.length} changes)`,
218
+ );
219
+ } else {
220
+ log(ctx, `Copied: ${record.path} → ${targetPath}`);
221
+ }
222
+ }
223
+
224
+ console.log(` Transformed ${ctx.transformedFiles.length} files`);
225
+
226
+ // Post-transform: resolve ~/islands/ imports to actual file locations.
227
+ // Islands are moved to src/sections/ during migration, but components
228
+ // import them via ~/islands/X which no longer exists. Scan src/ for
229
+ // the actual file and rewrite the import.
230
+ if (!ctx.dryRun) {
231
+ fixIslandImports(ctx);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Scan all transformed files for ~/islands/ imports and rewrite them
237
+ * to the actual path where the file was placed (sections/, components/, etc.).
238
+ */
239
+ function fixIslandImports(ctx: MigrationContext): void {
240
+ const srcDir = path.join(ctx.sourceDir, "src");
241
+ if (!fs.existsSync(srcDir)) return;
242
+
243
+ // Build a lookup: filename → relative path from src/
244
+ const fileLookup = new Map<string, string[]>();
245
+ function scanDir(dir: string) {
246
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
247
+ if (entry.isDirectory()) {
248
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
249
+ scanDir(path.join(dir, entry.name));
250
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
251
+ const relPath = path.relative(srcDir, path.join(dir, entry.name)).replace(/\\/g, "/");
252
+ const base = entry.name.replace(/\.tsx?$/, "");
253
+ if (!fileLookup.has(base)) fileLookup.set(base, []);
254
+ fileLookup.get(base)!.push(relPath);
255
+ }
256
+ }
257
+ }
258
+ scanDir(srcDir);
259
+
260
+ // Scan all .ts/.tsx files in src/ for ~/islands/ imports
261
+ const islandImportRe = /from\s+["'](~\/islands\/([^"']+))["']/g;
262
+ let fixCount = 0;
263
+
264
+ function walkAndFix(dir: string) {
265
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
266
+ if (entry.isDirectory()) {
267
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
268
+ walkAndFix(path.join(dir, entry.name));
269
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
270
+ const filePath = path.join(dir, entry.name);
271
+ let content = fs.readFileSync(filePath, "utf-8");
272
+ let modified = false;
273
+
274
+ content = content.replace(islandImportRe, (match, fullImport, islandPath) => {
275
+ // islandPath = "Cart/Indicator" or "SliderJS" or "Searchbar"
276
+ const basename = islandPath.replace(/\.tsx?$/, "").split("/").pop()!;
277
+
278
+ // Try to find the file — prefer components/ over sections/
279
+ const candidates = fileLookup.get(basename) || [];
280
+ // Exclude islands/ paths themselves and routes/
281
+ const valid = candidates.filter(
282
+ (c) => !c.startsWith("islands/") && !c.startsWith("routes/"),
283
+ );
284
+
285
+ if (valid.length === 0) return match; // can't resolve, leave as-is
286
+
287
+ // Prefer components/ over sections/
288
+ const preferred =
289
+ valid.find((c) => c.startsWith("components/")) ??
290
+ valid.find((c) => c.startsWith("sections/")) ??
291
+ valid[0];
292
+
293
+ const newPath = "~/" + preferred.replace(/\.tsx?$/, "");
294
+ modified = true;
295
+ return match.replace(fullImport, newPath);
296
+ });
297
+
298
+ if (modified) {
299
+ fs.writeFileSync(filePath, content, "utf-8");
300
+ fixCount++;
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ walkAndFix(srcDir);
307
+ if (fixCount > 0) {
308
+ console.log(` Fixed ~/islands/ imports in ${fixCount} files`);
309
+ }
310
+ }
@@ -0,0 +1,127 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { checks } from "./phase-verify";
6
+ import type { MigrationContext } from "./types";
7
+
8
+ /**
9
+ * The "No relative imports to deleted SDK files" check used to flag any
10
+ * relative import to `sdk/{clx,useId,useOffer,useVariantPossiblities,
11
+ * usePlatform}`. But `useOffer` and `useVariantPossiblities` are KEPT as
12
+ * site files (see RELATIVE_SDK_REWRITES in transforms/imports.ts) — so
13
+ * every site that legitimately imports them via a relative path was
14
+ * failing verify with a misleading error. See #212.
15
+ *
16
+ * Pull the specific check out of the registered array and exercise it on
17
+ * a fixture tree, so we don't have to satisfy the other 24 checks that
18
+ * `verify()` runs at once.
19
+ */
20
+
21
+ function makeCtx(sourceDir: string): MigrationContext {
22
+ return {
23
+ sourceDir,
24
+ siteName: "test-site",
25
+ platform: "custom",
26
+ vtexAccount: null,
27
+ gtmId: null,
28
+ importMap: {},
29
+ discoveredNpmDeps: {},
30
+ themeColors: {},
31
+ fontFamily: null,
32
+ files: [],
33
+ sectionMetas: [],
34
+ islandClassifications: [],
35
+ islandWrapperTargets: new Map(),
36
+ loaderInventory: [],
37
+ scaffoldedFiles: [],
38
+ transformedFiles: [],
39
+ deletedFiles: [],
40
+ movedFiles: [],
41
+ manualReviewItems: [],
42
+ frameworkFindings: [],
43
+ dryRun: false,
44
+ verbose: false,
45
+ };
46
+ }
47
+
48
+ const sdkCheck = checks.find(
49
+ (c) => c.name === "No relative imports to deleted SDK files",
50
+ );
51
+ if (!sdkCheck) {
52
+ throw new Error("verify check not found — name changed?");
53
+ }
54
+
55
+ function runCheck(ctx: MigrationContext): { ok: boolean; output: string } {
56
+ const lines: string[] = [];
57
+ const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
58
+ lines.push(args.map((a) => String(a)).join(" "));
59
+ });
60
+ try {
61
+ const ok = sdkCheck!.fn(ctx);
62
+ return { ok, output: lines.join("\n") };
63
+ } finally {
64
+ spy.mockRestore();
65
+ }
66
+ }
67
+
68
+ describe("verify check: 'No relative imports to deleted SDK files'", () => {
69
+ let tmp: string;
70
+
71
+ beforeEach(() => {
72
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-sdk-"));
73
+ fs.mkdirSync(path.join(tmp, "src", "components"), { recursive: true });
74
+ });
75
+
76
+ afterEach(() => {
77
+ fs.rmSync(tmp, { recursive: true, force: true });
78
+ });
79
+
80
+ it("passes when src/ imports useOffer/useVariantPossiblities relatively (kept files, #212)", () => {
81
+ fs.writeFileSync(
82
+ path.join(tmp, "src", "components", "ProductCard.tsx"),
83
+ `import { useOffer } from "../../sdk/useOffer";\n` +
84
+ `import { useVariantPossibilities } from "../../sdk/useVariantPossiblities";\n` +
85
+ `export const x = 1;\n`,
86
+ );
87
+ const { ok, output } = runCheck(makeCtx(tmp));
88
+ expect(ok).toBe(true);
89
+ expect(output).toBe("");
90
+ });
91
+
92
+ it("fails when src/ imports clx/useId/usePlatform relatively (truly deleted)", () => {
93
+ fs.writeFileSync(
94
+ path.join(tmp, "src", "components", "Bad.tsx"),
95
+ `import { clx } from "../../sdk/clx";\n` +
96
+ `import { useId } from "../../sdk/useId";\n` +
97
+ `import { usePlatform } from "../../sdk/usePlatform";\n` +
98
+ `export const x = 1;\n`,
99
+ );
100
+ const { ok, output } = runCheck(makeCtx(tmp));
101
+ expect(ok).toBe(false);
102
+ // The improved error reports each offending line, not just the file.
103
+ expect(output).toMatch(/components\/Bad\.tsx:.*sdk\/clx/);
104
+ expect(output).toMatch(/components\/Bad\.tsx:.*sdk\/useId/);
105
+ expect(output).toMatch(/components\/Bad\.tsx:.*sdk\/usePlatform/);
106
+ });
107
+
108
+ it("ignores commented-out references to deleted SDK files", () => {
109
+ fs.writeFileSync(
110
+ path.join(tmp, "src", "components", "Docs.tsx"),
111
+ `// import { clx } from "../../sdk/clx"\n` +
112
+ `// from "../../sdk/useId"\n` +
113
+ `export const x = 1;\n`,
114
+ );
115
+ const { ok } = runCheck(makeCtx(tmp));
116
+ expect(ok).toBe(true);
117
+ });
118
+
119
+ it("matches both .ts-suffixed and unsuffixed import paths", () => {
120
+ fs.writeFileSync(
121
+ path.join(tmp, "src", "components", "WithExt.tsx"),
122
+ `import { clx } from "../../sdk/clx.ts";\nexport const x = 1;\n`,
123
+ );
124
+ const { ok } = runCheck(makeCtx(tmp));
125
+ expect(ok).toBe(false);
126
+ });
127
+ });