@mcptoolshop/accessibility-suite 0.1.0

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 (241) hide show
  1. package/.github/workflows/ci.yml +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -0
  4. package/docs/prov-spec/.github/workflows/ci.yml +68 -0
  5. package/docs/prov-spec/CHANGELOG.md +69 -0
  6. package/docs/prov-spec/CODE_OF_CONDUCT.md +129 -0
  7. package/docs/prov-spec/CONFORMANCE_LEVELS.md +223 -0
  8. package/docs/prov-spec/CONTRIBUTING.md +145 -0
  9. package/docs/prov-spec/IMPLEMENTER_CHECKLIST.md +137 -0
  10. package/docs/prov-spec/LICENSE +21 -0
  11. package/docs/prov-spec/PRESS_RELEASE.md +74 -0
  12. package/docs/prov-spec/README.md +182 -0
  13. package/docs/prov-spec/SETUP.md +135 -0
  14. package/docs/prov-spec/WHY.md +86 -0
  15. package/docs/prov-spec/examples/artifact.example.json +14 -0
  16. package/docs/prov-spec/examples/artifact.ref.example.json +9 -0
  17. package/docs/prov-spec/examples/evidence.example.json +6 -0
  18. package/docs/prov-spec/examples/mcp.envelope.example.json +97 -0
  19. package/docs/prov-spec/examples/mcp.request.example.json +28 -0
  20. package/docs/prov-spec/examples/prov.record.example.json +35 -0
  21. package/docs/prov-spec/interop/PROOF_NODE_ENGINE.md +114 -0
  22. package/docs/prov-spec/spec/MCP_COMPATIBILITY.md +241 -0
  23. package/docs/prov-spec/spec/PROV_METHODS_CATALOG.md +142 -0
  24. package/docs/prov-spec/spec/PROV_METHODS_SPEC.md +397 -0
  25. package/docs/prov-spec/spec/methods.json +213 -0
  26. package/docs/prov-spec/spec/schemas/artifact.ref.schema.v0.1.json +58 -0
  27. package/docs/prov-spec/spec/schemas/artifact.schema.v0.1.json +61 -0
  28. package/docs/prov-spec/spec/schemas/assist.request.schema.v0.1.json +52 -0
  29. package/docs/prov-spec/spec/schemas/assist.response.schema.v0.1.json +70 -0
  30. package/docs/prov-spec/spec/schemas/cli.error.schema.v0.1.json +78 -0
  31. package/docs/prov-spec/spec/schemas/evidence.schema.v0.1.json +37 -0
  32. package/docs/prov-spec/spec/schemas/mcp.envelope.schema.v0.1.json +141 -0
  33. package/docs/prov-spec/spec/schemas/mcp.request.schema.v0.1.json +79 -0
  34. package/docs/prov-spec/spec/schemas/methods.schema.json +93 -0
  35. package/docs/prov-spec/spec/schemas/prov-capabilities.schema.json +122 -0
  36. package/docs/prov-spec/spec/schemas/prov.record.schema.v0.1.json +133 -0
  37. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/expected.json +4 -0
  38. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/input.json +1 -0
  39. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/double_wrapped.json +14 -0
  40. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/wrong_schema_version.json +11 -0
  41. package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/expected.json +24 -0
  42. package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/input.json +8 -0
  43. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/expected.json +7 -0
  44. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/input.json +1 -0
  45. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/non_hex_chars.json +16 -0
  46. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/uppercase_hex.json +16 -0
  47. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/wrong_length.json +16 -0
  48. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/hyphen_separator.json +8 -0
  49. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/reserved_namespace.json +8 -0
  50. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/starts_with_digit.json +8 -0
  51. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/uppercase.json +8 -0
  52. package/docs/prov-spec/spec/vectors/method_id_syntax/positive/valid_ids.json +18 -0
  53. package/docs/prov-spec/tools/python/prov_validator.py +428 -0
  54. package/examples/a11y-demo-site/.github/workflows/a11y-artifacts.yml +81 -0
  55. package/examples/a11y-demo-site/.github/workflows/a11y.yml +34 -0
  56. package/examples/a11y-demo-site/CODE_OF_CONDUCT.md +129 -0
  57. package/examples/a11y-demo-site/CONTRIBUTING.md +83 -0
  58. package/examples/a11y-demo-site/LICENSE +21 -0
  59. package/examples/a11y-demo-site/README.md +155 -0
  60. package/examples/a11y-demo-site/html/contact.html +15 -0
  61. package/examples/a11y-demo-site/html/index.html +20 -0
  62. package/examples/a11y-demo-site/scripts/a11y.sh +20 -0
  63. package/package.json +26 -0
  64. package/src/a11y-assist/.github/workflows/publish.yml +52 -0
  65. package/src/a11y-assist/.github/workflows/test.yml +30 -0
  66. package/src/a11y-assist/A11Y_ASSIST_TEST_COVERAGE_REQUIREMENTS.md +104 -0
  67. package/src/a11y-assist/CODE_OF_CONDUCT.md +129 -0
  68. package/src/a11y-assist/CONTRIBUTING.md +98 -0
  69. package/src/a11y-assist/ENGINE.md +363 -0
  70. package/src/a11y-assist/LICENSE +21 -0
  71. package/src/a11y-assist/PRESS_RELEASE.md +71 -0
  72. package/src/a11y-assist/QUICKSTART.md +101 -0
  73. package/src/a11y-assist/README.md +192 -0
  74. package/src/a11y-assist/RELEASE_NOTES.md +319 -0
  75. package/src/a11y-assist/a11y_assist/__init__.py +3 -0
  76. package/src/a11y-assist/a11y_assist/cli.py +599 -0
  77. package/src/a11y-assist/a11y_assist/from_cli_error.py +149 -0
  78. package/src/a11y-assist/a11y_assist/guard.py +444 -0
  79. package/src/a11y-assist/a11y_assist/ingest.py +407 -0
  80. package/src/a11y-assist/a11y_assist/methods.py +137 -0
  81. package/src/a11y-assist/a11y_assist/parse_raw.py +71 -0
  82. package/src/a11y-assist/a11y_assist/profiles/__init__.py +29 -0
  83. package/src/a11y-assist/a11y_assist/profiles/cognitive_load.py +245 -0
  84. package/src/a11y-assist/a11y_assist/profiles/cognitive_load_render.py +86 -0
  85. package/src/a11y-assist/a11y_assist/profiles/dyslexia.py +144 -0
  86. package/src/a11y-assist/a11y_assist/profiles/dyslexia_render.py +77 -0
  87. package/src/a11y-assist/a11y_assist/profiles/plain_language.py +119 -0
  88. package/src/a11y-assist/a11y_assist/profiles/plain_language_render.py +66 -0
  89. package/src/a11y-assist/a11y_assist/profiles/screen_reader.py +348 -0
  90. package/src/a11y-assist/a11y_assist/profiles/screen_reader_render.py +89 -0
  91. package/src/a11y-assist/a11y_assist/render.py +95 -0
  92. package/src/a11y-assist/a11y_assist/schemas/assist.request.schema.v0.1.json +52 -0
  93. package/src/a11y-assist/a11y_assist/schemas/assist.response.schema.v0.1.json +70 -0
  94. package/src/a11y-assist/a11y_assist/schemas/cli.error.schema.v0.1.json +78 -0
  95. package/src/a11y-assist/a11y_assist/storage.py +31 -0
  96. package/src/a11y-assist/pyproject.toml +60 -0
  97. package/src/a11y-assist/tests/__init__.py +1 -0
  98. package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_high.json +18 -0
  99. package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_medium.json +16 -0
  100. package/src/a11y-assist/tests/fixtures/base_inputs/raw_text_low.txt +3 -0
  101. package/src/a11y-assist/tests/fixtures/cli_error_good.json +9 -0
  102. package/src/a11y-assist/tests/fixtures/cli_error_missing_id.json +7 -0
  103. package/src/a11y-assist/tests/fixtures/cli_error_string_format.json +7 -0
  104. package/src/a11y-assist/tests/fixtures/expected/cognitive_load_high.txt +20 -0
  105. package/src/a11y-assist/tests/fixtures/expected/dyslexia_high.txt +20 -0
  106. package/src/a11y-assist/tests/fixtures/expected/lowvision_high.txt +18 -0
  107. package/src/a11y-assist/tests/fixtures/expected/plain_language_high.txt +14 -0
  108. package/src/a11y-assist/tests/fixtures/expected/screen_reader_high.txt +19 -0
  109. package/src/a11y-assist/tests/fixtures/golden_screen_reader_cli_error.txt +16 -0
  110. package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_no_id.txt +14 -0
  111. package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_with_id.txt +14 -0
  112. package/src/a11y-assist/tests/fixtures/raw_good.txt +11 -0
  113. package/src/a11y-assist/tests/fixtures/raw_no_id.txt +2 -0
  114. package/src/a11y-assist/tests/test_cognitive_load.py +469 -0
  115. package/src/a11y-assist/tests/test_dyslexia.py +337 -0
  116. package/src/a11y-assist/tests/test_explain.py +74 -0
  117. package/src/a11y-assist/tests/test_golden.py +127 -0
  118. package/src/a11y-assist/tests/test_guard.py +819 -0
  119. package/src/a11y-assist/tests/test_guard_integration.py +457 -0
  120. package/src/a11y-assist/tests/test_ingest.py +311 -0
  121. package/src/a11y-assist/tests/test_methods_metadata.py +236 -0
  122. package/src/a11y-assist/tests/test_plain_language.py +348 -0
  123. package/src/a11y-assist/tests/test_render.py +117 -0
  124. package/src/a11y-assist/tests/test_screen_reader.py +703 -0
  125. package/src/a11y-assist/tests/test_storage_last.py +61 -0
  126. package/src/a11y-assist/tests/test_triage.py +86 -0
  127. package/src/a11y-ci/.github/workflows/ci.yml +43 -0
  128. package/src/a11y-ci/.github/workflows/test.yml +30 -0
  129. package/src/a11y-ci/A11Y_CI_TEST_COVERAGE_REQUIREMENTS.md +94 -0
  130. package/src/a11y-ci/CODE_OF_CONDUCT.md +129 -0
  131. package/src/a11y-ci/CONTRIBUTING.md +142 -0
  132. package/src/a11y-ci/LICENSE +21 -0
  133. package/src/a11y-ci/README.md +105 -0
  134. package/src/a11y-ci/a11y_ci/__init__.py +3 -0
  135. package/src/a11y-ci/a11y_ci/allowlist.py +83 -0
  136. package/src/a11y-ci/a11y_ci/cli.py +145 -0
  137. package/src/a11y-ci/a11y_ci/gate.py +131 -0
  138. package/src/a11y-ci/a11y_ci/render.py +48 -0
  139. package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +24 -0
  140. package/src/a11y-ci/a11y_ci/scorecard.py +99 -0
  141. package/src/a11y-ci/npm/package.json +35 -0
  142. package/src/a11y-ci/pyproject.toml +64 -0
  143. package/src/a11y-ci/tests/__init__.py +1 -0
  144. package/src/a11y-ci/tests/fixtures/allowlist_expired.json +10 -0
  145. package/src/a11y-ci/tests/fixtures/allowlist_ok.json +10 -0
  146. package/src/a11y-ci/tests/fixtures/baseline_ok.json +7 -0
  147. package/src/a11y-ci/tests/fixtures/current_fail.json +6 -0
  148. package/src/a11y-ci/tests/fixtures/current_ok.json +6 -0
  149. package/src/a11y-ci/tests/fixtures/current_regresses.json +7 -0
  150. package/src/a11y-ci/tests/test_gate.py +134 -0
  151. package/src/a11y-evidence-engine/.github/workflows/ci.yml +53 -0
  152. package/src/a11y-evidence-engine/CODE_OF_CONDUCT.md +129 -0
  153. package/src/a11y-evidence-engine/CONTRIBUTING.md +128 -0
  154. package/src/a11y-evidence-engine/LICENSE +21 -0
  155. package/src/a11y-evidence-engine/README.md +71 -0
  156. package/src/a11y-evidence-engine/bin/a11y-engine.js +11 -0
  157. package/src/a11y-evidence-engine/fixtures/bad/button-no-name.html +30 -0
  158. package/src/a11y-evidence-engine/fixtures/bad/img-missing-alt.html +19 -0
  159. package/src/a11y-evidence-engine/fixtures/bad/input-missing-label.html +26 -0
  160. package/src/a11y-evidence-engine/fixtures/bad/missing-lang.html +11 -0
  161. package/src/a11y-evidence-engine/fixtures/good/index.html +29 -0
  162. package/src/a11y-evidence-engine/package-lock.json +109 -0
  163. package/src/a11y-evidence-engine/package.json +45 -0
  164. package/src/a11y-evidence-engine/src/cli.js +74 -0
  165. package/src/a11y-evidence-engine/src/evidence/canonicalize.js +52 -0
  166. package/src/a11y-evidence-engine/src/evidence/json_pointer.js +34 -0
  167. package/src/a11y-evidence-engine/src/evidence/prov_emit.js +153 -0
  168. package/src/a11y-evidence-engine/src/fswalk.js +56 -0
  169. package/src/a11y-evidence-engine/src/html_parse.js +117 -0
  170. package/src/a11y-evidence-engine/src/ids.js +53 -0
  171. package/src/a11y-evidence-engine/src/rules/document_missing_lang.js +50 -0
  172. package/src/a11y-evidence-engine/src/rules/form_control_missing_label.js +105 -0
  173. package/src/a11y-evidence-engine/src/rules/img_missing_alt.js +77 -0
  174. package/src/a11y-evidence-engine/src/rules/index.js +37 -0
  175. package/src/a11y-evidence-engine/src/rules/interactive_missing_name.js +129 -0
  176. package/src/a11y-evidence-engine/src/scan.js +128 -0
  177. package/src/a11y-evidence-engine/test/scan.test.js +149 -0
  178. package/src/a11y-evidence-engine/test/vectors.test.js +200 -0
  179. package/src/a11y-lint/.github/workflows/ci.yml +46 -0
  180. package/src/a11y-lint/.github/workflows/test.yml +34 -0
  181. package/src/a11y-lint/CODE_OF_CONDUCT.md +129 -0
  182. package/src/a11y-lint/CONTRIBUTING.md +70 -0
  183. package/src/a11y-lint/GOVERNANCE.md +57 -0
  184. package/src/a11y-lint/LICENSE +21 -0
  185. package/src/a11y-lint/PRESS_RELEASE.md +50 -0
  186. package/src/a11y-lint/README.md +276 -0
  187. package/src/a11y-lint/RELEASE_NOTES.md +57 -0
  188. package/src/a11y-lint/RELEASING.md +57 -0
  189. package/src/a11y-lint/a11y_lint/__init__.py +64 -0
  190. package/src/a11y-lint/a11y_lint/cli.py +319 -0
  191. package/src/a11y-lint/a11y_lint/errors.py +252 -0
  192. package/src/a11y-lint/a11y_lint/render.py +293 -0
  193. package/src/a11y-lint/a11y_lint/report_md.py +289 -0
  194. package/src/a11y-lint/a11y_lint/scan_cli_text.py +434 -0
  195. package/src/a11y-lint/a11y_lint/schemas/cli.error.schema.v0.1.json +83 -0
  196. package/src/a11y-lint/a11y_lint/scorecard.py +244 -0
  197. package/src/a11y-lint/a11y_lint/validate.py +225 -0
  198. package/src/a11y-lint/pyproject.toml +75 -0
  199. package/src/a11y-lint/tests/__init__.py +1 -0
  200. package/src/a11y-lint/tests/test_cli.py +200 -0
  201. package/src/a11y-lint/tests/test_errors.py +188 -0
  202. package/src/a11y-lint/tests/test_render.py +202 -0
  203. package/src/a11y-lint/tests/test_report_md.py +188 -0
  204. package/src/a11y-lint/tests/test_scan_cli_text.py +290 -0
  205. package/src/a11y-lint/tests/test_scorecard.py +195 -0
  206. package/src/a11y-lint/tests/test_validate.py +257 -0
  207. package/src/a11y-mcp-tools/.github/workflows/ci.yml +53 -0
  208. package/src/a11y-mcp-tools/CODE_OF_CONDUCT.md +129 -0
  209. package/src/a11y-mcp-tools/CONTRIBUTING.md +136 -0
  210. package/src/a11y-mcp-tools/LICENSE +21 -0
  211. package/src/a11y-mcp-tools/PROV_METHODS_CATALOG.md +104 -0
  212. package/src/a11y-mcp-tools/README.md +168 -0
  213. package/src/a11y-mcp-tools/bin/cli.js +452 -0
  214. package/src/a11y-mcp-tools/bin/server.js +244 -0
  215. package/src/a11y-mcp-tools/fixtures/requests/a11y.diagnose.ok.json +27 -0
  216. package/src/a11y-mcp-tools/fixtures/requests/a11y.evidence.ok.json +25 -0
  217. package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.ok.json +139 -0
  218. package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.provenance_fail.json +13 -0
  219. package/src/a11y-mcp-tools/fixtures/responses/a11y.evidence.ok.json +88 -0
  220. package/src/a11y-mcp-tools/package-lock.json +189 -0
  221. package/src/a11y-mcp-tools/package.json +49 -0
  222. package/src/a11y-mcp-tools/src/envelope.js +197 -0
  223. package/src/a11y-mcp-tools/src/index.js +9 -0
  224. package/src/a11y-mcp-tools/src/schemas/artifact.js +85 -0
  225. package/src/a11y-mcp-tools/src/schemas/diagnosis.schema.v0.1.json +137 -0
  226. package/src/a11y-mcp-tools/src/schemas/envelope.schema.v0.1.json +108 -0
  227. package/src/a11y-mcp-tools/src/schemas/evidence.bundle.schema.v0.1.json +129 -0
  228. package/src/a11y-mcp-tools/src/schemas/evidence.js +97 -0
  229. package/src/a11y-mcp-tools/src/schemas/index.js +11 -0
  230. package/src/a11y-mcp-tools/src/schemas/provenance.js +140 -0
  231. package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.request.schema.v0.1.json +77 -0
  232. package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.response.schema.v0.1.json +50 -0
  233. package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.request.schema.v0.1.json +120 -0
  234. package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.response.schema.v0.1.json +50 -0
  235. package/src/a11y-mcp-tools/src/tools/diagnose.js +597 -0
  236. package/src/a11y-mcp-tools/src/tools/evidence.js +481 -0
  237. package/src/a11y-mcp-tools/src/tools/index.js +10 -0
  238. package/src/a11y-mcp-tools/test/contract.test.mjs +154 -0
  239. package/src/a11y-mcp-tools/test/diagnose.test.js +485 -0
  240. package/src/a11y-mcp-tools/test/evidence.test.js +183 -0
  241. package/src/a11y-mcp-tools/test/schema.test.js +327 -0
