@decocms/start 2.16.0 → 2.17.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.
@@ -291,12 +291,30 @@ When you see real findings, update the imports to point at
291
291
  gets MUCH better — segment cookies, IS cookies, VTEX session auth all
292
292
  start working again instead of being silently stubbed to `{}` / `null`.
293
293
 
294
- **Note on `--fix`**: this rule is intentionally detect-only. Repointing
295
- imports requires a per-symbol map to canonical apps/start exports
296
- (e.g. `getSegmentFromBag` `@decocms/apps/vtex/utils/segment`), which
297
- the framework doesn't ship yet. Detect-only is still strictly more
298
- useful than nothing — the precision means each finding maps to exactly
299
- one PR's worth of mechanical work.
294
+ **Note on `--fix`** (since `@decocms/start >= 2.16.0`): the rule
295
+ auto-fixes the SAFE subset of swaps when every imported symbol from
296
+ a given shim is a `kind: "swap"` hint to the SAME canonical module.
297
+ Concretely:
298
+
299
+ | Pattern | `--fix` behaviour |
300
+ |---|---|
301
+ | `import { toProduct } from "~/lib/vtex-transform"` | rewritten to `@decocms/apps/vtex/utils/transform` |
302
+ | `import { withSegmentCookie } from "~/lib/vtex-segment"` | rewritten to `@decocms/apps/vtex/utils/segment` |
303
+ | `import { getSegmentFromBag, withSegmentCookie } from "~/lib/vtex-segment"` | left untouched (mixed swap + refactor) |
304
+ | `import { getISCookiesFromBag } from "~/lib/vtex-intelligent-search"` | left untouched (refactor-only — no canonical drop-in) |
305
+ | `import { toProduct, isFilterParam } from "~/lib/vtex-transform"` | left untouched (would lose the real impl) |
306
+
307
+ The auto-fix rewrites only the `from "..."` clause — the imported
308
+ names list is preserved verbatim, so `as`-aliased imports (e.g.
309
+ `{ toProduct as toP }`) keep working. After the import swap you may
310
+ still need to expand 1-arg `toProduct(p)` call sites to the canonical
311
+ 4-arg signature — see § 5 below.
312
+
313
+ The refactor-only cases (`getSegmentFromBag`, `getISCookiesFromBag`,
314
+ mixed surfaces) intentionally stay manual: the bag-based lookup
315
+ mechanism doesn't exist on TanStack Start, so each call site needs a
316
+ human reading `request.headers.get('cookie')` and calling
317
+ `buildSegmentFromCookies()` from the canonical module.
300
318
 
301
319
  ## 6. Drop `src/types/widgets.ts` — framework owns it
302
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.16.0",
3
+ "version": "2.17.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",
@@ -452,6 +452,60 @@ const ruleVtexShimRegression: Rule = {
452
452
  }
453
453
  return findings;
454
454
  },
