Add aria check program to go/cmd

This commit is contained in:
2026-07-20 12:05:55 -04:00
parent 03b5bea72d
commit c0ddef5923
7 changed files with 1871 additions and 0 deletions

228
go/cmd/aria-check/main.go Normal file
View File

@@ -0,0 +1,228 @@
// Command aria-check is a static accessibility linter for kjol front-ends. It reads
// source (Solid .tsx and gowasm .go alike — anything that authors Tailwind classes as
// string literals) and reports problems that can be caught without a browser.
//
// The first and only check today is COLOUR CONTRAST, implemented against the same
// algorithm as WebAIM's contrast checker (https://webaim.org/resources/contrastchecker/):
// WCAG 2.x relative luminance in sRGB, ratio (L1+0.05)/(L2+0.05).
//
// How it works:
//
// 1. Scan sources for string literals that hold class lists, keeping the utilities
// that co-occur in one literal together (contrast is about a foreground and a
// background on the SAME element — see scan.go).
// 2. Compile every bg-/text- token through kjol's own Tailwind engine (package tw)
// and read back the colours it resolves — palette OKLCH, hex tokens, semantic
// tokens, opacity modifiers and arbitrary values all included (theme.go).
// 3. For each co-occurring foreground/background pair, in both the light and dark
// appearances, composite away any translucency and measure the ratio against the
// WCAG threshold (check.go, color.go).
//
// Because the tokens are resolved by the real engine, aria-check stays correct as the
// design system changes: it never hard-codes a colour.
//
// Usage (globs/roots are relative to -base; omit them to scan the whole base):
//
// aria-check -base . -entry app/style.css frontend
// aria-check -base kjol/go/webui -level AAA
// aria-check -assume-surface -json .
//
// Exit status is non-zero when any contrast failure is found (unless -warn), so it
// drops straight into CI or a pre-commit hook.
//
// Extending it: contrast is one Checker; the scan + resolve scaffolding is meant to
// carry more. Natural next checks that are equally static-friendly — missing alt text
// on images, controls with no accessible label, heading-order jumps, redundant/absent
// ARIA roles — would each add a pass over the same file walk. See the closing notes in
// the repository discussion for the full list.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func main() {
var (
base = flag.String("base", ".", "root directory to scan and resolve -entry against")
entry = flag.String("entry", "", "app brand Tailwind entry stylesheet (optional; kjol's theme layer is always included)")
level = flag.String("level", "AA", "WCAG conformance level: AA or AAA")
min = flag.Float64("min", 0, "override the required ratio for normal-size text (AA only)")
exts = flag.String("ext", strings.Join(DefaultExts, ","), "comma-separated source file extensions to scan")
assumeSurface = flag.Bool("assume-surface", false, "also check text-only elements against the page surface colour")
asJSON = flag.Bool("json", false, "emit findings as JSON")
verbose = flag.Bool("v", false, "also report passing pairs")
warn = flag.Bool("warn", false, "always exit 0, even when failures are found")
)
flag.Parse()
opt := Options{Level: *level, MinOverride: *min, AssumeSurface: *assumeSurface}
groups, err := ScanTree(*base, flag.Args(), splitExts(*exts))
if err != nil {
fatal(err)
}
var entryCSS string
if *entry != "" {
b, err := os.ReadFile(*entry)
if err != nil {
fatal(err)
}
entryCSS = string(b)
}
resolver, err := NewResolver(entryCSS, *base, ColorCandidates(groups))
if err != nil {
fatal(fmt.Errorf("compiling theme: %w", err))
}
findings := Check(groups, resolver, opt)
sort.Slice(findings, func(i, j int) bool {
if findings[i].File != findings[j].File {
return findings[i].File < findings[j].File
}
if findings[i].Line != findings[j].Line {
return findings[i].Line < findings[j].Line
}
return findings[i].Ratio < findings[j].Ratio
})
failures := 0
for _, f := range findings {
if !f.Pass {
failures++
}
}
if *asJSON {
reportJSON(findings, *verbose)
} else {
reportText(findings, failures, len(groups), *verbose)
}
if failures > 0 && !*warn {
os.Exit(1)
}
}
func reportText(findings []Finding, failures, groups int, verbose bool) {
filesWithFail := map[string]bool{}
for _, f := range findings {
if f.Pass && !verbose {
continue
}
if !f.Pass {
filesWithFail[f.File] = true
}
fmt.Println(formatFinding(f))
}
fmt.Println()
if failures == 0 {
fmt.Printf("aria-check: no contrast failures (%d class groups scanned)\n", groups)
return
}
fmt.Printf("aria-check: %d contrast failure(s) across %d file(s) — %d class groups scanned\n",
failures, len(filesWithFail), groups)
}
func formatFinding(f Finding) string {
verdict := "FAIL"
if f.Pass {
verdict = "ok "
}
size := "normal"
if f.Large {
size = "large"
}
bg := f.BG
if bg == "" {
bg = "surface"
}
rel, err := filepath.Rel(".", f.File)
if err != nil {
rel = f.File
}
return fmt.Sprintf("%s:%d: %s %s %.2f:1 (need %.1f:1) %s on %s [%s %s]\n %s → %s %q",
rel, f.Line, verdict, f.ThemeName(), f.Ratio, f.Required,
f.FG, bg, f.ThemeName(), size,
hexOf(f.FGColor), hexOf(f.BGColor), truncate(f.Snip, 90))
}
type jsonFinding struct {
File string `json:"file"`
Line int `json:"line"`
Theme string `json:"theme"`
FG string `json:"fg"`
BG string `json:"bg"`
FGColor string `json:"fgColor"`
BGColor string `json:"bgColor"`
Ratio float64 `json:"ratio"`
Required float64 `json:"required"`
Large bool `json:"large"`
Pass bool `json:"pass"`
}
func reportJSON(findings []Finding, verbose bool) {
out := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
if f.Pass && !verbose {
continue
}
bg := f.BG
if bg == "" {
bg = "surface"
}
out = append(out, jsonFinding{
File: f.File, Line: f.Line, Theme: f.ThemeName(),
FG: f.FG, BG: bg, FGColor: hexOf(f.FGColor), BGColor: hexOf(f.BGColor),
Ratio: round2(f.Ratio), Required: f.Required, Large: f.Large, Pass: f.Pass,
})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(out)
}
func hexOf(c RGBA) string {
to := func(v float64) int { return int(clamp01(v)*255 + 0.5) }
if c.A < 1 {
return fmt.Sprintf("#%02x%02x%02x%02x", to(c.R), to(c.G), to(c.B), to(c.A))
}
return fmt.Sprintf("#%02x%02x%02x", to(c.R), to(c.G), to(c.B))
}
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
func splitExts(s string) []string {
var out []string
for _, e := range strings.Split(s, ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !strings.HasPrefix(e, ".") {
e = "." + e
}
out = append(out, strings.ToLower(e))
}
return out
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "aria-check:", err)
os.Exit(2)
}