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)), 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

FieldTypeDescription
scorenumberFinal 0–100 score, to two decimal places.
conformance_levelConformanceLevelWCAG conformance against the requested level.
issuesEquallIssue[]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.
summaryScanSummaryCounts over counted issues, 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). A scanner that failed outright is not credited. 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 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.
uncheckedUncheckedFile[] | undefinedThe 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.
routesRouteInfo[] | undefinedThe 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_atstringISO-8601 timestamp.
duration_msnumberWall-clock time of the scan.
engine_versionstring | undefinedEngine (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_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.
merged_fingerprintsstring[] | undefinedPresent 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.
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 | nullThe 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).
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.
review_onlyboolean | undefinedtrue 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_reasonstring | undefinedPlain-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 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; 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):

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

On this page