455
+ applyFix({ siteDir, fs }, findings, writer): FixAction[] {
456
+ if (findings.length === 0) return [];
457
+ const actions: FixAction[] = [];
458
+
459
+ // Per-file rewrite. Conservative: only swap the import path when EVERY
460
+ // imported symbol from the shim is a `kind: "swap"` hint pointing at
461
+ // the same canonical module. Mixed surfaces (some swap + some
462
+ // refactor, or a real impl + a stub) stay untouched — those need a
463
+ // human looking at call-site signatures.
464
+ for (const finding of findings) {
465
+ const stubsBySim = (finding.meta?.stubsBySim ?? {}) as Record<string, string[]>;
466
+ const abs = `${siteDir}/${finding.file}`;
467
+ if (!fs.exists(abs)) continue;
468
+
469
+ let content = fs.readText(abs);
470
+ let modified = false;
471
+
472
+ for (const [shim, _stubSyms] of Object.entries(stubsBySim)) {
473
+ const oldSpec = `~/lib/${shim}`;
474
+ const importedSymbols = namedRuntimeImportsFrom(content, oldSpec);
475
+ if (importedSymbols.length === 0) continue;
476
+
477
+ // Every imported symbol must be a swap-kind hint AND every hint
478
+ // must point at the same canonical module — otherwise we'd
479
+ // either drop a real impl or split the import across two paths,
480
+ // both of which are unsafe to do mechanically here.
481
+ const hints = importedSymbols.map((s) => STUB_FIX_HINTS[s]);
482
+ const allSwap = hints.every((h) => h && h.kind === "swap");
483
+ if (!allSwap) continue;
484
+ const targets = new Set(
485
+ hints.map((h) => (h as { kind: "swap"; canonical: string }).canonical),
486
+ );
487
+ if (targets.size !== 1) continue;
488
+ const target = [...targets][0];
489
+
490
+ const escaped = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
491
+ const importLineRe = new RegExp(`from\\s+(['"])${escaped}\\1`, "g");
492
+ const next = content.replace(importLineRe, (_m, q) => `from ${q}${target}${q}`);
493
+ if (next !== content) {
494
+ content = next;
495
+ modified = true;
496
+ actions.push({
497
+ file: finding.file,
498
+ kind: "rewrite-imports",
499
+ detail: `${oldSpec} → ${target} (${importedSymbols.join(", ")})`,
500
+ });
501
+ }
502
+ }
503
+
504
+ if (modified) writer.writeText(abs, content);
505
+ }
506
+
507
+ return actions;
508
+ },
455
509
  };
456
510
 
457
511
  /* ------------------------------------------------------------------ */
@@ -587,7 +587,12 @@ describe("runAudit — totals", () => {
587
587
  .map((r) => r.rule)
588
588
  .sort();
589
589
  expect(supported).toEqual(
590
- ["dead-lib-shims", "dead-runtime-shim", "local-widgets-types"].sort(),
590
+ [
591
+ "dead-lib-shims",
592
+ "dead-runtime-shim",
593
+ "local-widgets-types",
594
+ "vtex-shim-regression",
595
+ ].sort(),
591
596
  );
592
597
  });
593
598
  });
@@ -681,3 +686,121 @@ describe("runAudit — fix mode", () => {
681
686
  expect(store["/site/src/sections/A.tsx"]).toContain('"~/types/widgets-extra"');
682
687
  });
683
688
  });
