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)). Redirect the report cleanly with > report.json while still seeing progress in your terminal.
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[] | Active and ignored issues. Ignored ones carry ignored: true. |
summary | ScanSummary | Counts, 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). 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 (e.g. no scanners available, a scanner threw) — 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. |
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.2.0", 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. |
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 | Offending HTML element, truncated to 200 chars (axe-core only). |
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. |
ScanSummary
interface ScanSummary {
files_scanned: number
total_issues: number
by_severity: Record<Severity, number>
by_scanner: Record<string, number> // keyed by scanner.name
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 (e.g. axe 1.4.3 contrast needs a render)
| 'manual' // a scanner is capable of it but got no eligible files — 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
}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 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 — non-fail, non-pass verdicts only
}
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. The verdicts are emitted for every criterion of the target level, so they always sum to criteria_total.
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:
{
"score": 56.34,
"conformance_level": "Partial A",
"issues": [
{
"scanner": "axe-core",
"scanner_rule_id": "image-alt",
"wcag_criteria": ["1.1.1"],
"wcag_level": "A",
"pour": "perceivable",
"file_path": "src/components/Logo.tsx",
"line": null,
"column": null,
"html_snippet": "<img src=\"/logo.svg\">",
"severity": "critical",
"message": "Images must have alternate text",
"help_url": "https://dequeuniversity.com/rules/axe/4.11/image-alt",
"suggestion": "Add an alt attribute describing the image, or alt=\"\" if it's decorative."
}
],
"summary": {
"files_scanned": 33,
"total_issues": 34,
"by_severity": { "critical": 2, "serious": 11, "moderate": 19, "minor": 0 },
"by_scanner": { "axe-core": 23, "eslint-jsx-a11y": 13 },
"criteria_tested": ["1.1.1", "1.3.1", "2.4.4", "4.1.2"],
"criteria_failed": ["1.1.1", "4.1.2"],
"ignored_count": 2
},
"scanners_used": [
{ "name": "axe-core", "version": "4.11.1", "rules_count": 0, "issues_found": 23 },
{ "name": "eslint-jsx-a11y", "version": "6.10.2", "rules_count": 0, "issues_found": 13 }
],
"criteria_covered": ["1.1.1", "1.3.1", "2.4.4", "4.1.2"],
"criteria_total": 55,
"criterion_conformance": [
{ "criterion": "1.1.1", "level": "A", "name": "Non-text Content", "verdict": "fail", "evidence": ["9f3a1c7e2b4d6081"] },
{ "criterion": "1.3.1", "level": "A", "name": "Info and Relationships", "verdict": "pass_automated" },
{ "criterion": "2.4.4", "level": "A", "name": "Link Purpose (In Context)", "verdict": "pass_automated" },
{ "criterion": "4.1.2", "level": "A", "name": "Name, Role, Value", "verdict": "fail", "evidence": ["a7c02e5f19d3b644"] },
{ "criterion": "2.4.6", "level": "AA", "name": "Headings and Labels", "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/components/Gallery.tsx",
"line": 12,
"reason": "The alt looks like a file name, not a description.",
"confidence": "low"
}
],
"diagnostics": [],
"scanned_at": "2026-04-26T10:32:11.482Z",
"duration_ms": 812,
"engine_version": "0.2.0",
"score_model": 2
}API
Programmatic API for embedding the Equall scanner in another Node tool — runScan 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.