@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.
@@ -0,0 +1,305 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ _internals,
4
+ transformHtmxOnEvents,
5
+ } from "./htmx-on-events";
6
+
7
+ const { STANDARD_EVENT_MAP, TODO_MARKER } = _internals;
8
+
9
+ describe("transformHtmxOnEvents — basic renames", () => {
10
+ it("renames hx-on:click → onClick (colon variant)", () => {
11
+ const src = `<button hx-on:click={() => alert("hi")}>click</button>`;
12
+ const r = transformHtmxOnEvents(src);
13
+ expect(r.changed).toBe(true);
14
+ expect(r.content).toBe(
15
+ `<button onClick={() => alert("hi")}>click</button>`,
16
+ );
17
+ expect(r.notes[0]).toContain("Renamed 1 hx-on:* attribute(s)");
18
+ expect(r.notes[0]).toContain("onClick=1");
19
+ });
20
+
21
+ it("renames hx-on-click → onClick (dash variant)", () => {
22
+ const src = `<button hx-on-click={fn}>click</button>`;
23
+ const r = transformHtmxOnEvents(src);
24
+ expect(r.changed).toBe(true);
25
+ expect(r.content).toBe(`<button onClick={fn}>click</button>`);
26
+ });
27
+
28
+ it("preserves whitespace around `=`", () => {
29
+ const src = `<button hx-on:click = {fn}>x</button>`;
30
+ const r = transformHtmxOnEvents(src);
31
+ expect(r.content).toBe(`<button onClick = {fn}>x</button>`);
32
+ });
33
+
34
+ it("renames every standard event in the map", () => {
35
+ for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
36
+ const src = `<x hx-on:${htmxEvent}={fn}/>`;
37
+ const r = transformHtmxOnEvents(src);
38
+ expect(r.content, `event=${htmxEvent}`).toBe(`<x ${reactName}={fn}/>`);
39
+ }
40
+ });
41
+
42
+ it("renames every standard event in the map (dash variant)", () => {
43
+ for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
44
+ const src = `<x hx-on-${htmxEvent}={fn}/>`;
45
+ const r = transformHtmxOnEvents(src);
46
+ expect(r.content, `event=${htmxEvent}`).toBe(`<x ${reactName}={fn}/>`);
47
+ }
48
+ });
49
+
50
+ it("handles multiple events on the same element", () => {
51
+ const src = `<input hx-on:change={a} hx-on:keyup={b} hx-on:focus={c}/>`;
52
+ const r = transformHtmxOnEvents(src);
53
+ expect(r.content).toBe(
54
+ `<input onChange={a} onKeyUp={b} onFocus={c}/>`,
55
+ );
56
+ expect(r.notes[0]).toContain("Renamed 3 hx-on:* attribute(s)");
57
+ });
58
+
59
+ it("handles multi-line attribute values", () => {
60
+ const src = `<button
61
+ hx-on:click={() => {
62
+ setLoading(true);
63
+ doStuff();
64
+ }}>
65
+ Submit
66
+ </button>`;
67
+ const r = transformHtmxOnEvents(src);
68
+ expect(r.content).toContain("onClick={() => {");
69
+ expect(r.content).toContain("setLoading(true);");
70
+ expect(r.content).not.toContain("hx-on");
71
+ });
72
+
73
+ it("handles string-valued events (rare but legal htmx)", () => {
74
+ const src = `<button hx-on:click="alert('hi')">x</button>`;
75
+ const r = transformHtmxOnEvents(src);
76
+ expect(r.content).toBe(`<button onClick="alert('hi')">x</button>`);
77
+ });
78
+ });
79
+
80
+ describe("transformHtmxOnEvents — what stays untouched", () => {
81
+ it("leaves htmx lifecycle events alone (htmx-config-request, htmx-before-request, ...)", () => {
82
+ const src = `<form hx-post="/x"
83
+ hx-on:htmx-before-request={a}
84
+ hx-on:htmx-config-request={b}
85
+ hx-on-htmx-after-swap={c}
86
+ />`;
87
+ const r = transformHtmxOnEvents(src);
88
+ expect(r.changed).toBe(false);
89
+ expect(r.content).toBe(src);
90
+ });
91
+
92
+ it("renames standard events but preserves htmx lifecycle on the same element", () => {
93
+ const src = `<form hx-post="/x" hx-on:submit={validate} hx-on:htmx-before-request={addCsrf}>...</form>`;
94
+ const r = transformHtmxOnEvents(src);
95
+ expect(r.changed).toBe(true);
96
+ expect(r.content).toBe(
97
+ `<form hx-post="/x" onSubmit={validate} hx-on:htmx-before-request={addCsrf}>...</form>`,
98
+ );
99
+ });
100
+
101
+ it("leaves unknown/custom events alone (no React synthetic equivalent)", () => {
102
+ const src = `<x hx-on:my-custom-event={fn} hx-on-other-thing={fn2}/>`;
103
+ const r = transformHtmxOnEvents(src);
104
+ expect(r.changed).toBe(false);
105
+ expect(r.content).toBe(src);
106
+ });
107
+
108
+ it("leaves non-event hx-* attributes alone (hx-post, hx-target, hx-swap, ...)", () => {
109
+ const src = `<form hx-post="/x" hx-target="#r" hx-swap="innerHTML" hx-trigger="submit"/>`;
110
+ const r = transformHtmxOnEvents(src);
111
+ expect(r.changed).toBe(false);
112
+ expect(r.content).toBe(src);
113
+ });
114
+
115
+ it("leaves already-React onClick alone", () => {
116
+ const src = `<button onClick={fn}>x</button>`;
117
+ const r = transformHtmxOnEvents(src);
118
+ expect(r.changed).toBe(false);
119
+ expect(r.content).toBe(src);
120
+ });
121
+
122
+ it("does not match attribute names that merely contain 'hx-on' as a substring", () => {
123
+ const src = `<x data-hx-onfoo={fn} aria-hx-on="x"/>`;
124
+ const r = transformHtmxOnEvents(src);
125
+ expect(r.changed).toBe(false);
126
+ });
127
+
128
+ it("returns unchanged on files with no hx-on at all (fast path)", () => {
129
+ const src = `<button onClick={fn}>x</button>\n<form hx-post="/x"/>`;
130
+ const r = transformHtmxOnEvents(src);
131
+ expect(r.changed).toBe(false);
132
+ expect(r.content).toBe(src);
133
+ expect(r.notes).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("transformHtmxOnEvents — Fresh-isms TODO injection", () => {
138
+ it("injects a top-of-file MIGRATION TODO when handler references useScript()", () => {
139
+ const src = `import { useScript } from "site/sdk/useScript.ts";
140
+
141
+ export default function X() {
142
+ return <button hx-on:click={useScript(handler)}>x</button>;
143
+ }
144
+ `;
145
+ const r = transformHtmxOnEvents(src);
146
+ expect(r.changed).toBe(true);
147
+ expect(r.content.startsWith(TODO_MARKER)).toBe(true);
148
+ expect(r.content).toContain("onClick={useScript(handler)}");
149
+ expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(true);
150
+ });
151
+
152
+ it("injects a top-of-file MIGRATION TODO when handler references globalThis.window.STOREFRONT", () => {
153
+ const src = `<button hx-on:click={() => { globalThis.window.STOREFRONT.CART.addToCart({}); }}>buy</button>`;
154
+ const r = transformHtmxOnEvents(src);
155
+ expect(r.content.startsWith(TODO_MARKER)).toBe(true);
156
+ });
157
+
158
+ it("injects a top-of-file MIGRATION TODO when handler references STOREFRONT.* (shorthand)", () => {
159
+ const src = `import { STOREFRONT } from "site/sdk";\n<button hx-on:click={() => STOREFRONT.CART.addToCart()}>x</button>`;
160
+ const r = transformHtmxOnEvents(src);
161
+ expect(r.content.startsWith(TODO_MARKER)).toBe(true);
162
+ });
163
+
164
+ it("does NOT inject a TODO when no Fresh-isms are detected", () => {
165
+ const src = `<button hx-on:click={() => setOpen(true)}>x</button>`;
166
+ const r = transformHtmxOnEvents(src);
167
+ expect(r.changed).toBe(true);
168
+ expect(r.content.startsWith(TODO_MARKER)).toBe(false);
169
+ expect(r.content).toBe(`<button onClick={() => setOpen(true)}>x</button>`);
170
+ expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(false);
171
+ });
172
+
173
+ it("preserves a leading shebang and inserts the TODO after it", () => {
174
+ const src = `#!/usr/bin/env node\n<button hx-on:click={useScript(fn)}>x</button>\n`;
175
+ const r = transformHtmxOnEvents(src);
176
+ expect(r.content.startsWith("#!/usr/bin/env node\n")).toBe(true);
177
+ expect(r.content.split("\n")[1]).toBe(TODO_MARKER);
178
+ });
179
+
180
+ it("does not inject the TODO twice when the codemod is rerun (idempotent)", () => {
181
+ const src = `<button hx-on:click={useScript(fn)}>x</button>`;
182
+ const first = transformHtmxOnEvents(src);
183
+ const second = transformHtmxOnEvents(first.content);
184
+ expect(second.changed).toBe(false);
185
+ const occurrences = first.content.split(TODO_MARKER).length - 1;
186
+ expect(occurrences).toBe(1);
187
+ });
188
+ });
189
+
190
+ describe("transformHtmxOnEvents — idempotency + edge cases", () => {
191
+ it("is idempotent on a clean rewritten file (rerunning is a no-op)", () => {
192
+ const src = `<button onClick={fn}>x</button>`;
193
+ const first = transformHtmxOnEvents(src);
194
+ const second = transformHtmxOnEvents(first.content);
195
+ expect(first.changed).toBe(false);
196
+ expect(second.changed).toBe(false);
197
+ expect(second.content).toBe(src);
198
+ });
199
+
200
+ it("is idempotent on a file that just got rewritten (rerun produces same output)", () => {
201
+ const src = `<button hx-on:click={fn}>x</button>`;
202
+ const first = transformHtmxOnEvents(src);
203
+ const second = transformHtmxOnEvents(first.content);
204
+ expect(second.changed).toBe(false);
205
+ expect(second.content).toBe(first.content);
206
+ });
207
+
208
+ it("handles JSX components (capitalized tag names) just like lowercase intrinsics", () => {
209
+ const src = `<Accordion.Trigger hx-on-click={toggle}>open</Accordion.Trigger>`;
210
+ const r = transformHtmxOnEvents(src);
211
+ expect(r.content).toBe(
212
+ `<Accordion.Trigger onClick={toggle}>open</Accordion.Trigger>`,
213
+ );
214
+ });
215
+
216
+ it("renames within a long file containing many tags", () => {
217
+ const src = Array.from({ length: 20 })
218
+ .map((_, i) => `<button key={${i}} hx-on:click={() => setCount(${i})}>${i}</button>`)
219
+ .join("\n");
220
+ const r = transformHtmxOnEvents(src);
221
+ expect(r.notes[0]).toContain("Renamed 20 hx-on:* attribute(s)");
222
+ expect(r.content.split("onClick=").length - 1).toBe(20);
223
+ expect(r.content.includes("hx-on")).toBe(false);
224
+ });
225
+ });
226
+
227
+ describe("transformHtmxOnEvents — als-shaped fixtures", () => {
228
+ it("AddToBagButton: hx-on-click + Fresh useScript → onClick + TODO", () => {
229
+ const src = `import { useScript } from "site/sdk/useScript.ts";
230
+
231
+ interface Props { productId: string; }
232
+
233
+ export default function AddToBagButton({ productId }: Props) {
234
+ const handler = (id: string) => {
235
+ globalThis.window.STOREFRONT.CART.addToCart({ id });
236
+ };
237
+ return (
238
+ <button
239
+ class="btn"
240
+ hx-on-click={useScript(handler, productId)}
241
+ >
242
+ Add to bag
243
+ </button>
244
+ );
245
+ }
246
+ `;
247
+ const r = transformHtmxOnEvents(src);
248
+ expect(r.changed).toBe(true);
249
+ expect(r.content).toContain("onClick={useScript(handler, productId)}");
250
+ expect(r.content.startsWith(TODO_MARKER)).toBe(true);
251
+ const codeBody = r.content.split("§ Pattern 1 (event-handler).\n")[1];
252
+ expect(codeBody).not.toMatch(/\bhx-on\b/);
253
+ });
254
+
255
+ it("SearchInput: keeps hx-post/hx-target/etc, renames hx-on:change", () => {
256
+ const src = `<input
257
+ type="search"
258
+ hx-post={searchUrl}
259
+ hx-target="#suggestions"
260
+ hx-swap="innerHTML"
261
+ hx-trigger="keyup changed delay:300ms"
262
+ hx-sync="closest form:abort"
263
+ hx-on:change={(e) => { e.preventDefault(); }}
264
+ />`;
265
+ const r = transformHtmxOnEvents(src);
266
+ expect(r.changed).toBe(true);
267
+ expect(r.content).toContain(`onChange={(e) => { e.preventDefault(); }}`);
268
+ expect(r.content).toContain("hx-post={searchUrl}");
269
+ expect(r.content).toContain('hx-target="#suggestions"');
270
+ expect(r.content).toContain('hx-swap="innerHTML"');
271
+ expect(r.content).toContain('hx-sync="closest form:abort"');
272
+ });
273
+
274
+ it("RecoveryPassword: form with mixed standard + htmx lifecycle hooks", () => {
275
+ const src = `<form
276
+ hx-post={url}
277
+ hx-target="#form"
278
+ hx-swap="outerHTML"
279
+ hx-trigger="submit"
280
+ hx-disabled-elt="find button"
281
+ hx-indicator="#loader"
282
+ hx-select="#form"
283
+ hx-on:submit={(e) => { /* validate */ }}
284
+ hx-on-htmx-before-request={(e) => addCsrf(e)}
285
+ >
286
+
287
+ </form>`;
288
+ const r = transformHtmxOnEvents(src);
289
+ expect(r.changed).toBe(true);
290
+ expect(r.content).toContain(`onSubmit={(e) => { /* validate */ }}`);
291
+ expect(r.content).toContain("hx-on-htmx-before-request={(e) => addCsrf(e)}");
292
+ expect(r.content).toContain("hx-post={url}");
293
+ expect(r.content).toContain('hx-trigger="submit"');
294
+ });
295
+
296
+ it("Footer.tsx-style: simple onClick with no Fresh-ism — no TODO", () => {
297
+ const src = `<button hx-on:click={() => setOpen((p) => !p)}>menu</button>`;
298
+ const r = transformHtmxOnEvents(src);
299
+ expect(r.changed).toBe(true);
300
+ expect(r.content.startsWith(TODO_MARKER)).toBe(false);
301
+ expect(r.content).toBe(
302
+ `<button onClick={() => setOpen((p) => !p)}>menu</button>`,
303
+ );
304
+ });
305
+ });
@@ -0,0 +1,193 @@
1
+ import type { TransformResult } from "../types";
2
+
3
+ /**
4
+ * htmx → React event-name mapping for the seven `hx-on:*` / `hx-on-*`
5
+ * patterns that have a 1:1 React equivalent.
6
+ *
7
+ * The codemod is intentionally narrow: only events present in this map
8
+ * get renamed. Custom DOM events (`hx-on:my-thing`), htmx lifecycle
9
+ * events (`hx-on:htmx-config-request`), and any event that requires a
10
+ * React `addEventListener` in `useEffect` get left alone — the
11
+ * `htmx-residue` audit rule catches them and points at the per-pattern
12
+ * `htmx-rewrite.md` skill.
13
+ *
14
+ * Kept lowercase to mirror htmx's own naming and to keep the regex
15
+ * simple. JSX attribute names ARE case-sensitive; htmx writers always
16
+ * use lowercase.
17
+ */
18
+ const STANDARD_EVENT_MAP: Record<string, string> = {
19
+ click: "onClick",
20
+ dblclick: "onDoubleClick",
21
+ submit: "onSubmit",
22
+ reset: "onReset",
23
+ change: "onChange",
24
+ input: "onInput",
25
+ keyup: "onKeyUp",
26
+ keydown: "onKeyDown",
27
+ keypress: "onKeyPress",
28
+ focus: "onFocus",
29
+ blur: "onBlur",
30
+ focusin: "onFocus",
31
+ focusout: "onBlur",
32
+ mouseover: "onMouseOver",
33
+ mouseout: "onMouseOut",
34
+ mouseenter: "onMouseEnter",
35
+ mouseleave: "onMouseLeave",
36
+ mousedown: "onMouseDown",
37
+ mouseup: "onMouseUp",
38
+ mousemove: "onMouseMove",
39
+ contextmenu: "onContextMenu",
40
+ load: "onLoad",
41
+ scroll: "onScroll",
42
+ paste: "onPaste",
43
+ copy: "onCopy",
44
+ cut: "onCut",
45
+ dragstart: "onDragStart",
46
+ drag: "onDrag",
47
+ dragend: "onDragEnd",
48
+ drop: "onDrop",
49
+ dragenter: "onDragEnter",
50
+ dragleave: "onDragLeave",
51
+ dragover: "onDragOver",
52
+ wheel: "onWheel",
53
+ touchstart: "onTouchStart",
54
+ touchend: "onTouchEnd",
55
+ touchmove: "onTouchMove",
56
+ touchcancel: "onTouchCancel",
57
+ };
58
+
59
+ /**
60
+ * Marker comment we inject when a rename happened *and* the surviving
61
+ * handler bodies reference Fresh-only globals (`useScript`,
62
+ * `globalThis.window.STOREFRONT`, `STOREFRONT.*`). The comment is the
63
+ * only file-level annotation the codemod emits — per-occurrence
64
+ * comments would balloon the diff for a 88-rename file like
65
+ * als-storefront's hot paths. It is detected by an idempotency check
66
+ * so re-running the codemod does not double-inject.
67
+ */
68
+ const TODO_MARKER = "// MIGRATION TODO (codemod: htmx-on-event-rename):";
69
+
70
+ const TODO_BLOCK = `${TODO_MARKER}
71
+ // hx-on:* attributes were auto-renamed to React event handlers, but
72
+ // the handler bodies were preserved verbatim. They may reference
73
+ // Fresh-only globals like \`globalThis.window.STOREFRONT\` or
74
+ // \`useScript(...)\`. Verify each handler matches a TanStack Start
75
+ // equivalent (state hook, platform hook, or server function) — see
76
+ // .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md
77
+ // § Pattern 1 (event-handler).`;
78
+
79
+ /**
80
+ * Matches a single `hx-on:eventname=` or `hx-on-eventname=` attribute
81
+ * occurrence in the source.
82
+ *
83
+ * `\b` before `hx-on` keeps us from matching inside identifiers like
84
+ * `withHx-on` (impossible in TS but defensive). The separator capture
85
+ * group distinguishes colon vs dash so we can flip both syntactic
86
+ * variants in one pass.
87
+ *
88
+ * The event name allows a-zA-Z0-9 and `-` so multi-segment htmx events
89
+ * (`htmx-config-request`, `htmx-before-request`) are captured intact and
90
+ * we can decide *after* the match whether to rename or skip.
91
+ */
92
+ const HX_ON_ATTR_RE = /\bhx-on([:\-])([a-zA-Z][a-zA-Z0-9-]*)(\s*=)/g;
93
+
94
+ /**
95
+ * Heuristic patterns for handler bodies that reference Fresh-specific
96
+ * globals. Used only to gate TODO injection — false positives are
97
+ * harmless (extra comment), false negatives are tolerable (audit will
98
+ * still catch htmx residue elsewhere).
99
+ */
100
+ const FRESH_BODY_PATTERNS: readonly RegExp[] = [
101
+ /\buseScript\s*\(/,
102
+ /\bglobalThis\.window\.STOREFRONT\b/,
103
+ /\bSTOREFRONT\./,
104
+ ];
105
+
106
+ /**
107
+ * Rewrite `hx-on:click={...}` and `hx-on-click={...}` attributes to
108
+ * the React equivalent (`onClick={...}`), preserving the handler value
109
+ * verbatim. Renames only happen for events with a known React mapping.
110
+ *
111
+ * - htmx lifecycle events (`htmx-config-request`, `htmx-before-request`,
112
+ * `htmx-after-swap`, etc.) are left alone — they require manual
113
+ * rewrite per the htmx-rewrite skill, and the `htmx-residue` audit
114
+ * rule will catch them post-migration.
115
+ * - Unknown custom events (e.g. `hx-on:my-custom-thing`) are left alone
116
+ * — React doesn't have synthetic equivalents for arbitrary custom
117
+ * events; the engineer must wire those via `addEventListener` in
118
+ * `useEffect`, which the codemod cannot generate safely.
119
+ *
120
+ * If any rename happens AND the file contains Fresh-only body
121
+ * patterns, a single file-level TODO comment is injected at the top so
122
+ * reviewers know the bodies still need attention. Idempotent — running
123
+ * the codemod twice produces identical output.
124
+ */
125
+ export function transformHtmxOnEvents(content: string): TransformResult {
126
+ if (!content.includes("hx-on")) {
127
+ return { content, changed: false, notes: [] };
128
+ }
129
+
130
+ const renamesByEvent = new Map<string, number>();
131
+ let renamed = 0;
132
+
133
+ const next = content.replace(
134
+ HX_ON_ATTR_RE,
135
+ (match, _sep: string, eventName: string, equals: string) => {
136
+ const lower = eventName.toLowerCase();
137
+ if (lower.startsWith("htmx-")) return match;
138
+
139
+ const reactName = STANDARD_EVENT_MAP[lower];
140
+ if (!reactName) return match;
141
+
142
+ renamed += 1;
143
+ renamesByEvent.set(reactName, (renamesByEvent.get(reactName) ?? 0) + 1);
144
+ return `${reactName}${equals}`;
145
+ },
146
+ );
147
+
148
+ if (renamed === 0) {
149
+ return { content, changed: false, notes: [] };
150
+ }
151
+
152
+ const notes: string[] = [];
153
+ const breakdown = [...renamesByEvent.entries()]
154
+ .sort(([a], [b]) => a.localeCompare(b))
155
+ .map(([name, n]) => `${name}=${n}`)
156
+ .join(", ");
157
+ notes.push(`Renamed ${renamed} hx-on:* attribute(s) → React events (${breakdown})`);
158
+
159
+ const hasFreshIsms = FRESH_BODY_PATTERNS.some((re) => re.test(next));
160
+ const hasMarker = next.includes(TODO_MARKER);
161
+
162
+ if (!hasFreshIsms || hasMarker) {
163
+ return { content: next, changed: true, notes };
164
+ }
165
+
166
+ const final = injectTopOfFileTodo(next);
167
+ notes.push(
168
+ "Injected MIGRATION TODO — handler body references Fresh-only globals (useScript / globalThis.window.STOREFRONT)",
169
+ );
170
+ return { content: final, changed: true, notes };
171
+ }
172
+
173
+ /**
174
+ * Insert TODO_BLOCK as a top-of-file comment, *after* any leading
175
+ * shebang line or block comment. Keeps directives like `"use client"`
176
+ * intact (they live below the comment block, which is fine).
177
+ */
178
+ function injectTopOfFileTodo(source: string): string {
179
+ if (source.startsWith("#!")) {
180
+ const newlineIdx = source.indexOf("\n");
181
+ if (newlineIdx === -1) return `${source}\n${TODO_BLOCK}\n`;
182
+ return `${source.slice(0, newlineIdx + 1)}${TODO_BLOCK}\n${source.slice(newlineIdx + 1)}`;
183
+ }
184
+ return `${TODO_BLOCK}\n${source}`;
185
+ }
186
+
187
+ /** Exported for direct unit tests. */
188
+ export const _internals = {
189
+ STANDARD_EVENT_MAP,
190
+ HX_ON_ATTR_RE,
191
+ TODO_MARKER,
192
+ FRESH_BODY_PATTERNS,
193
+ };
@@ -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