Setting the file. One moment. Scan A11Y Code · Wix App · wix/skills · Skills Docsfunction findExportedComponent
— line 343
This file
- Number
- 1.78
- Position
- 78 of 81
- Type
- JavaScript
- Size
- 31 KB
- Lines
- 1,047
scripts/scan-a11y-code.cjs
JavaScript·1,047 lines·31 KB
ROOT
=
process.
cwd
();
11const LOCAL_REQUIRE = createRequire(__filename);
12const ROOT_REQUIRE = createRequire(path.join(ROOT, 'package.json'));
13const parser = loadModule('@babel/parser');
14const traverse = loadModule('@babel/traverse').default;
15const t = loadModule('@babel/types');
16
17const MAX_RESOLUTION_DEPTH = 4;
18const VALID_ARIA_PROPS = new Set([
19 'aria-activedescendant',
20 'aria-atomic',
21 'aria-autocomplete',
22 'aria-braillelabel',
23 'aria-brailleroledescription',
24 'aria-busy',
25 'aria-checked',
26 'aria-colcount',
27 'aria-colindex',
28 'aria-colindextext',
29 'aria-colspan',
30 'aria-controls',
31 'aria-current',
32 'aria-describedby',
33 'aria-description',
34 'aria-details',
35 'aria-disabled',
36 'aria-dropeffect',
37 'aria-errormessage',
38 'aria-expanded',
39 'aria-flowto',
40 'aria-grabbed',
41 'aria-haspopup',
42 'aria-hidden',
43 'aria-invalid',
44 'aria-keyshortcuts',
45 'aria-label',
46 'aria-labelledby',
47 'aria-level',
48 'aria-live',
49 'aria-modal',
50 'aria-multiline',
51 'aria-multiselectable',
52 'aria-orientation',
53 'aria-owns',
54 'aria-placeholder',
55 'aria-posinset',
56 'aria-pressed',
57 'aria-readonly',
58 'aria-relevant',
59 'aria-required',
60 'aria-roledescription',
61 'aria-rowcount',
62 'aria-rowindex',
63 'aria-rowindextext',
64 'aria-rowspan',
65 'aria-selected',
66 'aria-setsize',
67 'aria-sort',
68 'aria-valuemax',
69 'aria-valuemin',
70 'aria-valuenow',
71 'aria-valuetext',
72]);
73const VALID_ROLES = new Set([
74 'alert',
75 'alertdialog',
76 'application',
77 'article',
78 'banner',
79 'button',
80 'cell',
81 'checkbox',
82 'columnheader',
83 'combobox',
84 'complementary',
85 'contentinfo',
86 'definition',
87 'dialog',
88 'directory',
89 'document',
90 'feed',
91 'figure',
92 'form',
93 'grid',
94 'gridcell',
95 'group',
96 'heading',
97 'img',
98 'link',
99 'list',
100 'listbox',
101 'listitem',
102 'log',
103 'main',
104 'marquee',
105 'math',
106 'menu',
107 'menubar',
108 'menuitem',
109 'menuitemcheckbox',
110 'menuitemradio',
111 'navigation',
112 'none',
113 'note',
114 'option',
115 'presentation',
116 'progressbar',
117 'radio',
118 'radiogroup',
119 'region',
120 'row',
121 'rowgroup',
122 'rowheader',
123 'scrollbar',
124 'search',
125 'searchbox',
126 'separator',
127 'slider',
128 'spinbutton',
129 'status',
130 'switch',
131 'tab',
132 'table',
133 'tablist',
134 'tabpanel',
135 'term',
136 'textbox',
137 'timer',
138 'toolbar',
139 'tooltip',
140 'tree',
141 'treegrid',
142 'treeitem',
143]);
144const UNSUPPORTED_ARIA_ELEMENTS = new Set([
145 'meta',
146 'script',
147 'style',
148 'head',
149 'html',
150 'base',
151 'link',
152 'param',
153 'source',
154 'track',
155 'col',
156 'colgroup',
157]);
158
159const FILE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
160/** `A11y` fields a component may read. ZeroConfig turns each read into an editor control. */
161const A11Y_FIELDS = new Set(['ariaLabel']);
162const A11Y_FIELDS_LABEL = [...A11Y_FIELDS].join(', ');
163const A11Y_CONVERTER = 'convertA11yKeysToHtmlFormat';
164const HARDCODED_LABEL_ATTRIBUTES = new Set(['aria-label', 'aria-description']);
165const SUPPORTED_RULES = [
166 'alt-text',
167 'anchor-is-valid',
168 'aria-props',
169 'aria-role',
170 'aria-unsupported-elements',
171 'a11y-whole-object',
172 'a11y-disallowed-field',
173 'hardcoded-aria-label',
174];
175const parseCache = new Map();
176const resolutionWarnings = [];
177
178function loadModule(name) {
179 try {
180 return LOCAL_REQUIRE(name);
181 } catch (localError) {
182 try {
183 return ROOT_REQUIRE(name);
184 } catch (rootError) {
185 throw new Error(
186 `Missing dependency "${name}". Local resolution failed: ${localError.message}. Root resolution failed: ${rootError.message}`,
187 );
188 }
189 }
190}
191
192function parseFile(filePath) {
193 if (parseCache.has(filePath)) return parseCache.get(filePath);
194
195 try {
196 const code = fs.readFileSync(filePath, 'utf8');
197 const ast = parser.parse(code, {
198 sourceType: 'unambiguous',
199 plugins: [
200 'jsx',
201 'typescript',
202 'classProperties',
203 'objectRestSpread',
204 'optionalChaining',
205 'nullishCoalescingOperator',
206 ],
207 });
208 const parsed = { ok: true, ast, code };
209 parseCache.set(filePath, parsed);
210 return parsed;
211 } catch (error) {
212 const parsed = { ok: false, error };
213 parseCache.set(filePath, parsed);
214 return parsed;
215 }
216}
217
218function getJsxName(node) {
219 if (t.isJSXIdentifier(node)) return node.name;
220 if (t.isJSXMemberExpression(node))
221 return `${getJsxName(node.object)}.${getJsxName(node.property)}`;
222 if (t.isJSXNamespacedName(node)) return `${node.namespace.name}:${node.name.name}`;
223 return null;
224}
225
226function getAttribute(node, name) {
227 return (
228 node.attributes.find((attr) => t.isJSXAttribute(attr) && getJsxName(attr.name) === name) || null
229 );
230}
231
232function getLiteralAttributeValue(attr) {
233 if (!attr) return undefined;
234 if (!attr.value) return true;
235 if (t.isStringLiteral(attr.value)) return attr.value.value;
236 if (t.isJSXExpressionContainer(attr.value)) {
237 const expr = attr.value.expression;
238 if (t.isStringLiteral(expr)) return expr.value;
239 if (t.isBooleanLiteral(expr)) return expr.value;
240 if (t.isNumericLiteral(expr)) return expr.value;
241 if (t.isTemplateLiteral(expr) && expr.expressions.length === 0) {
242 return expr.quasis.map((q) => q.value.cooked || '').join('');
243 }
244 }
245 return undefined;
246}
247
248function hasTruthyAttribute(node, name) {
249 const attr = getAttribute(node, name);
250 return Boolean(attr);
251}
252
253function isNativeTag(name) {
254 return Boolean(name && /^[a-z]/.test(name));
255}
256
257function getImportMap(ast) {
258 const imports = new Map();
259
260 traverse(ast, {
261 ImportDeclaration(path) {
262 const source = path.node.source.value;
263 for (const specifier of path.node.specifiers) {
264 if (t.isImportDefaultSpecifier(specifier)) {
265 imports.set(specifier.local.name, { source, imported: 'default' });
266 } else if (t.isImportSpecifier(specifier)) {
267 imports.set(specifier.local.name, { source, imported: specifier.imported.name });
268 } else if (t.isImportNamespaceSpecifier(specifier)) {
269 imports.set(specifier.local.name, { source, imported: '*' });
270 }
271 }
272 },
273 });
274
275 return imports;
276}
277
278function findRootJsx(pathLike) {
279 if (!pathLike) return null;
280 if (
281 pathLike.isFunctionDeclaration() ||
282 pathLike.isFunctionExpression() ||
283 pathLike.isArrowFunctionExpression()
284 ) {
285 if (t.isJSXElement(pathLike.node.body) || t.isJSXFragment(pathLike.node.body))
286 return pathLike.node.body;
287 if (!t.isBlockStatement(pathLike.node.body)) return null;
288
289 for (const statement of pathLike.node.body.body) {
290 if (!t.isReturnStatement(statement)) continue;
291 const arg = statement.argument;
292 if (t.isJSXElement(arg) || t.isJSXFragment(arg)) return arg;
293 }
294 }
295 return null;
296}
297
298function resolveSourceFile(fromFile, source) {
299 const basedir = path.dirname(fromFile);
300 const tryFile = (candidate) => {
301 if (candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
302 return null;
303 };
304
305 if (source.startsWith('.')) {
306 const base = path.resolve(basedir, source);
307 for (const ext of FILE_EXTENSIONS) {
308 const direct = tryFile(base + ext);
309 if (direct) return direct;
310 }
311 for (const ext of FILE_EXTENSIONS) {
312 const indexFile = tryFile(path.join(base, `index${ext}`));
313 if (indexFile) return indexFile;
314 }
315 return tryFile(base);
316 }
317
318 try {
319 return require.resolve(source, { paths: [basedir, ROOT] });
320 } catch (error) {
321 resolutionWarnings.push({
322 source,
323 fromFile,
324 method: 'require.resolve',
325 message: error.message,
326 });
327 }
328
329 try {
330 return ROOT_REQUIRE.resolve(source);
331 } catch (error) {
332 resolutionWarnings.push({
333 source,
334 fromFile,
335 method: 'rootRequire.resolve',
336 message: error.message,
337 });
338 }
339
340 return null;
341}
342
343function findExportedComponent(ast, exportName) {
344 let found = null;
345
346 traverse(ast, {
347 FunctionDeclaration(path) {
348 if (found) return;
349 if (path.node.id && path.node.id.name === exportName) found = findRootJsx(path);
350 },
351 VariableDeclarator(path) {
352 if (found) return;
353 if (!t.isIdentifier(path.node.id, { name: exportName })) return;
354 const initPath = path.get('init');
355 found = findRootJsx(initPath);
356 },
357 ExportDefaultDeclaration(path) {
358 if (found || exportName !== 'default') return;
359 const declPath = path.get('declaration');
360 if (declPath.isIdentifier()) {
361 found = findNamedBindingJsx(ast, declPath.node.name);
362 } else {
363 found = findRootJsx(declPath);
364 }
365 },
366 });
367
368 return found;
369}
370
371function findNamedBindingJsx(ast, name) {
372 let found = null;
373 traverse(ast, {
374 FunctionDeclaration(path) {
375 if (found) return;
376 if (path.node.id && path.node.id.name === name) found = findRootJsx(path);
377 },
378 VariableDeclarator(path) {
379 if (found) return;
380 if (!t.isIdentifier(path.node.id, { name })) return;
381 found = findRootJsx(path.get('init'));
382 },
383 });
384 return found;
385}
386
387function inferByName(name) {
388 const lower = String(name || '').toLowerCase();
389 if (!lower) return null;
390 if (
391 /(^|\.)(img|image|avatar|thumbnail|photo|picture)$/.test(lower) ||
392 /(image|avatar|thumbnail|photo|picture)/.test(lower)
393 ) {
394 return {
395 semanticType: 'img',
396 confidence: 'low',
397 sourceKind: 'heuristic',
398 evidence: `Component name "${name}" looks image-like.`,
399 };
400 }
401 if (/(^|\.)(link|anchor|navlink)$/.test(lower) || /(link|anchor)/.test(lower)) {
402 return {
403 semanticType: 'a',
404 confidence: 'low',
405 sourceKind: 'heuristic',
406 evidence: `Component name "${name}" looks link-like.`,
407 };
408 }
409 if (/(button|btn|iconbutton|textbutton|closebutton)/.test(lower)) {
410 return {
411 semanticType: 'button',
412 confidence: 'low',
413 sourceKind: 'heuristic',
414 evidence: `Component name "${name}" looks button-like.`,
415 };
416 }
417 if (/(textarea|editor)/.test(lower)) {
418 return {
419 semanticType: 'textarea',
420 confidence: 'low',
421 sourceKind: 'heuristic',
422 evidence: `Component name "${name}" looks textarea-like.`,
423 };
424 }
425 if (/(input|textfield|search|select|checkbox|radio|switch|toggle)/.test(lower)) {
426 return {
427 semanticType: 'input',
428 confidence: 'low',
429 sourceKind: 'heuristic',
430 evidence: `Component name "${name}" looks input-like.`,
431 };
432 }
433 return null;
434}
435
436function inferFromProps(openingElement) {
437 const asValue = getLiteralAttributeValue(getAttribute(openingElement, 'as'));
438 if (typeof asValue === 'string' && isNativeTag(asValue)) {
439 return {
440 semanticType: asValue,
441 confidence: 'high',
442 sourceKind: 'polymorphic-prop',
443 evidence: `The component explicitly sets as="${asValue}".`,
444 };
445 }
446
447 const componentValue = getLiteralAttributeValue(getAttribute(openingElement, 'component'));
448 if (typeof componentValue === 'string' && isNativeTag(componentValue)) {
449 return {
450 semanticType: componentValue,
451 confidence: 'high',
452 sourceKind: 'polymorphic-prop',
453 evidence: `The component explicitly sets component="${componentValue}".`,
454 };
455 }
456
457 if (hasTruthyAttribute(openingElement, 'src')) {
458 return {
459 semanticType: 'img',
460 confidence: hasTruthyAttribute(openingElement, 'alt') ? 'low' : 'medium',
461 sourceKind: 'prop-evidence',
462 evidence: 'The component receives src-related props that suggest image semantics.',
463 };
464 }
465
466 if (hasTruthyAttribute(openingElement, 'href') || hasTruthyAttribute(openingElement, 'to')) {
467 return {
468 semanticType: 'a',
469 confidence: 'medium',
470 sourceKind: 'prop-evidence',
471 evidence: 'The component receives href/to props that suggest link semantics.',
472 };
473 }
474
475 if (hasTruthyAttribute(openingElement, 'onClick')) {
476 return {
477 semanticType: 'button',
478 confidence: 'low',
479 sourceKind: 'prop-evidence',
480 evidence: 'The component receives onClick, which may indicate button-like behavior.',
481 };
482 }
483
484 return null;
485}
486
487function chooseResolution(current, next) {
488 if (!next) return current;
489 if (!current) return next;
490 const rank = { high: 3, medium: 2, low: 1, unknown: 0 };
491 return rank[next.confidence] > rank[current.confidence] ? next : current;
492}
493
494function resolveComponentSemantic(filePath, openingElement, context, depth = 0) {
495 const name = getJsxName(openingElement.name);
496 if (!name)
497 return {
498 semanticType: 'unknown',
499 confidence: 'unknown',
500 sourceKind: 'unknown',
501 evidence: 'Unable to resolve JSX element name.',
502 };
503
504 if (isNativeTag(name)) {
505 return {
506 semanticType: name,
507 confidence: 'high',
508 sourceKind: 'native',
509 evidence: `The JSX element is the native tag <${name}>.`,
510 };
511 }
512
513 let resolved = chooseResolution(null, inferFromProps(openingElement));
514 resolved = chooseResolution(resolved, inferByName(name));
515
516 if (depth >= MAX_RESOLUTION_DEPTH) {
517 return (
518 resolved || {
519 semanticType: 'unknown',
520 confidence: 'unknown',
521 sourceKind: 'unknown',
522 evidence: `Resolution depth exceeded for ${name}.`,
523 }
524 );
525 }
526
527 const importInfo = context.imports.get(name);
528 if (importInfo) {
529 const resolvedSource = resolveSourceFile(filePath, importInfo.source);
530 if (resolvedSource) {
531 const parsed = parseFile(resolvedSource);
532 if (parsed.ok) {
533 const rootJsx = findExportedComponent(parsed.ast, importInfo.imported);
534 if (rootJsx && t.isJSXElement(rootJsx)) {
535 const innerImports = getImportMap(parsed.ast);
536 const nested = resolveComponentSemantic(
537 resolvedSource,
538 rootJsx.openingElement,
539 { imports: innerImports },
540 depth + 1,
541 );
542 if (nested.semanticType !== 'unknown') {
543 const sourceKind = importInfo.source.startsWith('.')
544 ? 'local-wrapper'
545 : 'package-component';
546 resolved = chooseResolution(resolved, {
547 semanticType: nested.semanticType,
548 confidence: nested.confidence === 'low' ? 'medium' : nested.confidence,
549 sourceKind,
550 evidence: `${name} resolves through ${path.relative(ROOT, resolvedSource)} to ${nested.semanticType} semantics.`,
551 });
552 }
553 }
554 }
555 }
556 }
557
558 const finalResolution = resolved || {
559 semanticType: 'unknown',
560 confidence: 'unknown',
561 sourceKind: 'unknown',
562 evidence: `Could not infer reliable semantics for ${name}.`,
563 };
564 return finalResolution;
565}
566
567function toRelative(filePath) {
568 return path.relative(ROOT, filePath) || filePath;
569}
570
571// ─────────────────────────────────────────────────────────────────────────────
572// Per-part a11y contract helpers
573// ─────────────────────────────────────────────────────────────────────────────
574
575/** Peel type assertions, parentheses, and guards such as `a11y && x` or `x ?? {}`. */
576function unwrapExpression(node) {
577 let current = node;
578 for (let guard = 0; current && guard < 20; guard++) {
579 if (
580 t.isTSAsExpression(current) ||
581 t.isTSNonNullExpression(current) ||
582 t.isTSTypeAssertion(current) ||
583 t.isParenthesizedExpression(current) ||
584 (t.isTSSatisfiesExpression && t.isTSSatisfiesExpression(current))
585 ) {
586 current = current.expression;
587 } else if (t.isLogicalExpression(current)) {
588 current = current.operator === '&&' ? current.right : current.left;
589 } else {
590 break;
591 }
592 }
593 return current;
594}
595
596function isA11yIdentifierName(name) {
597 return name === 'a11y' || name.endsWith('A11y');
598}
599
600/** `a11y`, `toggleA11y`, `props.a11y`, `elementProps?.toggle?.a11y`. */
601function isA11yLike(node) {
602 const expr = unwrapExpression(node);
603 if (t.isIdentifier(expr)) return isA11yIdentifierName(expr.name);
604 if ((t.isMemberExpression(expr) || t.isOptionalMemberExpression(expr)) && !expr.computed) {
605 return t.isIdentifier(expr.property, { name: 'a11y' });
606 }
607 return false;
608}
609
610/** `props.elementProps?.toggle` → ['props', 'elementProps', 'toggle']; null for other shapes. */
611function memberChain(node) {
612 const expr = unwrapExpression(node);
613 if (t.isIdentifier(expr)) return [expr.name];
614 if ((t.isMemberExpression(expr) || t.isOptionalMemberExpression(expr)) && !expr.computed) {
615 const parent = memberChain(expr.object);
616 if (!parent || !t.isIdentifier(expr.property)) return null;
617 return [...parent, expr.property.name];
618 }
619 return null;
620}
621
622function isElementPropsPartChain(chain, partsWithA11y) {
623 if (!chain || chain.length < 2) return false;
624 const index = chain.indexOf('elementProps');
625 return index !== -1 && index === chain.length - 2 && partsWithA11y.has(chain[chain.length - 1]);
626}
627
628function typeLiteralHasMember(typeNode, memberName) {
629 return (
630 t.isTSTypeLiteral(typeNode) &&
631 typeNode.members.some(
632 (member) =>
633 t.isTSPropertySignature(member) &&
634 ((t.isIdentifier(member.key) && member.key.name === memberName) ||
635 (t.isStringLiteral(member.key) && member.key.value === memberName)),
636 )
637 );
638}
639
640/**
641 * Names of `elementProps` parts whose declared type carries an `a11y` field,
642 * keyed by folder so one component's contract never applies to another.
643 * Reads every `*.props.ts` next to the scanned files plus the files themselves.
644 */
645function collectPropsTypes(files) {
646 const partsByDir = new Map();
647 const candidates = new Set(files);
648 for (const file of files) {
649 const dir = path.dirname(file);
650 if (!partsByDir.has(dir)) partsByDir.set(dir, new Set());
651 if (!fs.existsSync(dir)) continue;
652 for (const name of fs.readdirSync(dir)) {
653 if (name.endsWith('.props.ts')) candidates.add(path.join(dir, name));
654 }
655 }
656
657 for (const file of candidates) {
658 const parsed = parseFile(file);
659 if (!parsed.ok) continue;
660 const partsWithA11y = partsByDir.get(path.dirname(file));
661 traverse(parsed.ast, {
662 TSPropertySignature(path) {
663 const { key, typeAnnotation } = path.node;
664 if (!t.isIdentifier(key, { name: 'elementProps' }) || !typeAnnotation) return;
665 const literal = typeAnnotation.typeAnnotation;
666 if (!t.isTSTypeLiteral(literal)) return;
667 for (const member of literal.members) {
668 if (!t.isTSPropertySignature(member) || !member.typeAnnotation) continue;
669 const partName = t.isIdentifier(member.key)
670 ? member.key.name
671 : t.isStringLiteral(member.key)
672 ? member.key.value
673 : null;
674 if (partName && typeLiteralHasMember(member.typeAnnotation.typeAnnotation, 'a11y')) {
675 partsWithA11y.add(partName);
676 }
677 }
678 },
679 });
680 }
681
682 return partsByDir;
683}
684
685function contractFinding(filePath, node, rule, confidence, message) {
686 return findingFromNode(filePath, node, {
687 rule,
688 confidence,
689 message,
690 componentName: null,
691 semanticType: 'a11y-contract',
692 evidence: 'Per-part accessibility contract (ACCESSIBILITY.md).',
693 sourceKind: 'contract',
694 });
695}
696
697function findingFromNode(filePath, node, data) {
698 return {
699 file: toRelative(filePath),
700 line: node.loc ? node.loc.start.line : null,
701 column: node.loc ? node.loc.start.column + 1 : null,
702 rule: data.rule,
703 confidence: data.confidence,
704 message: data.message,
705 componentName: data.componentName,
706 semanticType: data.semanticType,
707 evidence: data.evidence,
708 sourceKind: data.sourceKind,
709 };
710}
711
712function scanFile(filePath, options = {}) {
713 const parsed = parseFile(filePath);
714 if (!parsed.ok) {
715 return {
716 parseError: {
717 file: toRelative(filePath),
718 message: parsed.error.message,
719 },
720 findings: [],
721 };
722 }
723
724 const imports = getImportMap(parsed.ast);
725 const findings = [];
726 const partsWithA11y = options.partsWithA11y || new Set();
727
728 const reportDisallowedField = (node, fieldName) => {
729 findings.push(
730 contractFinding(
731 filePath,
732 node,
733 'a11y-disallowed-field',
734 'high',
735 `a11y.${fieldName} is read; only ${A11Y_FIELDS_LABEL} may be read. Keep roles, state, and structure in component code.`,
736 ),
737 );
738 };
739
740 traverse(parsed.ast, {
741 JSXSpreadAttribute(path) {
742 const argument = unwrapExpression(path.node.argument);
743
744 if (isA11yLike(argument)) {
745 findings.push(
746 contractFinding(
747 filePath,
748 path.node,
749 'a11y-whole-object',
750 'high',
751 'The whole a11y object is spread onto an element; every field becomes an editor control.',
752 ),
753 );
754 return;
755 }
756
757 // A spread identifier may alias `a11y` or an `elementProps` part; follow its declaration.
758 let chain = memberChain(argument);
759 if (t.isIdentifier(argument)) {
760 const binding = path.scope.getBinding(argument.name);
761 const declarator = binding && binding.path && binding.path.node;
762 if (declarator && t.isVariableDeclarator(declarator) && t.isIdentifier(declarator.id)) {
763 if (declarator.init && isA11yLike(declarator.init)) {
764 findings.push(
765 contractFinding(
766 filePath,
767 path.node,
768 'a11y-whole-object',
769 'high',
770 `${argument.name} aliases the a11y object and is spread onto an element; read a11y.ariaLabel instead.`,
771 ),
772 );
773 return;
774 }
775 chain = memberChain(declarator.init);
776 }
777 }
778 if (isElementPropsPartChain(chain, partsWithA11y)) {
779 findings.push(
780 contractFinding(
781 filePath,
782 path.node,
783 'a11y-whole-object',
784 'medium',
785 `elementProps.${chain[chain.length - 1]} is spread although its type declares a11y; destructure a11y out first.`,
786 ),
787 );
788 }
789 },
790
791 CallExpression(path) {
792 const callee = path.node.callee;
793 const isConverter =
794 t.isIdentifier(callee, { name: A11Y_CONVERTER }) ||
795 ((t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
796 t.isIdentifier(callee.property, { name: A11Y_CONVERTER }));
797 if (isConverter) {
798 findings.push(
799 contractFinding(
800 filePath,
801 path.node,
802 'a11y-whole-object',
803 'high',
804 `${A11Y_CONVERTER} writes every a11y field to the DOM; read only the field the part needs.`,
805 ),
806 );
807 }
808 },
809
810 'MemberExpression|OptionalMemberExpression'(path) {
811 const { node } = path;
812 if (node.computed || !t.isIdentifier(node.property)) return;
813 if (!isA11yLike(node.object)) return;
814 if (A11Y_FIELDS.has(node.property.name)) return;
815 reportDisallowedField(node.property, node.property.name);
816 },
817
818 VariableDeclarator(path) {
819 const { id, init } = path.node;
820 if (!t.isObjectPattern(id) || !init || !isA11yLike(init)) return;
821 for (const property of id.properties) {
822 if (t.isRestElement(property)) {
823 findings.push(
824 contractFinding(
825 filePath,
826 property,
827 'a11y-whole-object',
828 'high',
829 'Rest-destructuring a11y keeps every field; pick the single field the part needs.',
830 ),
831 );
832 continue;
833 }
834 if (!t.isObjectProperty(property) || property.computed) continue;
835 const fieldName = t.isIdentifier(property.key)
836 ? property.key.name
837 : t.isStringLiteral(property.key)
838 ? property.key.value
839 : null;
840 if (fieldName && !A11Y_FIELDS.has(fieldName)) reportDisallowedField(property, fieldName);
841 }
842 },
843
844 JSXOpeningElement(path) {
845 const node = path.node;
846 const name = getJsxName(node.name);
847 if (!name) return;
848
849 const semantic = resolveComponentSemantic(filePath, node, { imports });
850 const ariaAttrs = node.attributes.filter(
851 (attr) => t.isJSXAttribute(attr) && getJsxName(attr.name)?.startsWith('aria-'),
852 );
853 const roleAttr = getAttribute(node, 'role');
854
855 for (const attr of ariaAttrs) {
856 const attrName = getJsxName(attr.name);
857 if (!HARDCODED_LABEL_ATTRIBUTES.has(attrName)) continue;
858 const literal = getLiteralAttributeValue(attr);
859 if (typeof literal === 'string' && literal.trim() !== '') {
860 findings.push(
861 contractFinding(
862 filePath,
863 attr,
864 'hardcoded-aria-label',
865 'high',
866 `${attrName}="${literal}" is a hardcoded string; use visible text, a11y.ariaLabel, or a constants.ts label.`,
867 ),
868 );
869 }
870 }
871
872 if (semantic.semanticType === 'img' && semantic.confidence !== 'low') {
873 const altAttr = getAttribute(node, 'alt');
874 if (!altAttr) {
875 findings.push(
876 findingFromNode(filePath, node, {
877 rule: 'alt-text',
878 confidence: semantic.confidence,
879 message: `${name} is treated as image-like but is missing an alt prop.`,
880 componentName: name,
881 semanticType: semantic.semanticType,
882 evidence: semantic.evidence,
883 sourceKind: semantic.sourceKind,
884 }),
885 );
886 }
887 } else if (semantic.semanticType === 'img' && semantic.confidence === 'low') {
888 const altAttr = getAttribute(node, 'alt');
889 if (!altAttr) {
890 findings.push(
891 findingFromNode(filePath, node, {
892 rule: 'alt-text',
893 confidence: semantic.confidence,
894 message: `${name} may be image-like and appears to be missing an alt prop.`,
895 componentName: name,
896 semanticType: semantic.semanticType,
897 evidence: semantic.evidence,
898 sourceKind: semantic.sourceKind,
899 }),
900 );
901 }
902 }
903
904 if (semantic.semanticType === 'a') {
905 const hrefAttr = getAttribute(node, 'href');
906 const toAttr = getAttribute(node, 'to');
907 const hrefValue = getLiteralAttributeValue(hrefAttr);
908 const toValue = getLiteralAttributeValue(toAttr);
909 const invalidLinkTarget =
910 (!hrefAttr && !toAttr) ||
911 hrefValue === '' ||
912 hrefValue === '#' ||
913 hrefValue === 'javascript:void(0)' ||
914 toValue === '' ||
915 toValue === '#' ||
916 toValue === 'javascript:void(0)';
917 if (invalidLinkTarget) {
918 findings.push(
919 findingFromNode(filePath, node, {
920 rule: 'anchor-is-valid',
921 confidence: semantic.confidence,
922 message:
923 semantic.confidence === 'low'
924 ? `${name} may be link-like but does not appear to provide a valid navigation target.`
925 : `${name} is treated as link-like but does not provide a valid navigation target.`,
926 componentName: name,
927 semanticType: semantic.semanticType,
928 evidence: semantic.evidence,
929 sourceKind: semantic.sourceKind,
930 }),
931 );
932 }
933 }
934
935 for (const attr of ariaAttrs) {
936 const attrName = getJsxName(attr.name);
937 if (!VALID_ARIA_PROPS.has(attrName)) {
938 findings.push(
939 findingFromNode(filePath, attr, {
940 rule: 'aria-props',
941 confidence: 'high',
942 message: `${attrName} is not a valid ARIA attribute name.`,
943 componentName: name,
944 semanticType: semantic.semanticType,
945 evidence: `The attribute name "${attrName}" is not in the allowed ARIA prop set.`,
946 sourceKind: semantic.sourceKind,
947 }),
948 );
949 }
950 }
951
952 const roleValue = getLiteralAttributeValue(roleAttr);
953 if (typeof roleValue === 'string' && !VALID_ROLES.has(roleValue)) {
954 findings.push(
955 findingFromNode(filePath, roleAttr, {
956 rule: 'aria-role',
957 confidence: 'high',
958 message: `"${roleValue}" is not a valid ARIA role value.`,
959 componentName: name,
960 semanticType: semantic.semanticType,
961 evidence: `The role value "${roleValue}" is not in the supported ARIA roles set.`,
962 sourceKind: semantic.sourceKind,
963 }),
964 );
965 }
966
967 if ((ariaAttrs.length > 0 || roleAttr) && semantic.confidence !== 'low') {
968 const semanticTag = semantic.semanticType;
969 if (UNSUPPORTED_ARIA_ELEMENTS.has(semanticTag)) {
970 findings.push(
971 findingFromNode(filePath, node, {
972 rule: 'aria-unsupported-elements',
973 confidence: semantic.confidence,
974 message: `${name} resolves to unsupported element <${semanticTag}> but carries ARIA attributes or role.`,
975 componentName: name,
976 semanticType: semantic.semanticType,
977 evidence: semantic.evidence,
978 sourceKind: semantic.sourceKind,
979 }),
980 );
981 }
982 }
983 },
984 });
985
986 return { parseError: null, findings };
987}
988
989/** Scan the given files. Any syntax error lands in `meta.parseErrors`; the review treats it as fatal. */
990function scan(files) {
991 const absoluteFiles = files.map((file) => path.resolve(file));
992 const partsByDir = collectPropsTypes(absoluteFiles);
993
994 const meta = {
995 filesScanned: files.length,
996 parser: '@babel/parser',
997 supportedRules: SUPPORTED_RULES,
998 a11yFields: [...A11Y_FIELDS],
999 confidenceModel: ['high', 'medium', 'low', 'unknown'],
1000 parseErrors: [],
1001 resolutionWarnings,
1002 };
1003
1004 const findings = [];
1005
1006 for (const file of absoluteFiles) {
1007 const result = scanFile(file, { partsWithA11y: partsByDir.get(path.dirname(file)) });
1008 if (result.parseError) meta.parseErrors.push(result.parseError);
1009 findings.push(...result.findings);
1010 }
1011
1012 const summary = {
1013 findings: findings.length,
1014 highConfidence: findings.filter((item) => item.confidence === 'high').length,
1015 mediumConfidence: findings.filter((item) => item.confidence === 'medium').length,
1016 lowConfidence: findings.filter((item) => item.confidence === 'low').length,
1017 filesWithFindings: new Set(findings.map((item) => item.file)).size,
1018 cleanFiles: files.length - new Set(findings.map((item) => item.file)).size,
1019 };
1020
1021 return { meta, findings, summary };
1022}
1023
1024function main() {
1025 const files = process.argv.slice(2);
1026 if (files.length === 0) {
1027 console.log(
1028 JSON.stringify(
1029 {
1030 error: 'No files specified.',
1031 usage: 'node <SKILL_ROOT>/scripts/scan-a11y-code.cjs <file1> [file2] ...',
1032 },
1033 null,
1034 2,
1035 ),
1036 );
1037 process.exit(1);
1038 }
1039
1040 console.log(JSON.stringify(scan(files), null, 2));
1041}
1042
1043module.exports = { scan };
1044
1045if (require.main === module) {
1046 main();
1047}