@assure-one/design-system 1.33.0 → 1.34.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.
@@ -15,7 +15,7 @@ node node_modules/@assure-one/design-system/codemods/run.mjs list
15
15
  node node_modules/@assure-one/design-system/codemods/run.mjs CM-15 src --dry --print
16
16
 
17
17
  # Apply, and write a JSON + Markdown report next to the run
18
- node node_modules/@assure-one/design-system/codemods/run.mjs CM-05 src --report reports/cm-05
18
+ node node_modules/@assure-one/design-system/codemods/run.mjs CM-02 src --report reports/cm-02
19
19
 
20
20
  # Everything this project is missing, in program order
21
21
  node node_modules/@assure-one/design-system/codemods/run.mjs upgrade --dry
@@ -81,12 +81,19 @@ Commit it with the codemod's changes. It is what makes
81
81
  second time (a `--dry` preview is still allowed, with a warning);
82
82
  - **prerequisites enforceable** — `requires.codemods` refuses to run before the
83
83
  codemods it builds on, and `requires.dsVersion` before the release that ships
84
- the replacement API;
84
+ the replacement API; a codemod whose prerequisite depends on its own state
85
+ exports a `guard` hook and calls `requireLedger([…])` from it (CM-03 phase 2
86
+ runs only when the ledger shows phase 1 **and** CM-02);
85
87
  - **`upgrade` exact** — a consumer several versions behind gets precisely the
86
88
  codemods it is missing, in program order.
87
89
 
88
- Report-only codemods and dry runs never touch it. The consumer scanner reads
89
- the file and the migration dashboard shows the applied ids
90
+ A transforming codemod is recorded when it ran for real: on its **first** run
91
+ even when every call site was already in the target shape (`filesChanged: 0` —
92
+ a project whose Buttons were all explicit must still be able to satisfy a
93
+ prerequisite on CM-02), and again whenever a later run changed a file. A run
94
+ that changes nothing in a project that already records the codemod adds no
95
+ entry. Report-only codemods and dry runs never touch it. The consumer scanner
96
+ reads the file and the migration dashboard shows the applied ids
90
97
  (`migration-status/<app>.json` → `codemodLedger`).
91
98
 
92
99
  ## Reports
@@ -100,15 +107,65 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
100
107
 
101
108
  ## Codemods
102
109
 
103
- | Id | Class | What it does |
104
- | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
105
- | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
106
- | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
107
- | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
108
- | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
110
+ | Id | Class | What it does |
111
+ | ----- | ----- | ------------------------------------------------------------------------------------------------------------------------------------- |
112
+ | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
113
+ | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
114
+ | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
115
+ | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
116
+ | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
117
+ | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
109
118
 
110
119
  The remaining ids of plan §29 land with the waves that ship their replacement
111
- APIs.
120
+ APIs (CM-04 and CM-05 need the Button `intent` and `iconStart`/`iconEnd` API of
121
+ W3-02 and W3-07).
122
+
123
+ ### CM-02 — Button explicit default size
124
+
125
+ Class A. Every `Button`, `SubmitButton` and `LinkButton` imported from the
126
+ design system (aliased and namespace imports included) that passes no `size`
127
+ gets `size="md"` — the value the component resolves today
128
+ (`defaultVariants.size`, 40px), so nothing moves on screen. It exists because
129
+ a size _meaning_ can only change once no call site depends on the default:
130
+ after CM-02, the D5 size flip (CM-03 phase 2, W9-06) touches nothing a
131
+ consumer did not write down, and 2.0 gate G6 counts exactly this.
132
+
133
+ The attribute goes after `variant` when there is one (the order the
134
+ applications use) and first otherwise, on its own line when the element's
135
+ attributes are one per line — a Prettier-formatted file stays formatted.
136
+
137
+ | rule | Contract | What happens |
138
+ | --------------- | ---------- | -------------------- |
139
+ | `implicit-size` | C-BTN-SIZE | `size="md"` is added |
140
+
141
+ Not touched, and listed under "could not be transformed": an element with a
142
+ spread (`<Button {...props}>`) — the spread may already carry `size`. Not
143
+ touched and not reported: an element with any `size` (the icon sizes, a
144
+ dynamic `size={x}`), a local component called `Button`, test files (the
145
+ scanner that measures G6 counts production files only).
146
+
147
+ ### CM-12 — Button `type="submit"` where evident
148
+
149
+ Class R. An untyped `Button` renders `<button>` without a `type`, which the
150
+ browser treats as `submit`; 2.0 flips the default to `"button"` (W9-05,
151
+ C-BTN-TYPE, gate G4). CM-12 writes down, before the flip, which Buttons submit
152
+ on purpose — and only where the file itself makes that evident:
153
+
154
+ | rule | Action | Severity | When |
155
+ | ----------------- | ------- | -------- | ---------------------------------------------------------------------------- |
156
+ | `submit-in-form` | applied | medium | inside a lower-case `<form>` in the same file, no `onClick`, no spread |
157
+ | `in-form-onclick` | review | high | inside a `<form>` with an `onClick` — submit with side effects, or a button? |
158
+ | `in-form-spread` | review | high | inside a `<form>` with `{...props}` — the spread may carry `type` |
159
+ | `outside-form` | review | low | no `<form>` in this file; rendered inside one elsewhere, it submits today |
160
+
161
+ The applied change states what the browser does today, so it is
162
+ behaviour-neutral; it is still in the report because a human has to agree that
163
+ submitting is what the button is for — a "Cancel" matching that shape is a
164
+ bug today, now visible. Nothing is followed across files (a component
165
+ rendered inside a `<form>` by another component is `outside-form`), and a
166
+ capitalised `<Form>` does not count: the react-hook-form / shadcn `Form` is a
167
+ context provider, not a form element. `SubmitButton`, `LinkButton`, a Button
168
+ with any `type`, a Button with `asChild` and test files are not reported.
112
169
 
