@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,311 @@
1
+ """Tests for the ingest command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from a11y_assist.ingest import (
12
+ IngestError,
13
+ build_advisories,
14
+ canonicalize,
15
+ group_by_file,
16
+ group_by_rule,
17
+ ingest,
18
+ load_findings,
19
+ verify_provenance,
20
+ write_advisories,
21
+ write_ingest_summary,
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def sample_findings(tmp_path: Path) -> Path:
27
+ """Create a sample findings.json for testing."""
28
+ findings = {
29
+ "engine": "a11y-evidence-engine",
30
+ "version": "0.1.0",
31
+ "target": {"path": "./test-html"},
32
+ "summary": {"files_scanned": 2, "errors": 3, "warnings": 1, "info": 0},
33
+ "findings": [
34
+ {
35
+ "finding_id": "finding-0001",
36
+ "rule_id": "html.img.missing_alt",
37
+ "severity": "error",
38
+ "confidence": 0.98,
39
+ "message": "Image element is missing alt text.",
40
+ "location": {"file": "index.html", "json_pointer": "/nodes/5"},
41
+ "evidence_ref": {
42
+ "record": "provenance/finding-0001/record.json",
43
+ "digest": "provenance/finding-0001/digest.json",
44
+ "envelope": "provenance/finding-0001/envelope.json",
45
+ },
46
+ },
47
+ {
48
+ "finding_id": "finding-0002",
49
+ "rule_id": "html.img.missing_alt",
50
+ "severity": "error",
51
+ "confidence": 0.98,
52
+ "message": "Image element is missing alt text.",
53
+ "location": {"file": "index.html", "json_pointer": "/nodes/8"},
54
+ "evidence_ref": {
55
+ "record": "provenance/finding-0002/record.json",
56
+ "digest": "provenance/finding-0002/digest.json",
57
+ "envelope": "provenance/finding-0002/envelope.json",
58
+ },
59
+ },
60
+ {
61
+ "finding_id": "finding-0003",
62
+ "rule_id": "html.form_control.missing_label",
63
+ "severity": "error",
64
+ "confidence": 0.95,
65
+ "message": "Form control is missing an associated label.",
66
+ "location": {"file": "form.html", "json_pointer": "/nodes/3"},
67
+ "evidence_ref": {
68
+ "record": "provenance/finding-0003/record.json",
69
+ "digest": "provenance/finding-0003/digest.json",
70
+ "envelope": "provenance/finding-0003/envelope.json",
71
+ },
72
+ },
73
+ {
74
+ "finding_id": "finding-0004",
75
+ "rule_id": "html.document.missing_lang",
76
+ "severity": "warning",
77
+ "confidence": 1.0,
78
+ "message": "Document is missing lang attribute.",
79
+ "location": {"file": "form.html", "json_pointer": "/nodes/0"},
80
+ "evidence_ref": {
81
+ "record": "provenance/finding-0004/record.json",
82
+ "digest": "provenance/finding-0004/digest.json",
83
+ "envelope": "provenance/finding-0004/envelope.json",
84
+ },
85
+ },
86
+ ],
87
+ }
88
+
89
+ findings_path = tmp_path / "findings.json"
90
+ findings_path.write_text(json.dumps(findings, indent=2))
91
+ return findings_path
92
+
93
+
94
+ @pytest.fixture
95
+ def findings_with_provenance(tmp_path: Path, sample_findings: Path) -> Path:
96
+ """Create findings with valid provenance bundles."""
97
+ # Create provenance directories
98
+ for i in range(1, 5):
99
+ prov_dir = tmp_path / f"provenance/finding-000{i}"
100
+ prov_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ # Evidence content
103
+ evidence = {
104
+ "document_ref": "test.html",
105
+ "pointer": f"/nodes/{i}",
106
+ "evidence": {"tagName": "img", "attrs": {}},
107
+ }
108
+
109
+ # Compute digest
110
+ canonical = canonicalize(evidence)
111
+ import hashlib
112
+
113
+ digest_value = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
114
+
115
+ # Write record.json
116
+ record = {
117
+ "prov.record.v0.1": {
118
+ "method_id": "engine.extract.evidence.json_pointer",
119
+ "timestamp": "2026-01-26T00:00:00Z",
120
+ "inputs": [],
121
+ "outputs": [{"artifact.v0.1": {"name": "evidence", "content": evidence}}],
122
+ "agent": {"name": "test", "version": "1.0"},
123
+ }
124
+ }
125
+ (prov_dir / "record.json").write_text(json.dumps(record))
126
+
127
+ # Write digest.json
128
+ digest = {
129
+ "prov.record.v0.1": {
130
+ "method_id": "integrity.digest.sha256",
131
+ "timestamp": "2026-01-26T00:00:00Z",
132
+ "inputs": [],
133
+ "outputs": [
134
+ {
135
+ "artifact.v0.1": {
136
+ "name": "digest",
137
+ "digest": {"algorithm": "sha256", "value": digest_value},
138
+ }
139
+ }
140
+ ],
141
+ "agent": {"name": "test", "version": "1.0"},
142
+ }
143
+ }
144
+ (prov_dir / "digest.json").write_text(json.dumps(digest))
145
+
146
+ # Write envelope.json
147
+ envelope = {"mcp.envelope.v0.1": {"result": {}, "provenance": {}}}
148
+ (prov_dir / "envelope.json").write_text(json.dumps(envelope))
149
+
150
+ return sample_findings
151
+
152
+
153
+ class TestLoadFindings:
154
+ def test_load_valid_findings(self, sample_findings: Path):
155
+ data = load_findings(sample_findings)
156
+ assert data["engine"] == "a11y-evidence-engine"
157
+ assert len(data["findings"]) == 4
158
+
159
+ def test_load_missing_file(self, tmp_path: Path):
160
+ with pytest.raises(IngestError, match="not found"):
161
+ load_findings(tmp_path / "nonexistent.json")
162
+
163
+ def test_load_invalid_json(self, tmp_path: Path):
164
+ bad_file = tmp_path / "bad.json"
165
+ bad_file.write_text("not json")
166
+ with pytest.raises(IngestError, match="Invalid JSON"):
167
+ load_findings(bad_file)
168
+
169
+ def test_load_missing_required_fields(self, tmp_path: Path):
170
+ incomplete = tmp_path / "incomplete.json"
171
+ incomplete.write_text('{"engine": "test"}')
172
+ with pytest.raises(IngestError, match="Missing required"):
173
+ load_findings(incomplete)
174
+
175
+
176
+ class TestCanonicalize:
177
+ def test_sorted_keys(self):
178
+ assert canonicalize({"z": 1, "a": 2}) == '{"a":2,"z":1}'
179
+
180
+ def test_nested_objects(self):
181
+ obj = {"b": {"d": 1, "c": 2}, "a": 1}
182
+ assert canonicalize(obj) == '{"a":1,"b":{"c":2,"d":1}}'
183
+
184
+ def test_arrays_preserve_order(self):
185
+ assert canonicalize([3, 1, 2]) == "[3,1,2]"
186
+
187
+ def test_null_and_booleans(self):
188
+ assert canonicalize(None) == "null"
189
+ assert canonicalize(True) == "true"
190
+ assert canonicalize(False) == "false"
191
+
192
+ def test_strings_escaped(self):
193
+ assert canonicalize('hello "world"') == '"hello \\"world\\""'
194
+
195
+
196
+ class TestGroupByRule:
197
+ def test_groups_correctly(self, sample_findings: Path):
198
+ data = load_findings(sample_findings)
199
+ grouped = group_by_rule(data["findings"])
200
+
201
+ assert len(grouped) == 3
202
+ # Should be sorted by count descending
203
+ assert grouped[0]["rule_id"] == "html.img.missing_alt"
204
+ assert grouped[0]["count"] == 2
205
+
206
+
207
+ class TestGroupByFile:
208
+ def test_groups_correctly(self, sample_findings: Path):
209
+ data = load_findings(sample_findings)
210
+ grouped = group_by_file(data["findings"])
211
+
212
+ assert len(grouped) == 2
213
+ # index.html has 2 errors, form.html has 1 error + 1 warning
214
+ assert grouped[0]["file"] == "index.html"
215
+ assert grouped[0]["errors"] == 2
216
+
217
+
218
+ class TestBuildAdvisories:
219
+ def test_builds_advisories(self, sample_findings: Path):
220
+ data = load_findings(sample_findings)
221
+ advisories = build_advisories(data["findings"])
222
+
223
+ assert len(advisories) == 3
224
+ # First advisory should be for most common rule
225
+ assert advisories[0]["rule_id"] == "html.img.missing_alt"
226
+ assert len(advisories[0]["instances"]) == 2
227
+ assert advisories[0]["title"] == "Add alt text to images"
228
+
229
+
230
+ class TestIngest:
231
+ def test_basic_ingest(self, sample_findings: Path):
232
+ result = ingest(sample_findings)
233
+
234
+ assert result.source_engine == "a11y-evidence-engine"
235
+ assert result.source_version == "0.1.0"
236
+ assert len(result.findings) == 4
237
+ assert len(result.by_rule) == 3
238
+
239
+ def test_filter_by_severity(self, sample_findings: Path):
240
+ result = ingest(sample_findings, min_severity="error")
241
+
242
+ # Should exclude the warning
243
+ assert len(result.findings) == 3
244
+
245
+ def test_provenance_verification_missing_files(self, sample_findings: Path):
246
+ result = ingest(sample_findings, verify_provenance_flag=True)
247
+
248
+ # Should have errors because provenance files don't exist
249
+ assert not result.provenance_verified
250
+ assert len(result.provenance_errors) > 0
251
+
252
+ def test_provenance_verification_success(self, findings_with_provenance: Path):
253
+ result = ingest(findings_with_provenance, verify_provenance_flag=True)
254
+
255
+ assert result.provenance_verified
256
+ assert len(result.provenance_errors) == 0
257
+
258
+
259
+ class TestWriteOutputs:
260
+ def test_write_summary(self, sample_findings: Path, tmp_path: Path):
261
+ result = ingest(sample_findings)
262
+ out_path = tmp_path / "output" / "ingest-summary.json"
263
+
264
+ write_ingest_summary(result, out_path)
265
+
266
+ assert out_path.exists()
267
+ data = json.loads(out_path.read_text())
268
+ assert data["source_engine"] == "a11y-evidence-engine"
269
+ assert "by_rule" in data
270
+
271
+ def test_write_advisories(self, sample_findings: Path, tmp_path: Path):
272
+ result = ingest(sample_findings)
273
+ out_path = tmp_path / "output" / "advisories.json"
274
+
275
+ write_advisories(result, out_path)
276
+
277
+ assert out_path.exists()
278
+ data = json.loads(out_path.read_text())
279
+ assert data["schema"] == "a11y-assist/advisories@v0.1"
280
+ assert len(data["advisories"]) == 3
281
+
282
+
283
+ class TestVerifyProvenance:
284
+ def test_missing_evidence_ref(self, tmp_path: Path):
285
+ finding = {"finding_id": "test-001"}
286
+ success, error = verify_provenance(finding, tmp_path)
287
+
288
+ assert not success
289
+ assert "Missing evidence_ref" in error
290
+
291
+ def test_missing_file(self, tmp_path: Path):
292
+ finding = {
293
+ "finding_id": "test-001",
294
+ "evidence_ref": {
295
+ "record": "provenance/record.json",
296
+ "digest": "provenance/digest.json",
297
+ "envelope": "provenance/envelope.json",
298
+ },
299
+ }
300
+ success, error = verify_provenance(finding, tmp_path)
301
+
302
+ assert not success
303
+ assert "not found" in error
304
+
305
+ def test_valid_provenance(self, findings_with_provenance: Path):
306
+ data = load_findings(findings_with_provenance)
307
+ base_dir = findings_with_provenance.parent
308
+
309
+ for finding in data["findings"]:
310
+ success, error = verify_provenance(finding, base_dir)
311
+ assert success, f"Failed for {finding['finding_id']}: {error}"
@@ -0,0 +1,236 @@
1
+ """Tests for methods metadata (audit-only fields).
2
+
3
+ These tests verify:
4
+ 1. Metadata is populated deterministically
5
+ 2. Metadata does not affect rendering output
6
+ 3. Evidence anchors are correct
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from a11y_assist.from_cli_error import assist_from_cli_error, load_cli_error
14
+ from a11y_assist.methods import (
15
+ METHOD_GUARD_VALIDATE,
16
+ METHOD_NORMALIZE_CLI_ERROR,
17
+ METHOD_NORMALIZE_RAW_TEXT,
18
+ METHOD_PROFILE_COGNITIVE_LOAD,
19
+ METHOD_PROFILE_DYSLEXIA,
20
+ METHOD_PROFILE_LOWVISION,
21
+ METHOD_PROFILE_PLAIN_LANGUAGE,
22
+ METHOD_PROFILE_SCREEN_READER,
23
+ with_method,
24
+ with_methods,
25
+ )
26
+ from a11y_assist.profiles import (
27
+ apply_cognitive_load,
28
+ apply_screen_reader,
29
+ render_cognitive_load,
30
+ )
31
+ from a11y_assist.render import AssistResult, Evidence, render_assist
32
+
33
+ FIXTURES_DIR = Path(__file__).parent / "fixtures"
34
+
35
+
36
+ class TestCliErrorMetadata:
37
+ """Tests for metadata from cli.error.v0.1 path."""
38
+
39
+ @pytest.fixture
40
+ def result(self):
41
+ """Load high-confidence fixture and get AssistResult."""
42
+ obj = load_cli_error(str(FIXTURES_DIR / "base_inputs" / "cli_error_high.json"))
43
+ return assist_from_cli_error(obj)
44
+
45
+ def test_methods_applied_contains_normalize(self, result):
46
+ """Result should include normalization method."""
47
+ assert METHOD_NORMALIZE_CLI_ERROR in result.methods_applied
48
+
49
+ def test_evidence_for_safest_next_step(self, result):
50
+ """Evidence should include safest_next_step source."""
51
+ fields = [e.field for e in result.evidence]
52
+ assert "safest_next_step" in fields
53
+
54
+ # Find the evidence for safest_next_step
55
+ for e in result.evidence:
56
+ if e.field == "safest_next_step":
57
+ assert e.source.startswith("cli.error.")
58
+ break
59
+
60
+ def test_evidence_for_plan_steps(self, result):
61
+ """Evidence should include plan step sources."""
62
+ plan_evidence = [e for e in result.evidence if e.field.startswith("plan[")]
63
+ # Should have evidence for each plan step
64
+ assert len(plan_evidence) == len(result.plan)
65
+
66
+ # Each should reference cli.error.fix
67
+ for e in plan_evidence:
68
+ assert "cli.error.fix" in e.source
69
+
70
+ def test_evidence_for_safe_commands(self, result):
71
+ """Evidence should include safe command sources."""
72
+ if result.next_safe_commands:
73
+ cmd_evidence = [
74
+ e for e in result.evidence if e.field.startswith("next_safe_commands[")
75
+ ]
76
+ assert len(cmd_evidence) == len(result.next_safe_commands)
77
+
78
+
79
+ class TestMetadataDoesNotAffectRendering:
80
+ """Verify that metadata doesn't change rendered output."""
81
+
82
+ @pytest.fixture
83
+ def result(self):
84
+ """Load fixture and get AssistResult."""
85
+ obj = load_cli_error(str(FIXTURES_DIR / "base_inputs" / "cli_error_high.json"))
86
+ return assist_from_cli_error(obj)
87
+
88
+ def test_lowvision_render_unchanged_with_metadata(self, result):
89
+ """Lowvision render should be same with or without metadata."""
90
+ # Render with metadata
91
+ output_with = render_assist(result)
92
+
93
+ # Create same result without metadata
94
+ result_without = AssistResult(
95
+ anchored_id=result.anchored_id,
96
+ confidence=result.confidence,
97
+ safest_next_step=result.safest_next_step,
98
+ plan=result.plan,
99
+ next_safe_commands=result.next_safe_commands,
100
+ notes=result.notes,
101
+ methods_applied=(),
102
+ evidence=(),
103
+ )
104
+ output_without = render_assist(result_without)
105
+
106
+ assert output_with == output_without
107
+
108
+ def test_cognitive_load_render_unchanged_with_metadata(self, result):
109
+ """Cognitive load render should be same with or without metadata."""
110
+ transformed = apply_cognitive_load(result)
111
+ output_with = render_cognitive_load(transformed)
112
+
113
+ # Same transform but clear metadata
114
+ transformed_clean = AssistResult(
115
+ anchored_id=transformed.anchored_id,
116
+ confidence=transformed.confidence,
117
+ safest_next_step=transformed.safest_next_step,
118
+ plan=transformed.plan,
119
+ next_safe_commands=transformed.next_safe_commands,
120
+ notes=transformed.notes,
121
+ methods_applied=(),
122
+ evidence=(),
123
+ )
124
+ output_without = render_cognitive_load(transformed_clean)
125
+
126
+ assert output_with == output_without
127
+
128
+
129
+ class TestMetadataDeterminism:
130
+ """Verify metadata is deterministic."""
131
+
132
+ def test_same_input_same_methods(self):
133
+ """Same input should produce same methods_applied."""
134
+ obj = load_cli_error(str(FIXTURES_DIR / "base_inputs" / "cli_error_high.json"))
135
+
136
+ results = []
137
+ for _ in range(3):
138
+ result = assist_from_cli_error(obj)
139
+ results.append(result.methods_applied)
140
+
141
+ # All should be identical
142
+ assert all(r == results[0] for r in results)
143
+
144
+ def test_same_input_same_evidence(self):
145
+ """Same input should produce same evidence."""
146
+ obj = load_cli_error(str(FIXTURES_DIR / "base_inputs" / "cli_error_high.json"))
147
+
148
+ results = []
149
+ for _ in range(3):
150
+ result = assist_from_cli_error(obj)
151
+ results.append(result.evidence)
152
+
153
+ # All should be identical
154
+ assert all(r == results[0] for r in results)
155
+
156
+
157
+ class TestWithMethodsHelpers:
158
+ """Tests for the methods.py helper functions."""
159
+
160
+ @pytest.fixture
161
+ def base_result(self):
162
+ """Create a minimal AssistResult."""
163
+ return AssistResult(
164
+ anchored_id="TEST.001",
165
+ confidence="High",
166
+ safest_next_step="Do something.",
167
+ plan=["Step 1", "Step 2"],
168
+ next_safe_commands=[],
169
+ notes=[],
170
+ methods_applied=(),
171
+ evidence=(),
172
+ )
173
+
174
+ def test_with_method_adds_single(self, base_result):
175
+ """with_method should add a single method ID."""
176
+ updated = with_method(base_result, "test.method")
177
+ assert "test.method" in updated.methods_applied
178
+
179
+ def test_with_method_preserves_existing(self, base_result):
180
+ """with_method should preserve existing methods."""
181
+ updated = with_method(base_result, "first.method")
182
+ updated = with_method(updated, "second.method")
183
+ assert "first.method" in updated.methods_applied
184
+ assert "second.method" in updated.methods_applied
185
+
186
+ def test_with_method_deduplicates(self, base_result):
187
+ """with_method should not add duplicates."""
188
+ updated = with_method(base_result, "test.method")
189
+ updated = with_method(updated, "test.method")
190
+ assert updated.methods_applied.count("test.method") == 1
191
+
192
+ def test_with_methods_adds_multiple(self, base_result):
193
+ """with_methods should add multiple method IDs."""
194
+ updated = with_methods(base_result, ["method.a", "method.b", "method.c"])
195
+ assert "method.a" in updated.methods_applied
196
+ assert "method.b" in updated.methods_applied
197
+ assert "method.c" in updated.methods_applied
198
+
199
+
200
+ class TestMethodIDConstants:
201
+ """Verify method ID constants are stable and well-formed."""
202
+
203
+ def test_normalize_methods_exist(self):
204
+ """Normalization method IDs should exist."""
205
+ assert METHOD_NORMALIZE_CLI_ERROR == "engine.normalize.from_cli_error_v0_1"
206
+ assert METHOD_NORMALIZE_RAW_TEXT == "engine.normalize.from_raw_text"
207
+
208
+ def test_profile_methods_exist(self):
209
+ """Profile method IDs should exist."""
210
+ assert METHOD_PROFILE_LOWVISION == "profile.lowvision.apply"
211
+ assert METHOD_PROFILE_COGNITIVE_LOAD == "profile.cognitive_load.apply"
212
+ assert METHOD_PROFILE_SCREEN_READER == "profile.screen_reader.apply"
213
+ assert METHOD_PROFILE_DYSLEXIA == "profile.dyslexia.apply"
214
+ assert METHOD_PROFILE_PLAIN_LANGUAGE == "profile.plain_language.apply"
215
+
216
+ def test_guard_methods_exist(self):
217
+ """Guard method IDs should exist."""
218
+ assert METHOD_GUARD_VALIDATE == "guard.validate_profile_transform"
219
+
220
+ def test_method_ids_are_dotted_namespace(self):
221
+ """All method IDs should follow dotted namespace convention."""
222
+ all_methods = [
223
+ METHOD_NORMALIZE_CLI_ERROR,
224
+ METHOD_NORMALIZE_RAW_TEXT,
225
+ METHOD_PROFILE_LOWVISION,
226
+ METHOD_PROFILE_COGNITIVE_LOAD,
227
+ METHOD_PROFILE_SCREEN_READER,
228
+ METHOD_PROFILE_DYSLEXIA,
229
+ METHOD_PROFILE_PLAIN_LANGUAGE,
230
+ METHOD_GUARD_VALIDATE,
231
+ ]
232
+ for method in all_methods:
233
+ # Should have at least one dot (namespace.name)
234
+ assert "." in method, f"{method} should be dotted"
235
+ # Should not have uppercase
236
+ assert method == method.lower(), f"{method} should be lowercase"