@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.
@@ -63,6 +63,7 @@ export function renderMarkdown(report) {
63
63
  const what = [
64
64
  f.component,
65
65
  f.rule,
66
+ f.action,
66
67
  f.severity && `severity ${f.severity}`,
67
68
  f.gate && `gate ${f.gate}`,
68
69
  f.confidence && `confidence ${f.confidence}`,
@@ -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 loadCodemod(id);
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
- const applied = meta.class !== "X" && !dry && changed.length > 0;
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 });