@decocms/start 2.12.0 → 2.14.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 (27) hide show
  1. package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +32 -6
  2. package/CLAUDE.md +1 -1
  3. package/package.json +1 -1
  4. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  5. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  6. package/scripts/migrate/post-cleanup/rules.ts +77 -6
  7. package/scripts/migrate/post-cleanup/runner.test.ts +123 -2
  8. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  9. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  10. package/scripts/migrate.ts +36 -8
  11. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +0 -655
  12. package/.cursor/skills/deco-to-tanstack-migration/references/codemod-commands.md +0 -174
  13. package/.cursor/skills/deco-to-tanstack-migration/references/commerce/README.md +0 -78
  14. package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +0 -174
  15. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +0 -834
  16. package/.cursor/skills/deco-to-tanstack-migration/references/imports/README.md +0 -70
  17. package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +0 -121
  18. package/.cursor/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +0 -231
  19. package/.cursor/skills/deco-to-tanstack-migration/references/signals/README.md +0 -220
  20. package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +0 -103
  21. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +0 -75
  22. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +0 -127
  23. package/.cursor/skills/deco-to-tanstack-migration/templates/router.md +0 -96
  24. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +0 -148
  25. package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +0 -197
  26. package/.cursor/skills/deco-to-tanstack-migration/templates/worker-entry.md +0 -67
  27. /package/{.cursor → .agents}/skills/deco-to-tanstack-migration/references/server-functions/README.md +0 -0
@@ -159,18 +159,44 @@ opt-in and tested.
159
159
  Older versions of the migration script's `phase-cleanup` had a bug where
160
160
  it actively rewrote valid `@decocms/apps/vtex/utils/*` and
161
161
  `@decocms/apps/vtex/client` imports back to the dead `~/lib/vtex-*` shims.
