@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,244 @@
1
+ """Scorecard builder for accessibility assessments.
2
+
3
+ Generates scorecards summarizing accessibility check results.
4
+
5
+ Philosophy:
6
+ - Grades (A-F) are DERIVED summaries for executive reporting
7
+ - Grades are NEVER primary - CI gates should be based on:
8
+ - Specific rule failures (especially WCAG-mapped rules)
9
+ - Error count thresholds
10
+ - Regressions from baseline
11
+ - Grades compress nuance; always check underlying rule failures
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from .errors import A11yMessage, Level
20
+
21
+
22
+ @dataclass
23
+ class RuleScore:
24
+ """Score for a single rule."""
25
+
26
+ rule: str
27
+ passed: int = 0
28
+ warnings: int = 0
29
+ errors: int = 0
30
+
31
+ @property
32
+ def total(self) -> int:
33
+ """Total number of checks for this rule."""
34
+ return self.passed + self.warnings + self.errors
35
+
36
+ @property
37
+ def score(self) -> float:
38
+ """Score as a percentage (0-100)."""
39
+ if self.total == 0:
40
+ return 100.0
41
+ # Passed = full points, warnings = half points, errors = no points
42
+ points = self.passed + (self.warnings * 0.5)
43
+ return (points / self.total) * 100
44
+
45
+ @property
46
+ def grade(self) -> str:
47
+ """Letter grade based on score."""
48
+ s = self.score
49
+ if s >= 90:
50
+ return "A"
51
+ elif s >= 80:
52
+ return "B"
53
+ elif s >= 70:
54
+ return "C"
55
+ elif s >= 60:
56
+ return "D"
57
+ else:
58
+ return "F"
59
+
60
+
61
+ @dataclass
62
+ class Scorecard:
63
+ """Accessibility scorecard summarizing check results."""
64
+
65
+ name: str
66
+ rule_scores: dict[str, RuleScore] = field(default_factory=dict)
67
+ messages: list[A11yMessage] = field(default_factory=list)
68
+
69
+ def add_message(self, message: A11yMessage) -> None:
70
+ """Add a message to the scorecard."""
71
+ self.messages.append(message)
72
+
73
+ # Get or create rule score
74
+ rule_name = message.rule or "unknown"
75
+ if rule_name not in self.rule_scores:
76
+ self.rule_scores[rule_name] = RuleScore(rule=rule_name)
77
+
78
+ score = self.rule_scores[rule_name]
79
+ if message.level == Level.OK:
80
+ score.passed += 1
81
+ elif message.level == Level.WARN:
82
+ score.warnings += 1
83
+ elif message.level == Level.ERROR:
84
+ score.errors += 1
85
+
86
+ def add_messages(self, messages: list[A11yMessage]) -> None:
87
+ """Add multiple messages to the scorecard."""
88
+ for msg in messages:
89
+ self.add_message(msg)
90
+
91
+ @property
92
+ def total_passed(self) -> int:
93
+ """Total number of passed checks."""
94
+ return sum(s.passed for s in self.rule_scores.values())
95
+
96
+ @property
97
+ def total_warnings(self) -> int:
98
+ """Total number of warnings."""
99
+ return sum(s.warnings for s in self.rule_scores.values())
100
+
101
+ @property
102
+ def total_errors(self) -> int:
103
+ """Total number of errors."""
104
+ return sum(s.errors for s in self.rule_scores.values())
105
+
106
+ @property
107
+ def total_checks(self) -> int:
108
+ """Total number of checks performed."""
109
+ return self.total_passed + self.total_warnings + self.total_errors
110
+
111
+ @property
112
+ def overall_score(self) -> float:
113
+ """Overall accessibility score (0-100)."""
114
+ if self.total_checks == 0:
115
+ return 100.0
116
+ points = self.total_passed + (self.total_warnings * 0.5)
117
+ return (points / self.total_checks) * 100
118
+
119
+ @property
120
+ def overall_grade(self) -> str:
121
+ """Overall letter grade."""
122
+ s = self.overall_score
123
+ if s >= 90:
124
+ return "A"
125
+ elif s >= 80:
126
+ return "B"
127
+ elif s >= 70:
128
+ return "C"
129
+ elif s >= 60:
130
+ return "D"
131
+ else:
132
+ return "F"
133
+
134
+ @property
135
+ def is_passing(self) -> bool:
136
+ """Check if the scorecard represents a passing assessment."""
137
+ return self.total_errors == 0
138
+
139
+ def summary(self) -> str:
140
+ """Get a text summary of the scorecard."""
141
+ lines = [
142
+ f"Accessibility Scorecard: {self.name}",
143
+ "=" * 40,
144
+ f"Overall Score: {self.overall_score:.1f}% ({self.overall_grade})",
145
+ f"Total Checks: {self.total_checks}",
146
+ f" Passed: {self.total_passed}",
147
+ f" Warnings: {self.total_warnings}",
148
+ f" Errors: {self.total_errors}",
149
+ "",
150
+ "By Rule:",
151
+ ]
152
+
153
+ for rule_name, score in sorted(self.rule_scores.items()):
154
+ lines.append(
155
+ f" {rule_name}: {score.score:.1f}% ({score.grade}) "
156
+ f"[{score.passed}P/{score.warnings}W/{score.errors}E]"
157
+ )
158
+
159
+ return "\n".join(lines)
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ """Convert to dictionary for JSON serialization."""
163
+ return {
164
+ "name": self.name,
165
+ "overall_score": round(self.overall_score, 2),
166
+ "overall_grade": self.overall_grade,
167
+ "is_passing": self.is_passing,
168
+ "totals": {
169
+ "checks": self.total_checks,
170
+ "passed": self.total_passed,
171
+ "warnings": self.total_warnings,
172
+ "errors": self.total_errors,
173
+ },
174
+ "rules": {
175
+ name: {
176
+ "score": round(score.score, 2),
177
+ "grade": score.grade,
178
+ "passed": score.passed,
179
+ "warnings": score.warnings,
180
+ "errors": score.errors,
181
+ }
182
+ for name, score in self.rule_scores.items()
183
+ },
184
+ "messages": [msg.to_dict() for msg in self.messages],
185
+ }
186
+
187
+
188
+ class ScorecardBuilder:
189
+ """Builder for creating scorecards from scan results."""
190
+
191
+ def __init__(self, name: str = "CLI Accessibility Assessment") -> None:
192
+ """Initialize the builder.
193
+
194
+ Args:
195
+ name: Name for the scorecard
196
+ """
197
+ self.scorecard = Scorecard(name=name)
198
+
199
+ def add_scan_result(self, messages: list[A11yMessage]) -> ScorecardBuilder:
200
+ """Add scan results to the scorecard.
201
+
202
+ Args:
203
+ messages: Messages from scanning
204
+
205
+ Returns:
206
+ Self for chaining
207
+ """
208
+ self.scorecard.add_messages(messages)
209
+ return self
210
+
211
+ def add_ok_check(self, rule: str, code: str, what: str) -> ScorecardBuilder:
212
+ """Record a passing check.
213
+
214
+ Args:
215
+ rule: Rule name
216
+ code: Error code
217
+ what: Description of what was checked
218
+
219
+ Returns:
220
+ Self for chaining
221
+ """
222
+ self.scorecard.add_message(A11yMessage.ok(code, what, rule=rule))
223
+ return self
224
+
225
+ def build(self) -> Scorecard:
226
+ """Build and return the scorecard."""
227
+ return self.scorecard
228
+
229
+
230
+ def create_scorecard(
231
+ messages: list[A11yMessage], name: str = "CLI Accessibility Assessment"
232
+ ) -> Scorecard:
233
+ """Convenience function to create a scorecard from messages.
234
+
235
+ Args:
236
+ messages: Messages to include
237
+ name: Name for the scorecard
238
+
239
+ Returns:
240
+ Built scorecard
241
+ """
242
+ builder = ScorecardBuilder(name)
243
+ builder.add_scan_result(messages)
244
+ return builder.build()
@@ -0,0 +1,225 @@
1
+ """Schema validation for CLI accessibility messages.
2
+
3
+ Validates that messages conform to the ground truth schema.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import jsonschema
13
+ from jsonschema import Draft202012Validator, ValidationError
14
+
15
+ from .errors import A11yMessage, Level
16
+
17
+ # Path to the schema file
18
+ SCHEMA_PATH = Path(__file__).parent / "schemas" / "cli.error.schema.v0.1.json"
19
+
20
+
21
+ def load_schema() -> dict[str, Any]:
22
+ """Load the CLI error schema from disk."""
23
+ with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
24
+ return json.load(f)
25
+
26
+
27
+ # Cache the schema and validator
28
+ _schema: dict[str, Any] | None = None
29
+ _validator: Draft202012Validator | None = None
30
+
31
+
32
+ def get_validator() -> Draft202012Validator:
33
+ """Get or create the cached schema validator."""
34
+ global _schema, _validator
35
+ if _validator is None:
36
+ _schema = load_schema()
37
+ _validator = Draft202012Validator(_schema)
38
+ return _validator
39
+
40
+
41
+ def validate_dict(data: dict[str, Any]) -> list[str]:
42
+ """Validate a dictionary against the CLI error schema.
43
+
44
+ Args:
45
+ data: Dictionary to validate
46
+
47
+ Returns:
48
+ List of validation error messages (empty if valid)
49
+ """
50
+ validator = get_validator()
51
+ errors = []
52
+
53
+ for error in validator.iter_errors(data):
54
+ # Build a human-readable path to the error
55
+ path = ".".join(str(p) for p in error.path) if error.path else "root"
56
+ errors.append(f"{path}: {error.message}")
57
+
58
+ return errors
59
+
60
+
61
+ def validate_message(message: A11yMessage) -> list[str]:
62
+ """Validate an A11yMessage against the schema.
63
+
64
+ Args:
65
+ message: Message to validate
66
+
67
+ Returns:
68
+ List of validation error messages (empty if valid)
69
+ """
70
+ return validate_dict(message.to_dict())
71
+
72
+
73
+ def is_valid(data: dict[str, Any] | A11yMessage) -> bool:
74
+ """Check if data is valid against the schema.
75
+
76
+ Args:
77
+ data: Dictionary or A11yMessage to validate
78
+
79
+ Returns:
80
+ True if valid, False otherwise
81
+ """
82
+ if isinstance(data, A11yMessage):
83
+ data = data.to_dict()
84
+ return len(validate_dict(data)) == 0
85
+
86
+
87
+ def validate_json_file(path: Path | str) -> tuple[list[dict[str, Any]], list[str]]:
88
+ """Validate a JSON file containing messages.
89
+
90
+ The file can contain either a single message object or an array of messages.
91
+
92
+ Args:
93
+ path: Path to the JSON file
94
+
95
+ Returns:
96
+ Tuple of (valid messages, validation errors)
97
+ """
98
+ path = Path(path)
99
+ errors: list[str] = []
100
+
101
+ try:
102
+ with open(path, "r", encoding="utf-8") as f:
103
+ data = json.load(f)
104
+ except json.JSONDecodeError as e:
105
+ return [], [f"Invalid JSON: {e}"]
106
+ except FileNotFoundError:
107
+ return [], [f"File not found: {path}"]
108
+
109
+ # Handle both single object and array
110
+ if isinstance(data, dict):
111
+ messages = [data]
112
+ elif isinstance(data, list):
113
+ messages = data
114
+ else:
115
+ return [], ["JSON must be an object or array of objects"]
116
+
117
+ valid_messages = []
118
+ for i, msg in enumerate(messages):
119
+ if not isinstance(msg, dict):
120
+ errors.append(f"Message {i}: expected object, got {type(msg).__name__}")
121
+ continue
122
+
123
+ msg_errors = validate_dict(msg)
124
+ if msg_errors:
125
+ for err in msg_errors:
126
+ errors.append(f"Message {i}: {err}")
127
+ else:
128
+ valid_messages.append(msg)
129
+
130
+ return valid_messages, errors
131
+
132
+
133
+ def validate_and_convert(data: dict[str, Any]) -> A11yMessage | list[str]:
134
+ """Validate and convert a dictionary to an A11yMessage.
135
+
136
+ Args:
137
+ data: Dictionary to validate and convert
138
+
139
+ Returns:
140
+ A11yMessage if valid, list of errors otherwise
141
+ """
142
+ errors = validate_dict(data)
143
+ if errors:
144
+ return errors
145
+
146
+ try:
147
+ return A11yMessage.from_dict(data)
148
+ except (ValueError, KeyError) as e:
149
+ return [str(e)]
150
+
151
+
152
+ class MessageValidator:
153
+ """Validator for batches of messages with summary statistics."""
154
+
155
+ def __init__(self) -> None:
156
+ self.valid_count = 0
157
+ self.invalid_count = 0
158
+ self.errors: list[tuple[int, list[str]]] = []
159
+ self.messages: list[A11yMessage] = []
160
+
161
+ def validate(self, data: dict[str, Any], index: int = 0) -> bool:
162
+ """Validate a single message.
163
+
164
+ Args:
165
+ data: Message dictionary to validate
166
+ index: Index for error reporting
167
+
168
+ Returns:
169
+ True if valid, False otherwise
170
+ """
171
+ result = validate_and_convert(data)
172
+ if isinstance(result, list):
173
+ self.invalid_count += 1
174
+ self.errors.append((index, result))
175
+ return False
176
+ else:
177
+ self.valid_count += 1
178
+ self.messages.append(result)
179
+ return True
180
+
181
+ def validate_batch(self, messages: list[dict[str, Any]]) -> None:
182
+ """Validate a batch of messages.
183
+
184
+ Args:
185
+ messages: List of message dictionaries to validate
186
+ """
187
+ for i, msg in enumerate(messages):
188
+ self.validate(msg, i)
189
+
190
+ @property
191
+ def is_all_valid(self) -> bool:
192
+ """Check if all validated messages were valid."""
193
+ return self.invalid_count == 0
194
+
195
+ @property
196
+ def total_count(self) -> int:
197
+ """Get total number of validated messages."""
198
+ return self.valid_count + self.invalid_count
199
+
200
+ def summary(self) -> str:
201
+ """Get a summary of validation results."""
202
+ total = self.total_count
203
+ if total == 0:
204
+ return "No messages validated"
205
+
206
+ if self.is_all_valid:
207
+ return f"All {total} messages valid"
208
+
209
+ return (
210
+ f"Validated {total} messages: "
211
+ f"{self.valid_count} valid, {self.invalid_count} invalid"
212
+ )
213
+
214
+ def error_report(self) -> str:
215
+ """Get detailed error report."""
216
+ if not self.errors:
217
+ return "No errors"
218
+
219
+ lines = [f"Found {len(self.errors)} invalid messages:"]
220
+ for index, errs in self.errors:
221
+ lines.append(f"\n Message {index}:")
222
+ for err in errs:
223
+ lines.append(f" - {err}")
224
+
225
+ return "\n".join(lines)
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "a11y-lint"
7
+ version = "0.1.0"
8
+ description = "Accessibility linter for CLI output - validates error messages follow accessible patterns"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ { name = "mcp-tool-shop", email = "64996768+mcp-tool-shop@users.noreply.github.com" }
14
+ ]
15
+ keywords = [
16
+ "accessibility",
17
+ "a11y",
18
+ "cli",
19
+ "linter",
20
+ "validation",
21
+ "wcag",
22
+ "low-vision",
23
+ "error-messages",
24
+ "testing",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 3 - Alpha",
28
+ "Environment :: Console",
29
+ "Intended Audience :: Developers",
30
+ "License :: OSI Approved :: MIT License",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Topic :: Software Development :: Quality Assurance",
36
+ "Topic :: Software Development :: Testing",
37
+ ]
38
+ dependencies = [
39
+ "jsonschema>=4.20.0",
40
+ "click>=8.1.0",
41
+ "rich>=13.0.0",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ dev = [
46
+ "pytest>=7.4.0",
47
+ "pytest-cov>=4.1.0",
48
+ "pyright>=1.1.350",
49
+ ]
50
+
51
+ [project.scripts]
52
+ a11y-lint = "a11y_lint.cli:main"
53
+
54
+ [project.urls]
55
+ Homepage = "https://github.com/mcp-tool-shop-org/a11y-lint"
56
+ Repository = "https://github.com/mcp-tool-shop-org/a11y-lint"
57
+ Issues = "https://github.com/mcp-tool-shop-org/a11y-lint/issues"
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["."]
61
+ include = ["a11y_lint*"]
62
+
63
+ [tool.setuptools.package-data]
64
+ a11y_lint = ["schemas/*.json"]
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
68
+ python_files = ["test_*.py"]
69
+ python_functions = ["test_*"]
70
+ addopts = "-v --tb=short"
71
+
72
+ [tool.pyright]
73
+ include = ["a11y_lint", "tests"]
74
+ pythonVersion = "3.10"
75
+ typeCheckingMode = "standard"
@@ -0,0 +1 @@
1
+ """Tests for a11y-lint."""