@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.
- package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -0
- package/.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md +527 -0
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +63 -12
- package/MIGRATION_TOOLING_PLAN.md +142 -31
- package/package.json +3 -2
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/post-cleanup/rules.ts +73 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +108 -0
- package/scripts/migrate-post-cleanup.ts +3 -2
|
@@ -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
|
-
|
|
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
|