Output format
Canonical JSON contract returned by runScan() and emitted by equall scan --json. The shape CI integrations and SDK consumers should code against.
This page documents the ScanResult document — the same shape whether it's emitted by equall scan . --json (the CLI calls JSON.stringify(result, null, 2) on it) or returned by runScan(). CI integrations and SDK consumers should treat this as the canonical contract.
When --json is passed, the JSON document is written to stdout and a one-line confirmation is written to stderr (✓ JSON report written (N issues)), along with any diagnostics. Redirect the report cleanly with > report.json while still seeing progress in your terminal.
With --diff, --json prints a different document, the diff result (new_issues, legacy_issues, …). Its shape is on API.
ScanResult
| Field | Type | Description |
|---|---|---|
score | number | Final 0–100 score, to two decimal places. |
conformance_level | ConformanceLevel | WCAG conformance against the requested level. |
issues | EquallIssue[] | Every finding, in three groups: counted issues, then review-only issues (review_only: true), then ignored issues (ignored: true). Only counted issues affect the score, the counts and the verdicts. Each group is sorted by file, position and rule, so the same code always produces the same JSON. |
summary | ScanSummary | Counts over counted issues, criteria tested/failed, ignored count. |
scanners_used | ScannerInfo[] | Which scanners ran, with their version and issue count. |
criteria_covered | string[] | Union of WCAG criteria the active scanners cover (e.g. ['1.1.1', '4.1.2']). |
criteria_total | number | Total criteria for the selected standard + target level, derived from the catalog. WCAG 2.2 (default): A 31, AA 55, AAA 86 (4.1.1 Parsing excluded as obsolete). WCAG 2.1: A 30, AA 50, AAA 78. |
coverage | CoverageReport | undefined | Honest, exercised coverage — per-criterion status (auto / partial / manual) based on what actually ran this scan, not what a scanner is merely capable of, plus the page-level rules reclassified out of violations on fragment scans (reclassified). A scanner that failed outright is not credited. See CoverageReport. |
criterion_conformance | CriterionConformance[] | undefined | One honest, scan-scoped support verdict per WCAG criterion of the target level — the evidence layer behind an accessibility statement / VPAT. Additive, attached the same way as coverage (absent only on the early-return paths that also omit coverage). Never routed into the score. See CriterionConformance. |
standard | WcagStandard | undefined | WCAG version the conformance view (criterion_conformance, criteria_total) was rendered against — 'wcag22' (default) or 'wcag21' (the public-sector legal bar, WAD/EN 301 549). A view filter only — the score is identical across standards. Optional for older consumers; runScan always sets it. |
confidence_flags | ConfidenceFlag[] | undefined | Alt-quality advisories — present-but-suspect <img> alt text. Additive only: never routed into issues, verdicts, the score, or coverage. [] when none. See ConfidenceFlag. |
diagnostics | string[] | undefined | Non-fatal scan warnings in plain text: no scanners available, a scanner that failed, a file a scanner skipped or could not parse, route-detection notes. Attached by runScan ([] when none) instead of being written to stderr, so a library / MCP consumer can capture them and --json output carries them. The CLI still prints them to stderr. |
unchecked | UncheckedFile[] | undefined | The files a scanner could not check, structured (the matching diagnostics lines are the plain-text form). A finding missing from an unchecked file was not re-checked, not fixed. [] when none; sorted by file, then scanner. See UncheckedFile. |
routes | RouteInfo[] | undefined | The URL routes the project's file-based routing defines (Next.js App and Pages Router, Astro, plain .html). Inventory only: never routed into the score, verdicts or coverage. Absent when detection was not attempted (in-memory input), [] when the scanned tree has no supported routing; both cases carry a diagnostics note. See RouteInfo. |
scanned_at | string | ISO-8601 timestamp. |
duration_ms | number | Wall-clock time of the scan. |
engine_version | string | undefined | Engine (package) version, e.g. "0.3.3", so results from different releases are comparable. The ? is for older-consumer type compatibility — runScan always populates it. Per-scanner versions remain in scanners_used[].version. |
score_model | number | undefined | Version of the scoring formula that produced score, currently 2 (see Scoring). Bumped only when the scoring formula or its input semantics change — scores from different score_model values are not comparable. Same type-compatibility note as engine_version. |
EquallIssue
Every scanner normalizes its findings to this shape before they reach the report.
| Field | Type | Description |
|---|---|---|
scanner | string | Source engine, e.g. 'axe-core', 'eslint-jsx-a11y', 'readability'. |
scanner_rule_id | string | Original rule ID from the source engine. |
scanners | string[] | undefined | Present only when equivalent findings from different engines were merged into this issue (e.g. ['eslint-jsx-a11y', 'axe-core'] for a missing alt both engines flagged). scanner still names the surviving engine. |
merged_fingerprints | string[] | undefined | Present only on merged issues: the fingerprint of the finding the merge absorbed (the axe-core twin of an eslint-jsx-a11y finding), exactly as it reads when unmerged. A tool that tracks issues by fingerprint can tell that a twin which reappears, then disappears again, was merged back, not fixed. Never part of the issue's own fingerprint. |
fingerprint | string | undefined | Stable 16-char identity that survives reformatting (never uses line numbers), so the same issue can be matched across commits. Populated by runScan after dedup; absent on raw scanner output. |
wcag_criteria | string[] | WCAG 2.2 criteria this issue maps to. Empty array = best-practice (not a WCAG violation). |
wcag_level | WcagLevel | null | Strictest level among wcag_criteria. |
pour | PourPrinciple | null | POUR principle. |
file_path | string | Path relative to the scanned root. |
line | number | null | 1-indexed line when the scanner provides one. axe-core findings are always null here. |
column | number | null | 1-indexed column when available. |
html_snippet | string | null | The offending markup, truncated to 200 chars: the HTML element for axe-core, the source token for eslint-jsx-a11y. null for readability (a whole-file metric). |
severity | Severity | Used to weight the score. |
message | string | Human-readable description. |
help_url | string | null | Link to documentation on how to fix the issue. |
suggestion | string | null | Plain-language fix guidance. |
ignored | boolean | undefined | true when suppressed by an equall-ignore comment. Omitted otherwise. |
review_only | boolean | undefined | true when static analysis cannot confirm the finding. It happens on component source (JSX/TSX, Vue, Svelte, Astro), where axe-core sees markup reconstructed from the code, not a rendered page: the element's name or content depends on props, runtime expressions or a component (<Button>, {...props}), or the rule needs the rendered page. The issue keeps its fingerprint and stays in the output, but never counts in the score, the counts or the verdicts. Plain .html is never affected. Omitted otherwise. |
review_reason | string | undefined | Plain-language reason a review_only finding could not be confirmed. |
ScanSummary
interface ScanSummary {
files_scanned: number
total_issues: number // counted issues only: not ignored, not review-only
by_severity: Record<Severity, number> // over counted issues
by_scanner: Record<string, number> // keyed by scanner.name, over counted issues
criteria_tested: string[] // WCAG criteria genuinely EXERCISED this scan (coverage-derived —
// a scanner with eligible files actually ran the check, minus
// page-level rules reclassified on a fragment). Not the same set
// as criteria_failed — a criterion can be tested and pass.
criteria_failed: string[] // WCAG criteria with at least one violation
ignored_count: number // Issues suppressed via equall-ignore
}ScannerInfo
interface ScannerInfo {
name: string
version: string
rules_count: number
issues_found: number
}rules_count is currently always 0 — the field is reserved for a future enhancement and should be treated as informational only. See Known issues.
CoverageReport
Honest coverage reports what each WCAG criterion's status actually is on this scan — what was exercised, not what a scanner could theoretically check. A scanner only counts toward a criterion if the scan actually contained a file of a type it handles.
interface CoverageReport {
criteria: CriterionCoverage[]
counts: Record<CoverageStatus, number> // how many criteria fall in each status
auto_criteria: string[] // criteria with status 'auto' — the genuinely-checked set
reclassified?: ReclassifiedRule[] // page-level rules moved out of violations on fragment
// scans — optional in the type for older consumers,
// but runScan always attaches it ([] when none)
}
interface CriterionCoverage {
criterion: string // e.g. '1.4.3'
status: CoverageStatus
scanners: string[] // scanners that exercised it (empty for 'manual')
}
type CoverageStatus =
| 'auto' // genuinely tested — a scanner received eligible files and exercised it
| 'partial' // nominally covered but statically incomplete — needs the rendered check
| 'manual' // a scanner is capable of it but got no eligible files (or failed) — verify another way
interface ReclassifiedRule {
rule_id: string // e.g. 'region'
scanner: string // e.g. 'axe-core'
reason: string // why it is not statically verifiable here
count: number // occurrences reclassified this scan
files: string[] // unique affected files
wcag_criteria: string[] // [] for best-practice rules
}partial criteria are the ones a check runs on but cannot conclude on without a rendered page: 1.4.3 (contrast is not computed statically), 1.4.1 and 2.5.8 (they need the layout) for axe-core, and 2.4.7 and 2.3.1 (only touched indirectly) for eslint-jsx-a11y. A criterion whose rule never runs is not in the report at all, so it reads as not evaluated.
Page-level rules on fragment scans
Some rules judge the composed page, not any single element — landmarks, the skip link, the document title, <html lang>. On a fragment (a JSX/TSX/Vue/Svelte component, an Astro page that renders into a layout, a partial .html include), that structure lives in the layout that composes the file at render time, so a static per-file scan cannot verify it. Reporting such findings as violations would assert something the scan cannot see.
These rules are therefore reclassified on fragment scans: they leave issues entirely and appear in coverage.reclassified, named and counted — never silently dropped. On document scans (a complete .html page, an Astro layout or component carrying its own <html>) they stay fully active. A file counts as a document when it carries document-level structure itself (<html>, or for .html files a <body>/doctype); when unsure, the scan defaults to fragment.
Rendered-only on fragments (reclassified): region, landmark-one-main, page-has-heading-one, bypass, skip-link, the landmark-no-duplicate-* / landmark-*-is-top-level family, landmark-unique, document-title, html-has-lang, html-lang-valid, html-xml-lang-mismatch.
Static-verifiable everywhere (never reclassified): element-triggered rules — a bad element inside the fragment is real evidence. Examples: image-alt, button-name, label, heading-order, meta-viewport, aria-* attribute rules, and all eslint-jsx-a11y rules.
Verify the reclassified rules by running equall scan on your built output (e.g. astro build && npx equall-cli scan dist/), where those rules execute against a real document instead of a fragment — the CLI lists them under a "Not verifiable on this scan" section, which now names that exact command plus a link to the guide, so nothing gets asserted as clean.
ConfidenceFlag
Static checks can confirm an <img> has an alt, not that the alt actually helps. alt="DSC00423", alt="untitled", or the image's own file name all pass automated checks — the criterion's verdict stays pass_automated (1.1.1 doesn't fail). confidence_flags surfaces these as a review suggestion instead of asserting a violation that a crude heuristic can't be sure of.
interface ConfidenceFlag {
criterion: string // the criterion the advisory relates to, e.g. '1.1.1'
signal: string // which precision signal fired: 'filename_as_alt' | 'alt_equals_src' |
// 'generic_placeholder' | 'gibberish'
value: string // the offending alt text
file_path: string
line?: number // best-effort; absent when not derivable
reason: string // plain-language why it looks suspect
confidence: 'low' // advisory only — reserved for future tiers
}Precision-first: it never fires on good short alts ("Menu", "Cart"), decorative alt="", or dynamic alts (alt={x}, :alt, v-bind:alt) — those are statically unknowable, so the check stays silent rather than guess. It's purely additive — it never changes an issue, a conformance verdict, the score, or coverage.
CriterionConformance
For every WCAG success criterion of the target level, the scan states one honest, scan-scoped support verdict — a pure derivation from issues × coverage × reclassified, no extra scanning. This is the evidence layer behind an accessibility statement / VPAT, not the VPAT itself: the engine never emits a formal "Supports". A documented map translates each verdict to VPAT vocabulary, applied later with human attestation — see the verdict reference.
interface CriterionConformance {
criterion: string // e.g. '1.4.3'
level: WcagLevel // from the WCAG catalog
name: string // criterion name from the catalog
verdict: ConformanceVerdict
evidence?: string[] // failing issue fingerprints — `fail` only
reason?: string // why not verifiable / not tested — the three not_* verdicts
// (also on the `4.1.1` pass under wcag21, which states why)
accepted_exceptions?: number // issues on this criterion suppressed with equall-ignore —
// inventory only, never counted as failures. Absent = 0.
}
type ConformanceVerdict =
| 'fail' // ≥1 active issue maps to the criterion — fail always wins
| 'pass_automated' // exercised `auto` this scan, zero issues — an automated basis
// only, never a bare "pass"
| 'not_verifiable_on_this_scan' // reclassified page-level rule on a fragment — verify on the
// rendered page
| 'not_tested_assisted' // coverage `partial` (e.g. contrast) — needs a rendered/assisted check
| 'not_tested_manual' // coverage `manual` or uncovered — verify manuallyThere is intentionally no not_applicable — applicability is a human judgement, not something the engine can claim on its own. fail always wins even over an otherwise-auto/exercised criterion; ignored and review-only issues never make a criterion fail. The verdicts are emitted for every criterion of the target level, so they always sum to criteria_total.
UncheckedFile
interface UncheckedFile {
scanner: string // scanner name, as in EquallIssue.scanner
file_path: string // as in EquallIssue.file_path
reason: 'parse_error' | 'analysis_error' | 'language_skipped'
}A file a scanner could not check: what that scanner would have found there is unknown, so do not read the absence of its findings as a fix. A scanner that failed outright is not listed per file; it is absent from scanners_used.
RouteInfo
interface RouteInfo {
pattern: string // URL pattern: leading '/', dynamic segments keep their source
// syntax, e.g. '/products/[slug]', '/docs/[...path]'
file: string // source file, relative to the scanned root
framework: RouteFramework
dynamic: boolean // true when the pattern has a dynamic segment
}
type RouteFramework = 'next-app' | 'next-pages' | 'astro' | 'html'Routes are derived from file paths only: no crawling, no network, no rendering. Next.js App Router reads app/**/page.* (route groups stripped); Pages Router reads pages/** (API routes excluded) only when the project declares Next.js; Astro reads src/pages/**; plain .html trees are a fallback used when no framework is detected. SvelteKit and Nuxt routes are not mapped yet: the scan says so on diagnostics. The terminal Summary prints one line with the count per framework.
Enum-ish unions
type ConformanceLevel = 'AAA' | 'AA' | 'A' | 'Partial A' | 'None'
type Severity = 'critical' | 'serious' | 'moderate' | 'minor'
type WcagLevel = 'A' | 'AA' | 'AAA'
type WcagStandard = 'wcag22' | 'wcag21'
type PourPrinciple = 'perceivable' | 'operable' | 'understandable' | 'robust''Partial A' means at least one Level A criterion is failing — the report is "not yet conformant, fix Level A first". 'None' means nothing was genuinely tested this scan (summary.criteria_tested is empty, e.g. no scannable files matched) — it does not mean every level failed. See Conformance level.
Example payload
A truncated real-shaped output (coverage and most entries of each list are cut):
{
"score": 65.38,
"conformance_level": "Partial A",
"issues": [
{
"scanner": "eslint-jsx-a11y",
"scanner_rule_id": "jsx-a11y/alt-text",
"wcag_criteria": ["1.1.1"],
"wcag_level": "A",
"pour": "perceivable",
"file_path": "src/components/Logo.tsx",
"line": 4,
"column": 7,
"html_snippet": "<img src=\"/logo.svg\" />",
"severity": "serious",
"message": "img elements must have an alt prop, either with meaningful text, or an empty string for decorative images. (jsx-a11y/alt-text)",
"help_url": "https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/alt-text.md",
"suggestion": null,
"scanners": ["eslint-jsx-a11y", "axe-core"],
"merged_fingerprints": ["30b6b7c2e8dcbe0c"],
"fingerprint": "864244ded66f5784"
},
{
"scanner": "axe-core",
"scanner_rule_id": "button-name",
"wcag_criteria": ["4.1.2"],
"wcag_level": "A",
"pour": "robust",
"file_path": "src/components/Toolbar.tsx",
"line": null,
"column": null,
"html_snippet": "<button variant=\"ghost\"><sunicon></sunicon></button>",
"severity": "critical",
"message": "Buttons must have discernible text (button-name)",
"help_url": "https://dequeuniversity.com/rules/axe/4.11/button-name?application=axeAPI",
"suggestion": "Fix any of the following: …",
"review_only": true,
"review_reason": "The element depends on props, runtime expressions or a component, which static analysis cannot see.",
"fingerprint": "90ac2990cc70971c"
}
],
"summary": {
"files_scanned": 3,
"total_issues": 7,
"by_severity": { "critical": 3, "serious": 4, "moderate": 0, "minor": 0 },
"by_scanner": { "axe-core": 4, "eslint-jsx-a11y": 3 },
"criteria_tested": ["1.1.1", "1.3.1", "2.1.1", "2.4.4", "4.1.2"],
"criteria_failed": ["1.1.1", "2.1.1", "2.4.4", "3.1.1", "4.1.2"],
"ignored_count": 0
},
"scanners_used": [
{ "name": "axe-core", "version": "4.11.1", "rules_count": 0, "issues_found": 10 },
{ "name": "eslint-jsx-a11y", "version": "6.10.2", "rules_count": 0, "issues_found": 4 }
],
"criteria_covered": ["1.1.1", "1.3.1", "1.4.3", "2.1.1", "2.4.4", "4.1.2"],
"criteria_total": 55,
"scanned_at": "2026-09-30T10:32:11.482Z",
"duration_ms": 812,
"engine_version": "0.3.3",
"score_model": 2,
"criterion_conformance": [
{ "criterion": "1.1.1", "level": "A", "name": "Non-text Content", "verdict": "fail", "evidence": ["864244ded66f5784", "43bada4ac49890d6"] },
{ "criterion": "1.3.1", "level": "A", "name": "Info and Relationships", "verdict": "pass_automated" },
{ "criterion": "1.4.3", "level": "AA", "name": "Contrast (Minimum)", "verdict": "not_tested_assisted", "reason": "Partially testable by static analysis — confirm with a rendered/assisted check (e.g. contrast in context)." },
{ "criterion": "1.2.1", "level": "A", "name": "Audio-only and Video-only (Prerecorded)", "verdict": "not_tested_manual", "reason": "No automated coverage on this scan — verify manually (keyboard, screen reader, human review)." }
],
"standard": "wcag22",
"confidence_flags": [
{
"criterion": "1.1.1",
"signal": "filename_as_alt",
"value": "DSC00423",
"file_path": "src/pages/index.html",
"line": 8,
"reason": "The alt looks like a file name, not a description.",
"confidence": "low"
}
],
"diagnostics": [],
"unchecked": [],
"routes": [
{ "pattern": "/products/[slug]", "file": "src/pages/products/[slug].astro", "framework": "astro", "dynamic": true }
]
}API
Programmatic API for embedding the Equall scanner in another Node tool — runScan, scanBuffer, runDiffScan and the public TypeScript types.
Verdict reference
What each support verdict means — Supports (automated), Does not support, Not evaluated — and how it maps to the VPAT/ACR vocabulary. The honest anchor for reading a scan.