@assure-one/design-system 1.33.0 → 1.35.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,223 @@
1
+ /**
2
+ * CM-04 — Button family: legacy single-axis `variant` → the `variant` +
3
+ * `intent` pair (class R; plan §29 seq 5, registry `C-BTN-VAR`, `C-BTN-ACCENT`).
4
+ *
5
+ * W3-02 gave `Button`, `LinkButton` and `SubmitButton` the two axes of
6
+ * ADR-007 — `variant` is the emphasis, `intent` the meaning — and kept the
7
+ * single-axis words as deprecated aliases, each a fixed pair
8
+ * (`LEGACY_BUTTON_VARIANTS` in `src/primitives/behavior/button-base.ts`,
9
+ * ADR-007 §4). Every alias renders the exact classes it always did, so the
10
+ * rewrite is pixel-neutral:
11
+ *
12
+ * | legacy word | becomes | `intent` written? |
13
+ * | ------------- | ------------------------------------ | ---------------------------------------- |
14
+ * | `primary` | `variant="solid"` | no — `brand` is what `solid` defaults to |
15
+ * | `secondary` | `variant="soft"` | no — `neutral` is `soft`'s default |
16
+ * | `destructive` | `variant="solid" intent="danger"` | yes |
17
+ * | `success` | `variant="solid" intent="success"` | yes |
18
+ * | `accent` | left alone, reported | its suite-action colour has no pair (D13) |
19
+ * | `dashed` | left alone, reported | its dashed border has no pair (2.x alias) |
20
+ *
21
+ * `intent` is omitted where it equals what the new emphasis resolves on its
22
+ * own (`DEFAULT_BUTTON_INTENT`), so the output is the smallest call that
23
+ * renders today's pixels. `ghost`, `outline` and `link` are emphases already
24
+ * and are not touched.
25
+ *
26
+ * ## What it reports
27
+ *
28
+ * | rule | action | severity | why |
29
+ * | ----------------------- | ------- | -------- | ---------------------------------------------------------------------------- |
30
+ * | `variant-split` | applied | low | the alias became its pair (or its emphasis alone, when the intent is the default) |
31
+ * | `destructive-as-danger` | applied | medium | `destructive` → `solid` + `danger`: the word moves to D4's default (`danger` canonical, ADR-007 §5); a human confirms |
32
+ * | `legacy-look` | review | medium | `accent` (D13) and `dashed` have a look no pair renders; they stay until decided / 2.x |
33
+ * | `dynamic-intent` | review | medium | a legacy word next to `intent={expr}` whose alias intent is not the new emphasis's default: when the expression is `undefined` the alias renders its own intent, the new word its default |
34
+ * | `dynamic-variant` | review | high | `variant={expr}`: the value is not visible here; map it by hand |
35
+ * | `spread-props` | review | medium | `{...props}` may carry `variant` or `intent`; rewriting the literal could change which wins |
36
+ * | `unknown-variant` | review | high | a word the Button never accepted |
37
+ *
38
+ * A legacy word next to a **literal** `intent` (`variant="destructive"
39
+ * intent="success"`) is rewritten to its emphasis only: the intent already
40
+ * wins on both sides of the rewrite (ADR-007 §4, `resolveButtonStyle`).
41
+ * `intent="destructive"` is not touched — that is CM-17, gated by D4.
42
+ *
43
+ * Elements that already speak the new words are not touched (idempotency).
44
+ * Local components with the same names are not touched; test files are
45
+ * skipped, as by the scanner that measures C-BTN-VAR.
46
+ */
47
+ import { analyseForms } from "../lib/forms.mjs";
48
+ import { applyEdits, insertAttribute, openingOf, replaceAttribute } from "../lib/jsx-edit.mjs";
49
+
50
+ export const meta = {
51
+ id: "CM-04",
52
+ title: "Button family: legacy variant → variant + intent",
53
+ class: "R",
54
+ oneShot: false,
55
+ requires: { codemods: [], dsVersion: null },
56
+ parses: ["code"],
57
+ includeTests: false,
58
+ usesTypeScript: true,
59
+ usesPostcss: false,
60
+ registryIds: ["C-BTN-VAR", "C-BTN-ACCENT"],
61
+ };
62
+
63
+ /** The design-system components that read `buttonVariants` (W3-02). */
64
+ export const BUTTONS = new Set(["Button", "LinkButton", "SubmitButton"]);
65
+
66
+ /**
67
+ * The legacy alias map — `LEGACY_BUTTON_VARIANTS` of
68
+ * `src/primitives/behavior/button-base.ts`, row for row (ADR-007 §4). A row
69
+ * with a `look` is a legacy-only look: no pair renders it, so the word stays.
70
+ * `tests/codemods/cm-04.test.mjs` checks this table against the source.
71
+ */
72
+ export const LEGACY_VARIANTS = {
73
+ primary: { variant: "solid", intent: "brand" },
74
+ secondary: { variant: "soft", intent: "neutral" },
75
+ destructive: { variant: "solid", intent: "danger" },
76
+ success: { variant: "solid", intent: "success" },
77
+ accent: { variant: "solid", intent: "brand", look: "accent" },
78
+ dashed: { variant: "outline", intent: "neutral", look: "dashed" },
79
+ };
80
+
81
+ /**
82
+ * The intent each emphasis renders when only `variant` is passed —
83
+ * `DEFAULT_BUTTON_INTENT` of button-base.ts (ADR-007 §3). Where the alias
84
+ * intent equals this, `intent` is omitted from the output.
85
+ */
86
+ export const DEFAULT_INTENT = {
87
+ solid: "brand",
88
+ soft: "neutral",
89
+ outline: "neutral",
90
+ ghost: "neutral",
91
+ link: "brand",
92
+ };
93
+
94
+ /** The emphases: already vocabulary, never rewritten. */
95
+ export const EMPHASES = new Set(Object.keys(DEFAULT_INTENT));
96
+
97
+ /** The registry contract a legacy word belongs to. */
98
+ export const contractOf = (word) => (word === "accent" ? "C-BTN-ACCENT" : "C-BTN-VAR");
99
+
100
+ /**
101
+ * What a literal legacy `variant` becomes, or null when the word is not a
102
+ * legacy alias: `{ rule, action, severity, variant, intent }`, `intent` being
103
+ * `null` when it is the new emphasis's default and is left out.
104
+ */
105
+ export function classifyVariant(word) {
106
+ const pair = LEGACY_VARIANTS[word];
107
+ if (!pair) return null;
108
+ if (pair.look) return { rule: "legacy-look", action: "review", severity: "medium" };
109
+ const intent = pair.intent === DEFAULT_INTENT[pair.variant] ? null : pair.intent;
110
+ if (word === "destructive") {
111
+ return {
112
+ rule: "destructive-as-danger",
113
+ action: "applied",
114
+ severity: "medium",
115
+ variant: pair.variant,
116
+ intent,
117
+ };
118
+ }
119
+ return {
120
+ rule: "variant-split",
121
+ action: "applied",
122
+ severity: "low",
123
+ variant: pair.variant,
124
+ intent,
125
+ };
126
+ }
127
+
128
+ export function transform(file, { ts }) {
129
+ const facts = analyseForms(ts, file.source, file.rel);
130
+ const findings = [];
131
+ const edits = [];
132
+
133
+ for (const el of facts.elements) {
134
+ if (!el.isDs || !BUTTONS.has(el.base) || el.component !== el.base) continue;
135
+ const variant = el.props.get("variant");
136
+ if (!variant) continue;
137
+ const word = variant.expression || variant.literals.length !== 1 ? null : variant.literals[0];
138
+ if (word !== null && EMPHASES.has(word)) continue;
139
+
140
+ const intentAttr = el.props.get("intent") ?? null;
141
+ const base = {
142
+ line: el.line,
143
+ registryId: contractOf(word),
144
+ match: `<${el.tag} variant=${variant.text ?? ""}>`,
145
+ component: el.component,
146
+ gate: null,
147
+ detail: {
148
+ variant: word,
149
+ dynamic: variant.expression,
150
+ intent: intentAttr
151
+ ? intentAttr.expression
152
+ ? null
153
+ : (intentAttr.literals[0] ?? null)
154
+ : undefined,
155
+ },
156
+ };
157
+ const review = (rule, severity) => findings.push({ ...base, rule, severity, action: "review" });
158
+
159
+ if (word === null) {
160
+ review("dynamic-variant", "high");
161
+ continue;
162
+ }
163
+ if (el.spread) {
164
+ review("spread-props", "medium");
165
+ continue;
166
+ }
167
+ const outcome = classifyVariant(word);
168
+ if (!outcome) {
169
+ review("unknown-variant", "high");
170
+ continue;
171
+ }
172
+ if (outcome.action === "review") {
173
+ review(outcome.rule, outcome.severity);
174
+ continue;
175
+ }
176
+ // A dynamic `intent` may be `undefined` at runtime, in which case the
177
+ // alias renders its own intent and the new emphasis its default: only
178
+ // safe when the two agree.
179
+ if (intentAttr && intentAttr.expression && outcome.intent !== null) {
180
+ review("dynamic-intent", "medium");
181
+ continue;
182
+ }
183
+
184
+ const opening = openingOf(ts, el.node);
185
+ const replaced = replaceAttribute(
186
+ ts,
187
+ facts.sf,
188
+ opening,
189
+ "variant",
190
+ `variant="${outcome.variant}"`,
191
+ );
192
+ if (!replaced) {
193
+ review("dynamic-variant", "high");
194
+ continue;
195
+ }
196
+ edits.push(replaced);
197
+ const writesIntent = outcome.intent !== null && !intentAttr;
198
+ if (writesIntent) {
199
+ edits.push(
200
+ insertAttribute(ts, facts.sf, opening, `intent="${outcome.intent}"`, { after: "variant" }),
201
+ );
202
+ }
203
+ findings.push({
204
+ ...base,
205
+ rule: outcome.rule,
206
+ severity: outcome.severity,
207
+ action: "applied",
208
+ detail: {
209
+ ...base.detail,
210
+ variant: word,
211
+ emphasis: outcome.variant,
212
+ intent: writesIntent ? outcome.intent : base.detail.intent,
213
+ },
214
+ });
215
+ }
216
+
217
+ return {
218
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
219
+ findings,
220
+ notTransformed: [],
221
+ parseErrors: facts.parseErrors,
222
+ };
223
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * CM-05 — Button family: `iconLeft` / `iconRight` → `iconStart` / `iconEnd`
3
+ * (class A; plan §29 seq 1, registry `C-BTN-ICONPROPS`).
4
+ *
5
+ * W3-07 named the two icon slots of `Button`, `LinkButton` and `SubmitButton`
6
+ * logically — `iconStart` is the left in LTR and the right in RTL — and kept
7
+ * `iconLeft` / `iconRight` as deprecated aliases of the same slots
8
+ * (`ButtonIconProps`, `resolveButtonIcons` in
9
+ * `src/primitives/behavior/button-base.ts`). Each alias is the same slot under
10
+ * another name, so the rename is a pure rename: only the attribute's name is
11
+ * replaced and its value — a one-line icon or a multi-line element — keeps its
12
+ * text and its formatting.
13
+ *
14
+ * | rule | registry | what happens |
15
+ * | -------------- | -------------- | ----------------------------------------- |
16
+ * | `icon-renamed` | C-BTN-ICONPROPS | `iconLeft` → `iconStart`, `iconRight` → `iconEnd` |
17
+ *
18
+ * ## What it leaves alone, and lists under "could not be transformed"
19
+ *
20
+ * - An element that passes **both names of one side** (`iconStart` and
21
+ * `iconLeft`): the component throws on that in development — two names,
22
+ * one slot — and a rename would turn the error into a duplicate attribute.
23
+ * Neither side of that element is touched; a human deletes one.
24
+ * - An element with a spread (`<Button iconLeft={…} {...props}>`): the spread
25
+ * may carry either name for the same slot, and which one wins depends on
26
+ * the order of the attributes — renaming the literal could change it.
27
+ *
28
+ * Elements that already use the new names are not touched (idempotency).
29
+ * Local components with the same names are not touched; test files are
30
+ * skipped, as by the scanner that measures C-BTN-ICONPROPS.
31
+ */
32
+ import { analyseForms } from "../lib/forms.mjs";
33
+ import { applyEdits, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
34
+
35
+ export const meta = {
36
+ id: "CM-05",
37
+ title: "Button family: iconLeft/iconRight → iconStart/iconEnd",
38
+ class: "A",
39
+ oneShot: false,
40
+ requires: { codemods: [], dsVersion: null },
41
+ parses: ["code"],
42
+ includeTests: false,
43
+ usesTypeScript: true,
44
+ usesPostcss: false,
45
+ registryIds: ["C-BTN-ICONPROPS"],
46
+ };
47
+
48
+ /** The design-system components with the two icon slots (W3-07). */
49
+ export const ICON_BUTTONS = new Set(["Button", "LinkButton", "SubmitButton"]);
50
+
51
+ /**
52
+ * Deprecated alias → the slot it names (`ButtonIconProps` of button-base.ts).
53
+ * `tests/codemods/cm-05.test.mjs` checks it against the source.
54
+ */
55
+ export const ICON_SLOTS = {
56
+ iconLeft: "iconStart",
57
+ iconRight: "iconEnd",
58
+ };
59
+
60
+ export function transform(file, { ts }) {
61
+ const facts = analyseForms(ts, file.source, file.rel);
62
+ const findings = [];
63
+ const notTransformed = [];
64
+ const edits = [];
65
+
66
+ for (const el of facts.elements) {
67
+ if (!el.isDs || !ICON_BUTTONS.has(el.base) || el.component !== el.base) continue;
68
+ const legacy = Object.keys(ICON_SLOTS).filter((name) => el.props.has(name));
69
+ if (!legacy.length) continue;
70
+
71
+ const clashes = legacy.filter((name) => el.props.has(ICON_SLOTS[name]));
72
+ if (clashes.length) {
73
+ notTransformed.push({
74
+ line: el.line,
75
+ reason: "both-names",
76
+ detail: `<${el.tag}> passes ${clashes
77
+ .map((name) => `\`${name}\` and \`${ICON_SLOTS[name]}\``)
78
+ .join(" and ")} — one slot, two names; delete the deprecated one by hand`,
79
+ });
80
+ continue;
81
+ }
82
+ if (el.spread) {
83
+ notTransformed.push({
84
+ line: el.line,
85
+ reason: "spread-props",
86
+ detail: `<${el.tag} {…}> — the spread may carry \`${legacy
87
+ .map((name) => ICON_SLOTS[name])
88
+ .join("\` or \`")}\` for the same slot; rename by hand once you know it does not`,
89
+ });
90
+ continue;
91
+ }
92
+
93
+ const opening = openingOf(ts, el.node);
94
+ for (const name of legacy) {
95
+ const edit = renameAttribute(ts, facts.sf, opening, name, ICON_SLOTS[name]);
96
+ if (!edit) continue;
97
+ edits.push(edit);
98
+ findings.push({
99
+ line: el.props.get(name).line,
100
+ registryId: "C-BTN-ICONPROPS",
101
+ rule: "icon-renamed",
102
+ match: `<${el.tag} ${name}>`,
103
+ component: el.component,
104
+ action: "applied",
105
+ gate: null,
106
+ detail: { from: name, to: ICON_SLOTS[name] },
107
+ });
108
+ }
109
+ }
110
+
111
+ return {
112
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
113
+ findings,
114
+ notTransformed,
115
+ parseErrors: facts.parseErrors,
116
+ };
117
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * CM-06 — `tone=` → `intent=` on StatusDot, IconTile, Spinner, SegmentedProgress
3
+ * and SuiteProgress (class R; plan §29 seq 6, registry `C-TONE`).
4
+ *
5
+ * W3-21 gave the five components the shared `intent` axis of ADR-007 and kept
6
+ * `tone` as a deprecated alias. Every legacy word resolves to one intent and
7
+ * renders the same classes, so the rename is pixel-neutral; the value map is
8
+ * the components' own alias tables (`STATUS_DOT_TONE_TO_INTENT`, …):
9
+ *
10
+ * | component | `tone` → `intent` |
11
+ * | ----------------- | -------------------------------------------------------------------------- |
12
+ * | StatusDot | `pro` → `brand`; `danger` `warning` `info` `success` `neutral` unchanged |
13
+ * | IconTile | `pro` → `brand`; status words unchanged; `audit` `books` `tax` → review |
14
+ * | Spinner | `muted` → `neutral`, `accent` → `info`, `destructive` → `danger`, `current` → removed (the default); `success` `warning` unchanged |
15
+ * | SegmentedProgress | `default` → `brand`, `destructive` → `danger`; `success` `warning` `info` unchanged; `tax` `audit` `accounting` → review |
16
+ * | SuiteProgress | `pro` → `brand`; `audit` `books` `tax` → review |
17
+ *
18
+ * ## What it reports
19
+ *
20
+ * | rule | action | severity | why |
21
+ * | --------------- | ------- | -------- | ---------------------------------------------------------------------------- |
22
+ * | `tone-renamed` | applied | low | same word on the new axis (or `default` → `brand`, `destructive` → `danger`) |
23
+ * | `pro-as-brand` | applied | medium | `pro` is the brand colour — unless the call site meant "generic purple" (Portal's firm-branded context); a human confirms |
24
+ * | `tone-removed` | applied | low | `tone="current"` on Spinner is the default; the attribute goes |
25
+ * | `product-tone` | review | medium | `audit` `books` `tax` `accounting` are product hues, never intents (ADR-007 §2): they stay on `tone` until the products own a service → colour map |
26
+ * | `dynamic-tone` | review | high | `tone={expr}`: the value is not visible here; map it by hand |
27
+ * | `spread-props` | review | medium | `{...props}` may carry `tone` too; renaming the literal could change which one wins |
28
+ * | `has-intent` | review | low | both axes present; `intent` already wins, delete `tone` by hand |
29
+ * | `unknown-tone` | review | high | a word the component never accepted |
30
+ *
31
+ * Elements that already speak `intent` and have no `tone` are not touched
32
+ * (idempotency). Local components with the same names are not touched; test
33
+ * files are skipped, as by the scanner that measures C-TONE.
34
+ */
35
+ import { analyseForms } from "../lib/forms.mjs";
36
+ import { applyEdits, openingOf, removeAttribute, replaceAttribute } from "../lib/jsx-edit.mjs";
37
+
38
+ export const meta = {
39
+ id: "CM-06",
40
+ title: "tone → intent on StatusDot, IconTile, Spinner, SegmentedProgress and SuiteProgress",
41
+ class: "R",
42
+ oneShot: false,
43
+ requires: { codemods: [], dsVersion: null },
44
+ parses: ["code"],
45
+ includeTests: false,
46
+ usesTypeScript: true,
47
+ usesPostcss: false,
48
+ registryIds: ["C-TONE"],
49
+ };
50
+
51
+ /** Marker: the legacy word has no intent and stays on `tone` (a product hue). */
52
+ export const PRODUCT = Symbol("product-tone");
53
+ /** Marker: the legacy word is the default and the attribute is simply removed. */
54
+ export const REMOVE = Symbol("remove");
55
+
56
+ /**
57
+ * `tone` → `intent` per component (mirrors the alias tables in
58
+ * src/primitives/{status-dot,icon-tile,spinner,segmented-progress,suite-progress}.tsx).
59
+ */
60
+ export const TONE_MAP = {
61
+ StatusDot: {
62
+ danger: "danger",
63
+ warning: "warning",
64
+ info: "info",
65
+ success: "success",
66
+ pro: "brand",
67
+ neutral: "neutral",
68
+ },
69
+ IconTile: {
70
+ pro: "brand",
71
+ neutral: "neutral",
72
+ info: "info",
73
+ success: "success",
74
+ warning: "warning",
75
+ danger: "danger",
76
+ audit: PRODUCT,
77
+ books: PRODUCT,
78
+ tax: PRODUCT,
79
+ },
80
+ Spinner: {
81
+ current: REMOVE,
82
+ muted: "neutral",
83
+ accent: "info",
84
+ success: "success",
85
+ warning: "warning",
86
+ destructive: "danger",
87
+ },
88
+ SegmentedProgress: {
89
+ default: "brand",
90
+ success: "success",
91
+ warning: "warning",
92
+ destructive: "danger",
93
+ info: "info",
94
+ tax: PRODUCT,
95
+ audit: PRODUCT,
96
+ accounting: PRODUCT,
97
+ },
98
+ SuiteProgress: {
99
+ pro: "brand",
100
+ audit: PRODUCT,
101
+ books: PRODUCT,
102
+ tax: PRODUCT,
103
+ },
104
+ };
105
+
106
+ export const TONED_COMPONENTS = new Set(Object.keys(TONE_MAP));
107
+
108
+ /** The rule a literal tone falls under, or null when the word is unknown. */
109
+ export function classifyTone(component, tone) {
110
+ const target = TONE_MAP[component]?.[tone];
111
+ if (target === undefined) return null;
112
+ if (target === PRODUCT) return { rule: "product-tone", action: "review", severity: "medium" };
113
+ if (target === REMOVE)
114
+ return { rule: "tone-removed", action: "applied", severity: "low", intent: null };
115
+ if (tone === "pro")
116
+ return { rule: "pro-as-brand", action: "applied", severity: "medium", intent: target };
117
+ return { rule: "tone-renamed", action: "applied", severity: "low", intent: target };
118
+ }
119
+
120
+ export function transform(file, { ts }) {
121
+ const facts = analyseForms(ts, file.source, file.rel);
122
+ const findings = [];
123
+ const edits = [];
124
+
125
+ for (const el of facts.elements) {
126
+ if (!el.isDs || !TONED_COMPONENTS.has(el.base) || el.component !== el.base) continue;
127
+ const tone = el.props.get("tone");
128
+ if (!tone) continue;
129
+
130
+ const base = {
131
+ line: el.line,
132
+ registryId: "C-TONE",
133
+ match: `<${el.tag} tone=${tone.text ?? ""}>`,
134
+ component: el.component,
135
+ gate: null,
136
+ detail: {
137
+ tone: tone.expression ? null : (tone.literals[0] ?? null),
138
+ dynamic: tone.expression,
139
+ },
140
+ };
141
+ const review = (rule, severity) => findings.push({ ...base, rule, severity, action: "review" });
142
+
143
+ if (tone.expression || tone.literals.length !== 1) {
144
+ review("dynamic-tone", "high");
145
+ continue;
146
+ }
147
+ if (el.spread) {
148
+ review("spread-props", "medium");
149
+ continue;
150
+ }
151
+ if (el.props.has("intent")) {
152
+ review("has-intent", "low");
153
+ continue;
154
+ }
155
+ const word = tone.literals[0];
156
+ const outcome = classifyTone(el.base, word);
157
+ if (!outcome) {
158
+ review("unknown-tone", "high");
159
+ continue;
160
+ }
161
+ if (outcome.action === "review") {
162
+ review(outcome.rule, outcome.severity);
163
+ continue;
164
+ }
165
+ const opening = openingOf(ts, el.node);
166
+ const edit =
167
+ outcome.intent === null
168
+ ? removeAttribute(ts, facts.sf, opening, "tone")
169
+ : replaceAttribute(ts, facts.sf, opening, "tone", `intent="${outcome.intent}"`);
170
+ if (!edit) {
171
+ review("dynamic-tone", "high");
172
+ continue;
173
+ }
174
+ edits.push(edit);
175
+ findings.push({
176
+ ...base,
177
+ rule: outcome.rule,
178
+ severity: outcome.severity,
179
+ action: "applied",
180
+ detail: { ...base.detail, intent: outcome.intent },
181
+ });
182
+ }
183
+
184
+ return {
185
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
186
+ findings,
187
+ notTransformed: [],
188
+ parseErrors: facts.parseErrors,
189
+ };
190
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * CM-12 — Button: `type="submit"` where the intent is evident, a report for
3
+ * the rest (class R; plan §29 seq 8, registry `C-BTN-TYPE`, feeds 2.0 gate
4
+ * G4).
5
+ *
6
+ * A `Button` that passes no `type` renders `<button>` without one, which the
7
+ * browser treats as `type="submit"`: inside a `<form>` it submits. 65% of the
8
+ * applications' Buttons pass no `type`, and the design system flips the
9
+ * default to `"button"` in 2.0 (W9-05) — a change that silently stops a form
10
+ * from submitting wherever the untyped Button was the submitter. CM-12
11
+ * writes down, before the flip, which Buttons submit **on purpose**.
12
+ *
13
+ * ## What it changes
14
+ *
15
+ * Only what is evident from the file itself: an untyped design-system
16
+ * `Button` that is lexically inside a lower-case `<form>` element **in the
17
+ * same file**, has no `onClick` and no spread gets `type="submit"`. That
18
+ * states what the browser does today, so the edit is behaviour-neutral; the
19
+ * report lists it (rule `submit-in-form`) because a human still has to agree
20
+ * that submitting is what the button is for — a "Cancel" that matches this
21
+ * shape is a bug today, and the codemod has just made it visible.
22
+ *
23
+ * ## What it reports and leaves to a human
24
+ *
25
+ * | rule | severity | why a human decides |
26
+ * | ------------------ | -------- | ------------------------------------------------------------------------------ |
27
+ * | `in-form-onclick` | high | inside a `<form>` with an `onClick`: submit with side effects, or a plain button? |
28
+ * | `in-form-spread` | high | inside a `<form>` with `{...props}`: the spread may or may not carry `type` |
29
+ * | `outside-form` | low | no `<form>` in this file; if the component is rendered in one elsewhere, it submits today |
30
+ *
31
+ * Choosing `submit` or `button` is behaviour, which plan §29 never automates
32
+ * beyond the evident case. Nothing is followed across files: a component
33
+ * defined in this file and rendered inside a `<form>` by another is
34
+ * `outside-form`. A capitalised `<Form>` is not a form element (the
35
+ * react-hook-form / shadcn `Form` is a context provider), so it does not
36
+ * count.
37
+ *
38
+ * Not reported: `SubmitButton` (always `type="submit"`), `LinkButton` (an
39
+ * anchor), a Button with any `type` — literal or dynamic — and a Button with
40
+ * `asChild` (it renders its child, `type` does not apply to it). Test files
41
+ * are skipped, as by the scanner that measures G4.
42
+ */
43
+ import { analyseForms } from "../lib/forms.mjs";
44
+ import { applyInsertions, insertAttribute, openingOf } from "../lib/jsx-edit.mjs";
45
+
46
+ export const meta = {
47
+ id: "CM-12",
48
+ title: 'Button: type="submit" on evident form submitters, a review list for the rest',
49
+ class: "R",
50
+ oneShot: false,
51
+ requires: { codemods: [], dsVersion: null },
52
+ parses: ["code"],
53
+ includeTests: false,
54
+ usesTypeScript: true,
55
+ usesPostcss: false,
56
+ registryIds: ["C-BTN-TYPE"],
57
+ };
58
+
59
+ /** The one design-system component whose untyped rendering submits. */
60
+ export const TYPED_BUTTON = "Button";
61
+
62
+ /** The element that gives an untyped button a form to submit. Lower-case only. */
63
+ export const FORM_TAG = "form";
64
+
65
+ /** Whether the element has a lower-case `<form>` ancestor in this file. */
66
+ export function insideForm(elements, el) {
67
+ for (let i = el.parent; i !== null; i = elements[i].parent) {
68
+ if (elements[i].tag === FORM_TAG) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ export function transform(file, { ts }) {
74
+ const facts = analyseForms(ts, file.source, file.rel);
75
+ const findings = [];
76
+ const edits = [];
77
+
78
+ for (const el of facts.elements) {
79
+ if (!el.isDs || el.base !== TYPED_BUTTON || el.component !== el.base) continue;
80
+ if (el.props.has("type") || el.props.has("asChild")) continue;
81
+
82
+ const inForm = insideForm(facts.elements, el);
83
+ const onClick = el.props.has("onClick");
84
+ const base = {
85
+ line: el.line,
86
+ registryId: "C-BTN-TYPE",
87
+ match: `<${el.tag}>`,
88
+ component: el.component,
89
+ gate: "G4",
90
+ detail: { inForm, onClick, spread: el.spread },
91
+ };
92
+
93
+ if (!inForm) {
94
+ findings.push({ ...base, rule: "outside-form", severity: "low", action: "review" });
95
+ continue;
96
+ }
97
+ if (el.spread) {
98
+ findings.push({ ...base, rule: "in-form-spread", severity: "high", action: "review" });
99
+ continue;
100
+ }
101
+ if (onClick) {
102
+ findings.push({ ...base, rule: "in-form-onclick", severity: "high", action: "review" });
103
+ continue;
104
+ }
105
+ edits.push(insertAttribute(ts, facts.sf, openingOf(ts, el.node), 'type="submit"'));
106
+ findings.push({ ...base, rule: "submit-in-form", severity: "medium", action: "applied" });
107
+ }
108
+
109
+ return {
110
+ output: edits.length ? applyInsertions(file.source, edits) : file.source,
111
+ findings,
112
+ notTransformed: [],
113
+ parseErrors: facts.parseErrors,
114
+ };
115
+ }