@decocms/start 2.19.0 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * HTMX surface analyzer.
3
+ *
4
+ * For sites with significant HTMX usage (the deco-cx Fresh stack
5
+ * embraced htmx as the interactivity model), the migration to TanStack
6
+ * Start has to either rewrite or eliminate every `hx-*` attribute.
7
+ * Per D2 in the migration tooling policy we don't ship a runtime —
8
+ * everything gets rewritten to React. This analyzer is the first step:
9
+ * it inventories the `hx-*` surface so the engineer (or the next
10
+ * codemod wave) knows exactly what shapes are out there before
11
+ * starting the rewrite.
12
+ *
13
+ * The output is a structured `HtmxInventory`: per-category counts,
14
+ * per-file counts, and three sample call sites per category. The
15
+ * report is the source of truth for the rewrite recipes documented
16
+ * in `references/htmx-rewrite.md` (Wave 13-B).
17
+ *
18
+ * Categorization is heuristic — we group elements by their **attribute
19
+ * cluster** rather than by individual attribute, because the rewrite
20
+ * recipe depends on the cluster (e.g. `hx-post + hx-target + hx-swap`
21
+ * on a `<form>` is a different rewrite than `hx-get + hx-target` on a
22
+ * `<button>`). False positives are recoverable: a human reads the
23
+ * report.
24
+ *
25
+ * Wave 13-A. Closes the analysis half of `htmx-foundations`; the
26
+ * codemods proper land in Wave 14.
27
+ */
28
+
29
+ import type { FsAdapter } from "../post-cleanup/types";
30
+
31
+ const SRC_GLOB_EXCLUDES = [
32
+ "node_modules",
33
+ "dist",
34
+ ".wrangler",
35
+ ".vite",
36
+ ".tanstack",
37
+ "build",
38
+ ".cursor",
39
+ ".agents",
40
+ "docs",
41
+ ];
42
+
43
+ /* ------------------------------------------------------------------ */
44
+ /* Public types */
45
+ /* ------------------------------------------------------------------ */
46
+
47
+ export type HtmxCategory =
48
+ | "event-handler"
49
+ | "form-swap"
50
+ | "click-swap"
51
+ | "auto-fetch"
52
+ | "oob-swap"
53
+ | "boost"
54
+ | "unmatched";
55
+
56
+ export interface HtmxOccurrence {
57
+ category: HtmxCategory;
58
+ file: string;
59
+ /** 1-indexed line of the opening tag's start. */
60
+ line: number;
61
+ /** Tag name (e.g. "button", "form", "input", "MyComponent"). */
62
+ tag: string;
63
+ /** Set of canonical hx-* attribute names found on this element. */
64
+ attrs: string[];
65
+ }
66
+
67
+ export interface HtmxFileSummary {
68
+ file: string;
69
+ total: number;
70
+ byCategory: Record<HtmxCategory, number>;
71
+ }
72
+
73
+ export interface HtmxInventory {
74
+ totalFiles: number;
75
+ totalOccurrences: number;
76
+ byCategory: Record<HtmxCategory, number>;
77
+ files: HtmxFileSummary[];
78
+ /** Up to 3 sample occurrences per category (ordered by file path). */
79
+ samples: Record<HtmxCategory, HtmxOccurrence[]>;
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ */
83
+ /* Entry point */
84
+ /* ------------------------------------------------------------------ */
85
+
86
+ /**
87
+ * Analyze every `*.{ts,tsx}` file under `siteDir` and return a
88
+ * structured inventory of htmx usage. Pure over the injected
89
+ * `FsAdapter`, so callers (CLI, tests, migration phase wiring) can
90
+ * substitute in-memory file systems.
91
+ */
92
+ export function analyzeHtmx(siteDir: string, fs: FsAdapter): HtmxInventory {
93
+ const files = fs.glob(siteDir, "**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
94
+ const occurrences: HtmxOccurrence[] = [];
95
+ const fileSummaries: HtmxFileSummary[] = [];
96
+
97
+ for (const abs of files) {
98
+ const rel = abs.startsWith(`${siteDir}/`)
99
+ ? abs.slice(siteDir.length + 1)
100
+ : abs;
101
+ const content = fs.readText(abs);
102
+ const fileOccurrences = analyzeFile(rel, content);
103
+ if (fileOccurrences.length === 0) continue;
104
+ const summary: HtmxFileSummary = {
105
+ file: rel,
106
+ total: fileOccurrences.length,
107
+ byCategory: emptyCategoryRecord(),
108
+ };
109
+ for (const occ of fileOccurrences) {
110
+ summary.byCategory[occ.category]++;
111
+ }
112
+ fileSummaries.push(summary);
113
+ occurrences.push(...fileOccurrences);
114
+ }
115
+
116
+ const byCategory = emptyCategoryRecord();
117
+ for (const occ of occurrences) byCategory[occ.category]++;
118
+
119
+ const samples = emptyCategorySamples();
120
+ for (const occ of occurrences) {
121
+ const bucket = samples[occ.category];
122
+ if (bucket.length < 3) bucket.push(occ);
123
+ }
124
+
125
+ // Order files by total descending so the "biggest offenders" land
126
+ // at the top of the report — that's the order an engineer wants to
127
+ // chip away at the surface in.
128
+ fileSummaries.sort((a, b) => b.total - a.total || a.file.localeCompare(b.file));
129
+
130
+ return {
131
+ totalFiles: fileSummaries.length,
132
+ totalOccurrences: occurrences.length,
133
+ byCategory,
134
+ files: fileSummaries,
135
+ samples,
136
+ };
137
+ }
138
+
139
+ function emptyCategoryRecord(): Record<HtmxCategory, number> {
140
+ return {
141
+ "event-handler": 0,
142
+ "form-swap": 0,
143
+ "click-swap": 0,
144
+ "auto-fetch": 0,
145
+ "oob-swap": 0,
146
+ boost: 0,
147
+ unmatched: 0,
148
+ };
149
+ }
150
+
151
+ function emptyCategorySamples(): Record<HtmxCategory, HtmxOccurrence[]> {
152
+ return {
153
+ "event-handler": [],
154
+ "form-swap": [],
155
+ "click-swap": [],
156
+ "auto-fetch": [],
157
+ "oob-swap": [],
158
+ boost: [],
159
+ unmatched: [],
160
+ };
161
+ }
162
+
163
+ /* ------------------------------------------------------------------ */
164
+ /* Per-file analysis */
165
+ /* ------------------------------------------------------------------ */
166
+
167
+ /**
168
+ * Parse a single source file and extract one occurrence per JSX
169
+ * opening-tag that carries any `hx-*` attribute. Exported so tests
170
+ * can exercise the parser without needing an FsAdapter.
171
+ */
172
+ export function analyzeFile(file: string, content: string): HtmxOccurrence[] {
173
+ const occurrences: HtmxOccurrence[] = [];
174
+ const seenTagStarts = new Set<number>();
175
+
176
+ // Anchored on the attribute name only (not the value), so JSX
177
+ // expression slots in the value (`hx-post={useSection({...})}`)
178
+ // don't trip the regex up.
179
+ const HX_ATTR_RE = /\bhx-([a-z]+(?:-[a-z]+)*(?::[A-Za-z]+)?)\b/g;
180
+
181
+ for (const m of content.matchAll(HX_ATTR_RE)) {
182
+ const attrIdx = m.index;
183
+ if (attrIdx === undefined) continue;
184
+ const tagOpen = findEnclosingTagOpen(content, attrIdx);
185
+ if (tagOpen < 0) continue;
186
+ if (seenTagStarts.has(tagOpen)) continue;
187
+ const tagClose = findOpeningTagClose(content, tagOpen);
188
+ if (tagClose < 0) continue;
189
+ seenTagStarts.add(tagOpen);
190
+
191
+ const tagSpan = content.slice(tagOpen, tagClose + 1);
192
+ const tag = extractTagName(tagSpan);
193
+ const attrs = collectHxAttrs(tagSpan);
194
+ const line = lineNumberAt(content, tagOpen);
195
+ const category = classify(tag, attrs);
196
+
197
+ occurrences.push({
198
+ category,
199
+ file,
200
+ line,
201
+ tag,
202
+ attrs,
203
+ });
204
+ }
205
+
206
+ return occurrences;
207
+ }
208
+
209
+ /**
210
+ * Walk backwards from `attrIdx` to the most recent `<` that starts an
211
+ * opening tag (`<TagName`). Returns the index of that `<`, or -1 if
212
+ * no plausible tag start is found before the start of the file.
213
+ *
214
+ * "Plausible tag start" = `<` followed by a letter or `_`, where the
215
+ * `<` is not part of a `</` (closing tag), `<<` (operator), or
216
+ * preceded by an operand that would make it a comparison.
217
+ */
218
+ function findEnclosingTagOpen(content: string, attrIdx: number): number {
219
+ for (let i = attrIdx - 1; i >= 0; i--) {
220
+ if (content[i] !== "<") continue;
221
+ // Closing tag — `</foo>` — wouldn't carry attributes.
222
+ if (content[i + 1] === "/") continue;
223
+ // Comparison / shift operator — left side is a non-tag char.
224
+ const next = content[i + 1];
225
+ if (!/[A-Za-z_]/.test(next ?? "")) continue;
226
+ // Be slightly defensive: if the character right before `<` is a
227
+ // closing-paren or another `>`, we're definitely in JSX. If it's
228
+ // an alpha char or `]`, this could be `a < b` — but `a < b` is
229
+ // followed by an *expression*, not a tag-name + space + `hx-…`,
230
+ // so the regex hit at attrIdx would be far away. Don't bother
231
+ // disambiguating; just return the candidate.
232
+ return i;
233
+ }
234
+ return -1;
235
+ }
236
+
237
+ /**
238
+ * From the `<` at `tagOpen`, walk forward to find the index of the
239
+ * `>` that closes the *opening tag* (not the close tag of the same
240
+ * element). Skips strings (single-, double-, backtick-quoted) and
241
+ * tracks balanced `{...}` expression slots so JSX expressions in
242
+ * attributes (`hx-post={useSection({...})}`) don't mislead us.
243
+ */
244
+ function findOpeningTagClose(content: string, tagOpen: number): number {
245
+ let i = tagOpen + 1;
246
+ const n = content.length;
247
+ while (i < n) {
248
+ const ch = content[i];
249
+ if (ch === '"' || ch === "'") {
250
+ i = skipStringQuote(content, i, ch);
251
+ continue;
252
+ }
253
+ if (ch === "`") {
254
+ i = skipTemplateLiteral(content, i);
255
+ continue;
256
+ }
257
+ if (ch === "{") {
258
+ i = skipBraceBalanced(content, i);
259
+ continue;
260
+ }
261
+ if (ch === "/" && content[i + 1] === ">") return i + 1;
262
+ if (ch === ">") return i;
263
+ i++;
264
+ }
265
+ return -1;
266
+ }
267
+
268
+ function skipStringQuote(content: string, start: number, quote: string): number {
269
+ let i = start + 1;
270
+ const n = content.length;
271
+ while (i < n) {
272
+ if (content[i] === "\\") {
273
+ i += 2;
274
+ continue;
275
+ }
276
+ if (content[i] === quote) return i + 1;
277
+ i++;
278
+ }
279
+ return n;
280
+ }
281
+
282
+ function skipTemplateLiteral(content: string, start: number): number {
283
+ let i = start + 1;
284
+ const n = content.length;
285
+ while (i < n) {
286
+ if (content[i] === "\\") {
287
+ i += 2;
288
+ continue;
289
+ }
290
+ if (content[i] === "`") return i + 1;
291
+ if (content[i] === "$" && content[i + 1] === "{") {
292
+ i = skipBraceBalanced(content, i + 1);
293
+ continue;
294
+ }
295
+ i++;
296
+ }
297
+ return n;
298
+ }
299
+
300
+ function skipBraceBalanced(content: string, openIdx: number): number {
301
+ let i = openIdx + 1;
302
+ let depth = 1;
303
+ const n = content.length;
304
+ while (i < n && depth > 0) {
305
+ const ch = content[i];
306
+ if (ch === '"' || ch === "'") {
307
+ i = skipStringQuote(content, i, ch);
308
+ continue;
309
+ }
310
+ if (ch === "`") {
311
+ i = skipTemplateLiteral(content, i);
312
+ continue;
313
+ }
314
+ if (ch === "{") {
315
+ depth++;
316
+ i++;
317
+ continue;
318
+ }
319
+ if (ch === "}") {
320
+ depth--;
321
+ i++;
322
+ continue;
323
+ }
324
+ i++;
325
+ }
326
+ return i;
327
+ }
328
+
329
+ function extractTagName(tagSpan: string): string {
330
+ // tagSpan starts with `<`. The tag name is /[A-Za-z_][\w.-]*/ until
331
+ // whitespace, `/`, or `>`.
332
+ const m = /^<([A-Za-z_][\w.-]*)/.exec(tagSpan);
333
+ return m?.[1] ?? "";
334
+ }
335
+
336
+ function collectHxAttrs(tagSpan: string): string[] {
337
+ // Strip JSX expression slots so `hx-post={someFn({hx-thing: 1})}`
338
+ // — which doesn't actually exist in JSX, but defensive — doesn't
339
+ // inflate attr counts. We match the same name shape as the entry
340
+ // regex.
341
+ const seen = new Set<string>();
342
+ const re = /\bhx-([a-z]+(?:-[a-z]+)*(?::[A-Za-z]+)?)\b/g;
343
+ for (const m of tagSpan.matchAll(re)) {
344
+ seen.add(`hx-${m[1]}`);
345
+ }
346
+ return [...seen].sort();
347
+ }
348
+
349
+ function lineNumberAt(content: string, idx: number): number {
350
+ let line = 1;
351
+ for (let i = 0; i < idx && i < content.length; i++) {
352
+ if (content[i] === "\n") line++;
353
+ }
354
+ return line;
355
+ }
356
+
357
+ /* ------------------------------------------------------------------ */
358
+ /* Classification */
359
+ /* ------------------------------------------------------------------ */
360
+
361
+ /**
362
+ * Bucket an element with attribute set `attrs` into one of the
363
+ * `HtmxCategory` values. Order is intentional: the more specific
364
+ * shapes are checked first.
365
+ *
366
+ * - **boost**: `hx-boost="true"` is a top-level switch — handled
367
+ * first so it doesn't conflate with click-swap/form-swap shapes.
368
+ * - **oob-swap**: `hx-swap-oob` / `hx-select-oob` is a structural
369
+ * pattern that almost never has a clean React equivalent.
370
+ * - **auto-fetch**: a fetch attribute paired with a `hx-trigger` that
371
+ * isn't user-driven (`keyup`, `intersect`, `revealed`, `load`,
372
+ * `every:`).
373
+ * - **form-swap**: `hx-post` (with or without `hx-target`/`hx-swap`)
374
+ * on a `<form>`, OR with an explicit `hx-trigger="submit"`.
375
+ * - **click-swap**: `hx-get` (or `hx-post`) on anything else with
376
+ * `hx-target` — the dominant button-driven pattern.
377
+ * - **event-handler**: `hx-on:*` or `hx-on:click` etc with no
378
+ * fetch attr — pure client-side handler that happens to be wired
379
+ * via htmx for historical consistency.
380
+ * - **unmatched**: anything that didn't fit cleanly. Reported as a
381
+ * manual-review bucket.
382
+ */
383
+ export function classify(tag: string, attrs: string[]): HtmxCategory {
384
+ const has = (name: string) => attrs.includes(name);
385
+ const hasAny = (re: RegExp) => attrs.some((a) => re.test(a));
386
+
387
+ if (has("hx-boost")) return "boost";
388
+ if (has("hx-swap-oob") || has("hx-select-oob")) return "oob-swap";
389
+
390
+ const hasFetch = hasAny(/^hx-(get|post|put|patch|delete)$/);
391
+ // htmx supports both colon (`hx-on:click`) and dash (`hx-on-click`)
392
+ // syntax for event handlers; HTML's spec doesn't allow `:` in
393
+ // attribute names so htmx 2.x canonicalised the dash form. Match
394
+ // both. The dash form is followed by an event name (`click`,
395
+ // `htmx-config-request`, etc.), the colon form by `:event`.
396
+ const hasOn = hasAny(/^hx-on(?:[:-]|$)/);
397
+ const hasTarget = has("hx-target");
398
+ const triggerIsAutoLike =
399
+ // We don't have the value of the trigger here — just whether
400
+ // the attribute exists. The dominant non-form pattern *with*
401
+ // hx-trigger is "keyup changed delay:Xms" or "intersect".
402
+ // Without value access, we infer "auto-fetch" by elimination:
403
+ // fetch + hx-trigger + non-form. Form-submit explicit triggers
404
+ // are caught by the form-swap branch via `tag === "form"`.
405
+ has("hx-trigger");
406
+
407
+ if (hasFetch) {
408
+ // `<form>` element — form-swap (regardless of trigger).
409
+ if (tag === "form") return "form-swap";
410
+ // `<input>`, `<textarea>` etc carrying a fetch attribute —
411
+ // they fire on input event, treat as auto-fetch.
412
+ if (tag === "input" || tag === "textarea" || tag === "select") {
413
+ return "auto-fetch";
414
+ }
415
+ // Non-form / non-input element with fetch + auto-trigger →
416
+ // auto-fetch (could be `<div hx-trigger="intersect" hx-get>`).
417
+ if (triggerIsAutoLike && !hasTarget) return "auto-fetch";
418
+ // Default for the dominant button-driven pattern.
419
+ return "click-swap";
420
+ }
421
+
422
+ if (hasOn) return "event-handler";
423
+
424
+ return "unmatched";
425
+ }
@@ -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
+ });
@@ -76,8 +76,9 @@ function showHelp() {
76
76
  Options:
77
77
  --source <dir> Site directory to audit (default: .)
78
78
  --fix Auto-apply mechanical fixes for the safe rules
79
- (dead-lib-shims, dead-runtime-shim, local-widgets-types).
80
- Other rules still detect-only.
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.
81
82
  --json Emit machine-readable JSON instead of pretty text
82
83
  --strict Exit code 2 if any warning-severity findings exist
83
84
  --help, -h Show this help