689
+
690
+ /* ------------------------------------------------------------------ */
691
+ /* W12-D / W12-E — vtex-shim-regression --fix for swap-able cases */
692
+ /* ------------------------------------------------------------------ */
693
+
694
+ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => {
695
+ it("rewrites `toProduct` import to canonical when it is the only stub imported", () => {
696
+ const { fs, writer, store } = makeMutableFs({
697
+ "/site/src/lib/vtex-transform.ts":
698
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
699
+ "/site/src/loaders/search.ts":
700
+ 'import { toProduct } from "~/lib/vtex-transform";\nconsole.log(toProduct);\n',
701
+ });
702
+ const report = runAudit(SITE, fs, { writer });
703
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
704
+ expect(r.fixes).toHaveLength(1);
705
+ expect(r.fixes![0].file).toBe("src/loaders/search.ts");
706
+ expect(r.fixes![0].kind).toBe("rewrite-imports");
707
+ expect(r.fixes![0].detail).toContain("@decocms/apps/vtex/utils/transform");
708
+ expect(r.fixes![0].detail).toContain("toProduct");
709
+ expect(store["/site/src/loaders/search.ts"]).toContain(
710
+ '"@decocms/apps/vtex/utils/transform"',
711
+ );
712
+ expect(store["/site/src/loaders/search.ts"]).not.toContain('"~/lib/vtex-transform"');
713
+ });
714
+
715
+ it("rewrites `withSegmentCookie` import to canonical when it is the only stub imported", () => {
716
+ const { fs, writer, store } = makeMutableFs({
717
+ // Mirrors the post-#123 throwing-stub body — `throw new Error(...)`
718
+ // is recognised by the shim classifier as `unconditional throw`.
719
+ "/site/src/lib/vtex-segment.ts":
720
+ 'export function withSegmentCookie(..._args: any[]): any { throw new Error("stub"); }\n',
721
+ "/site/src/loaders/x.ts":
722
+ 'import { withSegmentCookie } from "~/lib/vtex-segment";\nconsole.log(withSegmentCookie);\n',
723
+ });
724
+ const report = runAudit(SITE, fs, { writer });
725
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
726
+ expect(r.fixes).toHaveLength(1);
727
+ expect(r.fixes![0].detail).toContain("@decocms/apps/vtex/utils/segment");
728
+ expect(store["/site/src/loaders/x.ts"]).toContain(
729
+ '"@decocms/apps/vtex/utils/segment"',
730
+ );
731
+ });
732
+
733
+ it("does NOT rewrite mixed swap+refactor surface (getSegmentFromBag is refactor-only)", () => {
734
+ const { fs, writer, store } = makeMutableFs({
735
+ "/site/src/lib/vtex-segment.ts":
736
+ "export function getSegmentFromBag(_req?: any): null { return null; }\n" +
737
+ 'export function withSegmentCookie(..._args: any[]): any { throw new Error("stub"); }\n',
738
+ "/site/src/loaders/x.ts":
739
+ 'import { getSegmentFromBag, withSegmentCookie } from "~/lib/vtex-segment";\n',
740
+ });
741
+ const before = store["/site/src/loaders/x.ts"];
742
+ const report = runAudit(SITE, fs, { writer });
743
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
744
+ // Finding still emitted (audit), but no fix applied (mixed surface).
745
+ expect(r.findings).toHaveLength(1);
746
+ expect(r.fixes).toEqual([]);
747
+ expect(store["/site/src/loaders/x.ts"]).toBe(before);
748
+ });
749
+
750
+ it("does NOT rewrite when the file imports a real impl from the same shim", () => {
751
+ // vtex-intelligent-search exports both `getISCookiesFromBag` (stub →
752
+ // refactor) and `isFilterParam` (real impl, not in STUB_FIX_HINTS).
753
+ // Even if only one symbol is imported, the rule's classifier already
754
+ // skips real impls. But if a file imports BOTH, our --fix must not
755
+ // rewrite — the canonical doesn't expose isFilterParam.
756
+ const { fs, writer, store } = makeMutableFs({
757
+ "/site/src/lib/vtex-intelligent-search.ts":
758
+ "export function getISCookiesFromBag(_r?: any): Record<string,string> { return {}; }\n" +
759
+ "export function isFilterParam(k: string): boolean { return k.startsWith('filter.'); }\n",
760
+ "/site/src/loaders/x.ts":
761
+ 'import { getISCookiesFromBag, isFilterParam } from "~/lib/vtex-intelligent-search";\n',
762
+ });
763
+ const before = store["/site/src/loaders/x.ts"];
764
+ const report = runAudit(SITE, fs, { writer });
765
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
766
+ expect(r.findings).toHaveLength(1);
767
+ expect(r.fixes).toEqual([]);
768
+ expect(store["/site/src/loaders/x.ts"]).toBe(before);
769
+ });
770
+
771
+ it("rewrites multiple files using the same swap-able shim in one pass", () => {
772
+ const { fs, writer, store } = makeMutableFs({
773
+ "/site/src/lib/vtex-transform.ts":
774
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
775
+ "/site/src/loaders/A.ts":
776
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
777
+ "/site/src/loaders/B.ts":
778
+ "import { toProduct } from '~/lib/vtex-transform';\n",
779
+ });
780
+ const report = runAudit(SITE, fs, { writer });
781
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
782
+ expect(r.fixes!.length).toBe(2);
783
+ expect(store["/site/src/loaders/A.ts"]).toContain(
784
+ '"@decocms/apps/vtex/utils/transform"',
785
+ );
786
+ expect(store["/site/src/loaders/B.ts"]).toContain(
787
+ "'@decocms/apps/vtex/utils/transform'",
788
+ );
789
+ });
790
+
791
+ it("preserves the named-imports list verbatim when swapping", () => {
792
+ // The fix only rewrites the FROM clause, not the imported names. Keeps
793
+ // local aliases (`as`) intact.
794
+ const { fs, writer, store } = makeMutableFs({
795
+ "/site/src/lib/vtex-transform.ts":
796
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
797
+ "/site/src/loaders/x.ts":
798
+ 'import { toProduct as toP } from "~/lib/vtex-transform";\n' +
799
+ "console.log(toP);\n",
800
+ });
801
+ runAudit(SITE, fs, { writer });
802
+ expect(store["/site/src/loaders/x.ts"]).toContain(
803
+ 'import { toProduct as toP } from "@decocms/apps/vtex/utils/transform"',
804
+ );
805
+ });
806
+ });
@@ -66,6 +66,43 @@ describe("generateHooks (vtex)", () => {
66
66
  // Should be well under 20 lines (factory call + re-export + imports).
67
67
  expect(lineCount).toBeLessThan(20);
68
68
  });
