nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
"""Schema differ for OpenAPI specs."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
from mannf.product.healing.models import EndpointChange, FieldChange, SchemaDiff
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SchemaDiffer:
|
|
21
|
+
"""Compare two OpenAPI / Swagger specs and return a structured :class:`SchemaDiff`."""
|
|
22
|
+
|
|
23
|
+
def diff(self, old_spec: Dict[str, Any], new_spec: Dict[str, Any]) -> SchemaDiff:
|
|
24
|
+
"""Compare *old_spec* and *new_spec* and return a :class:`SchemaDiff`."""
|
|
25
|
+
old_endpoints = self._extract_endpoints(old_spec)
|
|
26
|
+
new_endpoints = self._extract_endpoints(new_spec)
|
|
27
|
+
|
|
28
|
+
old_keys: Set[Tuple[str, str]] = set(old_endpoints.keys())
|
|
29
|
+
new_keys: Set[Tuple[str, str]] = set(new_endpoints.keys())
|
|
30
|
+
|
|
31
|
+
added_keys = new_keys - old_keys
|
|
32
|
+
removed_keys = old_keys - new_keys
|
|
33
|
+
common_keys = old_keys & new_keys
|
|
34
|
+
|
|
35
|
+
added_endpoints = [
|
|
36
|
+
EndpointChange(method=method, path=path, change_type="added")
|
|
37
|
+
for method, path in sorted(added_keys)
|
|
38
|
+
]
|
|
39
|
+
removed_endpoints = [
|
|
40
|
+
EndpointChange(method=method, path=path, change_type="removed")
|
|
41
|
+
for method, path in sorted(removed_keys)
|
|
42
|
+
]
|
|
43
|
+
modified_endpoints = []
|
|
44
|
+
for method, path in sorted(common_keys):
|
|
45
|
+
old_op = old_endpoints[(method, path)]
|
|
46
|
+
new_op = new_endpoints[(method, path)]
|
|
47
|
+
changes = self._diff_operation(method, path, old_op, new_op, old_spec, new_spec)
|
|
48
|
+
if changes:
|
|
49
|
+
modified_endpoints.append(
|
|
50
|
+
EndpointChange(method=method, path=path, change_type="modified", details=changes)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
result = SchemaDiff(
|
|
54
|
+
added_endpoints=added_endpoints,
|
|
55
|
+
removed_endpoints=removed_endpoints,
|
|
56
|
+
modified_endpoints=modified_endpoints,
|
|
57
|
+
)
|
|
58
|
+
result.summary = {
|
|
59
|
+
"added": len(added_endpoints),
|
|
60
|
+
"removed": len(removed_endpoints),
|
|
61
|
+
"modified": len(modified_endpoints),
|
|
62
|
+
"total_changes": len(added_endpoints) + len(removed_endpoints) + len(modified_endpoints),
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def _extract_endpoints(self, spec: Dict[str, Any]) -> Dict[Tuple[str, str], Dict[str, Any]]:
|
|
67
|
+
"""Return mapping of (method, path) -> operation dict."""
|
|
68
|
+
endpoints: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
69
|
+
for path, path_item in spec.get("paths", {}).items():
|
|
70
|
+
if not isinstance(path_item, dict):
|
|
71
|
+
continue
|
|
72
|
+
for method in _HTTP_METHODS:
|
|
73
|
+
op = path_item.get(method)
|
|
74
|
+
if isinstance(op, dict):
|
|
75
|
+
endpoints[(method.upper(), path)] = op
|
|
76
|
+
return endpoints
|
|
77
|
+
|
|
78
|
+
def _diff_operation(
|
|
79
|
+
self,
|
|
80
|
+
method: str,
|
|
81
|
+
path: str,
|
|
82
|
+
old_op: Dict[str, Any],
|
|
83
|
+
new_op: Dict[str, Any],
|
|
84
|
+
old_spec: Dict[str, Any],
|
|
85
|
+
new_spec: Dict[str, Any],
|
|
86
|
+
) -> List[FieldChange]:
|
|
87
|
+
"""Return field-level changes between two operations."""
|
|
88
|
+
changes: List[FieldChange] = []
|
|
89
|
+
|
|
90
|
+
# Compare parameters
|
|
91
|
+
old_params = self._params_by_name(old_op.get("parameters", []), old_spec)
|
|
92
|
+
new_params = self._params_by_name(new_op.get("parameters", []), new_spec)
|
|
93
|
+
|
|
94
|
+
for name in set(old_params) - set(new_params):
|
|
95
|
+
changes.append(FieldChange(
|
|
96
|
+
field_path=f"parameters.{name}",
|
|
97
|
+
change_type="removed",
|
|
98
|
+
old_value=old_params[name],
|
|
99
|
+
new_value=None,
|
|
100
|
+
))
|
|
101
|
+
for name in set(new_params) - set(old_params):
|
|
102
|
+
changes.append(FieldChange(
|
|
103
|
+
field_path=f"parameters.{name}",
|
|
104
|
+
change_type="added",
|
|
105
|
+
old_value=None,
|
|
106
|
+
new_value=new_params[name],
|
|
107
|
+
))
|
|
108
|
+
for name in set(old_params) & set(new_params):
|
|
109
|
+
old_p = old_params[name]
|
|
110
|
+
new_p = new_params[name]
|
|
111
|
+
old_type = (old_p.get("schema") or {}).get("type")
|
|
112
|
+
new_type = (new_p.get("schema") or {}).get("type")
|
|
113
|
+
if old_type != new_type:
|
|
114
|
+
changes.append(FieldChange(
|
|
115
|
+
field_path=f"parameters.{name}.type",
|
|
116
|
+
change_type="type_changed",
|
|
117
|
+
old_value=old_type,
|
|
118
|
+
new_value=new_type,
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
# Compare request body schema
|
|
122
|
+
old_body = self._get_body_schema(old_op, old_spec)
|
|
123
|
+
new_body = self._get_body_schema(new_op, new_spec)
|
|
124
|
+
if old_body or new_body:
|
|
125
|
+
body_changes = self._diff_schemas("requestBody", old_body or {}, new_body or {})
|
|
126
|
+
changes.extend(body_changes)
|
|
127
|
+
|
|
128
|
+
# Compare response schemas (200, 201, 2xx)
|
|
129
|
+
old_resp = self._get_response_schema(old_op, old_spec)
|
|
130
|
+
new_resp = self._get_response_schema(new_op, new_spec)
|
|
131
|
+
if old_resp or new_resp:
|
|
132
|
+
resp_changes = self._diff_schemas("response", old_resp or {}, new_resp or {})
|
|
133
|
+
changes.extend(resp_changes)
|
|
134
|
+
|
|
135
|
+
return changes
|
|
136
|
+
|
|
137
|
+
def _params_by_name(
|
|
138
|
+
self, params: List[Dict[str, Any]], spec: Dict[str, Any]
|
|
139
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
140
|
+
result: Dict[str, Dict[str, Any]] = {}
|
|
141
|
+
for p in params:
|
|
142
|
+
if "$ref" in p:
|
|
143
|
+
p = _resolve_ref(spec, p["$ref"])
|
|
144
|
+
name = p.get("name", "")
|
|
145
|
+
if name:
|
|
146
|
+
result[name] = p
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def _get_body_schema(
|
|
150
|
+
self, op: Dict[str, Any], spec: Dict[str, Any]
|
|
151
|
+
) -> Optional[Dict[str, Any]]:
|
|
152
|
+
# OpenAPI 3
|
|
153
|
+
rb = op.get("requestBody", {})
|
|
154
|
+
if rb:
|
|
155
|
+
content = rb.get("content", {})
|
|
156
|
+
for mt in ("application/json", "application/x-www-form-urlencoded"):
|
|
157
|
+
if mt in content:
|
|
158
|
+
s = content[mt].get("schema", {})
|
|
159
|
+
return _resolve_ref(spec, s["$ref"]) if "$ref" in s else s
|
|
160
|
+
# Swagger 2
|
|
161
|
+
for p in op.get("parameters", []):
|
|
162
|
+
if "$ref" in p:
|
|
163
|
+
p = _resolve_ref(spec, p["$ref"])
|
|
164
|
+
if p.get("in") == "body":
|
|
165
|
+
s = p.get("schema", {})
|
|
166
|
+
return _resolve_ref(spec, s["$ref"]) if "$ref" in s else s
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _get_response_schema(
|
|
170
|
+
self, op: Dict[str, Any], spec: Dict[str, Any]
|
|
171
|
+
) -> Optional[Dict[str, Any]]:
|
|
172
|
+
responses = op.get("responses", {})
|
|
173
|
+
for code in ("200", "201", "2xx", "default"):
|
|
174
|
+
resp = responses.get(code, {})
|
|
175
|
+
if resp:
|
|
176
|
+
content = resp.get("content", {})
|
|
177
|
+
if content:
|
|
178
|
+
for mt in ("application/json",):
|
|
179
|
+
if mt in content:
|
|
180
|
+
s = content[mt].get("schema", {})
|
|
181
|
+
return _resolve_ref(spec, s["$ref"]) if "$ref" in s else s
|
|
182
|
+
schema = resp.get("schema", {})
|
|
183
|
+
if schema:
|
|
184
|
+
return _resolve_ref(spec, schema["$ref"]) if "$ref" in schema else schema
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def _diff_schemas(
|
|
188
|
+
self, prefix: str, old_schema: Dict[str, Any], new_schema: Dict[str, Any]
|
|
189
|
+
) -> List[FieldChange]:
|
|
190
|
+
changes: List[FieldChange] = []
|
|
191
|
+
old_props = old_schema.get("properties", {})
|
|
192
|
+
new_props = new_schema.get("properties", {})
|
|
193
|
+
|
|
194
|
+
for name in set(old_props) - set(new_props):
|
|
195
|
+
changes.append(FieldChange(
|
|
196
|
+
field_path=f"{prefix}.properties.{name}",
|
|
197
|
+
change_type="removed",
|
|
198
|
+
old_value=old_props[name],
|
|
199
|
+
new_value=None,
|
|
200
|
+
))
|
|
201
|
+
for name in set(new_props) - set(old_props):
|
|
202
|
+
changes.append(FieldChange(
|
|
203
|
+
field_path=f"{prefix}.properties.{name}",
|
|
204
|
+
change_type="added",
|
|
205
|
+
old_value=None,
|
|
206
|
+
new_value=new_props[name],
|
|
207
|
+
))
|
|
208
|
+
for name in set(old_props) & set(new_props):
|
|
209
|
+
old_t = old_props[name].get("type")
|
|
210
|
+
new_t = new_props[name].get("type")
|
|
211
|
+
if old_t != new_t:
|
|
212
|
+
changes.append(FieldChange(
|
|
213
|
+
field_path=f"{prefix}.properties.{name}.type",
|
|
214
|
+
change_type="type_changed",
|
|
215
|
+
old_value=old_t,
|
|
216
|
+
new_value=new_t,
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
# Compare required fields
|
|
220
|
+
old_req = set(old_schema.get("required", []))
|
|
221
|
+
new_req = set(new_schema.get("required", []))
|
|
222
|
+
for name in old_req - new_req:
|
|
223
|
+
changes.append(FieldChange(
|
|
224
|
+
field_path=f"{prefix}.required.{name}",
|
|
225
|
+
change_type="constraint_changed",
|
|
226
|
+
old_value="required",
|
|
227
|
+
new_value="optional",
|
|
228
|
+
))
|
|
229
|
+
for name in new_req - old_req:
|
|
230
|
+
changes.append(FieldChange(
|
|
231
|
+
field_path=f"{prefix}.required.{name}",
|
|
232
|
+
change_type="constraint_changed",
|
|
233
|
+
old_value="optional",
|
|
234
|
+
new_value="required",
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
return changes
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _resolve_ref(spec: Dict[str, Any], ref: str) -> Dict[str, Any]:
|
|
241
|
+
"""Resolve a ``$ref`` pointer within *spec*."""
|
|
242
|
+
if not ref.startswith("#/"):
|
|
243
|
+
return {}
|
|
244
|
+
parts = ref.lstrip("#/").split("/")
|
|
245
|
+
node: Any = spec
|
|
246
|
+
for part in parts:
|
|
247
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
248
|
+
if not isinstance(node, dict) or part not in node:
|
|
249
|
+
return {}
|
|
250
|
+
node = node[part]
|
|
251
|
+
return node if isinstance(node, dict) else {}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
"""Ingestor plugins package — public API."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mannf.product.ingestors.base import IngestorPlugin
|
|
11
|
+
from mannf.product.ingestors.models import (
|
|
12
|
+
IngestedEndpoint,
|
|
13
|
+
IngestedTestCase,
|
|
14
|
+
IngestorSource,
|
|
15
|
+
IngestResult,
|
|
16
|
+
)
|
|
17
|
+
from mannf.product.ingestors.loader import (
|
|
18
|
+
get_ingestor_by_name,
|
|
19
|
+
get_ingestor_for_source,
|
|
20
|
+
load_all_ingestors,
|
|
21
|
+
load_ingestors_from_directory,
|
|
22
|
+
load_ingestors_from_entry_points,
|
|
23
|
+
)
|
|
24
|
+
from mannf.product.ingestors.bgstm_ingestor import BGSTMIngestor
|
|
25
|
+
from mannf.product.ingestors.curl_ingestor import CurlIngestor
|
|
26
|
+
from mannf.product.ingestors.cypress_ingestor import CypressIngestor
|
|
27
|
+
from mannf.product.ingestors.gherkin_ingestor import GherkinIngestor
|
|
28
|
+
from mannf.product.ingestors.graphql_ingestor import GraphQLIngestor
|
|
29
|
+
from mannf.product.ingestors.grpc_ingestor import GrpcIngestor
|
|
30
|
+
from mannf.product.ingestors.har_ingestor import HARIngestor
|
|
31
|
+
from mannf.product.ingestors.openapi_ingestor import OpenAPIIngestor
|
|
32
|
+
from mannf.product.ingestors.playwright_ingestor import PlaywrightIngestor
|
|
33
|
+
from mannf.product.ingestors.postman_ingestor import PostmanIngestor
|
|
34
|
+
from mannf.product.ingestors.traffic_ingestor import TrafficIngestor
|
|
35
|
+
from mannf.product.ingestors.websocket_ingestor import WebSocketIngestor
|
|
36
|
+
|
|
37
|
+
# Built-in ingestors that are always available without entry-point registration.
|
|
38
|
+
BUILTIN_INGESTORS: list[IngestorPlugin] = [
|
|
39
|
+
OpenAPIIngestor(),
|
|
40
|
+
PostmanIngestor(),
|
|
41
|
+
GraphQLIngestor(),
|
|
42
|
+
GrpcIngestor(),
|
|
43
|
+
BGSTMIngestor(),
|
|
44
|
+
HARIngestor(),
|
|
45
|
+
CurlIngestor(),
|
|
46
|
+
GherkinIngestor(),
|
|
47
|
+
PlaywrightIngestor(),
|
|
48
|
+
CypressIngestor(),
|
|
49
|
+
TrafficIngestor(),
|
|
50
|
+
WebSocketIngestor(),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"IngestorPlugin",
|
|
55
|
+
"IngestorSource",
|
|
56
|
+
"IngestedEndpoint",
|
|
57
|
+
"IngestedTestCase",
|
|
58
|
+
"IngestResult",
|
|
59
|
+
"BGSTMIngestor",
|
|
60
|
+
"CurlIngestor",
|
|
61
|
+
"CypressIngestor",
|
|
62
|
+
"GherkinIngestor",
|
|
63
|
+
"GraphQLIngestor",
|
|
64
|
+
"GrpcIngestor",
|
|
65
|
+
"HARIngestor",
|
|
66
|
+
"OpenAPIIngestor",
|
|
67
|
+
"PlaywrightIngestor",
|
|
68
|
+
"PostmanIngestor",
|
|
69
|
+
"TrafficIngestor",
|
|
70
|
+
"WebSocketIngestor",
|
|
71
|
+
"BUILTIN_INGESTORS",
|
|
72
|
+
"load_all_ingestors",
|
|
73
|
+
"load_ingestors_from_directory",
|
|
74
|
+
"load_ingestors_from_entry_points",
|
|
75
|
+
"get_ingestor_by_name",
|
|
76
|
+
"get_ingestor_for_source",
|
|
77
|
+
]
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
"""Abstract base class for ingestor plugins.
|
|
7
|
+
|
|
8
|
+
All ingestor plugins must subclass :class:`IngestorPlugin` and implement the
|
|
9
|
+
three abstract methods: :meth:`ingest`, :meth:`validate_config`, and
|
|
10
|
+
:meth:`can_handle`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mannf.product.ingestors.models import (
|
|
20
|
+
IngestedEndpoint,
|
|
21
|
+
IngestedTestCase,
|
|
22
|
+
IngestorSource,
|
|
23
|
+
IngestResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IngestorPlugin(ABC):
|
|
30
|
+
"""Abstract base class for ingestor plugins.
|
|
31
|
+
|
|
32
|
+
Subclasses must define the class-level attributes ``name``,
|
|
33
|
+
``display_name``, ``description``, ``supported_extensions``, and
|
|
34
|
+
``supported_formats``, and implement the abstract methods :meth:`ingest`,
|
|
35
|
+
:meth:`validate_config`, and :meth:`can_handle`.
|
|
36
|
+
|
|
37
|
+
Example::
|
|
38
|
+
|
|
39
|
+
class MyIngestor(IngestorPlugin):
|
|
40
|
+
name = "myformat"
|
|
41
|
+
display_name = "My Format"
|
|
42
|
+
description = "Ingest MyFormat specs."
|
|
43
|
+
supported_extensions = [".myfmt"]
|
|
44
|
+
supported_formats = ["myformat"]
|
|
45
|
+
|
|
46
|
+
async def ingest(self, source, config):
|
|
47
|
+
content = self._read_source(source)
|
|
48
|
+
# ... parse content ...
|
|
49
|
+
return IngestResult(success=True, source=source)
|
|
50
|
+
|
|
51
|
+
def validate_config(self, config):
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
def can_handle(self, source):
|
|
55
|
+
return source.format in self.supported_formats
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str # e.g., "openapi"
|
|
59
|
+
display_name: str # e.g., "OpenAPI/Swagger"
|
|
60
|
+
description: str # one-line description
|
|
61
|
+
supported_extensions: list[str] # e.g., [".yaml", ".yml", ".json"]
|
|
62
|
+
supported_formats: list[str] # e.g., ["openapi", "swagger"]
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Abstract methods
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
|
|
70
|
+
"""Ingest the given source and return a unified :class:`IngestResult`.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
source:
|
|
75
|
+
Metadata and content of the source to ingest.
|
|
76
|
+
config:
|
|
77
|
+
Plugin-specific configuration dict (keys validated by
|
|
78
|
+
:meth:`validate_config`).
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
IngestResult
|
|
83
|
+
The result of the ingestion, including extracted endpoints and
|
|
84
|
+
test cases.
|
|
85
|
+
"""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
90
|
+
"""Validate plugin-specific configuration.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
config:
|
|
95
|
+
Configuration dict to validate.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
list[str]
|
|
100
|
+
A list of human-readable error strings. An empty list means the
|
|
101
|
+
config is valid.
|
|
102
|
+
"""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def can_handle(self, source: IngestorSource) -> bool:
|
|
107
|
+
"""Return True if this plugin can handle the given source.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
source:
|
|
112
|
+
The source to evaluate.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
bool
|
|
117
|
+
True if this plugin is capable of ingesting the source.
|
|
118
|
+
"""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Concrete helper methods
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _read_source(self, source: IngestorSource) -> str:
|
|
126
|
+
"""Read and return the raw content from *source*.
|
|
127
|
+
|
|
128
|
+
Tries, in order:
|
|
129
|
+
1. ``source.raw_content`` — returned as-is if present.
|
|
130
|
+
2. ``source.path`` — read from the local filesystem.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
source:
|
|
135
|
+
The source to read.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
str
|
|
140
|
+
The raw content string.
|
|
141
|
+
|
|
142
|
+
Raises
|
|
143
|
+
------
|
|
144
|
+
ValueError
|
|
145
|
+
If neither ``raw_content`` nor a valid ``path`` is available.
|
|
146
|
+
FileNotFoundError
|
|
147
|
+
If ``source.path`` points to a non-existent file.
|
|
148
|
+
"""
|
|
149
|
+
if source.raw_content is not None:
|
|
150
|
+
return source.raw_content
|
|
151
|
+
|
|
152
|
+
if source.path is not None:
|
|
153
|
+
file_path = Path(source.path)
|
|
154
|
+
if not file_path.exists():
|
|
155
|
+
raise FileNotFoundError(
|
|
156
|
+
f"Source file not found: {source.path!r}"
|
|
157
|
+
)
|
|
158
|
+
return file_path.read_text(encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"IngestorSource has no raw_content or path — cannot read content."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _make_error_result(
|
|
165
|
+
self, source: IngestorSource, error: str
|
|
166
|
+
) -> IngestResult:
|
|
167
|
+
"""Create a failed :class:`IngestResult` for the given *source*.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
source:
|
|
172
|
+
The source that failed ingestion.
|
|
173
|
+
error:
|
|
174
|
+
Human-readable error message describing the failure.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
IngestResult
|
|
179
|
+
A result with ``success=False`` and the error in ``errors``.
|
|
180
|
+
"""
|
|
181
|
+
return IngestResult(
|
|
182
|
+
success=False,
|
|
183
|
+
source=source,
|
|
184
|
+
errors=[error],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _detect_format(
|
|
188
|
+
self, content: str, source: IngestorSource
|
|
189
|
+
) -> str | None:
|
|
190
|
+
"""Attempt to detect the format of *content*.
|
|
191
|
+
|
|
192
|
+
Performs simple heuristic detection based on well-known keywords and
|
|
193
|
+
file extensions. Concrete plugins may override this for more
|
|
194
|
+
sophisticated detection.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
content:
|
|
199
|
+
The raw content string.
|
|
200
|
+
source:
|
|
201
|
+
The originating source (used to inspect file extension).
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
str | None
|
|
206
|
+
A format identifier string (e.g., ``"openapi"``, ``"postman"``,
|
|
207
|
+
``"har"``, ``"graphql"``, ``"gherkin"``), or ``None`` if the
|
|
208
|
+
format cannot be determined.
|
|
209
|
+
"""
|
|
210
|
+
# Check file extension first
|
|
211
|
+
if source.path is not None:
|
|
212
|
+
name = Path(source.path).name.lower()
|
|
213
|
+
ext = Path(source.path).suffix.lower()
|
|
214
|
+
if name.endswith((".cy.js", ".cy.ts", ".cy.jsx", ".cy.tsx")):
|
|
215
|
+
return "cypress"
|
|
216
|
+
if ext == ".feature":
|
|
217
|
+
return "gherkin"
|
|
218
|
+
if ext == ".graphql" or ext == ".gql":
|
|
219
|
+
return "graphql"
|
|
220
|
+
|
|
221
|
+
# Content-based heuristics (check more specific patterns first)
|
|
222
|
+
stripped = content.strip()
|
|
223
|
+
|
|
224
|
+
# HAR — always JSON with "log" key containing "entries"
|
|
225
|
+
if '"log"' in content and '"entries"' in content and '"request"' in content:
|
|
226
|
+
return "har"
|
|
227
|
+
|
|
228
|
+
# Postman — JSON with "info" and "item" keys
|
|
229
|
+
if '"info"' in content and '"item"' in content and '"postman"' in stripped.lower():
|
|
230
|
+
return "postman"
|
|
231
|
+
|
|
232
|
+
# OpenAPI 3.x
|
|
233
|
+
if "openapi:" in content or '"openapi"' in content:
|
|
234
|
+
return "openapi"
|
|
235
|
+
|
|
236
|
+
# Swagger 2.x
|
|
237
|
+
if "swagger:" in content or '"swagger"' in content:
|
|
238
|
+
return "openapi"
|
|
239
|
+
|
|
240
|
+
# GraphQL SDL
|
|
241
|
+
if stripped.startswith("type ") or "type Query" in content or "schema {" in content:
|
|
242
|
+
return "graphql"
|
|
243
|
+
|
|
244
|
+
# Gherkin
|
|
245
|
+
if stripped.startswith("Feature:") or "Scenario:" in content:
|
|
246
|
+
return "gherkin"
|
|
247
|
+
|
|
248
|
+
# Cypress — cy.visit/cy.get/cy.contains usage
|
|
249
|
+
if "cy.visit(" in content or "cy.get(" in content or "cy.contains(" in content:
|
|
250
|
+
return "cypress"
|
|
251
|
+
|
|
252
|
+
# cURL
|
|
253
|
+
if stripped.startswith("curl ") or "\ncurl " in content:
|
|
254
|
+
return "curl"
|
|
255
|
+
|
|
256
|
+
return None
|