a11y-fixer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. a11y_fixer-0.1.0/CHANGELOG.md +85 -0
  2. a11y_fixer-0.1.0/LICENSE +21 -0
  3. a11y_fixer-0.1.0/MANIFEST.in +6 -0
  4. a11y_fixer-0.1.0/PKG-INFO +364 -0
  5. a11y_fixer-0.1.0/README.md +328 -0
  6. a11y_fixer-0.1.0/a11y.toml.example +30 -0
  7. a11y_fixer-0.1.0/a11y_fixer.egg-info/PKG-INFO +364 -0
  8. a11y_fixer-0.1.0/a11y_fixer.egg-info/SOURCES.txt +56 -0
  9. a11y_fixer-0.1.0/a11y_fixer.egg-info/dependency_links.txt +1 -0
  10. a11y_fixer-0.1.0/a11y_fixer.egg-info/entry_points.txt +3 -0
  11. a11y_fixer-0.1.0/a11y_fixer.egg-info/requires.txt +13 -0
  12. a11y_fixer-0.1.0/a11y_fixer.egg-info/top_level.txt +2 -0
  13. a11y_fixer-0.1.0/a11y_fixer.py +1674 -0
  14. a11y_fixer-0.1.0/a11y_pr.py +220 -0
  15. a11y_fixer-0.1.0/evals/README.md +86 -0
  16. a11y_fixer-0.1.0/evals/fixtures/already-accessible.expected.json +8 -0
  17. a11y_fixer-0.1.0/evals/fixtures/already-accessible.input.jsx +15 -0
  18. a11y_fixer-0.1.0/evals/fixtures/anchor-no-content.expected.json +9 -0
  19. a11y_fixer-0.1.0/evals/fixtures/anchor-no-content.input.jsx +4 -0
  20. a11y_fixer-0.1.0/evals/fixtures/anchor-no-href.expected.json +7 -0
  21. a11y_fixer-0.1.0/evals/fixtures/anchor-no-href.input.jsx +7 -0
  22. a11y_fixer-0.1.0/evals/fixtures/decorative-img.expected.json +10 -0
  23. a11y_fixer-0.1.0/evals/fixtures/decorative-img.input.jsx +3 -0
  24. a11y_fixer-0.1.0/evals/fixtures/div-as-button.expected.json +10 -0
  25. a11y_fixer-0.1.0/evals/fixtures/div-as-button.input.jsx +10 -0
  26. a11y_fixer-0.1.0/evals/fixtures/handler-drop-risk.expected.json +6 -0
  27. a11y_fixer-0.1.0/evals/fixtures/handler-drop-risk.input.jsx +10 -0
  28. a11y_fixer-0.1.0/evals/fixtures/iframe-no-title.expected.json +10 -0
  29. a11y_fixer-0.1.0/evals/fixtures/iframe-no-title.input.jsx +3 -0
  30. a11y_fixer-0.1.0/evals/fixtures/img-no-alt.expected.json +10 -0
  31. a11y_fixer-0.1.0/evals/fixtures/img-no-alt.input.jsx +3 -0
  32. a11y_fixer-0.1.0/evals/fixtures/injection-in-comment.expected.json +10 -0
  33. a11y_fixer-0.1.0/evals/fixtures/injection-in-comment.input.jsx +8 -0
  34. a11y_fixer-0.1.0/evals/fixtures/injection-in-text.expected.json +7 -0
  35. a11y_fixer-0.1.0/evals/fixtures/injection-in-text.input.jsx +14 -0
  36. a11y_fixer-0.1.0/evals/fixtures/invalid-aria-prop.expected.json +11 -0
  37. a11y_fixer-0.1.0/evals/fixtures/invalid-aria-prop.input.jsx +16 -0
  38. a11y_fixer-0.1.0/evals/fixtures/label-no-control.expected.json +9 -0
  39. a11y_fixer-0.1.0/evals/fixtures/label-no-control.input.jsx +13 -0
  40. a11y_fixer-0.1.0/evals/fixtures/mouse-only-handlers.expected.json +7 -0
  41. a11y_fixer-0.1.0/evals/fixtures/mouse-only-handlers.input.jsx +11 -0
  42. a11y_fixer-0.1.0/evals/fixtures/positive-tabindex.expected.json +8 -0
  43. a11y_fixer-0.1.0/evals/fixtures/positive-tabindex.input.jsx +11 -0
  44. a11y_fixer-0.1.0/evals/fixtures/redundant-role.expected.json +11 -0
  45. a11y_fixer-0.1.0/evals/fixtures/redundant-role.input.jsx +12 -0
  46. a11y_fixer-0.1.0/evals/run.py +380 -0
  47. a11y_fixer-0.1.0/lint/.gitignore +2 -0
  48. a11y_fixer-0.1.0/lint/axe.mjs +138 -0
  49. a11y_fixer-0.1.0/lint/eslint.config.mjs +19 -0
  50. a11y_fixer-0.1.0/lint/lint.mjs +49 -0
  51. a11y_fixer-0.1.0/lint/package-lock.json +3908 -0
  52. a11y_fixer-0.1.0/lint/package.json +19 -0
  53. a11y_fixer-0.1.0/pyproject.toml +57 -0
  54. a11y_fixer-0.1.0/requirements.txt +4 -0
  55. a11y_fixer-0.1.0/setup.cfg +4 -0
  56. a11y_fixer-0.1.0/tests/test_a11y_fixer.py +1419 -0
  57. a11y_fixer-0.1.0/tests/test_a11y_pr.py +184 -0
  58. a11y_fixer-0.1.0/tests/test_evals.py +235 -0
