@assure-one/design-system 1.32.0 → 1.33.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 +44 -13
- package/codemods/README.md +60 -0
- package/codemods/lib/forms.mjs +253 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/registry.mjs +2 -0
- package/codemods/transforms/cm-14-hidden-mirrors.mjs +275 -0
- package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
- package/dist/css/components.css +7 -0
- package/dist/css/legacy-aliases.css +180 -4
- package/dist/css/shadcn.css +4 -4
- package/dist/css/tailwind.css +66 -3
- package/dist/css/tokens.css +205 -14
- package/dist/index.d.ts +529 -4
- package/dist/index.js +477 -51
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/system-BDU18fVg.d.ts +559 -0
- package/dist/tokens/index.d.ts +50 -439
- package/dist/tokens/index.js +513 -6
- package/dist/tokens/index.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-14 — hidden-input mirror finder (class X, report-only; plan §29,
|
|
3
|
+
* registry `C-HIDDEN-MIRRORS`, feeds Wave 4 W4-04).
|
|
4
|
+
*
|
|
5
|
+
* A *mirror* is a hidden `<input name="…">` a consumer added because a
|
|
6
|
+
* controlled design-system control emits no native form value ([CU §18]:
|
|
7
|
+
* "controlled design-system fields (Select, DatePicker) don't emit a native
|
|
8
|
+
* form value"). W4-04 gives those controls native participation, and the
|
|
9
|
+
* moment a call site passes `name` to the control **and** keeps its mirror,
|
|
10
|
+
* the form posts the field twice and the server action reads whichever the
|
|
11
|
+
* platform hands it first (plan §33, the duplicate-submission risk).
|
|
12
|
+
*
|
|
13
|
+
* Deleting a mirror therefore changes what the server receives, which is the
|
|
14
|
+
* one thing plan §29 never automates. CM-14 is report-only for ever: it
|
|
15
|
+
* names the site, the control, the field name and whether the control at
|
|
16
|
+
* that site already takes `name`, and a human decides each one.
|
|
17
|
+
*
|
|
18
|
+
* ## What it detects
|
|
19
|
+
*
|
|
20
|
+
* The applications do **not** put a mirror next to its control — they collect
|
|
21
|
+
* the mirrors at the top of the `<form>` and bind the control 200 lines
|
|
22
|
+
* lower. "Next to" is therefore read as *bound to the same state*:
|
|
23
|
+
*
|
|
24
|
+
* | rule | registry | what it is |
|
|
25
|
+
* | ------------------------- | ----------------- | ---------------------------------------------------------------- |
|
|
26
|
+
* | `mirror-shared-binding` | C-HIDDEN-MIRRORS | the hidden input's `value` reads a binding a DS control is bound to |
|
|
27
|
+
* | `mirror-in-form` | C-HIDDEN-MIRRORS | same `<form>` as a DS control that posts nothing today, no shared binding |
|
|
28
|
+
* | `unregistered-hidden-input` | (unregistered) | a hidden input with no DS control to mirror — a server-supplied field |
|
|
29
|
+
*
|
|
30
|
+
* The third rule is what makes the report reconcile with the audit's census
|
|
31
|
+
* of 64 hidden `<input>`s in PRO [CU §24]: that number counts every hidden
|
|
32
|
+
* input, and most of the auth-form ones carry a token or an email the server
|
|
33
|
+
* sent, not a mirrored control value. CM-14 reports all three classes so the
|
|
34
|
+
* difference is visible rather than asserted.
|
|
35
|
+
*
|
|
36
|
+
* ## What it deliberately does not do
|
|
37
|
+
*
|
|
38
|
+
* - It never writes a file (the runner refuses on class X).
|
|
39
|
+
* - It does not decide which of the two values is correct, and it does not
|
|
40
|
+
* report a `name` collision as an error: `<name>_from`/`_to`
|
|
41
|
+
* (DateRangePicker) and repeated `name`s read with `getAll()` are both
|
|
42
|
+
* legitimate.
|
|
43
|
+
* - It says nothing about hidden inputs the design system renders itself
|
|
44
|
+
* (DatePicker's own `<input type="hidden" name>`): those are inside the
|
|
45
|
+
* package, never in consumer source.
|
|
46
|
+
* - `<input type={expr}>` with a computed type is not resolved; it appears
|
|
47
|
+
* in `notTransformed` so the human sees what the scan could not read.
|
|
48
|
+
* - It does not look across files. A mirror in one file and its control in
|
|
49
|
+
* another is reported as `unregistered-hidden-input`, with the name, and
|
|
50
|
+
* left to the reader.
|
|
51
|
+
*/
|
|
52
|
+
import { CHANGE_PROPS, VALUE_PROPS, analyseForms, isWithin, literalValue } from "../lib/forms.mjs";
|
|
53
|
+
|
|
54
|
+
export const meta = {
|
|
55
|
+
id: "CM-14",
|
|
56
|
+
title: "hidden-input mirror finder: hidden <input name> next to a design-system control",
|
|
57
|
+
class: "X",
|
|
58
|
+
oneShot: false,
|
|
59
|
+
requires: { codemods: [], dsVersion: null },
|
|
60
|
+
parses: ["code"],
|
|
61
|
+
includeTests: false,
|
|
62
|
+
usesTypeScript: true,
|
|
63
|
+
usesPostcss: false,
|
|
64
|
+
registryIds: ["C-HIDDEN-MIRRORS"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** How a finding is weighted in the report and in the Wave 4 work. */
|
|
68
|
+
export const SEVERITY = { high: "high", medium: "medium", low: "low" };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Design-system controls a consumer mirrors, and whether they take `name`
|
|
72
|
+
* today (design system 1.32.0).
|
|
73
|
+
*
|
|
74
|
+
* - `"yes"` — the control accepts `name` now: `Select`, `DatePicker` and
|
|
75
|
+
* `DateRangePicker` declare it themselves (`DatePicker` renders its own
|
|
76
|
+
* hidden input, `DateRangePicker` renders `<name>_from`/`<name>_to`), and
|
|
77
|
+
* `Checkbox`, `Switch`, `RadioGroup` and `Slider` inherit the Radix root
|
|
78
|
+
* props and spread them, so Radix's own hidden input posts the value.
|
|
79
|
+
* A mirror next to one of these is a *duplicate today* the moment the call
|
|
80
|
+
* site also passes `name`.
|
|
81
|
+
* - `"planned"` — no `name` today; native participation arrives with the
|
|
82
|
+
* Wave 4 item named in the comment. A mirror here is load-bearing until
|
|
83
|
+
* then and must not be removed.
|
|
84
|
+
*
|
|
85
|
+
* `tests/codemods/cm-14.test.mjs` checks this table against the design
|
|
86
|
+
* system's own sources, so it cannot drift from the components it describes.
|
|
87
|
+
*/
|
|
88
|
+
export const NAME_CAPABILITY = {
|
|
89
|
+
Select: "yes", // src/primitives/select.tsx — `name?: string`
|
|
90
|
+
DatePicker: "yes", // renders `<input type="hidden" name>` with the ISO value
|
|
91
|
+
DateRangePicker: "yes", // renders `<name>_from` and `<name>_to`
|
|
92
|
+
Checkbox: "yes", // Radix checkbox root props
|
|
93
|
+
Switch: "yes", // Radix switch root props
|
|
94
|
+
RadioGroup: "yes", // Radix radio-group root props
|
|
95
|
+
Slider: "yes", // Radix slider root props
|
|
96
|
+
ClientSelect: "yes", // renders its own hidden `<input name>` (default `client_id`)
|
|
97
|
+
SearchSelect: "planned", // W4-12 (adapter over Combobox)
|
|
98
|
+
TeamMemberSelect: "planned", // W4-08/W4-12
|
|
99
|
+
MultiSelectField: "planned", // W4-08
|
|
100
|
+
ToggleGroup: "planned", // W4-16
|
|
101
|
+
PhoneCountryInput: "planned", // W4-04
|
|
102
|
+
OtpInput: "planned", // W4-04
|
|
103
|
+
Questions: "planned", // W4-04 (answer widgets)
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Controls that already post a native value without any help, so a hidden
|
|
108
|
+
* input in the same form is not a mirror of them. They are still recorded as
|
|
109
|
+
* bindings, because a mirror of a controlled `Input` does exist ([CU §18]:
|
|
110
|
+
* `contact_name`) and is worth reporting — it just is not a design-system
|
|
111
|
+
* gap.
|
|
112
|
+
*/
|
|
113
|
+
export const NATIVE_CONTROLS = new Set(["Input", "Textarea", "SearchInput", "SubmitButton"]);
|
|
114
|
+
|
|
115
|
+
const CONTROLS = new Set([...Object.keys(NAME_CAPABILITY), ...NATIVE_CONTROLS]);
|
|
116
|
+
|
|
117
|
+
/** Whether an `<input>` element is `type="hidden"`. */
|
|
118
|
+
const hiddenType = (el) => {
|
|
119
|
+
if (el.tag !== "input") return null;
|
|
120
|
+
const prop = el.props.get("type");
|
|
121
|
+
if (!prop) return null;
|
|
122
|
+
const literal = literalValue(prop);
|
|
123
|
+
if (literal === "hidden") return "literal";
|
|
124
|
+
if (prop.literals.includes("hidden")) return "computed";
|
|
125
|
+
return prop.expression ? "unknown" : null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** The bindings an attribute set reads, for the given attribute names. */
|
|
129
|
+
const bindingsOf = (el, names) => {
|
|
130
|
+
const out = new Set();
|
|
131
|
+
for (const name of names) {
|
|
132
|
+
const prop = el.props.get(name);
|
|
133
|
+
if (!prop) continue;
|
|
134
|
+
for (const id of prop.identifiers) out.add(id);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const intersect = (a, b) => [...a].filter((x) => b.has(x)).sort();
|
|
140
|
+
|
|
141
|
+
export function transform(file, { ts }) {
|
|
142
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
143
|
+
const findings = [];
|
|
144
|
+
const notTransformed = [];
|
|
145
|
+
|
|
146
|
+
const hidden = [];
|
|
147
|
+
for (const el of facts.elements) {
|
|
148
|
+
const kind = hiddenType(el);
|
|
149
|
+
if (kind === null) continue;
|
|
150
|
+
if (kind === "unknown") {
|
|
151
|
+
notTransformed.push({
|
|
152
|
+
line: el.line,
|
|
153
|
+
reason: "dynamic-type",
|
|
154
|
+
detail: `<input type={${el.props.get("type").text}}> — the type is computed, so the scan cannot tell whether it is hidden`,
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
hidden.push(el);
|
|
159
|
+
}
|
|
160
|
+
if (!hidden.length) return { findings, notTransformed, parseErrors: facts.parseErrors };
|
|
161
|
+
|
|
162
|
+
// Every design-system control in the file, with the bindings it is bound to.
|
|
163
|
+
const controls = facts.elements
|
|
164
|
+
.filter((el) => el.isDs && CONTROLS.has(el.base))
|
|
165
|
+
.map((el) => ({
|
|
166
|
+
el,
|
|
167
|
+
bindings: new Set([...bindingsOf(el, VALUE_PROPS), ...bindingsOf(el, CHANGE_PROPS)]),
|
|
168
|
+
acceptsName: NAME_CAPABILITY[el.base] ?? null,
|
|
169
|
+
native: NATIVE_CONTROLS.has(el.base),
|
|
170
|
+
named: literalValue(el.props.get("name")) ?? (el.props.has("name") ? "(expression)" : null),
|
|
171
|
+
}));
|
|
172
|
+
/** Controls that post nothing today and are the reason a mirror exists. */
|
|
173
|
+
const needMirror = controls.filter((c) => !c.native);
|
|
174
|
+
|
|
175
|
+
for (const el of hidden) {
|
|
176
|
+
const nameProp = el.props.get("name");
|
|
177
|
+
const name = nameProp ? (literalValue(nameProp) ?? nameProp.text) : null;
|
|
178
|
+
const valueProp = el.props.get("value");
|
|
179
|
+
const reads = new Set(valueProp?.identifiers ?? []);
|
|
180
|
+
|
|
181
|
+
// 1. The strong signal: the mirror and a control read the same binding.
|
|
182
|
+
const shared = controls
|
|
183
|
+
.map((c) => ({ c, common: intersect(reads, c.bindings) }))
|
|
184
|
+
.filter((x) => x.common.length)
|
|
185
|
+
.sort((a, b) => b.common.length - a.common.length);
|
|
186
|
+
if (shared.length) {
|
|
187
|
+
const best = shared[0];
|
|
188
|
+
const duplicate = best.c.named !== null && best.c.acceptsName === "yes";
|
|
189
|
+
add(findings, {
|
|
190
|
+
line: el.line,
|
|
191
|
+
registryId: "C-HIDDEN-MIRRORS",
|
|
192
|
+
severity: duplicate
|
|
193
|
+
? SEVERITY.high
|
|
194
|
+
: best.c.acceptsName === "yes"
|
|
195
|
+
? SEVERITY.medium
|
|
196
|
+
: SEVERITY.low,
|
|
197
|
+
rule: "mirror-shared-binding",
|
|
198
|
+
scope: el.form === null ? "no-form" : "form",
|
|
199
|
+
component: best.c.el.component,
|
|
200
|
+
match: name === null ? '<input type="hidden">' : `name=${JSON.stringify(name)}`,
|
|
201
|
+
detail: {
|
|
202
|
+
name,
|
|
203
|
+
binding: best.common.join(", "),
|
|
204
|
+
acceptsName: best.c.acceptsName,
|
|
205
|
+
controlPassesName: best.c.named,
|
|
206
|
+
controlLine: best.c.el.line,
|
|
207
|
+
sameForm: isWithin(facts.elements, best.c.el.index, el.form),
|
|
208
|
+
alsoMatches: shared.slice(1).map((x) => x.c.el.component),
|
|
209
|
+
nativeControl: best.c.native,
|
|
210
|
+
},
|
|
211
|
+
confidence: "high",
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 2. The weak signal: the same `<form>` holds a control that posts
|
|
217
|
+
// nothing today, but nothing ties this input to it.
|
|
218
|
+
const inForm =
|
|
219
|
+
el.form === null
|
|
220
|
+
? []
|
|
221
|
+
: needMirror.filter((c) => isWithin(facts.elements, c.el.index, el.form));
|
|
222
|
+
if (inForm.length) {
|
|
223
|
+
add(findings, {
|
|
224
|
+
line: el.line,
|
|
225
|
+
registryId: "C-HIDDEN-MIRRORS",
|
|
226
|
+
severity: SEVERITY.low,
|
|
227
|
+
rule: "mirror-in-form",
|
|
228
|
+
scope: "form",
|
|
229
|
+
component: [...new Set(inForm.map((c) => c.el.component))].sort().join(", "),
|
|
230
|
+
match: name === null ? '<input type="hidden">' : `name=${JSON.stringify(name)}`,
|
|
231
|
+
detail: {
|
|
232
|
+
name,
|
|
233
|
+
binding: null,
|
|
234
|
+
acceptsName: [...new Set(inForm.map((c) => c.acceptsName))].sort().join(", "),
|
|
235
|
+
controlPassesName: null,
|
|
236
|
+
controlLine: inForm[0].el.line,
|
|
237
|
+
sameForm: true,
|
|
238
|
+
},
|
|
239
|
+
confidence: "low",
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 3. Not a mirror: a hidden field carrying a value the server supplied.
|
|
245
|
+
add(findings, {
|
|
246
|
+
line: el.line,
|
|
247
|
+
registryId: null,
|
|
248
|
+
severity: SEVERITY.low,
|
|
249
|
+
rule: "unregistered-hidden-input",
|
|
250
|
+
scope: el.form === null ? "no-form" : "form",
|
|
251
|
+
component: null,
|
|
252
|
+
match: name === null ? '<input type="hidden">' : `name=${JSON.stringify(name)}`,
|
|
253
|
+
detail: {
|
|
254
|
+
name,
|
|
255
|
+
binding: valueProp?.text ?? null,
|
|
256
|
+
acceptsName: null,
|
|
257
|
+
controlPassesName: null,
|
|
258
|
+
dsControlsInFile: [...new Set(needMirror.map((c) => c.el.component))].sort(),
|
|
259
|
+
},
|
|
260
|
+
confidence: "medium",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
findings,
|
|
266
|
+
notTransformed,
|
|
267
|
+
parseErrors: facts.parseErrors,
|
|
268
|
+
context: {
|
|
269
|
+
hiddenInputs: hidden.length,
|
|
270
|
+
controls: [...new Set(controls.map((c) => c.el.component))].sort(),
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const add = (findings, finding) => findings.push({ gate: null, ...finding });
|