@decocms/blocks-cli 7.12.0 → 7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.12.0",
3
+ "version": "7.14.0",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.12.0",
33
+ "@decocms/blocks": "7.14.0",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -1677,6 +1677,93 @@ const ruleVtexProxyHandlerMissing: Rule = {
1677
1677
  },
1678
1678
  };
1679
1679
 
1680
+ /* ------------------------------------------------------------------ */
1681
+ /* Rule 15 — module-level signal .value reads without useStore */
1682
+ /* ------------------------------------------------------------------ */
1683
+
1684
+ /**
1685
+ * Detects the "frozen UI" pattern: a module-level signal whose `.value`
1686
+ * is read inside a React component without a `useStore()` subscription.
1687
+ *
1688
+ * In Preact, `.value` reads are automatically reactive. In React they
1689
+ * are just synchronous property accesses — no subscription is registered,
1690
+ * so the component renders once with the initial value and never updates.
1691
+ *
1692
+ * Two-pass approach:
1693
+ * 1. Find every `const name = signal(...)` at module scope (line starts
1694
+ * with optional `export const` — indented declarations inside
1695
+ * functions are skipped). First declaration wins when the same name
1696
+ * appears in multiple files (Map keyed by name deduplicates).
1697
+ * 2. For each .tsx consumer, check per-signal: does it read `name.value`
1698
+ * AND lack a `useStore(name.store` call? Both conditions must hold for
1699
+ * a finding — a file that subscribes to signal A but reads signal B
1700
+ * raw is still flagged for B.
1701
+ */
1702
+ const MODULE_SIGNAL_RE =
1703
+ /^(?:export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:\w+\.)?signal\s*\(/m;
1704
+
1705
+ const ruleSignalValueReads: Rule = {
1706
+ id: "signal-value-reads",
1707
+ title: "Module-level signal .value reads without useStore()",
1708
+ run({ siteDir, fs }: RuleContext): Finding[] {
1709
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1710
+
1711
+ // Pass 1: collect module-level signal declarations, first occurrence wins.
1712
+ // Map<signalName, declaredIn> deduplicates names across files so Pass 2
1713
+ // never emits two findings for the same (file, signalName) pair.
1714
+ const moduleSignals = new Map<string, string>();
1715
+ for (const abs of tsFiles) {
1716
+ const content = fs.readText(abs);
1717
+ const re = new RegExp(MODULE_SIGNAL_RE.source, "gm");
1718
+ let match: RegExpExecArray | null;
1719
+ while ((match = re.exec(content)) !== null) {
1720
+ const name = match[1];
1721
+ if (!moduleSignals.has(name)) {
1722
+ moduleSignals.set(name, abs.slice(siteDir.length + 1));
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ if (moduleSignals.size === 0) return [];
1728
+
1729
+ // Pre-compile per-signal regexes once — avoids N×M compilations in Pass 2.
1730
+ const valueReMap = new Map<string, RegExp>(
1731
+ [...moduleSignals.keys()].map((name) => [name, new RegExp(`\\b${name}\\.value\\b`)]),
1732
+ );
1733
+
1734
+ // Pass 2: for each .tsx file, check per-signal whether .value is read
1735
+ // without a matching useStore(name.store call in the same file.
1736
+ const tsxFiles = tsFiles.filter((f) => f.endsWith(".tsx"));
1737
+ const findings: Finding[] = [];
1738
+
1739
+ for (const abs of tsxFiles) {
1740
+ const content = fs.readText(abs);
1741
+ const rel = abs.slice(siteDir.length + 1);
1742
+
1743
+ for (const [name, declaredIn] of moduleSignals) {
1744
+ const valueRe = valueReMap.get(name)!;
1745
+ if (!valueRe.test(content)) continue;
1746
+ // Per-signal subscription check: useStore(name.store is the canonical
1747
+ // subscription pattern. A file that subscribes to a different signal
1748
+ // is not considered subscribed to this one.
1749
+ const subscribedRe = new RegExp(`\\buseStore\\s*\\(\\s*${name}\\.store\\b`);
1750
+ if (subscribedRe.test(content)) continue;
1751
+
1752
+ findings.push({
1753
+ rule: "signal-value-reads",
1754
+ severity: "warning",
1755
+ file: rel,
1756
+ message: `'${name}.value' read without useStore() — component will not re-render when the signal changes`,
1757
+ fix: `Use useStore(${name}.store, (s) => s) to subscribe, or replace with useQuery / useMutation`,
1758
+ meta: { signalName: name, declaredIn },
1759
+ });
1760
+ }
1761
+ }
1762
+
1763
+ return findings;
1764
+ },
1765
+ };
1766
+
1680
1767
  export const ALL_RULES: Rule[] = [
1681
1768
  ruleDeadLibShims,
1682
1769
  ruleObsoleteVitePlugins,
@@ -1692,6 +1779,7 @@ export const ALL_RULES: Rule[] = [
1692
1779
  ruleLockfileMissing,
1693
1780
  ruleLockfileDrift,
1694
1781
  rulePackageManagerMissing,
1782
+ ruleSignalValueReads,
1695
1783
  ];
1696
1784
 
1697
1785
  /** Exported for direct unit tests. */
@@ -1715,5 +1803,6 @@ export const _internals = {
1715
1803
  ruleLockfileMissing,
1716
1804
  ruleLockfileDrift,
1717
1805
  rulePackageManagerMissing,
1806
+ ruleSignalValueReads,
1718
1807
  },
1719
1808
  };
@@ -1793,3 +1793,184 @@ describe("rule: package-manager-missing", () => {
1793
1793
  expect(updated.packageManager).toMatch(/^bun@/);
1794
1794
  });
1795
1795
  });
1796
+
1797
+ /* ------------------------------------------------------------------ */
1798
+ /* signal-value-reads rule */
1799
+ /* ------------------------------------------------------------------ */
1800
+
1801
+ describe("rule: signal-value-reads", () => {
1802
+ it("flags a .tsx file that reads a module-level signal .value without useStore()", () => {
1803
+ const fs = makeFs({
1804
+ "/site/src/sdk/useAutocomplete.ts":
1805
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1806
+ "export const loading = signal(false);\n" +
1807
+ "export const suggestions = signal(null);\n",
1808
+ "/site/src/components/Searchbar.tsx":
1809
+ 'import { loading, suggestions } from "~/sdk/useAutocomplete";\n' +
1810
+ "export default function Searchbar() {\n" +
1811
+ " const { products = [] } = suggestions.value ?? {};\n" +
1812
+ " return loading.value ? <Spinner /> : <ul>{products.map(p => <li>{p.name}</li>)}</ul>;\n" +
1813
+ "}\n",
1814
+ });
1815
+ const report = runAudit(SITE, fs);
1816
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1817
+ // Two signals read without useStore — one finding per signal per file.
1818
+ expect(r.findings.length).toBeGreaterThanOrEqual(1);
1819
+ const files = r.findings.map((f) => f.file);
1820
+ expect(files.every((f) => f === "src/components/Searchbar.tsx")).toBe(true);
1821
+ expect(r.findings[0].severity).toBe("warning");
1822
+ expect(r.findings[0].fix).toContain("useStore");
1823
+ });
1824
+
1825
+ it("does NOT flag when the .tsx file also calls useStore()", () => {
1826
+ const fs = makeFs({
1827
+ "/site/src/sdk/useAutocomplete.ts":
1828
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1829
+ "export const loading = signal(false);\n",
1830
+ "/site/src/components/Searchbar.tsx":
1831
+ 'import { useStore } from "@tanstack/react-store";\n' +
1832
+ 'import { loading } from "~/sdk/useAutocomplete";\n' +
1833
+ "export default function Searchbar() {\n" +
1834
+ " const isLoading = useStore(loading.store, (s) => s);\n" +
1835
+ " return isLoading ? <Spinner /> : <div />;\n" +
1836
+ "}\n",
1837
+ });
1838
+ const report = runAudit(SITE, fs);
1839
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1840
+ expect(r.findings).toEqual([]);
1841
+ });
1842
+
1843
+ it("does NOT flag signal declared inside a function body (not module-level)", () => {
1844
+ const fs = makeFs({
1845
+ "/site/src/hooks/useCounter.ts":
1846
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1847
+ "export function useCounter() {\n" +
1848
+ " const count = signal(0);\n" + // indented — not module-level
1849
+ " return count;\n" +
1850
+ "}\n",
1851
+ "/site/src/components/Counter.tsx":
1852
+ 'import { useCounter } from "~/hooks/useCounter";\n' +
1853
+ "export default function Counter() {\n" +
1854
+ " const count = useCounter();\n" +
1855
+ " return <div>{count.value}</div>;\n" +
1856
+ "}\n",
1857
+ });
1858
+ const report = runAudit(SITE, fs);
1859
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1860
+ expect(r.findings).toEqual([]);
1861
+ });
1862
+
1863
+ it("does NOT flag .ts files (non-.tsx), which are not React render bodies", () => {
1864
+ const fs = makeFs({
1865
+ "/site/src/sdk/state.ts":
1866
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1867
+ "export const count = signal(0);\n",
1868
+ // A plain .ts file that reads .value — not a React component, no JSX.
1869
+ "/site/src/utils/helper.ts":
1870
+ 'import { count } from "~/sdk/state";\n' +
1871
+ "export function getCount() { return count.value; }\n",
1872
+ });
1873
+ const report = runAudit(SITE, fs);
1874
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1875
+ expect(r.findings).toEqual([]);
1876
+ });
1877
+
1878
+ it("flags only the unsubscribed signal when two signals coexist, one subscribed and one not", () => {
1879
+ const fs = makeFs({
1880
+ "/site/src/sdk/state.ts":
1881
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1882
+ "export const loading = signal(false);\n" +
1883
+ 'export const query = signal("");\n',
1884
+ // loading is correctly subscribed via useStore(loading.store, ...).
1885
+ // query.value is read raw — no useStore(query.store) call anywhere.
1886
+ // Per-signal check: loading should produce no finding; query should.
1887
+ "/site/src/components/Search.tsx":
1888
+ 'import { useStore } from "@tanstack/react-store";\n' +
1889
+ 'import { loading, query } from "~/sdk/state";\n' +
1890
+ "export default function Search() {\n" +
1891
+ " const isLoading = useStore(loading.store, (s) => s);\n" +
1892
+ " return isLoading ? null : <div>{query.value}</div>;\n" +
1893
+ "}\n",
1894
+ });
1895
+ const report = runAudit(SITE, fs);
1896
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1897
+ // loading is subscribed — no finding for it.
1898
+ // query.value is read without useStore(query.store) — one finding.
1899
+ expect(r.findings).toHaveLength(1);
1900
+ expect(r.findings[0].meta?.signalName).toBe("query");
1901
+ expect(r.findings[0].file).toBe("src/components/Search.tsx");
1902
+ });
1903
+
1904
+ it("does NOT flag when the .tsx file calls useStore with the exact signal's store", () => {
1905
+ const fs = makeFs({
1906
+ "/site/src/sdk/state.ts":
1907
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1908
+ "export const count = signal(0);\n",
1909
+ "/site/src/components/Counter.tsx":
1910
+ 'import { useStore } from "@tanstack/react-store";\n' +
1911
+ 'import { count } from "~/sdk/state";\n' +
1912
+ "export default function Counter() {\n" +
1913
+ " const n = useStore(count.store, (s) => s);\n" +
1914
+ " return <div>{count.value}</div>;\n" + // .value present but store is subscribed
1915
+ "}\n",
1916
+ });
1917
+ const report = runAudit(SITE, fs);
1918
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1919
+ expect(r.findings).toEqual([]);
1920
+ });
1921
+
1922
+ it("does not produce duplicate findings when two source files declare the same signal name", () => {
1923
+ const fs = makeFs({
1924
+ "/site/src/sdk/useAutocomplete.ts":
1925
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1926
+ "export const loading = signal(false);\n",
1927
+ // A re-export file also declares the same name — realistic in partial migrations.
1928
+ "/site/src/re-exports.ts":
1929
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1930
+ "export const loading = signal(false);\n",
1931
+ "/site/src/components/Searchbar.tsx":
1932
+ 'import { loading } from "~/sdk/useAutocomplete";\n' +
1933
+ "export default function Searchbar() {\n" +
1934
+ " return loading.value ? <Spinner /> : <div />;\n" +
1935
+ "}\n",
1936
+ });
1937
+ const report = runAudit(SITE, fs);
1938
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1939
+ // Only one finding despite two source files declaring "loading".
1940
+ expect(r.findings).toHaveLength(1);
1941
+ expect(r.findings[0].meta?.signalName).toBe("loading");
1942
+ });
1943
+
1944
+ it("returns zero findings on a site with no signal declarations", () => {
1945
+ const fs = makeFs({
1946
+ "/site/src/components/Static.tsx":
1947
+ "export default function Static() { return <div>hello</div>; }\n",
1948
+ });
1949
+ const report = runAudit(SITE, fs);
1950
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1951
+ expect(r.findings).toEqual([]);
1952
+ });
1953
+
1954
+ it("does NOT support auto-fix (correct replacement varies per case)", () => {
1955
+ const fs = makeFs({});
1956
+ const report = runAudit(SITE, fs);
1957
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1958
+ expect(r.supportsAutoFix).toBe(false);
1959
+ });
1960
+
1961
+ it("includes signalName and declaredIn in finding meta", () => {
1962
+ const fs = makeFs({
1963
+ "/site/src/sdk/signals.ts":
1964
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1965
+ "export const mySignal = signal(0);\n",
1966
+ "/site/src/components/Widget.tsx":
1967
+ 'import { mySignal } from "~/sdk/signals";\n' +
1968
+ "export default function Widget() { return <div>{mySignal.value}</div>; }\n",
1969
+ });
1970
+ const report = runAudit(SITE, fs);
1971
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1972
+ expect(r.findings).toHaveLength(1);
1973
+ expect(r.findings[0].meta?.signalName).toBe("mySignal");
1974
+ expect(r.findings[0].meta?.declaredIn).toBe("src/sdk/signals.ts");
1975
+ });
1976
+ });
@@ -121,9 +121,7 @@ import {
121
121
  } from "@decocms/blocks-admin";
122
122
  import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps-vtex/utils/proxy";
123
123
  import { extractVtexContext } from "@decocms/apps-vtex/middleware";
124
- import { loadRedirects, matchRedirect } from "@decocms/blocks/sdk/redirects";
125
124
  import { withABTesting } from "@decocms/blocks/sdk/abTesting";
126
- import { loadBlocks } from "@decocms/blocks/cms";
127
125
 
128
126
  // ---------------------------------------------------------------------------
129
127
  // VTEX checkout proxy — configured via @decocms/apps-vtex factory
@@ -152,11 +150,6 @@ const CSP_DIRECTIVES = [
152
150
 
153
151
  const serverEntry = createServerEntry({ fetch: handler.fetch });
154
152
 
155
- // ---------------------------------------------------------------------------
156
- // CMS Redirects — loaded once at module level from .deco/blocks/
157
- // ---------------------------------------------------------------------------
158
- const cmsRedirects = loadRedirects(loadBlocks());
159
-
160
153
  const MOBILE_RE = /mobile|android|iphone/i;
161
154
 
162
155
  const decoWorker = createDecoWorkerEntry(serverEntry, {
@@ -205,17 +198,6 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
205
198
 
206
199
  const abTestedWorker = withABTesting(decoWorker, {
207
200
  kvBinding: "SITES_KV",
208
- preHandler: (request, url) => {
209
- const redirect = matchRedirect(url.pathname, cmsRedirects);
210
- if (redirect) {
211
- const target = url.search ? \`\${redirect.to}\${url.search}\` : redirect.to;
212
- return new Response(null, {
213
- status: redirect.status,
214
- headers: { Location: target },
215
- });
216
- }
217
- return null;
218
- },
219
201
  shouldBypassAB: (_request, url) => {
220
202
  if (url.pathname === "/login" || url.pathname === "/login/" ||
221
203
  url.pathname === "/logout" || url.pathname === "/logout/") return false;