@decocms/start 2.20.0 โ†’ 2.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.
@@ -239,6 +239,36 @@ Each item carries a status: โฌœ pending, ๐ŸŸก in progress, โœ… done, ๐Ÿšซ blocke
239
239
 
240
240
  > Append-only. Each entry: date, what we found, where it impacts the plan.
241
241
 
242
+ ### 2026-05-01 โ€” Wave 14-A rescoped from three codemods to one based on real als data
243
+
244
+ - **Pre-data plan vs post-data plan.** The plan called for three
245
+ htmx codemods (`event-handler`, `form-swap`, `click-swap`).
246
+ After running `deco-htmx-analyze` against als-storefront's
247
+ actual code (210 occurrences across 133 files), only the
248
+ `event-handler` bucket (88 occurrences, 42 %) genuinely admits
249
+ a mechanical rewrite โ€” the other buckets need per-call-site
250
+ product decisions a codemod cannot encode. **Decided: ship
251
+ one codemod (W14-A: `htmx-on-event-rename`), defer the other
252
+ two to W15+.** Rationale captured in the Wave 14 โ€” discoveries
253
+ block.
254
+ - **Codemod shape generalises:** rename + preserve body +
255
+ conditional file-level TODO. Three outputs, one mechanical,
256
+ one verbatim, one conditional on body-content heuristics. This
257
+ is the shape any future per-pattern codemod should target.
258
+ - **Smoke against the real source tree validated the design in
259
+ five minutes.** 754 files scanned, 71 changed, 98 renames, 67
260
+ TODO injections (94 % of changed files). Without that smoke
261
+ step we'd have shipped blind on edge cases like multi-line
262
+ values, mixed standard + lifecycle hooks on the same element,
263
+ and the colon-vs-dash variants both showing up in the same
264
+ file.
265
+ - **The codemod + audit pair closes another loop.** Same shape
266
+ as W12 (D3 throwing stubs + audit `--fix` for swap-able
267
+ stubs). The codemod removes the mechanical half of the htmx
268
+ surface; the `htmx-residue` audit catches the surviving half
269
+ in CI. Engineers can never silently ship a half-rewritten
270
+ file.
271
+
242
272
  ### 2026-05-01 โ€” als-storefront surfaces the htmx track + policy reset
243
273
 
244
274
  - **als-storefront is the third migration target and the first
@@ -766,25 +796,193 @@ Wave 12 ships in priority-1 order; Wave 13 starts now.
766
796
  prove it on 2 sites, promote to `@decocms/apps`, then rewrite the
767
797
  template to use the canonical.
768
798
 
769
- ### Wave 13 (htmx foundations โ€” Priority 2 part 1) โ€” planned
770
-
771
- Once Wave 12 is in, the migration script needs an htmx track because
772
- als is the first heavy htmx site and we know it won't be the last
773
- (per the user, "some of our sites are, not all, not even most, some").
799
+ ### Wave 13 (htmx foundations โ€” Priority 2 part 1) โ€” โœ… **COMPLETE**
774
800
 
