Check every HTML file of a built site against WCAG 2.2 AA without a browser, write one SARIF 2.1.0 file per page for your code-scanning tool, and fail the build when a rule fails.
This is a small console app that you run after your site builds. It takes the output directory and a results directory, checks each .html file, and returns exit code 1 if anything failed.
using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Accessibility.Validation; using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue; // Usage: dotnet run -- <site directory> <output directory> string siteDir = args.Length > 0 ? args[0] : "site"; string outDir = args.Length > 1 ? args[1] : "a11y-results"; Directory.CreateDirectory(outDir); var checker = new Checker(); var options = new CheckOptions { Profile = ValidationProfile.Wcag22AA }; int failures = 0; foreach (string path in Directory.EnumerateFiles(siteDir, "*.html", SearchOption.AllDirectories).Order()) { string relative = Path.GetRelativePath(siteDir, path).Replace('\\', '/'); CheckReport report = checker.Check(Document.Parse(File.ReadAllBytes(path)), options); string sarif = report.ToSarifJson(new ExportOptions { Url = relative, Indented = true }); File.WriteAllText(Path.Combine(outDir, relative.Replace('/', '_') + ".sarif"), sarif); int failed = report.Count(Outcome.Failed); int review = report.Count(Outcome.NeedsReview) + report.Count(Outcome.NeedsRender); Console.WriteLine($"{relative}: {failed} failed, {review} to review"); foreach (RuleResult result in report.WithOutcome(Outcome.Failed)) { Console.WriteLine($" {result.RuleId} at {result.Locator}: {result.Message}"); } failures += failed; } return failures == 0 ? 0 : 1;
Run against a site with two pages:
dotnet run -- site a11y-resultsevents/index.html: 2 failed, 28 to review button-name at form > button: The button has no accessible name. image-alt at #content > article:nth-of-type(1) > img: The image has no accessible name. staff.html: 5 failed, 5 to review aria-deprecated-role at [role="directory"]: The role directory is deprecated. aria-valid-attr at p: The element has an aria- attribute that WAI-ARIA does not define. avoid-inline-spacing at p: Spacing set with !important in the style attribute stops a reader's style sheet from widening it. listitem at [role="directory"] > li:nth-of-type(1): The li is not inside a ul, an ol or a list. listitem at [role="directory"] > li:nth-of-type(2): The li is not inside a ul, an ol or a list.
The exit code is 1, so the CI step fails.
Profiles. ValidationProfile.Wcag22AA runs the rules that check a WCAG 2.2 A or AA criterion. Wcag21AA and Wcag20AA are there for older targets. All adds best-practice rules that no criterion requires. To narrow further, use CheckOptions.Criteria (criterion numbers such as "1.1.1"), Tags or RuleIds. A profile selects rules. It is not a statement about legal compliance.
What gets reported. Failures become SARIF results with kind: "fail" and a level from the rule’s impact: error for critical and serious, warning for moderate, note for minor. NeedsReview and NeedsRender results are written with kind: "review" and level: "none", as SARIF requires for anything that isn’t a failure. A tool that filters on level alone won’t show them. Passed and inapplicable results are left out unless you set IncludePassed or IncludeInapplicable in ExportOptions.
Each result has a region with line, column, byte offset and a snippet of the start tag, plus a locator (a CSS selector unique in the page). Each rule lists the WCAG criteria it checks, with help text and, where the rule comes from an ACT rule or an axe-core rule, a link to that rule’s page. Where a rule has exactly one correct fix, the result carries a SARIF fix whose byte replacements come from running the edit:
[{"description": {"text": "Use the role list instead of directory."}, "artifactChanges": [{"artifactLocation": {"uri": "staff.html", "index": 0}, "replacements": [{"deletedRegion": {"startLine": 10, "startColumn": 13, "endLine": 10, "endColumn": 22, "byteOffset": 179, "byteLength": 9}, "insertedContent": {"text": "list"}}]}]}]Things to know before you gate on it:
ExportOptions.Urlbecomes the artifact’suri, so pass the path your code-scanning tool expects for the file. Without it, the artifact is namedurn:onehtml:document:sha256:plus a hash of the bytes.- The checker sees the HTML you give it. Pages built by script should be checked from the browser’s serialized DOM, and rules that need layout (contrast over images, target size) report
NeedsRenderunless you supplyRenderedFactsfrom Chrome. - Script-driven state is invisible. Results under a focus or hover context are marked “CSS only”.
- Gate on failures, and treat review results as a worklist. The checker doesn’t replace human review.
Report formats: SARIF export and Results, which also covers the native and axe-compatible JSON from ToNativeJson() and ToAxeJson(). Which criteria are covered, and how: Coverage. To fix what the checker can fix for you: Apply suggested fixes.
To combine these checks with browser tests: Playwright accessibility testing in C#.
WCAG checks in xUnit and WebApplicationFactory, compared with axe: Automated accessibility testing in .NET.