@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.
- package/README.md +44 -13
- package/codemods/README.md +146 -14
- package/codemods/lib/forms.mjs +253 -0
- package/codemods/lib/jsx-edit.mjs +59 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +18 -6
- package/codemods/lib/registry.mjs +4 -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-12-button-type-submit.mjs +115 -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 +754 -53
- package/dist/index.js +3653 -3484
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/system-BDU18fVg.d.ts +559 -0
- package/dist/testing/index.cjs +12 -5
- package/dist/testing/index.js +12 -5
- 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 +5 -2
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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 });
|