@decocms/start 6.1.0 → 6.2.1

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.
@@ -15,27 +15,45 @@
15
15
  *
16
16
  * Rules (id — severity — what it catches):
17
17
  *
18
- * observability_missing error No `observability` key at all. CF captures nothing.
19
- * observability_disabled error `observability.enabled: false`. Master switch off.
20
- * traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard.
21
- * logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard.
22
- * head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md.
23
- * logs_head_sampling_rate_low warn `logs.head_sampling_rate < 1`. Sampling info/warn logs loses signal cheaply; errors go via the direct-POST channel.
24
- * persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded.
18
+ * observability_missing error No `observability` key at all. CF captures nothing.
19
+ * observability_disabled error `observability.enabled: false`. Master switch off.
20
+ * traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard.
21
+ * logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard.
22
+ * head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md.
23
+ * logs_head_sampling_rate_low warn `logs.head_sampling_rate < 1`. Sampling info/warn logs loses signal cheaply; errors go via the direct-POST channel.
24
+ * persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded.
25
+ *
26
+ * Phase 6 / D-14 — fleet-config drift rules (live outside the
27
+ * `observability` block but still owned by this audit):
28
+ *
29
+ * version_metadata_binding_missing error Missing `version_metadata` binding. `service.version` won't be stamped — regressions can't be attributed to a deploy.
30
+ * analytics_engine_binding_missing warn No `DECO_METRICS` AE binding. AE meter is off; OTLP meter still works but CF dashboard panels go dark.
31
+ * tail_consumer_missing error No `tail_consumers` entry pointing at `deco-otel-tail`. 100% error-capture is broken.
32
+ * otel_metrics_endpoint_missing warn `DECO_OTEL_METRICS_ENDPOINT` not set on `vars`. OTLP meter is off; only AE works.
33
+ * otel_traces_endpoint_missing warn `DECO_OTEL_TRACES_ENDPOINT` not set on `vars`. Framework `deco.*` spans drop unless CF Traces is on.
34
+ * otel_logs_endpoint_missing warn `DECO_OTEL_LOGS_ENDPOINT` not set on `vars`. Error logs ride CF Destinations only (head-sampled).
25
35
  *
26
36
  * Usage (from a site directory):
27
- * npx -p @decocms/start deco-audit-observability # audit cwd
28
- * npx -p @decocms/start deco-audit-observability --source ./ # explicit
37
+ * npx -p @decocms/start deco-audit-observability # audit cwd (warn mode — exit 0)
38
+ * npx -p @decocms/start deco-audit-observability --source ./ # explicit source dir
29
39
  * npx -p @decocms/start deco-audit-observability --json # machine-readable
40
+ * npx -p @decocms/start deco-audit-observability --mode block # error findings exit 1 (CI gate)
41
+ * npx -p @decocms/start deco-audit-observability --github # GitHub Actions annotations
30
42
  *
31
43
  * Options:
32
44
  * --source <dir> Site directory (default: .)
33
- * --json Emit findings as JSON to stdout (still exits non-zero on findings)
45
+ * --json Emit findings as JSON to stdout
46
+ * --mode <m> Gate hardness: "warn" (default — always exit 0 on findings,
47
+ * just print them) or "block" (exit 1 on any `error` finding).
48
+ * See D-16 in MIGRATION_TOOLING_PLAN.md for the rationale on
49
+ * why warn is the v1 default.
50
+ * --github Emit `::warning::` / `::error::` lines for GitHub Actions
51
+ * annotations in addition to the normal text output.
34
52
  * --help, -h Show this message
35
53
  *
36
54
  * Exit codes:
37
- * 0 — no findings (or only `info`-level findings; none defined yet)
38
- * 1 — at least one finding (warn or error)
55
+ * 0 — no findings, or `--mode warn` (the default) regardless of findings
56
+ * 1 — `--mode block` and at least one `error`-severity finding
39
57
  * 2 — file invalid / can't parse
40
58
  */
41
59
 