@@ -0,0 +1,105 @@
1
+ # a11y-ci
2
+
3
+ ![gate](https://img.shields.io/badge/gate-strict-blue)
4
+ ![contract](https://img.shields.io/badge/output-low--vision--first-green)
5
+ ![license](https://img.shields.io/badge/license-MIT-black)
6
+
7
+ CI gate for `a11y-lint` scorecards. Low-vision-first output.
8
+
9
+ ## What it does
10
+
11
+ - Fails if current run has findings at/above `--fail-on` (default: `serious`)
12
+ - Optional baseline comparison:
13
+ - fails on serious+ count regression
14
+ - fails if new serious+ finding IDs appear
15
+ - Optional allowlist with required reason + expiry
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install a11y-ci
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Gate (typical CI)
26
+
27
+ ```bash
28
+ a11y-ci gate --current a11y.scorecard.json --baseline baseline/a11y.scorecard.json
29
+ ```
30
+
31
+ ### Allowlist
32
+
33
+ ```bash
34
+ a11y-ci gate --current a11y.scorecard.json --baseline baseline/a11y.scorecard.json --allowlist a11y-ci.allowlist.json
35
+ ```
36
+
37
+ ### Fail severity
38
+
39
+ ```bash
40
+ a11y-ci gate --current a11y.scorecard.json --fail-on moderate
41
+ ```
42
+
43
+ ## Exit codes
44
+
45
+ | Code | Meaning |
46
+ |------|---------|
47
+ | 0 | Pass |
48
+ | 2 | Input/validation error |
49
+ | 3 | Policy gate failed |
50
+
51
+ ## Output Contract
52
+
53
+ All output follows the low-vision-first contract:
54
+
55
+ ```
56
+ [OK] Title (ID: NAMESPACE.CATEGORY.DETAIL)
57
+
58
+ What:
59
+ What happened.
60
+
61
+ Why:
62
+ Why it happened.
63
+
64
+ Fix:
65
+ How to fix it.
66
+ ```
67
+
68
+ ## Allowlist Format
69
+
70
+ Allowlist entries require:
71
+ - `finding_id`: The rule/finding ID to suppress
72
+ - `expires`: ISO date (yyyy-mm-dd) — expired entries fail the gate
73
+ - `reason`: Minimum 10 chars explaining the suppression
74
+
75
+ ```json
76
+ {
77
+ "version": "1",
78
+ "allow": [
79
+ {
80
+ "finding_id": "CLI.COLOR.ONLY",
81
+ "expires": "2026-12-31",
82
+ "reason": "Temporary suppression for legacy output. Tracked in issue #12."
83
+ }
84
+ ]
85
+ }
86
+ ```
87
+
88
+ ## GitHub Actions Example
89
+
90
+ ```yaml
91
+ - name: a11y gate
92
+ run: |
93
+ a11y-lint scan output.txt --json > a11y.scorecard.json
94
+ a11y-ci gate --current a11y.scorecard.json --baseline baseline/a11y.scorecard.json
95
+ ```
96
+
97
+ ## Notes
98
+
99
+ - This tool is deterministic. It does not call network services.
100
+ - Expired allowlist entries fail the gate (no permanent exceptions).
101
+ - Scorecards are format-tolerant: reads `summary` when present, otherwise computes from `findings`.
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,3 @@
1
+ """a11y-ci: CI gate for a11y-lint scorecards (low-vision-first)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,83 @@
1
+ """Allowlist handling with schema validation and expiry checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import date
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Set
11
+
12
+ from jsonschema import Draft202012Validator
13
+
14
+
15
+ def _load_schema() -> Dict[str, Any]:
16
+ """Load the allowlist JSON schema."""
17
+ with resources.files("a11y_ci.schemas").joinpath("allowlist.schema.json").open("rb") as f:
18
+ return json.load(f)
19
+
20
+
21
+ _SCHEMA = _load_schema()
22
+ _VALIDATOR = Draft202012Validator(_SCHEMA)
23
+
24
+
25
+ class AllowlistError(Exception):
26
+ """Raised when allowlist validation fails."""
27
+
28
+ pass
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AllowlistEntry:
33
+ """A single allowlist entry with required fields."""
34
+
35
+ finding_id: str
36
+ expires: str
37
+ reason: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Allowlist:
42
+ """Parsed allowlist with entries."""
43
+
44
+ entries: List[AllowlistEntry]
45
+
46
+ @staticmethod
47
+ def load(path: str) -> "Allowlist":
48
+ """Load and validate an allowlist from a JSON file."""
49
+ obj = json.loads(Path(path).read_text(encoding="utf-8"))
50
+ errors = []
51
+ for e in sorted(_VALIDATOR.iter_errors(obj), key=lambda x: x.path):
52
+ loc = ".".join([str(p) for p in e.path]) or "(root)"
53
+ errors.append(f"{loc}: {e.message}")
54
+ if errors:
55
+ raise AllowlistError("allowlist validation failed:\n" + "\n".join(errors))
56
+
57
+ allow = obj.get("allow", [])
58
+ entries: List[AllowlistEntry] = []
59
+ for item in allow:
60
+ entries.append(
61
+ AllowlistEntry(
62
+ finding_id=item["finding_id"].strip(),
63
+ expires=item["expires"].strip(),
64
+ reason=item["reason"].strip(),
65
+ )
66
+ )
67
+ return Allowlist(entries=entries)
68
+
69
+ def suppressed_ids(self) -> Set[str]:
70
+ """Get set of finding IDs that are suppressed."""
71
+ return {e.finding_id for e in self.entries}
72
+
73
+ def expired_entries(self, today: Optional[date] = None) -> List[AllowlistEntry]:
74
+ """Get list of entries that have expired."""
75
+ today = today or date.today()
76
+ expired: List[AllowlistEntry] = []
77
+ for e in self.entries:
78
+ # expires is ISO date yyyy-mm-dd
79
+ y, m, d = e.expires.split("-")
80
+ exp = date(int(y), int(m), int(d))
81
+ if exp < today:
82
+ expired.append(e)
83
+ return expired
@@ -0,0 +1,145 @@
1
+ """CLI entry point for a11y-ci."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from . import __version__
8
+ from .allowlist import Allowlist, AllowlistError
9
+ from .gate import gate
10
+ from .render import CliMessage, render
11
+ from .scorecard import Scorecard
12
+
13
+ EXIT_PASS = 0
14
+ EXIT_INPUT_ERROR = 2
15
+ EXIT_FAIL = 3
16
+
17
+
18
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
19
+ @click.version_option(__version__)
20
+ def main():
21
+ """a11y-ci: CI gate for a11y-lint scorecards."""
22
+ pass
23
+
24
+
25
+ @main.command("gate")
26
+ @click.option(
27
+ "--current",
28
+ "current_path",
29
+ required=True,
30
+ type=click.Path(exists=True, dir_okay=False),
31
+ help="Path to current scorecard JSON.",
32
+ )
33
+ @click.option(
34
+ "--baseline",
35
+ "baseline_path",
36
+ required=False,
37
+ type=click.Path(exists=True, dir_okay=False),
38
+ help="Path to baseline scorecard JSON (optional).",
39
+ )
40
+ @click.option(
41
+ "--fail-on",
42
+ "fail_on",
43
+ default="serious",
44
+ show_default=True,
45
+ type=click.Choice(["info", "minor", "moderate", "serious", "critical"], case_sensitive=False),
46
+ help="Minimum severity to fail on.",
47
+ )
48
+ @click.option(
49
+ "--allowlist",
50
+ "allowlist_path",
51
+ required=False,
52
+ type=click.Path(exists=True, dir_okay=False),
53
+ help="Path to allowlist JSON (optional).",
54
+ )
55
+ def gate_cmd(
56
+ current_path: str,
57
+ baseline_path: str | None,
58
+ fail_on: str,
59
+ allowlist_path: str | None,
60
+ ):
61
+ """Evaluate policy gate against scorecards."""
62
+ try:
63
+ current = Scorecard.load(current_path)
64
+ baseline = Scorecard.load(baseline_path) if baseline_path else None
65
+ allowlist = Allowlist.load(allowlist_path) if allowlist_path else None
66
+ except AllowlistError as e:
67
+ msg = CliMessage(
68
+ status="ERROR",
69
+ id="A11Y.CI.ALLOWLIST.INVALID",
70
+ title="Allowlist is invalid",
71
+ what=["The allowlist file failed schema validation."],
72
+ why=[
73
+ "The allowlist must include finding_id, expires, and reason for each entry."
74
+ ],
75
+ fix=[
76
+ "Fix the allowlist JSON and re-run the gate.",
77
+ f"Details: {str(e).splitlines()[0]}",
78
+ ],
79
+ )
80
+ click.echo(render(msg), nl=False)
81
+ raise SystemExit(EXIT_INPUT_ERROR)
82
+ except Exception as e:
83
+ msg = CliMessage(
84
+ status="ERROR",
85
+ id="A11Y.CI.INPUT.INVALID",
86
+ title="Could not read inputs",
87
+ what=["One or more input files could not be parsed."],
88
+ why=["The scorecard JSON may be malformed or missing required fields."],
89
+ fix=[
90
+ "Verify the JSON files exist and are valid.",
91
+ f"Error: {type(e).__name__}: {e}",
92
+ ],
93
+ )
94
+ click.echo(render(msg), nl=False)
95
+ raise SystemExit(EXIT_INPUT_ERROR)
96
+
97
+ result = gate(current=current, baseline=baseline, fail_on=fail_on, allowlist=allowlist)
98
+
99
+ if result.ok:
100
+ msg = CliMessage(
101
+ status="OK",
102
+ id="A11Y.CI.GATE.PASS",
103
+ title="Accessibility gate passed",
104
+ what=["No policy violations were detected."],
105
+ why=["Current findings meet the configured threshold and baseline policy."],
106
+ fix=["Proceed with merge/release."],
107
+ )
108
+ click.echo(render(msg), nl=False)
109
+ raise SystemExit(EXIT_PASS)
110
+
111
+ # fail message (low-vision friendly, actionable)
112
+ what_lines = ["Accessibility policy violations were detected."]
113
+ why_lines = result.reasons[:]
114
+ fix_lines = [
115
+ "Review the current scorecard and address the listed findings.",
116
+ "If this is intentional, add a time-bounded allowlist entry with justification.",
117
+ f"Re-run: a11y-ci gate --current {current_path}"
118
+ + (f" --baseline {baseline_path}" if baseline_path else "")
119
+ + (f" --allowlist {allowlist_path}" if allowlist_path else ""),
120
+ ]
121
+
122
+ # Include a short list of blocking IDs in Fix for immediate control
123
+ if result.current_blocking_ids:
124
+ fix_lines.append(
125
+ "Blocking IDs (current): "
126
+ + ", ".join(result.current_blocking_ids[:12])
127
+ + (" ..." if len(result.current_blocking_ids) > 12 else "")
128
+ )
129
+ if result.new_blocking_ids:
130
+ fix_lines.append(
131
+ "New blocking IDs (regression): "
132
+ + ", ".join(result.new_blocking_ids[:12])
133
+ + (" ..." if len(result.new_blocking_ids) > 12 else "")
134
+ )
135
+
136
+ msg = CliMessage(
137
+ status="ERROR",
138
+ id="A11Y.CI.GATE.FAIL",
139
+ title="Accessibility gate failed",
140
+ what=what_lines,
141
+ why=why_lines if why_lines else ["Gate policy was not satisfied."],
142
+ fix=fix_lines,
143
+ )
144
+ click.echo(render(msg), nl=False)
145
+ raise SystemExit(EXIT_FAIL)
@@ -0,0 +1,131 @@
1
+ """Core policy gate logic.
2
+
3
+ Rules:
4
+ 1. Current has any findings at/above fail_on threshold -> FAIL
5
+ 2. If baseline provided, regression in count at/above threshold -> FAIL
6
+ 3. If baseline provided, new finding IDs at/above threshold -> FAIL
7
+ 4. Expired allowlist entries -> FAIL (no permanent exceptions)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Dict, List, Optional, Set
14
+
15
+ from .allowlist import Allowlist
16
+ from .scorecard import (
17
+ SEVERITY_ORDER,
18
+ Scorecard,
19
+ finding_id,
20
+ normalize_severity,
21
+ severity_ge,
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class GateResult:
27
+ """Result of a gate evaluation."""
28
+
29
+ ok: bool
30
+ reasons: List[str]
31
+ current_blocking_ids: List[str]
32
+ new_blocking_ids: List[str]
33
+ current_counts: Dict[str, int]
34
+ baseline_counts: Optional[Dict[str, int]]
35
+
36
+
37
+ def apply_allowlist(scorecard: Scorecard, suppressed: Set[str]) -> Scorecard:
38
+ """Return a new scorecard with suppressed findings removed."""
39
+ if not suppressed:
40
+ return scorecard
41
+ filtered = [f for f in scorecard.findings if finding_id(f) not in suppressed]
42
+ raw = dict(scorecard.raw)
43
+ raw["findings"] = filtered
44
+ # keep summary if present but it's now stale; recompute counts from findings downstream
45
+ raw.pop("summary", None)
46
+ return Scorecard(raw=raw, findings=filtered)
47
+
48
+
49
+ def gate(
50
+ current: Scorecard,
51
+ baseline: Optional[Scorecard],
52
+ fail_on: str = "serious",
53
+ allowlist: Optional[Allowlist] = None,
54
+ ) -> GateResult:
55
+ """Evaluate the policy gate.
56
+
57
+ Args:
58
+ current: Current scorecard to evaluate
59
+ baseline: Optional baseline scorecard for regression detection
60
+ fail_on: Severity threshold (default: serious)
61
+ allowlist: Optional allowlist for suppressions
62
+
63
+ Returns:
64
+ GateResult with pass/fail status and reasons
65
+ """
66
+ fail_on = normalize_severity(fail_on)
67
+
68
+ suppressed: Set[str] = set()
69
+ reasons: List[str] = []
70
+
71
+ if allowlist:
72
+ suppressed = allowlist.suppressed_ids()
73
+ expired = allowlist.expired_entries()
74
+ if expired:
75
+ reasons.append(
76
+ "Allowlist contains expired entries (must be renewed or removed): "
77
+ + ", ".join([e.finding_id for e in expired])
78
+ )
79
+
80
+ cur = apply_allowlist(current, suppressed)
81
+ base = apply_allowlist(baseline, suppressed) if baseline else None
82
+
83
+ cur_counts = cur.counts()
84
+ base_counts = base.counts() if base else None
85
+
86
+ # Rule 1: current has any at/above fail_on
87
+ cur_blocking = cur.ids_at_or_above(fail_on)
88
+ if cur_blocking:
89
+ reasons.append(
90
+ f"Current run has {len(cur_blocking)} finding(s) at or above '{fail_on}'."
91
+ )
92
+
93
+ new_blocking: List[str] = []
94
+ if base:
95
+ # Rule 2: serious+ regression count
96
+ # We treat fail_on as the regression threshold too (default serious)
97
+ def count_at_or_above(counts: Dict[str, int], thr: str) -> int:
98
+ total = 0
99
+ for sev, n in counts.items():
100
+ if severity_ge(sev, thr):
101
+ total += int(n)
102
+ return total
103
+
104
+ cur_n = count_at_or_above(cur_counts, fail_on)
105
+ base_n = count_at_or_above(base_counts or {}, fail_on)
106
+ if cur_n > base_n:
107
+ reasons.append(
108
+ f"Regression: current has {cur_n} finding(s) at/above '{fail_on}' "
109
+ f"vs baseline {base_n}."
110
+ )
111
+
112
+ # Rule 3: new blocking IDs at/above threshold
113
+ base_ids = set(base.ids_at_or_above(fail_on))
114
+ cur_ids = set(cur.ids_at_or_above(fail_on))
115
+ new_blocking = sorted(cur_ids - base_ids)
116
+ if new_blocking:
117
+ reasons.append(
118
+ f"New finding(s) at/above '{fail_on}' not present in baseline: "
119
+ + ", ".join(new_blocking[:20])
120
+ + (" ..." if len(new_blocking) > 20 else "")
121
+ )
122
+
123
+ ok = len([r for r in reasons if r]) == 0
124
+ return GateResult(
125
+ ok=ok,
126
+ reasons=reasons,
127
+ current_blocking_ids=cur_blocking,
128
+ new_blocking_ids=new_blocking,
129
+ current_counts=cur_counts,
130
+ baseline_counts=base_counts,
131
+ )
@@ -0,0 +1,48 @@
1
+ """Low-vision-first CLI message rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List, Literal
7
+
8
+ Status = Literal["OK", "WARN", "ERROR"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class CliMessage:
13
+ """Structured CLI message following the What/Why/Fix contract."""
14
+
15
+ status: Status
16
+ id: str
17
+ title: str
18
+ what: List[str]
19
+ why: List[str]
20
+ fix: List[str]
21
+
22
+
23
+ def render(msg: CliMessage) -> str:
24
+ """Render a CLI message to the low-vision-first format.
25
+
26
+ Format:
27
+ [STATUS] Title (ID: NAMESPACE.CATEGORY.DETAIL)
28
+
29
+ What:
30
+ What happened.
31
+
32
+ Why:
33
+ Why it happened.
34
+
35
+ Fix:
36
+ How to fix it.
37
+ """
38
+ head = f"[{msg.status}] {msg.title} (ID: {msg.id})"
39
+ parts = [head, ""]
40
+ parts.append("What:")
41
+ parts.extend([f" {x}" for x in msg.what])
42
+ parts.append("")
43
+ parts.append("Why:")
44
+ parts.extend([f" {x}" for x in msg.why])
45
+ parts.append("")
46
+ parts.append("Fix:")
47
+ parts.extend([f" {x}" for x in msg.fix])
48
+ return "\n".join(parts) + "\n"
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://mcp-tool-shop.dev/schemas/a11y-ci.allowlist.schema.json",
4
+ "title": "a11y-ci Allowlist",
5
+ "type": "object",
6
+ "required": ["version", "allow"],
7
+ "properties": {
8
+ "version": { "type": "string", "enum": ["1"] },
9
+ "allow": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "required": ["finding_id", "expires", "reason"],
14
+ "properties": {
15
+ "finding_id": { "type": "string", "minLength": 3 },
16
+ "expires": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
17
+ "reason": { "type": "string", "minLength": 10 }
18
+ },
19
+ "additionalProperties": false
20
+ }
21
+ }
22
+ },
23
+ "additionalProperties": false
24
+ }
@@ -0,0 +1,99 @@
1
+ """Scorecard loading and severity handling.
2
+
3
+ This reads a11y-lint scorecards but is defensive: it supports either:
4
+ - summary bucket counts, and/or
5
+ - findings[] with severity and an ID (id, rule_id, or finding_id)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List
14
+
15
+ SEVERITY_ORDER = ["info", "minor", "moderate", "serious", "critical"]
16
+
17
+
18
+ def severity_ge(a: str, threshold: str) -> bool:
19
+ """Check if severity `a` is >= `threshold`."""
20
+ try:
21
+ return SEVERITY_ORDER.index(a) >= SEVERITY_ORDER.index(threshold)
22
+ except ValueError:
23
+ return False
24
+
25
+
26
+ def normalize_severity(s: str) -> str:
27
+ """Normalize severity string to canonical form."""
28
+ s = (s or "").strip().lower()
29
+ if s in SEVERITY_ORDER:
30
+ return s
31
+ # tolerate common alternates
32
+ if s == "warning":
33
+ return "moderate"
34
+ if s == "error":
35
+ return "serious"
36
+ return "info"
37
+
38
+
39
+ def finding_id(f: Dict[str, Any]) -> str:
40
+ """Extract finding ID from a finding dict, tolerating different key names."""
41
+ for k in ("id", "rule_id", "finding_id", "code"):
42
+ v = f.get(k)
43
+ if isinstance(v, str) and v.strip():
44
+ return v.strip()
45
+ # fallback: deterministic-ish
46
+ title = f.get("title") or f.get("message") or "unknown"
47
+ return f"UNKNOWN:{str(title)[:80]}"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Scorecard:
52
+ """Parsed scorecard with findings and computed counts."""
53
+
54
+ raw: Dict[str, Any]
55
+ findings: List[Dict[str, Any]]
56
+
57
+ @staticmethod
58
+ def load(path: str) -> "Scorecard":
59
+ """Load a scorecard from a JSON file."""
60
+ p = Path(path)
61
+ obj = json.loads(p.read_text(encoding="utf-8"))
62
+ findings = obj.get("findings") or []
63
+ if not isinstance(findings, list):
64
+ findings = []
65
+ # normalize severities
66
+ for f in findings:
67
+ if isinstance(f, dict):
68
+ f["severity"] = normalize_severity(str(f.get("severity", "info")))
69
+ return Scorecard(raw=obj, findings=[f for f in findings if isinstance(f, dict)])
70
+
71
+ def counts(self) -> Dict[str, int]:
72
+ """Get severity counts. Prefers summary if present; otherwise computes from findings."""
73
+ s = self.raw.get("summary")
74
+ if isinstance(s, dict) and all(k in s for k in SEVERITY_ORDER):
75
+ out = {k: int(s.get(k, 0) or 0) for k in SEVERITY_ORDER}
76
+ return out
77
+ out = {k: 0 for k in SEVERITY_ORDER}
78
+ for f in self.findings:
79
+ out[normalize_severity(str(f.get("severity")))] += 1
80
+ return out
81
+
82
+ def ids_at_or_above(self, threshold: str) -> List[str]:
83
+ """Get sorted list of finding IDs at or above severity threshold."""
84
+ thr = normalize_severity(threshold)
85
+ ids = []
86
+ for f in self.findings:
87
+ sev = normalize_severity(str(f.get("severity")))
88
+ if severity_ge(sev, thr):
89
+ ids.append(finding_id(f))
90
+ return sorted(set(ids))
91
+
92
+ def findings_at_or_above(self, threshold: str) -> List[Dict[str, Any]]:
93
+ """Get findings at or above severity threshold."""
94
+ thr = normalize_severity(threshold)
95
+ return [
96
+ f
97
+ for f in self.findings
98
+ if severity_ge(normalize_severity(str(f.get("severity"))), thr)
99
+ ]
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@mcptoolshop/a11y-ci",
3
+ "version": "0.2.1",
4
+ "description": "npm wrapper for a11y-ci - CI gate for accessibility scorecards",
5
+ "bin": {
6
+ "a11y-ci": "./bin/a11y-ci.js"
7
+ },
8
+ "scripts": {
9
+ "postinstall": "node postinstall.js"
10
+ },
11
+ "keywords": [
12
+ "accessibility",
13
+ "a11y",
14
+ "ci",
15
+ "testing",
16
+ "wcag",
17
+ "python",
18
+ "wrapper"
19
+ ],
20
+ "author": "mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com>",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/mcp-tool-shop-org/accessibility-suite.git",
25
+ "directory": "src/a11y-ci/npm"
26
+ },
27
+ "homepage": "https://github.com/mcp-tool-shop-org/accessibility-suite#readme",
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org"
34
+ }
35
+ }