69
+
70
+ it("useUser is the createUseUser factory shim (no signal-stub boilerplate)", () => {
71
+ const code = files["src/hooks/useUser.ts"];
72
+ expect(code).toContain(
73
+ 'import { createUseUser } from "@decocms/apps/vtex/hooks/createUseUser"',
74
+ );
75
+ expect(code).toContain('import { invoke } from "~/server/invoke"');
76
+ expect(code).toContain(
77
+ 'export type { Person } from "@decocms/apps/vtex/loaders/user"',
78
+ );
79
+ expect(code).toContain("export const { useUser, resetUser } = createUseUser");
80
+ // Must NOT scaffold the legacy signal stub.
81
+ expect(code).not.toContain('signal<User | null>(null)');
82
+ expect(code).not.toContain("export interface User {");
83
+ });
84
+
85
+ it("useWishlist is the createUseWishlist factory shim", () => {
86
+ const code = files["src/hooks/useWishlist.ts"];
87
+ expect(code).toContain(
88
+ 'import { createUseWishlist } from "@decocms/apps/vtex/hooks/createUseWishlist"',
89
+ );
90
+ expect(code).toContain('import { invoke } from "~/server/invoke"');
91
+ expect(code).toContain(
92
+ 'export type { WishlistItem } from "@decocms/apps/vtex/loaders/wishlist"',
93
+ );
94
+ expect(code).toContain(
95
+ "export const { useWishlist, resetWishlist } = createUseWishlist",
96
+ );
97
+ // Must NOT scaffold the legacy stub with TODO action bodies.
98
+ expect(code).not.toContain("TODO: Implement");
99
+ expect(code).not.toContain("getItem(_productId: string): boolean");
100
+ });
101
+
102
+ it("useUser + useWishlist VTEX shims are each well under 10 lines", () => {
103
+ expect(files["src/hooks/useUser.ts"].split("\n").length).toBeLessThan(10);
104
+ expect(files["src/hooks/useWishlist.ts"].split("\n").length).toBeLessThan(10);
105
+ });
69
106
  });
70
107
 
71
108
  describe("generateHooks (non-vtex)", () => {
@@ -82,4 +119,23 @@ describe("generateHooks (non-vtex)", () => {
82
119
  // Until a shopify factory exists, non-vtex platforms get the generic stub.
83
120
  expect(code).toContain("Cart Hook stub");
84
121
  });
122
+
123
+ it("non-vtex platforms keep the legacy signal-based useUser stub", () => {
124
+ const files = generateHooks(makeCtx("custom"));
125
+ const code = files["src/hooks/useUser.ts"];
126
+ // No factory CALL — docstring may mention it as a pointer for VTEX.
127
+ expect(code).not.toContain("createUseUser({");
128
+ expect(code).not.toContain("export const { useUser, resetUser }");
129
+ // Legacy stub shape (signal + User interface).
130
+ expect(code).toContain("signal<User | null>(null)");
131
+ expect(code).toContain("export interface User {");
132
+ });
133
+
134
+ it("non-vtex platforms keep the legacy useWishlist stub", () => {
135
+ const files = generateHooks(makeCtx("custom"));
136
+ const code = files["src/hooks/useWishlist.ts"];
137
+ expect(code).not.toContain("createUseWishlist({");
138
+ expect(code).not.toContain("export const { useWishlist, resetWishlist }");
139
+ expect(code).toContain("TODO: Implement");
140
+ });
85
141
  });
