Equall

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

FieldTypeDescription
scorenumberFinal 0–100 score, to two decimal places.
conformance_levelConformanceLevelWCAG conformance against the requested level.
issuesEquallIssue[]Active and ignored issues. Ignored ones carry ignored: true.
summaryScanSummaryCounts, criteria tested/failed, ignored count.
scanners_usedScannerInfo[]Which scanners ran, with their version and issue count.
criteria_coveredstring[]Union of WCAG criteria the active scanners cover (e.g. ['1.1.1', '4.1.2']).
criteria_totalnumberTotal 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.
coverageCoverageReport | undefinedHonest, 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_conformanceCriterionConformance[] | undefinedOne 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.
standardWcagStandard | undefinedWCAG 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_flagsConfidenceFlag[] | undefinedAlt-quality advisories — present-but-suspect <img> alt text. Additive only: never routed into issues, verdicts, the score, or coverage. [] when none. See ConfidenceFlag.
diagnosticsstring[] | undefinedNon-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_atstringISO-8601 timestamp.
duration_msnumberWall-clock time of the scan.
engine_versionstring | undefinedEngine (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_modelnumber | undefinedVersion 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.

FieldTypeDescription
scannerstringSource engine, e.g. 'axe-core', 'eslint-jsx-a11y', 'readability'.
scanner_rule_idstringOriginal rule ID from the source engine.
scannersstring[] | undefinedPresent 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.
fingerprintstring | undefinedStable 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_criteriastring[]WCAG 2.2 criteria this issue maps to. Empty array = best-practice (not a WCAG violation).
wcag_levelWcagLevel | nullStrictest level among wcag_criteria.
pourPourPrinciple | nullPOUR principle.
file_pathstringPath relative to the scanned root.
linenumber | null1-indexed line when the scanner provides one. axe-core findings are always null here.
columnnumber | null1-indexed column when available.
html_snippetstring | nullOffending HTML element, truncated to 200 chars (axe-core only).
severitySeverityUsed to weight the score.
messagestringHuman-readable description.
help_urlstring | nullLink to documentation on how to fix the issue.
suggestionstring | nullPlain-language fix guidance.
ignoredboolean | undefinedtrue 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 manually

There 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:

report.json
{
  "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
}

On this page