113
170
  ### CM-14 — hidden-input mirror finder
114
171
 
@@ -271,26 +328,41 @@ export function transform(file, { ts, postcss }) {
271
328
  ```
272
329
 
273
330
  `usesTypeScript` and `usesPostcss` ask the runner to resolve that parser from
274
- the project and inject it; a codemod never imports one itself.
331
+ the project and inject it; a codemod never imports one itself. A transforming
332
+ codemod edits the source with `lib/jsx-edit.mjs` (`insertAttribute`,
333
+ `applyInsertions`), which keeps the file's own formatting; a finding it
334
+ applied carries `action: "applied"`, one it leaves to a human
335
+ `action: "review"`.
336
+
337
+ A codemod may also export `guard(context)`, run before any file is read, that
338
+ returns the problems that refuse the run. It receives `{ root, ledger,
339
+ dsVersion, dry, requireLedger }`, where `requireLedger(["CM-02"])` returns one
340
+ problem per prerequisite the ledger lacks. `meta.requires.codemods` is the
341
+ declarative form of the same check; the hook is for a prerequisite that
342
+ depends on the codemod's own state (CM-03 phase 2).
275
343
 
276
344
  The runner owns everything else: the guards, which files are read, writing
277
345
  files and the ledger, the report, and the rule that a class X codemod may not
278
346
  change anything.
279
347
 
280
348
  Fixtures live in `codemods/__test__/<id>/*.snap` — one file per case, holding a
281
- small project and the expected findings (and, for transforming codemods, the
282
- expected output):
349
+ small project, the expected findings and, for transforming codemods, the
350
+ expected output of every file that changes:
283
351
 
284
352
  ```
285
353
  === file: src/thing.tsx
286
354
  <source>
355
+ === output: src/thing.tsx
356
+ <the source after the codemod>
287
357
  === expect
288
358
  { "findings": [ ["src/thing.tsx:12", "C-DOM-03", "class-on-component", "<class token>"] ] }
289
359
  ```
290
360
 
291
361
  `codemods/__test__/harness.mjs` materialises a fixture in a temporary
292
- directory, runs the codemod **twice** and asserts that the second run finds the
293
- same things and changes nothing — the idempotency requirement of plan §29.
362
+ directory, runs the codemod **twice** and asserts that the second run changes
363
+ nothing and finds what the first run did not apply — the idempotency
364
+ requirement of plan §29. A file without an `=== output:` section must come out
365
+ unchanged; a fixture with none is report-only and may write no ledger.
294
366
  `tests/codemods/*.test.mjs` runs all of it as part of `pnpm test:contracts`.
295
367
 
296
368
  Fixtures are `.snap` on purpose: Tailwind scans every other repository file for
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Source edits for transforming codemods.
3
+ *
4
+ * A transforming codemod (class A or R) changes consumer files that are
5
+ * formatted by the consumer's Prettier, so an edit must leave the file in the
6
+ * shape Prettier would produce: a new attribute goes on its own line when the
7
+ * element's attributes are one per line, and inline when they are inline. The
8
+ * edits are computed against the parsed tree and applied to the original text
9
+ * from the end backwards, so nothing else in the file moves.
10
+ *
11
+ * As everywhere in `codemods/lib`, the TypeScript module is passed in.
12
+ */
13
+
14
+ const isWhitespace = (text) => /^\s*$/.test(text);
15
+
16
+ /**
17
+ * The edit that inserts `text` (e.g. `size="md"`) as an attribute of the JSX
18
+ * opening or self-closing element `opening`.
19
+ *
20
+ * The attribute goes right after the attribute named `after` when the element
21
+ * has one, otherwise first. The separator copies the whitespace the element
22
+ * already uses before that position (a newline plus the indentation for
23
+ * one-attribute-per-line elements, a space otherwise).
24
+ *
25
+ * @returns {{ pos: number, text: string }}
26
+ */
27
+ export function insertAttribute(ts, sf, opening, text, { after = null } = {}) {
28
+ const source = sf.text;
29
+ const attrs = opening.attributes.properties;
30
+ const attributesStart = opening.attributes.pos;
31
+ const separatorBefore = (index) => {
32
+ const from = index === 0 ? attributesStart : attrs[index - 1].getEnd();
33
+ const gap = source.slice(from, attrs[index].getStart(sf));
34
+ return gap && isWhitespace(gap) ? gap : " ";
35
+ };
36
+
37
+ if (after) {
38
+ const index = attrs.findIndex(
39
+ (attr) => ts.isJsxAttribute(attr) && attr.name.getText(sf) === after,
40
+ );
41
+ if (index >= 0) {
42
+ return { pos: attrs[index].getEnd(), text: `${separatorBefore(index)}${text}` };
43
+ }
44
+ }
45
+ const separator = attrs.length ? separatorBefore(0) : " ";
46
+ return { pos: attributesStart, text: `${separator}${text}` };
47
+ }
48
+
49
+ /** `source` with every `{ pos, text }` insertion applied. */
50
+ export function applyInsertions(source, edits) {
51
+ let out = source;
52
+ for (const edit of [...edits].sort((a, b) => b.pos - a.pos)) {
53
+ out = `${out.slice(0, edit.pos)}${edit.text}${out.slice(edit.pos)}`;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** The opening element of a JSX element node (a self-closing one is its own). */
59
+ export const openingOf = (ts, node) => (ts.isJsxElement(node) ? node.openingElement : node);
@@ -56,9 +56,25 @@ export function recordApplied(root, { id, appliedAt, dsVersion, filesChanged })
56
56
  return { schema: LEDGER_SCHEMA, applied };
57
57
  }
58
58
 
59
+ /**
60
+ * The problems that stop codemod `id` while the codemods in `ids` are not in
61
+ * the ledger — one line per missing prerequisite, `[]` when all are there.
62
+ *
63
+ * `meta.requires.codemods` is the declarative form and goes through here. A
64
+ * codemod whose prerequisite depends on its own state calls it from its
65
+ * `guard` hook instead: CM-03 phase 2 runs only when the ledger shows phase 1
66
+ * and CM-02 (`requireLedger(["CM-02"])`, plan §29).
67
+ */
68
+ export function requireLedger(ledger, ids, { id = "This codemod" } = {}) {
69
+ const applied = appliedIds(ledger);
70
+ return ids
71
+ .filter((required) => !applied.has(required))
72
+ .map((required) => `${id} needs ${required} to be applied first (not in ${LEDGER_FILE}).`);
73
+ }
74
+
59
75
  /**
60
76
  * Why `meta` may not be applied to a project with this ledger and installed
61
- * DS version, or `null`. Checked before any file is read.
77
+ * DS version, or `[]`. Checked before any file is read.
62
78
  */
63
79
  export function guardProblems(meta, ledger, dsVersion, compareVersions) {
64
80
  const problems = [];
@@ -69,11 +85,7 @@ export function guardProblems(meta, ledger, dsVersion, compareVersions) {
69
85
  `${meta.id} is a one-shot codemod and the ledger shows it was already applied${when ? ` (${when})` : ""}.`,
70
86
  );
71
87
  }
72
- for (const required of meta.requires?.codemods ?? []) {
73
- if (!ids.has(required)) {
74
- problems.push(`${meta.id} needs ${required} to be applied first (not in ${LEDGER_FILE}).`);
75
- }
76
- }
88
+ problems.push(...requireLedger(ledger, meta.requires?.codemods ?? [], { id: meta.id }));
77
89
  const minimum = meta.requires?.dsVersion;
78
90
  if (minimum && compareVersions(dsVersion, minimum) < 0) {
79
91
  problems.push(
@@ -3,6 +3,8 @@
3
3
  * codemods listed here can be run by id; the sequence is the plan's.
4
4
  */
5
5
  export const CODEMODS = [
6
+ { id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
7
+ { id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
6
8
  { id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
7
9
  { id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
8
10
  { id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
@@ -63,6 +63,7 @@ export function renderMarkdown(report) {
63
63
  const what = [
64
64
  f.component,
65
65
  f.rule,
66
+ f.action,
66
67
  f.severity && `severity ${f.severity}`,
67
68
  f.gate && `gate ${f.gate}`,
68
69
  f.confidence && `confidence ${f.confidence}`,
@@ -15,7 +15,7 @@ import {
15
15
  loadPostcss,
16
16
  loadTypeScript,
17
17
  } from "./environment.mjs";
18
- import { appliedIds, guardProblems, readLedger, recordApplied } from "./ledger.mjs";
18
+ import { appliedIds, guardProblems, readLedger, recordApplied, requireLedger } from "./ledger.mjs";
19
19
  import { loadCodemod } from "./registry.mjs";
20
20
 
21
21
  export const REPORT_SCHEMA = 1;
@@ -37,18 +37,31 @@ export class Refused extends Error {
37
37
  * @param {string[]} [options.paths] files or directories (default: the root)
38
38
  * @param {boolean} [options.dry] report only; never write files or the ledger
39
39
  * @param {() => Date} [options.now] clock, for tests
40
+ * @param {(id: string) => Promise<object|null>} [options.load] codemod loader, for tests
40
41
  * @returns {Promise<object>} the report
41
42
  */
42
43
  export async function runCodemod(
43
44
  id,
44
- { root, paths = [], dry = false, now = () => new Date() } = {},
45
+ { root, paths = [], dry = false, now = () => new Date(), load = loadCodemod } = {},
45
46
  ) {
46
- const mod = await loadCodemod(id);
47
+ const mod = await load(id);
47
48
  if (!mod) throw new Refused([`Unknown codemod ${id}. \`run.mjs list\` shows the available ids.`]);
48
49
  const meta = mod.meta;
49
50
  const dsVersion = installedDsVersion();
50
51
  const ledger = readLedger(root);
51
52
  const problems = guardProblems(meta, ledger, dsVersion, compareVersions);
53
+ // A codemod whose prerequisite depends on its own state (CM-03 phase 2)
54
+ // exports `guard`; it gets the ledger and `requireLedger(ids)` bound to it.
55
+ if (typeof mod.guard === "function") {
56
+ const extra = await mod.guard({
57
+ root,
58
+ ledger,
59
+ dsVersion,
60
+ dry,
61
+ requireLedger: (ids) => requireLedger(ledger, ids, { id: meta.id }),
62
+ });
63
+ problems.push(...(extra ?? []));
64
+ }
52
65
  // A dry run may preview an already-applied one-shot codemod; missing
53
66
  // prerequisites still refuse, because the preview would be wrong.
54
67
  const blocking = dry ? problems.filter((p) => !p.includes("already applied")) : problems;
@@ -108,7 +121,13 @@ export async function runCodemod(
108
121
  String(a.match).localeCompare(String(b.match)),
109
122
  );
110
123
 
111
- const applied = meta.class !== "X" && !dry && changed.length > 0;
124
+ // A transforming codemod is recorded when it ran for real: on its first run
125
+ // even when every call site was already in the target shape (a prerequisite
126
+ // such as CM-02 must be satisfiable in such a project), and again whenever
127
+ // it changed a file. A run that changes nothing in a project that already
128
+ // records it adds no entry.
129
+ const applied =
130
+ meta.class !== "X" && !dry && (changed.length > 0 || !appliedIds(ledger).has(meta.id));
112
131
  const appliedAt = now().toISOString();
113
132
  if (applied)
114
133
  recordApplied(root, { id: meta.id, appliedAt, dsVersion, filesChanged: changed.length });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * CM-02 — Button: make the implicit default size explicit (class A; plan §29
3
+ * seq 4, registry `C-BTN-SIZE`, prerequisite of the size remap CM-03 and of
4
+ * 2.0 gate G6).
5
+ *
6
+ * Every `Button`, `SubmitButton` and `LinkButton` imported from the design
7
+ * system that passes no `size` gets `size="md"` — the value the component
8
+ * resolves today (`defaultVariants.size = "md"`, 40px), so the change is
9
+ * pixel-neutral. It exists because a size *meaning* can only change once no
10
+ * call site depends on the default: after CM-02, flipping what `md` or the
11
+ * default means (D5, W9-06) touches nothing a consumer did not write down.
12
+ *
13
+ * ## What it changes
14
+ *
15
+ * | rule | registry | what happens |
16
+ * | --------------- | ---------- | ----------------------------------------------------- |
17
+ * | `implicit-size` | C-BTN-SIZE | `size="md"` is added, after `variant` when there is one |
18
+ *
19
+ * The attribute follows the file's own formatting (one per line or inline,
20
+ * see `lib/jsx-edit.mjs`), so a Prettier-formatted file stays formatted.
21
+ *
22
+ * ## What it leaves alone, and reports
23
+ *
24
+ * - An element with a spread (`<Button {...props}>`): the spread may already
25
+ * carry `size`, and adding a literal before or after it would either be
26
+ * overridden or override. It is listed under "could not be transformed"
27
+ * with its line, for a human.
28
+ * - An element that already has `size` — any value, including the icon sizes
29
+ * and a dynamic `size={x}` — is not touched. That is also what makes the
30
+ * codemod idempotent.
31
+ * - Local components called `Button` that are not imported from the design
32
+ * system, and test files (the scanner that measures G6 counts production
33
+ * files only).
34
+ */
35
+ import { analyseForms } from "../lib/forms.mjs";
36
+ import { applyInsertions, insertAttribute, openingOf } from "../lib/jsx-edit.mjs";
37
+
38
+ export const meta = {
39
+ id: "CM-02",
40
+ title: 'Button: make the implicit default size explicit (size="md")',
41
+ class: "A",
42
+ oneShot: false,
43
+ requires: { codemods: [], dsVersion: null },
44
+ parses: ["code"],
45
+ includeTests: false,
46
+ usesTypeScript: true,
47
+ usesPostcss: false,
48
+ registryIds: ["C-BTN-SIZE"],
49
+ };
50
+
51
+ /** The design-system components whose `size` defaults to `md` today. */
52
+ export const SIZED_BUTTONS = new Set(["Button", "SubmitButton", "LinkButton"]);
53
+
54
+ /** What the component resolves when `size` is omitted (button.tsx `defaultVariants`). */
55
+ export const DEFAULT_SIZE = "md";
56
+
57
+ export function transform(file, { ts }) {
58
+ const facts = analyseForms(ts, file.source, file.rel);
59
+ const findings = [];
60
+ const notTransformed = [];
61
+ const edits = [];
62
+
63
+ for (const el of facts.elements) {
64
+ if (!el.isDs || !SIZED_BUTTONS.has(el.base) || el.component !== el.base) continue;
65
+ if (el.props.has("size")) continue;
66
+ if (el.spread) {
67
+ notTransformed.push({
68
+ line: el.line,
69
+ reason: "spread-props",
70
+ detail: `<${el.tag} {…}> — the spread may already pass \`size\`; add size="${DEFAULT_SIZE}" by hand if it does not`,
71
+ });
72
+ continue;
73
+ }
74
+ edits.push(
75
+ insertAttribute(ts, facts.sf, openingOf(ts, el.node), `size="${DEFAULT_SIZE}"`, {
76
+ after: "variant",
77
+ }),
78
+ );
79
+ findings.push({
80
+ line: el.line,
81
+ registryId: "C-BTN-SIZE",
82
+ rule: "implicit-size",
83
+ match: `<${el.tag}>`,
84
+ component: el.component,
85
+ action: "applied",
86
+ gate: "G6",
87
+ });
88
+ }
89
+
90
+ return {
91
+ output: edits.length ? applyInsertions(file.source, edits) : file.source,
92
+ findings,
93
+ notTransformed,
94
+ parseErrors: facts.parseErrors,
95
+ };
96
+ }
@@ -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
+ }