diff --git a/.github/workflows/scan-static-sites.yml b/.github/workflows/scan-static-sites.yml
index 3db95c1..d32c9f4 100644
--- a/.github/workflows/scan-static-sites.yml
+++ b/.github/workflows/scan-static-sites.yml
@@ -12,6 +12,11 @@ permissions:
jobs:
scan:
runs-on: ubuntu-latest
+ # Plugin config for the model-backed alt-text-quality rule.
+ env:
+ GITHUB_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
+ AZURE_VISION_ENDPOINT: ${{ secrets.AZURE_VISION_ENDPOINT }}
+ AZURE_VISION_KEY: ${{ secrets.AZURE_VISION_KEY }}
strategy:
fail-fast: false
matrix:
diff --git a/.gitignore b/.gitignore
index 2eb939f..697a621 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ coverage/
.vitest/
*.log
.DS_Store
+.env
diff --git a/README.md b/README.md
index aa746d0..15178c7 100644
--- a/README.md
+++ b/README.md
@@ -115,13 +115,14 @@ Trigger your scanner workflow manually or on its configured schedule. The plugin
The plugin runs every extracted image through an append-only registry of rules. Each rule returns a finding when an image fails its criteria, and the scanner turns each finding into an issue.
-| Rule | ID | Fires when | Example (flagged) |
-| ------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
-| **Missing alt** | `missing-alt-text` | The `alt` attribute is absent (`null`) or whitespace-only (`" "`). `alt=""` is treated as intentional decorative use and is **not** flagged. | `` `` |
-| **Vague alt** | `vague-alt-text` | The alt text is one of a curated set of generic single words (`image`, `photo`, `icon`, `logo`, `screenshot`, `chart`, `untitled`, etc.) or short filler phrases (`an image of`, `a photo of`). Normalization is applied before matching: case-insensitive, whitespace-collapsed, surrounding punctuation stripped. | `` `` `` |
-| **Filename as alt** | `filename-alt-text` | The alt text ends in a common image file extension (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.bmp`, `.ico`). | `` `` |
-| **Repeated alt** | `repeated-alt-text` | Two or more adjacent images on the rendered page share the same normalized alt text. Useful for patterns like five star icons all labeled `"3/5 stars"`. | Five consecutive `` elements |
-| **Placeholder alt** | `placeholder-alt-text` | The alt text matches a known boilerplate string that signals it was never written (`todo`, `tbd`, `fixme`, `placeholder`, `alt text`, `insert alt text`, `image alt`). Normalization is applied before matching. | `` `` |
+| Rule | ID | Fires when | Example (flagged) |
+| ------------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+| **Missing alt** | `missing-alt-text` | The `alt` attribute is absent (`null`) or whitespace-only (`" "`). `alt=""` is treated as intentional decorative use and is **not** flagged. | `` `` |
+| **Vague alt** | `vague-alt-text` | The alt text is one of a curated set of generic single words (`image`, `photo`, `icon`, `logo`, `screenshot`, `chart`, `untitled`, etc.) or short filler phrases (`an image of`, `a photo of`). Normalization is applied before matching: case-insensitive, whitespace-collapsed, surrounding punctuation stripped. | `` `` `` |
+| **Filename as alt** | `filename-alt-text` | The alt text ends in a common image file extension (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.bmp`, `.ico`). | `` `` |
+| **Repeated alt** | `repeated-alt-text` | Two or more adjacent images on the rendered page share the same normalized alt text. Useful for patterns like five star icons all labeled `"3/5 stars"`. | Five consecutive `` elements |
+| **Placeholder alt** | `placeholder-alt-text` | The alt text matches a known boilerplate string that signals it was never written (`todo`, `tbd`, `fixme`, `placeholder`, `alt text`, `insert alt text`, `image alt`). Normalization is applied before matching. | `` `` |
+| **Alt quality** (opt-in) | `alt-text-quality` | A vision model judges the alt text against the image itself and flags it when the text is inaccurate, incomplete, or otherwise low-quality — plausible-looking alt that the deterministic rules can't catch. **Disabled by default**; requires a GitHub Models token (optionally Azure AI Vision). See [Alt-text quality](#alt-text-quality-model-backed-opt-in). | `` |
### Image extraction
@@ -136,6 +137,40 @@ Before rules run, the plugin extracts images from the page through Playwright's
The scanner's built-in Axe scan includes a rule called [`image-alt`](https://dequeuniversity.com/rules/axe/4.10/image-alt) that catches missing and whitespace-only `alt` attributes. If you have both `"axe"` and `"alt-text-scan"` enabled, the same image may be flagged by both. The other four rules in this plugin (`vague-alt-text`, `filename-alt-text`, `repeated-alt-text`, `placeholder-alt-text`) are unique to the plugin and don't overlap with Axe.
+### Alt-text quality (model-backed, opt-in)
+
+The five rules above are deterministic pattern matches. `alt-text-quality` goes further: it sends each image and its alt text to a vision model, which judges whether the alt text actually and sufficiently describes the image. This catches plausible-looking but wrong or incomplete alt text — for example `alt="a person"` on a photo of a named individual.
+
+Because it makes a per-image model call (cost and latency), it is **disabled by default**. To turn it on:
+
+1. Enable the rule in `config.json` (see [Configuration](#configuration)):
+
+ ```json
+ {
+ "rules": {
+ "alt-text-quality": true
+ }
+ }
+ ```
+
+2. Provide a GitHub Models token as the `GITHUB_MODELS_TOKEN` environment variable (a PAT with the `models:read` scope).
+
+Optionally, supply Azure AI Vision credentials (`AZURE_VISION_ENDPOINT` and `AZURE_VISION_KEY`) to add an OCR-and-tags pre-pass that enriches the model's context. When both are present the plugin selects this augmented mode automatically; set `ALT_TEXT_JUDGE_MODE` to `copilot` or `azure-augmented` to force a mode.
+
+In a workflow, provide these as repository secrets at the **job** level so the scanner's sub-actions inherit them into the process that runs the plugin. GitHub disallows secret names beginning with `GITHUB_`, so store the token under a different name (e.g. `GH_MODELS_TOKEN`) and map it:
+
+```yaml
+jobs:
+ accessibility_scanner:
+ runs-on: ubuntu-latest
+ env:
+ GITHUB_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
+ AZURE_VISION_ENDPOINT: ${{ secrets.AZURE_VISION_ENDPOINT }} # optional
+ AZURE_VISION_KEY: ${{ secrets.AZURE_VISION_KEY }} # optional
+ steps:
+ # ...as in "Enable the plugin in your workflow" above
+```
+
---
## Output
@@ -175,7 +210,7 @@ To override the default enabled state of one or more rules, add a `config.json`
```
- Each key under `rules` is a rule ID from the [Rules](#rules) table above; the value is `true` (run the rule) or `false` (skip it).
-- Rules you don't list keep their default behavior. Today every rule defaults to enabled.
+- Rules you don't list keep their default behavior. Every rule defaults to enabled except `alt-text-quality`, which is opt-in (see [Alt-text quality](#alt-text-quality-model-backed-opt-in)).
- Unknown rule IDs and non-boolean values are logged as warnings and ignored (typo guard).
- A missing or malformed `config.json` causes the plugin to run with all defaults.
- The plugin reads the config once at startup, not per URL.
diff --git a/index.ts b/index.ts
index fac182a..390d7da 100644
--- a/index.ts
+++ b/index.ts
@@ -35,7 +35,8 @@ export default async function altTextScan({page, addFinding}: PluginArgs): Promi
for (const rule of enabledRules) {
let results
try {
- results = rule.evaluate(ctx)
+ // Rules may be sync or async; await both shapes uniformly.
+ results = await rule.evaluate(ctx)
} catch (err) {
console.error(`[alt-text-scan] rule "${rule.id}" threw on ${url}:`, err)
continue
diff --git a/package-lock.json b/package-lock.json
index 171add8..a13e022 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"eslint-plugin-check-file": "^3.3.1",
"playwright": "^1.61.1",
"prettier": "^3.8.4",
+ "tsx": "^4.22.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vitest": "^4.1.9"
@@ -58,6 +59,448 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1126,6 +1569,48 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2355,6 +2840,40 @@
"license": "0BSD",
"optional": true
},
+ "node_modules/tsx": {
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index 146e1d5..5e9350a 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
- "format:check": "prettier --check ."
+ "format:check": "prettier --check .",
+ "probe": "tsx --env-file=.env scripts/probe-alt-quality.ts"
},
"prettier": "@github/prettier-config",
"engines": {
@@ -25,6 +26,7 @@
"eslint-plugin-check-file": "^3.3.1",
"playwright": "^1.61.1",
"prettier": "^3.8.4",
+ "tsx": "^4.22.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vitest": "^4.1.9"
diff --git a/schema/config.schema.json b/schema/config.schema.json
index c9dcac6..39c7704 100644
--- a/schema/config.schema.json
+++ b/schema/config.schema.json
@@ -12,7 +12,7 @@
},
"rules": {
"type": "object",
- "description": "Per-rule enable/disable overrides. Rules not listed keep their default behavior (today, every rule defaults to enabled).",
+ "description": "Per-rule enable/disable overrides. Rules not listed keep their default behavior (every rule defaults to enabled except alt-text-quality, which is opt-in).",
"additionalProperties": false,
"properties": {
"missing-alt-text": {
@@ -34,6 +34,10 @@
"repeated-alt-text": {
"type": "boolean",
"description": "Flags two or more adjacent images in the rendered page that share the same normalized alt text."
+ },
+ "alt-text-quality": {
+ "type": "boolean",
+ "description": "Opt-in, model-backed. Sends each image and its alt text to a vision model (GitHub Models) to flag quality issues such as redundant prefixes, vague or context-missing descriptions, and medium-announcing link text. Disabled by default; requires GITHUB_MODELS_TOKEN and incurs per-image API cost."
}
}
}
diff --git a/scripts/probe-alt-quality.ts b/scripts/probe-alt-quality.ts
new file mode 100644
index 0000000..87b490f
--- /dev/null
+++ b/scripts/probe-alt-quality.ts
@@ -0,0 +1,186 @@
+// Offline scoring harness for the alt-text-quality rule.
+//
+// Reads test cases from tests/fixtures/alt-quality/cases.json, sends each
+// (image, alt, context) tuple through a JudgeAltText, and reports per-case
+// verdicts plus overall agreement against the expected verdicts.
+
+//
+// Run:
+// GITHUB_MODELS_TOKEN= npm run probe
+//
+// Optional env:
+// PROBE_MODEL — model id, default "openai/gpt-4o"
+// PROBE_CASES — path to a cases.json
+// ALT_TEXT_JUDGE_MODE — force "copilot" or "azure-augmented". When unset,
+// auto-selects azure-augmented if AZURE_VISION_* are set.
+// PROBE_MIN_INTERVAL_MS — minimum ms between cases (rate-limit pacing).
+// Set to 3500 for Azure F0's 20-calls/min ceiling.
+
+import {readFile} from 'node:fs/promises'
+import {dirname, resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
+
+import {createJudge} from '../src/judges/index.js'
+import type {Verdict} from '../src/judges/index.js'
+import {loadImageAsDataUrl} from '../src/utils/load-image-data-url.js'
+
+type ProbeCase = {
+ id: string
+ // Path relative to cases.json, OR an absolute path, OR an http(s) URL.
+ image: string
+ alt: string
+ // The "page context" the production rule passes in.
+ context?: string
+ expected?: Verdict
+}
+
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+// Reads intrinsic pixel dimensions straight from the image bytes. This mirrors what the browser reports as
+// naturalWidth/naturalHeight in production.
+function intrinsicSize(buf: Buffer): {width: number; height: number} {
+ const none = {width: 0, height: 0}
+
+ // PNG: 8-byte signature, then an IHDR chunk with width@16, height@20 (BE).
+ if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
+ return {width: buf.readUInt32BE(16), height: buf.readUInt32BE(20)}
+ }
+
+ // GIF: 'GIF8', then logical-screen width@6, height@8 (little-endian).
+ if (buf.length >= 10 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) {
+ return {width: buf.readUInt16LE(6), height: buf.readUInt16LE(8)}
+ }
+
+ // WebP: 'RIFF'....'WEBP' followed by a VP8 / VP8L / VP8X chunk.
+ if (buf.length >= 30 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
+ const chunk = buf.toString('ascii', 12, 16)
+ if (chunk === 'VP8 ') {
+ // Lossy: ...0x9d 0x01 0x2a, then 14-bit width then 14-bit height (LE).
+ return {width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff}
+ }
+ if (chunk === 'VP8L' && buf.length >= 25) {
+ // Lossless: 0x2f signature@20, then packed 14-bit (width-1), (height-1).
+ const bits = buf.readUInt32LE(21)
+ return {width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1}
+ }
+ if (chunk === 'VP8X') {
+ // Extended: 24-bit (width-1)@24, 24-bit (height-1)@27 (little-endian).
+ const width = (buf.readUInt8(24) | (buf.readUInt8(25) << 8) | (buf.readUInt8(26) << 16)) + 1
+ const height = (buf.readUInt8(27) | (buf.readUInt8(28) << 8) | (buf.readUInt8(29) << 16)) + 1
+ return {width, height}
+ }
+ }
+
+ // JPEG: scan segments for a Start-Of-Frame marker carrying the dimensions.
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
+ let off = 2
+ while (off + 9 < buf.length) {
+ if (buf[off] !== 0xff) {
+ off++
+ continue
+ }
+ const marker = buf.readUInt8(off + 1)
+ // SOF0..SOF15 carry size, except DHT(C4), JPG(C8) and DAC(CC).
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
+ return {height: buf.readUInt16BE(off + 5), width: buf.readUInt16BE(off + 7)}
+ }
+ const segLen = buf.readUInt16BE(off + 2)
+ if (segLen < 2) break
+ off += 2 + segLen
+ }
+ }
+
+ return none
+}
+
+async function main(): Promise {
+ const here = dirname(fileURLToPath(import.meta.url))
+ const casesPath = process.env['PROBE_CASES']
+ ? resolve(process.cwd(), process.env['PROBE_CASES'])
+ : resolve(here, '..', 'tests', 'fixtures', 'alt-quality', 'cases.json')
+
+ const cases = JSON.parse(await readFile(casesPath, 'utf8')) as ProbeCase[]
+ const baseDir = dirname(casesPath)
+
+ // Mirror createJudge()'s resolution for display: explicit env wins, else
+ // auto-select azure-augmented when Azure credentials are present.
+ const mode =
+ process.env['ALT_TEXT_JUDGE_MODE'] ||
+ (process.env['AZURE_VISION_ENDPOINT'] && process.env['AZURE_VISION_KEY'] ? 'azure-augmented' : 'copilot')
+ const model = process.env['PROBE_MODEL'] ?? 'openai/gpt-4o'
+ const minIntervalMs = Number(process.env['PROBE_MIN_INTERVAL_MS'] ?? '0')
+
+ let judge
+ try {
+ judge = createJudge()
+ } catch (err) {
+ console.error(err instanceof Error ? err.message : String(err))
+ process.exit(1)
+ }
+
+ console.log(`Mode: ${mode}`)
+ console.log(`Model: ${model}`)
+ console.log(`Cases: ${casesPath}`)
+ console.log(`Total: ${cases.length}`)
+ if (minIntervalMs > 0) console.log(`Pacing: ${minIntervalMs}ms minimum between cases`)
+ console.log('')
+
+ let agreements = 0
+ let withExpected = 0
+
+ for (const c of cases) {
+ const caseStart = Date.now()
+ process.stdout.write(`[${c.id}] `)
+ try {
+ const dataUrl = await loadImageAsDataUrl(c.image, {baseDir})
+ const bytes = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64')
+ const {width: naturalWidth, height: naturalHeight} = intrinsicSize(bytes)
+ const start = Date.now()
+ const verdict = await judge.judge({
+ imageDataUrl: dataUrl,
+ alt: c.alt,
+ context: c.context ?? '',
+ naturalWidth,
+ naturalHeight,
+ })
+ const latencyMs = Date.now() - start
+
+ let agreeMark = ''
+ if (c.expected !== undefined) {
+ withExpected++
+ const agree = c.expected === verdict.verdict
+ if (agree) agreements++
+ agreeMark = agree ? ' ✓' : ' ✗'
+ }
+
+ console.log(`${verdict.verdict} (conf ${verdict.confidence.toFixed(2)}, ${latencyMs}ms)${agreeMark}`)
+ if (naturalWidth > 0 && naturalHeight > 0) console.log(` size: ${naturalWidth}×${naturalHeight}`)
+ console.log(` alt: ${JSON.stringify(c.alt)}`)
+ if (c.expected !== undefined) console.log(` expected: ${c.expected}`)
+ if (verdict.issue) console.log(` issue: ${verdict.issue}`)
+ console.log(` reason: ${verdict.reasoning}`)
+ console.log('')
+ } catch (err) {
+ console.log('ERROR')
+ console.log(` ${err instanceof Error ? err.message : String(err)}`)
+ console.log('')
+ }
+
+ // Rate-limit pacing: ensure at least minIntervalMs has elapsed since this
+ // case started before moving to the next case.
+ if (minIntervalMs > 0) {
+ const elapsed = Date.now() - caseStart
+ const remaining = minIntervalMs - elapsed
+ if (remaining > 0) await sleep(remaining)
+ }
+ }
+
+ if (withExpected > 0) {
+ console.log(`Agreement with expected verdicts: ${agreements}/${withExpected}`)
+ }
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/src/extract.ts b/src/extract.ts
index b2f9880..365d951 100644
--- a/src/extract.ts
+++ b/src/extract.ts
@@ -1,27 +1,87 @@
import type {Page} from 'playwright'
import type {ImageRecord} from './types.js'
+// Maximum number of characters of nearby prose forwarded to model-backed rules.
+const NEARBY_TEXT_MAX = 600
+
// Returns one ImageRecord per HTML element that is exposed in the accessibility tree.
// Using getByRole('img') filters out elements that assistive tech cannot perceive.
export async function extractImages(page: Page): Promise {
- return page.getByRole('img').evaluateAll(els =>
- els
- // getByRole('img') also matches SVG/div with role="img", so filter those out.
- .filter(el => el.tagName === 'IMG')
- .map(el => {
- const rect = el.getBoundingClientRect()
- const boundingBox =
- rect.width === 0 && rect.height === 0 ? null : {x: rect.x, y: rect.y, width: rect.width, height: rect.height}
- return {
- src: el.getAttribute('src'),
- alt: el.getAttribute('alt'),
- role: el.getAttribute('role'),
- ariaHidden: el.getAttribute('aria-hidden') === 'true',
- ariaLabel: el.getAttribute('aria-label'),
- ariaLabelledBy: el.getAttribute('aria-labelledby'),
- outerHTML: el.outerHTML,
- boundingBox,
- }
- }),
- )
+ return page.getByRole('img').evaluateAll((els, maxNearby) => {
+ // Page-level topic, captured once and shared by every image record.
+ const pageTitle = (document.title ?? '').replace(/\s+/g, ' ').trim() || null
+ // All headings in document order, used to find each image's section.
+ const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'))
+
+ return (
+ els
+ // getByRole('img') also matches SVG/div with role="img", so filter those out.
+ .filter(el => el.tagName === 'IMG')
+ .map(el => {
+ const rect = el.getBoundingClientRect()
+ const boundingBox =
+ rect.width === 0 && rect.height === 0
+ ? null
+ : {x: rect.x, y: rect.y, width: rect.width, height: rect.height}
+
+ // Closest ancestor link with an href.
+ const linkEl = el.closest('a[href]') as HTMLAnchorElement | null
+ const inLink = linkEl ? {href: linkEl.getAttribute('href') ?? ''} : null
+
+ // Closest ancestor button
+ const inButton = el.closest('button, [role="button"]') !== null
+
+ // Associated : image must be inside a
+ let figcaption: string | null = null
+ const figure = el.closest('figure')
+ if (figure) {
+ const cap = figure.querySelector('figcaption')
+ const text = cap?.textContent?.trim()
+ if (text) figcaption = text
+ }
+
+ // Nearby prose
+ let nearbyText: string | null = null
+ const block = el.closest('p, li, section, article, main, aside, blockquote, td, th, div')
+ if (block) {
+ const text = (block.textContent ?? '').replace(/\s+/g, ' ').trim()
+ if (text) {
+ nearbyText = text.length > maxNearby ? `${text.slice(0, maxNearby)}…` : text
+ }
+ }
+
+ // Nearest heading that precedes the image in document order.
+ let sectionHeading: string | null = null
+ for (let i = headings.length - 1; i >= 0; i--) {
+ const h = headings[i]!
+ if (!(h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
+ const text = (h.textContent ?? '').replace(/\s+/g, ' ').trim()
+ if (text) {
+ sectionHeading = text
+ break
+ }
+ }
+
+ return {
+ src: el.getAttribute('src'),
+ alt: el.getAttribute('alt'),
+ role: el.getAttribute('role'),
+ ariaHidden: el.getAttribute('aria-hidden') === 'true',
+ ariaLabel: el.getAttribute('aria-label'),
+ ariaLabelledBy: el.getAttribute('aria-labelledby'),
+ outerHTML: el.outerHTML,
+ boundingBox,
+ // Intrinsic bitmap size
+ naturalWidth: (el as HTMLImageElement).naturalWidth,
+ naturalHeight: (el as HTMLImageElement).naturalHeight,
+ inLink,
+ inButton,
+ figcaption,
+ nearbyText,
+ pageTitle,
+ sectionHeading,
+ }
+ })
+ )
+ }, NEARBY_TEXT_MAX)
}
diff --git a/src/judges/azure-augmented-judge.ts b/src/judges/azure-augmented-judge.ts
new file mode 100644
index 0000000..30737a4
--- /dev/null
+++ b/src/judges/azure-augmented-judge.ts
@@ -0,0 +1,117 @@
+// AzureAugmentedJudge — decorator over a JudgeAltText
+// that runs an Azure Computer Vision pre-pass and folds the result into the
+// context handed to the inner judge.
+
+import type {JudgeAltText, JudgeInput, JudgeVerdict} from './types.js'
+
+// Narrowed shape of an Azure AI Vision 4.0 Image Analysis response
+export type AzureVisionAnalysis = {
+ caption?: {text: string; confidence: number}
+ denseCaptions?: Array<{text: string; confidence: number}>
+ readText?: string
+ tags?: Array<{name: string; confidence: number}>
+}
+
+export interface AzureVisionClient {
+ analyze(imageDataUrl: string): Promise
+}
+
+export type AzureAugmentedJudgeConfig = {
+ inner: JudgeAltText
+ vision: AzureVisionClient
+ // Tags below this confidence are dropped before being passed to the model.
+ tagConfidenceThreshold?: number
+ maxTags?: number
+ // Skip the Azure pre-pass when either known intrinsic dimension is at or below
+ // this many pixels
+ minImageDimension?: number
+}
+
+const DEFAULT_TAG_CONFIDENCE = 0.7
+const DEFAULT_MAX_TAGS = 8
+// Azure's documented hard minimum: images must be greater than 50x50 px.
+const DEFAULT_MIN_IMAGE_DIMENSION = 50
+
+export class AzureAugmentedJudge implements JudgeAltText {
+ private readonly inner: JudgeAltText
+ private readonly vision: AzureVisionClient
+ private readonly tagConfidenceThreshold: number
+ private readonly maxTags: number
+ private readonly minImageDimension: number
+
+ constructor(config: AzureAugmentedJudgeConfig) {
+ this.inner = config.inner
+ this.vision = config.vision
+ this.tagConfidenceThreshold = config.tagConfidenceThreshold ?? DEFAULT_TAG_CONFIDENCE
+ this.maxTags = config.maxTags ?? DEFAULT_MAX_TAGS
+ this.minImageDimension = config.minImageDimension ?? DEFAULT_MIN_IMAGE_DIMENSION
+ }
+
+ async judge(input: JudgeInput): Promise {
+ let analysis: AzureVisionAnalysis | null = null
+ if (this.belowSizeFloor(input)) {
+ // Too small for Azure to analyze usefully (it returns 400
+ // InvalidImageSize below its minimum).
+ } else {
+ try {
+ analysis = await this.vision.analyze(input.imageDataUrl)
+ } catch (err) {
+ // If it fails (image too small, transient 5xx, rate-limit, etc.), degrade
+ // to Copilot-only for this image
+ console.warn(
+ `[alt-text-quality] Azure pre-pass failed; falling back to Copilot-only for this image. ${err instanceof Error ? err.message : String(err)}`,
+ )
+ }
+ }
+ const enriched = analysis ? this.composeContext(input.context, analysis) : input.context
+ return this.inner.judge({...input, context: enriched})
+ }
+
+ // True when the intrinsic size is known and at or below the floor in either
+ // dimension.
+ private belowSizeFloor(input: JudgeInput): boolean {
+ const {naturalWidth, naturalHeight} = input
+ if (!naturalWidth || !naturalHeight) return false
+ return naturalWidth <= this.minImageDimension || naturalHeight <= this.minImageDimension
+ }
+
+ private composeContext(original: string, a: AzureVisionAnalysis): string {
+ const parts: string[] = []
+ if (a.caption) parts.push(`Azure CV caption: ${a.caption.text}`)
+ if (a.denseCaptions?.length) {
+ parts.push(`Azure CV regions: ${a.denseCaptions.map(c => c.text).join('; ')}`)
+ }
+ if (a.readText) parts.push(`Azure CV OCR: ${a.readText}`)
+ if (a.tags?.length) {
+ const top = a.tags
+ .filter(t => t.confidence >= this.tagConfidenceThreshold)
+ .slice(0, this.maxTags)
+ .map(t => t.name)
+ if (top.length) parts.push(`Azure CV tags: ${top.join(', ')}`)
+ }
+ if (parts.length === 0) return original
+ const preamble =
+ 'Azure Computer Vision pre-analysis (supplementary signals only — treat with skepticism). ' +
+ "The page's own context above and your own direct view of the image are authoritative and override these signals. " +
+ 'Azure can be wrong: ignore any OCR text that does not visibly appear in the image, and disregard tags that ' +
+ 'conflict with what you see. ' +
+ 'When the image is a link, button, or other functional control, these signals describe the picture itself, ' +
+ "not the control's purpose — do not let them push you toward a longer or more literal description, and do not " +
+ 'penalize a concise alt that correctly conveys where the link goes or what the control does. ' +
+ 'Lean on these signals mainly to confirm fine-grained, hard-to-read details ' +
+ '(exact line numbers, filenames, digits, embedded labels) that you would otherwise be unsure of:'
+ return `${original}\n\n${preamble}\n${parts.join('\n')}`
+ }
+}
+
+// Fallback AzureVisionClient used when azure-augmented mode is selected but no
+// Azure credentials are configured.
+export class NotImplementedAzureVisionClient implements AzureVisionClient {
+ async analyze(_imageDataUrl: string): Promise {
+ throw new Error(
+ 'AzureAugmentedJudge was invoked but no Azure Vision credentials are configured. ' +
+ 'Set AZURE_VISION_ENDPOINT and AZURE_VISION_KEY so createJudge() auto-wires AzureVisionApiClient, ' +
+ 'or pass an explicit client via createJudge({mode: "azure-augmented", visionClient}).',
+ )
+ }
+}
diff --git a/src/judges/azure-vision-api-client.ts b/src/judges/azure-vision-api-client.ts
new file mode 100644
index 0000000..5126ee9
--- /dev/null
+++ b/src/judges/azure-vision-api-client.ts
@@ -0,0 +1,105 @@
+// Real Azure AI Vision (Image Analysis 4.0) client. Implements the
+// AzureVisionClient interface that AzureAugmentedJudge depends on.
+
+import {Buffer} from 'node:buffer'
+import type {AzureVisionAnalysis, AzureVisionClient} from './azure-augmented-judge.js'
+import {fetchWithRetry} from '../utils/fetch-with-retry.js'
+
+export type AzureVisionApiClientConfig = {
+ // Defaults to AZURE_VISION_ENDPOINT.
+ endpoint?: string
+ // Defaults to AZURE_VISION_KEY.
+ key?: string
+ // Image Analysis API version. Defaults to the GA version.
+ apiVersion?: string
+ // Comma-separated feature list. The decorator's composeContext can use any
+ // subset of: caption, denseCaptions, read, tags. Note: caption and
+ // denseCaptions are restricted to specific regions (East US, West US,
+ // West Europe, etc.)
+ features?: string
+}
+
+const DEFAULT_API_VERSION = '2024-02-01'
+const DEFAULT_FEATURES = 'read,tags'
+
+// Narrowed shape of the Image Analysis 4.0 response.
+type AzureRawResponse = {
+ captionResult?: {text: string; confidence: number}
+ denseCaptionsResult?: {values: Array<{text: string; confidence: number}>}
+ readResult?: {
+ blocks?: Array<{
+ lines?: Array<{text: string}>
+ }>
+ }
+ tagsResult?: {values: Array<{name: string; confidence: number}>}
+}
+
+export class AzureVisionApiClient implements AzureVisionClient {
+ private readonly endpoint: string
+ private readonly key: string
+ private readonly apiVersion: string
+ private readonly features: string
+
+ constructor(config: AzureVisionApiClientConfig = {}) {
+ const endpoint = config.endpoint ?? process.env['AZURE_VISION_ENDPOINT']
+ const key = config.key ?? process.env['AZURE_VISION_KEY']
+ if (!endpoint) {
+ throw new Error(
+ 'AzureVisionApiClient requires an endpoint. Set AZURE_VISION_ENDPOINT or pass {endpoint} to the constructor.',
+ )
+ }
+ if (!key) {
+ throw new Error('AzureVisionApiClient requires a key. Set AZURE_VISION_KEY or pass {key} to the constructor.')
+ }
+ this.endpoint = endpoint.replace(/\/$/, '')
+ this.key = key
+ this.apiVersion = config.apiVersion ?? process.env['AZURE_VISION_API_VERSION'] ?? DEFAULT_API_VERSION
+ this.features = config.features ?? process.env['AZURE_VISION_FEATURES'] ?? DEFAULT_FEATURES
+ }
+
+ async analyze(imageDataUrl: string): Promise {
+ const bytes = decodeDataUrl(imageDataUrl)
+
+ const url = new URL(`${this.endpoint}/computervision/imageanalysis:analyze`)
+ url.searchParams.set('api-version', this.apiVersion)
+ url.searchParams.set('features', this.features)
+
+ const res = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: {
+ 'Ocp-Apim-Subscription-Key': this.key,
+ 'Content-Type': 'application/octet-stream',
+ },
+ body: new Blob([Uint8Array.from(bytes)]),
+ })
+
+ if (!res.ok) {
+ const errText = await res.text()
+ throw new Error(`Azure Vision request failed: ${res.status} ${res.statusText}\n${errText}`)
+ }
+
+ const raw = (await res.json()) as AzureRawResponse
+ return shape(raw)
+ }
+}
+
+function decodeDataUrl(dataUrl: string): Buffer {
+ const match = /^data:[^,]*;base64,(.+)$/s.exec(dataUrl)
+ if (!match) throw new Error('AzureVisionApiClient.analyze expects a base64 data URL.')
+ return Buffer.from(match[1]!, 'base64')
+}
+
+function shape(raw: AzureRawResponse): AzureVisionAnalysis {
+ const out: AzureVisionAnalysis = {}
+ if (raw.captionResult) out.caption = raw.captionResult
+ if (raw.denseCaptionsResult?.values?.length) out.denseCaptions = raw.denseCaptionsResult.values
+ if (raw.readResult?.blocks?.length) {
+ const lines: string[] = []
+ for (const block of raw.readResult.blocks) {
+ for (const line of block.lines ?? []) lines.push(line.text)
+ }
+ if (lines.length) out.readText = lines.join('\n')
+ }
+ if (raw.tagsResult?.values?.length) out.tags = raw.tagsResult.values
+ return out
+}
diff --git a/src/judges/caching.ts b/src/judges/caching.ts
new file mode 100644
index 0000000..b42bb24
--- /dev/null
+++ b/src/judges/caching.ts
@@ -0,0 +1,54 @@
+// Content-hash caches for the judge layer
+//
+// • CachingJudge — judgment cache: hash(image, alt, context) -> verdict
+// • CachingVisionClient — vision-extraction cache: SHA-256(image bytes) -> Azure analysis
+//
+// Both caches live for the lifetime of the judge instance, which is a single
+// scan run (createJudge() is memoized once per process). They cut redundant,
+// billable model/vision calls when the same image — or the same
+// image+alt+context tuple — recurs across pages. Logos, icons, and hero images
+// are the common case.
+
+import {createHash} from 'node:crypto'
+import type {AzureVisionAnalysis, AzureVisionClient} from './azure-augmented-judge.js'
+import type {JudgeAltText, JudgeInput, JudgeVerdict} from './types.js'
+
+function sha256(input: string): string {
+ return createHash('sha256').update(input).digest('hex')
+}
+
+// Hash just the image bytes (the base64 payload of the data URL). Two images
+// with identical bytes but different alt/context share this key.
+function hashImageBytes(imageDataUrl: string): string {
+ const comma = imageDataUrl.indexOf(',')
+ const payload = comma === -1 ? imageDataUrl : imageDataUrl.slice(comma + 1)
+ return sha256(payload)
+}
+
+export class CachingJudge implements JudgeAltText {
+ private readonly cache = new Map()
+
+ constructor(private readonly inner: JudgeAltText) {}
+
+ async judge(input: JudgeInput): Promise {
+ const key = sha256(`${hashImageBytes(input.imageDataUrl)}\u0000${input.alt}\u0000${input.context}`)
+ if (this.cache.has(key)) return this.cache.get(key)!
+ const verdict = await this.inner.judge(input)
+ this.cache.set(key, verdict)
+ return verdict
+ }
+}
+
+export class CachingVisionClient implements AzureVisionClient {
+ private readonly cache = new Map()
+
+ constructor(private readonly inner: AzureVisionClient) {}
+
+ async analyze(imageDataUrl: string): Promise {
+ const key = hashImageBytes(imageDataUrl)
+ if (this.cache.has(key)) return this.cache.get(key)!
+ const analysis = await this.inner.analyze(imageDataUrl)
+ this.cache.set(key, analysis)
+ return analysis
+ }
+}
diff --git a/src/judges/copilot-judge.ts b/src/judges/copilot-judge.ts
new file mode 100644
index 0000000..531ab56
--- /dev/null
+++ b/src/judges/copilot-judge.ts
@@ -0,0 +1,100 @@
+// CopilotJudge — calls a vision-capable model on GitHub Models with the
+// shared SYSTEM_PROMPT and VERDICT_SCHEMA, returns a JudgeVerdict.
+//
+// This is the default JudgeAltText implementation. The AzureAugmentedJudge wraps
+// an instance of this class and feeds it an enriched context.
+
+import {SYSTEM_PROMPT, VERDICT_SCHEMA} from './prompt.js'
+import type {JudgeAltText, JudgeInput, JudgeVerdict} from './types.js'
+import {fetchWithRetry} from '../utils/fetch-with-retry.js'
+
+export type CopilotJudgeConfig = {
+ // PAT with the `models:read` scope. Defaults to GITHUB_MODELS_TOKEN
+ // or GITHUB_TOKEN from the environment.
+ token?: string
+ // Model id, e.g. "openai/gpt-4o". Defaults to PROBE_MODEL or "openai/gpt-4o".
+ model?: string
+ // Override for tests or self-hosted gateways.
+ endpoint?: string
+ apiVersion?: string
+ // Sampling temperature. Defaults to 0 for deterministic verdicts.
+ temperature?: number
+}
+
+const DEFAULT_ENDPOINT = 'https://models.github.ai/inference/chat/completions'
+const DEFAULT_API_VERSION = '2026-03-10'
+const DEFAULT_MODEL = 'openai/gpt-4o'
+
+type ChatCompletionResponse = {
+ choices?: Array<{message?: {content?: string}}>
+}
+
+export class CopilotJudge implements JudgeAltText {
+ private readonly token: string
+ private readonly model: string
+ private readonly endpoint: string
+ private readonly apiVersion: string
+ private readonly temperature: number
+
+ constructor(config: CopilotJudgeConfig = {}) {
+ const token = config.token ?? process.env['GITHUB_MODELS_TOKEN'] ?? process.env['GITHUB_TOKEN']
+ if (!token) {
+ throw new Error(
+ 'CopilotJudge requires a token. Set GITHUB_MODELS_TOKEN (or GITHUB_TOKEN) ' +
+ 'to a PAT with the `models:read` scope, or pass {token} to the constructor.',
+ )
+ }
+ this.token = token
+ this.model = config.model ?? process.env['PROBE_MODEL'] ?? DEFAULT_MODEL
+ this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT
+ this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION
+ this.temperature = config.temperature ?? 0
+ }
+
+ async judge(input: JudgeInput): Promise {
+ const userText =
+ `Surrounding context: ${input.context || '(none provided)'}\n` +
+ `Current alt text: ${JSON.stringify(input.alt)}\n\n` +
+ `Evaluate the alt text against the image and respond with the required JSON object.`
+
+ const body = {
+ model: this.model,
+ messages: [
+ {role: 'system', content: SYSTEM_PROMPT},
+ {
+ role: 'user',
+ content: [
+ {type: 'text', text: userText},
+ {type: 'image_url', image_url: {url: input.imageDataUrl, detail: 'high'}},
+ ],
+ },
+ ],
+ response_format: {type: 'json_schema', json_schema: VERDICT_SCHEMA},
+ temperature: this.temperature,
+ }
+
+ const res = await fetchWithRetry(this.endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${this.token}`,
+ 'X-GitHub-Api-Version': this.apiVersion,
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (!res.ok) {
+ const errText = await res.text()
+ throw new Error(`inference failed: ${res.status} ${res.statusText}\n${errText}`)
+ }
+
+ const json = (await res.json()) as ChatCompletionResponse
+ const raw = json.choices?.[0]?.message?.content ?? ''
+ try {
+ return JSON.parse(raw) as JudgeVerdict
+ } catch {
+ throw new Error(`failed to parse model output as JSON:\n${raw}`)
+ }
+ }
+}
diff --git a/src/judges/index.ts b/src/judges/index.ts
new file mode 100644
index 0000000..9b01073
--- /dev/null
+++ b/src/judges/index.ts
@@ -0,0 +1,59 @@
+// Public entry-point for the judge layer.
+
+import {AzureAugmentedJudge, NotImplementedAzureVisionClient} from './azure-augmented-judge.js'
+import type {AzureVisionClient} from './azure-augmented-judge.js'
+import {AzureVisionApiClient} from './azure-vision-api-client.js'
+import {CachingJudge, CachingVisionClient} from './caching.js'
+import {CopilotJudge} from './copilot-judge.js'
+import type {CopilotJudgeConfig} from './copilot-judge.js'
+import type {JudgeAltText, JudgeMode} from './types.js'
+
+export type {JudgeAltText, JudgeInput, JudgeVerdict, JudgeMode, Verdict} from './types.js'
+export {CopilotJudge} from './copilot-judge.js'
+export type {CopilotJudgeConfig} from './copilot-judge.js'
+export {AzureAugmentedJudge, NotImplementedAzureVisionClient} from './azure-augmented-judge.js'
+export type {AzureVisionClient, AzureVisionAnalysis, AzureAugmentedJudgeConfig} from './azure-augmented-judge.js'
+export {AzureVisionApiClient} from './azure-vision-api-client.js'
+export {CachingJudge, CachingVisionClient} from './caching.js'
+export type {AzureVisionApiClientConfig} from './azure-vision-api-client.js'
+export {SYSTEM_PROMPT, VERDICT_SCHEMA} from './prompt.js'
+
+export type CreateJudgeOptions = {
+ mode?: JudgeMode
+ copilot?: CopilotJudgeConfig
+ visionClient?: AzureVisionClient
+}
+
+function resolveMode(opts: CreateJudgeOptions): JudgeMode {
+ if (opts.mode) return opts.mode
+ const env = process.env['ALT_TEXT_JUDGE_MODE']
+ if (env === 'copilot' || env === 'azure-augmented') return env
+ if (process.env['AZURE_VISION_ENDPOINT'] && process.env['AZURE_VISION_KEY']) {
+ return 'azure-augmented'
+ }
+ return 'copilot'
+}
+
+function resolveVisionClient(opts: CreateJudgeOptions): AzureVisionClient {
+ if (opts.visionClient) return opts.visionClient
+ if (process.env['AZURE_VISION_ENDPOINT'] && process.env['AZURE_VISION_KEY']) {
+ return new AzureVisionApiClient()
+ }
+ return new NotImplementedAzureVisionClient()
+}
+
+export function createJudge(opts: CreateJudgeOptions = {}): JudgeAltText {
+ const mode = resolveMode(opts)
+ const copilot = new CopilotJudge(opts.copilot)
+ // The judgment cache (hash(image, alt, context) -> verdict) wraps the
+ // outermost judge, so a hit skips everything below it — including the Azure
+ // pre-pass. The vision-extraction cache (image bytes -> Azure analysis) wraps
+ // just the Azure client, where two-stage mode gets most of its reuse.
+ if (mode === 'copilot') return new CachingJudge(copilot)
+ return new CachingJudge(
+ new AzureAugmentedJudge({
+ inner: copilot,
+ vision: new CachingVisionClient(resolveVisionClient(opts)),
+ }),
+ )
+}
diff --git a/src/judges/prompt.ts b/src/judges/prompt.ts
new file mode 100644
index 0000000..61c275d
--- /dev/null
+++ b/src/judges/prompt.ts
@@ -0,0 +1,150 @@
+// System prompt and structured-output schema for the alt-text-quality judge.
+//
+// Both the production rule and the offline probe import these constants, which
+// guarantees the two paths reason against the exact same instructions.
+
+export const SYSTEM_PROMPT = `# Identity
+
+You are an accessibility reviewer evaluating whether the alt text on a single HTML element is appropriate, given the image and the surrounding page context.
+
+You are conservative and trust the page author's framing. You do not impose your own preferred level of detail. You do not flag alt text just because you would have written something different.
+
+# Decision procedure
+
+Walk these steps in order. Stop at the first step that matches and emit its verdict.
+
+## Step 1 — Purely decorative
+The image is purely visual styling: separators, borders, spacers, ornamental flourishes, or generic mood/stock photography that contributes no information specific to this page.
+ - If current alt is "" → verdict = "ok"
+ - If current alt is non-empty → verdict = "decorative"
+
+## Step 2 — Redundant with adjacent label/caption
+The image's full informational content is already conveyed by **label-style or caption-style** adjacent text — specifically a figure caption directly above/below the image, or a short labeling string next to the image within the same component (e.g., a name plate under a portrait, an HTML figcaption element).
+
+This step does NOT apply when surrounding **body prose** merely *mentions* the image's subject. Body prose mentioning the subject still leaves the image with informational content (the subject's appearance, the visual evidence of a property, etc.) that an alt should convey — use Step 4 for that case.
+
+A reliable signal: if the provided context calls the adjacent text a "caption", "label", or "figcaption", Step 2 may apply. If the context calls it "body text", "paragraph", or "surrounding text", Step 2 does NOT apply — go to Step 4.
+ - If current alt is "" → verdict = "ok"
+ - If current alt is non-empty → verdict = "decorative"
+
+## Step 3 — Functional (image inside a link or button)
+The image is the only content of a link or button. The alt becomes the link's accessible name. Apply criteria F–H below. The alt is "ok" if and only if ALL hold:
+
+ F. NAMES-THE-TARGET — The alt names what the user will reach (the entity, page, or action). Naming the entity (e.g., "Astronaut Ellen Ochoa" for a link to her bio) qualifies; the alt does NOT need to use the literal word "link" or "page".
+ G. NOT-MEDIUM-ANNOUNCING — The alt does not announce the destination's medium or technology ("Wikipedia entry for …", "PDF of …", "Click here to read about …", "Link to …"). Screen readers already announce the link role.
+ H. NOT-GENERIC — The alt is not empty and not a placeholder like "Read More", "Image", "Click here".
+
+If any criterion fails → verdict = "needs-fix". Otherwise → verdict = "ok".
+
+## Step 4 — Informative (default)
+The image conveys information that contributes meaning to the page. Apply criteria A–E below. The alt is "ok" if and only if ALL hold:
+
+ A. ACCURATE — The alt describes what the image actually shows in this context.
+ B. CONTEXTUALLY-COMPLETE — The alt captures the content the page relies on the image to convey, given the surrounding text. If the surrounding text already covers some of that content, the alt may be brief.
+ C. NOT-DUPLICATIVE — The alt does not repeat sentences from nearby text verbatim.
+ D. NOT-INTRUSIVE — The alt does not introduce new claims absent from the page (no extra trivia, no marketing copy).
+ E. APPROPRIATELY-FRAMED — Vocabulary, register, and detail level match the audience and genre indicated by the context.
+
+If any criterion fails → verdict = "needs-fix". Otherwise → verdict = "ok".
+
+# Operational rules
+
+R1. **Trust the framing.** The provided context describes the page's audience and genre. If the context warrants longer description (educational pages applying "general-to-specific", art-history pages, textbook questions referring to specific image elements), longer alt is appropriate. If the context targets young children or is a labeled caption, short alt is appropriate. Do not penalize length when the context warrants it.
+
+R1a. **Surrounding analysis is not a detail requirement.** When the surrounding text *analyzes or discusses properties of the image* (e.g., an art-history paragraph dissecting a painting's composition, light, color, perspective), this does NOT mean the alt must reproduce that analysis. The alt names what the image is; the surrounding text supplies the analysis. A short, accurate alt is correct even when the surrounding paragraph is long and analytical — in fact, a longer alt would duplicate the surrounding analysis (failing C).
+
+R2. **Distinguish redundant prefixes from semantic prefixes.**
+ - REDUNDANT (penalize): "Image of …", "Picture of …", "Graphic of …" — these add no information.
+ - SEMANTIC (do NOT penalize): "Photograph of …", "Painting of …", "Drawing of …", "Diagram of …", "Map of …", "Chart of …", "Screenshot of …", "Icon of …" — these communicate the medium or genre, which is information.
+
+R3. **Labeled diagrams.** When the image is a diagram whose content is the labels it carries (anatomical labels, mitosis stages, taxonomic charts, flow-chart steps), naming the labels IS the correct alt content. Do not demand visual descriptions of how each labeled region looks.
+
+R4. **An empty alt ("") is the correct answer when (and only when) the image matches Step 1 or Step 2.** When the alt is already empty in such a case, return verdict = "ok" — no action is needed. Use "decorative" only when the current alt is non-empty and should be removed.
+
+R5. **Do not overthink.** Walk the decision procedure once, in order. Do not invent context that wasn't provided. Do not bring in general knowledge about what "good" alt text usually looks like beyond the rules above. Pick the first matching step and stop.
+
+# Output
+
+Return a single JSON object with EXACTLY these fields, in this order: step, reasoning, verdict, issue, confidence.
+
+# Examples
+
+
+The image is a horizontal separator graphic placed between two sections. The structural separation is conveyed by the page's headings and layout.
+
+
+
+
+
+Adjacent caption immediately above the image: "Ellen Ochoa, Astronaut". Body text below names her achievements.
+
+
+
+
+
+Book about national parks. Photograph of the Grand Canyon during monsoon season, surrounding text describes a violent storm.
+A photograph of a blue sky peeking through grey storm clouds over the Grand Canyon in early September.
+
+
+
+
+Biology textbook chapter-summary question: which of the following is not a main stage of mitosis? The image is a hand-drawn diagram with each stage labeled.
+Mitosis stages: Prophase, Prometaphase, Metaphase, Anaphase, and Telophase
+
+
+
+
+Geology page about acid erosion of rock formations; bird droppings are cited as a contributor.
+A close-up of a puffin with bright orange feet and a colorful beak.
+
+
+
+
+Image is the only content inside a link to an Ellen Ochoa Wikipedia page.
+Read More
+
+
+
+
+Body text near the image: "As the first Hispanic woman to go to space, Ellen Ochoa is widely regarded as a role model." The image is not inside a link. The body text names her but does not state she is an astronaut; the uniform in the image conveys that.
+Image of Ellen Ochoa, Astronaut
+
+
+
+
+The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The link's accessible name comes entirely from the image's alt text.
+Astronaut Ellen Ochoa
+
+
+
+
+The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The link's accessible name comes entirely from the image's alt text.
+Wikipedia entry for Ellen Ochoa, Astronaut
+
+
+
+
+An art-history textbook chapter on artistic technique. The surrounding paragraph analyzes the painting's use of light, color, form, perspective, proportion, and motion to depict the iconic crossing.
+Painting of George Washington crossing the Delaware River
+
+`
+
+// JSON Schema for Structured Outputs strict mode. Field order in `properties`
+// is the generation order; reasoning is generated before verdict to force
+// chain-of-thought before classification.
+export const VERDICT_SCHEMA = {
+ name: 'alt_text_verdict',
+ strict: true,
+ schema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ step: {type: 'integer', enum: [1, 2, 3, 4]},
+ reasoning: {type: 'string'},
+ verdict: {type: 'string', enum: ['ok', 'needs-fix', 'decorative']},
+ issue: {type: 'string'},
+ confidence: {type: 'number', minimum: 0, maximum: 1},
+ },
+ required: ['step', 'reasoning', 'verdict', 'issue', 'confidence'],
+ },
+} as const
diff --git a/src/judges/types.ts b/src/judges/types.ts
new file mode 100644
index 0000000..6a54108
--- /dev/null
+++ b/src/judges/types.ts
@@ -0,0 +1,30 @@
+// Shared contract for any backend that judges alt-text quality.
+
+export type Verdict = 'ok' | 'needs-fix' | 'decorative'
+
+// Output the model is forced to produce
+export type JudgeVerdict = {
+ step: 1 | 2 | 3 | 4
+ reasoning: string
+ verdict: Verdict
+ issue: string
+ confidence: number
+}
+
+// What the rule hands to a judge
+export type JudgeInput = {
+ imageDataUrl: string
+ alt: string
+ context: string
+ // Intrinsic pixel dimensions of the image, when known (0/undefined = unknown).
+ // AzureAugmentedJudge uses these to skip the Azure pre-pass for images below
+ // Azure's minimum size; the inner judge still runs.
+ naturalWidth?: number
+ naturalHeight?: number
+}
+
+export interface JudgeAltText {
+ judge(input: JudgeInput): Promise
+}
+
+export type JudgeMode = 'copilot' | 'azure-augmented'
diff --git a/src/rules/alt-text-quality.ts b/src/rules/alt-text-quality.ts
new file mode 100644
index 0000000..8cf3825
--- /dev/null
+++ b/src/rules/alt-text-quality.ts
@@ -0,0 +1,129 @@
+// alt-text-quality — model-backed rule that judges whether each image's alt
+// text is appropriate given the image and its surrounding page context.
+
+import {createJudge} from '../judges/index.js'
+import type {JudgeAltText, JudgeVerdict} from '../judges/index.js'
+import type {Rule, RuleContext, RuleResult, ImageRecord} from '../types.js'
+import {loadImageAsDataUrl} from '../utils/load-image-data-url.js'
+
+// Lazily build the judge so missing tokens surface only when the rule actually
+// runs
+let cachedJudge: JudgeAltText | null = null
+function getJudge(): JudgeAltText {
+ if (!cachedJudge) cachedJudge = createJudge()
+ return cachedJudge
+}
+
+// Test seam: lets a unit test inject a fake judge without touching env vars.
+export function __setJudge(judge: JudgeAltText | null): void {
+ cachedJudge = judge
+}
+
+// Resolve the image's src against the page URL so relative paths work.
+function resolveImageUrl(src: string, pageUrl: string): string | null {
+ try {
+ return new URL(src, pageUrl).toString()
+ } catch {
+ return null
+ }
+}
+
+// Build the natural-language context string handed to the judge from the
+// structured fields populated by extractImages
+
+const MAX_IMAGE_HTML = 500
+
+// The judge already sees the image via its data URL, so the raw src/srcset add
+// only token cost and risk leaking data to the model. Strip those values and cap the length.
+function sanitizeImageHtml(outerHTML: string): string {
+ const stripped = outerHTML
+ .replace(/\s+src\s*=\s*("[^"]*"|'[^']*')/gi, ' src="(omitted)"')
+ .replace(/\s+srcset\s*=\s*("[^"]*"|'[^']*')/gi, ' srcset="(omitted)"')
+ return stripped.length > MAX_IMAGE_HTML ? `${stripped.slice(0, MAX_IMAGE_HTML)}…` : stripped
+}
+
+function buildContextString(image: ImageRecord, pageUrl: string): string {
+ const parts: string[] = [`Page URL: ${pageUrl}`]
+ if (image.pageTitle) parts.push(`Page title: ${JSON.stringify(image.pageTitle)}`)
+ if (image.sectionHeading) parts.push(`Nearest heading above the image: ${JSON.stringify(image.sectionHeading)}`)
+ parts.push(`Image HTML: ${sanitizeImageHtml(image.outerHTML)}`)
+ if (image.inLink) parts.push(`The image is inside a link with href="${image.inLink.href}".`)
+ if (image.inButton) parts.push('The image is inside a button (or role="button" element).')
+ if (image.figcaption) parts.push(`Adjacent figcaption: ${JSON.stringify(image.figcaption)}`)
+ if (image.nearbyText) parts.push(`Surrounding body text: ${JSON.stringify(image.nearbyText)}`)
+ if (image.ariaLabel) parts.push(`aria-label="${image.ariaLabel}" is present on the image.`)
+ if (image.ariaLabelledBy) parts.push(`aria-labelledby="${image.ariaLabelledBy}" is present on the image.`)
+ return parts.join('\n')
+}
+
+// Translate a JudgeVerdict into a RuleResult understandable by the rest of
+// the plugin. "ok" verdicts produce no finding.
+function verdictToResult(image: ImageRecord, verdict: JudgeVerdict): RuleResult | null {
+ if (verdict.verdict === 'ok') return null
+
+ if (verdict.verdict === 'decorative') {
+ return {
+ image,
+ problemShort: `Alt text appears to describe a purely decorative or already-captioned image:\n"${image.alt ?? ''}"`,
+ solutionShort: 'Replace the alt attribute with alt="" so assistive tech skips the image.',
+ solutionLong: verdict.reasoning,
+ }
+ }
+
+ // verdict.verdict === 'needs-fix'
+ return {
+ image,
+ problemShort: `Alt text quality issue${verdict.issue ? ` (${verdict.issue})` : ''}:\n"${image.alt ?? ''}"`,
+ solutionShort: 'Revise the alt text per the reviewer reasoning below.',
+ solutionLong: verdict.reasoning,
+ }
+}
+
+export const altTextQuality: Rule = {
+ id: 'alt-text-quality',
+ problemUrl: 'https://www.w3.org/WAI/tutorials/images/',
+ // Opt-in. Requires GITHUB_MODELS_TOKEN and incurs per-image API cost.
+ defaultEnabled: false,
+
+ async evaluate(context: RuleContext): Promise {
+ const judge = getJudge()
+ const results: RuleResult[] = []
+
+ for (const image of context.images) {
+ // Skip images we cannot fetch or whose alt is structurally absent —
+ // missing-alt-text.ts handles those.
+ if (!image.src) continue
+ if (image.alt === null) continue
+
+ const resolved = resolveImageUrl(image.src, context.url)
+ if (!resolved) continue
+
+ let dataUrl: string
+ try {
+ dataUrl = await loadImageAsDataUrl(resolved)
+ } catch (err) {
+ console.error(`[alt-text-quality] failed to load ${resolved}:`, err)
+ continue
+ }
+
+ let verdict: JudgeVerdict
+ try {
+ verdict = await judge.judge({
+ imageDataUrl: dataUrl,
+ alt: image.alt,
+ context: buildContextString(image, context.url),
+ naturalWidth: image.naturalWidth,
+ naturalHeight: image.naturalHeight,
+ })
+ } catch (err) {
+ console.error(`[alt-text-quality] judge failed for ${resolved}:`, err)
+ continue
+ }
+
+ const result = verdictToResult(image, verdict)
+ if (result) results.push(result)
+ }
+
+ return results
+ },
+}
diff --git a/src/rules/index.ts b/src/rules/index.ts
index c54f518..8d9096e 100644
--- a/src/rules/index.ts
+++ b/src/rules/index.ts
@@ -1,4 +1,5 @@
import type {Rule} from '../types.js'
+import {altTextQuality} from './alt-text-quality.js'
import {filenameAltText} from './filename-alt-text.js'
import {vagueAltText} from './vague-alt-text.js'
import {missingAltText} from './missing-alt-text.js'
@@ -6,4 +7,11 @@ import {placeholderAltText} from './placeholder-alt-text.js'
import {repeatedAltText} from './repeated-alt-text.js'
// Append-only registry. Add a rule by importing it here and pushing it onto the array.
-export const allRules: Rule[] = [filenameAltText, vagueAltText, missingAltText, placeholderAltText, repeatedAltText]
+export const allRules: Rule[] = [
+ filenameAltText,
+ vagueAltText,
+ missingAltText,
+ placeholderAltText,
+ repeatedAltText,
+ altTextQuality,
+]
diff --git a/src/types.ts b/src/types.ts
index a8024d1..1e91d19 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -32,6 +32,24 @@ export type ImageRecord = {
ariaLabelledBy: string | null
outerHTML: string
boundingBox: BoundingBox | null
+ // Intrinsic (natural) pixel dimensions of the image bitmap, independent of
+ // CSS rendering.
+ naturalWidth: number
+ naturalHeight: number
+
+ inLink: {href: string} | null
+ // True when the image's closest ancestor button exists. Used together with inLink for "functional image" detection.
+ inButton: boolean
+ // Trimmed text content of an associated , if it exists.
+ figcaption: string | null
+ // Trimmed text content of the closest enclosing block-level element.
+ nearbyText: string | null
+ // Trimmed text of the page's , used as topical context for the
+ // model-backed rule.
+ pageTitle: string | null
+ // Trimmed text of the nearest heading (h1–h6) preceding the image in
+ // document order, naming the section the image sits under.
+ sectionHeading: string | null
}
// Pixel position and size of an image in the page's rendered layout.
@@ -56,12 +74,11 @@ export type RuleResult = {
solutionLong?: string
}
-// A rule is a pure, synchronous function over RuleContext.
+// A rule evaluates a RuleContext and returns its findings.
export type Rule = {
id: string
problemUrl: string
// Whether the rule runs when the consumer hasn't explicitly configured it.
- // Optional; treated as `true` when absent.
defaultEnabled?: boolean
- evaluate(ctx: RuleContext): RuleResult[]
+ evaluate(ctx: RuleContext): RuleResult[] | Promise
}
diff --git a/src/utils/fetch-with-retry.ts b/src/utils/fetch-with-retry.ts
new file mode 100644
index 0000000..5c876e9
--- /dev/null
+++ b/src/utils/fetch-with-retry.ts
@@ -0,0 +1,67 @@
+// Wraps fetch with a per-attempt timeout and bounded retry on transient
+// failures (HTTP 429, 5xx, network errors, and timeouts). A hung request is
+// aborted after `timeoutMs`; retries use exponential backoff.
+
+export type FetchRetryOptions = {
+ timeoutMs?: number
+ maxRetries?: number
+ // Base delay for exponential backoff.
+ baseDelayMs?: number
+}
+
+const DEFAULT_TIMEOUT_MS = 30_000
+const DEFAULT_MAX_RETRIES = 2
+const DEFAULT_BASE_DELAY_MS = 500
+
+export async function fetchWithRetry(
+ url: string | URL,
+ init: RequestInit,
+ options: FetchRetryOptions = {},
+): Promise {
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
+
+ let lastError: unknown
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ const res = await fetch(url, {...init, signal: controller.signal})
+ if ((res.status === 429 || res.status >= 500) && attempt < maxRetries) {
+ // Discard the body so the connection can be reused, then retry.
+ await res.body?.cancel()
+ await sleep(retryDelayMs(res, attempt, baseDelayMs))
+ continue
+ }
+ return res
+ } catch (err) {
+ lastError = err
+ if (attempt >= maxRetries) break
+ await sleep(backoffMs(attempt, baseDelayMs))
+ } finally {
+ clearTimeout(timer)
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error(`fetch failed after ${maxRetries + 1} attempts`)
+}
+
+function retryDelayMs(res: Response, attempt: number, baseDelayMs: number): number {
+ const retryAfter = res.headers.get('retry-after')
+ if (retryAfter) {
+ const seconds = Number(retryAfter)
+ if (Number.isFinite(seconds)) return seconds * 1000
+ const date = Date.parse(retryAfter)
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
+ }
+ return backoffMs(attempt, baseDelayMs)
+}
+
+function backoffMs(attempt: number, baseDelayMs: number): number {
+ return baseDelayMs * 2 ** attempt + Math.random() * baseDelayMs
+}
+
+function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
diff --git a/src/utils/load-image-data-url.ts b/src/utils/load-image-data-url.ts
new file mode 100644
index 0000000..7d41186
--- /dev/null
+++ b/src/utils/load-image-data-url.ts
@@ -0,0 +1,64 @@
+// Loads an image from any of: an http(s) URL, an absolute filesystem path,
+// or a path relative to a given base directory. Returns the bytes encoded as
+// a data URL suitable for vision-model `image_url` fields.
+
+import {readFile} from 'node:fs/promises'
+import {extname, isAbsolute, resolve} from 'node:path'
+import {fetchWithRetry} from './fetch-with-retry.js'
+
+const MIME_BY_EXT: Record = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.svg': 'image/svg+xml',
+ '.bmp': 'image/bmp',
+ '.avif': 'image/avif',
+}
+
+function guessMime(p: string): string {
+ return MIME_BY_EXT[extname(p).toLowerCase()] ?? 'application/octet-stream'
+}
+
+// Downstream consumers (e.g. the Azure client's decoder, the probe's byte parser)
+// assume a base64 data URL. A pass-through `data:` URL may instead be plain or
+// percent-encoded (common for inline SVG), so re-encode those to base64 to keep
+// the function's contract consistent.
+function normalizeDataUrl(dataUrl: string): string {
+ const comma = dataUrl.indexOf(',')
+ if (comma === -1) return dataUrl
+ const meta = dataUrl.slice('data:'.length, comma)
+ if (/;base64/i.test(meta)) return dataUrl
+ const payload = dataUrl.slice(comma + 1)
+ let decoded: string
+ try {
+ decoded = decodeURIComponent(payload)
+ } catch {
+ decoded = payload
+ }
+ const mime = meta.split(';')[0] || 'text/plain'
+ return `data:${mime};base64,${Buffer.from(decoded, 'utf8').toString('base64')}`
+}
+
+export type LoadImageOptions = {
+ // Used to resolve relative filesystem paths. Ignored for URLs.
+ baseDir?: string
+}
+
+export async function loadImageAsDataUrl(imageRef: string, options: LoadImageOptions = {}): Promise {
+ if (/^data:/i.test(imageRef)) return normalizeDataUrl(imageRef)
+
+ if (/^https?:\/\//i.test(imageRef)) {
+ const res = await fetchWithRetry(imageRef, {headers: {Accept: 'image/*'}})
+ if (!res.ok) throw new Error(`failed to fetch ${imageRef}: ${res.status} ${res.statusText}`)
+ const buf = Buffer.from(await res.arrayBuffer())
+ const mime = res.headers.get('content-type')?.split(';')[0]?.trim() || guessMime(imageRef)
+ return `data:${mime};base64,${buf.toString('base64')}`
+ }
+
+ const baseDir = options.baseDir ?? process.cwd()
+ const abs = isAbsolute(imageRef) ? imageRef : resolve(baseDir, imageRef)
+ const buf = await readFile(abs)
+ return `data:${guessMime(abs)};base64,${buf.toString('base64')}`
+}
diff --git a/tests/example-site.test.ts b/tests/example-site.test.ts
index 6dc29d6..859803b 100644
--- a/tests/example-site.test.ts
+++ b/tests/example-site.test.ts
@@ -51,6 +51,7 @@ describe('example site-with-errors', () => {
const {allRules} = await import('../src/rules/index.js')
for (const rule of allRules) {
+ if (rule.defaultEnabled === false) continue
expect(ruleIds).toContain(rule.id)
}
})
@@ -95,6 +96,7 @@ describe('example site-with-errors', () => {
const {allRules} = await import('../src/rules/index.js')
for (const rule of allRules) {
if (rule.id === 'missing-alt-text') continue
+ if (rule.defaultEnabled === false) continue
expect(ruleIds).toContain(rule.id)
}
} finally {
diff --git a/tests/extract.test.ts b/tests/extract.test.ts
index 30ea3f2..80db9d4 100644
--- a/tests/extract.test.ts
+++ b/tests/extract.test.ts
@@ -152,4 +152,45 @@ describe('extractImages', () => {
expect(images).toHaveLength(1)
})
})
+
+ describe('page title and section heading context', () => {
+ it('captures the page , normalized', async () => {
+ await page.setContent(
+ ` Acid Erosion of Rocks `,
+ )
+ const images = await extractImages(page)
+ expect(images[0]!.pageTitle).toBe('Acid Erosion of Rocks')
+ })
+
+ it('is null when the page has no title', async () => {
+ const images = await extractFromHTML(``)
+ expect(images[0]!.pageTitle).toBeNull()
+ })
+
+ it('captures the nearest heading preceding the image', async () => {
+ const images = await extractFromHTML(`
+
Page Heading
+
Puffins and Acid Erosion
+
+ `)
+ expect(images[0]!.sectionHeading).toBe('Puffins and Acid Erosion')
+ })
+
+ it('ignores headings that come after the image', async () => {
+ const images = await extractFromHTML(`
+
Before
+
+
After
+ `)
+ expect(images[0]!.sectionHeading).toBe('Before')
+ })
+
+ it('is null when no heading precedes the image', async () => {
+ const images = await extractFromHTML(`
+
+
After
+ `)
+ expect(images[0]!.sectionHeading).toBeNull()
+ })
+ })
})
diff --git a/tests/fixtures/alt-quality/cases-github.json b/tests/fixtures/alt-quality/cases-github.json
new file mode 100644
index 0000000..49cb17a
--- /dev/null
+++ b/tests/fixtures/alt-quality/cases-github.json
@@ -0,0 +1,205 @@
+[
+ {
+ "id": "ghdocs-repo-create-recommended",
+ "image": "https://docs.github.com/assets/cb-29762/mw-1440/images/help/repository/repo-create-global-nav-update.webp",
+ "alt": "Screenshot of a GitHub dropdown menu showing options to create new items. The menu item \"New repository\" is outlined in dark orange.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 1: Create a repository. Instruction text immediately before the image: \"In the upper-right corner of any page, select Create something new, then click New repository.\" The image is not inside a link. It illustrates which menu item to click.",
+ "expected": "ok"
+ },
+ {
+ "id": "ghdocs-repo-create-vague",
+ "image": "https://docs.github.com/assets/cb-29762/mw-1440/images/help/repository/repo-create-global-nav-update.webp",
+ "alt": "A menu.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 1: Create a repository. Instruction text immediately before the image: \"In the upper-right corner of any page, select Create something new, then click New repository.\" The image is not inside a link. It needs to convey which specific menu item is highlighted.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "ghdocs-repo-create-redundant-prefix",
+ "image": "https://docs.github.com/assets/cb-29762/mw-1440/images/help/repository/repo-create-global-nav-update.webp",
+ "alt": "Image of a GitHub dropdown menu showing options to create new items, with \"New repository\" outlined in dark orange.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 1: Create a repository. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "ghdocs-branch-dropdown-recommended",
+ "image": "https://docs.github.com/assets/cb-16584/mw-1440/images/help/branches/branch-selection-dropdown-global-nav-update.webp",
+ "alt": "Screenshot of the repository page. A dropdown menu, labeled with a branch icon and \"main\", is highlighted with an orange outline.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 2: Create a branch. Instruction text before the image: \"Above the file list, click the dropdown menu that says main.\" The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "ghdocs-branch-dropdown-mismatch",
+ "image": "https://docs.github.com/assets/cb-16584/mw-1440/images/help/branches/branch-selection-dropdown-global-nav-update.webp",
+ "alt": "Screenshot of the repository page with the Settings tab highlighted in orange.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 2: Create a branch. The image illustrates one step of the branch-creation instructions. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "ghdocs-new-branch-recommended",
+ "image": "https://docs.github.com/assets/cb-31023/mw-1440/images/help/repository/new-branch.webp",
+ "alt": "Screenshot of the branch dropdown for a repository. \"Create branch: readme-edits from 'main'\" is outlined in dark orange.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 2: Create a branch. Instruction before the image: \"Type a branch name, readme-edits, into the text box. Click Create branch: readme-edits from main.\" The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "ghdocs-new-branch-vague",
+ "image": "https://docs.github.com/assets/cb-31023/mw-1440/images/help/repository/new-branch.webp",
+ "alt": "A button to click.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 2: Create a branch. The image shows the branch dropdown with the \"Create branch: readme-edits from 'main'\" option highlighted. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "ghdocs-branching-diagram-recommended",
+ "image": "https://docs.github.com/assets/cb-23923/mw-1440/images/help/repository/branching.webp",
+ "alt": "Diagram of the two branches. The \"feature\" branch diverges from the \"main\" branch and is then merged back into main.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 2: Create a branch. Surrounding text explains that a feature branch is created off main, goes through \"Commit changes\", \"Submit pull request\", and \"Discuss proposed changes\", then is merged back into main. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "ghdocs-branching-diagram-mismatch",
+ "image": "https://docs.github.com/assets/cb-23923/mw-1440/images/help/repository/branching.webp",
+ "alt": "Diagram showing three separate branches running in parallel that are never merged together.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 2: Create a branch. A conceptual diagram illustrating how branching works. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "ghdocs-diff-recommended",
+ "image": "https://docs.github.com/assets/cb-32937/mw-1440/images/help/repository/diffs.webp",
+ "alt": "Screenshot of a diff for the README.md file. 3 red lines list the text that's being removed, and 3 green lines list the text being added.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 4: Open a pull request. Instruction before the image: \"Look over your changes in the diffs on the Compare page, make sure they're what you want to submit.\" The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "ghdocs-diff-mismatch",
+ "image": "https://docs.github.com/assets/cb-32937/mw-1440/images/help/repository/diffs.webp",
+ "alt": "Screenshot of a diff for the README.md file showing only added green lines with no removals.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 4: Open a pull request. Instruction near the image: \"Look over your changes in the diffs on the Compare page.\" The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "ghdocs-diff-vague",
+ "image": "https://docs.github.com/assets/cb-32937/mw-1440/images/help/repository/diffs.webp",
+ "alt": "A code diff.",
+ "context": "GitHub Docs \"Hello World\" tutorial, Step 4: Open a pull request. The image shows a README.md diff with 3 removed (red) and 3 added (green) lines. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "mdn-devtools-inspector-recommended",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/devtools_63_inspector.png",
+ "alt": "Screenshot of a browser with developer tools open. The web page is displayed in the top half of the browser, the developer tools occupy the bottom half. There are three panels open in the developer tools: HTML, with the body element selected, a CSS panel showing styles blocks targeting the highlighted body, and a computed styles panel showing all the author styles; the browser styles checkbox is not checked.",
+ "context": "MDN tutorial \"What are browser developer tools?\", section on opening the devtools. The surrounding text says the devtools live inside the browser in a subwindow. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "mdn-devtools-inspector-vague",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/devtools_63_inspector.png",
+ "alt": "Developer tools.",
+ "context": "MDN tutorial \"What are browser developer tools?\", section on opening the devtools. The image shows a browser with the HTML, CSS, and computed-styles panels open. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "mdn-debugger-source-recommended",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/source_code.png",
+ "alt": "Snippet of developer tools debugger panel with the breakpoint at line 18 highlighted.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript debugger section, illustrating how to set a breakpoint in the source pane. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "mdn-debugger-source-mismatch",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/source_code.png",
+ "alt": "Debugger panel with a breakpoint highlighted at line 42.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript debugger section, illustrating how to set a breakpoint in the source pane. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "mdn-console-recommended",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/console_only.png",
+ "alt": "The Console tab of the browser developer tools. Two JavaScript functions have been executed in the console. The user entered functions, and the console displayed the return values.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript console section. Surrounding text explains the console lets you run lines of JavaScript against the loaded page. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "mdn-console-mismatch",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/console_only.png",
+ "alt": "The Console tab of the developer tools showing two red JavaScript error messages.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript console section, showing the console after the user runs some JavaScript. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "mdn-console-vague",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/console_only.png",
+ "alt": "A console.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript console section. The image shows the Console tab with two executed functions and their return values. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "mdn-debugger-full-recommended",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/firefox_debugger.png",
+ "alt": "A test website that is served locally on port 8080. The developer tools sub-window is open. The JavaScript debugger tab is selected. It allows you to watch the value of variables and set breakpoints. A file with name 'example.js' is selected from the sources pane. A breakpoint is set at line number 18 of the file.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript debugger section. Surrounding text describes the debugger panes: file list, source code, and watch expressions/breakpoints. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "mdn-debugger-full-mismatch",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/firefox_debugger.png",
+ "alt": "The developer tools debugger with a file named 'index.html' selected and a breakpoint set at line 5.",
+ "context": "MDN tutorial \"What are browser developer tools?\", JavaScript debugger section, showing the debugger watching variables and breakpoints on a locally served test site. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "mdn-context-menu-recommended",
+ "image": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Tools_and_setup/What_are_browser_developer_tools/inspector_context.png",
+ "alt": "The Firefox logo as a DOM element in an example website with a context menu showing. A context menu appears when any item on the web page is right-clicked. The last menu item is 'Inspect element'.",
+ "context": "MDN tutorial \"What are browser developer tools?\", section on opening the devtools via the context menu. Surrounding text explains right-clicking an item and choosing Inspect Element. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-chart-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/chart.png",
+ "alt": "Bar chart showing monthly and total visitors for the first quarter 2025 for sites 1 to 3",
+ "context": "W3C WAI Images Tutorial, Complex Images example. A separate long description of the image is provided in adjacent text on the page, so the alt only needs to give a short identifying summary. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-chart-mismatch",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/chart.png",
+ "alt": "Pie chart showing annual revenue by region.",
+ "context": "W3C WAI Images Tutorial, Complex Images example. A separate long description of the image is provided in adjacent text on the page. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-chart-vague",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/chart.png",
+ "alt": "A chart.",
+ "context": "W3C WAI Images Tutorial, Complex Images. The image is a bar chart of quarterly website visitors for sites 1 to 3. A separate long description is provided adjacent to the image, but the short alt still needs to identify what the chart depicts. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-slogan-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/bad-top-text.png",
+ "alt": "City Lights: your access to the city.",
+ "context": "W3C WAI Images Tutorial, Images of Text. The image is a styled slogan with decorative text effects. Per guidance, the text alternative is the same as the slogan presented in the image; the decorative effects are not described. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-slogan-mismatch",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/bad-top-text.png",
+ "alt": "Welcome to the downtown shopping center.",
+ "context": "W3C WAI Images Tutorial, Images of Text example. The image is a styled slogan graphic; per guidance the text alternative should match the text shown in the image. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-math-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/repeat1.png",
+ "alt": "0.3333 recurring. (The recurrence is indicated by a line over the '3' in the fourth decimal place)",
+ "context": "W3C WAI Images Tutorial, Images of Text, mathematical expressions example. The image shows a recurring decimal; the recurrence is indicated by a line over a digit, which should be conveyed. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-math-mismatch",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/repeat1.png",
+ "alt": "0.6666 recurring, with a line over the final 6.",
+ "context": "W3C WAI Images Tutorial, Images of Text, mathematical expressions example. The image shows a recurring decimal; the recurrence is indicated by a line over a digit, which should be conveyed. The image is not inside a link.",
+ "expected": "needs-fix"
+ }
+]
diff --git a/tests/fixtures/alt-quality/cases.json b/tests/fixtures/alt-quality/cases.json
new file mode 100644
index 0000000..40e5d79
--- /dev/null
+++ b/tests/fixtures/alt-quality/cases.json
@@ -0,0 +1,359 @@
+[
+ {
+ "id": "webaim-ex1-recommended",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Astronaut Ellen Ochoa",
+ "context": "Body text near the image: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link. The image visually conveys both her identity and that she is an astronaut (her uniform); the body text names her but does not state she is an astronaut.",
+ "expected": "ok"
+ },
+ {
+ "id": "webaim-ex1-redundant-prefix",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Image of Ellen Ochoa, Astronaut",
+ "context": "Body text near the image: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex1-extra-info",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Ellen Ochoa, the first Hispanic woman to go into space",
+ "context": "Body text near the image: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex1-empty",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "",
+ "context": "Body text near the image: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link. The body text names her but does not state she is an astronaut; the uniform in the image conveys that.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex2-empty-correct",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "",
+ "context": "Adjacent caption text immediately above the image: \"Ellen Ochoa, Astronaut\". Body text below: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link. The image's content is fully captured by the adjacent caption.",
+ "expected": "ok"
+ },
+ {
+ "id": "webaim-ex2-vague",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Image",
+ "context": "Adjacent caption text immediately above the image: \"Ellen Ochoa, Astronaut\". Body text below: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link. The image's content is fully captured by the adjacent caption.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex2-redundant",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Ellen Ochoa",
+ "context": "Adjacent caption text immediately above the image: \"Ellen Ochoa, Astronaut\". Body text below: \"As the first Hispanic woman to go to space, and, later, the first Hispanic director of Johnson Space Center, Ellen Ochoa is widely regarded as a role model.\" The image is not inside a link. The image's content is fully captured by the adjacent caption.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex3-recommended-link",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Astronaut Ellen Ochoa",
+ "context": "The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The text \"Ellen Ochoa, Astronaut\" appears OUTSIDE the link, immediately after it. The link's accessible name comes entirely from the image's alt text.",
+ "expected": "ok"
+ },
+ {
+ "id": "webaim-ex3-read-more-link",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Read More",
+ "context": "The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The text \"Ellen Ochoa, Astronaut\" appears OUTSIDE the link, immediately after it. The link's accessible name comes entirely from the image's alt text.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex3-destination-info-link",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "Wikipedia entry for Ellen Ochoa, Astronaut",
+ "context": "The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The text \"Ellen Ochoa, Astronaut\" appears OUTSIDE the link, immediately after it. The link's accessible name comes entirely from the image's alt text.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex3-empty-link",
+ "image": "https://webaim.org/techniques/alttext/media/ellen-ochoa.jpg",
+ "alt": "",
+ "context": "The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The text \"Ellen Ochoa, Astronaut\" appears OUTSIDE the link, immediately after it. With empty alt, the link has no accessible name at all.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex5-empty-correct",
+ "image": "https://webaim.org/techniques/alttext/media/separator.gif",
+ "alt": "",
+ "context": "The image is a horizontal separator graphic placed between two document sections. The structural separation between sections is already conveyed by the page's headings and layout. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "webaim-ex5-decorative-line",
+ "image": "https://webaim.org/techniques/alttext/media/separator.gif",
+ "alt": "Decorative line",
+ "context": "The image is a horizontal separator graphic placed between two document sections. The structural separation between sections is already conveyed by the page's headings and layout. The image is not inside a link.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex5-separator",
+ "image": "https://webaim.org/techniques/alttext/media/separator.gif",
+ "alt": "Separator",
+ "context": "The image is a horizontal separator graphic placed between two document sections. The structural separation between sections is already conveyed by the page's headings and layout. The image is not inside a link.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex6-empty-correct",
+ "image": "https://webaim.org/techniques/alttext/media/handshake.jpg",
+ "alt": "",
+ "context": "Marketing page about customer service. Body text near the image: \"Our business promises the best service you will find on the planet. Our team is professionally trained to offer excellent customer service throughout the contract negotiation process. Customer satisfaction is our top priority and is guaranteed, or your money back.\" The image is generic stock photography that does not convey content unique to or important for this page.",
+ "expected": "ok"
+ },
+ {
+ "id": "webaim-ex6-handshake",
+ "image": "https://webaim.org/techniques/alttext/media/handshake.jpg",
+ "alt": "Handshake",
+ "context": "Marketing page about customer service. Body text near the image: \"Our business promises the best service you will find on the planet. Our team is professionally trained to offer excellent customer service throughout the contract negotiation process.\" The image is generic stock photography that does not convey content unique to or important for this page.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex6-verbose-description",
+ "image": "https://webaim.org/techniques/alttext/media/handshake.jpg",
+ "alt": "Businessmen shake hands to complete a contract",
+ "context": "Marketing page about customer service. Body text near the image: \"Our business promises the best service you will find on the planet. Our team is professionally trained to offer excellent customer service throughout the contract negotiation process.\" The image is generic stock photography that does not convey content unique to or important for this page.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex6-marketing-injection",
+ "image": "https://webaim.org/techniques/alttext/media/handshake.jpg",
+ "alt": "We guarantee our professional services",
+ "context": "Marketing page about customer service. Body text near the image: \"Our business promises the best service you will find on the planet. Our team is professionally trained to offer excellent customer service throughout the contract negotiation process.\" The image is generic stock photography that does not convey content unique to or important for this page.",
+ "expected": "decorative"
+ },
+ {
+ "id": "webaim-ex7-too-brief",
+ "image": "https://webaim.org/techniques/alttext/media/gw2.jpg",
+ "alt": "George Washington",
+ "context": "Body text near the image: \"In this painting, the artist Emanuel Leutze used light, color, form, perspective, proportion, and motion to create the composition.\" The image is not inside a link. The painting is widely known as 'Washington Crossing the Delaware'.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "webaim-ex7-recommended",
+ "image": "https://webaim.org/techniques/alttext/media/gw2.jpg",
+ "alt": "Painting of George Washington crossing the Delaware River",
+ "context": "Body text near the image: \"In this painting, the artist Emanuel Leutze used light, color, form, perspective, proportion, and motion to create the composition.\" The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "poet-context-mitosis-too-brief",
+ "image": "https://poet.bornaccessible.org/images/general01.jpg",
+ "alt": "Mitosis",
+ "context": "This illustration is found in the chapter summary of a biology textbook with the following question: \"Which of the following is not typically described as one of the four main stages of Mitosis? Where, in mitosis, is that process likely to be covered?\" To answer the question, the reader must identify the four main mitosis stages depicted in the image.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-context-mitosis-recommended",
+ "image": "https://poet.bornaccessible.org/images/general01.jpg",
+ "alt": "Mitosis stages: Prophase, Prometaphase, Metaphase, Anaphase, and Telophase",
+ "context": "This illustration is found in the chapter summary of a biology textbook with the following question: \"Which of the following is not typically described as one of the four main stages of Mitosis? Where, in mitosis, is that process likely to be covered?\" To answer the question, the reader must identify the four main mitosis stages depicted in the image.",
+ "expected": "ok"
+ },
+ {
+ "id": "poet-context-mitosis-excessive-details",
+ "image": "https://poet.bornaccessible.org/images/general01.jpg",
+ "alt": "Cells progressing through the stages of mitosis: Prophase is when chromatin become condensed; Prometaphase; Metaphase is the longest stage; Anaphase is the shortage stage; and Telophase is when two daughter nuclei form in the cell.",
+ "context": "This illustration is found in the chapter summary of a biology textbook with the following question: \"Which of the following is not typically described as one of the four main stages of Mitosis? Where, in mitosis, is that process likely to be covered?\" Per-stage details would lead the reader to the answer and inconsistent treatment of \"Prometaphase\" calls extra attention to it.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-audience-louvre-recommended",
+ "image": "https://poet.bornaccessible.org/images/general02.jpg",
+ "alt": "This is a photograph of the Louvre Museum in France at night. The entrance to the museum is a large pyramid made out of glass.",
+ "context": "Children's book describing the photograph of the Louvre Museum entrance (designed by architect I.M. Pei) to a young child. Vocabulary and references should match the audience's age and background knowledge.",
+ "expected": "ok"
+ },
+ {
+ "id": "poet-audience-louvre-too-complex",
+ "image": "https://poet.bornaccessible.org/images/general02.jpg",
+ "alt": "This is a night photograph of the Louvre Pyramids in front of the Louvre Palace. The juxtaposition of the architectural structures highlight I.M. Pei's proclivity for combining modern structures and classical forms.",
+ "context": "Children's book describing the photograph of the Louvre Museum entrance (designed by architect I.M. Pei) to a young child. Vocabulary and references should match the audience's age and background knowledge.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-audience-louvre-too-technical",
+ "image": "https://poet.bornaccessible.org/images/general02.jpg",
+ "alt": "This is a photograph of the Louvre Pyramids was taken at night. The low-angle shot, paired with indirect lighting of key architectural elements, makes for a dramatic composition.",
+ "context": "Children's book describing the photograph of the Louvre Museum entrance (designed by architect I.M. Pei) to a young child. Vocabulary and references should match the audience's age and background knowledge; photography-composition jargon is inappropriate.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-concise-solar-system-recommended",
+ "image": "https://poet.bornaccessible.org/images/general03.jpg",
+ "alt": "Figure 1.1",
+ "context": "Fifth-grade science textbook section about our solar system. The image has an adjacent caption immediately below it: \"Figure 1.1: The orbiting paths of the eight planets in our solar system around the sun - Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus and Neptune.\" The caption already provides the full description; the alt only needs to identify the figure for in-text references.",
+ "expected": "ok"
+ },
+ {
+ "id": "poet-concise-solar-system-redundant",
+ "image": "https://poet.bornaccessible.org/images/general03.jpg",
+ "alt": "The sun and planets make up our galaxy. The orbiting paths are represented by a dark-orange line for Mercury, yellow line for Venus, green line for Earth, red line for Mars, light-orange line for Jupiter, pastel-yellow line for Saturn, light-blue line for Uranus, and dark-blue line for Neptune.",
+ "context": "Fifth-grade science textbook section about our solar system. The image has an adjacent caption immediately below it: \"Figure 1.1: The orbiting paths of the eight planets in our solar system around the sun - Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus and Neptune.\" The caption already names all eight planets; the line colors are arbitrary visual styling and not meaningful.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-concise-solar-system-extra-info",
+ "image": "https://poet.bornaccessible.org/images/general03.jpg",
+ "alt": "The sun and planets are in order of their distance from the sun. Pluto, previously known as the ninth planet in our solar system, is now considered a dwarf planet. This declassification took place in August of 2006, and Pluto's orbit is represented by a purple line.",
+ "context": "Fifth-grade science textbook section about our solar system. The image has an adjacent caption immediately below it: \"Figure 1.1: The orbiting paths of the eight planets in our solar system around the sun - Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus and Neptune.\" Image descriptions should not introduce concepts not present in the surrounding text.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-objective-puffins-omits-key-detail",
+ "image": "https://poet.bornaccessible.org/images/general04.jpg",
+ "alt": "A close-up of a puffin, which is a fluffy, black-and-white bird with bright orange feet and a colorful beak comprised by wide bands of red, yellow, and black stripes.",
+ "context": "Geology website section discussing events that lead to the acid erosion of rock formations over time. The article identifies bird droppings as a contributor to acid erosion. The image clearly shows the puffin defecating on the rock; that defecation is the educationally relevant content.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-objective-puffins-subjective",
+ "image": "https://poet.bornaccessible.org/images/general04.jpg",
+ "alt": "A cute puffin doing his business on an earthy, red rock.",
+ "context": "Geology website section discussing events that lead to the acid erosion of rock formations over time. The article identifies bird droppings as a contributor to acid erosion. Descriptions should be objective and avoid aesthetic judgments or euphemisms.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-objective-puffins-recommended",
+ "image": "https://poet.bornaccessible.org/images/general04.jpg",
+ "alt": "A puffin bird with white liquid projecting from its tail end stands on a rocky mound covered by white excrements.",
+ "context": "Geology website section discussing events that lead to the acid erosion of rock formations over time. The article identifies bird droppings as a contributor to acid erosion.",
+ "expected": "ok"
+ },
+ {
+ "id": "poet-general-to-specific-map-too-simple",
+ "image": "https://poet.bornaccessible.org/images/general05.jpg",
+ "alt": "Artistic depiction of a US map.",
+ "context": "Art history book section introducing the concept of using recycled materials to produce art. The image shows a map of the United States assembled out of cut-up recycled license plates from each respective state.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-general-to-specific-map-recommended",
+ "image": "https://poet.bornaccessible.org/images/general05.jpg",
+ "alt": "A map of the United States made out of recycled license plates. This \"recycled map\" is about five feet wide, with each state represented by license plate cutouts from the respective state. Large states such as Texas and California are made of one or more colorful metal license plates while smaller New England states are represented by just a few inches of their license plates.",
+ "context": "Art history book section introducing the concept of using recycled materials to produce art. The image shows a map of the United States assembled out of cut-up recycled license plates from each respective state.",
+ "expected": "ok"
+ },
+ {
+ "id": "poet-general-to-specific-map-no-overview",
+ "image": "https://poet.bornaccessible.org/images/general05.jpg",
+ "alt": "California is made from two white license plates, each including a setting sun. Arizona is made from one red license plate with an off-white cactus on it. (The description would continue to describe each individual license plate.) This is an artistic depiction of a map of the United States.",
+ "context": "Art history book section introducing the concept of using recycled materials to produce art. The image shows a map of the United States assembled out of cut-up recycled license plates from each respective state. Descriptions should orient the reader with a high-level overview before diving into details.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-tone-grand-canyon-misused-vocab",
+ "image": "https://poet.bornaccessible.org/images/general06.jpg",
+ "alt": "An auspicious photograph of the Grand Canyon. A blue sky peaks through grey clouds, casting an glow across Arizona.",
+ "context": "Book about national parks in the United States. The photograph was taken in early September during Arizona's monsoon season, immediately after a violent storm described in the surrounding text. The image shows only a portion of the Grand Canyon, not all of Arizona.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-tone-grand-canyon-abbreviation",
+ "image": "https://poet.bornaccessible.org/images/general06.jpg",
+ "alt": "An image of a blue sky peeking through grey storm clouds over the Grand Canyon in early Sept.",
+ "context": "Book about national parks in the United States. The photograph was taken in early September during Arizona's monsoon season, immediately after a violent storm described in the surrounding text. Screen readers may announce abbreviations like \"Sept\" literally rather than expanding them.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "poet-tone-grand-canyon-recommended",
+ "image": "https://poet.bornaccessible.org/images/general06.jpg",
+ "alt": "A photograph of a blue sky peeking through grey storm clouds over the Grand Canyon in early September.",
+ "context": "Book about national parks in the United States. The photograph was taken in early September during Arizona's monsoon season, immediately after a violent storm described in the surrounding text.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-complex-chart-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/chart.png",
+ "alt": "Bar chart showing monthly and total visitors for the first quarter 2025 for sites 1 to 3",
+ "context": "A bar chart on a quarterly analytics report page. Immediately adjacent to the image is a text link reading \"Example.com Site visitors Jan to March 2025 text description of the bar chart\" that points to a separate long-description page containing the full underlying data table and trend analysis. The image is not inside a link.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-complex-chart-too-vague",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/chart.png",
+ "alt": "Chart",
+ "context": "A bar chart on a quarterly analytics report page. Immediately adjacent to the image is a text link reading \"Example.com Site visitors Jan to March 2025 text description of the bar chart\" that points to a separate long-description page containing the full underlying data table and trend analysis. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-complex-chart-no-quarter",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/chart.png",
+ "alt": "Bar chart",
+ "context": "A bar chart on a quarterly analytics report page. Immediately adjacent to the image is a text link reading \"Example.com Site visitors Jan to March 2025 text description of the bar chart\" that points to a separate long-description page containing the full underlying data table and trend analysis. The image is not inside a link.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-functional-logo-link-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/w3c.png",
+ "alt": "W3C home",
+ "context": "The image is the only content inside a link pointing to https://www.w3.org/ (the W3C home page). The image is the W3C logo (the letters 'W3C' rendered as an image of text). The link's accessible name comes entirely from the image's alt text.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-functional-logo-link-describes-image",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/w3c.png",
+ "alt": "W3C logo",
+ "context": "The image is the only content inside a link pointing to https://www.w3.org/ (the W3C home page). The image is the W3C logo. The link's accessible name comes entirely from the image's alt text.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-functional-logo-supplementing-link-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/w3c.png",
+ "alt": "",
+ "context": "The image is the W3C logo. It is inside a link pointing to https://www.w3.org/, and the visible text \"W3C Home\" appears within the same link immediately next to the image. The link's accessible name is provided by the visible text \"W3C Home\".",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-functional-logo-supplementing-link-redundant",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/w3c.png",
+ "alt": "W3C Home",
+ "context": "The image is the W3C logo. It is inside a link pointing to https://www.w3.org/, and the visible text \"W3C Home\" appears within the same link immediately next to the image. The link already has an accessible name from the visible text; the image's alt text duplicates that visible text.",
+ "expected": "decorative"
+ },
+ {
+ "id": "w3c-functional-print-icon-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/print.png",
+ "alt": "Print this page",
+ "context": "The image is a printer icon that is the only content inside a link/button whose function is to open the browser's print dialog. The image's alt text provides the entire accessible name for the control.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-functional-print-icon-describes-image",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/print.png",
+ "alt": "Printer",
+ "context": "The image is a printer icon that is the only content inside a link/button whose function is to open the browser's print dialog. The image's alt text provides the entire accessible name for the control.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-functional-search-button-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/searchbutton.png",
+ "alt": "Search",
+ "context": "The image is a magnifying-glass icon used as a submit button on a search form (an HTML ). The image's alt text provides the entire accessible name for the button. Activating the button submits the search.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-functional-search-button-describes-image",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/searchbutton.png",
+ "alt": "Magnifying glass",
+ "context": "The image is a magnifying-glass icon used as a submit button on a search form (an HTML ). The image's alt text provides the entire accessible name for the button. Activating the button submits the search.",
+ "expected": "needs-fix"
+ },
+ {
+ "id": "w3c-textual-unlinked-logo-recommended",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/wai.png",
+ "alt": "Web Accessibility Initiative",
+ "context": "The image is the Web Accessibility Initiative (WAI) logo, which is an image of styled text reading \"Web Accessibility Initiative\". The image is NOT inside a link. It appears in the page header as a brand mark, and no nearby text repeats the organization's full name.",
+ "expected": "ok"
+ },
+ {
+ "id": "w3c-textual-unlinked-logo-with-logo-suffix",
+ "image": "https://www.w3.org/WAI/content-images/tutorials/images/wai.png",
+ "alt": "Web Accessibility Initiative logo",
+ "context": "The image is the Web Accessibility Initiative (WAI) logo, which is an image of styled text reading \"Web Accessibility Initiative\". The image is NOT inside a link. It appears in the page header as a brand mark, and no nearby text repeats the organization's full name. Per W3C: for images used as logos, there is no need to mention in the alt text that the image is a logo.",
+ "expected": "needs-fix"
+ }
+]
diff --git a/tests/unit/alt-text-quality.test.ts b/tests/unit/alt-text-quality.test.ts
new file mode 100644
index 0000000..d179fa8
--- /dev/null
+++ b/tests/unit/alt-text-quality.test.ts
@@ -0,0 +1,128 @@
+import {describe, it, expect, afterEach, vi} from 'vitest'
+import {altTextQuality, __setJudge} from '../../src/rules/alt-text-quality.js'
+import type {JudgeAltText, JudgeInput, JudgeVerdict} from '../../src/judges/index.js'
+import type {ImageRecord, RuleResult} from '../../src/types.js'
+import {makeImage} from '../utils/helpers.js'
+
+// A data: URL is returned unchanged by loadImageAsDataUrl, so using one as the
+// image src keeps these tests fully offline.
+const DATA_URL = 'data:image/png;base64,iVBORw0KGgo='
+
+// Records every input it receives and returns whatever the responder produces,
+// letting each test drive the rule with a scripted verdict (or a thrown error).
+class FakeJudge implements JudgeAltText {
+ readonly calls: JudgeInput[] = []
+ constructor(private readonly responder: (input: JudgeInput) => JudgeVerdict) {}
+ async judge(input: JudgeInput): Promise {
+ this.calls.push(input)
+ return this.responder(input)
+ }
+}
+
+function verdict(overrides: Partial = {}): JudgeVerdict {
+ return {step: 4, reasoning: 'reasoning', verdict: 'ok', issue: '', confidence: 0.9, ...overrides}
+}
+
+async function run(images: ImageRecord[]): Promise {
+ return (await altTextQuality.evaluate({url: 'https://example.com', images})) as RuleResult[]
+}
+
+describe('alt-text-quality', () => {
+ afterEach(() => {
+ __setJudge(null)
+ vi.restoreAllMocks()
+ })
+
+ it('is opt-in (disabled by default)', () => {
+ expect(altTextQuality.defaultEnabled).toBe(false)
+ })
+
+ it('produces no finding for an "ok" verdict', async () => {
+ __setJudge(new FakeJudge(() => verdict({verdict: 'ok'})))
+ const results = await run([makeImage({src: DATA_URL, alt: 'A dog playing in the park'})])
+ expect(results).toHaveLength(0)
+ })
+
+ it('maps a "needs-fix" verdict to a finding with the issue, alt, and reasoning', async () => {
+ __setJudge(
+ new FakeJudge(() => verdict({verdict: 'needs-fix', issue: 'redundant-prefix', reasoning: 'Drop the prefix.'})),
+ )
+ const results = await run([makeImage({src: DATA_URL, alt: 'Image of a dog'})])
+ expect(results).toHaveLength(1)
+ expect(results[0]!.problemShort).toContain('redundant-prefix')
+ expect(results[0]!.problemShort).toContain('Image of a dog')
+ expect(results[0]!.solutionLong).toBe('Drop the prefix.')
+ })
+
+ it('maps a "decorative" verdict to an empty-alt recommendation', async () => {
+ __setJudge(new FakeJudge(() => verdict({verdict: 'decorative', reasoning: 'It is a spacer.'})))
+ const results = await run([makeImage({src: DATA_URL, alt: 'horizontal spacer'})])
+ expect(results).toHaveLength(1)
+ expect(results[0]!.problemShort).toContain('decorative')
+ expect(results[0]!.solutionShort).toContain('alt=""')
+ })
+
+ it('skips images with alt === null without calling the judge', async () => {
+ const fake = new FakeJudge(() => verdict())
+ __setJudge(fake)
+ const results = await run([makeImage({src: DATA_URL, alt: null})])
+ expect(results).toHaveLength(0)
+ expect(fake.calls).toHaveLength(0)
+ })
+
+ it('skips images with no src without calling the judge', async () => {
+ const fake = new FakeJudge(() => verdict({verdict: 'needs-fix'}))
+ __setJudge(fake)
+ const results = await run([makeImage({src: '', alt: 'a dog'})])
+ expect(results).toHaveLength(0)
+ expect(fake.calls).toHaveLength(0)
+ })
+
+ it('isolates per-image judge failures and keeps evaluating the rest', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const fake = new FakeJudge(input => {
+ if (input.alt === 'boom') throw new Error('judge failed')
+ return verdict({verdict: 'needs-fix', issue: 'vague'})
+ })
+ __setJudge(fake)
+ const results = await run([makeImage({src: DATA_URL, alt: 'boom'}), makeImage({src: DATA_URL, alt: 'a dog'})])
+ expect(results).toHaveLength(1)
+ expect(results[0]!.image.alt).toBe('a dog')
+ })
+
+ it('passes intrinsic image dimensions through to the judge', async () => {
+ const fake = new FakeJudge(() => verdict())
+ __setJudge(fake)
+ await run([makeImage({src: DATA_URL, alt: 'a dog', naturalWidth: 800, naturalHeight: 600})])
+ expect(fake.calls[0]!.naturalWidth).toBe(800)
+ expect(fake.calls[0]!.naturalHeight).toBe(600)
+ })
+
+ it('includes page title and section heading in the judge context', async () => {
+ const fake = new FakeJudge(() => verdict())
+ __setJudge(fake)
+ await run([
+ makeImage({src: DATA_URL, alt: 'a dog', pageTitle: 'Dogs of the World', sectionHeading: 'Working Breeds'}),
+ ])
+ expect(fake.calls[0]!.context).toContain('Dogs of the World')
+ expect(fake.calls[0]!.context).toContain('Working Breeds')
+ })
+
+ it('strips src/srcset values from the image HTML in the judge context', async () => {
+ const fake = new FakeJudge(() => verdict())
+ __setJudge(fake)
+ await run([
+ makeImage({
+ src: DATA_URL,
+ alt: 'a dog',
+ outerHTML:
+ '',
+ }),
+ ])
+ const context = fake.calls[0]!.context
+ expect(context).not.toContain('sig=abc123')
+ expect(context).not.toContain('sig=def456')
+ expect(context).toContain('src="(omitted)"')
+ expect(context).toContain('srcset="(omitted)"')
+ })
+})
diff --git a/tests/unit/judges-caching.test.ts b/tests/unit/judges-caching.test.ts
new file mode 100644
index 0000000..cd80b7a
--- /dev/null
+++ b/tests/unit/judges-caching.test.ts
@@ -0,0 +1,141 @@
+import {describe, it, expect} from 'vitest'
+import {CachingJudge, CachingVisionClient} from '../../src/judges/caching.js'
+import type {AzureVisionAnalysis, AzureVisionClient} from '../../src/judges/index.js'
+import type {JudgeAltText, JudgeInput, JudgeVerdict} from '../../src/judges/index.js'
+
+const IMG_A = 'data:image/png;base64,iVBORw0KGgoAAAA='
+const IMG_B = 'data:image/png;base64,Zm9vYmFyYmF6Cg=='
+
+function verdict(overrides: Partial = {}): JudgeVerdict {
+ return {step: 4, reasoning: 'reasoning', verdict: 'ok', issue: '', confidence: 0.9, ...overrides}
+}
+
+function input(overrides: Partial = {}): JudgeInput {
+ return {imageDataUrl: IMG_A, alt: 'a dog', context: 'Page URL: https://example.com', ...overrides}
+}
+
+// Counts how many times the inner judge actually runs.
+class CountingJudge implements JudgeAltText {
+ calls = 0
+ constructor(private readonly responder: (input: JudgeInput) => JudgeVerdict = () => verdict()) {}
+ async judge(input: JudgeInput): Promise {
+ this.calls++
+ return this.responder(input)
+ }
+}
+
+// Counts how many times the inner vision client actually runs.
+class CountingVisionClient implements AzureVisionClient {
+ calls = 0
+ constructor(private readonly responder: () => AzureVisionAnalysis = () => ({readText: 'text'})) {}
+ async analyze(): Promise {
+ this.calls++
+ return this.responder()
+ }
+}
+
+describe('CachingJudge', () => {
+ it('judges an identical (image, alt, context) tuple only once', async () => {
+ const inner = new CountingJudge()
+ const judge = new CachingJudge(inner)
+
+ const first = await judge.judge(input())
+ const second = await judge.judge(input())
+
+ expect(inner.calls).toBe(1)
+ expect(second).toEqual(first)
+ })
+
+ it('re-judges when the context differs', async () => {
+ const inner = new CountingJudge()
+ const judge = new CachingJudge(inner)
+
+ await judge.judge(input({context: 'Page URL: https://a.example'}))
+ await judge.judge(input({context: 'Page URL: https://b.example'}))
+
+ expect(inner.calls).toBe(2)
+ })
+
+ it('re-judges when the alt text differs', async () => {
+ const inner = new CountingJudge()
+ const judge = new CachingJudge(inner)
+
+ await judge.judge(input({alt: 'a dog'}))
+ await judge.judge(input({alt: 'a cat'}))
+
+ expect(inner.calls).toBe(2)
+ })
+
+ it('re-judges when the image bytes differ', async () => {
+ const inner = new CountingJudge()
+ const judge = new CachingJudge(inner)
+
+ await judge.judge(input({imageDataUrl: IMG_A}))
+ await judge.judge(input({imageDataUrl: IMG_B}))
+
+ expect(inner.calls).toBe(2)
+ })
+
+ it('returns the cached verdict on a hit', async () => {
+ const inner = new CountingJudge(() => verdict({verdict: 'needs-fix', issue: 'vague'}))
+ const judge = new CachingJudge(inner)
+
+ const hit = await judge.judge(input())
+ const cached = await judge.judge(input())
+
+ expect(cached.verdict).toBe('needs-fix')
+ expect(cached).toEqual(hit)
+ })
+
+ it('does not cache a thrown error', async () => {
+ let attempt = 0
+ const inner = new CountingJudge(() => {
+ attempt++
+ if (attempt === 1) throw new Error('transient')
+ return verdict()
+ })
+ const judge = new CachingJudge(inner)
+
+ await expect(judge.judge(input())).rejects.toThrow('transient')
+ await expect(judge.judge(input())).resolves.toEqual(verdict())
+ expect(inner.calls).toBe(2)
+ })
+})
+
+describe('CachingVisionClient', () => {
+ it('analyzes identical image bytes only once', async () => {
+ const inner = new CountingVisionClient()
+ const client = new CachingVisionClient(inner)
+
+ const first = await client.analyze(IMG_A)
+ const second = await client.analyze(IMG_A)
+
+ expect(inner.calls).toBe(1)
+ expect(second).toEqual(first)
+ })
+
+ it('re-analyzes when the image bytes differ', async () => {
+ const inner = new CountingVisionClient()
+ const client = new CachingVisionClient(inner)
+
+ await client.analyze(IMG_A)
+ await client.analyze(IMG_B)
+
+ expect(inner.calls).toBe(2)
+ })
+
+ it('does not cache a thrown error', async () => {
+ let attempt = 0
+ const inner: AzureVisionClient = {
+ async analyze(): Promise {
+ attempt++
+ if (attempt === 1) throw new Error('azure down')
+ return {readText: 'text'}
+ },
+ }
+ const client = new CachingVisionClient(inner)
+
+ await expect(client.analyze(IMG_A)).rejects.toThrow('azure down')
+ await expect(client.analyze(IMG_A)).resolves.toEqual({readText: 'text'})
+ })
+})
diff --git a/tests/unit/load-image-data-url.test.ts b/tests/unit/load-image-data-url.test.ts
new file mode 100644
index 0000000..c2c914b
--- /dev/null
+++ b/tests/unit/load-image-data-url.test.ts
@@ -0,0 +1,37 @@
+import {Buffer} from 'node:buffer'
+import {describe, it, expect} from 'vitest'
+import {loadImageAsDataUrl} from '../../src/utils/load-image-data-url.js'
+
+function decodeBase64DataUrl(dataUrl: string): {mime: string; bytes: Buffer} {
+ const match = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl)
+ if (!match) throw new Error(`not a base64 data URL: ${dataUrl}`)
+ return {mime: match[1]!, bytes: Buffer.from(match[2]!, 'base64')}
+}
+
+describe('loadImageAsDataUrl — data: URLs', () => {
+ it('returns an existing base64 data URL unchanged', async () => {
+ const input = 'data:image/png;base64,iVBORw0KGgo='
+ expect(await loadImageAsDataUrl(input)).toBe(input)
+ })
+
+ it('normalizes a percent-encoded SVG data URL to base64', async () => {
+ const svg = ''
+ const input = `data:image/svg+xml,${encodeURIComponent(svg)}`
+
+ const out = await loadImageAsDataUrl(input)
+ const {mime, bytes} = decodeBase64DataUrl(out)
+
+ expect(mime).toBe('image/svg+xml')
+ expect(bytes.toString('utf8')).toBe(svg)
+ })
+
+ it('normalizes a plain (non-encoded) data URL to base64', async () => {
+ const input = 'data:text/plain,hello world'
+
+ const out = await loadImageAsDataUrl(input)
+ const {mime, bytes} = decodeBase64DataUrl(out)
+
+ expect(mime).toBe('text/plain')
+ expect(bytes.toString('utf8')).toBe('hello world')
+ })
+})
diff --git a/tests/unit/missing-alt-text.test.ts b/tests/unit/missing-alt-text.test.ts
index 08f05c2..06dafea 100644
--- a/tests/unit/missing-alt-text.test.ts
+++ b/tests/unit/missing-alt-text.test.ts
@@ -20,12 +20,12 @@ describe('missing-alt-text', () => {
expect(evaluateAlts([' '], missingAltText)).toHaveLength(1)
})
- it('returns one result per missing-alt image and preserves order', () => {
+ it('returns one result per missing-alt image and preserves order', async () => {
const context: RuleContext = {
url: 'https://example.com',
images: [null, 'a dog', null, '', null].map((alt, i) => makeImage({alt, src: `image-${i}.png`})),
}
- const results = missingAltText.evaluate(context)
+ const results = await missingAltText.evaluate(context)
expect(results).toHaveLength(3)
expect(results.map(r => r.image.src)).toEqual(['image-0.png', 'image-2.png', 'image-4.png'])
})
diff --git a/tests/unit/vague-alt-text.test.ts b/tests/unit/vague-alt-text.test.ts
index e15f88e..5fdef7d 100644
--- a/tests/unit/vague-alt-text.test.ts
+++ b/tests/unit/vague-alt-text.test.ts
@@ -81,7 +81,7 @@ describe('vagueAltText', () => {
})
describe('output shape', () => {
- it('returns one result per offending image, and preserves the image reference', () => {
+ it('returns one result per offending image, and preserves the image reference', async () => {
const context: RuleContext = {
url: 'example website',
images: [
@@ -90,7 +90,7 @@ describe('vagueAltText', () => {
makeImage({alt: 'photo', src: 'c.png'}),
],
}
- const results = vagueAltText.evaluate(context)
+ const results = await vagueAltText.evaluate(context)
expect(results).toHaveLength(2)
expect(results.map(r => r.image.src)).toEqual(['a.png', 'c.png'])
})
diff --git a/tests/utils/helpers.ts b/tests/utils/helpers.ts
index 37d1768..550c4dd 100644
--- a/tests/utils/helpers.ts
+++ b/tests/utils/helpers.ts
@@ -14,6 +14,14 @@ export function makeImage(overrides: Partial = {}): ImageRecord {
ariaLabelledBy: null,
outerHTML: '',
boundingBox: null,
+ naturalWidth: 0,
+ naturalHeight: 0,
+ inLink: null,
+ inButton: false,
+ figcaption: null,
+ nearbyText: null,
+ pageTitle: null,
+ sectionHeading: null,
...overrides,
}
}
@@ -21,13 +29,17 @@ export function makeImage(overrides: Partial = {}): ImageRecord {
/**
* Wraps each alt string in an ImageRecord and runs the given rule against
* the resulting set. Returns the rule's findings.
+ *
+ * Helper is for synchronous rules only — it asserts the rule returned an
+ * array, not a Promise. Async rules (e.g. alt-text-quality) provide their
+ * own test scaffolding.
*/
export function evaluateAlts(alts: (string | null)[], rule: Rule): RuleResult[] {
const context: RuleContext = {
url: 'https://example.com',
images: alts.map(alt => makeImage({alt})),
}
- return rule.evaluate(context)
+ return runSync(rule, context)
}
/**
@@ -35,5 +47,15 @@ export function evaluateAlts(alts: (string | null)[], rule: Rule): RuleResult[]
* test needs control over fields beyond `alt` (e.g. boundingBox).
*/
export function evaluateImages(images: ImageRecord[], rule: Rule): RuleResult[] {
- return rule.evaluate({url: 'https://example.com', images})
+ return runSync(rule, {url: 'https://example.com', images})
+}
+
+function runSync(rule: Rule, context: RuleContext): RuleResult[] {
+ const result = rule.evaluate(context)
+ if (result instanceof Promise) {
+ throw new Error(
+ `[test helper] rule "${rule.id}" returned a Promise; evaluateAlts/evaluateImages support sync rules only`,
+ )
+ }
+ return result
}
diff --git a/tsconfig.json b/tsconfig.json
index fa0e524..bad082c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,5 +15,5 @@
"isolatedModules": true,
"noEmit": true
},
- "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"]
+ "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
}