@assure-one/design-system 1.30.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.
@@ -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
+ }