@assure-one/design-system 1.35.0 → 1.36.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,416 @@
1
+ /**
2
+ * CM-10 — `DatePicker onChange={e => f(e.target.value)}` →
3
+ * `onValueChange={value => f(value)}` (class R; plan §29 seq 9, registry
4
+ * `C-DATE-FAKEEVENT`).
5
+ *
6
+ * W4-18 gave `DatePicker` the value callback of target architecture §25,
7
+ * `onValueChange(iso | null)`, and kept the legacy fake-event `onChange`
8
+ * (`{ target: { name, value } }`, `value` the ISO string or `""` when
9
+ * cleared) as a deprecated alias that fires after it. The event was never a
10
+ * DOM event: every consumer handler unwraps `.target.value` and nothing else,
11
+ * so the handlers whose shape makes that visible are rewritten and every
12
+ * other handler is listed for a human.
13
+ *
14
+ * ## What it rewrites
15
+ *
16
+ * Only an inline arrow function whose parameter is used **exclusively** as
17
+ * the value, in one of these shapes:
18
+ *
19
+ * | handler | becomes |
20
+ * | --------------------------------------------- | ------------------------------------ |
21
+ * | `(e) => f(e.target.value)` (typed or not) | `(value) => f(value)` |
22
+ * | `e => setX(e.target.value)` | `value => setX(value)` |
23
+ * | `({ target }) => f(target.value)` | `(value) => f(value)` |
24
+ * | `({ target: { value } }) => f(value)` | `(value) => f(value)` |
25
+ * | `() => refetch()` | unchanged body, attribute renamed |
26
+ *
27
+ * The parameter is renamed `value` (`v`, then `next`, when the handler
28
+ * already binds that name); the body keeps its text and formatting, and only
29
+ * the `.target.value` accesses are replaced. `e.target.value` typed as a
30
+ * string becomes `string | null`: **a cleared picker now passes `null`, not
31
+ * `""`** — that is why the rewrite is reviewed (severity medium) and why a
32
+ * handler that reads a member of the value (`e.target.value.trim()`) is not
33
+ * rewritten: it would throw on clear.
34
+ *
35
+ * ## What it reports and leaves alone
36
+ *
37
+ * | rule | action | severity | when |
38
+ * | ---------------------- | ------- | -------- | -------------------------------------------------------------------------------------- |
39
+ * | `fake-event-unwrapped` | applied | medium | one of the shapes above was rewritten; confirm `null` on clear is what the state wants |
40
+ * | `handler-reference` | review | high | the handler is passed by reference (`onChange={handleChange}`, `{field.onChange}`, a factory call): its body is elsewhere |
41
+ * | `handler-shape` | review | high | an inline handler that reads more than `.target.value` (`e.target.name`, the event itself, a member of the value) or is not an arrow |
42
+ * | `has-value-change` | review | low | `onValueChange` is already wired; `onChange` still fires after it — delete it by hand |
43
+ * | `spread-props` | review | medium | `{...props}` may carry either callback; which one wins depends on attribute order |
44
+ *
45
+ * `DateRangePicker` is not touched: its `onChange` is already a value
46
+ * callback. Local components called `DatePicker` and test files are skipped,
47
+ * as by the scanner that measures C-DATE-FAKEEVENT.
48
+ */
49
+ import { analyseForms } from "../lib/forms.mjs";
50
+ import { applyEdits, attributeNamed, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
51
+
52
+ export const meta = {
53
+ id: "CM-10",
54
+ title: "DatePicker: fake-event onChange → onValueChange",
55
+ class: "R",
56
+ oneShot: false,
57
+ requires: { codemods: [], dsVersion: null },
58
+ parses: ["code"],
59
+ includeTests: false,
60
+ usesTypeScript: true,
61
+ usesPostcss: false,
62
+ registryIds: ["C-DATE-FAKEEVENT"],
63
+ };
64
+
65
+ /** The design-system components with the fake-event `onChange` (W4-18). */
66
+ export const FAKE_EVENT_PICKERS = new Set(["DatePicker"]);
67
+
68
+ export const LEGACY_PROP = "onChange";
69
+ export const PROP = "onValueChange";
70
+
71
+ /** Rule → what the report says a human must do with it. */
72
+ export const RULES = {
73
+ "fake-event-unwrapped": { action: "applied", severity: "medium" },
74
+ "handler-reference": { action: "review", severity: "high" },
75
+ "handler-shape": { action: "review", severity: "high" },
76
+ "has-value-change": { action: "review", severity: "low" },
77
+ "spread-props": { action: "review", severity: "medium" },
78
+ };
79
+
80
+ const NAME_CANDIDATES = ["value", "v", "next"];
81
+
82
+ const unparenthesize = (ts, node) => {
83
+ let current = node;
84
+ while (current && ts.isParenthesizedExpression(current)) current = current.expression;
85
+ return current;
86
+ };
87
+
88
+ /**
89
+ * Every identifier the node binds or reads, by text. Member names, object
90
+ * keys, JSX attribute names and destructuring keys are not bindings and are
91
+ * excluded.
92
+ */
93
+ function identifierNames(ts, node, out = new Set(), skip = () => false) {
94
+ if (skip(node)) return out;
95
+ if (ts.isIdentifier(node)) {
96
+ const parent = node.parent;
97
+ const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === node;
98
+ const isKey =
99
+ parent &&
100
+ (ts.isPropertyAssignment(parent) ||
101
+ ts.isPropertySignature(parent) ||
102
+ ts.isJsxAttribute(parent)) &&
103
+ parent.name === node;
104
+ const isBindingKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
105
+ if (!isMemberName && !isKey && !isBindingKey) out.add(node.text);
106
+ }
107
+ ts.forEachChild(node, (child) => {
108
+ identifierNames(ts, child, out, skip);
109
+ });
110
+ return out;
111
+ }
112
+
113
+ /** Whether a nested function inside `node` binds `name` again (then a reference is ambiguous). */
114
+ function rebinds(ts, node, name) {
115
+ let found = false;
116
+ const visit = (n) => {
117
+ if (found) return;
118
+ if (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) {
119
+ for (const p of n.parameters) {
120
+ if (identifierNames(ts, p.name).has(name)) found = true;
121
+ }
122
+ }
123
+ if (ts.isVariableDeclaration(n) && identifierNames(ts, n.name).has(name)) found = true;
124
+ ts.forEachChild(n, visit);
125
+ };
126
+ visit(node);
127
+ return found;
128
+ }
129
+
130
+ /** The identifier references to `name` inside `node` (reads, not member names or keys). */
131
+ function referencesTo(ts, node, name) {
132
+ const refs = [];
133
+ const visit = (n) => {
134
+ if (ts.isIdentifier(n) && n.text === name) {
135
+ const parent = n.parent;
136
+ const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === n;
137
+ const isKey = parent && ts.isPropertyAssignment(parent) && parent.name === n;
138
+ // A shorthand property `{ e }` reads the binding.
139
+ if (!isMemberName && !isKey) refs.push(n);
140
+ }
141
+ ts.forEachChild(n, visit);
142
+ };
143
+ visit(node);
144
+ return refs;
145
+ }
146
+
147
+ const isAccessOf = (ts, node, member) =>
148
+ node && ts.isPropertyAccessExpression(node) && node.name.text === member;
149
+
150
+ /**
151
+ * Whether the expression `node` is itself read further — a member, a call, an
152
+ * index — which would throw on `null`.
153
+ */
154
+ function isReadFurther(ts, node) {
155
+ const parent = node.parent;
156
+ if (!parent) return false;
157
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return true;
158
+ if (ts.isElementAccessExpression(parent) && parent.expression === node) return true;
159
+ if (ts.isCallExpression(parent) && parent.expression === node) return true;
160
+ if (ts.isNonNullExpression(parent)) return isReadFurther(ts, parent);
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Classifies the handler expression of `onChange`.
166
+ *
167
+ * @returns {{ kind: "reference" } | { kind: "other", reason: string } |
168
+ * { kind: "unwrap", shape: string, param: Node, name: string, keepName: boolean, values: Node[] }}
169
+ */
170
+ export function analyseHandler(ts, expression) {
171
+ const expr = unparenthesize(ts, expression);
172
+ if (!expr) return { kind: "other", reason: "empty expression" };
173
+ if (
174
+ ts.isIdentifier(expr) ||
175
+ ts.isPropertyAccessExpression(expr) ||
176
+ ts.isElementAccessExpression(expr) ||
177
+ ts.isCallExpression(expr) ||
178
+ ts.isNonNullExpression(expr) ||
179
+ ts.isAsExpression(expr)
180
+ ) {
181
+ return { kind: "reference" };
182
+ }
183
+ if (ts.isFunctionExpression(expr)) {
184
+ return { kind: "other", reason: "a `function` expression; only arrow functions are rewritten" };
185
+ }
186
+ if (!ts.isArrowFunction(expr)) {
187
+ return { kind: "other", reason: "not an arrow function" };
188
+ }
189
+ if (expr.parameters.length === 0) {
190
+ return {
191
+ kind: "unwrap",
192
+ shape: "ignores-event",
193
+ param: null,
194
+ name: null,
195
+ keepName: false,
196
+ values: [],
197
+ };
198
+ }
199
+ if (expr.parameters.length > 1) {
200
+ return { kind: "other", reason: "more than one parameter" };
201
+ }
202
+ const param = expr.parameters[0];
203
+ if (param.dotDotDotToken || param.initializer) {
204
+ return { kind: "other", reason: "a rest or defaulted parameter" };
205
+ }
206
+
207
+ const body = expr.body;
208
+ /**
209
+ * The `.value` reads of the binding `local` inside the body. `depth` is how
210
+ * many member accesses separate the binding from the value: 2 for the event
211
+ * (`e.target.value`), 1 for the target (`target.value`), 0 for the value.
212
+ * Every read must be exactly that access and nothing beyond it.
213
+ */
214
+ const valueReads = (local, depth) => {
215
+ if (rebinds(ts, body, local))
216
+ return { reason: `\`${local}\` is bound again inside the handler` };
217
+ const values = [];
218
+ for (const ref of referencesTo(ts, body, local)) {
219
+ let node = ref;
220
+ for (const member of ["target", "value"].slice(2 - depth)) {
221
+ const access = node.parent;
222
+ if (!isAccessOf(ts, access, member) || access.expression !== node) {
223
+ return {
224
+ reason: `reads \`${node.getText()}\` as more than \`${node.getText()}.${member}\``,
225
+ };
226
+ }
227
+ node = access;
228
+ }
229
+ if (isReadFurther(ts, node)) {
230
+ return {
231
+ reason: `reads a member of the value (\`${node.parent.getText()}\`); the new callback passes \`null\` when cleared`,
232
+ };
233
+ }
234
+ values.push(node);
235
+ }
236
+ return { values };
237
+ };
238
+
239
+ if (ts.isIdentifier(param.name)) {
240
+ const local = param.name.text;
241
+ const result = valueReads(local, 2);
242
+ if (result.reason) return { kind: "other", reason: result.reason };
243
+ return {
244
+ kind: "unwrap",
245
+ shape: result.values.length ? "event-target-value" : "ignores-event",
246
+ param,
247
+ name: local,
248
+ keepName: false,
249
+ values: result.values,
250
+ };
251
+ }
252
+
253
+ if (ts.isObjectBindingPattern(param.name)) {
254
+ const elements = param.name.elements;
255
+ if (elements.length !== 1) return { kind: "other", reason: "destructures more than `target`" };
256
+ const [element] = elements;
257
+ if (element.dotDotDotToken || element.initializer) {
258
+ return { kind: "other", reason: "a rest or defaulted destructuring" };
259
+ }
260
+ const key = element.propertyName ? element.propertyName.getText() : element.name.getText();
261
+ if (key !== "target")
262
+ return { kind: "other", reason: `destructures \`${key}\`, not \`target\`` };
263
+
264
+ if (ts.isIdentifier(element.name)) {
265
+ // `({ target })` or `({ target: t })`: every read must be `t.value`.
266
+ const result = valueReads(element.name.text, 1);
267
+ if (result.reason) return { kind: "other", reason: result.reason };
268
+ return {
269
+ kind: "unwrap",
270
+ shape: "target-value",
271
+ param,
272
+ name: element.name.text,
273
+ keepName: false,
274
+ values: result.values,
275
+ };
276
+ }
277
+ if (ts.isObjectBindingPattern(element.name)) {
278
+ // `({ target: { value } })` or `({ target: { value: v } })`.
279
+ const inner = element.name.elements;
280
+ if (inner.length !== 1 || inner[0].dotDotDotToken || inner[0].initializer) {
281
+ return { kind: "other", reason: "destructures more than `target.value`" };
282
+ }
283
+ const innerKey = inner[0].propertyName
284
+ ? inner[0].propertyName.getText()
285
+ : inner[0].name.getText();
286
+ if (innerKey !== "value" || !ts.isIdentifier(inner[0].name)) {
287
+ return {
288
+ kind: "other",
289
+ reason: `destructures \`target.${innerKey}\`, not \`target.value\``,
290
+ };
291
+ }
292
+ const local = inner[0].name.text;
293
+ const result = valueReads(local, 0);
294
+ if (result.reason) return { kind: "other", reason: result.reason };
295
+ return {
296
+ kind: "unwrap",
297
+ shape: "destructured-value",
298
+ param,
299
+ name: local,
300
+ keepName: true,
301
+ values: [],
302
+ };
303
+ }
304
+ }
305
+ return { kind: "other", reason: "an unrecognised parameter shape" };
306
+ }
307
+
308
+ /**
309
+ * A parameter name that nothing in the enclosing function (the component
310
+ * rendering the picker) binds or reads, so the new parameter shadows
311
+ * nothing: `value`, else `v`, else `next`.
312
+ */
313
+ export function freshName(ts, arrow) {
314
+ const isFunction = (n) =>
315
+ ts.isArrowFunction(n) ||
316
+ ts.isFunctionExpression(n) ||
317
+ ts.isFunctionDeclaration(n) ||
318
+ ts.isMethodDeclaration(n);
319
+ const ancestors = new Set([arrow]);
320
+ let scope = arrow.parent;
321
+ while (scope && !ts.isSourceFile(scope) && !isFunction(scope)) {
322
+ ancestors.add(scope);
323
+ scope = scope.parent;
324
+ }
325
+ // Sibling functions bind their own names; what they read is bound in the
326
+ // scope and seen at its declaration.
327
+ const used = identifierNames(
328
+ ts,
329
+ scope ?? arrow,
330
+ new Set(),
331
+ (n) => isFunction(n) && n !== scope && !ancestors.has(n),
332
+ );
333
+ return NAME_CANDIDATES.find((name) => !used.has(name)) ?? "nextValue";
334
+ }
335
+
336
+ export function transform(file, { ts }) {
337
+ const facts = analyseForms(ts, file.source, file.rel);
338
+ const findings = [];
339
+ const edits = [];
340
+
341
+ for (const el of facts.elements) {
342
+ if (!el.isDs || !FAKE_EVENT_PICKERS.has(el.base) || el.component !== el.base) continue;
343
+ const legacy = el.props.get(LEGACY_PROP);
344
+ if (!legacy) continue;
345
+
346
+ const base = {
347
+ registryId: "C-DATE-FAKEEVENT",
348
+ match: `<${el.tag} ${LEGACY_PROP}>`,
349
+ component: el.component,
350
+ gate: null,
351
+ };
352
+ const review = (rule, detail) =>
353
+ findings.push({ ...base, line: legacy.line, rule, ...RULES[rule], detail });
354
+
355
+ if (el.props.has(PROP)) {
356
+ review("has-value-change", {
357
+ reason: `\`${PROP}\` is already passed; delete \`${LEGACY_PROP}\``,
358
+ });
359
+ continue;
360
+ }
361
+ if (el.spread) {
362
+ review("spread-props", { reason: `the spread may carry \`${LEGACY_PROP}\` or \`${PROP}\`` });
363
+ continue;
364
+ }
365
+
366
+ const opening = openingOf(ts, el.node);
367
+ const attr = attributeNamed(ts, opening, LEGACY_PROP);
368
+ const init = attr?.initializer;
369
+ const expression = init && ts.isJsxExpression(init) ? init.expression : null;
370
+ if (!expression) {
371
+ review("handler-shape", { reason: "no handler expression" });
372
+ continue;
373
+ }
374
+ const handler = analyseHandler(ts, expression);
375
+ if (handler.kind === "reference") {
376
+ review("handler-reference", { handler: expression.getText(facts.sf) });
377
+ continue;
378
+ }
379
+ if (handler.kind === "other") {
380
+ review("handler-shape", { reason: handler.reason });
381
+ continue;
382
+ }
383
+
384
+ const arrow = unparenthesize(ts, expression);
385
+ const name = handler.keepName ? handler.name : freshName(ts, arrow);
386
+ edits.push(renameAttribute(ts, facts.sf, opening, LEGACY_PROP, PROP));
387
+ if (handler.param) {
388
+ edits.push({
389
+ pos: handler.param.getStart(facts.sf),
390
+ end: handler.param.getEnd(),
391
+ text: name,
392
+ });
393
+ }
394
+ for (const valueNode of handler.values) {
395
+ edits.push({ pos: valueNode.getStart(facts.sf), end: valueNode.getEnd(), text: name });
396
+ }
397
+ findings.push({
398
+ ...base,
399
+ line: legacy.line,
400
+ rule: "fake-event-unwrapped",
401
+ ...RULES["fake-event-unwrapped"],
402
+ detail: {
403
+ shape: handler.shape,
404
+ param: name,
405
+ cleared: 'the new callback passes `null` where `onChange` passed `""`',
406
+ },
407
+ });
408
+ }
409
+
410
+ return {
411
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
412
+ findings,
413
+ notTransformed: [],
414
+ parseErrors: facts.parseErrors,
415
+ };
416
+ }
@@ -97,9 +97,9 @@ export const NAME_CAPABILITY = {
97
97
  SearchSelect: "planned", // W4-12 (adapter over Combobox)
98
98
  TeamMemberSelect: "planned", // W4-08/W4-12
99
99
  MultiSelectField: "planned", // W4-08
100
- ToggleGroup: "planned", // W4-16
100
+ ToggleGroup: "yes", // W4-16: FormBridge (`name?: string`)
101
101
  PhoneCountryInput: "planned", // W4-04
102
- OtpInput: "planned", // W4-04
102
+ OtpInput: "yes", // src/primitives/otp-input.tsx — the `input-otp` input carries `name` (optional since W4-23: a Field can supply it)
103
103
  Questions: "planned", // W4-04 (answer widgets)
104
104
  };
105
105