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 scopeHow it works (and why it scales)
- Real AST:
.jsx/.tsxare parsed by a real JS/TS/JSX parser (@babel/parser, embedded in the bundle — still "no install"), not regex normalization.PascalCasecomponents 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/.jsmodules are graphed, never audited. The graph's own discovery widens to.ts/.js/.mjs/.cjson top of the markup allowlist: a barrel (components/index.tsre-exportingButton.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/.astroSFCs are first-class graph nodes. Parsed with component case preserved (same as the audit); the file's own<script>block — or an.astrofile's---…---frontmatter — is parsed separately as a script AST for its imports; and each SFC synthesizes a self component definition (PascalCase basename), so a.tsximporting./Widget.vueresolves 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 nearesttsconfig.json'sbaseUrl/paths), and namespace members (<UI.Button/>fromimport * as UI) all resolve to a discovered file. Bounds (documented, never a silent false "conforming"): barenode_modulesspecifiers (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 dynamicimport()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 inrelated.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 inrelated.cross-aria-ref-cross-file(WCAG 4.1.2, suppression): anaria-labelledby/aria-describedby/aria-controlswhose target id lives in another file → suppresses the falsearia-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 falsebutton-empty-name/icon-only-control-unnamedon the definition.cross-skip-link-target(WCAG 2.4.1, suppression): anhref="#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>withoutlangwhose 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-labelledbywhose target id is defined in another file → suppresses the falselabel-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,checkandverifyconsume them unchanged.