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,845 @@
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 ingestor plugin.
7
+
8
+ Parses GraphQL schemas from introspection JSON or SDL (Schema Definition
9
+ Language) files and produces normalised
10
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
11
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` objects.
12
+
13
+ Two input formats are supported:
14
+
15
+ * **Introspection JSON** — the ``{"data": {"__schema": {...}}}`` or plain
16
+ ``{"__schema": {...}}`` payload returned by a GraphQL introspection query.
17
+ * **SDL** — ``.graphql`` / ``.gql`` source text with ``type Query``,
18
+ ``type Mutation``, and/or ``type Subscription`` root-type definitions.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from mannf.product.ingestors.base import IngestorPlugin
30
+ from mannf.product.ingestors.models import (
31
+ IngestedEndpoint,
32
+ IngestedTestCase,
33
+ IngestorSource,
34
+ IngestResult,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Constants
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # Priority mapping by GraphQL operation type
44
+ _OPERATION_PRIORITY: dict[str, str] = {
45
+ "QUERY": "medium",
46
+ "MUTATION": "high",
47
+ "SUBSCRIPTION": "low",
48
+ }
49
+
50
+ # Tags added to every endpoint by operation type
51
+ _OPERATION_TAGS: dict[str, list[str]] = {
52
+ "QUERY": ["graphql", "query"],
53
+ "MUTATION": ["graphql", "mutation"],
54
+ "SUBSCRIPTION": ["graphql", "subscription"],
55
+ }
56
+
57
+ # Regex patterns for the lightweight SDL parser
58
+ _RE_BLOCK_COMMENT = re.compile(r'"""(.*?)"""', re.DOTALL)
59
+ _RE_LINE_COMMENT = re.compile(r'#[^\n]*')
60
+ _RE_TYPE_BLOCK = re.compile(
61
+ r'type\s+(\w+)\s*(?:implements[^{]*)?\{([^}]*)\}',
62
+ re.DOTALL,
63
+ )
64
+ _RE_FIELD = re.compile(
65
+ r'(\w+)\s*(\([^)]*\))?\s*:\s*([\w!\[\]]+)',
66
+ )
67
+ _RE_ARG = re.compile(
68
+ r'(\w+)\s*:\s*([\w!\[\]]+)(?:\s*=\s*[^\s,)]+)?',
69
+ )
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Introspection JSON helpers
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def _unwrap_introspection(data: dict[str, Any]) -> dict[str, Any] | None:
78
+ """Return the ``__schema`` dict from an introspection payload, or ``None``.
79
+
80
+ Handles both the raw ``{"__schema": {...}}`` shape and the wrapped
81
+ ``{"data": {"__schema": {...}}}`` shape returned by some clients.
82
+ """
83
+ if "__schema" in data:
84
+ return data["__schema"] # type: ignore[return-value]
85
+ nested = data.get("data") or {}
86
+ if isinstance(nested, dict) and "__schema" in nested:
87
+ return nested["__schema"] # type: ignore[return-value]
88
+ return None
89
+
90
+
91
+ def _is_introspection_json(data: dict[str, Any]) -> bool:
92
+ """Return ``True`` if *data* looks like a GraphQL introspection result."""
93
+ return _unwrap_introspection(data) is not None
94
+
95
+
96
+ def _type_name(type_ref: dict[str, Any] | None) -> str:
97
+ """Unwrap a nested ``{"kind": ..., "name": ..., "ofType": ...}`` type ref."""
98
+ if not type_ref:
99
+ return ""
100
+ if type_ref.get("name"):
101
+ return type_ref["name"]
102
+ return _type_name(type_ref.get("ofType"))
103
+
104
+
105
+ def _is_non_null(type_ref: dict[str, Any] | None) -> bool:
106
+ """Return ``True`` if the type ref represents a non-null (required) type."""
107
+ if not type_ref:
108
+ return False
109
+ return type_ref.get("kind") == "NON_NULL"
110
+
111
+
112
+ def _args_from_introspection(
113
+ args: list[dict[str, Any]],
114
+ ) -> list[dict[str, Any]]:
115
+ """Convert an introspection ``args`` array to ingestor parameter dicts."""
116
+ params: list[dict[str, Any]] = []
117
+ for arg in args:
118
+ if not isinstance(arg, dict):
119
+ continue
120
+ params.append(
121
+ {
122
+ "name": arg.get("name", ""),
123
+ "description": arg.get("description") or "",
124
+ "required": _is_non_null(arg.get("type")),
125
+ "schema": {"type": _type_name(arg.get("type"))},
126
+ "defaultValue": arg.get("defaultValue"),
127
+ }
128
+ )
129
+ return params
130
+
131
+
132
+ def _endpoints_from_introspection(
133
+ schema: dict[str, Any],
134
+ base_url: str,
135
+ include_deprecated: bool,
136
+ include_subscriptions: bool,
137
+ ) -> list[IngestedEndpoint]:
138
+ """Extract endpoints from a parsed ``__schema`` dict."""
139
+ # Build a lookup of all types by name
140
+ types_by_name: dict[str, dict[str, Any]] = {
141
+ t["name"]: t
142
+ for t in schema.get("types", [])
143
+ if isinstance(t, dict) and t.get("name")
144
+ }
145
+
146
+ root_type_map: dict[str, str] = {}
147
+ for role in ("queryType", "mutationType", "subscriptionType"):
148
+ ref = schema.get(role)
149
+ if isinstance(ref, dict) and ref.get("name"):
150
+ root_type_map[role] = ref["name"]
151
+
152
+ operation_kind: dict[str, str] = {
153
+ "queryType": "QUERY",
154
+ "mutationType": "MUTATION",
155
+ "subscriptionType": "SUBSCRIPTION",
156
+ }
157
+
158
+ endpoints: list[IngestedEndpoint] = []
159
+
160
+ for role, type_name in root_type_map.items():
161
+ if role == "subscriptionType" and not include_subscriptions:
162
+ continue
163
+ kind = operation_kind[role]
164
+ root_type = types_by_name.get(type_name)
165
+ if not root_type:
166
+ continue
167
+
168
+ for field in root_type.get("fields") or []:
169
+ if not isinstance(field, dict):
170
+ continue
171
+ if field.get("isDeprecated") and not include_deprecated:
172
+ continue
173
+
174
+ field_name: str = field.get("name", "")
175
+ description: str = field.get("description") or ""
176
+ return_type: str = _type_name(field.get("type"))
177
+ args: list[dict[str, Any]] = field.get("args") or []
178
+
179
+ # Collect directives for auth hints
180
+ auth_requirements: list[str] = _auth_from_directives(
181
+ field.get("directives") or []
182
+ )
183
+
184
+ path = f"/graphql#{kind.lower()}.{field_name}"
185
+
186
+ endpoint = IngestedEndpoint(
187
+ method=kind,
188
+ path=path,
189
+ summary=description,
190
+ description=description,
191
+ parameters=_args_from_introspection(args),
192
+ request_body=_build_request_body(args),
193
+ responses={
194
+ "200": {
195
+ "description": "Successful response",
196
+ "type": return_type,
197
+ }
198
+ },
199
+ auth_requirements=auth_requirements,
200
+ tags=list(_OPERATION_TAGS[kind]),
201
+ metadata={
202
+ "operationName": field_name,
203
+ "returnType": return_type,
204
+ "typeName": type_name,
205
+ "deprecated": bool(field.get("isDeprecated", False)),
206
+ "base_url": base_url,
207
+ "source": "introspection",
208
+ },
209
+ )
210
+ endpoints.append(endpoint)
211
+
212
+ return endpoints
213
+
214
+
215
+ def _auth_from_directives(directives: list[dict[str, Any]]) -> list[str]:
216
+ """Return auth scheme names hinted by directive names."""
217
+ auth_hints: list[str] = []
218
+ auth_directive_names = {"auth", "authenticated", "requiresAuth", "authorize", "jwt"}
219
+ for d in directives:
220
+ if not isinstance(d, dict):
221
+ continue
222
+ name = d.get("name", "")
223
+ if name in auth_directive_names:
224
+ auth_hints.append(name)
225
+ return auth_hints
226
+
227
+
228
+ def _build_request_body(
229
+ args: list[dict[str, Any]],
230
+ ) -> dict[str, Any] | None:
231
+ """Build a minimal request body descriptor from introspection args."""
232
+ if not args:
233
+ return None
234
+ properties: dict[str, Any] = {}
235
+ required: list[str] = []
236
+ for arg in args:
237
+ if not isinstance(arg, dict):
238
+ continue
239
+ name = arg.get("name", "")
240
+ type_name_str = _type_name(arg.get("type"))
241
+ properties[name] = {"type": type_name_str}
242
+ if _is_non_null(arg.get("type")):
243
+ required.append(name)
244
+
245
+ body: dict[str, Any] = {
246
+ "content": {
247
+ "application/json": {
248
+ "schema": {
249
+ "type": "object",
250
+ "properties": properties,
251
+ }
252
+ }
253
+ }
254
+ }
255
+ if required:
256
+ body["content"]["application/json"]["schema"]["required"] = required
257
+ return body
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # SDL parser helpers
262
+ # ---------------------------------------------------------------------------
263
+
264
+
265
+ def _is_sdl(content: str) -> bool:
266
+ """Return ``True`` if *content* appears to be GraphQL SDL source text."""
267
+ stripped = content.strip()
268
+ # SDL files often start with comments, schema/type keywords, or triple-quoted docs
269
+ return bool(
270
+ re.search(r'\btype\s+\w+', stripped)
271
+ or re.search(r'\bschema\s*\{', stripped)
272
+ or stripped.startswith('"""')
273
+ or stripped.startswith('#')
274
+ )
275
+
276
+
277
+ def _strip_comments(content: str) -> str:
278
+ """Strip ``#`` line comments from SDL content (preserving triple-quoted strings)."""
279
+ return _RE_LINE_COMMENT.sub('', content)
280
+
281
+
282
+ class _SDLField:
283
+ """Lightweight representation of a parsed SDL field/operation."""
284
+
285
+ __slots__ = ("name", "description", "args", "return_type")
286
+
287
+ def __init__(
288
+ self,
289
+ name: str,
290
+ description: str,
291
+ args: list[dict[str, Any]],
292
+ return_type: str,
293
+ ) -> None:
294
+ self.name = name
295
+ self.description = description
296
+ self.args = args
297
+ self.return_type = return_type
298
+
299
+
300
+ def _parse_sdl_args(args_str: str) -> list[dict[str, Any]]:
301
+ """Parse a parenthesised argument list string into parameter dicts."""
302
+ if not args_str:
303
+ return []
304
+ params: list[dict[str, Any]] = []
305
+ # Strip outer parentheses if present
306
+ args_str = args_str.strip().lstrip('(').rstrip(')')
307
+ for m in _RE_ARG.finditer(args_str):
308
+ arg_name = m.group(1)
309
+ arg_type = m.group(2)
310
+ required = arg_type.endswith('!')
311
+ params.append(
312
+ {
313
+ "name": arg_name,
314
+ "description": "",
315
+ "required": required,
316
+ "schema": {"type": arg_type.rstrip('!')},
317
+ "defaultValue": None,
318
+ }
319
+ )
320
+ return params
321
+
322
+
323
+ def _parse_sdl_type_block(block_body: str) -> list[_SDLField]:
324
+ """Parse the body of a GraphQL type block into :class:`_SDLField` objects.
325
+
326
+ Handles triple-quoted description strings attached to fields.
327
+ """
328
+ fields: list[_SDLField] = []
329
+ # Extract triple-quoted descriptions alongside fields
330
+ # Replace triple-quoted docs with placeholder to assist field parsing
331
+ descriptions: dict[int, str] = {}
332
+ idx = 0
333
+ cleaned_parts: list[str] = []
334
+ last = 0
335
+ for m in _RE_BLOCK_COMMENT.finditer(block_body):
336
+ cleaned_parts.append(block_body[last:m.start()])
337
+ desc_text = m.group(1).strip()
338
+ placeholder = f"__DOC_{idx}__"
339
+ cleaned_parts.append(placeholder)
340
+ descriptions[idx] = desc_text
341
+ idx += 1
342
+ last = m.end()
343
+ cleaned_parts.append(block_body[last:])
344
+ cleaned = ''.join(cleaned_parts)
345
+ cleaned = _strip_comments(cleaned)
346
+
347
+ # Split lines for field detection
348
+ lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
349
+ pending_desc = ""
350
+ for line in lines:
351
+ # Check if this line is a doc placeholder
352
+ doc_match = re.match(r'^__DOC_(\d+)__$', line)
353
+ if doc_match:
354
+ pending_desc = descriptions[int(doc_match.group(1))]
355
+ continue
356
+
357
+ field_match = _RE_FIELD.match(line)
358
+ if field_match:
359
+ f_name = field_match.group(1)
360
+ args_str = field_match.group(2) or ""
361
+ f_return_type = field_match.group(3)
362
+ args = _parse_sdl_args(args_str)
363
+ fields.append(
364
+ _SDLField(
365
+ name=f_name,
366
+ description=pending_desc,
367
+ args=args,
368
+ return_type=f_return_type,
369
+ )
370
+ )
371
+ pending_desc = ""
372
+
373
+ return fields
374
+
375
+
376
+ def _endpoints_from_sdl(
377
+ content: str,
378
+ base_url: str,
379
+ include_deprecated: bool,
380
+ include_subscriptions: bool,
381
+ ) -> tuple[list[IngestedEndpoint], list[str]]:
382
+ """Parse SDL text and return ``(endpoints, warnings)``."""
383
+ endpoints: list[IngestedEndpoint] = []
384
+ warnings: list[str] = []
385
+
386
+ root_type_names: dict[str, str] = {
387
+ "Query": "QUERY",
388
+ "Mutation": "MUTATION",
389
+ "Subscription": "SUBSCRIPTION",
390
+ }
391
+
392
+ for type_block_match in _RE_TYPE_BLOCK.finditer(content):
393
+ type_name = type_block_match.group(1)
394
+ if type_name not in root_type_names:
395
+ continue
396
+ kind = root_type_names[type_name]
397
+ if kind == "SUBSCRIPTION" and not include_subscriptions:
398
+ continue
399
+
400
+ block_body = type_block_match.group(2)
401
+ sdl_fields = _parse_sdl_type_block(block_body)
402
+
403
+ for sdl_field in sdl_fields:
404
+ # SDL has no built-in deprecated marker in a simple regex parser;
405
+ # skip fields only if include_deprecated is explicitly handled
406
+ path = f"/graphql#{kind.lower()}.{sdl_field.name}"
407
+
408
+ # Build parameter list from args
409
+ parameters = [
410
+ {
411
+ "name": a["name"],
412
+ "description": a["description"],
413
+ "required": a["required"],
414
+ "schema": a["schema"],
415
+ "defaultValue": a["defaultValue"],
416
+ }
417
+ for a in sdl_field.args
418
+ ]
419
+
420
+ request_body: dict[str, Any] | None = None
421
+ if sdl_field.args:
422
+ props = {
423
+ a["name"]: {"type": a["schema"].get("type", "")}
424
+ for a in sdl_field.args
425
+ }
426
+ required_args = [a["name"] for a in sdl_field.args if a["required"]]
427
+ schema_obj: dict[str, Any] = {
428
+ "type": "object",
429
+ "properties": props,
430
+ }
431
+ if required_args:
432
+ schema_obj["required"] = required_args
433
+ request_body = {
434
+ "content": {
435
+ "application/json": {"schema": schema_obj}
436
+ }
437
+ }
438
+
439
+ endpoint = IngestedEndpoint(
440
+ method=kind,
441
+ path=path,
442
+ summary=sdl_field.description,
443
+ description=sdl_field.description,
444
+ parameters=parameters,
445
+ request_body=request_body,
446
+ responses={
447
+ "200": {
448
+ "description": "Successful response",
449
+ "type": sdl_field.return_type,
450
+ }
451
+ },
452
+ auth_requirements=[],
453
+ tags=list(_OPERATION_TAGS[kind]),
454
+ metadata={
455
+ "operationName": sdl_field.name,
456
+ "returnType": sdl_field.return_type,
457
+ "typeName": type_name,
458
+ "deprecated": False,
459
+ "base_url": base_url,
460
+ "source": "sdl",
461
+ },
462
+ )
463
+ endpoints.append(endpoint)
464
+
465
+ return endpoints, warnings
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Test case generation
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ def _generate_test_cases(endpoint: IngestedEndpoint) -> list[IngestedTestCase]:
474
+ """Generate synthetic test cases for a GraphQL *endpoint*.
475
+
476
+ Produces:
477
+
478
+ * **Happy-path** — valid operation, expect a 200 response.
479
+ * **Missing required args** — omit a required argument, expect an error.
480
+ * **Security** — if auth is required, omit credentials (expect 401/403).
481
+ * **Invalid input type** — for MUTATION operations with typed args.
482
+ """
483
+ cases: list[IngestedTestCase] = []
484
+ kind = endpoint.method # "QUERY", "MUTATION", or "SUBSCRIPTION"
485
+ op_name = endpoint.metadata.get("operationName", endpoint.path)
486
+ priority = _OPERATION_PRIORITY.get(kind, "medium")
487
+ source_ref = f"{kind} {op_name}"
488
+
489
+ # --- Happy-path ---
490
+ cases.append(
491
+ IngestedTestCase(
492
+ name=f"[happy path] {kind} {op_name}",
493
+ description=(
494
+ f"Execute a valid {kind.lower()} '{op_name}' and expect a "
495
+ f"successful response."
496
+ ),
497
+ steps=[
498
+ {
499
+ "action": f"{kind} {op_name}",
500
+ "description": "Send valid GraphQL operation",
501
+ }
502
+ ],
503
+ expected_result="200 response with data field",
504
+ priority=priority,
505
+ tags=["happy-path", "functional"],
506
+ source_ref=source_ref,
507
+ metadata={
508
+ "intent": "functional",
509
+ "test_type": "positive",
510
+ "operationName": op_name,
511
+ },
512
+ )
513
+ )
514
+
515
+ # --- Missing required args ---
516
+ required_params = [
517
+ p for p in endpoint.parameters
518
+ if isinstance(p, dict) and p.get("required")
519
+ ]
520
+ if required_params:
521
+ first_required = required_params[0]
522
+ param_name = first_required.get("name", "arg")
523
+ cases.append(
524
+ IngestedTestCase(
525
+ name=f"[missing required arg] {kind} {op_name} — omit '{param_name}'",
526
+ description=(
527
+ f"Execute '{op_name}' without required argument "
528
+ f"'{param_name}' and expect an error response."
529
+ ),
530
+ steps=[
531
+ {
532
+ "action": f"{kind} {op_name}",
533
+ "description": f"Omit required argument '{param_name}'",
534
+ }
535
+ ],
536
+ expected_result="GraphQL error or 400 response",
537
+ priority=priority,
538
+ tags=["negative", "functional"],
539
+ source_ref=source_ref,
540
+ metadata={
541
+ "intent": "functional",
542
+ "test_type": "negative",
543
+ "neg_type": "missing_required_arg",
544
+ "missing_arg": param_name,
545
+ },
546
+ )
547
+ )
548
+
549
+ # --- Security test case ---
550
+ if endpoint.auth_requirements:
551
+ cases.append(
552
+ IngestedTestCase(
553
+ name=f"[security] {kind} {op_name} — missing auth",
554
+ description=(
555
+ f"Execute '{op_name}' without authentication and expect "
556
+ f"a 401 or 403 response."
557
+ ),
558
+ steps=[
559
+ {
560
+ "action": f"{kind} {op_name}",
561
+ "description": "Send request without auth credentials",
562
+ }
563
+ ],
564
+ expected_result="401 or 403 response",
565
+ priority="high",
566
+ tags=["security", "auth"],
567
+ source_ref=source_ref,
568
+ metadata={
569
+ "intent": "security",
570
+ "test_type": "negative",
571
+ "neg_type": "missing_auth",
572
+ "auth_schemes": endpoint.auth_requirements,
573
+ },
574
+ )
575
+ )
576
+
577
+ # --- Invalid input type (mutations only) ---
578
+ if kind == "MUTATION" and endpoint.parameters:
579
+ typed_params = [
580
+ p for p in endpoint.parameters
581
+ if isinstance(p, dict) and p.get("schema", {}).get("type")
582
+ ]
583
+ if typed_params:
584
+ param = typed_params[0]
585
+ param_name = param.get("name", "input")
586
+ cases.append(
587
+ IngestedTestCase(
588
+ name=f"[invalid input type] MUTATION {op_name} — wrong type for '{param_name}'",
589
+ description=(
590
+ f"Execute mutation '{op_name}' with an invalid type "
591
+ f"for '{param_name}' and expect an error response."
592
+ ),
593
+ steps=[
594
+ {
595
+ "action": f"MUTATION {op_name}",
596
+ "description": f"Use wrong type for '{param_name}'",
597
+ }
598
+ ],
599
+ expected_result="GraphQL error or 400 response",
600
+ priority=priority,
601
+ tags=["negative", "functional"],
602
+ source_ref=source_ref,
603
+ metadata={
604
+ "intent": "functional",
605
+ "test_type": "negative",
606
+ "neg_type": "invalid_input_type",
607
+ "param": param_name,
608
+ },
609
+ )
610
+ )
611
+
612
+ return cases
613
+
614
+
615
+ # ---------------------------------------------------------------------------
616
+ # GraphQLIngestor
617
+ # ---------------------------------------------------------------------------
618
+
619
+
620
+ class GraphQLIngestor(IngestorPlugin):
621
+ """Ingest GraphQL schemas from introspection JSON or SDL files.
622
+
623
+ Produces normalised
624
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
625
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` objects from
626
+ GraphQL introspection results (JSON) or SDL schema files (``.graphql``,
627
+ ``.gql``).
628
+
629
+ Configuration keys
630
+ ------------------
631
+ base_url : str, optional
632
+ The GraphQL endpoint URL to associate with extracted endpoints.
633
+ include_deprecated : bool, optional
634
+ When ``True``, deprecated fields/operations are included in the
635
+ output. Defaults to ``False``.
636
+ include_subscriptions : bool, optional
637
+ When ``False``, subscription operations are omitted. Defaults to
638
+ ``True``.
639
+ """
640
+
641
+ name = "graphql"
642
+ display_name = "GraphQL Schema"
643
+ description = "Ingest GraphQL schemas from introspection JSON or SDL files."
644
+ supported_extensions = [".graphql", ".gql", ".json"]
645
+ supported_formats = ["graphql"]
646
+
647
+ # ------------------------------------------------------------------
648
+ # validate_config
649
+ # ------------------------------------------------------------------
650
+
651
+ def validate_config(self, config: dict) -> list[str]:
652
+ """Validate the plugin configuration.
653
+
654
+ Parameters
655
+ ----------
656
+ config:
657
+ Supported keys: ``base_url`` (str), ``include_deprecated`` (bool),
658
+ ``include_subscriptions`` (bool).
659
+
660
+ Returns
661
+ -------
662
+ list[str]
663
+ Empty list when the config is valid; error strings otherwise.
664
+ """
665
+ errors: list[str] = []
666
+ if "base_url" in config and not isinstance(config["base_url"], str):
667
+ errors.append("'base_url' must be a string.")
668
+ if "include_deprecated" in config and not isinstance(
669
+ config["include_deprecated"], bool
670
+ ):
671
+ errors.append("'include_deprecated' must be a boolean.")
672
+ if "include_subscriptions" in config and not isinstance(
673
+ config["include_subscriptions"], bool
674
+ ):
675
+ errors.append("'include_subscriptions' must be a boolean.")
676
+ return errors
677
+
678
+ # ------------------------------------------------------------------
679
+ # can_handle
680
+ # ------------------------------------------------------------------
681
+
682
+ def can_handle(self, source: IngestorSource) -> bool:
683
+ """Return ``True`` for graphql format, supported extensions, or SDL/introspection content.
684
+
685
+ Parameters
686
+ ----------
687
+ source:
688
+ The source to evaluate.
689
+
690
+ Returns
691
+ -------
692
+ bool
693
+ ``True`` if this plugin can ingest the source.
694
+ """
695
+ if source.format and source.format in self.supported_formats:
696
+ return True
697
+
698
+ if source.path is not None:
699
+ ext = Path(source.path).suffix.lower()
700
+ if ext in {".graphql", ".gql"}:
701
+ return True
702
+ # .json could be introspection — peek at content
703
+ if ext == ".json" and source.raw_content:
704
+ try:
705
+ data = json.loads(source.raw_content)
706
+ return _is_introspection_json(data)
707
+ except (json.JSONDecodeError, ValueError):
708
+ return False
709
+
710
+ # No path — check raw content
711
+ if source.raw_content:
712
+ stripped = source.raw_content.strip()
713
+ if stripped.startswith("{"):
714
+ try:
715
+ data = json.loads(stripped)
716
+ return _is_introspection_json(data)
717
+ except (json.JSONDecodeError, ValueError):
718
+ pass
719
+ return _is_sdl(stripped)
720
+
721
+ return False
722
+
723
+ # ------------------------------------------------------------------
724
+ # ingest
725
+ # ------------------------------------------------------------------
726
+
727
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
728
+ """Parse a GraphQL schema and return a normalised :class:`IngestResult`.
729
+
730
+ Parameters
731
+ ----------
732
+ source:
733
+ The source to ingest. Must provide either ``raw_content`` or a
734
+ valid ``path``.
735
+ config:
736
+ Plugin-specific configuration. See :meth:`validate_config` for
737
+ supported keys.
738
+
739
+ Returns
740
+ -------
741
+ IngestResult
742
+ Populated result on success; ``success=False`` with ``errors`` on
743
+ failure.
744
+ """
745
+ # --- Read content ---
746
+ try:
747
+ content = self._read_source(source)
748
+ except (FileNotFoundError, ValueError) as exc:
749
+ return self._make_error_result(source, str(exc))
750
+
751
+ if not content or not content.strip():
752
+ return self._make_error_result(source, "Empty content — nothing to ingest.")
753
+
754
+ base_url: str = config.get("base_url", "") or ""
755
+ include_deprecated: bool = bool(config.get("include_deprecated", False))
756
+ include_subscriptions: bool = bool(config.get("include_subscriptions", True))
757
+
758
+ warnings: list[str] = []
759
+ endpoints: list[IngestedEndpoint] = []
760
+
761
+ # --- Detect and parse format ---
762
+ stripped = content.strip()
763
+ is_json = stripped.startswith("{") or stripped.startswith("[")
764
+
765
+ if is_json:
766
+ try:
767
+ data: dict[str, Any] = json.loads(content)
768
+ except json.JSONDecodeError as exc:
769
+ return self._make_error_result(
770
+ source, f"Failed to parse JSON: {exc}"
771
+ )
772
+
773
+ schema = _unwrap_introspection(data)
774
+ if schema is None:
775
+ return self._make_error_result(
776
+ source,
777
+ "JSON content does not contain a GraphQL introspection "
778
+ "result (__schema key not found).",
779
+ )
780
+
781
+ try:
782
+ endpoints = _endpoints_from_introspection(
783
+ schema,
784
+ base_url=base_url,
785
+ include_deprecated=include_deprecated,
786
+ include_subscriptions=include_subscriptions,
787
+ )
788
+ except Exception as exc: # noqa: BLE001
789
+ return self._make_error_result(
790
+ source, f"Failed to process introspection JSON: {exc}"
791
+ )
792
+ else:
793
+ # Treat as SDL
794
+ if not _is_sdl(content):
795
+ warnings.append(
796
+ "Content does not appear to be valid GraphQL SDL; "
797
+ "attempting to parse anyway."
798
+ )
799
+ try:
800
+ endpoints, sdl_warnings = _endpoints_from_sdl(
801
+ content,
802
+ base_url=base_url,
803
+ include_deprecated=include_deprecated,
804
+ include_subscriptions=include_subscriptions,
805
+ )
806
+ warnings.extend(sdl_warnings)
807
+ except Exception as exc: # noqa: BLE001
808
+ return self._make_error_result(
809
+ source, f"Failed to parse SDL schema: {exc}"
810
+ )
811
+
812
+ if not endpoints:
813
+ warnings.append(
814
+ "No operations found. Ensure the schema defines at least one "
815
+ "of: type Query, type Mutation, type Subscription."
816
+ )
817
+
818
+ # --- Generate test cases ---
819
+ test_cases: list[IngestedTestCase] = []
820
+ for ep in endpoints:
821
+ test_cases.extend(_generate_test_cases(ep))
822
+
823
+ # --- Stats ---
824
+ query_count = sum(1 for e in endpoints if e.method == "QUERY")
825
+ mutation_count = sum(1 for e in endpoints if e.method == "MUTATION")
826
+ subscription_count = sum(1 for e in endpoints if e.method == "SUBSCRIPTION")
827
+
828
+ stats: dict[str, Any] = {
829
+ "endpoints_count": len(endpoints),
830
+ "test_cases_count": len(test_cases),
831
+ "queries_count": query_count,
832
+ "mutations_count": mutation_count,
833
+ "subscriptions_count": subscription_count,
834
+ "base_url": base_url,
835
+ }
836
+
837
+ return IngestResult(
838
+ success=True,
839
+ source=source,
840
+ endpoints=endpoints,
841
+ test_cases=test_cases,
842
+ errors=[],
843
+ warnings=warnings,
844
+ stats=stats,
845
+ )