@@ -5,13 +5,14 @@ export function generateHooks(ctx: MigrationContext): Record<string, string> {
5
5
 
6
6
  if (ctx.platform === "vtex") {
7
7
  files["src/hooks/useCart.ts"] = generateVtexUseCart();
8
+ files["src/hooks/useUser.ts"] = generateVtexUseUser();
9
+ files["src/hooks/useWishlist.ts"] = generateVtexUseWishlist();
8
10
  } else {
9
11
  files["src/hooks/useCart.ts"] = generateGenericUseCart();
12
+ files["src/hooks/useUser.ts"] = generateGenericUseUser();
13
+ files["src/hooks/useWishlist.ts"] = generateGenericUseWishlist();
10
14
  }
11
15
 
12
- files["src/hooks/useUser.ts"] = generateUseUser();
13
- files["src/hooks/useWishlist.ts"] = generateUseWishlist();
14
-
15
16
  return files;
16
17
  }
17
18
 
@@ -66,9 +67,39 @@ export default useCart;
66
67
  `;
67
68
  }
68
69
 
69
- function generateUseUser(): string {
70
+ // VTEX path — these are five-line factory shims. The heavy lifting
71
+ // (singleton state, listener pattern, async actions, signal-shaped
72
+ // accessors, legacy arg-swap conventions) lives in
73
+ // @decocms/apps/vtex/hooks/createUseUser and createUseWishlist.
74
+ function generateVtexUseUser(): string {
75
+ return `import { createUseUser } from "@decocms/apps/vtex/hooks/createUseUser";
76
+ import { invoke } from "~/server/invoke";
77
+
78
+ export type { Person } from "@decocms/apps/vtex/loaders/user";
79
+
80
+ export const { useUser, resetUser } = createUseUser({ invoke });
81
+ `;
82
+ }
83
+
84
+ function generateVtexUseWishlist(): string {
85
+ return `import { createUseWishlist } from "@decocms/apps/vtex/hooks/createUseWishlist";
86
+ import { invoke } from "~/server/invoke";
87
+
88
+ export type { WishlistItem } from "@decocms/apps/vtex/loaders/wishlist";
89
+
90
+ export const { useWishlist, resetWishlist } = createUseWishlist({ invoke });
91
+ `;
92
+ }
93
+
94
+ // Non-VTEX fallback — keeps the legacy signal-based stub shape so any
95
+ // generic platform port that already consumes `~/hooks/useUser` keeps
96
+ // type-checking. Sites must wire their own platform-specific impl.
97
+ function generateGenericUseUser(): string {
70
98
  return `/**
71
99
  * User Hook — wire to invoke.site.loaders for your platform's user API.
100
+ *
101
+ * VTEX sites get a real factory from @decocms/apps/vtex/hooks/createUseUser;
102
+ * see migration template hooks.ts for the canonical five-line shim.
72
103
  */
73
104
  import { signal } from "~/sdk/signal";
74
105
 
@@ -90,12 +121,12 @@ export default useUser;
90
121
  `;
91
122
  }
92
123
 
93
- function generateUseWishlist(): string {
124
+ function generateGenericUseWishlist(): string {
94
125
  return `/**
95
126
  * Wishlist Hook — wire to invoke.site.loaders/actions for your platform.
96
127
  *
97
- * For VTEX: use invoke.site.loaders.getWishlistItems and
98
- * invoke.site.actions.addWishlistItem / removeWishlistItem.
128
+ * VTEX sites get a real factory from @decocms/apps/vtex/hooks/createUseWishlist;
129
+ * see migration template hooks.ts for the canonical five-line shim.
99
130
  */
100
131
  import { signal } from "~/sdk/signal";
101
132
 
@@ -105,7 +136,7 @@ export function useWishlist() {
105
136
  return {
106
137
  loading,
107
138
  async addItem(_productId: string, _productGroupId: string) {
108
- // TODO: Implement
139
+ // TODO: Implement for your platform
109
140
  },
110
141
  async removeItem(_productId: string) {
111
142
  // TODO: Implement