---
title: "Run WCAG checks in CI with SARIF output"
url: https://recursiveai.org/onehtml/cookbook/wcag-checks-ci-sarif/
description: "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…"
version: "1.0.0"
---

# Run WCAG checks in CI with SARIF output

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.

```csharp
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:

```shell
dotnet run -- site a11y-results
```

```text
events/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:

```json
[{"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.Url` becomes the artifact’s `uri`, so pass the path your code-scanning tool expects for the file. Without it, the artifact is named `urn: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 `NeedsRender` unless you supply `RenderedFacts` from 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](https://recursiveai.org/onehtml/docs/sarif/) and [Results](https://recursiveai.org/onehtml/docs/results/), which also covers the native and axe-compatible JSON from `ToNativeJson()` and `ToAxeJson()`. Which criteria are covered, and how: [Coverage](https://recursiveai.org/onehtml/docs/coverage/). To fix what the checker can fix for you: [Apply suggested fixes](https://recursiveai.org/onehtml/cookbook/apply-suggested-fixes/).

To combine these checks with browser tests: [Playwright accessibility testing in C#](https://recursiveai.org/onehtml/articles/playwright-accessibility-testing-csharp/).

WCAG checks in xUnit and WebApplicationFactory, compared with axe: [Automated accessibility testing in .NET](https://recursiveai.org/onehtml/articles/automated-accessibility-testing-dotnet/).
