@decocms/start 2.23.0 → 2.24.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.
@@ -18,8 +18,10 @@ npx -p @decocms/start deco-post-cleanup
18
18
 
19
19
  # Auto-apply mechanical fixes for the safe rules, then report what's left.
20
20
  # Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types,
21
- # vtex-shim-regression (swap subset), obsolete-vite-plugins.
22
- # Other rules stay detect-only they require human judgment.
21
+ # vtex-shim-regression (swap subset), obsolete-vite-plugins,
22
+ # local-framework-duplicate (auto-fixable subset of the registry).
23
+ # Other rules — and the warn-only entries of local-framework-duplicate —
24
+ # stay detect-only. They require human judgment.
23
25
  npx -p @decocms/start deco-post-cleanup --fix
24
26
 
25
27
  # Combine for CI: auto-fix safe rules, fail (exit 2) if warnings remain.
@@ -29,14 +31,16 @@ npx -p @decocms/start deco-post-cleanup --fix --strict
29
31
  npx -p @decocms/start deco-post-cleanup --json
30
32
  ```
31
33
 
32
- The audit covers all 7 rules below and prints the exact file path +
34
+ The audit covers all 9 rules below and prints the exact file path +
33
35
  suggested fix for each finding. With `--fix`, the safe rules
34
36
  auto-apply: `rm` for dead files, regex-anchored import rewrites for
35
37
  shadowed shims (`local-widgets-types`, `dead-runtime-shim`), the swap
36
- subset of `vtex-shim-regression`, and JS-aware removal of obsolete
37
- inline plugin literals from `vite.config.ts`. The output explicitly
38
- tags rules that require manual work as `(0 fixed, manual)`, so you
39
- always know what's left after auto-fix runs.
38
+ subset of `vtex-shim-regression`, JS-aware removal of obsolete
39
+ inline plugin literals from `vite.config.ts`, and rewrite-imports +
40
+ delete for the auto-fixable subset of `local-framework-duplicate`
41
+ (see § 8). The output explicitly tags rules that require manual work
42
+ as `(0 fixed, manual)`, so you always know what's left after auto-fix
43
+ runs.
40
44
 
41
45
  Real-world signal: on baggagio, `--fix` produced a byte-identical
42
46
  diff to the manual cleanup PR a human had just made (45 files,
@@ -398,7 +402,58 @@ In `--strict` mode any residue exits 2 — wire that into CI once a
398
402
  site has finished its HTMX rewrite to prevent regressions sneaking
399
403
  back in via copy-paste from a Fresh source.
400
404
 
401
- ## 8. Search for orphan `TODO: move into framework` comments
405
+ ## 8. Drop site-local copies of framework code (`local-framework-duplicate`)
406
+
407
+ The audit's `local-framework-duplicate` rule encodes a registry of
408
+ files we expect sites to NOT carry locally because the canonical
409
+ implementation already ships in `@decocms/start`. New entries go in
410
+ `scripts/migrate/post-cleanup/rules.ts → FRAMEWORK_DUPLICATES`.
411
+
412
+ Two kinds of finding:
413
+
414
+ | Kind | Auto-fix | Example | What you do |
415
+ |---|---|---|---|
416
+ | **Pure dup** (`safeToAutoFix: true`) | YES | `src/sdk/clx.ts` matches `@decocms/start/sdk/clx` byte-for-byte | `--fix` rewrites every `from "~/sdk/clx"` to `from "@decocms/start/sdk/clx"` and deletes the file. Zero behavior change. |
417
+ | **Partial overlap** (`safeToAutoFix: false`) | NO | `src/sdk/useSendEvent.ts` (typed) overlaps `@decocms/start/sdk/analytics → useSendEvent` (permissive) | The rule emits a `warning` with a `reason` explaining the manual judgement: widen the framework export, accept type loss, or fork on purpose. Human picks. |
418
+
419
+ ### How the rule fires
420
+
421
+ The site file must match every regex in `contentSignature` before
422
+ the rule treats it as the framework dup. This is conservative on
423
+ purpose — sites that genuinely forked the helper (added platform
424
+ logic, wrapped in something else) are skipped automatically.
425
+
426
+ ### Current registry
427
+
428
+ | Site path | Canonical | Auto-fix? | Reason / status |
429
+ |---|---|---|---|
430
+ | `src/sdk/clx.ts` | `@decocms/start/sdk/clx` | yes | Identical implementation; baggagio's extra `clsx` alias has zero callers. |
431
+ | `src/sdk/useSendEvent.ts` | `@decocms/start/sdk/analytics` | no | Site copy uses `<E extends AnalyticsEvent>` generic; framework export is permissive. Replace 1:1 = type-safety loss. Either widen the framework first or accept the loss. |
432
+ | `src/matchers/location.ts` | `@decocms/start/matchers/builtins` | no | Framework's `registerBuiltinMatchers()` ships a richer location matcher (`request.cf` first, geo cookies fallback, headers fallback) plus 10 sibling matchers. Adopting changes behaviour — verify country-name lookup parity, swap `setup.ts`'s `customMatchers` entry. |
433
+
434
+ ### Adding a new entry
435
+
436
+ When you spot a site carrying its own copy of code that lives in
437
+ `@decocms/start`, add an entry to `FRAMEWORK_DUPLICATES`:
438
+
439
+ ```ts
440
+ {
441
+ id: "<short-stable-id>",
442
+ sitePath: "src/<path>.ts",
443
+ canonicalImport: "@decocms/start/<path>",
444
+ contentSignature: [/<regex 1>/, /<regex 2>/],
445
+ safeToAutoFix: true | false,
446
+ reason: "<required when not safeToAutoFix>",
447
+ description: "<one-liner used in finding message>",
448
+ }
449
+ ```
450
+
451
+ Per **D4** in the migration tooling policy, the framework promotion
452
+ itself happens at 3+ sites. This registry is the *enforcement* layer
453
+ once promoted: every other site picks up the convergence
454
+ automatically the next time `deco-post-cleanup --fix` runs.
455
+
456
+ ## 9. Search for orphan `TODO: move into framework` comments
402
457
 
403
458
  Real sites accumulate `TODO` comments like `// TODO: move into decoVitePlugin