@@ -0,0 +1,85 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project aims to
5
+ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - `--fail-on STATUSES` — narrow the exit-1 gate to specific outcomes (e.g.
12
+ `invalid`, or `would-fix,invalid,residual-violations`). Overrides the `--check`
13
+ gate; a file that errored still exits 1.
14
+ - Eval harness `--axe` — renders each opted-in fixture's fixed component to
15
+ static markup and runs axe-core against it in jsdom (`lint/axe.mjs`), so a
16
+ fixture can assert the fix is actually clean to assistive tech, not just to
17
+ the linter. Fixtures opt in with `expect_axe_clean` / `axe_props`; adds an
18
+ `axe_clean_rate` metric.
19
+ - Eval corpus grown from 7 to 15 fixtures: `invalid-aria-prop`, `iframe-no-title`,
20
+ `anchor-no-content`, `redundant-role`, `mouse-only-handlers`, a second
21
+ false-positive guard `decorative-img` (a correct empty `alt=""` the model must
22
+ not "helpfully" fill in), and two `prompt-injection` fixtures that verify
23
+ override instructions embedded in a JS comment or in rendered JSX text don't
24
+ steer the model off its task (SYSTEM_PROMPT constraint #7).
25
+
26
+ ### Changed
27
+
28
+ - `--workers > 1` now shares a single rate-limit cooldown across all worker
29
+ threads: a provider 429 pauses the whole pool (with jitter) instead of each
30
+ thread running its own blind backoff into the same quota. Single-worker runs
31
+ are unchanged.
32
+
33
+ ### Fixed
34
+
35
+ - The sdist no longer vendors `lint/node_modules/` (a stray `*.json` glob in
36
+ `MANIFEST.in` was pulling in every nested `package.json`). It ships only the
37
+ runner scripts, `package.json`, and `package-lock.json`; run `npm install` in
38
+ `lint/` after install as before.
39
+
40
+ ## [0.1.0] - 2026-09-08
41
+
42
+ First tagged release. The agent went from a "trust the model and write to disk"
43
+ script to a checked-remediation tool with CI, caching, and an eval harness.
44
+
45
+ ### Added
46
+
47
+ - **Structural safety gate** — every fix is parsed as JSX/TSX (tree-sitter) and
48
+ compared against a fingerprint of the original (event handlers, hook calls,
49
+ identifiers). A fix that fails to parse or drops behavior is rejected as
50
+ `invalid`, never written.
51
+ - **Repair pass** — a rejected fix is re-prompted once with the specific reason
52
+ (`--repair-attempts`, default 1) before being reported.
53
+ - **`--lint` feedback loop** — bundled `eslint-plugin-jsx-a11y` runs in-process,
54
+ its violations are fed into the prompt, and the model's output is re-linted
55
+ (`--lint-rounds`, default 2). Lint-clean files skip the model entirely.
56
+ - **CI modes** — `--check` (exit 1 on any residual issue, never writes),
57
+ `--patch FILE` (unified diff), `--json-summary FILE` (machine-readable run
58
+ report).
59
+ - **`--since REF`** — restrict the run to files changed vs a git ref (changed
60
+ tracked files plus new untracked ones).
61
+ - **Content-hash cache** (`.a11y-cache.json`) keyed by a prompt fingerprint
62
+ (model, provider, base URL, system prompt, lint ruleset); concurrency-safe
63
+ merge-on-save behind a lock file.
64
+ - **Provider abstraction** — Groq (default), OpenAI, or any OpenAI-compatible
65
+ server via `--provider` / `--base-url`. Deterministic decoding, truncation
66
+ detection, retry with `Retry-After` awareness.
67
+ - **`a11y.toml`** project config (auto-discovered) — design-system primitives to
68
+ leave alone, extra prompt rules, ignore globs.
69
+ - **Observability** — `--log FILE` writes a line-flushed JSONL event stream;
70
+ `--verbose` / `--quiet`; `--timeout`; graceful SIGINT (drains in-flight files,
71
+ saves, exits 130).
72
+ - **PR-bot** (`a11y-fixer-pr`) — runs the fixer, opens or updates a pull request
73
+ with a per-file summary via `gh`.
74
+ - **Eval harness** (`evals/run.py`) — golden fixtures scored on status,
75
+ lint-cleanliness, and identifier preservation; aggregate `fix_rate`,
76
+ `false_positive_rate`, `regression_rate`.
77
+ - Faithful write-back of original encoding and line endings (BOM / CRLF);
78
+ machine-readable outputs are always LF.
79
+ - `--version`.
80
+ - Packaging (`pyproject.toml`, console scripts `a11y-fixer` and
81
+ `a11y-fixer-pr`), 138-test pytest suite, and GitHub Actions workflows for the
82
+ PR check, the PR-bot, and the eval.
83
+
84
+ [Unreleased]: https://github.com/arysarin/a11y-fixer/compare/v0.1.0...HEAD
85
+ [0.1.0]: https://github.com/arysarin/a11y-fixer/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 a11y-fixer authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ include LICENSE README.md CHANGELOG.md requirements.txt a11y.toml.example MANIFEST.in
2
+ recursive-include lint *.mjs
3
+ include lint/package.json lint/package-lock.json lint/eslint.config.mjs lint/.gitignore
4
+ prune lint/node_modules
5
+ recursive-include tests *.py
6
+ recursive-include evals *.py *.json *.jsx *.tsx *.toml *.md
@@ -0,0 +1,364 @@
1
+ Metadata-Version: 2.4
2
+ Name: a11y-fixer
3
+ Version: 0.1.0
4
+ Summary: LLM accessibility remediation agent for JSX/TSX with structural safety checks
5
+ Author: a11y-fixer authors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arysarin/a11y-fixer
8
+ Project-URL: Repository, https://github.com/arysarin/a11y-fixer
9
+ Project-URL: Changelog, https://github.com/arysarin/a11y-fixer/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/arysarin/a11y-fixer/issues
11
+ Keywords: accessibility,a11y,wcag,aria,jsx,react,llm,codemod
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: groq>=0.11.0
27
+ Requires-Dist: python-dotenv>=1.0.0
28
+ Requires-Dist: tree-sitter>=0.21
29
+ Requires-Dist: tree-sitter-typescript>=0.23
30
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Provides-Extra: openai
34
+ Requires-Dist: openai>=1.0; extra == "openai"
35
+ Dynamic: license-file
36
+
37
+ # a11y-fixer
38
+
39
+ An accessibility remediation agent for React JSX/TSX. It scans a file or
40
+ directory, sends each component through an LLM (Groq, OpenAI, or any
41
+ OpenAI-compatible endpoint) with a WCAG 2.0/2.2 + ARIA APG system prompt,
42
+ **validates the result structurally**, and then either rewrites the source,
43
+ emits a patch, or reports for CI.
44
+
45
+ The validation step is what makes the output safe to apply unattended: model
46
+ output is parsed as JSX/TSX and compared against the original, and a fix is
47
+ rejected if it fails to parse or drops an event handler or hook call.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install a11y-fixer
53
+ ```
54
+
55
+ That gives you the core agent — structural gate, `--write` / `--patch` /
56
+ `--check` / `--fail-on`, all providers, the cache, `--since`. The optional
57
+ eslint-jsx-a11y feedback loop (`--lint`) and the eval harness's axe pass
58
+ (`--axe`) run a bundled Node package that isn't in the wheel; for those, work
59
+ from a clone:
60
+
61
+ ```bash
62
+ git clone https://github.com/arysarin/a11y-fixer && cd a11y-fixer
63
+ pip install -e ".[dev]" # editable install + pytest
64
+ npm --prefix lint install # only needed for --lint / --axe
65
+ ```
66
+
67
+ Set an API key via environment or a local `.env` (the variable depends on the
68
+ provider — `GROQ_API_KEY` by default, `OPENAI_API_KEY` for `--provider openai`):
69
+
70
+ ```bash
71
+ echo "GROQ_API_KEY=gsk-..." > .env
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ # dry run: print diffs, change nothing
78
+ a11y-fixer src/
79
+
80
+ # apply validated fixes in place (writes .bak alongside each changed file)
81
+ a11y-fixer src/ --write
82
+
83
+ # CI: check only what this PR changed
84
+ a11y-fixer src/ --since origin/main --check
85
+
86
+ # write validated fixes to a patch instead of touching the tree
87
+ a11y-fixer src/ --patch a11y-fixes.patch
88
+
89
+ # CI gate: exit non-zero if any file still has an accessibility issue
90
+ a11y-fixer src/ --check --json-summary a11y-summary.json
91
+ ```
92
+
93
+ ### Output modes
94
+
95
+ | Mode | Flag | Writes source? | Exit on findings |
96
+ | --- | --- | --- | --- |
97
+ | Dry run | *(default)* | no | 0 |
98
+ | Write | `--write` | yes (validated only) | 0 |
99
+ | Patch | `--patch FILE` | no (diff to `FILE`) | 0 |
100
+ | Check | `--check` | no | 1 |
101
+
102
+ `--write` is ignored when combined with `--check` or `--patch`.
103
+
104
+ ### Exit codes
105
+
106
+ - `0` — clean, or fixes applied/emitted successfully
107
+ - `1` — `--check` (or `--fail-on`) found a gated outcome, **or** a file errored
108
+ (API failure, truncated response, unreadable file)
109
+ - `2` — bad invocation (missing API key, path not found, `--workers < 1`)
110
+ - `130` — interrupted with Ctrl-C (partial results saved)
111
+
112
+ ### Options
113
+
114
+ | Flag | Default | Purpose |
115
+ | --- | --- | --- |
116
+ | `--ext` | `jsx,tsx` | comma-separated extensions to scan |
117
+ | `--exclude` | `node_modules,.git,dist,build,.next` | directory names to skip |
118
+ | `--since REF` | — | only process files changed vs a git ref (e.g. `origin/main`) |
119
+ | `--model` | `openai/gpt-oss-120b` | Groq model id |
120
+ | `--write` | off | apply fixes in place |
121
+ | `--no-backup` | off | skip `.bak` files when writing |
122
+ | `--strict` | off | also block a fix that drops *any* identifier, not just handlers/hooks |
123
+ | `--repair-attempts N` | `1` | re-prompt a fix that fails the structural gate, up to N times (`0` disables) |
124
+ | `--lint` | off | run eslint-jsx-a11y, target its violations, verify the fix (needs Node) |
125
+ | `--lint-rounds N` | `2` | max model passes to clear lint violations |
126
+ | `--lint-config FILE` | bundled a11y config | ESLint config to lint with |
127
+ | `--config FILE` | auto-discover `a11y.toml` | project config (see above) |
128
+ | `--no-config` | off | skip config auto-discovery |
129
+ | `--provider` | `groq` | `groq`, `openai`, or `openai-compatible` |
130
+ | `--model` | provider default | model id (`openai/gpt-oss-120b` for groq, `gpt-4o-mini` for openai) |
131
+ | `--base-url` | — | API base URL; required for `openai-compatible` |
132
+ | `--check` | off | CI mode: never write, exit 1 on findings |
133
+ | `--fail-on STATUSES` | — | narrow the exit-1 gate to specific outcomes, e.g. `invalid` or `would-fix,invalid,residual-violations` (overrides `--check`'s gate; a file that errored still exits 1) |
134
+ | `--patch FILE` | — | write validated fixes to `FILE` as a unified diff |
135
+ | `--json-summary FILE` | — | write a machine-readable run summary |
136
+ | `--cache FILE` | `.a11y-cache.json` | cache file for skipping unchanged files |
137
+ | `--no-cache` | off | ignore and don't update the cache |
138
+ | `--api-key` | `$GROQ_API_KEY` | Groq key override |
139
+ | `--workers` | `1` | files processed concurrently (with `> 1`, all workers share one rate-limit cooldown, so a provider 429 pauses the pool instead of each thread backing off blind) |
140
+ | `--timeout SECONDS` | `120` | per model request / lint subprocess timeout (`0` = none) |
141
+ | `--log FILE` | — | append a JSONL event log |
142
+ | `--quiet` / `--verbose` | — | suppress per-file output / show lint+repair progress |
143
+
144
+ ## Observability
145
+
146
+ `--log FILE` appends one JSON object per line as the run proceeds — it survives
147
+ Ctrl-C and crashes, since each line is flushed immediately:
148
+
149
+ ```
150
+ {"event":"run_start","mode":"CHECK","model":"...","files":42, ...}
151
+ {"event":"file_start","path":"src/NavBar.jsx"}
152
+ {"event":"lint","path":"src/NavBar.jsx","phase":"initial","violations":2}
153
+ {"event":"model_call","path":"src/NavBar.jsx","kind":"lint_round","round":1,"prompt_tokens":970,"completion_tokens":734}
154
+ {"event":"file_done","path":"src/NavBar.jsx","status":"would-fix","seconds":7.6,"violations_before":2,"violations_after":0,"repair_attempts":1, ...}
155
+ {"event":"run_end","duration_seconds":8.6,"interrupted":false,"scanned":42, ...}
156
+ ```
157
+
158
+ Ctrl-C lets in-flight files finish, saves the cache and prints the summary, then
159
+ exits `130`. A second Ctrl-C bails immediately. Parallel runs sharing a
160
+ `.a11y-cache.json` merge on save (each writes only the entries it touched) behind
161
+ a best-effort lock file, so they don't clobber each other.
162
+
163
+ ## Linter feedback loop (`--lint`)
164
+
165
+ By default the agent trusts the model to find and fix accessibility issues.
166
+ `--lint` closes that loop with a real linter:
167
+
168
+ 1. Run `eslint-plugin-jsx-a11y` over the file.
169
+ 2. If it's clean, **skip the model entirely** (no tokens spent).
170
+ 3. Otherwise pass the exact violations into the prompt, get a fix, and re-lint it.
171
+ 4. Repeat up to `--lint-rounds` (default 2) until clean or give up.
172
+ 5. The structural gate (parse, handlers/hooks) still runs on the final result.
173
+
174
+ The run summary reports `a11y lint N -> M violations`, the JSON summary carries
175
+ per-file `violations_before` / `violations_after` / `lint_rounds`, and under
176
+ `--check` any file left with violations fails CI.
177
+
178
+ **Setup** — needs Node.js and a one-time install of the bundled ESLint:
179
+
180
+ ```bash
181
+ cd lint && npm install # installs eslint + jsx-a11y + the TS parser
182
+ a11y-fixer src/ --lint --check
183
+ ```
184
+
185
+ `--lint-config FILE` points at your project's own ESLint config instead of the
186
+ bundled a11y-only one. The bundled rule set is folded into the cache key, so
187
+ changing it re-checks every file.
188
+
189
+ ## Project config
190
+
191
+ Drop an `a11y.toml` (or `.a11y.toml`) in the directory you run from — it's
192
+ auto-discovered (`--config FILE` to point elsewhere, `--no-config` to skip). See
193
+ [a11y.toml.example](a11y.toml.example).
194
+
195
+ ```toml
196
+ primitives = ["Button", "Link", "Modal"] # already-accessible components; leave them alone
197
+ rules = ["Our <Icon> is decorative unless a title prop is set."]
198
+ ignore = ["**/*.stories.tsx", "src/legacy/**"] # skip these files
199
+ ext = "jsx,tsx" # optional; CLI --ext still wins
200
+ exclude = "node_modules,dist,coverage" # optional; CLI --exclude still wins
201
+ ```
202
+
203
+ `primitives` and `rules` are injected into the system prompt so the model stops
204
+ adding redundant ARIA to your wrapped components. `ignore` patterns are matched
205
+ (with `fnmatch`) against both the working-dir-relative path and the basename.
206
+ Changing the config changes the prompt, which invalidates the cache.
207
+
208
+ ## Providers
209
+
210
+ The model call goes through a small `LLMClient` adapter, so any OpenAI-shaped
211
+ API works:
212
+
213
+ ```bash
214
+ # Groq (default)
215
+ a11y-fixer src/ --check
216
+
217
+ # OpenAI
218
+ a11y-fixer src/ --provider openai --model gpt-4o-mini --check # needs OPENAI_API_KEY
219
+ pip install "a11y-fixer[openai]" # installs the openai SDK
220
+
221
+ # Any OpenAI-compatible server (Ollama, vLLM, LiteLLM, LM Studio, ...)
222
+ a11y-fixer src/ --provider openai-compatible \
223
+ --base-url http://localhost:11434/v1 --model qwen2.5-coder:7b --check
224
+ ```
225
+
226
+ `--base-url` also lets the `openai-compatible` provider run without an API key
227
+ (for local servers that don't need one). Changing provider, model, or base URL
228
+ invalidates the cache.
229
+
230
+ ## Caching
231
+
232
+ Every run records the content hash of each file it verified as **clean**
233
+ (`unchanged`) or **already fixed** (`fixed`) into `--cache` (default
234
+ `.a11y-cache.json`, keyed relative to the working directory). A later run whose
235
+ file content still matches skips the model call entirely and reports it as
236
+ `unchanged (from cache)` with zero tokens.
237
+
238
+ Only known-good outcomes are cached, so the cache can never suppress a pending
239
+ fix, a validation failure, or a diff you need to see — `would-fix`, `invalid`,
240
+ and `error` files are re-processed every run. The whole cache is discarded when
241
+ the model (`--model`) or the system prompt changes, since a stale "clean"
242
+ verdict would no longer be trustworthy.
243
+
244
+ Add `.a11y-cache.json` to `.gitignore` (already done here). Use `--no-cache` in
245
+ environments where you want every file re-checked unconditionally.
246
+
247
+ ## How validation works
248
+
249
+ For each file the agent builds a structural fingerprint (via `tree-sitter`) of
250
+ the original and the model output:
251
+
252
+ 1. **Parse gate** — if the original parsed cleanly and the fix does not, the fix
253
+ is rejected (`invalid`) and never written.
254
+ 2. **Handler / hook preservation** — any `on*` JSX attribute or `use*` hook call
255
+ present in the original but gone from the fix rejects it.
256
+ 3. **`--strict`** — additionally rejects if any other identifier drops to zero
257
+ references (noisier: legitimate refactors like `window.location.href = …` →
258
+ `<a href>` trip this).
259
+
260
+ If the original file does not parse, all checks are skipped (no trustworthy
261
+ baseline) and the fix is written as-is under `--write`.
262
+
263
+ **Repair pass** — when the structural gate rejects a fix, it is sent back once
264
+ (configurable with `--repair-attempts N`, default 1, `0` disables) with the exact
265
+ reason (`event handlers removed: onClick`, `does not parse`, …) and re-validated.
266
+ A recovered fix proceeds normally; the summary reports `repair K/N recovered`.
267
+ This costs one extra model call per rejected file.
268
+
269
+ A truncated or empty model response is always an error, never a partial write.
270
+
271
+ File content is treated strictly as code to remediate; the system prompt tells
272
+ the model to ignore any instructions embedded in it (prompt-injection guard),
273
+ and the structural gate above is the real backstop regardless. The eval corpus
274
+ carries `prompt-injection` fixtures (override text in a JS comment and in
275
+ rendered JSX) that check this holds against the live model.
276
+
277
+ Files are read as UTF-8; a byte-order mark and CRLF line endings are detected
278
+ and preserved on write-back. A non-UTF-8 file is reported as an `error` rather
279
+ than guessed at.
280
+
281
+ `--since REF` narrows the run to files that differ from a git ref — tracked
282
+ modifications plus new untracked files, deletions excluded. A run with nothing
283
+ changed exits `0` ("No changed files to process"); an unknown ref or a
284
+ non-git directory exits `2`.
285
+
286
+ ## CI
287
+
288
+ See [.github/workflows/a11y.yml](.github/workflows/a11y.yml): runs `--check` on
289
+ pull requests touching `.jsx`/`.tsx`, and a manual `workflow_dispatch` produces
290
+ a fix patch as a build artifact. Requires a `GROQ_API_KEY` repository secret
291
+ (or `OPENAI_API_KEY` if you switch `--provider`).
292
+
293
+ To stop the build only on a fix that failed the safety gate — while letting
294
+ merely-unremediated files through — use `--fail-on`:
295
+
296
+ ```bash
297
+ a11y-fixer src/ --since origin/main --lint --fail-on invalid
298
+ ```
299
+
300
+ ## Opening a PR with the fixes
301
+
302
+ `a11y_pr.py` (console script `a11y-fixer-pr`) runs the agent and turns the result
303
+ into a pull request. Run it from the repo root with a clean tracked tree:
304
+
305
+ ```bash
306
+ a11y-fixer-pr src/ --lint --label accessibility
307
+ ```
308
+
309
+ It runs `a11y-fixer src/ --lint --patch … --json-summary …` (any extra args pass
310
+ through), and if there are validated fixes: branches from HEAD (`--branch`,
311
+ default `a11y-fixes`), applies the patch, commits (message carries the lint
312
+ delta), force-pushes, and creates or refreshes a PR through the `gh` CLI. The PR
313
+ body is a per-file table (status, `violations_before → after`, repair passes)
314
+ with token/cost totals and a note that rejected fixes were left out. `--dry-run`
315
+ stops after the local commit; without `gh` it pushes and prints the compare
316
+ instructions. [.github/workflows/a11y-pr.yml](.github/workflows/a11y-pr.yml) wires
317
+ it to `workflow_dispatch`.
318
+
319
+ ## Limitations
320
+
321
+ - Without `--lint`, semantic correctness of a fix is not verified — only that it
322
+ parses and keeps handlers/hooks. `--lint` verifies against
323
+ `eslint-plugin-jsx-a11y`; `axe-core` on rendered output would be stronger still.
324
+ - The bundled `lint/` directory ships in source checkouts and editable installs
325
+ (`pip install -e .`); a plain wheel install does not carry it, so run `--lint`
326
+ from a checkout.
327
+ - Each file is processed in isolation; the agent has no knowledge of shared
328
+ design-system components (an `a11y.toml` mitigates this).
329
+ - Cost figures in the summary are estimates from `MODEL_PRICING` in
330
+ `a11y_fixer.py`; update the rates for your model.
331
+
332
+ ## Tests
333
+
334
+ ```bash
335
+ pytest
336
+ ```
337
+
338
+ `.github/workflows/ci.yml` runs the suite on Python 3.10 / 3.11 / 3.12 on every
339
+ push and PR, plus a smoke check that the bundled `eslint-plugin-jsx-a11y` config
340
+ still flags a known violation and that the package builds with its runtime data
341
+ files (`lint/`, `a11y.toml.example`).
342
+
343
+ ## Releasing
344
+
345
+ `a11y_fixer.__version__` is the single source of truth; `pyproject.toml` reads it
346
+ dynamically, so a release is: bump that string, move the `CHANGELOG.md`
347
+ `[Unreleased]` items under a dated version heading, tag `vX.Y.Z`. `a11y-fixer
348
+ --version` reports the installed version.
349
+
350
+ ## Evals
351
+
352
+ [evals/](evals/) holds golden fixtures and a scorer for measuring a prompt /
353
+ model / validation change rather than eyeballing it:
354
+
355
+ ```bash
356
+ GROQ_API_KEY=... python evals/run.py --lint --json after.json
357
+ GROQ_API_KEY=... python evals/run.py --lint --axe # also render + run axe-core
358
+ ```
359
+
360
+ Reports `fix_rate`, `false_positive_rate`, `regression_rate`, and (with `--axe`)
361
+ `axe_clean_rate`; exits non-zero below `--threshold` (default 1.0). `--axe`
362
+ renders each opted-in fixture's fixed component and runs axe-core against it in
363
+ jsdom, checking the fix actually helps assistive tech rather than just passing
364
+ the linter. See [evals/README.md](evals/README.md).