@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.
- package/README.md +1 -0
- package/codemods/README.md +212 -15
- package/codemods/lib/jsx-edit.mjs +118 -0
- package/codemods/lib/ledger.mjs +18 -6
- package/codemods/lib/registry.mjs +6 -0
- package/codemods/lib/report.mjs +1 -0
- package/codemods/lib/runner.mjs +23 -4
- package/codemods/transforms/cm-02-button-explicit-size.mjs +96 -0
- package/codemods/transforms/cm-04-button-variant-intent.mjs +223 -0
- package/codemods/transforms/cm-05-button-icon-slots.mjs +117 -0
- package/codemods/transforms/cm-06-tone-to-intent.mjs +190 -0
- package/codemods/transforms/cm-12-button-type-submit.mjs +115 -0
- package/codemods/transforms/cm-19-progress-explicit-intent.mjs +192 -0
- package/dist/css/components.css +3 -3
- package/dist/css/legacy-aliases.css +5 -0
- package/dist/css/tokens.css +5 -0
- package/dist/icons/index.d.ts +3 -0
- package/dist/icons/index.js +1833 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index-B3OiMlQv.d.ts +509 -0
- package/dist/index.d.ts +1140 -432
- package/dist/index.js +5197 -4469
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/{system-BDU18fVg.d.ts → system-lyhOUTpa.d.ts} +1 -1
- package/dist/testing/index.cjs +12 -5
- package/dist/testing/index.js +12 -5
- package/dist/tokens/index.d.ts +1 -1
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ The single source of truth for visual design across all Assure products.
|
|
|
13
13
|
- [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
|
|
14
14
|
- [**`docs/integration/css.md`**](./docs/integration/css.md) — how an application loads our CSS: layer order, import sequence, the compatibility preset, removing `@source`, runtime brand theming.
|
|
15
15
|
- [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
|
|
16
|
+
- [**`docs/design-system/icons.md`**](./docs/design-system/icons.md) — the icon catalogue and its server-safe entry (`@assure-one/design-system/icons`, experimental): import an icon in a Server Component without a client boundary.
|
|
16
17
|
- [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
|
|
17
18
|
|
|
18
19
|
## Install
|
package/codemods/README.md
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
89
|
-
|
|
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,16 +107,191 @@ 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-
|
|
106
|
-
| CM-
|
|
107
|
-
| CM-
|
|
108
|
-
| CM-
|
|
110
|
+
| Id | Class | What it does |
|
|
111
|
+
| ----- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
112
|
+
| CM-05 | A | Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd` (pure rename; both names of one slot, spreads → "could not be transformed") |
|
|
113
|
+
| CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
|
|
114
|
+
| CM-04 | R | Button family legacy `variant` → `variant` + `intent` pair (`primary` → `solid`, `destructive` → `solid` + `danger`, …); `accent`, `dashed`, dynamic → report |
|
|
115
|
+
| CM-06 | R | `tone` → `intent` on StatusDot, IconTile, Spinner, SegmentedProgress, SuiteProgress (`pro` → `brand`, listed for review); product hues → report |
|
|
116
|
+
| CM-19 | R | ProgressBar/ProgressRing: `variant` → `intent`, and the value → colour rule written down as an explicit `intent` where the value is a literal; dynamic values → report |
|
|
117
|
+
| CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
|
|
118
|
+
| CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
|
|
119
|
+
| CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
|
|
120
|
+
| CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
|
|
121
|
+
| CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
|
|
109
122
|
|
|
110
123
|
The remaining ids of plan §29 land with the waves that ship their replacement
|
|
111
124
|
APIs.
|
|
112
125
|
|
|
126
|
+
### CM-05 — Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd`
|
|
127
|
+
|
|
128
|
+
Class A. W3-07 named the two icon slots of `Button`, `LinkButton` and
|
|
129
|
+
`SubmitButton` logically — `iconStart` is the left in LTR and the right in
|
|
130
|
+
RTL — and kept `iconLeft` / `iconRight` as deprecated aliases of the same
|
|
131
|
+
slots (contract C-BTN-ICONPROPS, removed in 3.0). Each alias is the same slot
|
|
132
|
+
under another name, so this is a pure rename: only the attribute's name is
|
|
133
|
+
replaced, and its value — a one-line icon or a multi-line element — keeps its
|
|
134
|
+
text and formatting. Aliased and namespace imports count.
|
|
135
|
+
|
|
136
|
+
| rule | Contract | What happens |
|
|
137
|
+
| -------------- | --------------- | ------------------------------------------------- |
|
|
138
|
+
| `icon-renamed` | C-BTN-ICONPROPS | `iconLeft` → `iconStart`, `iconRight` → `iconEnd` |
|
|
139
|
+
|
|
140
|
+
Not touched, and listed under "could not be transformed": an element that
|
|
141
|
+
passes **both names of one slot** (`iconStart` and `iconLeft` — the component
|
|
142
|
+
throws on that in development; a rename would make a duplicate attribute;
|
|
143
|
+
delete the deprecated one by hand), and an element with a spread
|
|
144
|
+
(`<Button iconLeft={…} {...props}>` — the spread may carry the other name for
|
|
145
|
+
the same slot, and which one wins depends on attribute order). Not touched and
|
|
146
|
+
not reported: elements already on the new names, local components with the
|
|
147
|
+
same names, test files.
|
|
148
|
+
|
|
149
|
+
### CM-04 — Button family legacy `variant` → `variant` + `intent`
|
|
150
|
+
|
|
151
|
+
Class R. W3-02 gave `Button`, `LinkButton` and `SubmitButton` the two axes of
|
|
152
|
+
ADR-007 — `variant` is the emphasis, `intent` the meaning — and kept the
|
|
153
|
+
single-axis words as deprecated aliases (contract C-BTN-VAR, removed in 3.0),
|
|
154
|
+
each a fixed pair (`LEGACY_BUTTON_VARIANTS`, ADR-007 §4). Every alias renders
|
|
155
|
+
the exact classes it always did, so the rewrite is pixel-neutral. `intent` is
|
|
156
|
+
written only where it differs from what the new emphasis resolves on its own
|
|
157
|
+
(`DEFAULT_BUTTON_INTENT`, ADR-007 §3), so the output is the smallest call that
|
|
158
|
+
renders today's pixels; when written it goes right after `variant`, on its own
|
|
159
|
+
line in a one-attribute-per-line element. `tests/codemods/cm-04.test.mjs`
|
|
160
|
+
checks both tables against `src/primitives/behavior/button-base.ts`.
|
|
161
|
+
|
|
162
|
+
| legacy word | becomes | `intent` written? |
|
|
163
|
+
| ------------- | ---------------------------------- | ----------------------------------------- |
|
|
164
|
+
| `primary` | `variant="solid"` | no — `brand` is what `solid` defaults to |
|
|
165
|
+
| `secondary` | `variant="soft"` | no — `neutral` is what `soft` defaults to |
|
|
166
|
+
| `destructive` | `variant="solid" intent="danger"` | yes |
|
|
167
|
+
| `success` | `variant="solid" intent="success"` | yes |
|
|
168
|
+
| `accent` | left alone, reported | its suite-action colour has no pair (D13) |
|
|
169
|
+
| `dashed` | left alone, reported | its dashed border has no pair (2.x alias) |
|
|
170
|
+
|
|
171
|
+
| rule | Contract | What happens |
|
|
172
|
+
| ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
173
|
+
| `variant-split` | C-BTN-VAR | the alias became its pair, or its emphasis alone when the intent is the default (applied, low) |
|
|
174
|
+
| `destructive-as-danger` | C-BTN-VAR | `destructive` → `solid` + `danger` (applied, **medium**): the word moves to D4's default (`danger` canonical, ADR-007 §5); confirm |
|
|
175
|
+
| `legacy-look` | C-BTN-VAR, C-BTN-ACCENT | `dashed` (C-BTN-VAR) and `accent` (C-BTN-ACCENT, D13) have a look no pair renders; they stay (review, medium) |
|
|
176
|
+
| `dynamic-intent` | C-BTN-VAR | a legacy word next to `intent={expr}` whose alias intent is not the new emphasis's default: `undefined` would change the colour (review, medium) |
|
|
177
|
+
| `dynamic-variant` | C-BTN-VAR | `variant={expr}`: map the expression's values by hand (review, high) |
|
|
178
|
+
| `spread-props` | C-BTN-VAR | `{...props}` may carry `variant` or `intent`; rewriting the literal could change which wins (review, medium) |
|
|
179
|
+
| `unknown-variant` | C-BTN-VAR | a word the Button never accepted (review, high) |
|
|
180
|
+
|
|
181
|
+
A legacy word next to a **literal** `intent` (`variant="destructive"
|
|
182
|
+
intent="success"`) is rewritten to its emphasis only: the intent already wins
|
|
183
|
+
on both sides of the rewrite. `intent="destructive"` is not touched — that is
|
|
184
|
+
CM-17, gated by D4. Not touched and not reported: the emphases (`ghost`,
|
|
185
|
+
`outline`, `link`, `solid`, `soft`), elements with no `variant`, local
|
|
186
|
+
components with the same names, test files.
|
|
187
|
+
|
|
188
|
+
### CM-06 — `tone` → `intent`
|
|
189
|
+
|
|
190
|
+
Class R. W3-21 gave `StatusDot`, `IconTile`, `Spinner`, `SegmentedProgress` and
|
|
191
|
+
`SuiteProgress` the shared `intent` axis of ADR-007 and kept `tone` as a
|
|
192
|
+
deprecated alias (contract C-TONE, removed in 3.0). Every legacy word resolves
|
|
193
|
+
to one intent and renders the same classes, so the rename is pixel-neutral. The
|
|
194
|
+
attribute is rewritten in place — `tone="pro"` → `intent="brand"` — so a
|
|
195
|
+
Prettier-formatted file stays formatted; `tone="current"` on `Spinner` is the
|
|
196
|
+
default and is removed.
|
|
197
|
+
|
|
198
|
+
| component | `tone` → `intent` |
|
|
199
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
200
|
+
| StatusDot | `pro` → `brand`; `danger` `warning` `info` `success` `neutral` unchanged |
|
|
201
|
+
| IconTile | `pro` → `brand`; status words unchanged; `audit` `books` `tax` → review |
|
|
202
|
+
| Spinner | `muted` → `neutral`, `accent` → `info`, `destructive` → `danger`, `current` → removed; `success` `warning` unchanged |
|
|
203
|
+
| SegmentedProgress | `default` → `brand`, `destructive` → `danger`; `success` `warning` `info` unchanged; `tax` `audit` `accounting` → review |
|
|
204
|
+
| SuiteProgress | `pro` → `brand`; `audit` `books` `tax` → review |
|
|
205
|
+
|
|
206
|
+
| rule | Contract | What happens |
|
|
207
|
+
| -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
208
|
+
| `tone-renamed` | C-TONE | the attribute becomes `intent="…"` (applied, low) |
|
|
209
|
+
| `pro-as-brand` | C-TONE | `pro` becomes `brand` (applied, **medium**): confirm the call site meant the brand colour, not "generic purple" |
|
|
210
|
+
| `tone-removed` | C-TONE | `tone="current"` is deleted (applied, low) |
|
|
211
|
+
| `product-tone` | C-TONE | `audit` `books` `tax` `accounting` have no intent (ADR-007 §2) and stay on `tone` until the products own a service → colour map |
|
|
212
|
+
| `dynamic-tone` | C-TONE | `tone={expr}`: map the expression's values by hand (review, high) |
|
|
213
|
+
| `spread-props` | C-TONE | `{...props}` may carry `tone` too; renaming the literal could change which one wins (review) |
|
|
214
|
+
| `has-intent` | C-TONE | both axes present; `intent` already wins, delete `tone` by hand (review, low) |
|
|
215
|
+
| `unknown-tone` | C-TONE | a word the component never accepted (review, high) |
|
|
216
|
+
|
|
217
|
+
Not touched: elements that already speak `intent` (idempotency), local
|
|
218
|
+
components with the same names, test files.
|
|
219
|
+
|
|
220
|
+
### CM-19 — Progress: explicit `intent`
|
|
221
|
+
|
|
222
|
+
Class R. `ProgressBar` and `ProgressRing` colour themselves **by value** when
|
|
223
|
+
no colour is passed (`< 50%` brand, `≥ 50%` warning, `≥ 80%` success) — a
|
|
224
|
+
business mapping the design system should not own, which 2.0 switches off
|
|
225
|
+
(W9-10, contract C-PROGRESS). W3-21 added `intent`; passing it disables the
|
|
226
|
+
rule. CM-19 makes every call site say what it renders today, so the flip
|
|
227
|
+
changes nothing a consumer did not write down: `variant="<legacy word>"`
|
|
228
|
+
becomes `intent="<colour>"` (`default` → `brand`, `destructive` → `danger`),
|
|
229
|
+
and a meter with no colour and a literal `value` (literal or absent `max`)
|
|
230
|
+
gets the `intent` the rule picks for that value, after `value`. The rename to
|
|
231
|
+
the unified `Progress variant="bar" | "ring"` (target architecture §26) is the
|
|
232
|
+
second half of this codemod and lands with that component.
|
|
233
|
+
|
|
234
|
+
| rule | Contract | What happens |
|
|
235
|
+
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
236
|
+
| `variant-renamed` | C-PROGRESS | `variant` becomes `intent` (applied, low) |
|
|
237
|
+
| `auto-colour-made-explicit` | C-PROGRESS | the value rule's intent is written down (applied, **medium**): confirm the colour, not the number, was the point |
|
|
238
|
+
| `indeterminate-brand` | C-PROGRESS | `value={null}` gets `intent="brand"` (applied, low) |
|
|
239
|
+
| `dynamic-value` | C-PROGRESS | no colour and `value={expr}` (or `max={expr}`): the colour depends on runtime data → a human (review, high) |
|
|
240
|
+
| `dynamic-variant` | C-PROGRESS | `variant={expr}`: map the values by hand (review, high) |
|
|
241
|
+
| `spread-props` | C-PROGRESS | `{...props}` may carry `variant`, `intent` or `value` (review) |
|
|
242
|
+
| `unknown-variant` | C-PROGRESS | a word the component never accepted (review, high) |
|
|
243
|
+
|
|
244
|
+
`SegmentedProgress` and `SuiteProgress` have no value rule; CM-06 moves their
|
|
245
|
+
`tone`. Not touched: elements that already pass `intent`, local components
|
|
246
|
+
with the same names, test files.
|
|
247
|
+
|
|
248
|
+
### CM-02 — Button explicit default size
|
|
249
|
+
|
|
250
|
+
Class A. Every `Button`, `SubmitButton` and `LinkButton` imported from the
|
|
251
|
+
design system (aliased and namespace imports included) that passes no `size`
|
|
252
|
+
gets `size="md"` — the value the component resolves today
|
|
253
|
+
(`defaultVariants.size`, 40px), so nothing moves on screen. It exists because
|
|
254
|
+
a size _meaning_ can only change once no call site depends on the default:
|
|
255
|
+
after CM-02, the D5 size flip (CM-03 phase 2, W9-06) touches nothing a
|
|
256
|
+
consumer did not write down, and 2.0 gate G6 counts exactly this.
|
|
257
|
+
|
|
258
|
+
The attribute goes after `variant` when there is one (the order the
|
|
259
|
+
applications use) and first otherwise, on its own line when the element's
|
|
260
|
+
attributes are one per line — a Prettier-formatted file stays formatted.
|
|
261
|
+
|
|
262
|
+
| rule | Contract | What happens |
|
|
263
|
+
| --------------- | ---------- | -------------------- |
|
|
264
|
+
| `implicit-size` | C-BTN-SIZE | `size="md"` is added |
|
|
265
|
+
|
|
266
|
+
Not touched, and listed under "could not be transformed": an element with a
|
|
267
|
+
spread (`<Button {...props}>`) — the spread may already carry `size`. Not
|
|
268
|
+
touched and not reported: an element with any `size` (the icon sizes, a
|
|
269
|
+
dynamic `size={x}`), a local component called `Button`, test files (the
|
|
270
|
+
scanner that measures G6 counts production files only).
|
|
271
|
+
|
|
272
|
+
### CM-12 — Button `type="submit"` where evident
|
|
273
|
+
|
|
274
|
+
Class R. An untyped `Button` renders `<button>` without a `type`, which the
|
|
275
|
+
browser treats as `submit`; 2.0 flips the default to `"button"` (W9-05,
|
|
276
|
+
C-BTN-TYPE, gate G4). CM-12 writes down, before the flip, which Buttons submit
|
|
277
|
+
on purpose — and only where the file itself makes that evident:
|
|
278
|
+
|
|
279
|
+
| rule | Action | Severity | When |
|
|
280
|
+
| ----------------- | ------- | -------- | ---------------------------------------------------------------------------- |
|
|
281
|
+
| `submit-in-form` | applied | medium | inside a lower-case `<form>` in the same file, no `onClick`, no spread |
|
|
282
|
+
| `in-form-onclick` | review | high | inside a `<form>` with an `onClick` — submit with side effects, or a button? |
|
|
283
|
+
| `in-form-spread` | review | high | inside a `<form>` with `{...props}` — the spread may carry `type` |
|
|
284
|
+
| `outside-form` | review | low | no `<form>` in this file; rendered inside one elsewhere, it submits today |
|
|
285
|
+
|
|
286
|
+
The applied change states what the browser does today, so it is
|
|
287
|
+
behaviour-neutral; it is still in the report because a human has to agree that
|
|
288
|
+
submitting is what the button is for — a "Cancel" matching that shape is a
|
|
289
|
+
bug today, now visible. Nothing is followed across files (a component
|
|
290
|
+
rendered inside a `<form>` by another component is `outside-form`), and a
|
|
291
|
+
capitalised `<Form>` does not count: the react-hook-form / shadcn `Form` is a
|
|
292
|
+
context provider, not a form element. `SubmitButton`, `LinkButton`, a Button
|
|
293
|
+
with any `type`, a Button with `asChild` and test files are not reported.
|
|
294
|
+
|
|
113
295
|
### CM-14 — hidden-input mirror finder
|
|
114
296
|
|
|
115
297
|
Report-only, **for ever**: deleting a mirror changes what the server receives,
|
|
@@ -271,26 +453,41 @@ export function transform(file, { ts, postcss }) {
|
|
|
271
453
|
```
|
|
272
454
|
|
|
273
455
|
`usesTypeScript` and `usesPostcss` ask the runner to resolve that parser from
|
|
274
|
-
the project and inject it; a codemod never imports one itself.
|
|
456
|
+
the project and inject it; a codemod never imports one itself. A transforming
|
|
457
|
+
codemod edits the source with `lib/jsx-edit.mjs` (`insertAttribute`,
|
|
458
|
+
`applyInsertions`), which keeps the file's own formatting; a finding it
|
|
459
|
+
applied carries `action: "applied"`, one it leaves to a human
|
|
460
|
+
`action: "review"`.
|
|
461
|
+
|
|
462
|
+
A codemod may also export `guard(context)`, run before any file is read, that
|
|
463
|
+
returns the problems that refuse the run. It receives `{ root, ledger,
|
|
464
|
+
dsVersion, dry, requireLedger }`, where `requireLedger(["CM-02"])` returns one
|
|
465
|
+
problem per prerequisite the ledger lacks. `meta.requires.codemods` is the
|
|
466
|
+
declarative form of the same check; the hook is for a prerequisite that
|
|
467
|
+
depends on the codemod's own state (CM-03 phase 2).
|
|
275
468
|
|
|
276
469
|
The runner owns everything else: the guards, which files are read, writing
|
|
277
470
|
files and the ledger, the report, and the rule that a class X codemod may not
|
|
278
471
|
change anything.
|
|
279
472
|
|
|
280
473
|
Fixtures live in `codemods/__test__/<id>/*.snap` — one file per case, holding a
|
|
281
|
-
small project
|
|
282
|
-
expected output
|
|
474
|
+
small project, the expected findings and, for transforming codemods, the
|
|
475
|
+
expected output of every file that changes:
|
|
283
476
|
|
|
284
477
|
```
|
|
285
478
|
=== file: src/thing.tsx
|
|
286
479
|
<source>
|
|
480
|
+
=== output: src/thing.tsx
|
|
481
|
+
<the source after the codemod>
|
|
287
482
|
=== expect
|
|
288
483
|
{ "findings": [ ["src/thing.tsx:12", "C-DOM-03", "class-on-component", "<class token>"] ] }
|
|
289
484
|
```
|
|
290
485
|
|
|
291
486
|
`codemods/__test__/harness.mjs` materialises a fixture in a temporary
|
|
292
|
-
directory, runs the codemod **twice** and asserts that the second run
|
|
293
|
-
|
|
487
|
+
directory, runs the codemod **twice** and asserts that the second run changes
|
|
488
|
+
nothing and finds what the first run did not apply — the idempotency
|
|
489
|
+
requirement of plan §29. A file without an `=== output:` section must come out
|
|
490
|
+
unchanged; a fixture with none is report-only and may write no ledger.
|
|
294
491
|
`tests/codemods/*.test.mjs` runs all of it as part of `pnpm test:contracts`.
|
|
295
492
|
|
|
296
493
|
Fixtures are `.snap` on purpose: Tailwind scans every other repository file for
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
/** The JSX attribute named `name` on `opening`, or null. */
|
|
50
|
+
export function attributeNamed(ts, opening, name) {
|
|
51
|
+
return (
|
|
52
|
+
opening.attributes.properties.find(
|
|
53
|
+
(attr) => ts.isJsxAttribute(attr) && attr.name.getText() === name,
|
|
54
|
+
) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The edit that replaces the attribute named `name` (its whole text, name and
|
|
60
|
+
* initializer) with `text`, e.g. `tone="pro"` → `intent="brand"`. The
|
|
61
|
+
* attribute keeps its place, so the file's formatting is untouched.
|
|
62
|
+
*
|
|
63
|
+
* @returns {{ pos: number, end: number, text: string } | null}
|
|
64
|
+
*/
|
|
65
|
+
export function replaceAttribute(ts, sf, opening, name, text) {
|
|
66
|
+
const attr = attributeNamed(ts, opening, name);
|
|
67
|
+
if (!attr) return null;
|
|
68
|
+
return { pos: attr.getStart(sf), end: attr.getEnd(), text };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The edit that renames the attribute `name` to `to` and keeps its value,
|
|
73
|
+
* e.g. `iconLeft={<X />}` → `iconStart={<X />}`. Only the name's characters
|
|
74
|
+
* are replaced, so the initializer — however many lines it spans — and the
|
|
75
|
+
* file's formatting are untouched.
|
|
76
|
+
*
|
|
77
|
+
* @returns {{ pos: number, end: number, text: string } | null}
|
|
78
|
+
*/
|
|
79
|
+
export function renameAttribute(ts, sf, opening, name, to) {
|
|
80
|
+
const attr = attributeNamed(ts, opening, name);
|
|
81
|
+
if (!attr) return null;
|
|
82
|
+
return { pos: attr.name.getStart(sf), end: attr.name.getEnd(), text: to };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The edit that removes the attribute named `name` together with the
|
|
87
|
+
* whitespace before it, so `<X a tone="current" b>` becomes `<X a b>` and a
|
|
88
|
+
* one-attribute-per-line element loses the whole line.
|
|
89
|
+
*
|
|
90
|
+
* @returns {{ pos: number, end: number, text: string } | null}
|
|
91
|
+
*/
|
|
92
|
+
export function removeAttribute(ts, sf, opening, name) {
|
|
93
|
+
const attrs = opening.attributes.properties;
|
|
94
|
+
const index = attrs.findIndex((attr) => ts.isJsxAttribute(attr) && attr.name.getText() === name);
|
|
95
|
+
if (index < 0) return null;
|
|
96
|
+
const from = index === 0 ? opening.attributes.pos : attrs[index - 1].getEnd();
|
|
97
|
+
return { pos: from, end: attrs[index].getEnd(), text: "" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `source` with every edit applied, from the end backwards so positions stay
|
|
102
|
+
* valid. An edit is `{ pos, text }` (an insertion) or `{ pos, end, text }` (a
|
|
103
|
+
* replacement of the range). Edits must not overlap.
|
|
104
|
+
*/
|
|
105
|
+
export function applyEdits(source, edits) {
|
|
106
|
+
let out = source;
|
|
107
|
+
const ordered = [...edits].sort((a, b) => b.pos - a.pos || (b.end ?? b.pos) - (a.end ?? a.pos));
|
|
108
|
+
for (const edit of ordered) {
|
|
109
|
+
out = `${out.slice(0, edit.pos)}${edit.text}${out.slice(edit.end ?? edit.pos)}`;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `source` with every `{ pos, text }` insertion applied. */
|
|
115
|
+
export const applyInsertions = applyEdits;
|
|
116
|
+
|
|
117
|
+
/** The opening element of a JSX element node (a self-closing one is its own). */
|
|
118
|
+
export const openingOf = (ts, node) => (ts.isJsxElement(node) ? node.openingElement : node);
|
package/codemods/lib/ledger.mjs
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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,12 @@
|
|
|
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-05", module: "../transforms/cm-05-button-icon-slots.mjs" },
|
|
7
|
+
{ id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
|
|
8
|
+
{ id: "CM-04", module: "../transforms/cm-04-button-variant-intent.mjs" },
|
|
9
|
+
{ id: "CM-06", module: "../transforms/cm-06-tone-to-intent.mjs" },
|
|
10
|
+
{ id: "CM-19", module: "../transforms/cm-19-progress-explicit-intent.mjs" },
|
|
11
|
+
{ id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
|
|
6
12
|
{ id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
|
|
7
13
|
{ id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
|
|
8
14
|
{ id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
|
package/codemods/lib/report.mjs
CHANGED
package/codemods/lib/runner.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|