404
459
  in next @decocms/start release`. These are roadmap items the framework
@@ -415,7 +470,7 @@ For each hit, decide:
415
470
 
416
471
  ## Verification checklist
417
472
 
418
- After completing 1-8:
473
+ After completing 1-9:
419
474
 
420
475
  - [ ] `npm run typecheck` baseline matches pre-cleanup count (no new errors)
421
476
  - [ ] `npm run dev` starts and `/`, `/some-pdp/p`, `/s?q=foo` all render
@@ -1128,6 +1128,97 @@ had no skill coverage. Wave 15-A closes both loops in one PR.
1128
1128
  `useSendEvent`/`clx`/location-matcher imports, `relative()`
1129
1129
  SKU-stripping extension. Sequenced after 15-A merges.
1130
1130
 
1131
+ ### Wave 15-B-1 (cross-site convergence — `local-framework-duplicate` audit rule) — 🟡 **IN FLIGHT**
1132
+
1133
+ First slice of the cross-site-convergence backlog deferred from
1134
+ Wave 15-A. Concrete data first (per the user-rule "verify before
1135
+ designing"): the `useSendEvent`/`clx`/location-matcher promotion
1136
+ turned out to be *not* a "promote site code → framework" exercise.
1137
+ The framework already has each helper. The work is **enforcing the
1138
+ existing canonical** when sites carry their own copy.
1139
+
1140
+ Verified state (2026-05-01 grep against both sites):
1141
+
1142
+ | Item | casaevideo | baggagio | Action |
1143
+ |---|---|---|---|
1144
+ | `src/sdk/clx.ts` | absent (already canonical) | present, identical body + dead `clsx` alias (zero callers) | pure dup → **auto-fix** |
1145
+ | `src/sdk/useSendEvent.ts` | absent | present, **stricter** typing (`<E extends AnalyticsEvent>` generic) vs framework's permissive shape | **warn-only** (replacing 1:1 weakens types) |
1146
+ | `src/matchers/location.ts` | present, cookie-only subset of framework | absent | **warn-only** (framework's `registerBuiltinMatchers()` is a behavior superset; needs per-site verification of country-name lookup parity) |
1147
+
1148
+ So this is exactly *one* mechanically-applicable fix (`clx` in
1149
+ baggagio) plus two judgement calls. Hand-applying would be cheap;
1150
+ the value is making the audit *enforce* the convergence so the next
1151
+ copy-paste regression on any future site gets caught automatically.
1152
+
1153
+ **Shipped (one PR against `decocms/deco-start`):**
1154
+
1155
+ 38. `feat(migrate): local-framework-duplicate audit rule with registry-driven enforcement` 🟡 **WAITING ON CI**
1156
+ - **New rule `local-framework-duplicate`** in
1157
+ `scripts/migrate/post-cleanup/rules.ts` driven by an exported
1158
+ `FRAMEWORK_DUPLICATES` registry. Each entry is `{ id,
1159
+ sitePath, canonicalImport, contentSignature: RegExp[],
1160
+ safeToAutoFix, reason?, description }`. The rule fires only
1161
+ when **every** content-signature regex matches the site file
1162
+ — conservative on purpose so genuinely-forked helpers are
1163
+ skipped.
1164
+ - **Auto-fix path** (when `safeToAutoFix: true`): rewrite all
1165
+ `from "~/<derived>"` importers to `from
1166
+ "<canonicalImport>"` via the existing `rewriteImportSpec`
1167
+ helper, then delete the file. Already-canonical importers
1168
+ are left untouched.
1169
+ - **Warn-only path** (when `safeToAutoFix: false`): rule still
1170
+ fires + populates the finding's `fix:` field with the
1171
+ `reason` so engineers see *why* auto-fix is gated and what
1172
+ they need to verify before manual cleanup.
1173
+ - **Three initial entries** in the registry, mapped 1:1 to the
1174
+ cross-site audit findings:
1175
+ | id | site path | canonical | auto-fix? |
1176
+ |---|---|---|---|
1177
+ | `clx` | `src/sdk/clx.ts` | `@decocms/start/sdk/clx` | **yes** |
1178
+ | `use-send-event` | `src/sdk/useSendEvent.ts` | `@decocms/start/sdk/analytics` | no (typing regression) |
1179
+ | `location-matcher` | `src/matchers/location.ts` | `@decocms/start/matchers/builtins` | no (behavior superset, parity check needed) |
1180
+ - **11 new tests** covering: pure-dup detection, fork detection
1181
+ (signature mismatch → no flag), warn-only entries, severity
1182
+ uniformity (warning for both kinds, so `--strict` gates
1183
+ everything), auto-fix happy-path (delete + rewrite both
1184
+ importers, leave canonical importers alone), warn-only
1185
+ auto-fix is a no-op (does NOT delete partial-overlap files),
1186
+ mixed coexistence (auto-fixable `clx` and warn-only
1187
+ `useSendEvent` in the same tree → only `clx` gets auto-fixed),
1188
+ `supportsAutoFix` flag is true (since rule has `applyFix`).
1189
+ - **CLI help text + `post-migration-cleanup.md` § 8** updated
1190
+ with the new rule's table and the "adding a new entry"
1191
+ section. Old § 8 (orphan TODO comments) renumbered to § 9.
1192
+ - 345 → 353 tests pass, typecheck clean, end-to-end disk smoke
1193
+ against a temp fixture confirmed: 2 importers rewritten + 1
1194
+ file deleted in one `--fix` run.
1195
+ - **Real-site smoke**:
1196
+ - **baggagio**: rule fires twice — `clx.ts` (auto-fixable),
1197
+ `useSendEvent.ts` (warn-only with the typed-generic reason).
1198
+ - **casaevideo**: rule fires once — `location.ts` (warn-only
1199
+ with the `registerBuiltinMatchers()` adoption hint).
1200
+ - **Net**: every future site that copy-pastes any of these three
1201
+ files gets a tight audit finding + auto-fix on the safe one.
1202
+ The registry pattern means adding a 4th cross-site duplicate
1203
+ is a single object literal — no new rule, no new tests
1204
+ scaffolding, no new doc section.
1205
+
1206
+ **Still in the cross-site backlog (sequenced behind 15-B-1):**
1207
+
1208
+ - **15-B-2** — `useSuggestions` framework helper (new export in
1209
+ `@decocms/start/sdk` typed by `Resolved<T>`, optional Sentry
1210
+ hook). Sites adopt incrementally; once 2+ adopt, add a registry
1211
+ entry pointing the legacy hand-rolled implementations at the
1212
+ canonical via `local-framework-duplicate`.
1213
+ - **15-B-3** — `useOffer` factory (D4 candidate; needs design pass
1214
+ for PIX/installment plugin slots).
1215
+ - **15-B-4** — `Picture` API unification (breaking; needs a
1216
+ picking-the-winner pass between casaevideo's and baggagio's
1217
+ shapes, plus a codemod for call sites).
1218
+ - **15-B-5** — `relative()` SKU-stripping option in
1219
+ `@decocms/apps/commerce/sdk/url`. Apps-side change; sites delete
1220
+ their wrapper.
1221
+
1131
1222
  ### Wave 15+ (htmx cleanup PRs on als + propagation to other sites) — Priority 3 / 4
1132
1223
 
1133
1224
  Each htmx pattern that survives the codemod becomes a per-pattern PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.23.0",
3
+ "version": "2.24.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",
@@ -943,6 +943,181 @@ const ruleFrameworkTodos: Rule = {
943
943
  },
944
944
  };
945
945
 
946
+ /* ------------------------------------------------------------------ */
947
+ /* Rule — `local-framework-duplicate` — site-local copy of fwk code */
948
+ /* ------------------------------------------------------------------ */
949
+
950
+ /**
951
+ * Registry of files we expect sites to NOT carry locally because the
952
+ * canonical implementation already lives in `@decocms/start` (or a
953
+ * sibling apps package).
954
+ *
955
+ * Two flavours:
956
+ * - `safeToAutoFix: true` — site file is a behaviour-equivalent dup
957
+ * of the framework export. `--fix` rewrites every `from "~/<path>"`
958
+ * importer to `from "<canonicalImport>"` and deletes the file.
959
+ * - `safeToAutoFix: false` — site file *overlaps* with framework code
960
+ * but isn't a clean drop-in (different typing, partial coverage,
961
+ * stricter behaviour, etc.). The rule still flags it so the entry
962
+ * surfaces in audits, but never deletes — the `reason` explains why
963
+ * a human has to make the call.
964
+ *
965
+ * `contentSignature` regexes ALL must match the site file's contents
966
+ * before the rule fires. They are deliberately specific enough to
967
+ * avoid catching forks that happen to share a filename but have
968
+ * diverged.
969
+ */
970
+ interface FrameworkDuplicate {
971
+ /** Stable id surfaced in finding meta and CLI/JSON output. */
972
+ id: string;
973
+ /** Site-relative path of the duplicated file (e.g. "src/sdk/clx.ts"). */
974
+ sitePath: string;
975
+ /** Canonical import to rewrite to. */
976
+ canonicalImport: string;
977
+ /**
978
+ * Heuristic content fingerprint. The site file must match every
979
+ * regex for the rule to consider it the framework dup.
980
+ */
981
+ contentSignature: RegExp[];
982
+ /**
983
+ * When true, the rule's `applyFix` will rewrite all importers and
984
+ * delete the file. When false, the rule emits a warning only —
985
+ * `reason` explains the manual judgement required.
986
+ */
987
+ safeToAutoFix: boolean;
988
+ /**
989
+ * Required when `safeToAutoFix: false`. Surfaces in the finding's
990
+ * `fix:` field so users see *why* the auto-fix is gated.
991
+ */
992
+ reason?: string;
993
+ /**
994
+ * Human-readable one-liner shown in the finding message and used
995
+ * to compose the `fix:` hint when auto-fixable.
996
+ */
997
+ description: string;
998
+ }
999
+
1000
+ /**
1001
+ * Add an entry here when:
1002
+ * - 1+ migrated sites carry their own copy of code that already
1003
+ * exists in `@decocms/start` (or a sibling apps package), AND
1004
+ * - the canonical version is at least feature-equivalent.
1005
+ *
1006
+ * Per D4 in the migration tooling policy, the framework promotion
1007
+ * itself happens at 3+ sites — but once promoted, this registry is
1008
+ * how we *enforce* convergence on the remaining sites.
1009
+ */
1010
+ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
1011
+ {
1012
+ id: "clx",
1013
+ sitePath: "src/sdk/clx.ts",
1014
+ canonicalImport: "@decocms/start/sdk/clx",
1015
+ contentSignature: [
1016
+ /export\s+const\s+clx\s*=/,
1017
+ /args\.filter\(Boolean\)\.join/,
1018
+ ],
1019
+ safeToAutoFix: true,
1020
+ description: "src/sdk/clx.ts duplicates @decocms/start/sdk/clx",
1021
+ },
1022
+ {
1023
+ id: "use-send-event",
1024
+ sitePath: "src/sdk/useSendEvent.ts",
1025
+ canonicalImport: "@decocms/start/sdk/analytics",
1026
+ contentSignature: [
1027
+ /export\s+(?:const|function)\s+useSendEvent/,
1028
+ /data-event/,
1029
+ /encodeURIComponent/,
1030
+ ],
1031
+ safeToAutoFix: false,
1032
+ reason:
1033
+ "site copy uses a typed AnalyticsEvent generic; the framework export is permissive. " +
1034
+ "Replacing 1:1 weakens type-safety. Either widen the framework export (preferred), or " +
1035
+ "rewrite call sites to drop the generic. Manual review required.",
1036
+ description:
1037
+ "src/sdk/useSendEvent.ts overlaps with @decocms/start/sdk/analytics → useSendEvent",
1038
+ },
1039
+ {
1040
+ id: "location-matcher",
1041
+ sitePath: "src/matchers/location.ts",
1042
+ canonicalImport: "@decocms/start/matchers/builtins",
1043
+ contentSignature: [
1044
+ /registerMatcher\(\s*['"]website\/matchers\/location\.ts['"]/,
1045
+ /__cf_geo/,
1046
+ ],
1047
+ safeToAutoFix: false,
1048
+ reason:
1049
+ "framework's registerBuiltinMatchers() ships a richer location matcher (request.cf + " +
1050
+ "geo cookies + headers + 10 sibling matchers). Adopting it changes behaviour: " +
1051
+ "verify country-name lookup parity (resolveCountryCode vs site's inline table) and " +
1052
+ "swap setup.ts's customMatchers entry to call registerBuiltinMatchers().",
1053
+ description:
1054
+ "src/matchers/location.ts overlaps with @decocms/start/matchers/builtins → registerBuiltinMatchers()",
1055
+ },
1056
+ ];
1057
+
1058
+ const ruleLocalFrameworkDuplicate: Rule = {
1059
+ id: "local-framework-duplicate",
1060
+ title: "Site-local copy of framework code",
1061
+ run({ siteDir, fs }: RuleContext): Finding[] {
1062
+ const findings: Finding[] = [];
1063
+ for (const dup of FRAMEWORK_DUPLICATES) {
1064
+ const abs = `${siteDir}/${dup.sitePath}`;
1065
+ if (!fs.exists(abs)) continue;
1066
+ const content = fs.readText(abs);
1067
+ const matchesAll = dup.contentSignature.every((re) => re.test(content));
1068
+ if (!matchesAll) continue;
1069
+
1070
+ const fixMessage = dup.safeToAutoFix
1071
+ ? `Auto-fixable: rewrite \`from "~/${stripExt(dup.sitePath.replace(/^src\//, ""))}"\` → \`from "${dup.canonicalImport}"\` and delete ${dup.sitePath}.`
1072
+ : dup.reason ?? "Manual review required.";
1073
+
1074
+ findings.push({
1075
+ rule: "local-framework-duplicate",
1076
+ severity: "warning",
1077
+ file: dup.sitePath,
1078
+ message: `${dup.description}${dup.safeToAutoFix ? " (pure dup)" : " (partial overlap)"}`,
1079
+ fix: fixMessage,
1080
+ meta: {
1081
+ id: dup.id,
1082
+ canonicalImport: dup.canonicalImport,
1083
+ safeToAutoFix: dup.safeToAutoFix,
1084
+ ...(dup.reason ? { reason: dup.reason } : {}),
1085
+ },
1086
+ });
1087
+ }
1088
+ return findings;
1089
+ },
1090
+ applyFix(ctx, findings, writer): FixAction[] {
1091
+ const actions: FixAction[] = [];
1092
+ for (const f of findings) {
1093
+ const id = f.meta?.id as string | undefined;
1094
+ const safe = f.meta?.safeToAutoFix === true;
1095
+ if (!safe || !id) continue;
1096
+ const dup = FRAMEWORK_DUPLICATES.find((d) => d.id === id);
1097
+ if (!dup) continue;
1098
+
1099
+ const siteImportSpec = `~/${stripExt(dup.sitePath.replace(/^src\//, ""))}`;
1100
+ const updated = rewriteImportSpec(
1101
+ ctx,
1102
+ writer,
1103
+ siteImportSpec,
1104
+ dup.canonicalImport,
1105
+ );
1106
+ writer.deleteFile(`${ctx.siteDir}/${dup.sitePath}`);
1107
+ actions.push({
1108
+ file: dup.sitePath,
1109
+ kind: "rewrite-imports+delete",
1110
+ detail: `rewrote ${updated.length} import(s) "${siteImportSpec}" → "${dup.canonicalImport}" and deleted ${dup.sitePath}`,
1111
+ });
1112
+ }
1113
+ return actions;
1114
+ },
1115
+ };
1116
+
1117
+ function stripExt(path: string): string {
1118
+ return path.replace(/\.(ts|tsx|js|jsx|mjs)$/, "");
1119
+ }
1120
+
946
1121
  /* ------------------------------------------------------------------ */
