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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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)
|