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,149 @@
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
+ """CTRF (Common Test Results Format) report formatter for NAT scan output.
7
+
8
+ Produces a single ``ctrf-report.json`` file that conforms to the
9
+ `CTRF schema <https://ctrf.io>`_ (v0). The report contains one test entry
10
+ per test case in the provided :class:`~mannf.core.testing.models.TestSuite`.
11
+
12
+ Usage::
13
+
14
+ from mannf.product.formatters.ctrf_formatter import build_ctrf_report
15
+
16
+ json_str = build_ctrf_report(report, suite)
17
+ Path("ctrf-report.json").write_text(json_str, encoding="utf-8")
18
+
19
+ CTRF is consumed by several GitHub Action steps (e.g. ``ctrf-io/github-actions-ctrf``),
20
+ Slack notification bots, and custom dashboards.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from datetime import datetime, timezone
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ if TYPE_CHECKING:
30
+ from mannf.core.testing.models import TestSuite
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Status mapping
34
+ # ---------------------------------------------------------------------------
35
+
36
+ # CTRF statuses: passed | failed | skipped | pending | other
37
+ _STATUS_MAP = {
38
+ "passed": "passed",
39
+ "failed": "failed",
40
+ "broken": "failed",
41
+ "skipped": "skipped",
42
+ }
43
+
44
+
45
+ def _result_status(result: Any) -> str:
46
+ """Return a CTRF-compatible status string for a single test result."""
47
+ if not getattr(result, "passed", True):
48
+ return "failed"
49
+ return "passed"
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Public API
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def build_ctrf_report(
58
+ report: dict[str, Any],
59
+ suite: "TestSuite",
60
+ ) -> str:
61
+ """Build a CTRF JSON report string from a NAT scan result.
62
+
63
+ Parameters
64
+ ----------
65
+ report:
66
+ The NAT scan report dict (as produced by ``_run_scan``).
67
+ suite:
68
+ The :class:`~mannf.core.testing.models.TestSuite` that ran the tests.
69
+
70
+ Returns
71
+ -------
72
+ str
73
+ Pretty-printed CTRF JSON suitable for writing to a ``.json`` file.
74
+ """
75
+ s = report.get("suite", {})
76
+ suite_name = getattr(suite, "name", report.get("spec", "NAT Scan"))
77
+ base_url = report.get("base_url", report.get("endpoint", ""))
78
+ now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
79
+
80
+ total = int(s.get("total", 0))
81
+ passed = int(s.get("passed", 0))
82
+ failed = int(s.get("failed", 0)) + int(s.get("errored", 0))
83
+ skipped = 0
84
+ pending = 0
85
+ other = total - passed - failed - skipped - pending
86
+ if other < 0:
87
+ other = 0
88
+
89
+ tests: list[dict[str, Any]] = []
90
+ for result in getattr(suite, "results", []):
91
+ tc_id = getattr(result, "test_case_id", None) or "unknown"
92
+ status = _result_status(result)
93
+ exec_ms = getattr(result, "execution_time_ms", None) or 0.0
94
+ error = getattr(result, "error", None)
95
+
96
+ entry: dict[str, Any] = {
97
+ "name": tc_id,
98
+ "status": status,
99
+ "duration": round(float(exec_ms), 3),
100
+ "start": now_ms,
101
+ "stop": now_ms + int(exec_ms),
102
+ }
103
+ if error:
104
+ entry["message"] = str(error)
105
+ entry["trace"] = str(error)
106
+ if base_url:
107
+ entry["suite"] = suite_name
108
+ tests.append(entry)
109
+
110
+ ctrf: dict[str, Any] = {
111
+ "results": {
112
+ "tool": {
113
+ "name": "NAT",
114
+ "version": _nat_version(),
115
+ "url": "https://nat-testing.io",
116
+ },
117
+ "summary": {
118
+ "tests": total,
119
+ "passed": passed,
120
+ "failed": failed,
121
+ "pending": pending,
122
+ "skipped": skipped,
123
+ "other": other,
124
+ "start": now_ms,
125
+ "stop": now_ms,
126
+ },
127
+ "tests": tests,
128
+ "environment": {
129
+ "appName": suite_name,
130
+ "appUrl": base_url,
131
+ },
132
+ }
133
+ }
134
+
135
+ return json.dumps(ctrf, indent=2, ensure_ascii=False)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Internal helpers
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def _nat_version() -> str:
144
+ """Return the installed NAT version string, or 'unknown' if unavailable."""
145
+ try:
146
+ from mannf._version import __version__
147
+ return __version__
148
+ except Exception:
149
+ return "unknown"
@@ -0,0 +1,30 @@
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
+ """Self-healing test suite subsystem for NAT."""
7
+
8
+ from mannf.product.healing.graphql_schema_diff import GraphQLSchemaDiffer
9
+ from mannf.product.healing.healer import TestHealer
10
+ from mannf.product.healing.models import (
11
+ EndpointChange,
12
+ FieldChange,
13
+ HealingAction,
14
+ HealingReport,
15
+ HealingResult,
16
+ SchemaDiff,
17
+ )
18
+ from mannf.product.healing.schema_diff import SchemaDiffer
19
+
20
+ __all__ = [
21
+ "SchemaDiff",
22
+ "EndpointChange",
23
+ "FieldChange",
24
+ "HealingAction",
25
+ "HealingReport",
26
+ "HealingResult",
27
+ "SchemaDiffer",
28
+ "GraphQLSchemaDiffer",
29
+ "TestHealer",
30
+ ]
@@ -0,0 +1,152 @@
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
+ """GraphQL schema differ."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, List
12
+
13
+ from mannf.product.healing.models import EndpointChange, FieldChange, SchemaDiff
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class GraphQLSchemaDiffer:
19
+ """Compare two GraphQL introspection results and return a :class:`SchemaDiff`."""
20
+
21
+ def diff(
22
+ self, old_introspection: Dict[str, Any], new_introspection: Dict[str, Any]
23
+ ) -> SchemaDiff:
24
+ """Compare two GraphQL introspection results."""
25
+ old_schema = self._extract_schema(old_introspection)
26
+ new_schema = self._extract_schema(new_introspection)
27
+
28
+ old_types = old_schema.get("types", {})
29
+ new_types = new_schema.get("types", {})
30
+ old_ops = old_schema.get("operations", {})
31
+ new_ops = new_schema.get("operations", {})
32
+
33
+ added_endpoints: List[EndpointChange] = []
34
+ removed_endpoints: List[EndpointChange] = []
35
+ modified_endpoints: List[EndpointChange] = []
36
+
37
+ for name in set(new_ops) - set(old_ops):
38
+ kind = new_ops[name].get("kind", "query").upper()
39
+ added_endpoints.append(EndpointChange(method=kind, path=name, change_type="added"))
40
+ for name in set(old_ops) - set(new_ops):
41
+ kind = old_ops[name].get("kind", "query").upper()
42
+ removed_endpoints.append(EndpointChange(method=kind, path=name, change_type="removed"))
43
+ for name in set(old_ops) & set(new_ops):
44
+ old_op = old_ops[name]
45
+ new_op = new_ops[name]
46
+ changes = self._diff_gql_operation(name, old_op, new_op, old_types, new_types)
47
+ if changes:
48
+ kind = new_op.get("kind", "query").upper()
49
+ modified_endpoints.append(
50
+ EndpointChange(method=kind, path=name, change_type="modified", details=changes)
51
+ )
52
+
53
+ result = SchemaDiff(
54
+ added_endpoints=sorted(added_endpoints, key=lambda e: e.path),
55
+ removed_endpoints=sorted(removed_endpoints, key=lambda e: e.path),
56
+ modified_endpoints=sorted(modified_endpoints, key=lambda e: e.path),
57
+ )
58
+ result.summary = {
59
+ "added": len(result.added_endpoints),
60
+ "removed": len(result.removed_endpoints),
61
+ "modified": len(result.modified_endpoints),
62
+ "total_changes": (
63
+ len(result.added_endpoints)
64
+ + len(result.removed_endpoints)
65
+ + len(result.modified_endpoints)
66
+ ),
67
+ }
68
+ return result
69
+
70
+ def _extract_schema(self, introspection: Dict[str, Any]) -> Dict[str, Any]:
71
+ """Parse introspection result into operations and types dict."""
72
+ data = introspection.get("data", introspection)
73
+ schema = data.get("__schema", {})
74
+
75
+ types_map: Dict[str, Dict[str, Any]] = {}
76
+ for t in schema.get("types", []):
77
+ name = t.get("name", "")
78
+ if name and not name.startswith("__"):
79
+ types_map[name] = t
80
+
81
+ operations: Dict[str, Dict[str, Any]] = {}
82
+
83
+ def _extract_ops_from_type(type_name: str, kind: str) -> None:
84
+ type_obj = types_map.get(type_name, {})
85
+ for f in type_obj.get("fields", []) or []:
86
+ field_name = f.get("name", "")
87
+ if field_name:
88
+ operations[field_name] = {
89
+ "kind": kind,
90
+ "type": f.get("type"),
91
+ "args": f.get("args", []),
92
+ }
93
+
94
+ query_type = (schema.get("queryType") or {}).get("name")
95
+ mutation_type = (schema.get("mutationType") or {}).get("name")
96
+ if query_type:
97
+ _extract_ops_from_type(query_type, "query")
98
+ if mutation_type:
99
+ _extract_ops_from_type(mutation_type, "mutation")
100
+
101
+ return {"types": types_map, "operations": operations}
102
+
103
+ def _diff_gql_operation(
104
+ self,
105
+ name: str,
106
+ old_op: Dict[str, Any],
107
+ new_op: Dict[str, Any],
108
+ old_types: Dict[str, Any],
109
+ new_types: Dict[str, Any],
110
+ ) -> List[FieldChange]:
111
+ changes: List[FieldChange] = []
112
+
113
+ # Compare return type
114
+ old_type = _type_name(old_op.get("type"))
115
+ new_type = _type_name(new_op.get("type"))
116
+ if old_type != new_type:
117
+ changes.append(FieldChange(
118
+ field_path=f"{name}.returnType",
119
+ change_type="type_changed",
120
+ old_value=old_type,
121
+ new_value=new_type,
122
+ ))
123
+
124
+ # Compare args
125
+ old_args = {a["name"]: a for a in (old_op.get("args") or []) if a.get("name")}
126
+ new_args = {a["name"]: a for a in (new_op.get("args") or []) if a.get("name")}
127
+ for arg_name in set(old_args) - set(new_args):
128
+ changes.append(FieldChange(
129
+ field_path=f"{name}.args.{arg_name}",
130
+ change_type="removed",
131
+ old_value=old_args[arg_name],
132
+ new_value=None,
133
+ ))
134
+ for arg_name in set(new_args) - set(old_args):
135
+ changes.append(FieldChange(
136
+ field_path=f"{name}.args.{arg_name}",
137
+ change_type="added",
138
+ old_value=None,
139
+ new_value=new_args[arg_name],
140
+ ))
141
+
142
+ return changes
143
+
144
+
145
+ def _type_name(type_ref: Any) -> str:
146
+ """Unwrap NON_NULL/LIST wrappers and return base type name."""
147
+ if not type_ref:
148
+ return ""
149
+ kind = type_ref.get("kind", "")
150
+ if kind in ("NON_NULL", "LIST"):
151
+ return _type_name(type_ref.get("ofType"))
152
+ return type_ref.get("name", "")
@@ -0,0 +1,141 @@
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
+ """TestHealer — automated self-healing for NAT test suites."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from mannf.product.healing.models import HealingAction, HealingReport, HealingResult
16
+ from mannf.product.healing.schema_diff import SchemaDiffer
17
+ from mannf.core.testing.models import TestCase
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class TestHealer:
23
+ """Automatically heal a NAT test suite when the API spec changes.
24
+
25
+ Steps:
26
+ 1. Parse both specs using OpenApiParser
27
+ 2. Run SchemaDiffer.diff() to identify changes
28
+ 3. For removed endpoints: retire matching tests
29
+ 4. For added endpoints: generate new tests
30
+ 5. For modified endpoints: remove old, generate new
31
+ """
32
+
33
+ def heal(
34
+ self,
35
+ old_spec_path: str,
36
+ new_spec_path: str,
37
+ existing_tests: List[TestCase],
38
+ base_url: str = "",
39
+ scan_id: Optional[str] = None,
40
+ ) -> HealingResult:
41
+ """Run healing and return a :class:`HealingResult`."""
42
+ from mannf.product.integrations.openapi_parser import OpenApiParser, _load_spec
43
+
44
+ scan_id = scan_id or str(uuid.uuid4())
45
+ now = datetime.now(timezone.utc).isoformat()
46
+
47
+ # Load both specs
48
+ old_spec = _load_spec(old_spec_path)
49
+ new_spec = _load_spec(new_spec_path)
50
+
51
+ # Diff
52
+ differ = SchemaDiffer()
53
+ schema_diff = differ.diff(old_spec, new_spec)
54
+
55
+ actions: List[HealingAction] = []
56
+ removed_tests: List[TestCase] = []
57
+ new_tests: List[TestCase] = []
58
+
59
+ # Build sets of affected endpoints
60
+ removed_eps = {(ec.method, ec.path) for ec in schema_diff.removed_endpoints}
61
+ added_eps = {(ec.method, ec.path) for ec in schema_diff.added_endpoints}
62
+ modified_eps = {(ec.method, ec.path) for ec in schema_diff.modified_endpoints}
63
+
64
+ # Categorize existing tests
65
+ kept_tests: List[TestCase] = []
66
+ for tc in existing_tests:
67
+ spec_path = tc.metadata.get("spec_path", tc.target)
68
+ method = tc.metadata.get("method", "GET")
69
+ key = (method, spec_path)
70
+
71
+ if key in removed_eps:
72
+ removed_tests.append(tc)
73
+ actions.append(HealingAction(
74
+ action_type="removed",
75
+ endpoint=f"{method} {spec_path}",
76
+ test_case_id=tc.id,
77
+ reason="Endpoint removed from spec",
78
+ timestamp=now,
79
+ ))
80
+ elif key in modified_eps:
81
+ removed_tests.append(tc)
82
+ actions.append(HealingAction(
83
+ action_type="removed",
84
+ endpoint=f"{method} {spec_path}",
85
+ test_case_id=tc.id,
86
+ reason="Endpoint modified in spec — regenerating",
87
+ timestamp=now,
88
+ ))
89
+ else:
90
+ kept_tests.append(tc)
91
+
92
+ # Generate new tests for added endpoints and modified endpoints
93
+ needs_regen = added_eps | modified_eps
94
+ if needs_regen:
95
+ try:
96
+ parser = OpenApiParser(spec_path=new_spec_path, base_url=base_url)
97
+ all_new_cases = parser.parse()
98
+ for tc in all_new_cases:
99
+ tc_spec_path = tc.metadata.get("spec_path", tc.target)
100
+ tc_method = tc.metadata.get("method", "GET")
101
+ key = (tc_method, tc_spec_path)
102
+ if key in needs_regen:
103
+ new_tests.append(tc)
104
+ action_type = "regenerated"
105
+ reason = (
106
+ "Endpoint added to spec"
107
+ if key in added_eps
108
+ else "Endpoint modified — regenerated"
109
+ )
110
+ actions.append(HealingAction(
111
+ action_type=action_type,
112
+ endpoint=f"{tc_method} {tc_spec_path}",
113
+ test_case_id=tc.id,
114
+ reason=reason,
115
+ timestamp=now,
116
+ ))
117
+ except Exception as exc:
118
+ logger.warning("Failed to regenerate tests for new/modified endpoints: %s", exc)
119
+
120
+ summary = {
121
+ "regenerated": sum(1 for a in actions if a.action_type == "regenerated"),
122
+ "removed": sum(1 for a in actions if a.action_type == "removed"),
123
+ "kept": len(kept_tests),
124
+ }
125
+
126
+ report = HealingReport(
127
+ scan_id=scan_id,
128
+ old_spec=old_spec_path,
129
+ new_spec=new_spec_path,
130
+ actions=actions,
131
+ summary=summary,
132
+ timestamp=now,
133
+ schema_diff=schema_diff,
134
+ )
135
+
136
+ return HealingResult(
137
+ kept_tests=kept_tests,
138
+ removed_tests=removed_tests,
139
+ new_tests=new_tests,
140
+ healing_report=report,
141
+ )
@@ -0,0 +1,175 @@
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
+ """Data models for the self-healing test suite system."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ @dataclass
16
+ class FieldChange:
17
+ """A field-level change detected between two API specs."""
18
+
19
+ field_path: str
20
+ change_type: str # "added" | "removed" | "type_changed" | "constraint_changed"
21
+ old_value: Any = None
22
+ new_value: Any = None
23
+
24
+ def to_dict(self) -> Dict[str, Any]:
25
+ return {
26
+ "field_path": self.field_path,
27
+ "change_type": self.change_type,
28
+ "old_value": self.old_value,
29
+ "new_value": self.new_value,
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class EndpointChange:
35
+ """A change detected for a specific API endpoint."""
36
+
37
+ method: str
38
+ path: str
39
+ change_type: str # "added" | "removed" | "modified"
40
+ details: List[FieldChange] = field(default_factory=list)
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ return {
44
+ "method": self.method,
45
+ "path": self.path,
46
+ "change_type": self.change_type,
47
+ "details": [d.to_dict() for d in self.details],
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class SchemaDiff:
53
+ """Result of comparing two API specs."""
54
+
55
+ added_endpoints: List[EndpointChange] = field(default_factory=list)
56
+ removed_endpoints: List[EndpointChange] = field(default_factory=list)
57
+ modified_endpoints: List[EndpointChange] = field(default_factory=list)
58
+ summary: Dict[str, int] = field(default_factory=dict)
59
+
60
+ def to_dict(self) -> Dict[str, Any]:
61
+ return {
62
+ "added_endpoints": [e.to_dict() for e in self.added_endpoints],
63
+ "removed_endpoints": [e.to_dict() for e in self.removed_endpoints],
64
+ "modified_endpoints": [e.to_dict() for e in self.modified_endpoints],
65
+ "summary": self.summary,
66
+ }
67
+
68
+ @property
69
+ def has_changes(self) -> bool:
70
+ return bool(self.added_endpoints or self.removed_endpoints or self.modified_endpoints)
71
+
72
+
73
+ @dataclass
74
+ class HealingAction:
75
+ """A single healing action taken by the TestHealer."""
76
+
77
+ action_type: str # "regenerated" | "removed" | "repaired"
78
+ endpoint: str # "{method} {path}"
79
+ test_case_id: str
80
+ reason: str
81
+ timestamp: str
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ return {
85
+ "action_type": self.action_type,
86
+ "endpoint": self.endpoint,
87
+ "test_case_id": self.test_case_id,
88
+ "reason": self.reason,
89
+ "timestamp": self.timestamp,
90
+ }
91
+
92
+
93
+ @dataclass
94
+ class HealingReport:
95
+ """Full report of all healing actions taken during a self-healing run."""
96
+
97
+ scan_id: str
98
+ old_spec: str
99
+ new_spec: str
100
+ actions: List[HealingAction] = field(default_factory=list)
101
+ summary: Dict[str, int] = field(default_factory=dict)
102
+ timestamp: str = ""
103
+ schema_diff: Optional[SchemaDiff] = None
104
+
105
+ def to_dict(self) -> Dict[str, Any]:
106
+ return {
107
+ "scan_id": self.scan_id,
108
+ "old_spec": self.old_spec,
109
+ "new_spec": self.new_spec,
110
+ "actions": [a.to_dict() for a in self.actions],
111
+ "summary": self.summary,
112
+ "timestamp": self.timestamp,
113
+ "schema_diff": self.schema_diff.to_dict() if self.schema_diff else None,
114
+ }
115
+
116
+ def save(self, path: str) -> None:
117
+ with open(path, "w", encoding="utf-8") as fh:
118
+ json.dump(self.to_dict(), fh, indent=2)
119
+
120
+ def human_readable(self) -> str:
121
+ lines = []
122
+ lines.append("=" * 60)
123
+ lines.append(" Self-Healing Report")
124
+ lines.append("=" * 60)
125
+ lines.append(f" Scan ID: {self.scan_id}")
126
+ lines.append(f" Old spec: {self.old_spec}")
127
+ lines.append(f" New spec: {self.new_spec}")
128
+ lines.append(f" Time: {self.timestamp}")
129
+ lines.append("")
130
+ s = self.summary
131
+ lines.append(f" Tests regenerated: {s.get('regenerated', 0)}")
132
+ lines.append(f" Tests removed: {s.get('removed', 0)}")
133
+ lines.append(f" Tests kept: {s.get('kept', 0)}")
134
+ lines.append("=" * 60)
135
+ if self.actions:
136
+ lines.append("\nActions:")
137
+ for action in self.actions:
138
+ lines.append(f" [{action.action_type.upper()}] {action.endpoint} — {action.reason}")
139
+ return "\n".join(lines)
140
+
141
+ def markdown(self) -> str:
142
+ lines = []
143
+ lines.append("# Self-Healing Report")
144
+ lines.append(f"\n**Scan ID:** {self.scan_id}")
145
+ lines.append(f"**Timestamp:** {self.timestamp}")
146
+ lines.append(f"**Old Spec:** `{self.old_spec}`")
147
+ lines.append(f"**New Spec:** `{self.new_spec}`")
148
+ s = self.summary
149
+ lines.append("\n## Summary")
150
+ lines.append("| Action | Count |")
151
+ lines.append("|--------|-------|")
152
+ lines.append(f"| Regenerated | {s.get('regenerated', 0)} |")
153
+ lines.append(f"| Removed | {s.get('removed', 0)} |")
154
+ lines.append(f"| Kept | {s.get('kept', 0)} |")
155
+ if self.actions:
156
+ lines.append("\n## Actions")
157
+ lines.append("| Type | Endpoint | Reason |")
158
+ lines.append("|------|----------|--------|")
159
+ for action in self.actions:
160
+ lines.append(f"| {action.action_type} | {action.endpoint} | {action.reason} |")
161
+ return "\n".join(lines)
162
+
163
+
164
+ @dataclass
165
+ class HealingResult:
166
+ """Result of running the TestHealer."""
167
+
168
+ kept_tests: List[Any] = field(default_factory=list) # List[TestCase]
169
+ removed_tests: List[Any] = field(default_factory=list) # List[TestCase]
170
+ new_tests: List[Any] = field(default_factory=list) # List[TestCase]
171
+ healing_report: Optional[HealingReport] = None
172
+
173
+ @property
174
+ def all_active_tests(self) -> List[Any]:
175
+ return self.kept_tests + self.new_tests