Product design skills

references/cross-file.md

A supporting file of the ultra11y skill.

Cross-file analysis (audit --graph)

By default the engine audits each file in isolation. --graph (alias --cross-file) adds a pass that resolves imports between files, builds a dependency + component graph, and applies rules only the cross-file context makes visible — without a browser (no Playwright). It complements the per-file static engine and the optional dynamic tier (scan): all three feed the same AuditResult.

node scripts/ultra11y.mjs audit "src/**/*.tsx" --graph --json > audit.json
node scripts/ultra11y.mjs audit --changed --graph     # git diff, graph over the whole scope

How it works (and why it scales)

  • Real AST: .jsx/.tsx are parsed by a real JS/TS/JSX parser (@babel/parser, embedded in the bundle — still "no install"), not regex normalization. PascalCase components keep their case (per-file rules ignore them; only cross-file rules resolve them); native elements stay lowercase.
  • Two passes, bounded memory: pass 1 — read each file, extract a small graph node (imports, components + render-control signals, ids, <html lang>), then drop the AST/Doc. Pass 2 — the usual audit loop, which also runs the cross-file rules with the graph in hand. The whole repo is never held in memory: O(number of files) small nodes.
  • --changed --graph: the graph indexes the whole scope (to resolve a reference into an unchanged definition), but only the diffed files are audited.
  • Plain .ts/.js modules are graphed, never audited. The graph's own discovery widens to .ts/.js/.mjs/.cjs on top of the markup allowlist: a barrel (components/index.ts re-exporting Button.tsx) or a plain-JS component definition is real cross-file structure that imports resolve through — chained re-exports (export { Button } from "./Button", barrels of barrels) are followed to the real definition. Those files feed imports/re-exports/definitions/ids into the graph but never enter the audit loop (no markup rule ever runs on them).
  • .vue/.svelte/.astro SFCs are first-class graph nodes. Parsed with component case preserved (same as the audit); the file's own <script> block — or an .astro file's ---…--- frontmatter — is parsed separately as a script AST for its imports; and each SFC synthesizes a self component definition (PascalCase basename), so a .tsx importing ./Widget.vue resolves cross-file and the SFC counts in capture coverage. Astro frontmatter is also stripped (offset-preserving) before the template parse, so frontmatter TypeScript (Array<string>) never produces phantom elements or findings.
  • Import resolution: relative specifiers (./IconButton), tsconfig-paths aliases (@/components/Icon, read from the nearest tsconfig.json's baseUrl/paths), and namespace members (<UI.Button/> from import * as UI) all resolve to a discovered file. Bounds (documented, never a silent false "conforming"): bare node_modules specifiers (react) are out of scope; alias bases are resolved relative to the working dir (run the audit from the project root); React context value flow and dynamic import() are not traced.

The cross-file rules

  • cross-icon-only-unnamed (WCAG 2.4.4/4.1.2, flag): a component that renders an icon-only control and can receive a name ({...props} / aria-label={…} / {children}) is used without a name. The flag is placed at the usage site, with the component definition in related.
  • cross-prop-drilled-name-lost (WCAG 4.1.2, flag): a usage passes an accessible-name prop (aria-label/label/…) to a component that renders a control but neither spreads {...props} nor forwards a name to it → the name is silently dropped. Flagged at the usage, with the control definition in related.
  • cross-aria-ref-cross-file (WCAG 4.1.2, suppression): an aria-labelledby/ aria-describedby/aria-controls whose target id lives in another file → suppresses the false aria-ref-missing-id. Suppressed only when every in-page-missing id resolves elsewhere, so a genuinely-dangling reference is never hidden.
  • cross-aria-forwarding (WCAG 4.1.2, suppression): a native control that forwards {...props} is nameable by its parent → it suppresses the false button-empty-name/icon-only-control-unnamed on the definition.
  • cross-skip-link-target (WCAG 2.4.1, suppression): an href="#id" anchor whose target lives in another file (imported layout/component) → suppresses the false "broken anchor". A target missing everywhere stays a true positive.
  • cross-page-lang (WCAG 3.1.1, suppression): an <html> without lang whose imported layout/wrapper declares the language → suppresses the false positive.
  • cross-name-ref-cross-file (WCAG 1.3.1, suppression): a <label for="id"> or a heading/aria-labelledby whose target id is defined in another file → suppresses the false label-for-dangling / empty-heading. Suppressed only when the id resolves elsewhere.

Benefit for fix

Because the AST indexes the real file (exact offsets, non-lossy Doc), fix can apply its safe codemods on JSX/TSX (remove redundant ARIA, insert alt/lang/title/ aria-label), always behind the anti-regression gate. Codemods that rewrite an attribute name stay disabled on JSX (so tabIndex={5} is never turned into tabindex="0").

Cross-file flags merge into the same AuditResult: report, prd, check and verify consume them unchanged.

On this page