@assure-one/design-system 1.32.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.
@@ -0,0 +1,442 @@
1
+ /**
2
+ * CM-20 — Select sentinel finder (class X, report-only; plan §29, registry
3
+ * `C-SELECT-EMPTY`, feeds Wave 4 W4-08).
4
+ *
5
+ * `Select` reserves the empty string for "nothing picked" and Radix refuses
6
+ * `value=""` on an item, so every application invented a stand-in value for
7
+ * the "no value" option and converts it back at the form boundary ([CU §18]:
8
+ * "sentinels, because Select reserves the empty string for 'nothing picked'").
9
+ * W4-08 gives `Select` a real `value: string | null`.
10
+ *
11
+ * Replacing a sentinel with `null` changes what the server receives — the
12
+ * one thing plan §29 never automates — so CM-20 is report-only for ever. It
13
+ * finds the sentinels and the line where each is converted; a human decides
14
+ * what the field should post.
15
+ *
16
+ * ## Finding the sentinels rather than assuming them
17
+ *
18
+ * `"none"` and `"__unassigned__"` are the two the registry names, but the
19
+ * applications use many more (`__all__`, `__all_products__`, `__custom__`,
20
+ * `__scratch__`, `__external__`, `__none__`, …), and `"none"` is also a
21
+ * perfectly real domain value (`deposit_type: "none"`). A fixed list would
22
+ * both miss and over-report, so CM-20 works from evidence in the file:
23
+ *
24
+ * | evidence | why it is conclusive |
25
+ * | ------------ | ------------------------------------------------------------------------ |
26
+ * | round trip | the file converts the value to `null`/`undefined`/`""`, or defaults to it |
27
+ * | `__dunder__` | the spelling exists only to dodge Radix's empty-value rule |
28
+ * | constant name| `NONE_VALUE`, `UNASSIGNED`, `NO_OWNER`, `ALL_MEMBERS`, `…_SENTINEL` |
29
+ * | option label | the option is labelled "All …", "None", "Unassigned", "No …" |
30
+ *
31
+ * A spelling is reported only where it is actually used as a `Select` value:
32
+ * a control's `value`/`defaultValue`, a `SelectItem value`, or the `value`
33
+ * of an option record in a file that renders one of the Select family. The
34
+ * same `"__ungrouped__"` used as a grouping key in a table is not a Select
35
+ * sentinel and stays silent.
36
+ *
37
+ * | rule | registry | what it is |
38
+ * | --------------------- | --------------- | ------------------------------------------------------------ |
39
+ * | `empty-string-value` | C-SELECT-EMPTY | `value=""` on a Select-family control or an option |
40
+ * | `sentinel-dunder` | C-SELECT-EMPTY | a `__…__` spelling used as a Select value |
41
+ * | `sentinel-constant` | C-SELECT-EMPTY | a no-value-named constant used as a Select value |
42
+ * | `sentinel-word` | C-SELECT-EMPTY | `none`/`all`/… with a no-value label or a round trip |
43
+ * | `sentinel-discovered` | C-SELECT-EMPTY | any other spelling the file converts to nothing |
44
+ * | `sentinel-conversion` | C-SELECT-EMPTY | the line that maps a reported sentinel to `null`/`undefined`/`""` |
45
+ *
46
+ * ## What it deliberately does not do
47
+ *
48
+ * - It never writes a file (the runner refuses on class X), and it proposes
49
+ * no replacement: whether a field should post `null`, `""` or the sentinel
50
+ * is a server contract.
51
+ * - A known word with no evidence at all (`"none"` as a genuine deposit
52
+ * type, `"all"` as a real filter the API understands) is **not** reported.
53
+ * CM-20 would rather miss a sentinel than invite someone to change a value
54
+ * the backend depends on.
55
+ * - Sentinels outside the Select family — grouping keys, tab ids, route
56
+ * segments — are out of scope even when the spelling matches.
57
+ * - It does not follow a spelling across files; a sentinel declared in a
58
+ * shared constants module and used elsewhere is reported at the use site
59
+ * only if that file resolves it.
60
+ */
61
+ import { analyseForms, literalValue } from "../lib/forms.mjs";
62
+
63
+ export const meta = {
64
+ id: "CM-20",
65
+ title: "Select sentinel finder: option values standing in for “no value”",
66
+ class: "X",
67
+ oneShot: false,
68
+ requires: { codemods: [], dsVersion: null },
69
+ parses: ["code"],
70
+ includeTests: false,
71
+ usesTypeScript: true,
72
+ usesPostcss: false,
73
+ registryIds: ["C-SELECT-EMPTY"],
74
+ };
75
+
76
+ /** How a finding is weighted in the report and in the Wave 4 work. */
77
+ export const SEVERITY = { high: "high", medium: "medium", low: "low" };
78
+
79
+ /**
80
+ * The Select family: components whose `value` is an option identifier the
81
+ * user picks from a list. `DatePicker` and the checkbox-like controls are
82
+ * not here — they have no "no value" option to spell.
83
+ */
84
+ export const SELECT_FAMILY = new Set([
85
+ "Select",
86
+ "SelectRoot",
87
+ "SelectItem",
88
+ "SearchSelect",
89
+ "ClientSelect",
90
+ "TeamMemberSelect",
91
+ "MultiSelectField",
92
+ "MultiFilterPill",
93
+ "DisplayMenu",
94
+ "Combobox",
95
+ ]);
96
+
97
+ /** Attributes of a Select-family element that hold an option value. */
98
+ const VALUE_ATTRIBUTES = ["value", "defaultValue"];
99
+
100
+ /** Object keys that make an object literal an option record. */
101
+ const LABEL_KEYS = new Set(["label", "labelKey", "title", "name", "text"]);
102
+
103
+ /** Words that stand in for "no value" often enough to be worth checking. */
104
+ export const NO_VALUE_WORDS = new Set([
105
+ "none",
106
+ "all",
107
+ "any",
108
+ "unassigned",
109
+ "unspecified",
110
+ "unset",
111
+ "empty",
112
+ "null",
113
+ "undefined",
114
+ "n/a",
115
+ "na",
116
+ "-",
117
+ "--",
118
+ "—",
119
+ ]);
120
+
121
+ /** A spelling whose only purpose is to dodge Radix's empty-value rule. */
122
+ export const DUNDER = /^__[A-Za-z0-9][\w-]*__$/;
123
+
124
+ /** A constant name that says the value means "nothing picked". */
125
+ export const SENTINEL_NAME =
126
+ /(?:^|_)(?:NONE|ALL|ANY|UNASSIGNED|UNSPECIFIED|UNSET|EMPTY|SENTINEL|PLACEHOLDER|NO)(?:_|$)/;
127
+
128
+ /** A label that says the option is the "no value" one. */
129
+ export const NO_VALUE_LABEL =
130
+ /(?:^|[.\s_\-—])(?:all|none|any|unassigned|unspecified|unset|empty|everyone|everything|no)(?:[.\s_\-—]|$)/i;
131
+
132
+ export function transform(file, { ts }) {
133
+ const facts = analyseForms(ts, file.source, file.rel);
134
+ const sf = facts.sf;
135
+ const usesSelect = [...facts.componentsUsed].some((c) => SELECT_FAMILY.has(c));
136
+ if (!usesSelect) return { findings: [], parseErrors: facts.parseErrors };
137
+
138
+ const findings = [];
139
+ const notTransformed = [];
140
+
141
+ // -------------------------------------------------------------------------
142
+ // Evidence 1 — round trips. The file converts a spelling to nothing, or
143
+ // defaults to it when there is nothing.
144
+
145
+ const EMPTY = (node) =>
146
+ node !== undefined &&
147
+ node !== null &&
148
+ ((ts.isStringLiteral(node) && node.text === "") ||
149
+ node.kind === ts.SyntaxKind.NullKeyword ||
150
+ (ts.isIdentifier(node) && node.text === "undefined") ||
151
+ (ts.isNoSubstitutionTemplateLiteral(node) && node.text === ""));
152
+
153
+ const stringOf = (node) => {
154
+ if (!node) return null;
155
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
156
+ if (ts.isIdentifier(node)) return facts.constants.get(node.text)?.text ?? null;
157
+ return null;
158
+ };
159
+ /** The declared name behind a value expression, when it is a constant. */
160
+ const constantName = (node) =>
161
+ node && ts.isIdentifier(node) && facts.constants.has(node.text) ? node.text : null;
162
+
163
+ /** spelling → { line, mapsTo, how } */
164
+ const roundTrips = new Map();
165
+ const noteRoundTrip = (spelling, node, mapsTo, how) => {
166
+ if (spelling === null || spelling === "") return;
167
+ if (roundTrips.has(spelling)) return;
168
+ roundTrips.set(spelling, {
169
+ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
170
+ mapsTo,
171
+ how,
172
+ text: node.getText(sf).replace(/\s+/g, " ").slice(0, 160),
173
+ });
174
+ };
175
+ const emptyName = (node) =>
176
+ ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
177
+ ? '""'
178
+ : node.kind === ts.SyntaxKind.NullKeyword
179
+ ? "null"
180
+ : "undefined";
181
+
182
+ /**
183
+ * The opening elements of the Select family, so that `x ?? SENTINEL` can be
184
+ * required to sit in a Select's own `value` — `currency ?? "USD"` elsewhere
185
+ * in the file is a default, not a stand-in for "nothing picked".
186
+ */
187
+ const selectOpenings = new Set();
188
+ for (const el of facts.elements) {
189
+ if (el.isDs && SELECT_FAMILY.has(el.base)) {
190
+ selectOpenings.add(ts.isJsxElement(el.node) ? el.node.openingElement : el.node);
191
+ }
192
+ }
193
+ const isSelectValueAttribute = (node) =>
194
+ ts.isJsxAttribute(node) &&
195
+ VALUE_ATTRIBUTES.includes(node.name.getText(sf)) &&
196
+ selectOpenings.has(node.parent?.parent);
197
+
198
+ const same = (a, b) => a && b && a.getText(sf).trim() === b.getText(sf).trim();
199
+
200
+ const scanEvidence = (node, inSelectValue) => {
201
+ // The normaliser shape: `x === SENTINEL ? nothing : x`, and its inverse
202
+ // `x === "" ? SENTINEL : x`. The untouched branch must be the subject
203
+ // itself — `status === "completed" ? null : "draft"` maps one domain
204
+ // value onto another and is not a sentinel.
205
+ if (ts.isConditionalExpression(node)) {
206
+ const cond = node.condition;
207
+ if (ts.isBinaryExpression(cond) && /^[=!]==?$/.test(cond.operatorToken.getText(sf))) {
208
+ const negated = cond.operatorToken.getText(sf).startsWith("!");
209
+ const [emptyBranch, subjectBranch] = negated
210
+ ? [node.whenFalse, node.whenTrue]
211
+ : [node.whenTrue, node.whenFalse];
212
+ const sides = [cond.left, cond.right];
213
+ const subject = sides.find((s) => stringOf(s) === null);
214
+ if (subject && same(subject, subjectBranch) && EMPTY(emptyBranch)) {
215
+ const spelling = sides.map(stringOf).find((s) => s !== null && s !== "");
216
+ if (spelling !== undefined) {
217
+ noteRoundTrip(spelling, node, emptyName(emptyBranch), "conditional");
218
+ }
219
+ } else if (subject && same(subject, emptyBranch) && sides.some(EMPTY)) {
220
+ // `x === "" ? SENTINEL : x` — the sentinel stands in for `""`.
221
+ const branchSpelling = stringOf(subjectBranch);
222
+ if (branchSpelling !== null && branchSpelling !== "") {
223
+ noteRoundTrip(branchSpelling, node, '""', "conditional");
224
+ }
225
+ }
226
+ }
227
+ }
228
+ // `x ?? SENTINEL` inside a Select's own `value`: the value the control
229
+ // shows when the state holds nothing.
230
+ if (inSelectValue && ts.isBinaryExpression(node)) {
231
+ const op = node.operatorToken.getText(sf);
232
+ if (op === "??" || op === "||") {
233
+ const spelling = stringOf(node.right);
234
+ if (spelling !== null && spelling !== "" && stringOf(node.left) === null) {
235
+ noteRoundTrip(spelling, node, "a missing value", "default");
236
+ }
237
+ }
238
+ }
239
+ const next = inSelectValue || isSelectValueAttribute(node);
240
+ ts.forEachChild(node, (child) => scanEvidence(child, next));
241
+ };
242
+ scanEvidence(sf, false);
243
+
244
+ // -------------------------------------------------------------------------
245
+ // Evidence 2 — option records: `{ value: "…", label: "…" }` anywhere in a
246
+ // file that renders a Select-family component.
247
+
248
+ /** spelling → label text */
249
+ const optionLabels = new Map();
250
+ const optionRecords = [];
251
+ const scanOptions = (node) => {
252
+ if (ts.isObjectLiteralExpression(node)) {
253
+ let value = null;
254
+ let valueNode = null;
255
+ let label = null;
256
+ for (const prop of node.properties) {
257
+ if (!ts.isPropertyAssignment(prop) || !prop.name) continue;
258
+ const key =
259
+ ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null;
260
+ if (key === "value") {
261
+ value = stringOf(prop.initializer);
262
+ valueNode = prop.initializer;
263
+ } else if (key && LABEL_KEYS.has(key) && label === null) {
264
+ label = stringOf(prop.initializer);
265
+ }
266
+ }
267
+ if (value !== null && (label !== null || valueNode !== null)) {
268
+ optionRecords.push({
269
+ spelling: value,
270
+ label,
271
+ node: valueNode,
272
+ line: sf.getLineAndCharacterOfPosition(valueNode.getStart(sf)).line + 1,
273
+ constant: constantName(valueNode),
274
+ });
275
+ if (label !== null && !optionLabels.has(value)) optionLabels.set(value, label);
276
+ }
277
+ }
278
+ ts.forEachChild(node, scanOptions);
279
+ };
280
+ scanOptions(sf);
281
+
282
+ // -------------------------------------------------------------------------
283
+ // Use sites: where a spelling is actually a Select value.
284
+
285
+ /** { spelling, line, where, component, label, constant } */
286
+ const uses = [];
287
+ for (const el of facts.elements) {
288
+ if (!el.isDs || !SELECT_FAMILY.has(el.base)) continue;
289
+ for (const attribute of VALUE_ATTRIBUTES) {
290
+ const prop = el.props.get(attribute);
291
+ if (!prop) continue;
292
+ const direct = literalValue(prop);
293
+ const spellings = direct !== null ? [direct] : resolveFromExpression(prop);
294
+ for (const spelling of spellings) {
295
+ uses.push({
296
+ spelling,
297
+ line: prop.line,
298
+ where: `${attribute} on ${el.component}`,
299
+ component: el.component,
300
+ label:
301
+ el.base === "SelectItem" ? childText(el.node) : (optionLabels.get(spelling) ?? null),
302
+ constant: prop.identifiers.find((id) => facts.constants.has(id)) ?? null,
303
+ });
304
+ }
305
+ }
306
+ if (el.spread && !el.props.has("value") && !el.props.has("defaultValue")) {
307
+ notTransformed.push({
308
+ line: el.line,
309
+ reason: "spread-props",
310
+ detail: `<${el.component} {...props}> — the value is spread in, so the scan cannot read it`,
311
+ });
312
+ }
313
+ }
314
+ for (const record of optionRecords) {
315
+ uses.push({
316
+ spelling: record.spelling,
317
+ line: record.line,
318
+ where: "option value",
319
+ component: null,
320
+ label: record.label,
321
+ constant: record.constant,
322
+ });
323
+ }
324
+
325
+ /**
326
+ * The spellings a `value={…}` expression can hold. The empty string is
327
+ * excluded on purpose: inside an expression it is the *result* of a
328
+ * conversion (`x === SENTINEL ? "" : x`), not a `value=""` the control is
329
+ * given, and `empty-string-value` is about the latter.
330
+ */
331
+ function resolveFromExpression(prop) {
332
+ const out = [];
333
+ for (const id of prop.identifiers) {
334
+ const constant = facts.constants.get(id);
335
+ if (constant) out.push(constant.text);
336
+ }
337
+ for (const literal of prop.literals) out.push(literal);
338
+ return [...new Set(out)].filter((s) => s !== "");
339
+ }
340
+
341
+ /** The text children of a `<SelectItem>`, its visible label. */
342
+ function childText(node) {
343
+ if (!node || !ts.isJsxElement(node)) return null;
344
+ const parts = node.children
345
+ .filter((c) => ts.isJsxText(c))
346
+ .map((c) => c.text.trim())
347
+ .filter(Boolean);
348
+ return parts.length ? parts.join(" ").slice(0, 80) : null;
349
+ }
350
+
351
+ // -------------------------------------------------------------------------
352
+ // Classification.
353
+
354
+ const reported = new Set();
355
+ const reportedSpellings = new Set();
356
+ for (const use of uses) {
357
+ const key = `${use.line}:${use.spelling}:${use.where}`;
358
+ if (reported.has(key)) continue;
359
+ reported.add(key);
360
+ const trip = roundTrips.get(use.spelling) ?? null;
361
+ const labelled = use.label !== null && NO_VALUE_LABEL.test(use.label);
362
+ const named = use.constant !== null && SENTINEL_NAME.test(use.constant);
363
+
364
+ let rule = null;
365
+ let severity = SEVERITY.medium;
366
+ let confidence = "high";
367
+ if (use.spelling === "") {
368
+ rule = "empty-string-value";
369
+ severity = SEVERITY.high;
370
+ } else if (DUNDER.test(use.spelling)) {
371
+ rule = "sentinel-dunder";
372
+ severity = trip ? SEVERITY.high : SEVERITY.medium;
373
+ } else if (named) {
374
+ rule = "sentinel-constant";
375
+ severity = trip ? SEVERITY.high : SEVERITY.medium;
376
+ confidence = trip || labelled ? "high" : "medium";
377
+ } else if (NO_VALUE_WORDS.has(use.spelling.toLowerCase())) {
378
+ // A known word is only a sentinel with evidence: `"none"` is also a
379
+ // real deposit type and `"all"` a real filter the API understands.
380
+ if (!trip && !labelled) continue;
381
+ rule = "sentinel-word";
382
+ severity = trip ? SEVERITY.high : SEVERITY.medium;
383
+ confidence = trip ? "high" : "medium";
384
+ } else if (trip) {
385
+ // A spelling no list would have predicted. A conditional that maps it
386
+ // to nothing is conclusive; a `??` default inside the Select's own
387
+ // value is weaker — it may be a deliberate default role or currency
388
+ // rather than a stand-in for "nothing picked".
389
+ rule = "sentinel-discovered";
390
+ severity = trip.how === "conditional" ? SEVERITY.high : SEVERITY.medium;
391
+ confidence = trip.how === "conditional" ? "high" : "medium";
392
+ } else continue;
393
+
394
+ reportedSpellings.add(use.spelling);
395
+ findings.push({
396
+ line: use.line,
397
+ registryId: "C-SELECT-EMPTY",
398
+ gate: null,
399
+ severity,
400
+ rule,
401
+ scope: use.where,
402
+ component: use.component,
403
+ match: JSON.stringify(use.spelling),
404
+ detail: {
405
+ spelling: use.spelling,
406
+ constant: use.constant,
407
+ label: use.label,
408
+ mapsTo: trip?.mapsTo ?? null,
409
+ conversionLine: trip?.line ?? null,
410
+ },
411
+ confidence,
412
+ });
413
+ }
414
+
415
+ // The conversion site itself: the line where the server contract is
416
+ // decided, reported once per reported spelling.
417
+ for (const [spelling, trip] of [...roundTrips].sort(([a], [b]) => (a < b ? -1 : 1))) {
418
+ if (!reportedSpellings.has(spelling)) continue;
419
+ findings.push({
420
+ line: trip.line,
421
+ registryId: "C-SELECT-EMPTY",
422
+ gate: null,
423
+ severity: SEVERITY.medium,
424
+ rule: "sentinel-conversion",
425
+ scope: trip.how,
426
+ component: null,
427
+ match: trip.text,
428
+ detail: { spelling, mapsTo: trip.mapsTo, constant: null, label: null, conversionLine: null },
429
+ confidence: trip.how === "conditional" ? "high" : "medium",
430
+ });
431
+ }
432
+
433
+ return {
434
+ findings,
435
+ notTransformed,
436
+ parseErrors: facts.parseErrors,
437
+ context: {
438
+ sentinels: [...reportedSpellings].sort(),
439
+ roundTrips: [...roundTrips.keys()].sort(),
440
+ },
441
+ };
442
+ }