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,195 @@
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
+ """Linear exporter plugin — creates Linear issues from NAT security findings."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ import httpx
13
+
14
+ from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
15
+ from mannf.product.security.models import SecurityFinding, SecurityReport
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _LINEAR_API_URL = "https://api.linear.app/graphql"
20
+
21
+ # Linear priority values: 0=no priority, 1=urgent, 2=high, 3=medium, 4=low
22
+ _SEVERITY_TO_LINEAR_PRIORITY: dict[str, int] = {
23
+ "critical": 1,
24
+ "high": 2,
25
+ "medium": 3,
26
+ "low": 4,
27
+ "info": 0,
28
+ }
29
+
30
+ _ISSUE_CREATE_MUTATION = """
31
+ mutation IssueCreate($input: IssueCreateInput!) {
32
+ issueCreate(input: $input) {
33
+ success
34
+ issue {
35
+ id
36
+ identifier
37
+ url
38
+ }
39
+ }
40
+ }
41
+ """
42
+
43
+
44
+ class LinearExporter(ExporterPlugin):
45
+ """Exports NAT security findings as Linear issues via the GraphQL API.
46
+
47
+ Required config keys:
48
+ api_key: Linear personal API key.
49
+ team_id: Linear team ID to create issues in.
50
+
51
+ Optional config keys:
52
+ project_id: Linear project ID to assign issues to.
53
+ assignee_id: Linear user ID to assign issues to.
54
+ label_ids: List of Linear label IDs to attach to every issue.
55
+ """
56
+
57
+ name = "linear"
58
+ display_name = "Linear"
59
+ description = "Creates Linear issues for NAT security findings via the GraphQL API."
60
+
61
+ def validate_config(self, config: dict) -> list[str]:
62
+ required = ("api_key", "team_id")
63
+ return [f"'{k}' is required" for k in required if not config.get(k)]
64
+
65
+ async def test_connection(self, config: dict) -> TestConnectionResult:
66
+ """Test connection via POST /graphql with viewer query — confirms api_key."""
67
+ errors = self.validate_config(config)
68
+ if errors:
69
+ return TestConnectionResult(
70
+ success=False,
71
+ message=f"Config validation failed: {'; '.join(errors)}",
72
+ details={"errors": errors},
73
+ )
74
+
75
+ api_key = config["api_key"]
76
+ headers = {
77
+ "Authorization": api_key,
78
+ "Content-Type": "application/json",
79
+ }
80
+ payload = {"query": "{ viewer { id name } }"}
81
+
82
+ try:
83
+ async with httpx.AsyncClient(timeout=10) as client:
84
+ resp = await client.post(_LINEAR_API_URL, json=payload, headers=headers)
85
+ resp.raise_for_status()
86
+ data = resp.json()
87
+ except httpx.HTTPStatusError as exc:
88
+ status = exc.response.status_code
89
+ if status == 401:
90
+ msg = "Linear authentication failed — check your api_key"
91
+ else:
92
+ msg = f"Linear API error {status}: {exc.response.text}"
93
+ return TestConnectionResult(success=False, message=msg, details={})
94
+ except httpx.RequestError as exc:
95
+ return TestConnectionResult(
96
+ success=False,
97
+ message=f"Linear network error: {exc}",
98
+ details={},
99
+ )
100
+
101
+ gql_errors = data.get("errors")
102
+ if gql_errors:
103
+ return TestConnectionResult(
104
+ success=False,
105
+ message=f"Linear GraphQL error: {gql_errors[0].get('message', str(gql_errors))}",
106
+ details={},
107
+ )
108
+
109
+ viewer = data.get("data", {}).get("viewer", {})
110
+ name = viewer.get("name", "unknown")
111
+ return TestConnectionResult(
112
+ success=True,
113
+ message=f"Connected to Linear — authenticated as '{name}'",
114
+ details={"id": viewer.get("id"), "name": name},
115
+ )
116
+
117
+ def _severity_to_priority(self, severity: str) -> str:
118
+ # Return a string representation for the base class contract;
119
+ # use _severity_to_linear_priority() for the numeric value.
120
+ return str(_SEVERITY_TO_LINEAR_PRIORITY.get(severity.lower(), 0))
121
+
122
+ def _severity_to_linear_priority(self, severity: str) -> int:
123
+ return _SEVERITY_TO_LINEAR_PRIORITY.get(severity.lower(), 0)
124
+
125
+ async def export_finding(
126
+ self,
127
+ finding: SecurityFinding,
128
+ report: SecurityReport,
129
+ config: dict,
130
+ ) -> ExportResult:
131
+ api_key = config["api_key"]
132
+ team_id = config["team_id"]
133
+ project_id = config.get("project_id")
134
+ assignee_id = config.get("assignee_id")
135
+ label_ids: list[str] = config.get("label_ids") or []
136
+
137
+ priority = self._severity_to_linear_priority(finding.severity)
138
+ description = self._format_finding_body(finding, report)
139
+
140
+ issue_input: dict = {
141
+ "teamId": team_id,
142
+ "title": f"[NAT] {finding.title}",
143
+ "description": description,
144
+ "priority": priority,
145
+ }
146
+ if project_id:
147
+ issue_input["projectId"] = project_id
148
+ if assignee_id:
149
+ issue_input["assigneeId"] = assignee_id
150
+ if label_ids:
151
+ issue_input["labelIds"] = label_ids
152
+
153
+ headers = {
154
+ "Authorization": api_key,
155
+ "Content-Type": "application/json",
156
+ }
157
+
158
+ payload = {
159
+ "query": _ISSUE_CREATE_MUTATION,
160
+ "variables": {"input": issue_input},
161
+ }
162
+
163
+ try:
164
+ async with httpx.AsyncClient(headers=headers, timeout=30) as client:
165
+ resp = await client.post(_LINEAR_API_URL, json=payload)
166
+ resp.raise_for_status()
167
+ data = resp.json()
168
+ except httpx.HTTPStatusError as exc:
169
+ error_msg = f"Linear API error {exc.response.status_code}: {exc.response.text}"
170
+ logger.warning("LinearExporter: %s", error_msg)
171
+ return ExportResult(finding=finding, success=False, error=error_msg)
172
+ except httpx.RequestError as exc:
173
+ error_msg = f"Linear network error: {exc}"
174
+ logger.warning("LinearExporter: %s", error_msg)
175
+ return ExportResult(finding=finding, success=False, error=error_msg)
176
+
177
+ errors = data.get("errors")
178
+ if errors:
179
+ error_msg = f"Linear GraphQL errors: {errors}"
180
+ logger.warning("LinearExporter: %s", error_msg)
181
+ return ExportResult(finding=finding, success=False, error=error_msg)
182
+
183
+ issue_create = data.get("data", {}).get("issueCreate", {})
184
+ if not issue_create.get("success"):
185
+ error_msg = "Linear issueCreate returned success=false"
186
+ logger.warning("LinearExporter: %s", error_msg)
187
+ return ExportResult(finding=finding, success=False, error=error_msg)
188
+
189
+ issue = issue_create.get("issue", {})
190
+ return ExportResult(
191
+ finding=finding,
192
+ success=True,
193
+ external_id=issue.get("identifier"),
194
+ external_url=issue.get("url"),
195
+ )
@@ -0,0 +1,233 @@
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
+ """Plugin loader for custom ExporterPlugin implementations.
7
+
8
+ Provides two discovery mechanisms:
9
+ - Directory scanning: load .py files from a directory path.
10
+ - Entry-point discovery: use importlib.metadata to find registered exporters.
11
+
12
+ Both mechanisms validate that each discovered class has the required attributes
13
+ and the callable methods before instantiating it. Invalid exporters are skipped
14
+ with a warning — they will never cause the scanner to crash.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib
20
+ import importlib.util
21
+ import inspect
22
+ import logging
23
+ import sys
24
+ from importlib.metadata import entry_points
25
+ from pathlib import Path
26
+
27
+ from mannf.product.exporters.base import ExporterPlugin
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _REQUIRED_ATTRS = ("name", "display_name", "description")
32
+
33
+
34
+ def _is_valid_exporter(cls: type) -> bool:
35
+ """Return True if *cls* is a valid, concrete ExporterPlugin subclass."""
36
+ if not (isinstance(cls, type) and issubclass(cls, ExporterPlugin) and cls is not ExporterPlugin):
37
+ return False
38
+ for attr in _REQUIRED_ATTRS:
39
+ if not hasattr(cls, attr):
40
+ logger.warning(
41
+ "Exporter %s is missing required attribute '%s' — skipping.",
42
+ cls.__name__,
43
+ attr,
44
+ )
45
+ return False
46
+ if not callable(getattr(cls, "export_finding", None)):
47
+ logger.warning(
48
+ "Exporter %s has no callable 'export_finding' method — skipping.",
49
+ cls.__name__,
50
+ )
51
+ return False
52
+ if not callable(getattr(cls, "validate_config", None)):
53
+ logger.warning(
54
+ "Exporter %s has no callable 'validate_config' method — skipping.",
55
+ cls.__name__,
56
+ )
57
+ return False
58
+ return True
59
+
60
+
61
+ def _instantiate(cls: type) -> ExporterPlugin | None:
62
+ """Try to instantiate *cls*; log a warning and return None on failure."""
63
+ try:
64
+ return cls()
65
+ except Exception as exc: # noqa: BLE001
66
+ logger.warning(
67
+ "Exporter %s raised %s during __init__ — skipping.", cls.__name__, exc
68
+ )
69
+ return None
70
+
71
+
72
+ def load_exporters_from_directory(path: str) -> list[ExporterPlugin]:
73
+ """Discover and load ExporterPlugin subclasses from all ``.py`` files in *path*.
74
+
75
+ Parameters
76
+ ----------
77
+ path:
78
+ Filesystem path to a directory containing exporter ``.py`` files.
79
+
80
+ Returns
81
+ -------
82
+ list[ExporterPlugin]
83
+ Instantiated exporter objects for every valid exporter found.
84
+ """
85
+ exporters: list[ExporterPlugin] = []
86
+ directory = Path(path)
87
+
88
+ if not directory.exists():
89
+ logger.warning(
90
+ "Exporter directory '%s' does not exist — no exporters loaded.", path
91
+ )
92
+ return exporters
93
+
94
+ if not directory.is_dir():
95
+ logger.warning(
96
+ "Exporter path '%s' is not a directory — no exporters loaded.", path
97
+ )
98
+ return exporters
99
+
100
+ for py_file in sorted(directory.glob("*.py")):
101
+ module_name = f"_nat_exporter_{py_file.stem}"
102
+ try:
103
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
104
+ if spec is None or spec.loader is None:
105
+ logger.warning(
106
+ "Could not create module spec for '%s' — skipping.", py_file
107
+ )
108
+ continue
109
+ module = importlib.util.module_from_spec(spec)
110
+ sys.modules[module_name] = module
111
+ spec.loader.exec_module(module) # type: ignore[union-attr]
112
+ except Exception as exc: # noqa: BLE001
113
+ logger.warning(
114
+ "Failed to import exporter file '%s': %s — skipping.", py_file, exc
115
+ )
116
+ continue
117
+
118
+ for _name, obj in inspect.getmembers(module, inspect.isclass):
119
+ if obj.__module__ != module_name:
120
+ # Avoid re-processing imported base classes
121
+ continue
122
+ if _is_valid_exporter(obj):
123
+ instance = _instantiate(obj)
124
+ if instance is not None:
125
+ exporters.append(instance)
126
+
127
+ return exporters
128
+
129
+
130
+ def load_exporters_from_entry_points(
131
+ group: str = "nat.exporters",
132
+ ) -> list[ExporterPlugin]:
133
+ """Discover and load ExporterPlugin subclasses registered as package entry points.
134
+
135
+ Parameters
136
+ ----------
137
+ group:
138
+ The entry-point group name to scan (default ``"nat.exporters"``).
139
+
140
+ Returns
141
+ -------
142
+ list[ExporterPlugin]
143
+ Instantiated exporter objects for every valid entry point found.
144
+ """
145
+ exporters: list[ExporterPlugin] = []
146
+
147
+ try:
148
+ eps = entry_points(group=group)
149
+ except Exception as exc: # noqa: BLE001
150
+ logger.warning(
151
+ "Failed to query entry points for group '%s': %s", group, exc
152
+ )
153
+ return exporters
154
+
155
+ for ep in eps:
156
+ try:
157
+ obj = ep.load()
158
+ except Exception as exc: # noqa: BLE001
159
+ logger.warning(
160
+ "Failed to load entry point '%s': %s — skipping.", ep.name, exc
161
+ )
162
+ continue
163
+
164
+ if _is_valid_exporter(obj):
165
+ instance = _instantiate(obj)
166
+ if instance is not None:
167
+ exporters.append(instance)
168
+ else:
169
+ logger.warning(
170
+ "Entry point '%s' does not point to a valid ExporterPlugin subclass — skipping.",
171
+ ep.name,
172
+ )
173
+
174
+ return exporters
175
+
176
+
177
+ def load_all_exporters(
178
+ exporters_dir: str | None = None,
179
+ entry_point_group: str = "nat.exporters",
180
+ ) -> list[ExporterPlugin]:
181
+ """Load exporters from both a directory and registered entry points, deduplicating by class name.
182
+
183
+ Parameters
184
+ ----------
185
+ exporters_dir:
186
+ Optional filesystem path to a directory of exporter ``.py`` files.
187
+ entry_point_group:
188
+ Entry-point group to scan (default ``"nat.exporters"``).
189
+
190
+ Returns
191
+ -------
192
+ list[ExporterPlugin]
193
+ Merged, deduplicated list of instantiated exporter objects.
194
+ """
195
+ seen_class_names: set[str] = set()
196
+ combined: list[ExporterPlugin] = []
197
+
198
+ dir_exporters = (
199
+ load_exporters_from_directory(exporters_dir) if exporters_dir else []
200
+ )
201
+ ep_exporters = load_exporters_from_entry_points(entry_point_group)
202
+
203
+ for exporter in dir_exporters + ep_exporters:
204
+ class_name = type(exporter).__name__
205
+ if class_name not in seen_class_names:
206
+ seen_class_names.add(class_name)
207
+ combined.append(exporter)
208
+
209
+ return combined
210
+
211
+
212
+ def get_exporter_by_name(
213
+ name: str,
214
+ exporters: list[ExporterPlugin],
215
+ ) -> ExporterPlugin | None:
216
+ """Look up an exporter by its ``name`` attribute.
217
+
218
+ Parameters
219
+ ----------
220
+ name:
221
+ The exporter name to search for (e.g., ``"jira"``, ``"linear"``).
222
+ exporters:
223
+ List of instantiated exporter objects to search.
224
+
225
+ Returns
226
+ -------
227
+ ExporterPlugin | None
228
+ The first matching exporter, or ``None`` if not found.
229
+ """
230
+ for exporter in exporters:
231
+ if exporter.name == name:
232
+ return exporter
233
+ return None