@@ -53,14 +71,24 @@ export interface Finding {
53
71
  fix?: string;
54
72
  }
55
73
 
74
+ export type GateMode = "warn" | "block";
75
+
56
76
  interface CliOpts {
57
77
  source: string;
58
78
  json: boolean;
59
79
  help: boolean;
80
+ mode: GateMode;
81
+ github: boolean;
60
82
  }
61
83
 
62
84
  function parseArgs(argv: string[]): CliOpts {
63
- const opts: CliOpts = { source: ".", json: false, help: false };
85
+ const opts: CliOpts = {
86
+ source: ".",
87
+ json: false,
88
+ help: false,
89
+ mode: "warn",
90
+ github: false,
91
+ };
64
92
  for (let i = 0; i < argv.length; i++) {
65
93
  const flag = argv[i];
66
94
  switch (flag) {
@@ -70,6 +98,20 @@ function parseArgs(argv: string[]): CliOpts {
70
98
  case "--json":
71
99
  opts.json = true;
72
100
  break;
101
+ case "--mode": {
102
+ const value = argv[++i];
103
+ if (value !== "warn" && value !== "block") {
104
+ console.error(
105
+ `audit: --mode must be "warn" or "block" (got "${value ?? ""}")`,
106
+ );
107
+ process.exit(2);
108
+ }
109
+ opts.mode = value;
110
+ break;
111
+ }
112
+ case "--github":
113
+ opts.github = true;
114
+ break;
73
115
  case "--help":
74
116
  case "-h":
75
117
  opts.help = true;
@@ -91,14 +133,18 @@ function showHelp(): void {
91
133
  npx -p @decocms/start deco-audit-observability [options]
92
134
 
93
135
  Options:
94
- --source <dir> Site directory (default: .)
95
- --json Emit findings as JSON
96
- --help, -h This message
136
+ --source <dir> Site directory (default: .)
137
+ --json Emit findings as JSON
138
+ --mode <m> "warn" (default, exit 0) | "block" (exit 1 on errors)
139
+ --github Emit ::warning::/::error:: lines for GitHub Actions
140
+ --help, -h This message
97
141
 
98
142
  Exit codes:
99
- 0 no findings
100
- 1 one or more findings (warn or error)
143
+ 0 no findings, OR --mode warn (default — annotate and move on)
144
+ 1 --mode block AND at least one error-severity finding
101
145
  2 wrangler.jsonc missing or unparseable
146
+
147
+ See D-16 in MIGRATION_TOOLING_PLAN.md for the v1 "warn-only" policy.
102
148
  `);
103
149
  }
104
150
 
@@ -236,6 +282,138 @@ export function auditObservabilityBlock(
236
282
  return findings;
237
283
  }
238
284
 
285
+ /**
286
+ * Fleet-config drift rules — owned by the same audit because the
287
+ * cumulative effect of "observability block correct, bindings missing"
288
+ * is identical to "observability block missing" (no data lands in
289
+ * ClickHouse).
290
+ *
291
+ * The CLI composes `auditObservabilityBlock` + `auditFleetBindings`.
292
+ * Both return Finding[]; callers concatenate.
293
+ */
294
+ export interface WranglerLike {
295
+ observability?: ObservabilityBlock;
296
+ version_metadata?: { binding?: string } | unknown;
297
+ analytics_engine_datasets?: Array<{ binding?: string; dataset?: string }> | unknown;
298
+ tail_consumers?: Array<{ service?: string; environment?: string }> | unknown;
299
+ vars?: Record<string, unknown> | unknown;
300
+ }
301
+
302
+ export function auditFleetBindings(wrangler: WranglerLike): Finding[] {
303
+ const findings: Finding[] = [];
304
+
305
+ // version_metadata — required so `service.version` is stamped on every
306
+ // span and log line. Without it, regressions can't be tied to a
307
+ // specific deployment.
308
+ const vmBinding =
309
+ typeof wrangler.version_metadata === "object" &&
310
+ wrangler.version_metadata !== null &&
311
+ "binding" in wrangler.version_metadata
312
+ ? (wrangler.version_metadata as { binding?: string }).binding
313
+ : undefined;
314
+ if (!vmBinding) {
315
+ findings.push({
316
+ id: "version_metadata_binding_missing",
317
+ severity: "error",
318
+ message:
319
+ "wrangler.jsonc is missing a `version_metadata.binding` entry. " +
320
+ "`service.version` won't appear on spans/logs and the deploy-correlation " +
321
+ "panel will be empty. Recommended value: `CF_VERSION_METADATA`.",
322
+ fix: "npx -p @decocms/start deco-cf-observability --write",
323
+ });
324
+ }
325
+
326
+ // DECO_METRICS — Analytics Engine binding. The AE meter is the hot-
327
+ // path CF dashboard view; OTLP works without it but we lose the
328
+ // operator-grade short-window panels.
329
+ const aeDatasets = Array.isArray(wrangler.analytics_engine_datasets)
330
+ ? (wrangler.analytics_engine_datasets as Array<{ binding?: string }>)
331
+ : [];
332
+ const hasMetricsBinding = aeDatasets.some((d) => d?.binding === "DECO_METRICS");
333
+ if (!hasMetricsBinding) {
334
+ findings.push({
335
+ id: "analytics_engine_binding_missing",
336
+ severity: "warn",
337
+ message:
338
+ "wrangler.jsonc has no `analytics_engine_datasets[].binding == 'DECO_METRICS'`. " +
339
+ "The AE meter is off; the hot-path operator dashboards in CF will be empty. " +
340
+ "OTLP metrics keep flowing if `DECO_OTEL_METRICS_ENDPOINT` is set.",
341
+ fix: "npx -p @decocms/start deco-cf-observability --write",
342
+ });
343
+ }
344
+
345
+ // tail_consumers — must list deco-otel-tail. Phase 1 enrichment is
346
+ // useless without the tail consumer firing.
347
+ const tail = Array.isArray(wrangler.tail_consumers)
348
+ ? (wrangler.tail_consumers as Array<{ service?: string }>)
349
+ : [];
350
+ const hasTailConsumer = tail.some((t) => t?.service === "deco-otel-tail");
351
+ if (!hasTailConsumer) {
352
+ findings.push({
353
+ id: "tail_consumer_missing",
354
+ severity: "error",
355
+ message:
356
+ "wrangler.jsonc has no `tail_consumers[].service == 'deco-otel-tail'` entry. " +
357
+ "100% error-capture is broken — only the head-sampled CF Destinations path " +
358
+ "will report errors, and isolate crashes will be invisible.",
359
+ fix: "npx -p @decocms/start deco-cf-observability --write",
360
+ });
361
+ }
362
+
363
+ // DECO_OTEL_*_ENDPOINT env vars. Audit each separately so the message
364
+ // explains which channel is silently no-op.
365
+ const vars =
366
+ typeof wrangler.vars === "object" && wrangler.vars !== null
367
+ ? (wrangler.vars as Record<string, unknown>)
368
+ : {};
369
+ const checkVar = (id: string, name: string, severity: Severity, channel: string) => {
370
+ const v = vars[name];
371
+ if (typeof v !== "string" || v.length === 0) {
372
+ findings.push({
373
+ id,
374
+ severity,
375
+ message:
376
+ `wrangler.jsonc \`vars.${name}\` is not set. ${channel} is a no-op; ` +
377
+ `data captured in this channel never lands in ClickHouse. ` +
378
+ `See docs/observability.md for the canonical endpoints.`,
379
+ fix: "npx -p @decocms/start deco-cf-observability --write",
380
+ });
381
+ }
382
+ };
383
+ checkVar(
384
+ "otel_metrics_endpoint_missing",
385
+ "DECO_OTEL_METRICS_ENDPOINT",
386
+ "warn",
387
+ "OTLP metrics direct-POST",
388
+ );
389
+ checkVar(
390
+ "otel_traces_endpoint_missing",
391
+ "DECO_OTEL_TRACES_ENDPOINT",
392
+ "warn",
393
+ "OTLP traces direct-POST",
394
+ );
395
+ checkVar(
396
+ "otel_logs_endpoint_missing",
397
+ "DECO_OTEL_LOGS_ENDPOINT",
398
+ "warn",
399
+ "OTLP error-log direct-POST",
400
+ );
401
+
402
+ return findings;
403
+ }
404
+
405
+ /**
406
+ * One-stop call for the full wrangler audit — composes the
407
+ * observability-block rules with the fleet-binding rules. Both keep
408
+ * working standalone for fine-grained tests.
409
+ */
410
+ export function auditWranglerConfig(wrangler: WranglerLike): Finding[] {
411
+ return [
412
+ ...auditObservabilityBlock(wrangler.observability),
413
+ ...auditFleetBindings(wrangler),
414
+ ];
415
+ }
416
+
239
417
  function findingsToText(file: string, findings: Finding[]): string {
240
418
  if (findings.length === 0) {
241
419
  return `OK ${file} — observability config looks canonical`;
@@ -263,26 +441,49 @@ function main(): void {
263
441
  process.exit(2);
264
442
  }
265
443
 
266
- let parsed: { observability?: ObservabilityBlock };
444
+ let parsed: WranglerLike;
267
445
  try {
268
- parsed = parseJsonc(fs.readFileSync(file, "utf8"));
446
+ parsed = parseJsonc(fs.readFileSync(file, "utf8")) as WranglerLike;
269
447
  } catch (err) {
270
448
  console.error(`audit: ${file} could not be parsed: ${(err as Error).message}`);
271
449
  process.exit(2);
272
450
  }
273
451
 
274
- const findings = auditObservabilityBlock(parsed.observability);
452
+ const findings = auditWranglerConfig(parsed);
275
453
 
276
454
  if (opts.json) {
277
- process.stdout.write(JSON.stringify({ file, findings }, null, 2) + "\n");
455
+ process.stdout.write(
456
+ JSON.stringify({ file, mode: opts.mode, findings }, null, 2) + "\n",
457
+ );
278
458
  } else {
279
459
  process.stdout.write(findingsToText(file, findings) + "\n");
280
460
  }
281
461
 
282
- // Any finding (warn or error) is a non-zero exit. info-severity would not
283
- // flip the exit, but no info-severity rules are defined yet.
284
- const blocking = findings.some((f) => f.severity !== "info");
285
- process.exit(blocking ? 1 : 0);
462
+ if (opts.github) {
463
+ for (const f of findings) {
464
+ // GitHub Actions workflow command. `error` and `warning` annotate the
465
+ // diff; `notice` is informational. We never emit `error` in warn mode
466
+ // even for error-severity rules — the v1 policy is annotate-don't-fail.
467
+ const level = opts.mode === "block" && f.severity === "error"
468
+ ? "error"
469
+ : f.severity === "info" ? "notice" : "warning";
470
+ const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`;
471
+ const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(
472
+ /\n/g,
473
+ "%0A",
474
+ );
475
+ process.stdout.write(`::${level} title=${f.id}::${escaped}\n`);
476
+ }
477
+ }
478
+
479
+ // Exit policy: D-16 / Phase 6 decision.
480
+ // warn — annotate only; always exit 0 (CI sees the findings but ships)
481
+ // block — exit 1 on any `error`-severity finding
482
+ // The default is `warn` because storefronts are upgraded over weeks; a
483
+ // day-one block would fail PRs that have nothing to do with observability.
484
+ const shouldFail = opts.mode === "block" &&
485
+ findings.some((f) => f.severity === "error");
486
+ process.exit(shouldFail ? 1 : 0);
286
487
  }
287
488
 
288
489
  // Only run when invoked directly, not when imported by tests.
@@ -1578,12 +1578,101 @@ const rulePackageManagerMissing: Rule = {
1578
1578
  },
1579
1579
  };
1580
1580
 
1581
+ /* ------------------------------------------------------------------ */
1582
+ /* Rule N — `vtex-proxy-handler-missing` — worker-entry without proxy */
1583
+ /* ------------------------------------------------------------------ */
1584
+
1585
+ /**
1586
+ * Every VTEX storefront on @decocms/start needs a reverse proxy for
1587
+ * `/checkout/*`, `/account/*`, `/api/*`, `/files/*`, etc. — those paths
1588
+ * must hit the VTEX origin (not TanStack Start) so the user lands on
1589
+ * the real checkout UI carrying their VTEX session cookies.
1590
+ *
1591
+ * The canonical wiring lives in `src/worker-entry.ts` via
1592
+ * `createDecoWorkerEntry(..., { proxyHandler })`, where the handler
1593
+ * calls `shouldProxyToVtex(url.pathname)` + a `createVtexCheckoutProxy`
1594
+ * instance. The migration scaffold (`scripts/migrate/templates/server-entry.ts`)
1595
+ * emits this by default for VTEX sites, but two regressions sneak it out:
1596
+ *
1597
+ * 1. A site migrated by a pre-scaffold version of the script (e.g.
1598
+ * before the VTEX worker-entry template existed).
1599
+ * 2. Someone refactors `worker-entry.ts` and drops the proxy block.
1600
+ *
1601
+ * Without the proxy, add-to-cart still "succeeds" (the action runs
1602
+ * server-side via TanStack RPC), but clicking "Finalizar" navigates
1603
+ * to `/checkout` on the storefront — which returns the SPA shell —
1604
+ * and the user never reaches VTEX checkout. The VTEX-side orderForm
1605
+ * lives at vtexcommercestable.com.br with no way to see it.
1606
+ *
1607
+ * Detection is cheap: VTEX sites should import `shouldProxyToVtex`
1608
+ * (or wire a `proxyHandler:` to `createDecoWorkerEntry`). We flag
1609
+ * absence as `info` (not warning) because old in-prod sites we
1610
+ * deliberately don't touch would otherwise stay noisy.
1611
+ */
1612
+ const ruleVtexProxyHandlerMissing: Rule = {
1613
+ id: "vtex-proxy-handler-missing",
1614
+ title: "VTEX worker-entry missing the checkout/API proxy handler",
1615
+ run({ siteDir, fs }: RuleContext): Finding[] {
1616
+ // Only run when the site is actually VTEX. The cheapest signal is
1617
+ // any import from `@decocms/apps/vtex/...` in src/ — every VTEX
1618
+ // site has at least one (commerceLoaders, hooks, types, etc.).
1619
+ const srcFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1620
+ const isVtex = srcFiles.some((abs) =>
1621
+ fs.readText(abs).includes("@decocms/apps/vtex"),
1622
+ );
1623
+ if (!isVtex) return [];
1624
+
1625
+ const workerEntryAbs = `${siteDir}/src/worker-entry.ts`;
1626
+ if (!fs.exists(workerEntryAbs)) {
1627
+ return [
1628
+ {
1629
+ rule: "vtex-proxy-handler-missing",
1630
+ severity: "info",
1631
+ file: "src/worker-entry.ts",
1632
+ message:
1633
+ "VTEX site has no src/worker-entry.ts — /checkout proxy can't run, the user will see the SPA shell instead of VTEX checkout",
1634
+ fix: "Re-run `deco-migrate` (the scaffold emits a worker-entry with createVtexCheckoutProxy), or copy from scripts/migrate/templates/server-entry.ts",
1635
+ },
1636
+ ];
1637
+ }
1638
+
1639
+ const content = fs.readText(workerEntryAbs);
1640
+ // Match either symbol — sites use the factory function OR the
1641
+ // shouldProxyToVtex predicate as the entry point. Presence of
1642
+ // either is a strong signal the proxy block exists; absence of
1643
+ // both means it was dropped.
1644
+ const hasProxyImport =
1645
+ /from\s+["']@decocms\/apps\/vtex\/utils\/proxy["']/.test(content);
1646
+ // Match both long form (`proxyHandler: async (...) => ...`) and
1647
+ // object-shorthand wiring (`{ proxyHandler }`, `{ proxyHandler, admin }`).
1648
+ // The anchor `[{,]` requires the identifier to appear as a property —
1649
+ // not as a bare `const proxyHandler = ...` declaration, which is
1650
+ // followed by `=` and wouldn't match either branch.
1651
+ const hasProxyHandler = /[{,]\s*proxyHandler\s*[:,}]/.test(content);
1652
+
1653
+ if (hasProxyImport && hasProxyHandler) return [];
1654
+
1655
+ return [
1656
+ {
1657
+ rule: "vtex-proxy-handler-missing",
1658
+ severity: "info",
1659
+ file: "src/worker-entry.ts",
1660
+ message: hasProxyImport
1661
+ ? "imports proxy helpers but no `proxyHandler:` is wired into createDecoWorkerEntry — /checkout requests will fall through to TanStack and render the SPA shell"
1662
+ : "no `@decocms/apps/vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin",
1663
+ fix: "Add `proxyHandler` to createDecoWorkerEntry; see scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry) for the canonical block",
1664
+ },
1665
+ ];
1666
+ },
1667
+ };
1668
+
1581
1669
  export const ALL_RULES: Rule[] = [
1582
1670
  ruleDeadLibShims,
1583
1671
  ruleObsoleteVitePlugins,
1584
1672
  ruleDeadRuntimeShim,
1585
1673
  ruleSiteLocalGlobals,
1586
1674
  ruleVtexShimRegression,
1675
+ ruleVtexProxyHandlerMissing,
1587
1676
  ruleLocalWidgetsTypes,
1588
1677
  ruleFrameworkTodos,
1589
1678
  ruleLocalFrameworkDuplicate,
@@ -1606,6 +1695,7 @@ export const _internals = {
1606
1695
  ruleDeadRuntimeShim,
1607
1696
  ruleSiteLocalGlobals,
1608
1697
  ruleVtexShimRegression,
1698
+ ruleVtexProxyHandlerMissing,
1609
1699
  ruleHtmxResidue,
1610
1700
  ruleLocalWidgetsTypes,
1611
1701
  ruleFrameworkTodos,
@@ -628,6 +628,109 @@ describe("rule: framework-todos", () => {
628
628
  });
629
629
  });
630
630
 
631
+ describe("rule: vtex-proxy-handler-missing", () => {
632
+ // Canonical wiring matching scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry):
633
+ // imports from @decocms/apps/vtex/utils/proxy and passes proxyHandler to createDecoWorkerEntry.
634
+ const okWorkerEntry = `
635
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
636
+ import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
637
+ const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
638
+ export default createDecoWorkerEntry(serverEntry, {
639
+ proxyHandler: async (req, url) => {
640
+ if (!shouldProxyToVtex(url.pathname)) return null;
641
+ return proxy(req, url);
642
+ },
643
+ });
644
+ `;
645
+
646
+ it("does not flag non-VTEX sites", () => {
647
+ const fs = makeFs({
648
+ "/site/src/index.ts": "export const x = 1;",
649
+ // Worker entry intentionally without the proxy import — non-VTEX
650
+ // sites don't need it and shouldn't be flagged.
651
+ "/site/src/worker-entry.ts": "export default {};",
652
+ });
653
+ const report = runAudit(SITE, fs);
654
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
655
+ expect(r.findings).toEqual([]);
656
+ });
657
+
658
+ it("does not flag VTEX site whose worker-entry has the proxyHandler wired", () => {
659
+ const fs = makeFs({
660
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
661
+ "/site/src/worker-entry.ts": okWorkerEntry,
662
+ });
663
+ const report = runAudit(SITE, fs);
664
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
665
+ expect(r.findings).toEqual([]);
666
+ });
667
+
668
+ it("flags VTEX site missing src/worker-entry.ts entirely", () => {
669
+ const fs = makeFs({
670
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
671
+ });
672
+ const report = runAudit(SITE, fs);
673
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
674
+ expect(r.findings).toHaveLength(1);
675
+ expect(r.findings[0].severity).toBe("info");
676
+ expect(r.findings[0].message).toContain("no src/worker-entry.ts");
677
+ });
678
+
679
+ it("flags VTEX site whose worker-entry omits the proxy import", () => {
680
+ const fs = makeFs({
681
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
682
+ "/site/src/worker-entry.ts": `
683
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
684
+ export default createDecoWorkerEntry(serverEntry, { admin: {} });
685
+ `,
686
+ });
687
+ const report = runAudit(SITE, fs);
688
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
689
+ expect(r.findings).toHaveLength(1);
690
+ expect(r.findings[0].message).toContain("no `@decocms/apps/vtex/utils/proxy` import");
691
+ });
692
+
693
+ it("does not flag VTEX site using object-shorthand proxyHandler wiring", () => {
694
+ // Hand-written worker entries commonly hoist proxyHandler to a top-level
695
+ // const and pass it via shorthand. The detector must treat
696
+ // `{ proxyHandler }`, `{ proxyHandler, admin }`, and `{ admin, proxyHandler }`
697
+ // the same as `proxyHandler: ...` — otherwise the audit cries wolf.
698
+ const shorthandWorkerEntry = `
699
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
700
+ import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
701
+ const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
702
+ const proxyHandler = async (req: Request, url: URL) => {
703
+ if (!shouldProxyToVtex(url.pathname)) return null;
704
+ return proxy(req, url);
705
+ };
706
+ export default createDecoWorkerEntry(serverEntry, { admin: {}, proxyHandler });
707
+ `;
708
+ const fs = makeFs({
709
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
710
+ "/site/src/worker-entry.ts": shorthandWorkerEntry,
711
+ });
712
+ const report = runAudit(SITE, fs);
713
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
714
+ expect(r.findings).toEqual([]);
715
+ });
716
+
717
+ it("flags VTEX site that imports proxy helpers but never wires proxyHandler", () => {
718
+ const fs = makeFs({
719
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
720
+ "/site/src/worker-entry.ts": `
721
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
722
+ import { shouldProxyToVtex } from "@decocms/apps/vtex/utils/proxy";
723
+ // note: shouldProxyToVtex imported but not used in a proxyHandler.
724
+ export default createDecoWorkerEntry(serverEntry, { admin: {} });
725
+ `,
726
+ });
727
+ const report = runAudit(SITE, fs);
728
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
729
+ expect(r.findings).toHaveLength(1);
730
+ expect(r.findings[0].message).toContain("no `proxyHandler:` is wired");
731
+ });
732
+ });
733
+
631
734
  describe("internals", () => {
632
735
  it("extractExports parses common forms (top-level, unindented)", () => {
633
736
  const code = [
@@ -255,6 +255,19 @@ function bootstrap(ctx: { sourceDir: string }) {
255
255
  const pm = "bun";
256
256
  if (!run(`${pm} install`, "Install dependencies", true)) return;
257
257
  run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
258
+ // generate-invoke emits src/server/invoke.gen.ts with top-level
259
+ // createServerFn declarations + the forwardResponseCookies bridge that
260
+ // propagates VTEX Set-Cookie headers (orderFormId, segment, sc…) to the
261
+ // browser. Without this file, the site falls back to the proxy
262
+ // `~/runtime.ts` route which hits /deco/invoke and used to drop cookies,
263
+ // making the cart appear empty at /checkout after addItemToCart. The
264
+ // upstream invoke handler now also forwards cookies correctly, but
265
+ // running the generator gives every freshly-migrated site the canonical
266
+ // RPC path so VTEX hooks (useCart, useUser, useWishlist) work end-to-end.
267
+ run(
268
+ "bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts",
269
+ "Generate VTEX invoke server functions",
270
+ );
258
271
  run("bunx tsr generate", "Generate TanStack routes");
259
272
 
260
273
  if (failures > 0) {