@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,117 @@
1
+ "use strict";
2
+
3
+ const { Parser } = require("htmlparser2");
4
+
5
+ /**
6
+ * Parse HTML and build a flat nodes[] array in document order.
7
+ * Each node has a stable index for JSON pointer references.
8
+ *
9
+ * @param {string} html - Raw HTML content
10
+ * @returns {{ nodes: Array, root: Object|null }}
11
+ */
12
+ function parseHtml(html) {
13
+ const nodes = [];
14
+ const stack = [];
15
+ let root = null;
16
+
17
+ const parser = new Parser(
18
+ {
19
+ onopentag(name, attrs) {
20
+ const node = {
21
+ type: "element",
22
+ tagName: name.toLowerCase(),
23
+ attrs: { ...attrs },
24
+ children: [],
25
+ parent: stack.length > 0 ? stack[stack.length - 1] : null,
26
+ index: nodes.length,
27
+ };
28
+
29
+ nodes.push(node);
30
+
31
+ if (node.parent) {
32
+ node.parent.children.push(node);
33
+ } else {
34
+ root = node;
35
+ }
36
+
37
+ stack.push(node);
38
+ },
39
+
40
+ ontext(text) {
41
+ // Only capture non-whitespace text for accessibility analysis
42
+ const trimmed = text.trim();
43
+ if (trimmed && stack.length > 0) {
44
+ const textNode = {
45
+ type: "text",
46
+ content: text,
47
+ trimmed: trimmed,
48
+ parent: stack[stack.length - 1],
49
+ index: nodes.length,
50
+ };
51
+ nodes.push(textNode);
52
+ stack[stack.length - 1].children.push(textNode);
53
+ }
54
+ },
55
+
56
+ onclosetag() {
57
+ stack.pop();
58
+ },
59
+ },
60
+ {
61
+ lowerCaseTags: true,
62
+ lowerCaseAttributeNames: true,
63
+ decodeEntities: true,
64
+ }
65
+ );
66
+
67
+ parser.write(html);
68
+ parser.end();
69
+
70
+ return { nodes, root };
71
+ }
72
+
73
+ /**
74
+ * Get the text content of an element (concatenated child text nodes).
75
+ * @param {Object} node - Element node
76
+ * @returns {string}
77
+ */
78
+ function getTextContent(node) {
79
+ if (!node || !node.children) return "";
80
+
81
+ let text = "";
82
+ for (const child of node.children) {
83
+ if (child.type === "text") {
84
+ text += child.content;
85
+ } else if (child.type === "element") {
86
+ text += getTextContent(child);
87
+ }
88
+ }
89
+ return text.trim();
90
+ }
91
+
92
+ /**
93
+ * Find all elements matching a predicate.
94
+ * @param {Array} nodes - Flat nodes array
95
+ * @param {Function} predicate - (node) => boolean
96
+ * @returns {Array}
97
+ */
98
+ function findElements(nodes, predicate) {
99
+ return nodes.filter((n) => n.type === "element" && predicate(n));
100
+ }
101
+
102
+ /**
103
+ * Find element by ID.
104
+ * @param {Array} nodes - Flat nodes array
105
+ * @param {string} id - Element ID
106
+ * @returns {Object|null}
107
+ */
108
+ function getElementById(nodes, id) {
109
+ return nodes.find((n) => n.type === "element" && n.attrs.id === id) || null;
110
+ }
111
+
112
+ module.exports = {
113
+ parseHtml,
114
+ getTextContent,
115
+ findElements,
116
+ getElementById,
117
+ };
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Generate deterministic finding IDs.
5
+ * Findings are sorted by (file, rule_id, pointer) then numbered.
6
+ *
7
+ * @param {Array} findings - Raw findings array
8
+ * @returns {Array} Findings with assigned finding_id
9
+ */
10
+ function assignFindingIds(findings) {
11
+ // Sort for deterministic ordering
12
+ const sorted = [...findings].sort((a, b) => {
13
+ // First by file
14
+ const fileCompare = a.location.file.localeCompare(b.location.file);
15
+ if (fileCompare !== 0) return fileCompare;
16
+
17
+ // Then by rule_id
18
+ const ruleCompare = a.rule_id.localeCompare(b.rule_id);
19
+ if (ruleCompare !== 0) return ruleCompare;
20
+
21
+ // Then by pointer (numeric extraction for proper sorting)
22
+ const aIndex = extractPointerIndex(a.location.json_pointer);
23
+ const bIndex = extractPointerIndex(b.location.json_pointer);
24
+ return aIndex - bIndex;
25
+ });
26
+
27
+ // Assign sequential IDs
28
+ return sorted.map((finding, index) => ({
29
+ ...finding,
30
+ finding_id: formatFindingId(index + 1),
31
+ }));
32
+ }
33
+
34
+ /**
35
+ * Extract numeric index from JSON pointer.
36
+ * @param {string} pointer - e.g., "/nodes/12"
37
+ * @returns {number}
38
+ */
39
+ function extractPointerIndex(pointer) {
40
+ const match = pointer.match(/\/nodes\/(\d+)/);
41
+ return match ? parseInt(match[1], 10) : 0;
42
+ }
43
+
44
+ /**
45
+ * Format finding ID with zero-padding.
46
+ * @param {number} num - Finding number (1-based)
47
+ * @returns {string} e.g., "finding-0001"
48
+ */
49
+ function formatFindingId(num) {
50
+ return `finding-${String(num).padStart(4, "0")}`;
51
+ }
52
+
53
+ module.exports = { assignFindingIds, formatFindingId };
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+
3
+ const { findElements } = require("../html_parse.js");
4
+
5
+ const RULE_ID = "html.document.missing_lang";
6
+
7
+ /**
8
+ * Check for <html> element missing lang attribute.
9
+ * WCAG 3.1.1 - Language of Page (Level A)
10
+ */
11
+ function run(nodes, context) {
12
+ const findings = [];
13
+
14
+ const htmlElements = findElements(nodes, (n) => n.tagName === "html");
15
+
16
+ for (const node of htmlElements) {
17
+ const lang = node.attrs.lang;
18
+
19
+ if (!lang || lang.trim() === "") {
20
+ findings.push({
21
+ rule_id: RULE_ID,
22
+ severity: "error",
23
+ confidence: 1.0,
24
+ message: "Document is missing a lang attribute on the <html> element.",
25
+ location: {
26
+ file: context.relativePath,
27
+ json_pointer: `/nodes/${node.index}`,
28
+ },
29
+ evidence: {
30
+ tagName: node.tagName,
31
+ attrs: filterAttrs(node.attrs, ["lang"]),
32
+ },
33
+ });
34
+ }
35
+ }
36
+
37
+ return findings;
38
+ }
39
+
40
+ function filterAttrs(attrs, keys) {
41
+ const filtered = {};
42
+ for (const key of keys) {
43
+ if (key in attrs) {
44
+ filtered[key] = attrs[key];
45
+ }
46
+ }
47
+ return filtered;
48
+ }
49
+
50
+ module.exports = { id: RULE_ID, run };
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+
3
+ const { findElements, getElementById } = require("../html_parse.js");
4
+
5
+ const RULE_ID = "html.form_control.missing_label";
6
+
7
+ const FORM_CONTROLS = ["input", "select", "textarea"];
8
+ const EXEMPT_INPUT_TYPES = ["hidden", "submit", "reset", "button", "image"];
9
+
10
+ /**
11
+ * Check for form controls missing associated labels.
12
+ * WCAG 1.3.1 - Info and Relationships (Level A)
13
+ * WCAG 4.1.2 - Name, Role, Value (Level A)
14
+ *
15
+ * A form control needs one of:
16
+ * - Associated <label for="...">
17
+ * - aria-label attribute
18
+ * - aria-labelledby attribute (pointing to existing element)
19
+ */
20
+ function run(nodes, context) {
21
+ const findings = [];
22
+
23
+ // Find all label elements for lookup
24
+ const labels = findElements(nodes, (n) => n.tagName === "label");
25
+ const labelForIds = new Set(
26
+ labels.map((l) => l.attrs.for).filter((f) => f)
27
+ );
28
+
29
+ // Check form controls
30
+ const controls = findElements(nodes, (n) => FORM_CONTROLS.includes(n.tagName));
31
+
32
+ for (const node of controls) {
33
+ // Skip exempt input types
34
+ if (node.tagName === "input") {
35
+ const type = (node.attrs.type || "text").toLowerCase();
36
+ if (EXEMPT_INPUT_TYPES.includes(type)) {
37
+ continue;
38
+ }
39
+ }
40
+
41
+ if (!hasAccessibleLabel(node, labelForIds, nodes)) {
42
+ findings.push({
43
+ rule_id: RULE_ID,
44
+ severity: "error",
45
+ confidence: 0.95,
46
+ message: `Form control <${node.tagName}> is missing an associated label.`,
47
+ location: {
48
+ file: context.relativePath,
49
+ json_pointer: `/nodes/${node.index}`,
50
+ },
51
+ evidence: {
52
+ tagName: node.tagName,
53
+ attrs: filterAttrs(node.attrs, [
54
+ "id",
55
+ "name",
56
+ "type",
57
+ "aria-label",
58
+ "aria-labelledby",
59
+ ]),
60
+ },
61
+ });
62
+ }
63
+ }
64
+
65
+ return findings;
66
+ }
67
+
68
+ /**
69
+ * Check if a form control has an accessible label.
70
+ */
71
+ function hasAccessibleLabel(node, labelForIds, nodes) {
72
+ // Check aria-label
73
+ if (node.attrs["aria-label"] && node.attrs["aria-label"].trim()) {
74
+ return true;
75
+ }
76
+
77
+ // Check aria-labelledby (must point to existing element)
78
+ if (node.attrs["aria-labelledby"]) {
79
+ const ids = node.attrs["aria-labelledby"].split(/\s+/);
80
+ for (const id of ids) {
81
+ if (getElementById(nodes, id)) {
82
+ return true;
83
+ }
84
+ }
85
+ }
86
+
87
+ // Check for associated label via 'for' attribute
88
+ if (node.attrs.id && labelForIds.has(node.attrs.id)) {
89
+ return true;
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ function filterAttrs(attrs, keys) {
96
+ const filtered = {};
97
+ for (const key of keys) {
98
+ if (key in attrs) {
99
+ filtered[key] = attrs[key];
100
+ }
101
+ }
102
+ return filtered;
103
+ }
104
+
105
+ module.exports = { id: RULE_ID, run };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+
3
+ const { findElements } = require("../html_parse.js");
4
+
5
+ const RULE_ID = "html.img.missing_alt";
6
+
7
+ /**
8
+ * Check for <img> elements missing alt attribute.
9
+ * WCAG 1.1.1 - Non-text Content (Level A)
10
+ *
11
+ * Exceptions:
12
+ * - role="presentation" or role="none"
13
+ * - aria-hidden="true"
14
+ */
15
+ function run(nodes, context) {
16
+ const findings = [];
17
+
18
+ const imgElements = findElements(nodes, (n) => n.tagName === "img");
19
+
20
+ for (const node of imgElements) {
21
+ // Skip decorative images
22
+ if (isDecorativeImage(node)) {
23
+ continue;
24
+ }
25
+
26
+ const alt = node.attrs.alt;
27
+
28
+ // Missing alt attribute entirely
29
+ if (alt === undefined) {
30
+ findings.push({
31
+ rule_id: RULE_ID,
32
+ severity: "error",
33
+ confidence: 0.98,
34
+ message: "Image element is missing alt text.",
35
+ location: {
36
+ file: context.relativePath,
37
+ json_pointer: `/nodes/${node.index}`,
38
+ },
39
+ evidence: {
40
+ tagName: node.tagName,
41
+ attrs: filterAttrs(node.attrs, ["src", "alt", "role", "aria-hidden"]),
42
+ },
43
+ });
44
+ }
45
+ }
46
+
47
+ return findings;
48
+ }
49
+
50
+ /**
51
+ * Check if image is marked as decorative.
52
+ */
53
+ function isDecorativeImage(node) {
54
+ const role = node.attrs.role;
55
+ if (role === "presentation" || role === "none") {
56
+ return true;
57
+ }
58
+
59
+ const ariaHidden = node.attrs["aria-hidden"];
60
+ if (ariaHidden === "true") {
61
+ return true;
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ function filterAttrs(attrs, keys) {
68
+ const filtered = {};
69
+ for (const key of keys) {
70
+ if (key in attrs) {
71
+ filtered[key] = attrs[key];
72
+ }
73
+ }
74
+ return filtered;
75
+ }
76
+
77
+ module.exports = { id: RULE_ID, run };
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ const imgMissingAlt = require("./img_missing_alt.js");
4
+ const formControlMissingLabel = require("./form_control_missing_label.js");
5
+ const interactiveMissingName = require("./interactive_missing_name.js");
6
+ const documentMissingLang = require("./document_missing_lang.js");
7
+
8
+ /**
9
+ * All available rules.
10
+ * Each rule exports: { id, run(nodes, context) => findings[] }
11
+ */
12
+ const rules = [
13
+ documentMissingLang,
14
+ imgMissingAlt,
15
+ formControlMissingLabel,
16
+ interactiveMissingName,
17
+ ];
18
+
19
+ /**
20
+ * Run all rules against parsed HTML nodes.
21
+ *
22
+ * @param {Array} nodes - Flat nodes array from parseHtml
23
+ * @param {Object} context - { filePath, relativePath }
24
+ * @returns {Array} Raw findings (without finding_id assigned)
25
+ */
26
+ function runRules(nodes, context) {
27
+ const findings = [];
28
+
29
+ for (const rule of rules) {
30
+ const ruleFindings = rule.run(nodes, context);
31
+ findings.push(...ruleFindings);
32
+ }
33
+
34
+ return findings;
35
+ }
36
+
37
+ module.exports = { rules, runRules };
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+
3
+ const { findElements, getTextContent, getElementById } = require("../html_parse.js");
4
+
5
+ const RULE_ID = "html.interactive.missing_name";
6
+
7
+ /**
8
+ * Check for interactive elements missing accessible names.
9
+ * WCAG 4.1.2 - Name, Role, Value (Level A)
10
+ *
11
+ * Checks:
12
+ * - <button> elements
13
+ * - <a href="..."> elements (links)
14
+ *
15
+ * An accessible name can come from:
16
+ * - Text content
17
+ * - aria-label
18
+ * - aria-labelledby
19
+ * - title attribute (fallback)
20
+ */
21
+ function run(nodes, context) {
22
+ const findings = [];
23
+
24
+ // Check buttons
25
+ const buttons = findElements(nodes, (n) => n.tagName === "button");
26
+ for (const node of buttons) {
27
+ if (!hasAccessibleName(node, nodes)) {
28
+ findings.push({
29
+ rule_id: RULE_ID,
30
+ severity: "error",
31
+ confidence: 0.95,
32
+ message: "Button element is missing an accessible name.",
33
+ location: {
34
+ file: context.relativePath,
35
+ json_pointer: `/nodes/${node.index}`,
36
+ },
37
+ evidence: {
38
+ tagName: node.tagName,
39
+ attrs: filterAttrs(node.attrs, [
40
+ "id",
41
+ "type",
42
+ "aria-label",
43
+ "aria-labelledby",
44
+ "title",
45
+ ]),
46
+ textContent: getTextContent(node).substring(0, 50),
47
+ },
48
+ });
49
+ }
50
+ }
51
+
52
+ // Check links (anchors with href)
53
+ const links = findElements(
54
+ nodes,
55
+ (n) => n.tagName === "a" && n.attrs.href !== undefined
56
+ );
57
+ for (const node of links) {
58
+ if (!hasAccessibleName(node, nodes)) {
59
+ findings.push({
60
+ rule_id: RULE_ID,
61
+ severity: "error",
62
+ confidence: 0.95,
63
+ message: "Link element is missing an accessible name.",
64
+ location: {
65
+ file: context.relativePath,
66
+ json_pointer: `/nodes/${node.index}`,
67
+ },
68
+ evidence: {
69
+ tagName: node.tagName,
70
+ attrs: filterAttrs(node.attrs, [
71
+ "href",
72
+ "aria-label",
73
+ "aria-labelledby",
74
+ "title",
75
+ ]),
76
+ textContent: getTextContent(node).substring(0, 50),
77
+ },
78
+ });
79
+ }
80
+ }
81
+
82
+ return findings;
83
+ }
84
+
85
+ /**
86
+ * Check if an element has an accessible name.
87
+ */
88
+ function hasAccessibleName(node, nodes) {
89
+ // Check aria-label
90
+ if (node.attrs["aria-label"] && node.attrs["aria-label"].trim()) {
91
+ return true;
92
+ }
93
+
94
+ // Check aria-labelledby
95
+ if (node.attrs["aria-labelledby"]) {
96
+ const ids = node.attrs["aria-labelledby"].split(/\s+/);
97
+ for (const id of ids) {
98
+ const labelElement = getElementById(nodes, id);
99
+ if (labelElement && getTextContent(labelElement).trim()) {
100
+ return true;
101
+ }
102
+ }
103
+ }
104
+
105
+ // Check text content
106
+ const textContent = getTextContent(node).trim();
107
+ if (textContent) {
108
+ return true;
109
+ }
110
+
111
+ // Check title as fallback
112
+ if (node.attrs.title && node.attrs.title.trim()) {
113
+ return true;
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ function filterAttrs(attrs, keys) {
120
+ const filtered = {};
121
+ for (const key of keys) {
122
+ if (key in attrs) {
123
+ filtered[key] = attrs[key];
124
+ }
125
+ }
126
+ return filtered;
127
+ }
128
+
129
+ module.exports = { id: RULE_ID, run };
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { walkHtmlFiles } = require("./fswalk.js");
6
+ const { parseHtml } = require("./html_parse.js");
7
+ const { runRules } = require("./rules/index.js");
8
+ const { assignFindingIds } = require("./ids.js");
9
+ const { emitProvenance } = require("./evidence/prov_emit.js");
10
+
11
+ const ENGINE_NAME = "a11y-evidence-engine";
12
+ const ENGINE_VERSION = "0.1.0";
13
+
14
+ /**
15
+ * Scan HTML files and produce findings with provenance.
16
+ *
17
+ * @param {string} targetPath - File or directory to scan
18
+ * @param {string} outDir - Output directory
19
+ * @returns {Object} Scan result with summary
20
+ */
21
+ async function scan(targetPath, outDir) {
22
+ const resolvedTarget = path.resolve(targetPath);
23
+ const resolvedOut = path.resolve(outDir);
24
+
25
+ // Gather HTML files
26
+ const files = walkHtmlFiles(resolvedTarget);
27
+
28
+ if (files.length === 0) {
29
+ throw new Error(`No HTML files found in: ${resolvedTarget}`);
30
+ }
31
+
32
+ // Collect all findings
33
+ const allFindings = [];
34
+ const baseDir = fs.statSync(resolvedTarget).isDirectory()
35
+ ? resolvedTarget
36
+ : path.dirname(resolvedTarget);
37
+
38
+ for (const filePath of files) {
39
+ const html = fs.readFileSync(filePath, "utf8");
40
+ const { nodes } = parseHtml(html);
41
+
42
+ const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/");
43
+
44
+ const context = {
45
+ filePath,
46
+ relativePath,
47
+ };
48
+
49
+ const findings = runRules(nodes, context);
50
+ allFindings.push(...findings);
51
+ }
52
+
53
+ // Assign deterministic IDs
54
+ const numberedFindings = assignFindingIds(allFindings);
55
+
56
+ // Create output directory
57
+ fs.mkdirSync(resolvedOut, { recursive: true });
58
+
59
+ // Generate timestamp for all provenance records (deterministic within scan)
60
+ const timestamp = new Date().toISOString();
61
+
62
+ // Emit provenance for each finding
63
+ const findingsWithRefs = [];
64
+
65
+ for (const finding of numberedFindings) {
66
+ const provDir = path.join(resolvedOut, "provenance", finding.finding_id);
67
+ fs.mkdirSync(provDir, { recursive: true });
68
+
69
+ const { record, digest, envelope } = emitProvenance(finding, {
70
+ engineVersion: ENGINE_VERSION,
71
+ timestamp,
72
+ });
73
+
74
+ // Write provenance files
75
+ fs.writeFileSync(
76
+ path.join(provDir, "record.json"),
77
+ JSON.stringify(record, null, 2)
78
+ );
79
+ fs.writeFileSync(
80
+ path.join(provDir, "digest.json"),
81
+ JSON.stringify(digest, null, 2)
82
+ );
83
+ fs.writeFileSync(
84
+ path.join(provDir, "envelope.json"),
85
+ JSON.stringify(envelope, null, 2)
86
+ );
87
+
88
+ // Add evidence_ref to finding (without the raw evidence)
89
+ const { evidence, ...findingWithoutEvidence } = finding;
90
+ findingsWithRefs.push({
91
+ ...findingWithoutEvidence,
92
+ evidence_ref: {
93
+ record: `provenance/${finding.finding_id}/record.json`,
94
+ digest: `provenance/${finding.finding_id}/digest.json`,
95
+ envelope: `provenance/${finding.finding_id}/envelope.json`,
96
+ },
97
+ });
98
+ }
99
+
100
+ // Build summary
101
+ const summary = {
102
+ files_scanned: files.length,
103
+ errors: findingsWithRefs.filter((f) => f.severity === "error").length,
104
+ warnings: findingsWithRefs.filter((f) => f.severity === "warning").length,
105
+ info: findingsWithRefs.filter((f) => f.severity === "info").length,
106
+ };
107
+
108
+ // Build output
109
+ const output = {
110
+ engine: ENGINE_NAME,
111
+ version: ENGINE_VERSION,
112
+ target: {
113
+ path: path.relative(process.cwd(), resolvedTarget).replace(/\\/g, "/") || ".",
114
+ },
115
+ summary,
116
+ findings: findingsWithRefs,
117
+ };
118
+
119
+ // Write findings.json
120
+ fs.writeFileSync(
121
+ path.join(resolvedOut, "findings.json"),
122
+ JSON.stringify(output, null, 2)
123
+ );
124
+
125
+ return output;
126
+ }
127
+
128
+ module.exports = { scan };