@assure-one/design-system 1.31.0 → 1.32.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 +24 -0
- package/codemods/0.2.0-radix-migration.mjs +315 -0
- package/codemods/README.md +305 -0
- package/codemods/lib/css-selectors.mjs +33 -0
- package/codemods/lib/css-values.mjs +223 -0
- package/codemods/lib/ds-stylesheet.mjs +168 -0
- package/codemods/lib/environment.mjs +72 -0
- package/codemods/lib/files.mjs +100 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +84 -0
- package/codemods/lib/registry.mjs +30 -0
- package/codemods/lib/report.mjs +119 -0
- package/codemods/lib/runner.mjs +164 -0
- package/codemods/run.mjs +161 -0
- package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
- package/codemods/transforms/cm-16-globals-css.mjs +487 -0
- package/dist/css/base.css +60 -0
- package/dist/css/legacy-aliases.css +489 -0
- package/dist/css/shadcn.css +155 -0
- package/dist/css/tailwind.css +233 -0
- package/dist/css/tokens.css +439 -0
- package/dist/index.d.ts +279 -24
- package/dist/index.js +1513 -584
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/testing/index.cjs +458 -0
- package/dist/testing/index.d.cts +253 -0
- package/dist/testing/index.d.ts +253 -0
- package/dist/testing/index.js +452 -0
- package/dist/testing/setup.cjs +123 -0
- package/dist/testing/setup.js +121 -0
- package/dist/testing/style-stub.cjs +7 -0
- package/dist/testing/style-stub.js +5 -0
- package/dist/tokens/index.d.ts +96 -96
- package/dist/tokens/index.js +48 -48
- package/dist/tokens/index.js.map +1 -1
- package/package.json +73 -5
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-15 — DOM-selector finder (class X, report-only; plan §29, registry C-DOM-*).
|
|
3
|
+
*
|
|
4
|
+
* Finds consumer code that depends on the internal DOM of design-system
|
|
5
|
+
* components and maps each hit to a compatibility-registry id:
|
|
6
|
+
*
|
|
7
|
+
* - class tokens on a DS component that reach into its children
|
|
8
|
+
* (descendant/child arbitrary variants, `*:`, `has-[…]`, nth/first/last-child)
|
|
9
|
+
* - the same tokens on a wrapper element whose JSX subtree holds a DS component
|
|
10
|
+
* - file-level group references to groups or attributes the DS sets
|
|
11
|
+
* (`group-data-[labels…]`, `…/sidebar`, `…/row`)
|
|
12
|
+
* - DOM queries (`querySelector`, `closest`, `matches`, Playwright `locator`)
|
|
13
|
+
* whose selector names DS internals, and header/label text matching
|
|
14
|
+
* - text queries for copy the DS renders itself ("Next", "Notifications"…)
|
|
15
|
+
* - tests that read the DS `dist` bundle
|
|
16
|
+
* - global CSS selectors that name DS internals
|
|
17
|
+
*
|
|
18
|
+
* Findings without a registry id are reported as `unregistered`: they are
|
|
19
|
+
* DOM coupling the registry does not track yet. Radix `data-state`-style
|
|
20
|
+
* attributes, ARIA roles and cmdk attributes are permanent contracts
|
|
21
|
+
* (P-DATA-STATE, P-ROLES, C-CMDK-ATTR) and are not reported.
|
|
22
|
+
*
|
|
23
|
+
* Never modifies files.
|
|
24
|
+
*/
|
|
25
|
+
import { analyse, splitVariants } from "../lib/jsx.mjs";
|
|
26
|
+
import { cssSelectors } from "../lib/css-selectors.mjs";
|
|
27
|
+
|
|
28
|
+
export const meta = {
|
|
29
|
+
id: "CM-15",
|
|
30
|
+
title: "DOM-selector finder on design-system components",
|
|
31
|
+
class: "X",
|
|
32
|
+
oneShot: false,
|
|
33
|
+
requires: { codemods: [], dsVersion: null },
|
|
34
|
+
parses: ["code", "css"],
|
|
35
|
+
includeTests: true,
|
|
36
|
+
usesTypeScript: true,
|
|
37
|
+
registryIds: [
|
|
38
|
+
"C-DOM-01",
|
|
39
|
+
"C-DOM-02",
|
|
40
|
+
"C-DOM-03",
|
|
41
|
+
"C-DOM-04",
|
|
42
|
+
"C-DOM-05",
|
|
43
|
+
"C-DOM-06",
|
|
44
|
+
"C-DOM-07",
|
|
45
|
+
"C-DOM-09",
|
|
46
|
+
"C-DOM-10",
|
|
47
|
+
"C-DOM-11",
|
|
48
|
+
"C-DOM-12",
|
|
49
|
+
"C-DOM-13",
|
|
50
|
+
"C-DOM-14",
|
|
51
|
+
"C-DOM-15",
|
|
52
|
+
"C-DOM-16",
|
|
53
|
+
"C-DOM-17",
|
|
54
|
+
"C-DOM-18",
|
|
55
|
+
"C-DOM-19",
|
|
56
|
+
"C-TOAST-ARIALABEL",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Token classification
|
|
62
|
+
|
|
63
|
+
const SIBLING_OR_CHILD = /^&\s*(?:_|>|\+|~)|^&\s*:(?:has|is|where)\(|^&_|^&>/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How a class token reaches into DOM it does not own, or `null`.
|
|
67
|
+
* Returns `{ kind, selector }`; `selector` is the variant text (for matching).
|
|
68
|
+
*/
|
|
69
|
+
export function domReach(token) {
|
|
70
|
+
const { variants } = splitVariants(token.replace(/^!/, ""));
|
|
71
|
+
for (const v of variants) {
|
|
72
|
+
const bare = v.replace(/^!/, "");
|
|
73
|
+
if (bare === "*" || bare === "**")
|
|
74
|
+
return { kind: bare === "*" ? "child" : "descendant", selector: bare };
|
|
75
|
+
if (bare.startsWith("[") && bare.endsWith("]") && bare.includes("&")) {
|
|
76
|
+
const inner = bare.slice(1, -1);
|
|
77
|
+
const afterAmp = inner.slice(inner.indexOf("&"));
|
|
78
|
+
if (SIBLING_OR_CHILD.test(afterAmp) || /[_>+~]/.test(afterAmp.replace(/\[[^\]]*\]/g, ""))) {
|
|
79
|
+
return { kind: /^&\s*>|^&>/.test(afterAmp) ? "child" : "descendant", selector: inner };
|
|
80
|
+
}
|
|
81
|
+
continue; // self-only (`[&[data-state=open]]`, `[&:hover]`)
|
|
82
|
+
}
|
|
83
|
+
if (/^(?:group-|peer-)?has-\[/.test(bare)) {
|
|
84
|
+
return {
|
|
85
|
+
kind: "has",
|
|
86
|
+
selector: bare.replace(/^(?:group-|peer-)?has-\[/, "").replace(/\]$/, ""),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
/^(?:nth|nth-last|first|last|only)-(?:child|of-type)?\[?/.test(bare) &&
|
|
91
|
+
/child|\[/.test(bare)
|
|
92
|
+
) {
|
|
93
|
+
// `nth-[2]:` etc. style the element's own position: not DS internals.
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Group references the DS provides: `{ group, attribute }` or `null`. */
|
|
101
|
+
export function groupReach(token) {
|
|
102
|
+
const { variants } = splitVariants(token.replace(/^!/, ""));
|
|
103
|
+
for (const v of variants) {
|
|
104
|
+
const m = v.match(/^(?:group|peer)-(?:data-\[([a-z-]+)[^\]]*\]|[^/]*)(?:\/([a-z][a-z-]*))?$/);
|
|
105
|
+
if (!m || !v.startsWith("group")) continue;
|
|
106
|
+
const attribute = m[1] ?? null;
|
|
107
|
+
const group = m[2] ?? null;
|
|
108
|
+
if (attribute || group) return { attribute, group, variant: v };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Attributes the DS sets on its own elements that are not permanent
|
|
114
|
+
// contracts. Radix/ARIA-style attributes (state, side, align, orientation,
|
|
115
|
+
// disabled, highlighted) are P-DATA-STATE and skipped.
|
|
116
|
+
const PERMANENT_ATTRIBUTES = new Set([
|
|
117
|
+
"state",
|
|
118
|
+
"side",
|
|
119
|
+
"align",
|
|
120
|
+
"orientation",
|
|
121
|
+
"disabled",
|
|
122
|
+
"highlighted",
|
|
123
|
+
"placeholder",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// Named groups declared inside DS components, with the components that own them.
|
|
127
|
+
const DS_GROUPS = {
|
|
128
|
+
sidebar: { components: /^Sidebar/, registryId: "C-DOM-06" },
|
|
129
|
+
"brand-trigger": { components: /^SidebarBrandSwitcher/, registryId: "C-DOM-06" },
|
|
130
|
+
link: { components: /^Sidebar/, registryId: "C-DOM-06" },
|
|
131
|
+
row: {
|
|
132
|
+
components: /^(?:Table|DataTable|FolderTree|DocumentDetailPanel)/,
|
|
133
|
+
registryId: "C-DOM-07",
|
|
134
|
+
},
|
|
135
|
+
agreement: { components: /^AgreementViewer$/, registryId: "C-DOM-01" },
|
|
136
|
+
step: { components: /^AgreementViewer$/, registryId: "C-DOM-01" },
|
|
137
|
+
dash: { components: /^DashGrid/, registryId: null },
|
|
138
|
+
bubble: { components: /^MessageBubble/, registryId: null },
|
|
139
|
+
addon: { components: /^Proposal/, registryId: null },
|
|
140
|
+
timelogger: { components: /^TimeLogger/, registryId: null },
|
|
141
|
+
"attachment-chip": { components: /^AttachmentChip/, registryId: null },
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// DS-set attributes and the registry id they belong to.
|
|
145
|
+
const DS_ATTRIBUTES = {
|
|
146
|
+
labels: "C-DOM-06",
|
|
147
|
+
collapsible: "C-DOM-06",
|
|
148
|
+
"with-toolbar": "C-DOM-18",
|
|
149
|
+
"detail-open": null,
|
|
150
|
+
peek: null,
|
|
151
|
+
"sticky-stack-host": null,
|
|
152
|
+
"sticky-stack-layer": null,
|
|
153
|
+
"sticky-stack-section-header": null,
|
|
154
|
+
"sticky-stack-sticky": null,
|
|
155
|
+
slot: null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Component rules (tag scope). Order matters: first match wins.
|
|
160
|
+
|
|
161
|
+
const has = (re) => (reach) => re.test(reach.selector);
|
|
162
|
+
const any = () => true;
|
|
163
|
+
const TAG_RULES = [
|
|
164
|
+
{ id: "C-DOM-01", components: /^AgreementViewer$/, test: any },
|
|
165
|
+
{ id: "C-DOM-02", components: /^ProposalSignatureBlock$/, test: any },
|
|
166
|
+
{ id: "C-DOM-03", components: /^MasterDetailLayout$/, test: (r) => r.kind === "child" },
|
|
167
|
+
{ id: "C-DOM-04", components: /^FileUpload$/, test: has(/label/) },
|
|
168
|
+
{
|
|
169
|
+
id: "C-DOM-05",
|
|
170
|
+
components: /^(?:IconTile|Button|LinkButton|SubmitButton)$/,
|
|
171
|
+
test: has(/(?:^|[_>\s])svg\b/),
|
|
172
|
+
},
|
|
173
|
+
{ id: "C-DOM-06", components: /^Sidebar/, test: any },
|
|
174
|
+
{
|
|
175
|
+
id: "C-DOM-09",
|
|
176
|
+
components: /^(?:Table|TableBody|TableHeader|DataTable|DataTableBody)$/,
|
|
177
|
+
test: has(/(?:^|[_>\s])(?:tr|th|td)\b/),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "C-DOM-10",
|
|
181
|
+
components: /^(?:Select|SelectTrigger)$/,
|
|
182
|
+
test: has(/(?:^|[_>\s])(?:span|svg)\b/),
|
|
183
|
+
},
|
|
184
|
+
{ id: "C-DOM-11", components: /^Accordion/, test: any },
|
|
185
|
+
{ id: "C-DOM-12", components: /^ProgressBar$/, test: has(/role/) },
|
|
186
|
+
{ id: "C-DOM-13", components: /^(?:DataTableHeader|DataTableHead)$/, test: has(/button/) },
|
|
187
|
+
{ id: "C-DOM-15", components: /^DocumentRequestCard/, test: any },
|
|
188
|
+
{
|
|
189
|
+
id: "C-DOM-16",
|
|
190
|
+
components: /^(?:MetadataGrid|DataItem)$/,
|
|
191
|
+
test: has(/(?:^|[_>\s])(?:dt|dd|span)\b/),
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
const ruleFor = (component, reach) =>
|
|
196
|
+
TAG_RULES.find((r) => r.components.test(component) && r.test(reach)) ?? null;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether an unmapped selector on a *wrapper* element is worth reporting: it
|
|
200
|
+
* must name an element, `*`, or a `data-`/`role` attribute. A wrapper selector
|
|
201
|
+
* that only targets classes of the app or of another library
|
|
202
|
+
* (`[&_.react-pdf__Page]`), or only a pseudo-class (`has-[:focus-visible]`),
|
|
203
|
+
* is the consumer's own markup. On a design-system tag every reaching selector
|
|
204
|
+
* is reported, class targets included: those classes are inside DS markup.
|
|
205
|
+
*/
|
|
206
|
+
const namesStructure = (selector) =>
|
|
207
|
+
/(?:^|[\s_>+~([])(?:\*|[a-z][a-z0-9]*\b)/.test(selector.replace(/\.[\w-]+/g, "")) ||
|
|
208
|
+
/\[(?:data-|role)/.test(selector);
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// DOM queries and text
|
|
212
|
+
|
|
213
|
+
const QUERY_METHODS = new Set([
|
|
214
|
+
"querySelector",
|
|
215
|
+
"querySelectorAll",
|
|
216
|
+
"closest",
|
|
217
|
+
"matches",
|
|
218
|
+
"locator",
|
|
219
|
+
]);
|
|
220
|
+
const TEXT_QUERY = /^(?:get|getAll|query|queryAll|find|findAll)By(?:Text|LabelText|Title)$/;
|
|
221
|
+
const ROLE_QUERY = /^(?:get|getAll|query|queryAll|find|findAll)ByRole$/;
|
|
222
|
+
|
|
223
|
+
// Copy the DS renders by default, with the components that render it.
|
|
224
|
+
const DS_TEXT = [
|
|
225
|
+
{ text: "Next", components: /^AgreementViewer$/, registryId: "C-DOM-01" },
|
|
226
|
+
{
|
|
227
|
+
text: "Notifications",
|
|
228
|
+
components: /^(?:Toaster|ToastProvider|useToast|toast)$/,
|
|
229
|
+
registryId: "C-TOAST-ARIALABEL",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
text: "Next page",
|
|
233
|
+
components: /^(?:Pagination|DataTablePagination|DataTableView)/,
|
|
234
|
+
registryId: null,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
text: "Previous page",
|
|
238
|
+
components: /^(?:Pagination|DataTablePagination|DataTableView)/,
|
|
239
|
+
registryId: null,
|
|
240
|
+
},
|
|
241
|
+
{ text: "Clear search", components: /^SearchInput$/, registryId: null },
|
|
242
|
+
{ text: "Back", components: /^MasterDetailLayout$/, registryId: null },
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const mentions = (text, pattern) => {
|
|
246
|
+
const names = new Set(text.match(/\b[A-Za-z_$][\w$]*\b/g) ?? []);
|
|
247
|
+
return [...names].filter((n) => pattern.test(n));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const selectorFindings = (selector) => {
|
|
251
|
+
const out = [];
|
|
252
|
+
if (/:has\([^)]*data-esign-cover/.test(selector))
|
|
253
|
+
out.push({ id: "C-DOM-17", why: "esign-cover" });
|
|
254
|
+
if (/data-radix-select-viewport/.test(selector))
|
|
255
|
+
out.push({ id: "C-DOM-19", why: "radix-select-viewport" });
|
|
256
|
+
if (/aria-label=["']?Notifications/.test(selector))
|
|
257
|
+
out.push({ id: "C-TOAST-ARIALABEL", why: "toast-region" });
|
|
258
|
+
for (const m of selector.matchAll(/\[data-([a-z][a-z-]*)/g)) {
|
|
259
|
+
const attribute = m[1];
|
|
260
|
+
if (attribute in DS_ATTRIBUTES)
|
|
261
|
+
out.push({ id: DS_ATTRIBUTES[attribute], why: `data-${attribute}` });
|
|
262
|
+
}
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
return out.filter((f) => {
|
|
265
|
+
const key = `${f.id}:${f.why}`;
|
|
266
|
+
if (seen.has(key)) return false;
|
|
267
|
+
seen.add(key);
|
|
268
|
+
return true;
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export function transform(file, { ts }) {
|
|
275
|
+
if (file.kind === "css") return { findings: cssFindings(file) };
|
|
276
|
+
return codeFindings(file, ts);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function cssFindings(file) {
|
|
280
|
+
if (file.test) return [];
|
|
281
|
+
const findings = [];
|
|
282
|
+
for (const { selector, line } of cssSelectors(file.source)) {
|
|
283
|
+
for (const hit of selectorFindings(selector)) {
|
|
284
|
+
findings.push({
|
|
285
|
+
line,
|
|
286
|
+
registryId: hit.id,
|
|
287
|
+
rule: "css-selector",
|
|
288
|
+
scope: "selector",
|
|
289
|
+
component: null,
|
|
290
|
+
match: selector.slice(0, 200),
|
|
291
|
+
confidence: hit.id ? "high" : "medium",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return findings;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function codeFindings(file, ts) {
|
|
299
|
+
const facts = analyse(ts, file.source, file.rel);
|
|
300
|
+
const findings = [];
|
|
301
|
+
const add = (f) => findings.push({ confidence: "high", component: null, ...f });
|
|
302
|
+
const text = file.source;
|
|
303
|
+
const used = facts.componentsUsed;
|
|
304
|
+
const usedList = [...used].sort();
|
|
305
|
+
|
|
306
|
+
if (!file.test) {
|
|
307
|
+
// 1. Tokens on DS component tags.
|
|
308
|
+
for (const usage of facts.usages) {
|
|
309
|
+
for (const { token, line } of usage.tokens) {
|
|
310
|
+
const reach = domReach(token);
|
|
311
|
+
if (!reach) continue;
|
|
312
|
+
const rule = ruleFor(usage.component, reach) ?? ruleFor(usage.base, reach);
|
|
313
|
+
add({
|
|
314
|
+
line,
|
|
315
|
+
registryId: rule?.id ?? null,
|
|
316
|
+
rule: rule ? "class-on-component" : "unregistered-class-on-component",
|
|
317
|
+
scope: "tag",
|
|
318
|
+
component: usage.component,
|
|
319
|
+
match: token,
|
|
320
|
+
confidence: rule ? "high" : "medium",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 2. Descendant tokens on wrappers of DS components.
|
|
326
|
+
for (const el of facts.rawElements) {
|
|
327
|
+
if (!el.dsDescendants.length) continue;
|
|
328
|
+
for (const { token, line } of el.tokens) {
|
|
329
|
+
const reach = domReach(token);
|
|
330
|
+
if (!reach || reach.kind === "child") continue;
|
|
331
|
+
const rules = el.dsDescendants
|
|
332
|
+
.map((c) => ({ c, rule: ruleFor(c, reach) }))
|
|
333
|
+
.filter((x) => x.rule && x.rule.test !== any);
|
|
334
|
+
const hit = rules[0];
|
|
335
|
+
if (!hit && !namesStructure(reach.selector)) continue;
|
|
336
|
+
add({
|
|
337
|
+
line,
|
|
338
|
+
registryId: hit?.rule.id ?? null,
|
|
339
|
+
rule: hit ? "class-on-wrapper" : "unregistered-class-on-wrapper",
|
|
340
|
+
scope: "ancestor",
|
|
341
|
+
component: hit?.c ?? el.dsDescendants.join(", "),
|
|
342
|
+
match: token,
|
|
343
|
+
confidence: hit ? "medium" : "low",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 3. File-level group and attribute references.
|
|
349
|
+
const ownGroups = new Set(
|
|
350
|
+
facts.rawElements.flatMap((el) =>
|
|
351
|
+
el.tokens.map((t) => t.token.match(/^group\/([a-z][a-z-]*)$/)?.[1]).filter(Boolean),
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
const onTags = new Set(facts.usages.flatMap((u) => u.tokens.map((t) => `${t.line}${t.token}`)));
|
|
355
|
+
for (const { token, line } of facts.fileTokens) {
|
|
356
|
+
const g = groupReach(token);
|
|
357
|
+
if (!g) continue;
|
|
358
|
+
if (g.attribute && PERMANENT_ATTRIBUTES.has(g.attribute) && g.group !== "row") continue;
|
|
359
|
+
let registryId;
|
|
360
|
+
let component;
|
|
361
|
+
if (g.group && DS_GROUPS[g.group]) {
|
|
362
|
+
const owner = DS_GROUPS[g.group];
|
|
363
|
+
const owners = usedList.filter((c) => owner.components.test(c));
|
|
364
|
+
if (!owners.length) continue;
|
|
365
|
+
if (ownGroups.has(g.group) && g.group !== "row") continue;
|
|
366
|
+
registryId = owner.registryId;
|
|
367
|
+
component = owners.join(", ");
|
|
368
|
+
} else if (g.attribute && g.attribute in DS_ATTRIBUTES && usedList.length) {
|
|
369
|
+
registryId = DS_ATTRIBUTES[g.attribute];
|
|
370
|
+
component = null;
|
|
371
|
+
} else continue;
|
|
372
|
+
if (onTags.has(`${line}${token}`) && domReach(token)) continue; // already reported
|
|
373
|
+
add({
|
|
374
|
+
line,
|
|
375
|
+
registryId,
|
|
376
|
+
rule: registryId ? "group-reference" : "unregistered-group-reference",
|
|
377
|
+
scope: "file",
|
|
378
|
+
component,
|
|
379
|
+
match: token,
|
|
380
|
+
confidence: "medium",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 4. MetadataGrid / DataItem wrappers style dt/dd from anywhere in the file.
|
|
385
|
+
if (used.has("MetadataGrid") || used.has("DataItem")) {
|
|
386
|
+
const reported = new Set(findings.map((f) => `${f.line}${f.match}`));
|
|
387
|
+
for (const { token, line } of facts.fileTokens) {
|
|
388
|
+
const reach = domReach(token);
|
|
389
|
+
if (!reach || !/(?:^|[_>\s])(?:dt|dd)\b/.test(reach.selector)) continue;
|
|
390
|
+
if (reported.has(`${line}${token}`)) continue;
|
|
391
|
+
add({
|
|
392
|
+
line,
|
|
393
|
+
registryId: "C-DOM-16",
|
|
394
|
+
rule: "class-in-file",
|
|
395
|
+
scope: "file",
|
|
396
|
+
component: usedList.filter((c) => /^(?:MetadataGrid|DataItem)$/.test(c)).join(", "),
|
|
397
|
+
match: token,
|
|
398
|
+
confidence: "medium",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 5. Calls: DOM queries, text queries, dist parsing.
|
|
405
|
+
const argText = (node) => {
|
|
406
|
+
if (!node) return null;
|
|
407
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
408
|
+
if (ts.isIdentifier(node)) {
|
|
409
|
+
const parts = facts.resolveConstant(node.text);
|
|
410
|
+
return parts ? parts.map((p) => p.text).join("") : null;
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
};
|
|
414
|
+
const dataTableFile = /\bDataTableView\b/.test(text);
|
|
415
|
+
const agreementFile = used.has("AgreementViewer") || /\bAgreementViewer\b/.test(text);
|
|
416
|
+
|
|
417
|
+
const visit = (node) => {
|
|
418
|
+
if (ts.isCallExpression(node)) {
|
|
419
|
+
const callee = node.expression;
|
|
420
|
+
const name = ts.isPropertyAccessExpression(callee)
|
|
421
|
+
? callee.name.text
|
|
422
|
+
: ts.isIdentifier(callee)
|
|
423
|
+
? callee.text
|
|
424
|
+
: null;
|
|
425
|
+
const first = argText(node.arguments[0]);
|
|
426
|
+
const line = facts.lineOf(node);
|
|
427
|
+
if (name && QUERY_METHODS.has(name) && first !== null) {
|
|
428
|
+
const hits = selectorFindings(first);
|
|
429
|
+
for (const hit of hits) {
|
|
430
|
+
add({
|
|
431
|
+
line,
|
|
432
|
+
registryId: hit.id,
|
|
433
|
+
rule: "dom-query",
|
|
434
|
+
scope: "call",
|
|
435
|
+
match: `${name}(${JSON.stringify(first)})`,
|
|
436
|
+
confidence: hit.id ? "high" : "medium",
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (!hits.length && name !== "locator" && !file.test) {
|
|
440
|
+
const tag = first.trim();
|
|
441
|
+
if (agreementFile && /^button\b/.test(tag)) {
|
|
442
|
+
add({
|
|
443
|
+
line,
|
|
444
|
+
registryId: "C-DOM-01",
|
|
445
|
+
rule: "dom-query",
|
|
446
|
+
scope: "call",
|
|
447
|
+
component: "AgreementViewer",
|
|
448
|
+
match: `${name}(${JSON.stringify(first)})`,
|
|
449
|
+
});
|
|
450
|
+
} else if (dataTableFile && /^(?:th|thead)\b/.test(tag)) {
|
|
451
|
+
add({
|
|
452
|
+
line,
|
|
453
|
+
registryId: "C-DOM-14",
|
|
454
|
+
rule: "dom-query",
|
|
455
|
+
scope: "call",
|
|
456
|
+
component: "DataTableView",
|
|
457
|
+
match: `${name}(${JSON.stringify(first)})`,
|
|
458
|
+
});
|
|
459
|
+
} else if (
|
|
460
|
+
dataTableFile &&
|
|
461
|
+
/^button\b/.test(tag) &&
|
|
462
|
+
/\bth\b|header/i.test(node.expression.getText(facts.sf))
|
|
463
|
+
) {
|
|
464
|
+
add({
|
|
465
|
+
line,
|
|
466
|
+
registryId: "C-DOM-14",
|
|
467
|
+
rule: "dom-query",
|
|
468
|
+
scope: "call",
|
|
469
|
+
component: "DataTableView",
|
|
470
|
+
match: `${name}(${JSON.stringify(first)})`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (name && (TEXT_QUERY.test(name) || ROLE_QUERY.test(name) || name === "getByText")) {
|
|
476
|
+
let literal = TEXT_QUERY.test(name) ? first : null;
|
|
477
|
+
if (
|
|
478
|
+
ROLE_QUERY.test(name) &&
|
|
479
|
+
node.arguments[1] &&
|
|
480
|
+
ts.isObjectLiteralExpression(node.arguments[1])
|
|
481
|
+
) {
|
|
482
|
+
const prop = node.arguments[1].properties.find(
|
|
483
|
+
(p) => ts.isPropertyAssignment(p) && p.name.getText(facts.sf) === "name",
|
|
484
|
+
);
|
|
485
|
+
literal = prop ? argText(prop.initializer) : null;
|
|
486
|
+
}
|
|
487
|
+
const entry = literal && DS_TEXT.find((d) => d.text === literal.trim());
|
|
488
|
+
const owners = entry ? mentions(text, entry.components) : [];
|
|
489
|
+
if (entry && owners.length) {
|
|
490
|
+
const shown = ROLE_QUERY.test(name)
|
|
491
|
+
? `${name}(…, { name: ${JSON.stringify(literal)} })`
|
|
492
|
+
: `${name}(${JSON.stringify(literal)})`;
|
|
493
|
+
add({
|
|
494
|
+
line,
|
|
495
|
+
registryId: entry.registryId,
|
|
496
|
+
rule: entry.registryId ? "text-query" : "unregistered-text-query",
|
|
497
|
+
scope: "call",
|
|
498
|
+
component: owners.join(", "),
|
|
499
|
+
match: shown,
|
|
500
|
+
confidence: "medium",
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (name && /^(?:readFileSync|readFile|require|resolve)$/.test(name)) {
|
|
505
|
+
const path = node.arguments
|
|
506
|
+
.map((a) => argText(a))
|
|
507
|
+
.find((a) => a && /design-system\/dist\b|(?:^|\/)dist\/index\.js$/.test(a));
|
|
508
|
+
if (path) {
|
|
509
|
+
const owners = mentions(text, /^Proposal/);
|
|
510
|
+
add({
|
|
511
|
+
line,
|
|
512
|
+
registryId: owners.length ? "C-DOM-02" : null,
|
|
513
|
+
rule: owners.length ? "dist-parsing" : "unregistered-dist-parsing",
|
|
514
|
+
scope: "call",
|
|
515
|
+
component: owners.join(", ") || null,
|
|
516
|
+
match: `${name}(${JSON.stringify(path.slice(0, 160))})`,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Header/label text matching in production code.
|
|
522
|
+
if (
|
|
523
|
+
!file.test &&
|
|
524
|
+
ts.isBinaryExpression(node) &&
|
|
525
|
+
/^===?$|^!==?$/.test(node.operatorToken.getText(facts.sf))
|
|
526
|
+
) {
|
|
527
|
+
const sides = [node.left, node.right];
|
|
528
|
+
const lit = sides.map(argText).find((v) => v !== null);
|
|
529
|
+
const other = sides.find((s) => argText(s) === null);
|
|
530
|
+
const reads = other && /\.(?:textContent|innerText)\b/.test(other.getText(facts.sf));
|
|
531
|
+
if (lit !== undefined && reads) {
|
|
532
|
+
const entry = DS_TEXT.find((d) => d.text === lit.trim());
|
|
533
|
+
if (entry && mentions(text, entry.components).length) {
|
|
534
|
+
add({
|
|
535
|
+
line: facts.lineOf(node),
|
|
536
|
+
registryId: entry.registryId,
|
|
537
|
+
rule: "text-match",
|
|
538
|
+
scope: "call",
|
|
539
|
+
component: mentions(text, entry.components).join(", "),
|
|
540
|
+
match: node.getText(facts.sf).slice(0, 160),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (
|
|
546
|
+
!file.test &&
|
|
547
|
+
dataTableFile &&
|
|
548
|
+
ts.isPropertyAccessExpression(node) &&
|
|
549
|
+
/^(?:textContent|innerText)$/.test(node.name.text)
|
|
550
|
+
) {
|
|
551
|
+
const target = node.expression.getText(facts.sf);
|
|
552
|
+
if (/header|\bth\b|cell/i.test(target)) {
|
|
553
|
+
add({
|
|
554
|
+
line: facts.lineOf(node),
|
|
555
|
+
registryId: "C-DOM-14",
|
|
556
|
+
rule: "text-match",
|
|
557
|
+
scope: "call",
|
|
558
|
+
component: "DataTableView",
|
|
559
|
+
match: node.getText(facts.sf).slice(0, 160),
|
|
560
|
+
confidence: "medium",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
ts.forEachChild(node, visit);
|
|
565
|
+
};
|
|
566
|
+
visit(facts.sf);
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
findings,
|
|
570
|
+
parseErrors: facts.parseErrors,
|
|
571
|
+
context: { components: usedList },
|
|
572
|
+
};
|
|
573
|
+
}
|