162
- Confirm your loaders import direct from `@decocms/apps`:
162
+
163
+ The post-cleanup audit now classifies **per-symbol**: it reads each
164
+ `~/lib/vtex-*` shim file, labels every named export as `stub`,
165
+ `type-only`, or `functional`, and only flags an import when at least
166
+ one imported symbol is a real silent stub (returns `null` / `{}` / `[]`
167
+ / identity-cast / unconditional throw). Functional helpers shipped
168
+ alongside stubs (e.g. a `parseCookie` cookie parser, a `fetchSafe`
169
+ wrapper) no longer create noise.
170
+
171
+ The audit's finding names the exact stub symbols, e.g.
172
+
173
+ ```
174
+ [WARNING] src/loaders/search/x.ts — Imports stub-only symbols from
175
+ vtex-transform (toProduct); vtex-segment (getSegmentFromBag) —
176
+ runtime is silently stubbed
177
+ fix: Repoint imports to '@decocms/apps/vtex/...' or
178
+ 'apps/commerce/utils/...'
179
+ ```
180
+
181
+ Manual sweep (still useful if you don't have the audit handy):
163
182
 
164
183
  ```bash
165
184
  rg "from ['\"]~/lib/vtex-" src/
166
185
  # Expected: 0 hits (or only site-specific reasons you can articulate)
167
186
  ```
168
187
 
169
- If you see hits, update the imports to point at `@decocms/apps/vtex/...`
170
- directly (or the corresponding `commerce/utils/*` if it's a generic
171
- utility). Your runtime behavior gets MUCH better — segment cookies, IS
172
- cookies, VTEX session auth all start working again instead of being
173
- silently stubbed to `{}` / `null`.
188
+ When you see real findings, update the imports to point at
189
+ `@decocms/apps/vtex/...` directly (or the corresponding
190
+ `commerce/utils/*` if it's a generic utility). Your runtime behavior
191
+ gets MUCH better — segment cookies, IS cookies, VTEX session auth all
192
+ start working again instead of being silently stubbed to `{}` / `null`.
193
+
194
+ **Note on `--fix`**: this rule is intentionally detect-only. Repointing
195
+ imports requires a per-symbol map to canonical apps/start exports
196
+ (e.g. `getSegmentFromBag` → `@decocms/apps/vtex/utils/segment`), which
197
+ the framework doesn't ship yet. Detect-only is still strictly more
198
+ useful than nothing — the precision means each finding maps to exactly
199
+ one PR's worth of mechanical work.
174
200
 
175
201
  ## 6. Drop `src/types/widgets.ts` — framework owns it
176
202
 
package/CLAUDE.md CHANGED
@@ -117,7 +117,7 @@ Schema is composed at runtime: `generate-schema.ts` produces section schemas, `c
117
117
 
118
118
  ## Migration Guide
119
119
 
120
- Detailed migration playbook from Fresh/Preact/Deno to TanStack Start/React/Workers is available at `.cursor/skills/deco-to-tanstack-migration/`. Covers:
120
+ Detailed migration playbook from Fresh/Preact/Deno to TanStack Start/React/Workers is available at `.agents/skills/deco-to-tanstack-migration/` (the canonical location — also surfaced as a Cursor skill via the `.agents/` skills root). Covers:
121
121
 
122
122
  - Import rewrites (Preact → React, @preact/signals → @tanstack/store)
123
123
  - Deco framework elimination (@deco/deco/*, $fresh/*)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests for Phase 9 (cleanup audit integration into migrate.ts).
3
+ *
4
+ * These exercise the wrapper logic — what it prints, when it fails,
5
+ * how it interacts with --strict and dry-run. The underlying audit
6
+ * rules are tested separately in post-cleanup/runner.test.ts; we
7
+ * stub the disk minimally here just to drive findings counts.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { cleanupAudit } from "./phase-cleanup-audit";
15
+ import { createContext } from "./types";
16
+
17
+ let tmpDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deco-migrate-audit-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+
27
+ function makeCtx(overrides?: { dryRun?: boolean }) {
28
+ return createContext(tmpDir, {
29
+ dryRun: overrides?.dryRun ?? false,
30
+ verbose: false,
31
+ });
32
+ }
33
+
34
+ describe("cleanupAudit — dry-run", () => {
35
+ it("is a no-op in dry-run mode (returns false, no console output)", () => {
36
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
37
+ const ctx = makeCtx({ dryRun: true });
38
+ const failed = cleanupAudit(ctx);
39
+ expect(failed).toBe(false);
40
+ expect(spy).not.toHaveBeenCalled();
41
+ spy.mockRestore();
42
+ });
43
+ });
44
+
45
+ describe("cleanupAudit — empty site", () => {
46
+ it("prints success and returns false when there are no findings", () => {
47
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
48
+ const ctx = makeCtx();
49
+ const failed = cleanupAudit(ctx);
50
+ expect(failed).toBe(false);
51
+ const out = spy.mock.calls.map((c) => c.join(" ")).join("\n");
52
+ expect(out).toMatch(/No findings/);
53
+ spy.mockRestore();
54
+ });
55
+ });
56
+
57
+ describe("cleanupAudit — info-only findings (e.g. local widgets.ts)", () => {
58
+ beforeEach(() => {
59
+ // Set up a finding for rule 6 (local-widgets-types):
60
+ // need src/types/widgets.ts + at least one importer.
61
+ fs.mkdirSync(path.join(tmpDir, "src", "types"), { recursive: true });
62
+ fs.writeFileSync(
63
+ path.join(tmpDir, "src", "types", "widgets.ts"),
64
+ "export type ImageWidget = string;\n",
65
+ );
66
+ fs.mkdirSync(path.join(tmpDir, "src", "sections"), { recursive: true });
67
+ fs.writeFileSync(
68
+ path.join(tmpDir, "src", "sections", "Foo.tsx"),
69
+ 'import type { ImageWidget } from "~/types/widgets";\nexport const x: ImageWidget = "y";\n',
70
+ );
71
+ });
72
+
73
+ it("prints the finding and returns false (info doesn't fail strict)", () => {
74
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
75
+ const ctx = makeCtx();
76
+ const failed = cleanupAudit(ctx, { strict: true });
77
+ expect(failed).toBe(false);
78
+ const out = spy.mock.calls.map((c) => c.join(" ")).join("\n");
79
+ expect(out).toMatch(/Local src\/types\/widgets\.ts/);
80
+ expect(out).toMatch(/widgets\.ts/);
81
+ expect(out).toMatch(/deco-post-cleanup --fix/);
82
+ spy.mockRestore();
83
+ });
84
+ });
85
+
86
+ describe("cleanupAudit — warning findings (vtex-shim-regression)", () => {
87
+ beforeEach(() => {
88
+ // Trigger rule 5 (vtex-shim-regression, warning severity):
89
+ // any file outside src/lib/ that imports from ~/lib/vtex-*.
90
+ fs.mkdirSync(path.join(tmpDir, "src", "loaders"), { recursive: true });
91
+ fs.writeFileSync(
92
+ path.join(tmpDir, "src", "loaders", "Product.ts"),
93
+ 'import { fetchSafe } from "~/lib/vtex-fetch";\nexport default fetchSafe;\n',
94
+ );
95
+ });
96
+
97
+ it("returns false in non-strict mode even with warning findings", () => {
98
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
99
+ const ctx = makeCtx();
100
+ const failed = cleanupAudit(ctx, { strict: false });
101
+ expect(failed).toBe(false);
102
+ spy.mockRestore();
103
+ });
104
+
105
+ it("returns true in --strict mode when warning findings exist", () => {
106
+ const spyLog = vi.spyOn(console, "log").mockImplementation(() => {});
107
+ const ctx = makeCtx();
108
+ const failed = cleanupAudit(ctx, { strict: true });
109
+ expect(failed).toBe(true);
110
+ const out = spyLog.mock.calls.map((c) => c.join(" ")).join("\n");
111
+ expect(out).toMatch(/--strict/);
112
+ expect(out).toMatch(/failed the audit/);
113
+ spyLog.mockRestore();
114
+ });
115
+ });
116
+
117
+ describe("cleanupAudit — output truncation", () => {
118
+ beforeEach(() => {
119
+ // Fabricate >5 vtex-shim-regression findings to test the cap.
120
+ fs.mkdirSync(path.join(tmpDir, "src", "loaders"), { recursive: true });
121
+ for (let i = 0; i < 8; i++) {
122
+ fs.writeFileSync(
123
+ path.join(tmpDir, "src", "loaders", `Loader${i}.ts`),
124
+ 'import { fetchSafe } from "~/lib/vtex-fetch";\n',
125
+ );
126
+ }
127
+ });
128
+
129
+ it("caps per-rule output at 5 with a 'and N more' suffix", () => {
130
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
131
+ const ctx = makeCtx();
132
+ cleanupAudit(ctx);
133
+ const out = spy.mock.calls.map((c) => c.join(" ")).join("\n");
134
+ expect(out).toMatch(/and 3 more/);
135
+ spy.mockRestore();
136
+ });
137
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Phase 9: Post-Migration Cleanup Audit
3
+ *
4
+ * Runs the same `deco-post-cleanup` audit logic as the standalone CLI,
5
+ * but inline at the tail of `deco-migrate`. The goal is to surface any
6
+ * residual debt the migration script can't fix on its own (e.g.
7
+ * silent vtex shim regressions, orphan TODOs, manual-review items)
8
+ * the moment the migration completes — without making the user
9
+ * remember a separate command.
10
+ *
11
+ * Behaviour:
12
+ * - Always READ-ONLY. Auto-fix is opt-in via the standalone CLI's
13
+ * `--fix` flag — never invoked from inside the migration script
14
+ * to keep the migration's mutation surface predictable.
15
+ * - Skipped in dry-run (no migrated output to scan).
16
+ * - Skipped via `--no-cleanup-audit` when integrated runs are noisy.
17
+ * - In `--strict` mode, returns true (caller exits 2) if any
18
+ * warning-severity findings exist. Info findings never fail the
19
+ * build — they're just hints.
20
+ */
21
+
22
+ import { banner, bold, gray, green, red, yellow } from "./colors";
23
+ import { realFsAdapter, runAudit } from "./post-cleanup/runner";
24
+ import type { AuditReport, Severity } from "./post-cleanup/types";
25
+ import type { MigrationContext } from "./types";
26
+
27
+ export interface CleanupAuditOptions {
28
+ /** Promote warning findings to fatal (exit 2 from main). Default: false. */
29
+ strict?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Returns `true` when the caller should exit with a non-zero code.
34
+ * Always false in normal mode — audit is informational by default.
35
+ */
36
+ export function cleanupAudit(ctx: MigrationContext, opts: CleanupAuditOptions = {}): boolean {
37
+ if (ctx.dryRun) {
38
+ return false;
39
+ }
40
+
41
+ banner("Phase 9: Post-Migration Cleanup Audit");
42
+
43
+ const report = runAudit(ctx.sourceDir, realFsAdapter);
44
+
45
+ if (report.totalFindings === 0) {
46
+ console.log(` ${green("✓")} No findings — migration output is clean.`);
47
+ return false;
48
+ }
49
+
50
+ printSummary(report);
51
+
52
+ const warnings = countSeverity(report, "warning");
53
+ const infos = countSeverity(report, "info");
54
+ const willFail = (opts.strict ?? false) && warnings > 0;
55
+
56
+ console.log("");
57
+ console.log(
58
+ ` ${bold("Audit:")} ${report.totalFindings} finding(s) — ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`,
59
+ );
60
+ console.log(
61
+ ` ${gray("Run")} ${bold("deco-post-cleanup --fix")} ${gray("from this directory to auto-correct the safe rules,")}`,
62
+ );
63
+ console.log(` ${gray("or see post-migration-cleanup.md for the full per-rule playbook.")}`);
64
+
65
+ if (willFail) {
66
+ console.log(
67
+ `\n ${red("--strict:")} ${warnings} warning-severity finding(s) failed the audit.`,
68
+ );
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ function severityTag(sev: Severity, text: string): string {
76
+ if (sev === "warning") return yellow(text);
77
+ return gray(text);
78
+ }
79
+
80
+ function printSummary(report: AuditReport): void {
81
+ let idx = 0;
82
+ for (const summary of report.rules) {
83
+ idx++;
84
+ if (summary.findings.length === 0) continue;
85
+ const headColor = yellow;
86
+ console.log(
87
+ ` ${headColor(`[${idx}] ${summary.title}`)} ${gray(`(${summary.findings.length} found)`)}`,
88
+ );
89
+ // Cap the per-rule output so a noisy site doesn't drown the
90
+ // migration's own report. Standalone CLI shows everything.
91
+ const visible = summary.findings.slice(0, 5);
92
+ for (const f of visible) {
93
+ const tag = severityTag(f.severity, `[${f.severity.toUpperCase()}]`);
94
+ console.log(` ${tag} ${bold(f.file)} — ${f.message}`);
95
+ }
96
+ const hidden = summary.findings.length - visible.length;
97
+ if (hidden > 0) {
98
+ console.log(` ${gray(`...and ${hidden} more (run deco-post-cleanup for full list)`)}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ function countSeverity(report: AuditReport, sev: Severity): number {
104
+ return report.rules.flatMap((r) => r.findings).filter((f) => f.severity === sev).length;
105
+ }
@@ -9,6 +9,7 @@
9
9
  * Rules are intentionally read-only here — `--fix` is a follow-up.
10
10
  */
11
11
 
12
+ import { classifyShimExports, type ExportClass } from "./shim-classify";
12
13
  import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
13
14
 
14
15
  const SRC_GLOB_EXCLUDES = ["node_modules", "dist", ".wrangler", ".vite", ".tanstack", "build"];
@@ -238,27 +239,97 @@ const ruleSiteLocalGlobals: Rule = {
238
239
  /* Rule 5 — `~/lib/vtex-*` shim regression */
239
240
  /* ------------------------------------------------------------------ */
240
241
 
242
+ /**
243
+ * Parse one or more ES `import { a, b as c, type d } from "spec"` blocks
244
+ * targeting a specific source spec out of a file. Returns the list of
245
+ * imported names (resolved to their original symbol, ignoring `as`
246
+ * rebinds), with `import type {…}` and inline `type` modifiers stripped
247
+ * — those carry no runtime, so the rule treats them as out-of-scope.
248
+ */
249
+ function namedRuntimeImportsFrom(content: string, spec: string): string[] {
250
+ const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
251
+ // `(type\s+)?` captures the entire-import `import type { … }` form.
252
+ // Per-symbol `type` modifiers inside the braces are stripped below.
253
+ const re = new RegExp(
254
+ `import\\s+(type\\s+)?\\{([^}]+)\\}\\s+from\\s+['\"]${escaped}['\"]`,
255
+ "g",
256
+ );
257
+ const out: string[] = [];
258
+ for (const m of content.matchAll(re)) {
259
+ if (m[1]) continue; // entire import is type-only
260
+ for (const raw of m[2].split(",")) {
261
+ const trimmed = raw.trim();
262
+ if (!trimmed || trimmed.startsWith("type ")) continue;
263
+ // `foo as bar` → `foo` (we want the source symbol, not the local alias).
264
+ const sourceName = trimmed.split(/\s+as\s+/)[0].trim();
265
+ if (sourceName) out.push(sourceName);
266
+ }
267
+ }
268
+ return out;
269
+ }
270
+
241
271
  const ruleVtexShimRegression: Rule = {
242
272
  id: "vtex-shim-regression",
243
273
  title: "Imports from ~/lib/vtex-* (silent stub regression)",
244
274
  run({ siteDir, fs }: RuleContext): Finding[] {
245
275
  const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
246
276
  const findings: Finding[] = [];
247
- const re = /from\s+['"]~\/lib\/vtex-([A-Za-z0-9-]+)['"]/g;
277
+
278
+ // Per-shim classification cache. Each shim file is read at most once
279
+ // per audit run, even when imported by dozens of consumers.
280
+ const shimClasses = new Map<string, Map<string, ExportClass>>();
281
+ function classOf(shim: string, symbol: string): ExportClass {
282
+ let map = shimClasses.get(shim);
283
+ if (!map) {
284
+ const abs = `${siteDir}/src/lib/${shim}.ts`;
285
+ map = new Map<string, ExportClass>();
286
+ if (fs.exists(abs)) {
287
+ for (const ce of classifyShimExports(fs.readText(abs))) {
288
+ map.set(ce.name, ce.class);
289
+ }
290
+ }
291
+ shimClasses.set(shim, map);
292
+ }
293
+ // Unknown symbols (file missing or not exported) default to "stub" —
294
+ // pessimistic on purpose. If the symbol can't be found locally, the
295
+ // import is at best dead code, at worst a TS error; either way the
296
+ // user wants visibility into it. Compile phase catches the TS side.
297
+ return map.get(symbol) ?? "stub";
298
+ }
299
+
300
+ // Match the bare `from "~/lib/vtex-X"` to know which shims are touched.
301
+ const fromRe = /from\s+['"]~\/lib\/vtex-([A-Za-z0-9-]+)['"]/g;
248
302
  for (const abs of tsFiles) {
249
303
  if (abs.includes("/src/lib/")) continue;
250
304
  const content = fs.readText(abs);
251
- const matches = [...content.matchAll(re)];
252
- if (matches.length === 0) continue;
305
+ const usedShims = new Set<string>(
306
+ [...content.matchAll(fromRe)].map((m) => `vtex-${m[1]}`),
307
+ );
308
+ if (usedShims.size === 0) continue;
309
+
310
+ // Per-file: which shim → which stub symbols are imported.
311
+ const stubsBySim = new Map<string, string[]>();
312
+ for (const shim of usedShims) {
313
+ const symbols = namedRuntimeImportsFrom(content, `~/lib/${shim}`);
314
+ const stubs = symbols.filter((s) => classOf(shim, s) === "stub");
315
+ if (stubs.length > 0) stubsBySim.set(shim, stubs);
316
+ }
317
+ if (stubsBySim.size === 0) continue;
318
+
253
319
  const rel = abs.slice(siteDir.length + 1);
254
- const shims = [...new Set(matches.map((m) => `vtex-${m[1]}`))];
320
+ const detail = [...stubsBySim.entries()]
321
+ .map(([s, syms]) => `${s} (${syms.join(", ")})`)
322
+ .join("; ")
323
+ ;
255
324
  findings.push({
256
325
  rule: "vtex-shim-regression",
257
326
  severity: "warning",
258
327
  file: rel,
259
- message: `Imports from dead shim(s): ${shims.join(", ")} — runtime is silently stubbed`,
328
+ message: `Imports stub-only symbols from ${detail} — runtime is silently stubbed`,
260
329
  fix: "Repoint imports to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'",
261
- meta: { shims },
330
+ meta: {
331
+ stubsBySim: Object.fromEntries(stubsBySim),
332
+ },
262
333
  });
263
334
  }
264
335
  return findings;
@@ -263,14 +263,19 @@ describe("rule: site-local-with-globals", () => {
263
263
  });
264
264
 
265
265
  describe("rule: vtex-shim-regression", () => {
266
- it("flags imports from ~/lib/vtex-segment", () => {
266
+ // Default-pessimistic case: shim file missing → unknown symbols treated
267
+ // as stubs so audit always surfaces the import. (Compile phase catches
268
+ // the underlying TS error separately.)
269
+ it("flags imports when shim file is missing (defensive default)", () => {
267
270
  const fs = makeFs({
268
271
  "/site/src/sections/Foo.tsx": 'import { getSegment } from "~/lib/vtex-segment";\n',
269
272
  });
270
273
  const report = runAudit(SITE, fs);
271
274
  const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
272
275
  expect(r.findings).toHaveLength(1);
273
- expect(r.findings[0].meta?.shims).toContain("vtex-segment");
276
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
277
+ "vtex-segment": ["getSegment"],
278
+ });
274
279
  });
275
280
 
276
281
  it("does not flag imports from src/lib itself", () => {
@@ -281,6 +286,122 @@ describe("rule: vtex-shim-regression", () => {
281
286
  const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
282
287
  expect(r.findings).toEqual([]);
283
288
  });
289
+
290
+ it("does NOT flag when imported symbols are all functional", () => {
291
+ const fs = makeFs({
292
+ "/site/src/lib/vtex-id.ts":
293
+ "export function parseCookie(s?: string): Record<string,string> {\n" +
294
+ " if (!s) return {};\n" +
295
+ " return Object.fromEntries(s.split(';').map(c => c.split('=') as [string,string]));\n" +
296
+ "}\n",
297
+ "/site/src/actions/x.ts": 'import { parseCookie } from "~/lib/vtex-id";\n',
298
+ });
299
+ const report = runAudit(SITE, fs);
300
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
301
+ // parseCookie has nested-block functional impl → not a stub → no warning.
302
+ expect(r.findings).toEqual([]);
303
+ });
304
+
305
+ it("flags only the stub symbols when import set is mixed", () => {
306
+ const fs = makeFs({
307
+ "/site/src/lib/vtex-segment.ts":
308
+ "export function getSegmentFromBag(_req?: any): null { return null; }\n" +
309
+ "export function withSegmentCookie(headers: Headers): Headers {\n" +
310
+ " headers.set('x', 'y');\n" +
311
+ " return headers;\n" +
312
+ "}\n",
313
+ "/site/src/loaders/x.ts":
314
+ 'import { getSegmentFromBag, withSegmentCookie } from "~/lib/vtex-segment";\n',
315
+ });
316
+ const report = runAudit(SITE, fs);
317
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
318
+ expect(r.findings).toHaveLength(1);
319
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
320
+ "vtex-segment": ["getSegmentFromBag"],
321
+ });
322
+ expect(r.findings[0].message).toContain("getSegmentFromBag");
323
+ expect(r.findings[0].message).not.toContain("withSegmentCookie");
324
+ });
325
+
326
+ it("flags identity-cast (toProduct) as a stub", () => {
327
+ const fs = makeFs({
328
+ "/site/src/lib/vtex-transform.ts":
329
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
330
+ "/site/src/loaders/search.ts":
331
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
332
+ });
333
+ const report = runAudit(SITE, fs);
334
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
335
+ expect(r.findings).toHaveLength(1);
336
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
337
+ "vtex-transform": ["toProduct"],
338
+ });
339
+ });
340
+
341
+ it("does NOT flag `import type { X }` from a stub-having shim", () => {
342
+ const fs = makeFs({
343
+ "/site/src/lib/vtex-client.ts":
344
+ "export interface VTEXCommerceStable { account: string; }\n" +
345
+ "export function stub(): null { return null; }\n",
346
+ "/site/src/loaders/x.ts":
347
+ 'import type { VTEXCommerceStable } from "~/lib/vtex-client";\n',
348
+ });
349
+ const report = runAudit(SITE, fs);
350
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
351
+ // Type-only imports have no runtime → never a regression.
352
+ expect(r.findings).toEqual([]);
353
+ });
354
+
355
+ it("ignores per-symbol `type` modifier and only flags runtime imports", () => {
356
+ const fs = makeFs({
357
+ "/site/src/lib/vtex-mixed.ts":
358
+ "export interface Cfg { a: string; }\n" +
359
+ "export function stub(): null { return null; }\n" +
360
+ "export function ok(): boolean { return true; }\n",
361
+ "/site/src/loaders/x.ts":
362
+ 'import { type Cfg, stub, ok } from "~/lib/vtex-mixed";\n',
363
+ });
364
+ const report = runAudit(SITE, fs);
365
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
366
+ expect(r.findings).toHaveLength(1);
367
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
368
+ "vtex-mixed": ["stub"],
369
+ });
370
+ });
371
+
372
+ it("aggregates findings per file across multiple shims", () => {
373
+ const fs = makeFs({
374
+ "/site/src/lib/vtex-segment.ts":
375
+ "export function getSegmentFromBag(): null { return null; }\n",
376
+ "/site/src/lib/vtex-transform.ts":
377
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
378
+ "/site/src/loaders/search.ts":
379
+ 'import { getSegmentFromBag } from "~/lib/vtex-segment";\n' +
380
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
381
+ });
382
+ const report = runAudit(SITE, fs);
383
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
384
+ expect(r.findings).toHaveLength(1);
385
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
386
+ "vtex-segment": ["getSegmentFromBag"],
387
+ "vtex-transform": ["toProduct"],
388
+ });
389
+ });
390
+
391
+ it("supports `as`-renamed imports (resolves to source name)", () => {
392
+ const fs = makeFs({
393
+ "/site/src/lib/vtex-segment.ts":
394
+ "export function getSegmentFromBag(): null { return null; }\n",
395
+ "/site/src/loaders/x.ts":
396
+ 'import { getSegmentFromBag as getSeg } from "~/lib/vtex-segment";\n',
397
+ });
398
+ const report = runAudit(SITE, fs);
399
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
400
+ expect(r.findings).toHaveLength(1);
401
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
402
+ "vtex-segment": ["getSegmentFromBag"],
403
+ });
404
+ });
284
405
  });
285
406
 
286
407
  describe("rule: local-widgets-types", () => {