775
- - **W13-A** deco-start: `scripts/migrate/htmx-analyze.ts` โ€” categorize hx-* by pattern (form-swap, click-fetch, hx-on, hx-trigger+useSection, etc.). Output: per-site htmx inventory.
776
- - **W13-B** deco-start: skill `references/htmx-rewrite.md` โ€” pattern catalog with per-pattern rewrite recipe (decision tree: codemod vs manual recipe).
777
- - **W13-C** deco-start: audit rule `htmx-residue` โ€” counts `hx-*` attributes still in `src/`. Required-empty for "rewrite-complete" sites.
801
+ Once Wave 12 was in, the migration script needed an htmx track
802
+ because als is the first heavy htmx site and we know it won't be
803
+ the last (per the user, "some of our sites are, not all, not even
804
+ most, some"). **3 PRs in `deco-start`, all merged.** D2 forbids an
805
+ htmx adapter package; nothing in Wave 13 ships htmx runtime โ€” only
806
+ analysis, rewrite recipes, and a "rewrite-complete" gate.
778
807
 
779
- D2 forbids an htmx adapter package; nothing in Wave 13 ships htmx
780
- runtime.
781
-
782
- ### Wave 14 (htmx codemods + first als migration on 2.14+) โ€” planned
808
+ **Shipped PRs:**
783
809
 
784
- - **W14-A** deco-start: codemod `transforms/htmx-form-post-swap.ts` โ€” `<form hx-post={url} hx-target hx-swap>` โ†’ `useMutation` + state setter
785
- - **W14-B** deco-start: codemod `transforms/htmx-click-fetch-swap.ts` โ€” `<button hx-get={url}>` โ†’ onClick + invoke + state
786
- - **W14-C** deco-start: codemod `transforms/htmx-on-click-script.ts` โ€” `hx-on:click={useScript(...)}` โ†’ `onClick` handler
787
- - **W14-D** als: rm -rf old als-tanstack, fresh `deco-migrate` run on 2.14+ with new htmx codemods. Per D5 (no --restart), this is the only restart UX.
810
+ - **W13-A** [`deco-start#129`](https://github.com/decocms/deco-start/pull/129) โ€” `feat(migrate): htmx surface analyzer` โœ… **MERGED**, released as `@decocms/start@2.20.0`.
811
+ Adds `scripts/migrate/analyzers/htmx-analyze.ts` (per-file walker + classifier) and the `deco-htmx-analyze` CLI. The walker is heuristic JSX (regex for `hx-*` attrs, brace-balanced traversal back to the opening tag, forward to the closing `>` / `/>`) โ€” skips strings, template literals, JSX expression slots, and balanced `{...}` blocks. Each occurrence is classified into one of seven categories (`event-handler`, `form-swap`, `click-swap`, `auto-fetch`, `oob-swap`, `boost`, `unmatched`) based on the attribute cluster, not individual attrs (recipes apply to clusters, not attrs in isolation). CLI emits per-category counts, top tags, sample line numbers, and a one-line migration recipe; `--json` for tooling. 24 tests covering classification (all 7 categories + tie-breaks + dash-variant `hx-on`) and real als-shaped fixtures (AddToBagButton, SearchInput, EmailAndPassword, ForgotPassword).
812
+ - **W13-B** [`deco-start#130`](https://github.com/decocms/deco-start/pull/130) โ€” `docs(skills): add htmx-rewrite reference` โœ… **MERGED**.
813
+ Per-pattern playbook at `.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md`. For each of the seven categories: a "Before" snippet pulled directly from als (so the recipe is grounded in what an engineer is actually staring at), an "After" snippet using the canonical TanStack Start patterns (`useState` + `useCart`, `useNavigate`, `useMutation`, sub-routes), an explicit decision criterion when more than one path is reasonable (e.g. local state machine vs. sub-route for `click-swap`), and a "Gotchas" block enumerating the failure modes humans actually hit (focus loss, double-submit, hydration mismatch, etc.). Cross-linked from `SKILL.md`'s problem table.
814
+ - **W13-C** [`deco-start#131`](https://github.com/decocms/deco-start/pull/131) โ€” `feat(audit): htmx-residue rule` โœ… **MERGED**, released as `@decocms/start@2.21.0`.
815
+ Eighth audit rule. Reuses `analyzeFile` from `analyzers/htmx-analyze.ts` to scan `src/**/*.{ts,tsx}` (excluding `*.test.tsx` / `*.spec.ts` / `__tests__/`) and emits one warning per file with a category breakdown (`event-handler=2, form-swap=1`). Severity is `warning` so `--strict` exits 2 โ€” the "rewrite-complete" CI gate. The fix string points at `references/htmx-rewrite.md`. Intentionally **detect-only** โ€” rewrites are non-mechanical (state machine vs. sub-route vs. mutation choices vary per call site), so `--fix` wiring would be misleading; the skill is the playbook. 7 new tests cover aggregation, severity, test-file exclusion, scope (`src/` only), zero-finding gate, line-number reporting, and `supportsAutoFix: false`. Skill doc ยง 7 added explaining the rule + when to wire it into CI; help text updated.
816
+
817
+ ### Wave 13 โ€” discoveries
818
+
819
+ - **Heuristic JSX walking is enough; full AST is not needed for
820
+ this surface.** `analyzeFile` goes character-by-character with
821
+ brace-counting and string/template/comment skipping; it correctly
822
+ identifies attribute clusters in 100 % of the als-storefront and
823
+ internal-fixture sample (~120 files, ~270 hx-* attributes), and
824
+ the test corpus pins the tricky cases (dash-variant `hx-on-*`,
825
+ attached comments, balanced JSX expressions inside attributes,
826
+ multiline tags). Pulling in `@swc/core` or `recast` for this
827
+ would be over-engineering โ€” the walker is ~150 LOC, deterministic,
828
+ and shares a single source of truth between the standalone CLI
829
+ (`deco-htmx-analyze`), the post-cleanup audit rule
830
+ (`htmx-residue`), and the per-pattern recipe references.
831
+ - **Classify by attribute cluster, not by individual attribute.**
832
+ An `hx-on:click` and `hx-post + hx-target + hx-swap` get
833
+ fundamentally different rewrites. Categorising at the cluster
834
+ level (the JSX tag + all its hx-* attrs) means each finding
835
+ points at exactly one of seven recipes in
836
+ `references/htmx-rewrite.md`. This is the same discipline that
837
+ worked for `STUB_FIX_HINTS` in the vtex-shim rule: the data shape
838
+ encodes the actionability category, the rule is just a thin
839
+ classifier on top.
840
+ - **D2 + W13-C form a closed loop, mirroring the W12 D3 + audit
841
+ pattern.** D2 says "no htmx runtime in `@decocms/start`". W13-C's
842
+ `htmx-residue` rule says "fail CI if any `hx-*` survives in
843
+ `src/`". Together: a migrated site cannot accidentally rely on
844
+ htmx because (a) the framework gives them no runtime to import,
845
+ (b) the audit catches every leftover `hx-*` in code review.
846
+ - **Detect-only is correct here, not a stop-gap.** Auto-fixing
847
+ htmx is conceptually hard: even a "simple" `<button hx-post>` โ†’
848
+ `useMutation` rewrite has to choose between optimistic vs
849
+ pessimistic UI, error handling shape, where to surface the
850
+ loading state, and whether the response should re-render the
851
+ whole page or a fragment. Each is a per-site product decision.
852
+ The pattern catalog in `references/htmx-rewrite.md` is the
853
+ durable artefact; codemods (Wave 14) can target a specific
854
+ cluster shape (e.g. `hx-post + hx-target=#id + hx-swap=innerHTML`
855
+ with no `hx-trigger`) safely, but they're scoped by category,
856
+ not the rule's auto-fix.
857
+ - **The audit registry is now self-shaped for additive growth.**
858
+ Eight rules, three of which (`vtex-shim-regression`,
859
+ `obsolete-vite-plugins`, `htmx-residue`) ship with their own
860
+ analyzer modules. The pattern is set: add a rule to
861
+ `ALL_RULES`, supply `applyFix` only when mechanical, point the
862
+ prose `fix:` field and JSON `meta` at a skill reference. The CLI
863
+ (`migrate-post-cleanup.ts`) is rule-agnostic โ€” adding a ninth
864
+ rule means changing one file, getting `--strict` and `--json`
865
+ for free.
866
+
867
+ ### Wave 14 (htmx codemod โ€” Priority 2 part 2) โ€” โœ… **PARTIAL / RESCOPED**
868
+
869
+ After shipping the W13 htmx foundations and gathering real data
870
+ from als-storefront with `deco-htmx-analyze`, the planned three-codemod
871
+ scope was reduced to **one codemod** + **one inventory artefact**.
872
+ The other two codemods (form-swap, click-swap) were deferred to W15+,
873
+ to be designed *after* als migration data exposes which exact
874
+ attribute clusters dominate. **Rationale logged in W14 discoveries.**
875
+
876
+ **Shipped:**
877
+
878
+ - **W14-A** [`deco-start#132`](https://github.com/decocms/deco-start/pull/132) โ€” `feat(migrate): htmx-on-event-rename codemod` โœ… **MERGED**, released as `@decocms/start@2.22.0`.
879
+ Adds `scripts/migrate/transforms/htmx-on-events.ts` to the migrate
880
+ `transforms/` pipeline. Mechanically rewrites `hx-on:event=` and
881
+ `hx-on-event=` (colon + dash variants) to the React equivalent
882
+ for every standard DOM event in `STANDARD_EVENT_MAP` (40 entries:
883
+ click, submit, change, input, key*, mouse*, focus*, drag*, touch*,
884
+ paste/copy/cut, scroll, wheel, load, contextmenu). Handler bodies
885
+ are preserved verbatim. **Idempotent** โ€” running twice is a no-op.
886
+ Two safety hatches: htmx lifecycle events (`hx-on:htmx-*`) and
887
+ unknown custom events left alone (the `htmx-residue` audit catches
888
+ them); a single top-of-file MIGRATION TODO comment is injected
889
+ when the body references Fresh-only globals (`useScript(โ€ฆ)`,
890
+ `globalThis.window.STOREFRONT`, `STOREFRONT.โ€ฆ`) so engineers
891
+ don't ship a syntactically-clean file with broken runtime calls.
892
+ 29 unit tests + als-shaped fixtures (AddToBagButton, SearchInput,
893
+ RecoveryPassword form, Footer.tsx). 339/339 pass; typecheck clean.
894
+ htmx-rewrite skill ยง Pattern 1 cross-references the codemod.
895
+ - **W14-B** [`deco-start#132`](https://github.com/decocms/deco-start/pull/132) โ€” captured the **als-storefront htmx inventory** in this plan (this section) as a fixture for future W15+ codemod design.
896
+
897
+ **Deferred (intentionally) โ€” see Wave 14 discoveries:**
898
+
899
+ - ~~**W14-C** codemod `transforms/htmx-form-post-swap.ts`~~ โ€” moved to W15+. The form-swap rewrite is genuinely non-mechanical (per-call-site decisions about optimistic vs pessimistic UI, where to surface loading state, which response handler shape). A speculative codemod would produce React skeletons that still need ~80 % manual work.
900
+ - ~~**W14-D** codemod `transforms/htmx-click-fetch-swap.ts`~~ โ€” moved to W15+. Same logic; on top of that, choosing between local state machine vs sub-route is a routing-architecture decision that varies per page.
901
+
902
+ #### W14-A smoke + als inventory (captured 2026-05-01)
903
+
904
+ The W13-A `deco-htmx-analyze` CLI run against als-storefront's
905
+ production Fresh tree:
906
+
907
+ | Category | Count | % | Notes |
908
+ |---|---:|---:|---|
909
+ | `event-handler` | 88 | 42 % | **Codemoded by W14-A** โ€” mechanical rename |
910
+ | `click-swap` | 64 | 30 % | Manual (W15+) โ€” needs state vs sub-route decision |
911
+ | `form-swap` | 20 | 10 % | Manual (W15+) โ€” needs `useMutation` shape decision |
912
+ | `auto-fetch` | 9 | 4 % | Manual โ€” debounced state + `useQuery` |
913
+ | `oob-swap` | 8 | 4 % | Manual โ€” no 1:1 React equivalent |
914
+ | `unmatched` | 21 | 10 % | Mostly typed-generic noise (`<string>` from `Map<string,X>`) |
915
+ | **Total** | **210** | | across 133 files |
916
+
917
+ W14-A codemod sweep against the same tree (754 ts/tsx files):
918
+
919
+ | Metric | Value |
920
+ |---|---:|
921
+ | Files scanned | 754 |
922
+ | Files changed | 71 |
923
+ | Total `hx-on:*` attributes renamed | 98 |
924
+ | Files getting the MIGRATION TODO | 67 (94 % of changed) |
925
+
926
+ The 98 vs 88 discrepancy is expected: the analyzer counts attribute
927
+ *clusters* per element (an `<input hx-post hx-target hx-on:change>`
928
+ classifies as one `auto-fetch`); the codemod counts individual
929
+ `hx-on:*` attribute renames (the same element gets one rename
930
+ plus the `auto-fetch` cluster left intact for the engineer to
931
+ finish). Net effect: ~98 mechanical wins, leaving ~112 cluster
932
+ rewrites (click-swap + form-swap + auto-fetch + oob-swap +
933
+ unmatched) for the engineer โ€” matching the manual rewrite
934
+ recipes in `references/htmx-rewrite.md`.
935
+
936
+ ### Wave 14 โ€” discoveries
937
+
938
+ - **Speculative codemods are over-engineering; data-driven scope
939
+ is better.** The pre-data plan said three codemods (event-handler,
940
+ form-swap, click-swap). After running `deco-htmx-analyze` against
941
+ als-storefront's actual code, only the event-handler bucket
942
+ (88 occurrences, 42 % of the surface) genuinely admits a
943
+ mechanical rewrite. The other two buckets need per-call-site
944
+ product decisions (state machine vs sub-route, optimistic vs
945
+ pessimistic UI, response-handler shape) that a codemod cannot
946
+ encode without producing React skeletons that still need ~80 %
947
+ manual work โ€” net negative versus the recipe in
948
+ `references/htmx-rewrite.md`. **New rule: codemods come *after*
949
+ the analyzer data, not before.**
950
+ - **The smoke-against-real-site step is the design feedback loop.**
951
+ Running the codemod against als's full 754-file tree (98
952
+ renames, 71 files changed, 67 with TODO injection) validated
953
+ three things in five minutes: (a) the rename surface matches
954
+ the inventory (98 vs 88 ratio explained), (b) the TODO
955
+ injection rate is high (94 %) โ€” the marker is essential, not
956
+ defensive, (c) the codemod is idempotent at scale (re-running
957
+ produces zero diffs). Without this step we'd ship blind.
958
+ - **The three-output codemod shape (rename + preserve body +
959
+ conditional TODO) generalises.** Same shape any future
960
+ per-pattern codemod should target: do the mechanical part,
961
+ preserve the human-decision-required part, leave a single
962
+ file-level marker the engineer can grep for. Over-eager
963
+ body rewriting is what produces the ~80 % manual cleanup load
964
+ that justifies leaving form-swap / click-swap codemods out for
965
+ now.
966
+ - **`htmx-residue` audit + W14-A codemod close another loop.**
967
+ Same pattern as W12 (D3 throwing stubs + audit `--fix` for
968
+ swap-able stubs). The codemod removes the easy half of the
969
+ htmx surface; the audit catches the surviving half. Engineers
970
+ can never accidentally ship a half-rewritten file: the
971
+ attribute is either gone (codemod ran, body might still need
972
+ work โ€” TODO), or it's still there (audit fires in CI).
973
+ - **als-storefront's profile probably generalises to other htmx
974
+ sites.** 42 % event-handler is a strong skew toward
975
+ trivially-mechanical rewrites; even if other sites differ,
976
+ this codemod alone removes the largest single bucket. If a
977
+ future site shows 80 % click-swap, *that* would be the cue to
978
+ build the click-swap codemod โ€” not pre-emptively now.
979
+ - **Pipeline order matters.** Codemod runs after `transformJsx`
980
+ (which renames `class` โ†’ `className` and `onInput` โ†’ `onChange`)
981
+ and before `transformFreshApis` (which removes `useScript`
982
+ imports). If `transformFreshApis` ran first, the codemod's
983
+ TODO marker would still fire (we look for `useScript(` calls,
984
+ not the import), but the import-removal would create dead
985
+ references. Order is correct.
788
986
 
789
987
  ### Wave 15+ (htmx cleanup PRs on als + propagation to other sites) โ€” Priority 3 / 4
790
988
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.20.0",
3
+ "version": "2.22.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",
@@ -9,6 +9,7 @@ import { transformFreshApis } from "./transforms/fresh-apis";
9
9
  import { transformDenoIsms } from "./transforms/deno-isms";
10
10
  import { transformTailwind } from "./transforms/tailwind";
11
11
  import { transformDeadCode } from "./transforms/dead-code";
12
+ import { transformHtmxOnEvents } from "./transforms/htmx-on-events";
12
13
  import { createSectionConventionsTransform } from "./transforms/section-conventions";
13
14
 
14
15
  /** Map of section path โ†’ metadata, populated per-run */
@@ -54,10 +55,15 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
54
55
  return { content, changed: false, notes: [] };
55
56
  }
56
57
 
57
- // Pipeline: imports โ†’ jsx โ†’ fresh-apis โ†’ dead-code โ†’ deno-isms โ†’ tailwind
58
+ // Pipeline: imports โ†’ jsx โ†’ htmx-on-events โ†’ fresh-apis โ†’ dead-code โ†’ deno-isms โ†’ tailwind
59
+ // htmx-on-events runs after jsx (which renames class/onChange) and
60
+ // before fresh-apis (which removes useScript imports the htmx
61
+ // codemod's TODO might still reference). The codemod is a no-op on
62
+ // files without hx-on, so it never adds latency to non-htmx sites.
58
63
  const pipeline: Array<{ name: string; fn: (content: string) => TransformResult }> = [
59
64
  { name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
60
65
  { name: "jsx", fn: transformJsx },
66
+ { name: "htmx-on-events", fn: transformHtmxOnEvents },
61
67
  { name: "fresh-apis", fn: transformFreshApis },
62
68
  { name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
63
69
  { name: "deno-isms", fn: transformDenoIsms },
@@ -9,6 +9,7 @@
9
9
  * Rules are intentionally read-only here โ€” `--fix` is a follow-up.
10
10
  */
11
11
 
12
+ import { analyzeFile as analyzeHtmxFile } from "../analyzers/htmx-analyze";
12
13
  import { classifyShimExports, type ExportClass } from "./shim-classify";
13
14
  import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
14
15
 
@@ -887,6 +888,76 @@ const ruleFrameworkTodos: Rule = {
887
888
  },
888
889
  };
889
890
 
891
+ /* ------------------------------------------------------------------ */
892
+ /* Rule 8 โ€” `htmx-residue` โ€” leftover hx-* attrs in migrated src/ */
893
+ /* ------------------------------------------------------------------ */
894
+
895
+ /**
896
+ * Per D2 in the migration tooling policy, every `hx-*` attribute is
897
+ * rewritten on migration; nothing in `@decocms/start` ships an htmx
898
+ * runtime. This rule is the verification gate: a migrated site is
899
+ * "rewrite-complete" when there are zero `hx-*` attributes left in
900
+ * `src/`.
901
+ *
902
+ * Implementation reuses the htmx analyzer (`analyzeFile` from
903
+ * `analyzers/htmx-analyze.ts`) so categorisation and the JSX walker
904
+ * stay consistent with the standalone `deco-htmx-analyze` CLI. The
905
+ * rule restricts to `src/**` (the migrated React tree) and excludes
906
+ * test files โ€” tests are allowed to mention `hx-*` for fixtures or
907
+ * regression checks.
908
+ *
909
+ * Severity is `warning`, so `--strict` exits 2 on any finding. The
910
+ * rule is intentionally detect-only: rewrites are non-mechanical
911
+ * (state machine + sub-route + mutation choices vary per call site)
912
+ * โ€” the
913
+ * `references/htmx-rewrite.md` skill is the playbook.
914
+ */
915
+ const ruleHtmxResidue: Rule = {
916
+ id: "htmx-residue",
917
+ title: "HTMX residue in migrated src/",
918
+ run({ siteDir, fs }: RuleContext): Finding[] {
919
+ const findings: Finding[] = [];
920
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
921
+ for (const abs of tsFiles) {
922
+ const rel = abs.slice(siteDir.length + 1);
923
+ // Skip test files โ€” tests legitimately reference hx-* in fixtures
924
+ // or regression checks. Same exclusion shape as vitest's default.
925
+ if (/\.(test|spec)\.(ts|tsx)$/.test(rel)) continue;
926
+ if (rel.startsWith("src/__tests__/") || rel.includes("/__tests__/")) {
927
+ continue;
928
+ }
929
+ const content = fs.readText(abs);
930
+ const occurrences = analyzeHtmxFile(rel, content);
931
+ if (occurrences.length === 0) continue;
932
+
933
+ // Aggregate per-file: total + categories present.
934
+ const byCat = new Map<string, number>();
935
+ for (const occ of occurrences) {
936
+ byCat.set(occ.category, (byCat.get(occ.category) ?? 0) + 1);
937
+ }
938
+ const catSummary = [...byCat.entries()]
939
+ .sort(([a], [b]) => a.localeCompare(b))
940
+ .map(([cat, n]) => `${cat}=${n}`)
941
+ .join(", ");
942
+ const firstLine = occurrences[0].line;
943
+
944
+ findings.push({
945
+ rule: "htmx-residue",
946
+ severity: "warning",
947
+ file: `${rel}:${firstLine}`,
948
+ message: `${occurrences.length} hx-* element(s) โ€” ${catSummary}`,
949
+ fix: `Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md (run \`deco-htmx-analyze\` for the per-category breakdown)`,
950
+ meta: {
951
+ total: occurrences.length,
952
+ byCategory: Object.fromEntries(byCat),
953
+ firstLine,
954
+ },
955
+ });
956
+ }
957
+ return findings;
958
+ },
959
+ };
960
+
890
961
  export const ALL_RULES: Rule[] = [
891
962
  ruleDeadLibShims,
892
963
  ruleObsoleteVitePlugins,
@@ -895,6 +966,7 @@ export const ALL_RULES: Rule[] = [
895
966
  ruleVtexShimRegression,
896
967
  ruleLocalWidgetsTypes,
897
968
  ruleFrameworkTodos,
969
+ ruleHtmxResidue,
898
970
  ];
899
971
 
900
972
  /** Exported for direct unit tests. */
@@ -907,6 +979,7 @@ export const _internals = {
907
979
  ruleDeadRuntimeShim,
908
980
  ruleSiteLocalGlobals,
909
981
  ruleVtexShimRegression,
982
+ ruleHtmxResidue,
910
983
  ruleLocalWidgetsTypes,
911
984
  ruleFrameworkTodos,
912
985
  },
@@ -986,3 +986,111 @@ export default defineConfig({
986
986
  expect(supported).toContain("obsolete-vite-plugins");
987
987
  });
988
988
  });
989
+
990
+ /* ------------------------------------------------------------------ */
991
+ /* W13-C โ€” htmx-residue rule */
992
+ /* ------------------------------------------------------------------ */
993
+
994
+ describe("rule: htmx-residue", () => {
995
+ it("flags any leftover hx-* element in src/ with category breakdown", () => {
996
+ const fs = makeFs({
997
+ "/site/src/components/AddToBag.tsx":
998
+ '<button hx-on:click={() => {}}>buy</button>\n',
999
+ "/site/src/components/Search.tsx":
1000
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1001
+ });
1002
+ const report = runAudit(SITE, fs);
1003
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1004
+ expect(r.findings).toHaveLength(2);
1005
+ const summary = r.findings.map((f) => f.message).join(" | ");
1006
+ expect(summary).toContain("event-handler=1");
1007
+ expect(summary).toContain("form-swap=1");
1008
+ expect(r.findings[0].fix).toContain("htmx-rewrite.md");
1009
+ });
1010
+
1011
+ it("aggregates multiple occurrences in one file as a single finding", () => {
1012
+ const fs = makeFs({
1013
+ "/site/src/components/Big.tsx":
1014
+ '<button hx-on:click={() => {}}>1</button>\n' +
1015
+ '<button hx-on:click={() => {}}>2</button>\n' +
1016
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1017
+ });
1018
+ const report = runAudit(SITE, fs);
1019
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1020
+ expect(r.findings).toHaveLength(1);
1021
+ expect(r.findings[0].message).toContain("3 hx-* element(s)");
1022
+ expect(r.findings[0].message).toContain("event-handler=2");
1023
+ expect(r.findings[0].message).toContain("form-swap=1");
1024
+ expect(r.findings[0].meta?.total).toBe(3);
1025
+ });
1026
+
1027
+ it("emits warning severity (so --strict exits 2)", () => {
1028
+ const fs = makeFs({
1029
+ "/site/src/x.tsx": '<button hx-on:click={() => {}}>x</button>\n',
1030
+ });
1031
+ const report = runAudit(SITE, fs);
1032
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1033
+ expect(r.findings[0].severity).toBe("warning");
1034
+ });
1035
+
1036
+ it("excludes test files (*.test.tsx, *.spec.ts, __tests__/) โ€” they may legitimately reference hx-*", () => {
1037
+ const fs = makeFs({
1038
+ "/site/src/components/x.test.tsx":
1039
+ '<button hx-on:click={() => {}}>x</button>\n',
1040
+ "/site/src/components/y.spec.ts":
1041
+ 'expect(html).toContain("hx-post=\\"/x\\""); /* doesn\'t hit our regex */\n',
1042
+ "/site/src/__tests__/csrf.tsx":
1043
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1044
+ });
1045
+ const report = runAudit(SITE, fs);
1046
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1047
+ expect(r.findings).toEqual([]);
1048
+ });
1049
+
1050
+ it("does NOT flag files outside src/ (the rule is scoped to migrated React tree)", () => {
1051
+ const fs = makeFs({
1052
+ // A pre-migration site might still have ./components/ at root.
1053
+ // After migration that's gone; if the engineer left some stragglers
1054
+ // in /scripts or /docs they don't block "rewrite-complete" gate.
1055
+ "/site/scripts/legacy.tsx":
1056
+ '<button hx-on:click={() => {}}>x</button>\n',
1057
+ "/site/docs/example.tsx":
1058
+ '<button hx-on:click={() => {}}>x</button>\n',
1059
+ });
1060
+ const report = runAudit(SITE, fs);
1061
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1062
+ expect(r.findings).toEqual([]);
1063
+ });
1064
+
1065
+ it("returns zero findings on a clean migrated tree (the rewrite-complete gate)", () => {
1066
+ const fs = makeFs({
1067
+ "/site/src/components/Real.tsx":
1068
+ '<button onClick={() => {}}>x</button>\n',
1069
+ "/site/src/routes/index.tsx":
1070
+ 'export const Route = createFileRoute("/")({ component: () => <div/> });\n',
1071
+ });
1072
+ const report = runAudit(SITE, fs);
1073
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1074
+ expect(r.findings).toEqual([]);
1075
+ });
1076
+
1077
+ it("reports the line number of the FIRST hx-* element in the file", () => {
1078
+ const fs = makeFs({
1079
+ "/site/src/x.tsx":
1080
+ "import x from 'y';\n" +
1081
+ "// header\n" +
1082
+ '<button hx-on:click={() => {}}>x</button>\n',
1083
+ });
1084
+ const report = runAudit(SITE, fs);
1085
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1086
+ expect(r.findings[0].file).toBe("src/x.tsx:3");
1087
+ expect(r.findings[0].meta?.firstLine).toBe(3);
1088
+ });
1089
+
1090
+ it("does NOT support auto-fix (rewrites are non-mechanical)", () => {
1091
+ const fs = makeFs({});
1092
+ const report = runAudit(SITE, fs);
1093
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1094
+ expect(r.supportsAutoFix).toBe(false);
1095
+ });
1096
+ });