@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,434 @@
1
+ """CLI text scanner for accessibility issues.
2
+
3
+ Scans CLI output text and detects common accessibility problems.
4
+
5
+ Rule Categories:
6
+ - WCAG: Rules mapped to WCAG success criteria (failures are accessibility violations)
7
+ - Policy: Cognitive accessibility and best practice rules (not WCAG requirements)
8
+
9
+ This distinction matters: "WCAG doesn't forbid all caps" is true, but this tool
10
+ enforces accessibility policy beyond minimum WCAG compliance.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Callable
19
+
20
+ from .errors import A11yMessage, ErrorCodes, Level, Location
21
+
22
+
23
+ class RuleCategory(Enum):
24
+ """Category of accessibility rule."""
25
+
26
+ WCAG = "wcag" # Mapped to WCAG success criteria
27
+ POLICY = "policy" # Best practice / cognitive accessibility
28
+
29
+
30
+ @dataclass
31
+ class Rule:
32
+ """An accessibility rule that can be checked against text.
33
+
34
+ Attributes:
35
+ name: Rule identifier (e.g., "no-color-only")
36
+ code: Error code (e.g., "CLR001")
37
+ description: Human-readable description
38
+ check: Function that performs the check
39
+ category: WCAG or Policy
40
+ wcag_ref: WCAG success criterion reference (if applicable)
41
+ """
42
+
43
+ name: str
44
+ code: str
45
+ description: str
46
+ check: Callable[[str, str | None, int], A11yMessage | None]
47
+ category: RuleCategory = RuleCategory.POLICY
48
+ wcag_ref: str | None = None
49
+
50
+ def __call__(
51
+ self, text: str, file: str | None = None, line: int = 1
52
+ ) -> A11yMessage | None:
53
+ """Run the rule check."""
54
+ return self.check(text, file, line)
55
+
56
+
57
+ # Common jargon and technical terms that may be unclear
58
+ JARGON_PATTERNS = [
59
+ (r"\bNaN\b", "NaN (Not a Number)"),
60
+ (r"\bEOF\b", "EOF (End of File)"),
61
+ (r"\bEOL\b", "EOL (End of Line)"),
62
+ (r"\bSTDIN\b", "STDIN (standard input)"),
63
+ (r"\bSTDOUT\b", "STDOUT (standard output)"),
64
+ (r"\bSTDERR\b", "STDERR (standard error)"),
65
+ (r"\bSIGKILL\b", "SIGKILL signal"),
66
+ (r"\bSIGTERM\b", "SIGTERM signal"),
67
+ (r"\bOOM\b", "OOM (Out of Memory)"),
68
+ (r"\bTTY\b", "TTY (terminal)"),
69
+ (r"\bPID\b", "PID (process ID)"),
70
+ (r"\bUID\b", "UID (user ID)"),
71
+ (r"\bGID\b", "GID (group ID)"),
72
+ ]
73
+
74
+ # Patterns that suggest color-only information
75
+ COLOR_ONLY_PATTERNS = [
76
+ r"shown in (red|green|yellow|blue)",
77
+ r"(red|green|yellow|blue) indicates",
78
+ r"highlighted in (red|green|yellow|blue)",
79
+ r"marked (red|green|yellow|blue)",
80
+ ]
81
+
82
+ # Maximum recommended line length for readability
83
+ MAX_LINE_LENGTH = 120
84
+
85
+ # Emoji regex (simplified, catches common emoji ranges)
86
+ EMOJI_PATTERN = re.compile(
87
+ r"[\U0001F300-\U0001F9FF]|[\U00002600-\U000027BF]|[\U0001FA00-\U0001FAFF]"
88
+ )
89
+
90
+
91
+ def _make_location(
92
+ file: str | None, line: int, column: int | None = None, context: str | None = None
93
+ ) -> Location:
94
+ """Create a Location object."""
95
+ return Location(file=file, line=line, column=column, context=context)
96
+
97
+
98
+ def check_line_length(text: str, file: str | None, line_num: int) -> A11yMessage | None:
99
+ """Check if any line exceeds the maximum recommended length."""
100
+ lines = text.split("\n")
101
+ for i, line in enumerate(lines):
102
+ if len(line) > MAX_LINE_LENGTH:
103
+ return A11yMessage.warn(
104
+ code=ErrorCodes.LINE_TOO_LONG,
105
+ what=f"Line {line_num + i} is {len(line)} characters long",
106
+ why=(
107
+ "Long lines are difficult to read, especially for users with "
108
+ "cognitive disabilities or those using screen magnification."
109
+ ),
110
+ fix=f"Break the line into multiple lines of {MAX_LINE_LENGTH} characters or fewer.",
111
+ rule="line-length",
112
+ location=_make_location(file, line_num + i, context=line[:80] + "..."),
113
+ )
114
+ return None
115
+
116
+
117
+ def check_all_caps(text: str, file: str | None, line_num: int) -> A11yMessage | None:
118
+ """Check for all-caps messages (excluding short acronyms)."""
119
+ # Find words that are all caps and longer than 4 characters
120
+ words = re.findall(r"\b[A-Z]{5,}\b", text)
121
+ # Filter out common acronyms
122
+ acronyms = {"ERROR", "WARN", "DEBUG", "FATAL", "TRACE", "HTTPS", "HTTP"}
123
+ long_caps = [w for w in words if w not in acronyms]
124
+
125
+ if long_caps:
126
+ return A11yMessage.warn(
127
+ code=ErrorCodes.ALL_CAPS_MESSAGE,
128
+ what=f"All-caps text detected: {', '.join(long_caps[:3])}",
129
+ why=(
130
+ "All-caps text is harder to read and may be interpreted as shouting. "
131
+ "Screen readers may spell out each letter instead of reading words."
132
+ ),
133
+ fix="Use sentence case or title case instead of all caps.",
134
+ rule="no-all-caps",
135
+ location=_make_location(file, line_num),
136
+ )
137
+ return None
138
+
139
+
140
+ def check_jargon(text: str, file: str | None, line_num: int) -> A11yMessage | None:
141
+ """Check for technical jargon that may be unclear."""
142
+ for pattern, term in JARGON_PATTERNS:
143
+ match = re.search(pattern, text)
144
+ if match:
145
+ return A11yMessage.warn(
146
+ code=ErrorCodes.JARGON_DETECTED,
147
+ what=f"Technical jargon detected: '{match.group()}'",
148
+ why=(
149
+ "Technical abbreviations may be unfamiliar to new users or those "
150
+ "using assistive technologies that read text literally."
151
+ ),
152
+ fix=f"Consider expanding or explaining the term: {term}",
153
+ rule="plain-language",
154
+ location=_make_location(
155
+ file, line_num, column=match.start() + 1, context=match.group()
156
+ ),
157
+ )
158
+ return None
159
+
160
+
161
+ def check_color_only(text: str, file: str | None, line_num: int) -> A11yMessage | None:
162
+ """Check for information conveyed only through color."""
163
+ text_lower = text.lower()
164
+ for pattern in COLOR_ONLY_PATTERNS:
165
+ match = re.search(pattern, text_lower)
166
+ if match:
167
+ return A11yMessage.error(
168
+ code=ErrorCodes.COLOR_ONLY_INFO,
169
+ what="Information conveyed only through color",
170
+ why=(
171
+ "Users who are colorblind or using monochrome displays cannot "
172
+ "perceive color-based information. This violates WCAG 2.1 SC 1.4.1."
173
+ ),
174
+ fix=(
175
+ "Supplement color with text indicators like [ERROR], [OK], or icons. "
176
+ "Never rely solely on color to convey meaning."
177
+ ),
178
+ rule="no-color-only",
179
+ location=_make_location(file, line_num, context=match.group()),
180
+ )
181
+ return None
182
+
183
+
184
+ def check_emoji_overuse(text: str, file: str | None, line_num: int) -> A11yMessage | None:
185
+ """Check for excessive emoji use that may confuse screen readers."""
186
+ emojis = EMOJI_PATTERN.findall(text)
187
+ if len(emojis) > 3:
188
+ return A11yMessage.warn(
189
+ code=ErrorCodes.EMOJI_OVERUSE,
190
+ what=f"Excessive emoji use ({len(emojis)} emojis in message)",
191
+ why=(
192
+ "Screen readers announce each emoji by name, which can be verbose "
193
+ "and interrupt the flow of information."
194
+ ),
195
+ fix="Limit emojis to 1-2 per message and ensure meaning is also conveyed in text.",
196
+ rule="emoji-moderation",
197
+ location=_make_location(file, line_num),
198
+ metadata={"emoji_count": len(emojis)},
199
+ )
200
+ return None
201
+
202
+
203
+ def check_missing_punctuation(
204
+ text: str, file: str | None, line_num: int
205
+ ) -> A11yMessage | None:
206
+ """Check if error messages lack proper punctuation."""
207
+ # Only check lines that look like error messages
208
+ if not any(
209
+ marker in text.upper() for marker in ["ERROR", "WARN", "FAIL", "INVALID"]
210
+ ):
211
+ return None
212
+
213
+ # Check if the message ends with punctuation
214
+ stripped = text.rstrip()
215
+ if stripped and stripped[-1] not in ".!?:":
216
+ return A11yMessage.warn(
217
+ code=ErrorCodes.NO_PUNCTUATION,
218
+ what="Error message lacks ending punctuation",
219
+ why=(
220
+ "Proper punctuation helps screen readers use appropriate pauses and "
221
+ "intonation, improving comprehension."
222
+ ),
223
+ fix="End error messages with appropriate punctuation (period, exclamation, or colon).",
224
+ rule="punctuation",
225
+ location=_make_location(file, line_num, context=stripped[-50:]),
226
+ )
227
+ return None
228
+
229
+
230
+ def check_error_structure(
231
+ text: str, file: str | None, line_num: int
232
+ ) -> A11yMessage | None:
233
+ """Check if error messages follow the What/Why/Fix structure."""
234
+ # Look for error indicators
235
+ if not any(marker in text.upper() for marker in ["ERROR", "FAIL", "EXCEPTION"]):
236
+ return None
237
+
238
+ # Check for explanation (why/because/since)
239
+ has_why = any(
240
+ word in text.lower() for word in ["because", "since", "due to", "reason"]
241
+ )
242
+ # Check for fix suggestion
243
+ has_fix = any(
244
+ word in text.lower()
245
+ for word in ["try", "fix", "resolve", "solution", "to fix", "you can"]
246
+ )
247
+
248
+ if not has_why and not has_fix:
249
+ return A11yMessage.warn(
250
+ code=ErrorCodes.MISSING_WHY,
251
+ what="Error message lacks explanation or fix suggestion",
252
+ why=(
253
+ "Users benefit from understanding why an error occurred and how to "
254
+ "fix it. This is especially important for users with cognitive disabilities."
255
+ ),
256
+ fix="Add context explaining why the error occurred and suggest how to resolve it.",
257
+ rule="error-structure",
258
+ location=_make_location(file, line_num),
259
+ )
260
+ return None
261
+
262
+
263
+ def check_ambiguous_pronouns(
264
+ text: str, file: str | None, line_num: int
265
+ ) -> A11yMessage | None:
266
+ """Check for ambiguous pronouns without clear referents."""
267
+ # Patterns like "it failed" or "this is invalid" at the start
268
+ ambiguous = re.search(
269
+ r"^(it|this|that|these|those)\s+(is|was|are|were|failed|error)",
270
+ text.lower().strip(),
271
+ )
272
+ if ambiguous:
273
+ return A11yMessage.warn(
274
+ code=ErrorCodes.AMBIGUOUS_PRONOUN,
275
+ what=f"Ambiguous pronoun '{ambiguous.group(1)}' without clear referent",
276
+ why=(
277
+ "Pronouns without clear referents can confuse users, especially those "
278
+ "with cognitive disabilities or those using screen readers."
279
+ ),
280
+ fix="Replace the pronoun with the specific thing being referenced.",
281
+ rule="no-ambiguous-pronouns",
282
+ location=_make_location(file, line_num, context=ambiguous.group()),
283
+ )
284
+ return None
285
+
286
+
287
+ # Registry of all rules
288
+ # WCAG rules: Mapped to specific success criteria - failures are accessibility violations
289
+ # Policy rules: Best practices for cognitive accessibility - not WCAG requirements
290
+ RULES: list[Rule] = [
291
+ # Policy rules (cognitive accessibility, best practices)
292
+ Rule(
293
+ "line-length", ErrorCodes.LINE_TOO_LONG, "Check line length",
294
+ check_line_length, RuleCategory.POLICY
295
+ ),
296
+ Rule(
297
+ "no-all-caps", ErrorCodes.ALL_CAPS_MESSAGE, "Check for all caps",
298
+ check_all_caps, RuleCategory.POLICY
299
+ ),
300
+ Rule(
301
+ "plain-language", ErrorCodes.JARGON_DETECTED, "Check for jargon",
302
+ check_jargon, RuleCategory.POLICY
303
+ ),
304
+ Rule(
305
+ "emoji-moderation", ErrorCodes.EMOJI_OVERUSE, "Check emoji overuse",
306
+ check_emoji_overuse, RuleCategory.POLICY
307
+ ),
308
+ Rule(
309
+ "punctuation", ErrorCodes.NO_PUNCTUATION, "Check punctuation",
310
+ check_missing_punctuation, RuleCategory.POLICY
311
+ ),
312
+ Rule(
313
+ "error-structure", ErrorCodes.MISSING_WHY, "Check error structure",
314
+ check_error_structure, RuleCategory.POLICY
315
+ ),
316
+ Rule(
317
+ "no-ambiguous-pronouns", ErrorCodes.AMBIGUOUS_PRONOUN, "Check ambiguous pronouns",
318
+ check_ambiguous_pronouns, RuleCategory.POLICY
319
+ ),
320
+ # WCAG rules (mapped to success criteria)
321
+ Rule(
322
+ "no-color-only", ErrorCodes.COLOR_ONLY_INFO, "Check color-only info",
323
+ check_color_only, RuleCategory.WCAG, wcag_ref="1.4.1"
324
+ ),
325
+ ]
326
+
327
+
328
+ class Scanner:
329
+ """Scanner that runs accessibility rules against CLI text."""
330
+
331
+ def __init__(self, rules: list[Rule] | None = None) -> None:
332
+ """Initialize the scanner.
333
+
334
+ Args:
335
+ rules: Rules to use (default: all rules)
336
+ """
337
+ self.rules = rules or RULES.copy()
338
+ self.messages: list[A11yMessage] = []
339
+
340
+ def enable_rule(self, name: str) -> None:
341
+ """Enable a rule by name."""
342
+ for rule in RULES:
343
+ if rule.name == name and rule not in self.rules:
344
+ self.rules.append(rule)
345
+ break
346
+
347
+ def disable_rule(self, name: str) -> None:
348
+ """Disable a rule by name."""
349
+ self.rules = [r for r in self.rules if r.name != name]
350
+
351
+ def scan_line(self, line: str, file: str | None = None, line_num: int = 1) -> list[A11yMessage]:
352
+ """Scan a single line of text.
353
+
354
+ Args:
355
+ line: Line to scan
356
+ file: Source file path (for error reporting)
357
+ line_num: Line number (for error reporting)
358
+
359
+ Returns:
360
+ List of issues found
361
+ """
362
+ issues = []
363
+ for rule in self.rules:
364
+ result = rule(line, file, line_num)
365
+ if result:
366
+ issues.append(result)
367
+ return issues
368
+
369
+ def scan_text(self, text: str, file: str | None = None) -> list[A11yMessage]:
370
+ """Scan a block of text.
371
+
372
+ Args:
373
+ text: Text to scan
374
+ file: Source file path (for error reporting)
375
+
376
+ Returns:
377
+ List of issues found
378
+ """
379
+ self.messages = []
380
+ lines = text.split("\n")
381
+
382
+ for i, line in enumerate(lines, start=1):
383
+ if line.strip(): # Skip empty lines
384
+ issues = self.scan_line(line, file, i)
385
+ self.messages.extend(issues)
386
+
387
+ return self.messages
388
+
389
+ def scan_file(self, path: str) -> list[A11yMessage]:
390
+ """Scan a file for accessibility issues.
391
+
392
+ Args:
393
+ path: Path to file to scan
394
+
395
+ Returns:
396
+ List of issues found
397
+ """
398
+ with open(path, "r", encoding="utf-8") as f:
399
+ text = f.read()
400
+ return self.scan_text(text, file=path)
401
+
402
+ @property
403
+ def error_count(self) -> int:
404
+ """Number of ERROR-level issues found."""
405
+ return sum(1 for m in self.messages if m.level == Level.ERROR)
406
+
407
+ @property
408
+ def warn_count(self) -> int:
409
+ """Number of WARN-level issues found."""
410
+ return sum(1 for m in self.messages if m.level == Level.WARN)
411
+
412
+ @property
413
+ def has_errors(self) -> bool:
414
+ """Check if any ERROR-level issues were found."""
415
+ return self.error_count > 0
416
+
417
+
418
+ def scan(text: str, file: str | None = None) -> list[A11yMessage]:
419
+ """Convenience function to scan text with default rules.
420
+
421
+ Args:
422
+ text: Text to scan
423
+ file: Source file path (for error reporting)
424
+
425
+ Returns:
426
+ List of issues found
427
+ """
428
+ scanner = Scanner()
429
+ return scanner.scan_text(text, file)
430
+
431
+
432
+ def get_rule_names() -> list[str]:
433
+ """Get the names of all available rules."""
434
+ return [r.name for r in RULES]
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://a11y-lint.dev/schemas/cli.error.schema.v0.1.json",
4
+ "title": "CLI Ground Truth Message",
5
+ "description": "Schema for accessible CLI error messages with What/Why/Fix structure",
6
+ "type": "object",
7
+ "required": ["level", "code", "what"],
8
+ "properties": {
9
+ "level": {
10
+ "type": "string",
11
+ "enum": ["OK", "WARN", "ERROR"],
12
+ "description": "Severity level of the message"
13
+ },
14
+ "code": {
15
+ "type": "string",
16
+ "pattern": "^[A-Z][A-Z0-9]{1,3}[0-9]{3}$",
17
+ "description": "Unique error code (e.g., A11Y001, CLI002)",
18
+ "examples": ["A11Y001", "CLI002", "FMT003"]
19
+ },
20
+ "what": {
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "maxLength": 200,
24
+ "description": "Brief description of what happened or was checked"
25
+ },
26
+ "why": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "maxLength": 500,
30
+ "description": "Explanation of why this matters for accessibility"
31
+ },
32
+ "fix": {
33
+ "type": "string",
34
+ "minLength": 1,
35
+ "maxLength": 500,
36
+ "description": "Actionable suggestion for how to fix the issue"
37
+ },
38
+ "location": {
39
+ "type": "object",
40
+ "description": "Location of the issue in the source",
41
+ "properties": {
42
+ "file": {
43
+ "type": "string",
44
+ "description": "File path where the issue was found"
45
+ },
46
+ "line": {
47
+ "type": "integer",
48
+ "minimum": 1,
49
+ "description": "Line number (1-indexed)"
50
+ },
51
+ "column": {
52
+ "type": "integer",
53
+ "minimum": 1,
54
+ "description": "Column number (1-indexed)"
55
+ },
56
+ "context": {
57
+ "type": "string",
58
+ "maxLength": 200,
59
+ "description": "Snippet of text around the issue"
60
+ }
61
+ }
62
+ },
63
+ "rule": {
64
+ "type": "string",
65
+ "description": "Name of the accessibility rule that was checked",
66
+ "examples": ["contrast-ratio", "plain-language", "error-structure"]
67
+ },
68
+ "metadata": {
69
+ "type": "object",
70
+ "description": "Additional rule-specific metadata",
71
+ "additionalProperties": true
72
+ }
73
+ },
74
+ "if": {
75
+ "properties": {
76
+ "level": { "const": "ERROR" }
77
+ }
78
+ },
79
+ "then": {
80
+ "required": ["level", "code", "what", "why", "fix"]
81
+ },
82
+ "additionalProperties": false
83
+ }