@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,149 @@
1
+ """Load and validate cli.error.v0.1, produce deterministic assist.
2
+
3
+ High-confidence path: validated JSON with ID, What, Why, Fix.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Tuple
12
+
13
+ from jsonschema import Draft202012Validator
14
+
15
+ from .methods import METHOD_NORMALIZE_CLI_ERROR, evidence_for_plan
16
+ from .render import AssistResult, Evidence
17
+
18
+
19
+ def _load_schema(name: str) -> Dict[str, Any]:
20
+ """Load a JSON schema from the schemas package."""
21
+ with resources.files("a11y_assist.schemas").joinpath(name).open("rb") as f:
22
+ return json.load(f)
23
+
24
+
25
+ _CLI_ERROR_SCHEMA = _load_schema("cli.error.schema.v0.1.json")
26
+ _CLI_ERROR_VALIDATOR = Draft202012Validator(_CLI_ERROR_SCHEMA)
27
+
28
+
29
+ class CliErrorValidationError(Exception):
30
+ """Raised when cli.error.v0.1 validation fails."""
31
+
32
+ def __init__(self, errors: List[str]):
33
+ super().__init__("cli.error.v0.1 validation failed")
34
+ self.errors = errors
35
+
36
+
37
+ def load_cli_error(path: str) -> Dict[str, Any]:
38
+ """Load and validate a cli.error.v0.1 JSON file."""
39
+ obj = json.loads(Path(path).read_text(encoding="utf-8"))
40
+ errs: List[str] = []
41
+ for e in sorted(_CLI_ERROR_VALIDATOR.iter_errors(obj), key=lambda x: x.path):
42
+ loc = ".".join([str(p) for p in e.path]) or "(root)"
43
+ errs.append(f"{loc}: {e.message}")
44
+ if errs:
45
+ raise CliErrorValidationError(errs)
46
+ return obj
47
+
48
+
49
+ def _normalize_to_list(value: Any) -> List[str]:
50
+ """Normalize a value to a list of strings (handles both string and array formats)."""
51
+ if value is None:
52
+ return []
53
+ if isinstance(value, str):
54
+ return [value] if value.strip() else []
55
+ if isinstance(value, list):
56
+ return [str(v) for v in value if v]
57
+ return []
58
+
59
+
60
+ def assist_from_cli_error(obj: Dict[str, Any]) -> AssistResult:
61
+ """Generate an AssistResult from a validated cli.error.v0.1 object.
62
+
63
+ Deterministic: no guessing. Builds plan from Fix lines.
64
+ Handles both string and array formats for what/why/fix.
65
+ """
66
+ # Support both 'id' and 'code' fields for ID
67
+ err_id = obj.get("id") or obj.get("code")
68
+ title = obj.get("title") or obj.get("what", "Issue")
69
+ if isinstance(title, list):
70
+ title = title[0] if title else "Issue"
71
+
72
+ # Normalize to lists
73
+ what = _normalize_to_list(obj.get("what"))
74
+ why = _normalize_to_list(obj.get("why"))
75
+ fix = _normalize_to_list(obj.get("fix"))
76
+
77
+ # Build plan from Fix lines
78
+ plan: List[str] = []
79
+ for line in fix:
80
+ if isinstance(line, str) and line.strip():
81
+ plan.append(line.strip())
82
+
83
+ if not plan:
84
+ plan = ["Follow the Fix steps provided by the tool output."]
85
+
86
+ safest_next = "Follow the Fix steps in order, starting with the least risky check."
87
+ if why and isinstance(why[0], str) and why[0].strip():
88
+ safest_next = (
89
+ "Start by confirming the cause described under 'Why', "
90
+ "then apply the first Fix step."
91
+ )
92
+
93
+ # SAFE commands: only include clearly non-destructive suggestions from fix text.
94
+ # v0.1 is conservative: we only surface commands already present (not invented).
95
+ next_cmds: List[str] = []
96
+ for line in fix:
97
+ if isinstance(line, str):
98
+ # Accept explicit dry-run or command prefixes
99
+ if "--dry-run" in line or line.strip().startswith(("$ ", "> ", "run ")):
100
+ next_cmds.append(line.replace("$", "").replace(">", "").strip())
101
+ # Accept "Re-run: <cmd>" style
102
+ if line.lower().startswith("re-run:"):
103
+ next_cmds.append(line.split(":", 1)[1].strip())
104
+
105
+ # Filter to SAFE-only heuristically
106
+ safe_filtered = [
107
+ c for c in next_cmds if "--dry-run" in c or "validate" in c or "check" in c
108
+ ]
109
+ safe_filtered = list(dict.fromkeys(safe_filtered)) # dedupe preserving order
110
+
111
+ notes = [
112
+ f"Original title: {title}",
113
+ "This assist block is additive; it does not replace the tool's output.",
114
+ ]
115
+
116
+ # Build evidence anchors for traceability
117
+ evidence: List[Evidence] = []
118
+
119
+ # Evidence for safest_next_step
120
+ if why:
121
+ evidence.append(Evidence(field="safest_next_step", source="cli.error.why[0]"))
122
+ else:
123
+ evidence.append(Evidence(field="safest_next_step", source="cli.error.fix[0]"))
124
+
125
+ # Evidence for plan steps (map to fix lines)
126
+ evidence.extend(evidence_for_plan(plan, source_prefix="cli.error.fix"))
127
+
128
+ # Evidence for safe commands (track which fix line they came from)
129
+ for i, cmd in enumerate(safe_filtered[:3]):
130
+ # Find the original fix line index
131
+ for j, fix_line in enumerate(fix):
132
+ if cmd in fix_line or (
133
+ fix_line.lower().startswith("re-run:") and cmd == fix_line.split(":", 1)[1].strip()
134
+ ):
135
+ evidence.append(
136
+ Evidence(field=f"next_safe_commands[{i}]", source=f"cli.error.fix[{j}]")
137
+ )
138
+ break
139
+
140
+ return AssistResult(
141
+ anchored_id=err_id if isinstance(err_id, str) else None,
142
+ confidence="High",
143
+ safest_next_step=safest_next,
144
+ plan=plan,
145
+ next_safe_commands=safe_filtered[:3],
146
+ notes=notes,
147
+ methods_applied=(METHOD_NORMALIZE_CLI_ERROR,),
148
+ evidence=tuple(evidence),
149
+ )
@@ -0,0 +1,444 @@
1
+ """Profile Guard: centralized invariant checker for profile transforms.
2
+
3
+ Runs after every profile transform to prevent unsafe drift.
4
+ Guard failures are engine bugs, not user errors.
5
+
6
+ Invariants enforced:
7
+ 1. Anchored ID cannot be invented or changed
8
+ 2. Confidence cannot increase
9
+ 3. SAFE-only commands: no new commands, no risky
10
+ 4. Step count caps enforced
11
+ 5. Profile must not add new factual content
12
+ 6. Profile-specific constraints (parentheticals, visual refs)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Dict, List, Literal, Optional, Set
20
+
21
+ from .render import AssistResult, Confidence
22
+
23
+ Severity = Literal["ERROR", "WARN"]
24
+
25
+ # Stopwords to ignore in content overlap checking
26
+ STOPWORDS = frozenset([
27
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
28
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
29
+ "should", "may", "might", "must", "shall", "can", "to", "of", "in",
30
+ "for", "on", "with", "at", "by", "from", "as", "into", "through",
31
+ "during", "before", "after", "above", "below", "between", "under",
32
+ "again", "further", "then", "once", "here", "there", "when", "where",
33
+ "why", "how", "all", "each", "few", "more", "most", "other", "some",
34
+ "such", "no", "nor", "not", "only", "own", "same", "so", "than",
35
+ "too", "very", "just", "also", "now", "and", "but", "or", "if", "it",
36
+ "its", "this", "that", "these", "those", "what", "which", "who",
37
+ "whom", "your", "you", "we", "they", "them", "their", "our", "my",
38
+ ])
39
+
40
+ # Allowed glue vocabulary for plan steps (common action words)
41
+ GLUE_VOCABULARY = frozenset([
42
+ "step", "first", "next", "last", "run", "rerun", "re-run", "confirm",
43
+ "check", "verify", "try", "retry", "follow", "start", "continue",
44
+ "do", "ensure", "make", "see", "look", "update", "fix", "apply",
45
+ "tool", "tools", "command", "commands", "output", "input", "file",
46
+ "files", "error", "errors", "warning", "warnings", "dry", "dryrun",
47
+ "dry-run", "validate", "validation", "config", "configuration",
48
+ "line", "cli", "json", "order", "instructions", "steps",
49
+ ])
50
+
51
+ # Visual navigation patterns
52
+ VISUAL_NAV_PATTERNS = re.compile(
53
+ r"\b(see\s+)?(above|below|left|right|arrow)\b", re.IGNORECASE
54
+ )
55
+
56
+ # Parenthetical pattern
57
+ PARENTHETICAL_PATTERN = re.compile(r"[\(\)\[\]]")
58
+
59
+ # Confidence ordering (lower index = lower confidence)
60
+ CONFIDENCE_ORDER = {"Low": 0, "Medium": 1, "High": 2}
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class GuardIssue:
65
+ """A single guard violation."""
66
+
67
+ severity: Severity
68
+ code: str # e.g. A11Y.ASSIST.GUARD.COMMANDS.INVENTED
69
+ message: str # human-readable
70
+ details: Dict[str, str] = field(default_factory=dict)
71
+
72
+
73
+ class GuardViolation(Exception):
74
+ """Exception raised when profile transform violates invariants."""
75
+
76
+ def __init__(self, issues: List[GuardIssue]):
77
+ super().__init__("Profile guard violation")
78
+ self.issues = issues
79
+
80
+ def __str__(self) -> str:
81
+ lines = ["Profile guard violation:"]
82
+ for issue in self.issues:
83
+ lines.append(f" [{issue.severity}] {issue.code}: {issue.message}")
84
+ for k, v in issue.details.items():
85
+ lines.append(f" {k}: {v}")
86
+ return "\n".join(lines)
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class GuardContext:
91
+ """Context for guard validation."""
92
+
93
+ profile: str # e.g. "screen-reader"
94
+ confidence: Confidence # High/Medium/Low
95
+ input_kind: str # cli_error_json|raw_text|scorecard_json|last_log
96
+ allowed_safe_commands: frozenset[str] # derived from base inputs verbatim
97
+
98
+ # Per-profile constraints
99
+ forbid_parentheticals: bool = False
100
+ forbid_visual_refs: bool = False
101
+ max_steps: Optional[int] = None # enforce if set
102
+ allow_commands_on_low: bool = False # default: no commands on Low confidence
103
+
104
+
105
+ def _tokenize_content(text: str) -> Set[str]:
106
+ """Tokenize text into lowercase content words.
107
+
108
+ - Letters/numbers only
109
+ - Strip punctuation
110
+ - Drop stopwords
111
+ - Drop tokens < 3 chars
112
+ """
113
+ # Extract alphanumeric tokens
114
+ tokens = re.findall(r"[a-zA-Z0-9]+", text.lower())
115
+ # Filter
116
+ return {
117
+ t for t in tokens
118
+ if len(t) >= 3 and t not in STOPWORDS
119
+ }
120
+
121
+
122
+ def _is_content_supported(line: str, base_tokens: Set[str]) -> bool:
123
+ """Check if a line is supported by base text content.
124
+
125
+ A line is supported if:
126
+ - It shares at least one content word with base text, OR
127
+ - It's composed solely of glue vocabulary + base content words
128
+ """
129
+ line_tokens = _tokenize_content(line)
130
+
131
+ if not line_tokens:
132
+ # Empty line or all stopwords/short words - allowed
133
+ return True
134
+
135
+ # Check for overlap with base
136
+ overlap = line_tokens & base_tokens
137
+ if overlap:
138
+ return True
139
+
140
+ # Check if all tokens are glue vocabulary
141
+ non_glue = line_tokens - GLUE_VOCABULARY
142
+ if not non_glue:
143
+ return True
144
+
145
+ # Check if non-glue tokens are in base
146
+ unsupported = non_glue - base_tokens
147
+ return len(unsupported) == 0
148
+
149
+
150
+ def _check_id_invariant(
151
+ base: AssistResult, profiled: AssistResult, issues: List[GuardIssue]
152
+ ) -> None:
153
+ """Check: Anchored ID cannot be invented or changed."""
154
+ if base.anchored_id is None:
155
+ if profiled.anchored_id is not None:
156
+ issues.append(GuardIssue(
157
+ severity="ERROR",
158
+ code="A11Y.ASSIST.GUARD.ID.INVENTED",
159
+ message="Profile invented an anchored ID that didn't exist in base",
160
+ details={
161
+ "base_id": "None",
162
+ "profiled_id": str(profiled.anchored_id),
163
+ },
164
+ ))
165
+ else:
166
+ if profiled.anchored_id != base.anchored_id:
167
+ issues.append(GuardIssue(
168
+ severity="ERROR",
169
+ code="A11Y.ASSIST.GUARD.ID.CHANGED",
170
+ message="Profile changed the anchored ID",
171
+ details={
172
+ "base_id": str(base.anchored_id),
173
+ "profiled_id": str(profiled.anchored_id),
174
+ },
175
+ ))
176
+
177
+
178
+ def _check_confidence_invariant(
179
+ base: AssistResult, profiled: AssistResult, issues: List[GuardIssue]
180
+ ) -> None:
181
+ """Check: Confidence cannot increase."""
182
+ base_level = CONFIDENCE_ORDER.get(base.confidence, 0)
183
+ profiled_level = CONFIDENCE_ORDER.get(profiled.confidence, 0)
184
+
185
+ if profiled_level > base_level:
186
+ issues.append(GuardIssue(
187
+ severity="ERROR",
188
+ code="A11Y.ASSIST.GUARD.CONFIDENCE.INCREASED",
189
+ message="Profile increased confidence level (not allowed)",
190
+ details={
191
+ "base_confidence": base.confidence,
192
+ "profiled_confidence": profiled.confidence,
193
+ },
194
+ ))
195
+
196
+
197
+ def _check_commands_invariant(
198
+ base: AssistResult,
199
+ profiled: AssistResult,
200
+ ctx: GuardContext,
201
+ issues: List[GuardIssue],
202
+ ) -> None:
203
+ """Check: SAFE-only commands - no new commands, no risky."""
204
+ # Check each profiled command
205
+ for cmd in profiled.next_safe_commands:
206
+ # Normalize for comparison (strip $ prefix)
207
+ normalized_cmd = cmd.lstrip("$ ").strip()
208
+
209
+ # Check if command is in allowed set
210
+ allowed = False
211
+ for allowed_cmd in ctx.allowed_safe_commands:
212
+ normalized_allowed = allowed_cmd.lstrip("$ ").strip()
213
+ if normalized_cmd == normalized_allowed:
214
+ allowed = True
215
+ break
216
+
217
+ if not allowed:
218
+ issues.append(GuardIssue(
219
+ severity="ERROR",
220
+ code="A11Y.ASSIST.GUARD.COMMANDS.INVENTED",
221
+ message="Profile included a command not in the allowed set",
222
+ details={
223
+ "command": cmd,
224
+ "allowed_commands": ", ".join(ctx.allowed_safe_commands) or "(none)",
225
+ },
226
+ ))
227
+
228
+ # Check Low confidence rule
229
+ if ctx.confidence == "Low" and not ctx.allow_commands_on_low:
230
+ if profiled.next_safe_commands:
231
+ issues.append(GuardIssue(
232
+ severity="ERROR",
233
+ code="A11Y.ASSIST.GUARD.COMMANDS.DISALLOWED_LOW_CONF",
234
+ message="Profile included commands on Low confidence (not allowed)",
235
+ details={
236
+ "commands": ", ".join(profiled.next_safe_commands),
237
+ },
238
+ ))
239
+
240
+
241
+ def _check_step_count_invariant(
242
+ profiled: AssistResult, ctx: GuardContext, issues: List[GuardIssue]
243
+ ) -> None:
244
+ """Check: Step count caps enforced."""
245
+ if ctx.max_steps is not None:
246
+ if len(profiled.plan) > ctx.max_steps:
247
+ issues.append(GuardIssue(
248
+ severity="ERROR",
249
+ code="A11Y.ASSIST.GUARD.PLAN.TOO_MANY_STEPS",
250
+ message=f"Profile exceeded max steps ({ctx.max_steps})",
251
+ details={
252
+ "max_steps": str(ctx.max_steps),
253
+ "actual_steps": str(len(profiled.plan)),
254
+ },
255
+ ))
256
+
257
+
258
+ def _check_content_support_invariant(
259
+ base_text: str,
260
+ profiled: AssistResult,
261
+ issues: List[GuardIssue],
262
+ ) -> None:
263
+ """Check: Profile must not add new factual content."""
264
+ base_tokens = _tokenize_content(base_text)
265
+
266
+ # Check safest_next_step
267
+ if not _is_content_supported(profiled.safest_next_step, base_tokens):
268
+ issues.append(GuardIssue(
269
+ severity="WARN",
270
+ code="A11Y.ASSIST.GUARD.CONTENT.UNSUPPORTED",
271
+ message="Safest next step contains content not found in base text",
272
+ details={
273
+ "text": profiled.safest_next_step[:80],
274
+ },
275
+ ))
276
+
277
+ # Check plan steps
278
+ for i, step in enumerate(profiled.plan):
279
+ if not _is_content_supported(step, base_tokens):
280
+ issues.append(GuardIssue(
281
+ severity="WARN",
282
+ code="A11Y.ASSIST.GUARD.CONTENT.UNSUPPORTED",
283
+ message=f"Plan step {i + 1} contains content not found in base text",
284
+ details={
285
+ "step": step[:80],
286
+ },
287
+ ))
288
+
289
+
290
+ def _check_parentheticals_constraint(
291
+ profiled: AssistResult, issues: List[GuardIssue]
292
+ ) -> None:
293
+ """Check: No parentheticals allowed (profile-specific)."""
294
+ fields_to_check = [
295
+ ("safest_next_step", profiled.safest_next_step),
296
+ ]
297
+ fields_to_check.extend(
298
+ (f"plan[{i}]", step) for i, step in enumerate(profiled.plan)
299
+ )
300
+ fields_to_check.extend(
301
+ (f"notes[{i}]", note) for i, note in enumerate(profiled.notes)
302
+ )
303
+
304
+ for field_name, text in fields_to_check:
305
+ if PARENTHETICAL_PATTERN.search(text):
306
+ issues.append(GuardIssue(
307
+ severity="ERROR",
308
+ code="A11Y.ASSIST.GUARD.TEXT.PARENTHETICALS_FORBIDDEN",
309
+ message=f"Parentheticals found in {field_name} (forbidden by profile)",
310
+ details={
311
+ "field": field_name,
312
+ "text": text[:80],
313
+ },
314
+ ))
315
+
316
+
317
+ def _check_visual_refs_constraint(
318
+ profiled: AssistResult, issues: List[GuardIssue]
319
+ ) -> None:
320
+ """Check: No visual navigation references (profile-specific)."""
321
+ fields_to_check = [
322
+ ("safest_next_step", profiled.safest_next_step),
323
+ ]
324
+ fields_to_check.extend(
325
+ (f"plan[{i}]", step) for i, step in enumerate(profiled.plan)
326
+ )
327
+ fields_to_check.extend(
328
+ (f"notes[{i}]", note) for i, note in enumerate(profiled.notes)
329
+ )
330
+
331
+ for field_name, text in fields_to_check:
332
+ if VISUAL_NAV_PATTERNS.search(text):
333
+ issues.append(GuardIssue(
334
+ severity="ERROR",
335
+ code="A11Y.ASSIST.GUARD.TEXT.VISUAL_REFS_FORBIDDEN",
336
+ message=f"Visual navigation reference found in {field_name} (forbidden by profile)",
337
+ details={
338
+ "field": field_name,
339
+ "text": text[:80],
340
+ },
341
+ ))
342
+
343
+
344
+ def validate_profile_transform(
345
+ base_text: str,
346
+ base_result: AssistResult,
347
+ profiled_result: AssistResult,
348
+ ctx: GuardContext,
349
+ ) -> None:
350
+ """Validate that a profile transform respects all invariants.
351
+
352
+ Args:
353
+ base_text: The source text for content support checking
354
+ base_result: The AssistResult before profile transformation
355
+ profiled_result: The AssistResult after profile transformation
356
+ ctx: Guard context with profile rules and constraints
357
+
358
+ Raises:
359
+ GuardViolation: If any invariant is violated
360
+ """
361
+ issues: List[GuardIssue] = []
362
+
363
+ # 1. Anchored ID invariant
364
+ _check_id_invariant(base_result, profiled_result, issues)
365
+
366
+ # 2. Confidence invariant
367
+ _check_confidence_invariant(base_result, profiled_result, issues)
368
+
369
+ # 3. Commands invariant
370
+ _check_commands_invariant(base_result, profiled_result, ctx, issues)
371
+
372
+ # 4. Step count invariant
373
+ _check_step_count_invariant(profiled_result, ctx, issues)
374
+
375
+ # 5. Content support invariant (WARN only)
376
+ _check_content_support_invariant(base_text, profiled_result, issues)
377
+
378
+ # 6. Profile-specific constraints
379
+ if ctx.forbid_parentheticals:
380
+ _check_parentheticals_constraint(profiled_result, issues)
381
+
382
+ if ctx.forbid_visual_refs:
383
+ _check_visual_refs_constraint(profiled_result, issues)
384
+
385
+ # Raise if any ERROR-level issues
386
+ errors = [i for i in issues if i.severity == "ERROR"]
387
+ if errors:
388
+ raise GuardViolation(errors)
389
+
390
+ # For WARN-level issues, we could log them but don't fail
391
+ # (In future, could add a strict mode that fails on WARN too)
392
+
393
+
394
+ # Profile rules configuration
395
+ def get_guard_context(
396
+ profile: str,
397
+ confidence: Confidence,
398
+ input_kind: str,
399
+ allowed_commands: Set[str],
400
+ ) -> GuardContext:
401
+ """Create a GuardContext for a profile.
402
+
403
+ Args:
404
+ profile: Profile name (lowvision, cognitive-load, screen-reader, dyslexia, plain-language)
405
+ confidence: Confidence level from base result
406
+ input_kind: Type of input (cli_error_json, raw_text, etc.)
407
+ allowed_commands: Set of allowed SAFE commands from base
408
+
409
+ Returns:
410
+ GuardContext configured for the profile
411
+ """
412
+ # Base configuration
413
+ forbid_parentheticals = False
414
+ forbid_visual_refs = False
415
+ max_steps: Optional[int] = None
416
+ allow_commands_on_low = False
417
+
418
+ if profile == "lowvision":
419
+ max_steps = 5
420
+ elif profile == "cognitive-load":
421
+ max_steps = 3
422
+ elif profile == "screen-reader":
423
+ forbid_parentheticals = True
424
+ forbid_visual_refs = True
425
+ # Screen-reader: 5 steps normally, 3 on Low confidence
426
+ max_steps = 3 if confidence == "Low" else 5
427
+ elif profile == "dyslexia":
428
+ forbid_parentheticals = True
429
+ forbid_visual_refs = True
430
+ max_steps = 5
431
+ elif profile == "plain-language":
432
+ forbid_parentheticals = True
433
+ max_steps = 4
434
+
435
+ return GuardContext(
436
+ profile=profile,
437
+ confidence=confidence,
438
+ input_kind=input_kind,
439
+ allowed_safe_commands=frozenset(allowed_commands),
440
+ forbid_parentheticals=forbid_parentheticals,
441
+ forbid_visual_refs=forbid_visual_refs,
442
+ max_steps=max_steps,
443
+ allow_commands_on_low=allow_commands_on_low,
444
+ )