nat-engine 1__py3-none-any.whl

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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,424 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """AccessibilityScanner – static WCAG audit against a DOMSnapshot.
7
+
8
+ Analyses a :class:`~mannf.core.browser.dom_snapshot.DOMSnapshot` for common
9
+ WCAG 2.1 violations **without requiring a browser runtime or axe-core**. All
10
+ checks operate on the data already present in the snapshot's element list.
11
+
12
+ Rules implemented
13
+ -----------------
14
+ * ``missing-alt-text`` — ``<img>`` with no ``alt`` attribute (WCAG 1.1.1)
15
+ * ``missing-form-label`` — ``<input>``/``<select>``/``<textarea>`` without a
16
+ ``<label>``, ``aria-label``, or ``aria-labelledby``
17
+ (WCAG 1.3.1, 4.1.2)
18
+ * ``empty-link`` — ``<a>`` with no text content (WCAG 2.4.4)
19
+ * ``empty-button`` — ``<button>`` with no text content (WCAG 4.1.2)
20
+ * ``missing-document-title``— page ``<title>`` is absent or empty (WCAG 2.4.2)
21
+ * ``missing-lang-attr`` — ``<html>`` element has no ``lang`` attribute
22
+ (WCAG 3.1.1)
23
+ * ``unlabelled-placeholder``— ``<input>`` has a placeholder but no accessible
24
+ label (informational / moderate)
25
+ * ``duplicate-id`` — multiple elements share the same ``id``
26
+ (WCAG 4.1.1)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime, timezone
33
+ from typing import Any
34
+
35
+ from mannf.core.browser.dom_snapshot import DOMElement, DOMSnapshot
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Data model
40
+ # ---------------------------------------------------------------------------
41
+
42
+ @dataclass
43
+ class AccessibilityViolation:
44
+ """A single WCAG accessibility violation found in a page snapshot.
45
+
46
+ Parameters
47
+ ----------
48
+ rule_id:
49
+ Machine-readable rule identifier (e.g. ``"missing-alt-text"``).
50
+ description:
51
+ Human-readable explanation of the violation.
52
+ impact:
53
+ Severity level: ``"critical"``, ``"serious"``, ``"moderate"``, or
54
+ ``"minor"``.
55
+ wcag_criteria:
56
+ Relevant WCAG 2.1 success criterion (e.g. ``"1.1.1"``).
57
+ selector:
58
+ CSS selector of the offending element.
59
+ element_html:
60
+ Shortened HTML representation for display in reports.
61
+ """
62
+
63
+ rule_id: str
64
+ description: str
65
+ impact: str
66
+ wcag_criteria: str
67
+ selector: str
68
+ element_html: str
69
+
70
+
71
+ @dataclass
72
+ class AccessibilityScanResult:
73
+ """Aggregated result of an accessibility scan.
74
+
75
+ Parameters
76
+ ----------
77
+ url:
78
+ URL of the page that was scanned.
79
+ timestamp:
80
+ ISO 8601 UTC timestamp of when the scan was performed.
81
+ violations:
82
+ All violations found during the scan.
83
+ passes:
84
+ Number of rules that passed (no violations found).
85
+ total_rules:
86
+ Total number of rules evaluated.
87
+ """
88
+
89
+ url: str
90
+ timestamp: str
91
+ violations: list[AccessibilityViolation] = field(default_factory=list)
92
+ passes: int = 0
93
+ total_rules: int = 0
94
+
95
+ @property
96
+ def violation_count(self) -> int:
97
+ """Total number of violations found."""
98
+ return len(self.violations)
99
+
100
+ @property
101
+ def compliance_score(self) -> float:
102
+ """Compliance percentage in the range 0.0–100.0.
103
+
104
+ Returns ``100.0`` when no rules were evaluated.
105
+ """
106
+ if self.total_rules == 0:
107
+ return 100.0
108
+ return round((self.passes / self.total_rules) * 100.0, 1)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Internal helpers
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _element_html(el: DOMElement) -> str:
116
+ """Build a short HTML-like representation of *el* for reports."""
117
+ attrs: list[str] = []
118
+ if el.element_id:
119
+ attrs.append(f'id="{el.element_id}"')
120
+ for key, val in el.attributes.items():
121
+ attrs.append(f'{key}="{val}"')
122
+ attr_str = " " + " ".join(attrs) if attrs else ""
123
+ text = el.text[:40] if el.text else ""
124
+ if text:
125
+ return f"<{el.tag}{attr_str}>{text}</{el.tag}>"
126
+ return f"<{el.tag}{attr_str}>"
127
+
128
+
129
+ def _has_accessible_label(el: DOMElement, labelled_ids: set[str]) -> bool:
130
+ """Return ``True`` if *el* has an accessible name from any common source.
131
+
132
+ Checks (in order):
133
+ - ``aria-label`` attribute
134
+ - ``aria-labelledby`` attribute
135
+ - ``id`` referenced by a ``<label for="…">`` element in the snapshot
136
+ """
137
+ if el.attributes.get("aria-label", "").strip():
138
+ return True
139
+ if el.attributes.get("aria-labelledby", "").strip():
140
+ return True
141
+ if el.element_id and el.element_id in labelled_ids:
142
+ return True
143
+ return False
144
+
145
+
146
+ def _collect_label_targets(elements: list[DOMElement]) -> set[str]:
147
+ """Return the set of element IDs referenced by ``<label for="…">`` elements."""
148
+ targets: set[str] = set()
149
+ for el in elements:
150
+ if el.tag == "label":
151
+ for_val = el.attributes.get("for", "").strip()
152
+ if for_val:
153
+ targets.add(for_val)
154
+ return targets
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Rule implementations
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def _rule_missing_alt_text(elements: list[DOMElement]) -> list[AccessibilityViolation]:
162
+ violations: list[AccessibilityViolation] = []
163
+ for el in elements:
164
+ if el.tag == "img":
165
+ alt = el.attributes.get("alt")
166
+ if alt is None:
167
+ violations.append(
168
+ AccessibilityViolation(
169
+ rule_id="missing-alt-text",
170
+ description=(
171
+ "Image is missing an alt attribute. Screen readers "
172
+ "will not be able to convey the image content."
173
+ ),
174
+ impact="critical",
175
+ wcag_criteria="1.1.1",
176
+ selector=el.selector,
177
+ element_html=_element_html(el),
178
+ )
179
+ )
180
+ return violations
181
+
182
+
183
+ def _rule_missing_form_label(
184
+ elements: list[DOMElement], labelled_ids: set[str]
185
+ ) -> list[AccessibilityViolation]:
186
+ violations: list[AccessibilityViolation] = []
187
+ form_controls = {"input", "select", "textarea"}
188
+ # Exclude hidden inputs — they do not need labels.
189
+ for el in elements:
190
+ if el.tag not in form_controls:
191
+ continue
192
+ if el.attributes.get("type", "").lower() == "hidden":
193
+ continue
194
+ if not _has_accessible_label(el, labelled_ids):
195
+ violations.append(
196
+ AccessibilityViolation(
197
+ rule_id="missing-form-label",
198
+ description=(
199
+ f"Form control <{el.tag}> has no associated label, "
200
+ "aria-label, or aria-labelledby."
201
+ ),
202
+ impact="serious",
203
+ wcag_criteria="1.3.1",
204
+ selector=el.selector,
205
+ element_html=_element_html(el),
206
+ )
207
+ )
208
+ return violations
209
+
210
+
211
+ def _rule_empty_link(elements: list[DOMElement]) -> list[AccessibilityViolation]:
212
+ violations: list[AccessibilityViolation] = []
213
+ for el in elements:
214
+ if el.tag == "a":
215
+ if not el.text.strip() and not el.attributes.get("aria-label", "").strip():
216
+ violations.append(
217
+ AccessibilityViolation(
218
+ rule_id="empty-link",
219
+ description=(
220
+ "Link has no discernible text content. Screen readers "
221
+ "cannot describe where the link leads."
222
+ ),
223
+ impact="serious",
224
+ wcag_criteria="2.4.4",
225
+ selector=el.selector,
226
+ element_html=_element_html(el),
227
+ )
228
+ )
229
+ return violations
230
+
231
+
232
+ def _rule_empty_button(elements: list[DOMElement]) -> list[AccessibilityViolation]:
233
+ violations: list[AccessibilityViolation] = []
234
+ for el in elements:
235
+ if el.tag == "button":
236
+ if not el.text.strip() and not el.attributes.get("aria-label", "").strip():
237
+ violations.append(
238
+ AccessibilityViolation(
239
+ rule_id="empty-button",
240
+ description=(
241
+ "Button has no discernible text content. Screen readers "
242
+ "cannot describe the button's action."
243
+ ),
244
+ impact="critical",
245
+ wcag_criteria="4.1.2",
246
+ selector=el.selector,
247
+ element_html=_element_html(el),
248
+ )
249
+ )
250
+ return violations
251
+
252
+
253
+ def _rule_missing_document_title(snapshot: DOMSnapshot) -> list[AccessibilityViolation]:
254
+ if not snapshot.title or not snapshot.title.strip():
255
+ return [
256
+ AccessibilityViolation(
257
+ rule_id="missing-document-title",
258
+ description=(
259
+ "The page is missing a <title> element. This prevents users "
260
+ "from identifying the page in browser history or tab strips."
261
+ ),
262
+ impact="serious",
263
+ wcag_criteria="2.4.2",
264
+ selector="head > title",
265
+ element_html="<title></title>",
266
+ )
267
+ ]
268
+ return []
269
+
270
+
271
+ def _rule_missing_lang_attr(elements: list[DOMElement]) -> list[AccessibilityViolation]:
272
+ for el in elements:
273
+ if el.tag == "html":
274
+ if not el.attributes.get("lang", "").strip():
275
+ return [
276
+ AccessibilityViolation(
277
+ rule_id="missing-lang-attr",
278
+ description=(
279
+ "The <html> element is missing a lang attribute. "
280
+ "Screen readers use this to select the correct voice profile."
281
+ ),
282
+ impact="serious",
283
+ wcag_criteria="3.1.1",
284
+ selector="html",
285
+ element_html=_element_html(el),
286
+ )
287
+ ]
288
+ return []
289
+ # No <html> element found in snapshot — flag it
290
+ return [
291
+ AccessibilityViolation(
292
+ rule_id="missing-lang-attr",
293
+ description=(
294
+ "No <html> element found in snapshot; cannot verify lang attribute."
295
+ ),
296
+ impact="serious",
297
+ wcag_criteria="3.1.1",
298
+ selector="html",
299
+ element_html="<html>",
300
+ )
301
+ ]
302
+
303
+
304
+ def _rule_unlabelled_placeholder(
305
+ elements: list[DOMElement], labelled_ids: set[str]
306
+ ) -> list[AccessibilityViolation]:
307
+ """Flag ``<input>`` elements that rely solely on placeholder text for labelling."""
308
+ violations: list[AccessibilityViolation] = []
309
+ for el in elements:
310
+ if el.tag != "input":
311
+ continue
312
+ if el.attributes.get("type", "").lower() == "hidden":
313
+ continue
314
+ placeholder = el.attributes.get("placeholder", "").strip()
315
+ if placeholder and not _has_accessible_label(el, labelled_ids):
316
+ violations.append(
317
+ AccessibilityViolation(
318
+ rule_id="unlabelled-placeholder",
319
+ description=(
320
+ "Input uses placeholder text as its only label. "
321
+ "Placeholder text disappears when the user starts typing."
322
+ ),
323
+ impact="moderate",
324
+ wcag_criteria="1.3.1",
325
+ selector=el.selector,
326
+ element_html=_element_html(el),
327
+ )
328
+ )
329
+ return violations
330
+
331
+
332
+ def _rule_duplicate_ids(elements: list[DOMElement]) -> list[AccessibilityViolation]:
333
+ seen: dict[str, int] = {}
334
+ for el in elements:
335
+ if el.element_id:
336
+ seen[el.element_id] = seen.get(el.element_id, 0) + 1
337
+
338
+ violations: list[AccessibilityViolation] = []
339
+ for eid, count in seen.items():
340
+ if count > 1:
341
+ violations.append(
342
+ AccessibilityViolation(
343
+ rule_id="duplicate-id",
344
+ description=(
345
+ f'id="{eid}" appears {count} times on the page. '
346
+ "Duplicate IDs break ARIA relationships and JS selectors."
347
+ ),
348
+ impact="serious",
349
+ wcag_criteria="4.1.1",
350
+ selector=f"#{eid}",
351
+ element_html=f'[id="{eid}"] (×{count})',
352
+ )
353
+ )
354
+ return violations
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Public API
359
+ # ---------------------------------------------------------------------------
360
+
361
+ # Ordered list of (rule_fn, *extra_args) for the scanner loop. Each entry is
362
+ # a callable that returns list[AccessibilityViolation].
363
+ _RULES: list[str] = [
364
+ "missing-alt-text",
365
+ "missing-form-label",
366
+ "empty-link",
367
+ "empty-button",
368
+ "missing-document-title",
369
+ "missing-lang-attr",
370
+ "unlabelled-placeholder",
371
+ "duplicate-id",
372
+ ]
373
+
374
+
375
+ def scan_accessibility(snapshot: DOMSnapshot) -> AccessibilityScanResult:
376
+ """Run accessibility rules against a :class:`DOMSnapshot`.
377
+
378
+ No browser runtime is required — all checks operate on the static element
379
+ data already captured in the snapshot.
380
+
381
+ Parameters
382
+ ----------
383
+ snapshot:
384
+ A :class:`~mannf.core.browser.dom_snapshot.DOMSnapshot` to audit.
385
+
386
+ Returns
387
+ -------
388
+ AccessibilityScanResult
389
+ Aggregated violations, pass count, and compliance score.
390
+ """
391
+ timestamp = datetime.now(timezone.utc).isoformat()
392
+ elements = snapshot.elements
393
+
394
+ labelled_ids = _collect_label_targets(elements)
395
+
396
+ all_violations: list[AccessibilityViolation] = []
397
+
398
+ # Track which rules produced violations so we can compute `passes`.
399
+ rules_with_violations: set[str] = set()
400
+
401
+ def _run(rule_id: str, violations: list[AccessibilityViolation]) -> None:
402
+ if violations:
403
+ rules_with_violations.add(rule_id)
404
+ all_violations.extend(violations)
405
+
406
+ _run("missing-alt-text", _rule_missing_alt_text(elements))
407
+ _run("missing-form-label", _rule_missing_form_label(elements, labelled_ids))
408
+ _run("empty-link", _rule_empty_link(elements))
409
+ _run("empty-button", _rule_empty_button(elements))
410
+ _run("missing-document-title", _rule_missing_document_title(snapshot))
411
+ _run("missing-lang-attr", _rule_missing_lang_attr(elements))
412
+ _run("unlabelled-placeholder", _rule_unlabelled_placeholder(elements, labelled_ids))
413
+ _run("duplicate-id", _rule_duplicate_ids(elements))
414
+
415
+ total_rules = len(_RULES)
416
+ passes = total_rules - len(rules_with_violations)
417
+
418
+ return AccessibilityScanResult(
419
+ url=snapshot.url,
420
+ timestamp=timestamp,
421
+ violations=all_violations,
422
+ passes=passes,
423
+ total_rules=total_rules,
424
+ )
@@ -0,0 +1,178 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Discovery model dataclasses for the WebCrawlerAgent output.
7
+
8
+ These typed dataclasses represent the structured JSON discovery model produced
9
+ by the :class:`~mannf.core.agents.web_crawler_agent.WebCrawlerAgent` and
10
+ consumed by downstream Phase 2 components (Scenario Config Generator, etc.).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+
21
+ @dataclass
22
+ class DiscoveredField:
23
+ """A single form field discovered on a page.
24
+
25
+ Parameters
26
+ ----------
27
+ selector:
28
+ CSS selector to locate the field.
29
+ type:
30
+ Input type: ``"text"``, ``"email"``, ``"password"``, ``"select"``, etc.
31
+ required:
32
+ Whether the field is marked as required.
33
+ label:
34
+ Associated label text (from ``<label for=…>`` or ancestor ``<label>``).
35
+ name:
36
+ Value of the ``name`` attribute.
37
+ placeholder:
38
+ Placeholder text, or empty string.
39
+ pattern:
40
+ HTML5 validation pattern, or empty string.
41
+ """
42
+
43
+ selector: str
44
+ type: str
45
+ required: bool
46
+ label: str
47
+ name: str
48
+ placeholder: str
49
+ pattern: str
50
+
51
+
52
+ @dataclass
53
+ class DiscoveredForm:
54
+ """A form discovered on a page.
55
+
56
+ Parameters
57
+ ----------
58
+ action:
59
+ Form ``action`` URL (may be relative or absolute).
60
+ method:
61
+ HTTP method: ``"GET"`` or ``"POST"``.
62
+ fields:
63
+ Ordered list of :class:`DiscoveredField` objects within the form.
64
+ submit_selector:
65
+ CSS selector for the submit button/input within (or associated with) the form.
66
+ """
67
+
68
+ action: str
69
+ method: str
70
+ fields: list[DiscoveredField]
71
+ submit_selector: str
72
+
73
+
74
+ @dataclass
75
+ class InteractiveElement:
76
+ """A non-form interactive element discovered on a page.
77
+
78
+ Parameters
79
+ ----------
80
+ selector:
81
+ CSS selector to locate the element.
82
+ text:
83
+ Visible text content.
84
+ type:
85
+ Element role: ``"button"``, ``"link"``, ``"dropdown"``, ``"tab"``, etc.
86
+ tag:
87
+ Lower-cased HTML tag name.
88
+ """
89
+
90
+ selector: str
91
+ text: str
92
+ type: str
93
+ tag: str
94
+
95
+
96
+ @dataclass
97
+ class DiscoveredPage:
98
+ """A single page node in the page graph.
99
+
100
+ Parameters
101
+ ----------
102
+ url:
103
+ Page URL (typically a path relative to ``base_url``, or full URL for
104
+ external pages that are recorded but not followed).
105
+ title:
106
+ Page ``<title>`` text.
107
+ forms:
108
+ Forms detected on this page.
109
+ links:
110
+ Outgoing link ``href`` values discovered on this page.
111
+ interactive_elements:
112
+ Non-form interactive elements (buttons, dropdowns, tabs, …).
113
+ """
114
+
115
+ url: str
116
+ title: str
117
+ forms: list[DiscoveredForm]
118
+ links: list[str]
119
+ interactive_elements: list[InteractiveElement]
120
+
121
+
122
+ @dataclass
123
+ class PageEdge:
124
+ """A directed edge in the page graph representing a navigation action.
125
+
126
+ Parameters
127
+ ----------
128
+ from_url:
129
+ Source page URL.
130
+ to_url:
131
+ Destination page URL.
132
+ action:
133
+ Navigation type: ``"click"``, ``"navigate"``, or ``"submit"``.
134
+ selector:
135
+ CSS selector that triggers the navigation (e.g. ``"a[href='/login']"``).
136
+ """
137
+
138
+ from_url: str
139
+ to_url: str
140
+ action: str
141
+ selector: str
142
+
143
+
144
+ @dataclass
145
+ class DiscoveryModel:
146
+ """Full discovery model produced by a single crawl run.
147
+
148
+ Parameters
149
+ ----------
150
+ base_url:
151
+ The root URL that was crawled.
152
+ pages:
153
+ All pages discovered during the crawl.
154
+ edges:
155
+ Navigation edges between pages.
156
+ crawl_duration_s:
157
+ Total crawl time in seconds.
158
+ crawl_timestamp:
159
+ ISO 8601 timestamp (with timezone) for when the crawl was completed.
160
+ """
161
+
162
+ base_url: str
163
+ pages: list[DiscoveredPage] = field(default_factory=list)
164
+ edges: list[PageEdge] = field(default_factory=list)
165
+ crawl_duration_s: float = 0.0
166
+ crawl_timestamp: str = ""
167
+
168
+ # ------------------------------------------------------------------
169
+ # Serialisation
170
+ # ------------------------------------------------------------------
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ """Serialise to a JSON-compatible dict matching the issue output shape."""
174
+ return dataclasses.asdict(self)
175
+
176
+ def to_json(self) -> str:
177
+ """Serialise to a JSON string (pretty-printed, 2-space indent)."""
178
+ return json.dumps(self.to_dict(), indent=2)