@decocms/blocks-cli 7.21.1 → 7.22.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.
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { _internals } from "./rules";
3
+ import type { FsAdapter, FsWriter } from "./types";
4
+
5
+ const {
6
+ ruleStringStyleProps,
7
+ ruleFreshAssetCalls,
8
+ ruleImportMetaResolve,
9
+ ruleUndeclaredPlatformGlobal,
10
+ ruleCtxRuntimeApiResidue,
11
+ ruleDuplicateImports,
12
+ ruleCorruptedTernaries,
13
+ ruleBrokenTemplateLiterals,
14
+ ruleTailwindPaletteDropped,
15
+ } = _internals.rules;
16
+
17
+ function makeFs(files: Record<string, string>): FsAdapter {
18
+ const norm = Object.fromEntries(
19
+ Object.entries(files).map(([k, v]) => [k.replace(/\\/g, "/"), v]),
20
+ );
21
+ return {
22
+ exists(absPath) {
23
+ return absPath.replace(/\\/g, "/") in norm;
24
+ },
25
+ readText(absPath) {
26
+ const key = absPath.replace(/\\/g, "/");
27
+ if (!(key in norm)) throw new Error(`ENOENT: ${absPath}`);
28
+ return norm[key];
29
+ },
30
+ glob(siteDir, pattern, excludeDirs = []) {
31
+ const root = siteDir.replace(/\\/g, "/");
32
+ const all = Object.keys(norm).filter((p) => p.startsWith(`${root}/`));
33
+ const filtered = all.filter((p) => {
34
+ const rel = p.slice(root.length + 1);
35
+ return !excludeDirs.some((dir) => rel.startsWith(`${dir}/`));
36
+ });
37
+ const branches = pattern.includes("{")
38
+ ? pattern
39
+ .match(/\{([^{}]+)\}/)![1]
40
+ .split(",")
41
+ .map((b) => pattern.replace(/\{[^{}]+\}/, b.trim()))
42
+ : [pattern];
43
+ const regexes = branches.map((p) => {
44
+ const re = p
45
+ .replace(/[.+^$()|]/g, "\\$&")
46
+ .replace(/\*\*\//g, "<<DBL>>")
47
+ .replace(/\*\*/g, "<<DBL>>")
48
+ .replace(/\*/g, "[^/]*")
49
+ .replace(/<<DBL>>/g, "(?:.*/)?");
50
+ return new RegExp(`^${re}$`);
51
+ });
52
+ return filtered
53
+ .filter((p) => {
54
+ const rel = p.slice(root.length + 1);
55
+ return regexes.some((re) => re.test(rel));
56
+ })
57
+ .sort();
58
+ },
59
+ };
60
+ }
61
+
62
+ function makeWriter(fs: FsAdapter): FsWriter & { written: Record<string, string> } {
63
+ const written: Record<string, string> = {};
64
+ return {
65
+ written,
66
+ deleteFile(absPath) {
67
+ delete written[absPath];
68
+ },
69
+ writeText(absPath, content) {
70
+ written[absPath] = content;
71
+ (fs as any).__norm ??= {};
72
+ },
73
+ };
74
+ }
75
+
76
+ const SITE = "/site";
77
+
78
+ describe("ruleStringStyleProps (#369)", () => {
79
+ it("flags string style props", () => {
80
+ const fs = makeFs({
81
+ [`${SITE}/src/sections/Slide.tsx`]: `<div style="a: b">x</div>`,
82
+ });
83
+ const findings = ruleStringStyleProps.run({ siteDir: SITE, fs });
84
+ expect(findings).toHaveLength(1);
85
+ expect(findings[0].meta?.count).toBe(1);
86
+ });
87
+
88
+ it("does not flag object style props", () => {
89
+ const fs = makeFs({
90
+ [`${SITE}/src/sections/Slide.tsx`]: `<div style={{ a: "b" }}>x</div>`,
91
+ });
92
+ expect(ruleStringStyleProps.run({ siteDir: SITE, fs })).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("ruleFreshAssetCalls (#369)", () => {
97
+ it("flags asset() calls with no local declaration", () => {
98
+ const fs = makeFs({
99
+ [`${SITE}/src/sections/Fonts.tsx`]: `const href = asset(font.source);`,
100
+ });
101
+ expect(ruleFreshAssetCalls.run({ siteDir: SITE, fs })).toHaveLength(1);
102
+ });
103
+
104
+ it("ignores a locally-declared asset() helper", () => {
105
+ const fs = makeFs({
106
+ [`${SITE}/src/utils/asset.ts`]: `export function asset(x: string) { return x; }`,
107
+ });
108
+ expect(ruleFreshAssetCalls.run({ siteDir: SITE, fs })).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe("ruleImportMetaResolve (#369)", () => {
113
+ it("flags import.meta.resolve calls", () => {
114
+ const fs = makeFs({
115
+ [`${SITE}/src/sections/Searchbar.tsx`]: `const url = import.meta.resolve("./x");`,
116
+ });
117
+ expect(ruleImportMetaResolve.run({ siteDir: SITE, fs })).toHaveLength(1);
118
+ });
119
+ });
120
+
121
+ describe("ruleUndeclaredPlatformGlobal (#369)", () => {
122
+ it("flags a bare `platform` reference with no import/declaration", () => {
123
+ const fs = makeFs({
124
+ [`${SITE}/src/sections/Cart.tsx`]: `if (platform === "vtex") { doThing(); }`,
125
+ });
126
+ expect(ruleUndeclaredPlatformGlobal.run({ siteDir: SITE, fs })).toHaveLength(1);
127
+ });
128
+
129
+ it("ignores platform when imported", () => {
130
+ const fs = makeFs({
131
+ [`${SITE}/src/sections/Cart.tsx`]: `import { platform } from "~/apps/storefront";\nif (platform === "vtex") {}`,
132
+ });
133
+ expect(ruleUndeclaredPlatformGlobal.run({ siteDir: SITE, fs })).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("ruleCtxRuntimeApiResidue (#369)", () => {
138
+ it("flags ctx.get({ __resolveType and ctx.response", () => {
139
+ const fs = makeFs({
140
+ [`${SITE}/src/sections/ProductShelf.tsx`]: `const x = ctx.get({ __resolveType: "resolvables" });`,
141
+ [`${SITE}/src/sections/Theme.tsx`]: `ctx.response.headers.set("x", "y");`,
142
+ });
143
+ const findings = ruleCtxRuntimeApiResidue.run({ siteDir: SITE, fs });
144
+ expect(findings).toHaveLength(2);
145
+ });
146
+ });
147
+
148
+ describe("ruleDuplicateImports (#369)", () => {
149
+ it("flags the same identifier imported twice", () => {
150
+ const fs = makeFs({
151
+ [`${SITE}/src/sections/SearchResult.tsx`]: [
152
+ `import { ImageWidget } from "site/types.ts";`,
153
+ `import { ImageWidget } from "site/other.ts";`,
154
+ `export const x = 1;`,
155
+ ].join("\n"),
156
+ });
157
+ const findings = ruleDuplicateImports.run({ siteDir: SITE, fs });
158
+ expect(findings).toHaveLength(1);
159
+ expect(findings[0].meta?.identifiers).toEqual(["ImageWidget"]);
160
+ });
161
+
162
+ it("applyFix removes the redundant import line", () => {
163
+ const fs = makeFs({
164
+ [`${SITE}/src/sections/SearchResult.tsx`]: [
165
+ `import { ImageWidget } from "site/types.ts";`,
166
+ `import { ImageWidget } from "site/other.ts";`,
167
+ `export const x = 1;`,
168
+ ].join("\n"),
169
+ });
170
+ const findings = ruleDuplicateImports.run({ siteDir: SITE, fs });
171
+ const writer = makeWriter(fs);
172
+ const actions = ruleDuplicateImports.applyFix!({ siteDir: SITE, fs }, findings, writer);
173
+ expect(actions).toHaveLength(1);
174
+ const updated = writer.written[`${SITE}/src/sections/SearchResult.tsx`];
175
+ expect(updated).toContain(`import { ImageWidget } from "site/types.ts";`);
176
+ expect(updated).not.toContain(`from "site/other.ts"`);
177
+ });
178
+
179
+ it("does not flag distinct identifiers", () => {
180
+ const fs = makeFs({
181
+ [`${SITE}/src/sections/X.tsx`]: [
182
+ `import { A } from "a";`,
183
+ `import { B } from "b";`,
184
+ ].join("\n"),
185
+ });
186
+ expect(ruleDuplicateImports.run({ siteDir: SITE, fs })).toEqual([]);
187
+ });
188
+ });
189
+
190
+ describe("ruleCorruptedTernaries (#369)", () => {
191
+ it("flags a corrupted ternary in a template literal", () => {
192
+ const fs = makeFs({
193
+ [`${SITE}/src/sections/Filters.tsx`]: "const c = `foo ${a ? \"x\"} : \"y\"}`;",
194
+ });
195
+ const findings = ruleCorruptedTernaries.run({ siteDir: SITE, fs });
196
+ expect(findings).toHaveLength(1);
197
+ expect(findings[0].meta?.count).toBe(1);
198
+ });
199
+
200
+ it("applyFix repairs the ternary", () => {
201
+ const fs = makeFs({
202
+ [`${SITE}/src/sections/Filters.tsx`]: "const c = `foo ${a ? \"x\"} : \"y\"}`;",
203
+ });
204
+ const findings = ruleCorruptedTernaries.run({ siteDir: SITE, fs });
205
+ const writer = makeWriter(fs);
206
+ ruleCorruptedTernaries.applyFix!({ siteDir: SITE, fs }, findings, writer);
207
+ const updated = writer.written[`${SITE}/src/sections/Filters.tsx`];
208
+ expect(updated).toBe('const c = `foo ${a ? "x" : "y"}`;');
209
+ });
210
+
211
+ it("does not flag a correct ternary", () => {
212
+ const fs = makeFs({
213
+ [`${SITE}/src/sections/Filters.tsx`]: 'const c = `foo ${a ? "x" : "y"}`;',
214
+ });
215
+ expect(ruleCorruptedTernaries.run({ siteDir: SITE, fs })).toEqual([]);
216
+ });
217
+ });
218
+
219
+ describe("ruleBrokenTemplateLiterals (#369)", () => {
220
+ it('flags `${await ""}`', () => {
221
+ const fs = makeFs({
222
+ [`${SITE}/src/sections/Fonts.tsx`]: '`/styles.css?revision=${await ""}`;',
223
+ });
224
+ expect(ruleBrokenTemplateLiterals.run({ siteDir: SITE, fs })).toHaveLength(1);
225
+ });
226
+ });
227
+
228
+ describe("ruleTailwindPaletteDropped (#369)", () => {
229
+ it("flags a custom color missing from the v4 @theme block", () => {
230
+ const fs = makeFs({
231
+ [`${SITE}/tailwind.config.ts`]: `export default {
232
+ theme: {
233
+ extend: {
234
+ colors: {
235
+ perola: "#efe9e2",
236
+ "perola-intermediario": "#d8cfc2",
237
+ },
238
+ },
239
+ },
240
+ };`,
241
+ [`${SITE}/src/app.css`]: `@theme {\n --color-*: initial;\n}`,
242
+ });
243
+ const findings = ruleTailwindPaletteDropped.run({ siteDir: SITE, fs });
244
+ expect(findings).toHaveLength(1);
245
+ expect(findings[0].meta?.missing).toEqual(["perola", "perola-intermediario"]);
246
+ });
247
+
248
+ it("does not flag when every custom color is present in @theme", () => {
249
+ const fs = makeFs({
250
+ [`${SITE}/tailwind.config.ts`]: `export default {
251
+ theme: { extend: { colors: { perola: "#efe9e2" } } },
252
+ };`,
253
+ [`${SITE}/src/app.css`]: `@theme {\n --color-perola: #efe9e2;\n}`,
254
+ });
255
+ expect(ruleTailwindPaletteDropped.run({ siteDir: SITE, fs })).toEqual([]);
256
+ });
257
+
258
+ it("is a no-op without a tailwind.config file", () => {
259
+ const fs = makeFs({ [`${SITE}/src/app.css`]: `@theme {}` });
260
+ expect(ruleTailwindPaletteDropped.run({ siteDir: SITE, fs })).toEqual([]);
261
+ });
262
+ });
@@ -1764,6 +1764,309 @@ const ruleSignalValueReads: Rule = {
1764
1764
  },
1765
1765
  };
1766
1766
 
1767
+ /* ------------------------------------------------------------------ */
1768
+ /* Rules — runtime-breaking artifacts left by the Fresh→TanStack */
1769
+ /* migration (#369). Each one crashes SSR/hydration until fixed by */
1770
+ /* hand; today they pass through the transforms silently. */
1771
+ /* ------------------------------------------------------------------ */
1772
+
1773
+ const ruleStringStyleProps: Rule = {
1774
+ id: "string-style-props",
1775
+ title: "String `style` props (React rejects, Preact allowed)",
1776
+ run({ siteDir, fs }: RuleContext): Finding[] {
1777
+ const tsxFiles = fs.glob(siteDir, "src/**/*.tsx", SRC_GLOB_EXCLUDES);
1778
+ const re = /style\s*=\s*(?:"[^"]*"|\{\s*"[^"]*"\s*\}|\{\s*`[^`]*`\s*\})/g;
1779
+ const findings: Finding[] = [];
1780
+ for (const abs of tsxFiles) {
1781
+ const content = fs.readText(abs);
1782
+ const count = [...content.matchAll(re)].length;
1783
+ if (count === 0) continue;
1784
+ findings.push({
1785
+ rule: "string-style-props",
1786
+ severity: "warning",
1787
+ file: abs.slice(siteDir.length + 1),
1788
+ message: `${count} string \`style\` prop(s) — React throws "the \`style\` prop expects a mapping ... not a string"`,
1789
+ fix: `Convert each to an object: style="a: b" → style={{ a: "b" }}`,
1790
+ meta: { count },
1791
+ });
1792
+ }
1793
+ return findings;
1794
+ },
1795
+ };
1796
+
1797
+ const ruleFreshAssetCalls: Rule = {
1798
+ id: "fresh-asset-calls",
1799
+ title: "`asset()` Fresh global with no @decocms/blocks equivalent",
1800
+ run({ siteDir, fs }: RuleContext): Finding[] {
1801
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1802
+ const callRe = /\basset\(/;
1803
+ const localDeclRe = /\b(?:const|function)\s+asset\b/;
1804
+ const findings: Finding[] = [];
1805
+ for (const abs of tsFiles) {
1806
+ const content = fs.readText(abs);
1807
+ if (!callRe.test(content) || localDeclRe.test(content)) continue;
1808
+ findings.push({
1809
+ rule: "fresh-asset-calls",
1810
+ severity: "warning",
1811
+ file: abs.slice(siteDir.length + 1),
1812
+ message: "`asset(...)` from $fresh/runtime has no @decocms/blocks equivalent — undefined at runtime",
1813
+ fix: "Remove the asset() wrapper — pass the URL/path through directly",
1814
+ });
1815
+ }
1816
+ return findings;
1817
+ },
1818
+ };
1819
+
1820
+ const ruleImportMetaResolve: Rule = {
1821
+ id: "import-meta-resolve",
1822
+ title: "`import.meta.resolve(...)` — unsupported in the Workers module runner",
1823
+ run({ siteDir, fs }: RuleContext): Finding[] {
1824
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1825
+ const re = /import\.meta\.resolve\s*\(/;
1826
+ const findings: Finding[] = [];
1827
+ for (const abs of tsFiles) {
1828
+ const content = fs.readText(abs);
1829
+ if (!re.test(content)) continue;
1830
+ findings.push({
1831
+ rule: "import-meta-resolve",
1832
+ severity: "warning",
1833
+ file: abs.slice(siteDir.length + 1),
1834
+ message: "`import.meta.resolve(...)` crashes at load in the Cloudflare Workers module runner",
1835
+ fix: "Replace with a static import or a precomputed URL/path",
1836
+ });
1837
+ }
1838
+ return findings;
1839
+ },
1840
+ };
1841
+
1842
+ const ruleUndeclaredPlatformGlobal: Rule = {
1843
+ id: "platform-global",
1844
+ title: "`platform` global with no local declaration or import",
1845
+ run({ siteDir, fs }: RuleContext): Finding[] {
1846
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1847
+ const usageRe = /\bplatform\s*(?:===|!==|==|!=)|\bif\s*\(\s*platform\b/;
1848
+ const declaredRe =
1849
+ /\b(?:const|let|var|function)\s+platform\b|\bimport\s+.*\bplatform\b.*from\s+["']/;
1850
+ const findings: Finding[] = [];
1851
+ for (const abs of tsFiles) {
1852
+ const content = fs.readText(abs);
1853
+ if (!usageRe.test(content) || declaredRe.test(content)) continue;
1854
+ findings.push({
1855
+ rule: "platform-global",
1856
+ severity: "warning",
1857
+ file: abs.slice(siteDir.length + 1),
1858
+ message: "`platform` is referenced but never declared or imported — ReferenceError at runtime",
1859
+ fix: "Import the active platform from your apps/storefront.ts setup, or hardcode the site's platform string",
1860
+ });
1861
+ }
1862
+ return findings;
1863
+ },
1864
+ };
1865
+
1866
+ const ruleCtxRuntimeApiResidue: Rule = {
1867
+ id: "ctx-runtime-api-residue",
1868
+ title: "deco.cx runtime APIs with no @decocms/blocks equivalent (`ctx.get`, `ctx.response`)",
1869
+ run({ siteDir, fs }: RuleContext): Finding[] {
1870
+ const tsxFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1871
+ const patterns: Array<{ re: RegExp; label: string }> = [
1872
+ { re: /\bctx\.get\s*\(\s*\{\s*__resolveType/, label: "ctx.get({ __resolveType: ... })" },
1873
+ { re: /\bctx\.response\b/, label: "ctx.response" },
1874
+ ];
1875
+ const findings: Finding[] = [];
1876
+ for (const abs of tsxFiles) {
1877
+ const content = fs.readText(abs);
1878
+ const hits = patterns.filter((p) => p.re.test(content)).map((p) => p.label);
1879
+ if (hits.length === 0) continue;
1880
+ findings.push({
1881
+ rule: "ctx-runtime-api-residue",
1882
+ severity: "warning",
1883
+ file: abs.slice(siteDir.length + 1),
1884
+ message: `${hits.join(", ")} — deco-cx/deco runtime API with no @decocms/blocks equivalent`,
1885
+ fix: "Remove or replace with the equivalent @decocms/blocks resolver/response API",
1886
+ meta: { apis: hits },
1887
+ });
1888
+ }
1889
+ return findings;
1890
+ },
1891
+ };
1892
+
1893
+ const ruleDuplicateImports: Rule = {
1894
+ id: "duplicate-imports",
1895
+ title: "Same identifier imported twice",
1896
+ run({ siteDir, fs }: RuleContext): Finding[] {
1897
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1898
+ const findings: Finding[] = [];
1899
+ for (const abs of tsFiles) {
1900
+ const content = fs.readText(abs);
1901
+ const dupes = findDuplicateNamedImports(content);
1902
+ if (dupes.length === 0) continue;
1903
+ findings.push({
1904
+ rule: "duplicate-imports",
1905
+ severity: "warning",
1906
+ file: abs.slice(siteDir.length + 1),
1907
+ message: `Duplicate import(s): ${dupes.join(", ")} — passes esbuild, fails babel-react-compiler`,
1908
+ fix: "Remove the redundant import line",
1909
+ meta: { identifiers: dupes },
1910
+ });
1911
+ }
1912
+ return findings;
1913
+ },
1914
+ applyFix({ siteDir, fs }: RuleContext, findings: Finding[], writer: FsWriter): FixAction[] {
1915
+ const actions: FixAction[] = [];
1916
+ for (const f of findings) {
1917
+ const abs = `${siteDir}/${f.file}`;
1918
+ const content = fs.readText(abs);
1919
+ const { content: next, removed } = dedupeNamedImports(content);
1920
+ if (removed.length === 0) continue;
1921
+ writer.writeText(abs, next);
1922
+ actions.push({ file: f.file, kind: "edit", detail: `removed ${removed.length} duplicate import(s)` });
1923
+ }
1924
+ return actions;
1925
+ },
1926
+ };
1927
+
1928
+ /** Named-import line: `import { A, B as C } from "spec";` */
1929
+ const NAMED_IMPORT_LINE_RE = /^import\s+\{([^}]*)\}\s+from\s+["'][^"']+["'];?\s*$/gm;
1930
+
1931
+ function localNamesOf(namedClause: string): string[] {
1932
+ return namedClause
1933
+ .split(",")
1934
+ .map((n) => n.trim())
1935
+ .filter(Boolean)
1936
+ .map((n) => {
1937
+ const asMatch = n.match(/^(?:type\s+)?[\w$]+\s+as\s+([\w$]+)$/);
1938
+ return asMatch ? asMatch[1] : n.replace(/^type\s+/, "");
1939
+ });
1940
+ }
1941
+
1942
+ function findDuplicateNamedImports(content: string): string[] {
1943
+ const seen = new Set<string>();
1944
+ const dupes = new Set<string>();
1945
+ for (const m of content.matchAll(NAMED_IMPORT_LINE_RE)) {
1946
+ for (const name of localNamesOf(m[1])) {
1947
+ if (seen.has(name)) dupes.add(name);
1948
+ seen.add(name);
1949
+ }
1950
+ }
1951
+ return [...dupes].sort();
1952
+ }
1953
+
1954
+ /** Drops any named import line whose bindings are ALL already seen. */
1955
+ function dedupeNamedImports(content: string): { content: string; removed: string[] } {
1956
+ const seen = new Set<string>();
1957
+ const removed: string[] = [];
1958
+ const next = content.replace(NAMED_IMPORT_LINE_RE, (line, namedClause) => {
1959
+ const names = localNamesOf(namedClause);
1960
+ const allDup = names.length > 0 && names.every((n) => seen.has(n));
1961
+ for (const n of names) seen.add(n);
1962
+ if (allDup) {
1963
+ removed.push(...names);
1964
+ return "";
1965
+ }
1966
+ return line;
1967
+ });
1968
+ return { content: next, removed };
1969
+ }
1970
+
1971
+ const ruleCorruptedTernaries: Rule = {
1972
+ id: "corrupted-ternaries",
1973
+ title: "Corrupted ternary in a template-literal className (closing brace in the wrong branch)",
1974
+ run({ siteDir, fs }: RuleContext): Finding[] {
1975
+ const tsxFiles = fs.glob(siteDir, "src/**/*.tsx", SRC_GLOB_EXCLUDES);
1976
+ const re = /\$\{[^{}?]+\?\s*"(?:[^"\\]|\\.)*"\}\s*:\s*"(?:[^"\\]|\\.)*"\}/g;
1977
+ const findings: Finding[] = [];
1978
+ for (const abs of tsxFiles) {
1979
+ const content = fs.readText(abs);
1980
+ const count = [...content.matchAll(re)].length;
1981
+ if (count === 0) continue;
1982
+ findings.push({
1983
+ rule: "corrupted-ternaries",
1984
+ severity: "warning",
1985
+ file: abs.slice(siteDir.length + 1),
1986
+ message: `${count} corrupted ternary/ternaries in a template literal — the \`}\` closes the expression before \`: "..."\`, causing a syntax error`,
1987
+ fix: `\`... \\\${a ? "x"} : "y"\`\` → \`... \\\${a ? "x" : "y"}\``,
1988
+ meta: { count },
1989
+ });
1990
+ }
1991
+ return findings;
1992
+ },
1993
+ applyFix({ siteDir, fs }: RuleContext, findings: Finding[], writer: FsWriter): FixAction[] {
1994
+ const actions: FixAction[] = [];
1995
+ const re = /\$\{([^{}?]+)\?\s*("(?:[^"\\]|\\.)*")\}\s*:\s*("(?:[^"\\]|\\.)*")\}/g;
1996
+ for (const f of findings) {
1997
+ const abs = `${siteDir}/${f.file}`;
1998
+ const content = fs.readText(abs);
1999
+ let count = 0;
2000
+ const next = content.replace(re, (_m, cond, a, b) => {
2001
+ count++;
2002
+ return `\${${cond}? ${a} : ${b}}`;
2003
+ });
2004
+ if (count === 0) continue;
2005
+ writer.writeText(abs, next);
2006
+ actions.push({ file: f.file, kind: "edit", detail: `repaired ${count} corrupted ternary/ternaries` });
2007
+ }
2008
+ return actions;
2009
+ },
2010
+ };
2011
+
2012
+ const ruleBrokenTemplateLiterals: Rule = {
2013
+ id: "broken-template-literals",
2014
+ title: 'Broken template literal emitted by the migration (`${await ""}`)',
2015
+ run({ siteDir, fs }: RuleContext): Finding[] {
2016
+ const tsxFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
2017
+ const re = /\$\{await\s+""\}/;
2018
+ const findings: Finding[] = [];
2019
+ for (const abs of tsxFiles) {
2020
+ const content = fs.readText(abs);
2021
+ if (!re.test(content)) continue;
2022
+ findings.push({
2023
+ rule: "broken-template-literals",
2024
+ severity: "warning",
2025
+ file: abs.slice(siteDir.length + 1),
2026
+ message: '`${await ""}` is an invalid placeholder emitted by the migration',
2027
+ fix: "Replace with the real expression (e.g. a revision/build-hash variable) or drop the interpolation",
2028
+ });
2029
+ }
2030
+ return findings;
2031
+ },
2032
+ };
2033
+
2034
+ const ruleTailwindPaletteDropped: Rule = {
2035
+ id: "tailwind-palette-dropped",
2036
+ title: "Custom Tailwind v3 palette not ported to the v4 @theme reset",
2037
+ run({ siteDir, fs }: RuleContext): Finding[] {
2038
+ const configFiles = fs.glob(siteDir, "tailwind.config.{ts,js}", []);
2039
+ if (configFiles.length === 0) return [];
2040
+
2041
+ const configContent = fs.readText(configFiles[0]);
2042
+ const extendColorsMatch = configContent.match(/extend\s*:\s*\{[^]*?colors\s*:\s*\{([^]*?)\n\s*\}/);
2043
+ if (!extendColorsMatch) return [];
2044
+
2045
+ const colorNames = [...extendColorsMatch[1].matchAll(/^\s*["']?([\w-]+)["']?\s*:/gm)].map(
2046
+ (m) => m[1],
2047
+ );
2048
+ if (colorNames.length === 0) return [];
2049
+
2050
+ const cssFiles = fs.glob(siteDir, "src/**/*.css", SRC_GLOB_EXCLUDES);
2051
+ const themeFile = cssFiles.find((f) => fs.readText(f).includes("@theme"));
2052
+ const themeContent = themeFile ? fs.readText(themeFile) : "";
2053
+
2054
+ const missing = colorNames.filter((name) => !themeContent.includes(name));
2055
+ if (missing.length === 0) return [];
2056
+
2057
+ return [
2058
+ {
2059
+ rule: "tailwind-palette-dropped",
2060
+ severity: "warning",
2061
+ file: themeFile ? themeFile.slice(siteDir.length + 1) : "tailwind.config.ts",
2062
+ message: `Custom color(s) from tailwind.config.ts not present in the v4 @theme block: ${missing.join(", ")} — \`@apply\` on them fails ("unknown utility class")`,
2063
+ fix: "Port each color as a --color-<name> CSS variable inside the @theme block",
2064
+ meta: { missing },
2065
+ },
2066
+ ];
2067
+ },
2068
+ };
2069
+
1767
2070
  export const ALL_RULES: Rule[] = [
1768
2071
  ruleDeadLibShims,
1769
2072
  ruleObsoleteVitePlugins,
@@ -1780,6 +2083,15 @@ export const ALL_RULES: Rule[] = [
1780
2083
  ruleLockfileDrift,
1781
2084
  rulePackageManagerMissing,
1782
2085
  ruleSignalValueReads,
2086
+ ruleStringStyleProps,
2087
+ ruleFreshAssetCalls,
2088
+ ruleImportMetaResolve,
2089
+ ruleUndeclaredPlatformGlobal,
2090
+ ruleCtxRuntimeApiResidue,
2091
+ ruleDuplicateImports,
2092
+ ruleCorruptedTernaries,
2093
+ ruleBrokenTemplateLiterals,
2094
+ ruleTailwindPaletteDropped,
1783
2095
  ];
1784
2096
 
1785
2097
  /** Exported for direct unit tests. */
@@ -1804,5 +2116,14 @@ export const _internals = {
1804
2116
  ruleLockfileDrift,
1805
2117
  rulePackageManagerMissing,
1806
2118
  ruleSignalValueReads,
2119
+ ruleStringStyleProps,
2120
+ ruleFreshAssetCalls,
2121
+ ruleImportMetaResolve,
2122
+ ruleUndeclaredPlatformGlobal,
2123
+ ruleCtxRuntimeApiResidue,
2124
+ ruleDuplicateImports,
2125
+ ruleCorruptedTernaries,
2126
+ ruleBrokenTemplateLiterals,
2127
+ ruleTailwindPaletteDropped,
1807
2128
  },
1808
2129
  };
@@ -799,6 +799,8 @@ describe("runAudit — totals", () => {
799
799
  "obsolete-vite-plugins",
800
800
  "package-manager-missing",
801
801
  "vtex-shim-regression",
802
+ "duplicate-imports",
803
+ "corrupted-ternaries",
802
804
  ].sort(),
803
805
  );
804
806
  });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createContext } from "../types";
3
+ import type { MigrationContext } from "../types";
4
+ import { generateServerEntry } from "./server-entry";
5
+
6
+ /**
7
+ * Regression guard for #368: createUseUser/createUseWishlist
8
+ * (@decocms/apps-vtex/hooks) require invoke.vtex.loaders.user/wishlist and
9
+ * invoke.vtex.actions.addToWishlist/removeFromWishlist. Before this fix, the
10
+ * scaffolded invoke only wired `vtex.actions` from invoke.gen.ts, so any
11
+ * scaffolded site rendering useUser()/useWishlist() crashed the Header on
12
+ * mount (`Cannot read properties of undefined (reading 'user')`).
13
+ */
14
+ function makeVtexCtx(): MigrationContext {
15
+ const ctx = createContext("/tmp/vtex-invoke-scaffold-fixture-site");
16
+ ctx.siteName = "acme-storefront";
17
+ ctx.platform = "vtex";
18
+ ctx.vtexAccount = "acme";
19
+ return ctx;
20
+ }
21
+
22
+ describe("VTEX invoke scaffold wires vtex.loaders + wishlist actions (#368)", () => {
23
+ const files = generateServerEntry(makeVtexCtx());
24
+ const invokeSrc = files["src/server/invoke.ts"];
25
+
26
+ it("scaffolds invoke.vtex.loaders.user and invoke.vtex.loaders.wishlist", () => {
27
+ expect(invokeSrc).toContain("loaders: {");
28
+ expect(invokeSrc).toContain("user: _vtexUser");
29
+ expect(invokeSrc).toContain("wishlist: _vtexWishlist");
30
+ });
31
+
32
+ it("scaffolds invoke.vtex.actions.addToWishlist and removeFromWishlist", () => {
33
+ expect(invokeSrc).toContain("addToWishlist: _addToWishlist");
34
+ expect(invokeSrc).toContain("removeFromWishlist: _removeFromWishlist");
35
+ });
36
+
37
+ it("still spreads the generated checkout/session/masterData/newsletter actions", () => {
38
+ expect(invokeSrc).toContain("...vtexActions,");
39
+ });
40
+
41
+ it("imports getUser/getWishlist/addItem/removeItem from @decocms/apps-vtex", () => {
42
+ expect(invokeSrc).toContain('from "@decocms/apps-vtex/loaders/user"');
43
+ expect(invokeSrc).toContain('from "@decocms/apps-vtex/loaders/wishlist"');
44
+ expect(invokeSrc).toContain('from "@decocms/apps-vtex/actions/wishlist"');
45
+ });
46
+
47
+ it("derives shopperId from the auth JWT instead of a hardcoded value", () => {
48
+ expect(invokeSrc).toContain("getShopperId");
49
+ expect(invokeSrc).toContain("parseVtexAuthJwt");
50
+ });
51
+ });