947
1122
  /* Rule 8 — `htmx-residue` — leftover hx-* attrs in migrated src/ */
948
1123
  /* ------------------------------------------------------------------ */
@@ -1021,6 +1196,7 @@ export const ALL_RULES: Rule[] = [
1021
1196
  ruleVtexShimRegression,
1022
1197
  ruleLocalWidgetsTypes,
1023
1198
  ruleFrameworkTodos,
1199
+ ruleLocalFrameworkDuplicate,
1024
1200
  ruleHtmxResidue,
1025
1201
  ];
1026
1202
 
@@ -1037,5 +1213,6 @@ export const _internals = {
1037
1213
  ruleHtmxResidue,
1038
1214
  ruleLocalWidgetsTypes,
1039
1215
  ruleFrameworkTodos,
1216
+ ruleLocalFrameworkDuplicate,
1040
1217
  },
1041
1218
  };
@@ -666,6 +666,7 @@ describe("runAudit — totals", () => {
666
666
  [
667
667
  "dead-lib-shims",
668
668
  "dead-runtime-shim",
669
+ "local-framework-duplicate",
669
670
  "local-widgets-types",
670
671
  "obsolete-vite-plugins",
671
672
  "vtex-shim-regression",
@@ -1170,3 +1171,193 @@ describe("rule: htmx-residue", () => {
1170
1171
  expect(r.supportsAutoFix).toBe(false);
1171
1172
  });
1172
1173
  });
1174
+
1175
+ /* ------------------------------------------------------------------ */
1176
+ /* W15-B-1 — local-framework-duplicate rule */
1177
+ /* ------------------------------------------------------------------ */
1178
+
1179
+ describe("rule: local-framework-duplicate", () => {
1180
+ it("flags src/sdk/clx.ts when content matches the framework export (auto-fixable)", () => {
1181
+ const fs = makeFs({
1182
+ "/site/src/sdk/clx.ts":
1183
+ "export const clx = (...args: (string | null | undefined | false)[]) =>\n" +
1184
+ ' args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n' +
1185
+ "export default clx;\n",
1186
+ });
1187
+ const report = runAudit(SITE, fs);
1188
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1189
+ expect(r.findings).toHaveLength(1);
1190
+ expect(r.findings[0].file).toBe("src/sdk/clx.ts");
1191
+ expect(r.findings[0].message).toContain("pure dup");
1192
+ expect(r.findings[0].meta?.id).toBe("clx");
1193
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(true);
1194
+ expect(r.findings[0].meta?.canonicalImport).toBe("@decocms/start/sdk/clx");
1195
+ });
1196
+
1197
+ it("flags src/sdk/clx.ts when site adds a clsx alias (signature still matches)", () => {
1198
+ const fs = makeFs({
1199
+ "/site/src/sdk/clx.ts":
1200
+ "export const clx = (...args: (string | null | undefined | false)[]) =>\n" +
1201
+ ' args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n' +
1202
+ "export const clsx = clx;\n" +
1203
+ "export default clx;\n",
1204
+ });
1205
+ const report = runAudit(SITE, fs);
1206
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1207
+ expect(r.findings).toHaveLength(1);
1208
+ expect(r.findings[0].meta?.id).toBe("clx");
1209
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(true);
1210
+ });
1211
+
1212
+ it("does NOT flag a clx.ts that has been forked (signature mismatch)", () => {
1213
+ const fs = makeFs({
1214
+ // Realistic fork: uses lodash-style cn from a different package.
1215
+ "/site/src/sdk/clx.ts":
1216
+ 'import { cn } from "lodash";\nexport const clx = cn;\nexport default clx;\n',
1217
+ });
1218
+ const report = runAudit(SITE, fs);
1219
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1220
+ expect(r.findings).toEqual([]);
1221
+ });
1222
+
1223
+ it("flags src/sdk/useSendEvent.ts as warn-only (typing regression risk)", () => {
1224
+ const fs = makeFs({
1225
+ "/site/src/sdk/useSendEvent.ts":
1226
+ 'import { AnalyticsEvent } from "@decocms/apps/commerce/types";\n' +
1227
+ "export const useSendEvent = <E extends AnalyticsEvent>(\n" +
1228
+ " { event, on }: { event: E; on: 'click' | 'view' | 'change' },\n" +
1229
+ ") => ({\n" +
1230
+ ' "data-event": encodeURIComponent(JSON.stringify(event)),\n' +
1231
+ ' "data-event-trigger": on,\n' +
1232
+ "});\n",
1233
+ });
1234
+ const report = runAudit(SITE, fs);
1235
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1236
+ expect(r.findings).toHaveLength(1);
1237
+ expect(r.findings[0].file).toBe("src/sdk/useSendEvent.ts");
1238
+ expect(r.findings[0].message).toContain("partial overlap");
1239
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1240
+ expect(r.findings[0].fix).toContain("typed AnalyticsEvent generic");
1241
+ });
1242
+
1243
+ it("flags src/matchers/location.ts as warn-only (behaviour-superset opportunity)", () => {
1244
+ const fs = makeFs({
1245
+ "/site/src/matchers/location.ts":
1246
+ 'import { registerMatcher } from "@decocms/start/cms";\n' +
1247
+ "export function registerLocationMatcher(): void {\n" +
1248
+ ' registerMatcher("website/matchers/location.ts", (rule, ctx) => {\n' +
1249
+ " const cookies = ctx.cookies ?? {};\n" +
1250
+ " const country = cookies.__cf_geo_country ?? '';\n" +
1251
+ " return Boolean(country);\n" +
1252
+ " });\n" +
1253
+ "}\n",
1254
+ });
1255
+ const report = runAudit(SITE, fs);
1256
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1257
+ expect(r.findings).toHaveLength(1);
1258
+ expect(r.findings[0].file).toBe("src/matchers/location.ts");
1259
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1260
+ expect(r.findings[0].fix).toContain("registerBuiltinMatchers");
1261
+ });
1262
+
1263
+ it("emits zero findings on a clean tree (no duplicates present)", () => {
1264
+ const fs = makeFs({
1265
+ "/site/src/sections/Hello.tsx":
1266
+ 'import { clx } from "@decocms/start/sdk/clx";\nexport default () => <div className={clx("a")}>x</div>;\n',
1267
+ });
1268
+ const report = runAudit(SITE, fs);
1269
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1270
+ expect(r.findings).toEqual([]);
1271
+ });
1272
+
1273
+ it("emits warning severity for both auto-fixable AND warn-only entries (--strict gates everything)", () => {
1274
+ const fs = makeFs({
1275
+ "/site/src/sdk/clx.ts":
1276
+ 'export const clx = (...args: any[]) => args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n',
1277
+ "/site/src/sdk/useSendEvent.ts":
1278
+ 'export const useSendEvent = (e: any) => ({ "data-event": encodeURIComponent(JSON.stringify(e)) });\n',
1279
+ });
1280
+ const report = runAudit(SITE, fs);
1281
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1282
+ for (const f of r.findings) expect(f.severity).toBe("warning");
1283
+ });
1284
+
1285
+ it("auto-fix rewrites importers using ~/sdk/clx and deletes the file", () => {
1286
+ const { fs, writer, store } = makeMutableFs({
1287
+ "/site/src/sdk/clx.ts":
1288
+ 'export const clx = (...args: any[]) => args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n',
1289
+ "/site/src/components/A.tsx":
1290
+ 'import { clx } from "~/sdk/clx";\nexport default () => clx("x");\n',
1291
+ "/site/src/components/B.tsx":
1292
+ 'import { clx } from "~/sdk/clx";\nimport React from "react";\nexport default () => clx("y");\n',
1293
+ "/site/src/components/Unrelated.tsx":
1294
+ 'import { clx } from "@decocms/start/sdk/clx";\nexport default () => clx("z");\n',
1295
+ });
1296
+ const report = runAudit(SITE, fs, { writer });
1297
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1298
+ expect(r.fixes).toBeDefined();
1299
+ expect(r.fixes!.length).toBe(1);
1300
+ expect(r.fixes![0].kind).toBe("rewrite-imports+delete");
1301
+ expect(r.fixes![0].detail).toContain("rewrote 2 import(s)");
1302
+ // File deleted from the in-memory store
1303
+ expect(store["/site/src/sdk/clx.ts"]).toBeUndefined();
1304
+ // Importers rewritten
1305
+ expect(store["/site/src/components/A.tsx"]).toContain(
1306
+ 'from "@decocms/start/sdk/clx"',
1307
+ );
1308
+ expect(store["/site/src/components/B.tsx"]).toContain(
1309
+ 'from "@decocms/start/sdk/clx"',
1310
+ );
1311
+ // Already-canonical import untouched
1312
+ expect(store["/site/src/components/Unrelated.tsx"]).toContain(
1313
+ 'from "@decocms/start/sdk/clx"',
1314
+ );
1315
+ expect(store["/site/src/components/Unrelated.tsx"]).not.toMatch(/~\/sdk\/clx/);
1316
+ });
1317
+
1318
+ it("auto-fix is a no-op for warn-only entries (does NOT delete partial-overlap files)", () => {
1319
+ const { fs, writer, store } = makeMutableFs({
1320
+ "/site/src/sdk/useSendEvent.ts":
1321
+ 'import { AnalyticsEvent } from "@decocms/apps/commerce/types";\n' +
1322
+ "export const useSendEvent = <E extends AnalyticsEvent>() => ({\n" +
1323
+ ' "data-event": encodeURIComponent("x"),\n' +
1324
+ "});\n",
1325
+ "/site/src/matchers/location.ts":
1326
+ 'import { registerMatcher } from "@decocms/start/cms";\n' +
1327
+ 'registerMatcher("website/matchers/location.ts", () => Boolean(__cf_geo_country));\n',
1328
+ });
1329
+ const report = runAudit(SITE, fs, { writer });
1330
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1331
+ expect(r.findings.length).toBe(2);
1332
+ // Both fixes are no-ops because safeToAutoFix === false.
1333
+ expect(r.fixes ?? []).toEqual([]);
1334
+ // Files preserved.
1335
+ expect(store["/site/src/sdk/useSendEvent.ts"]).toBeDefined();
1336
+ expect(store["/site/src/matchers/location.ts"]).toBeDefined();
1337
+ });
1338
+
1339
+ it("auto-fix runs only on auto-fixable entries when both kinds coexist", () => {
1340
+ const { fs, writer, store } = makeMutableFs({
1341
+ "/site/src/sdk/clx.ts":
1342
+ 'export const clx = (...args: any[]) => args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n',
1343
+ "/site/src/sdk/useSendEvent.ts":
1344
+ 'export const useSendEvent = (e: any) => ({ "data-event": encodeURIComponent(JSON.stringify(e)) });\n',
1345
+ "/site/src/components/A.tsx":
1346
+ 'import { clx } from "~/sdk/clx";\nexport default () => clx("x");\n',
1347
+ });
1348
+ const report = runAudit(SITE, fs, { writer });
1349
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1350
+ expect(r.findings.length).toBe(2);
1351
+ expect(r.fixes!.length).toBe(1); // only clx auto-fixed
1352
+ expect(r.fixes![0].file).toBe("src/sdk/clx.ts");
1353
+ expect(store["/site/src/sdk/clx.ts"]).toBeUndefined();
1354
+ expect(store["/site/src/sdk/useSendEvent.ts"]).toBeDefined();
1355
+ });
1356
+
1357
+ it("supportsAutoFix is true (the rule has applyFix even though some entries are warn-only)", () => {
1358
+ const fs = makeFs({});
1359
+ const report = runAudit(SITE, fs);
1360
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1361
+ expect(r.supportsAutoFix).toBe(true);
1362
+ });
1363
+ });
@@ -77,8 +77,10 @@ function showHelp() {
77
77
  --source <dir> Site directory to audit (default: .)
78
78
  --fix Auto-apply mechanical fixes for the safe rules
79
79
  (dead-lib-shims, dead-runtime-shim, local-widgets-types,
80
- vtex-shim-regression swap subset, obsolete-vite-plugins).
81
- Other rules — including htmx-residue — stay detect-only.
80
+ vtex-shim-regression swap subset, obsolete-vite-plugins,
81
+ local-framework-duplicate auto-fixable subset).
82
+ Other rules — including htmx-residue and the warn-only
83
+ entries of local-framework-duplicate — stay detect-only.
82
84
  --json Emit machine-readable JSON instead of pretty text
83
85
  --strict Exit code 2 if any warning-severity findings exist
84
86
  --help, -h Show this help