codegraph-engine 3.0.0__2-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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +868 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/api_drift.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Production-Grade Cross-Language Client-Server API Contract Drift Detector.
|
|
2
|
+
|
|
3
|
+
Reconciles client-side data fetches (React, Next.js, Vue, Axios, SWR, React-Query)
|
|
4
|
+
against backend route declarations (FastAPI, Flask, Express, Django, NestJS)
|
|
5
|
+
and database models:
|
|
6
|
+
1. ORPHANED_CLIENT_ROUTE: Client fetch points to a non-existent route (runtime 404).
|
|
7
|
+
2. HTTP_METHOD_MISMATCH: Client sends POST, but backend endpoint only accepts GET (runtime 405).
|
|
8
|
+
3. PARAMETER_MISMATCH: Path parameter disparity between client fetch and backend declaration.
|
|
9
|
+
4. CROSS_LANGUAGE_FIELD_DRIFT: Client TypeScript interface expects a field that backend model/schema lacks.
|
|
10
|
+
5. NULLABILITY_DRIFT: Client TypeScript interface treats a field as non-nullable, but backend allows NULL/None.
|
|
11
|
+
6. TYPE_INCOMPATIBILITY: Client TypeScript type conflicts with backend type (e.g., number vs UUID/string).
|
|
12
|
+
7. UNUSED_BACKEND_ROUTE: Backend endpoint with zero client callers or tests.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from codegraph.database.schema_drift import extract_schema_fields_from_class_node
|
|
25
|
+
from codegraph.route_topology import (
|
|
26
|
+
match_route_topology,
|
|
27
|
+
normalize_route,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ApiContractDrift:
|
|
33
|
+
kind: str # ORPHANED_CLIENT_ROUTE | HTTP_METHOD_MISMATCH | PARAMETER_MISMATCH | CROSS_LANGUAGE_FIELD_DRIFT | NULLABILITY_DRIFT | TYPE_INCOMPATIBILITY | UNUSED_BACKEND_ROUTE
|
|
34
|
+
client_route: str
|
|
35
|
+
client_method: str = ""
|
|
36
|
+
client_file: str = ""
|
|
37
|
+
client_line: int = 1
|
|
38
|
+
client_type: str = ""
|
|
39
|
+
backend_route: str = ""
|
|
40
|
+
backend_methods: tuple[str, ...] = ()
|
|
41
|
+
backend_handler: str = ""
|
|
42
|
+
backend_file: str = ""
|
|
43
|
+
field_name: str = ""
|
|
44
|
+
client_field_type: str = ""
|
|
45
|
+
backend_field_type: str = ""
|
|
46
|
+
severity: str = "CRITICAL" # CRITICAL | WARNING | INFO
|
|
47
|
+
message: str = ""
|
|
48
|
+
|
|
49
|
+
def as_dict(self) -> dict[str, Any]:
|
|
50
|
+
return {
|
|
51
|
+
"kind": self.kind,
|
|
52
|
+
"client_route": self.client_route,
|
|
53
|
+
"client_method": self.client_method,
|
|
54
|
+
"client_file": self.client_file,
|
|
55
|
+
"client_line": self.client_line,
|
|
56
|
+
"client_type": self.client_type,
|
|
57
|
+
"backend_route": self.backend_route,
|
|
58
|
+
"backend_methods": list(self.backend_methods),
|
|
59
|
+
"backend_handler": self.backend_handler,
|
|
60
|
+
"backend_file": self.backend_file,
|
|
61
|
+
"field_name": self.field_name,
|
|
62
|
+
"client_field_type": self.client_field_type,
|
|
63
|
+
"backend_field_type": self.backend_field_type,
|
|
64
|
+
"severity": self.severity,
|
|
65
|
+
"message": self.message,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ApiContractDriftReport:
|
|
71
|
+
has_drift: bool
|
|
72
|
+
total_client_calls: int
|
|
73
|
+
total_backend_routes: int
|
|
74
|
+
matched_calls: int = 0
|
|
75
|
+
drifts: tuple[ApiContractDrift, ...] = ()
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"has_drift": self.has_drift,
|
|
80
|
+
"total_client_calls": self.total_client_calls,
|
|
81
|
+
"total_backend_routes": self.total_backend_routes,
|
|
82
|
+
"matched_calls": self.matched_calls,
|
|
83
|
+
"drift_count": len(self.drifts),
|
|
84
|
+
"drifts": [d.as_dict() for d in self.drifts],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_TS_TO_BACKEND_TYPES: dict[str, set[str]] = {
|
|
89
|
+
"string": {
|
|
90
|
+
"str", "varchar", "text", "char", "character varying", "string",
|
|
91
|
+
"citext", "charfield", "emailfield", "uuid", "uuidfield", "date",
|
|
92
|
+
"datetime", "timestamp",
|
|
93
|
+
},
|
|
94
|
+
"number": {
|
|
95
|
+
"int", "float", "integer", "bigint", "smallint", "serial",
|
|
96
|
+
"bigserial", "tinyint", "double", "real", "numeric", "decimal",
|
|
97
|
+
"integerfield", "floatfield", "decimalfield",
|
|
98
|
+
},
|
|
99
|
+
"boolean": {"bool", "boolean", "tinyint", "booleanfield"},
|
|
100
|
+
"date": {"date", "datetime", "timestamp", "timestamptz", "datefield", "datetimefield"},
|
|
101
|
+
"any": set(),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _are_ts_backend_types_compatible(ts_type: str, backend_type: str) -> bool:
|
|
106
|
+
clean_ts = ts_type.strip().rstrip("?").lower()
|
|
107
|
+
clean_be = backend_type.strip().lower()
|
|
108
|
+
if clean_ts in ("any", "unknown", "object") or clean_be in ("any", "unknown", "object"):
|
|
109
|
+
return True
|
|
110
|
+
if clean_ts == clean_be:
|
|
111
|
+
return True
|
|
112
|
+
compatible_be_types = _TS_TO_BACKEND_TYPES.get(clean_ts)
|
|
113
|
+
if compatible_be_types is None:
|
|
114
|
+
return True
|
|
115
|
+
for be_allowed in compatible_be_types:
|
|
116
|
+
if be_allowed in clean_be or clean_be in be_allowed:
|
|
117
|
+
return True
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_backend_fields_for_handler(
|
|
122
|
+
con: sqlite3.Connection,
|
|
123
|
+
repo_root: Path | None,
|
|
124
|
+
handler_canonical_id: str,
|
|
125
|
+
handler_name: str,
|
|
126
|
+
backend_file: str,
|
|
127
|
+
) -> dict[str, tuple[str, bool]]:
|
|
128
|
+
"""Discover schema fields (type_name, is_nullable) for a backend handler.
|
|
129
|
+
|
|
130
|
+
Checks:
|
|
131
|
+
1. Python AST Pydantic / dataclass response model or return annotation.
|
|
132
|
+
2. Python AST function parameter input schemas.
|
|
133
|
+
3. Database columns from db_entities or db_queries.
|
|
134
|
+
"""
|
|
135
|
+
fields: dict[str, tuple[str, bool]] = {}
|
|
136
|
+
|
|
137
|
+
# 1. AST inspection if file exists
|
|
138
|
+
if repo_root and backend_file:
|
|
139
|
+
full_path = repo_root / backend_file
|
|
140
|
+
if full_path.exists() and backend_file.endswith(".py"):
|
|
141
|
+
try:
|
|
142
|
+
tree = ast.parse(full_path.read_text(encoding="utf-8", errors="replace"), filename=str(full_path))
|
|
143
|
+
handler_fn: ast.FunctionDef | ast.AsyncFunctionDef | None = None
|
|
144
|
+
for node in ast.walk(tree):
|
|
145
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == handler_name:
|
|
146
|
+
handler_fn = node
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if handler_fn:
|
|
150
|
+
target_model_name: str | None = None
|
|
151
|
+
# Check decorators for response_model=ModelName
|
|
152
|
+
for dec in handler_fn.decorator_list:
|
|
153
|
+
if isinstance(dec, ast.Call):
|
|
154
|
+
for kw in dec.keywords:
|
|
155
|
+
if kw.arg == "response_model":
|
|
156
|
+
try:
|
|
157
|
+
target_model_name = ast.unparse(kw.value).strip()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Check return annotation -> ModelName
|
|
162
|
+
if not target_model_name and handler_fn.returns:
|
|
163
|
+
try:
|
|
164
|
+
ret_str = ast.unparse(handler_fn.returns).strip()
|
|
165
|
+
ret_str = ret_str.replace("list[", "").replace("List[", "").replace("]", "")
|
|
166
|
+
ret_str = ret_str.replace("Optional[", "").replace("]", "").split("|")[0].strip()
|
|
167
|
+
target_model_name = ret_str
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
# Check parameter input schema
|
|
172
|
+
if not target_model_name:
|
|
173
|
+
excluded = {"request", "db", "session", "self", "cls"}
|
|
174
|
+
for arg in handler_fn.args.args:
|
|
175
|
+
if arg.arg not in excluded and arg.annotation:
|
|
176
|
+
try:
|
|
177
|
+
ann_str = ast.unparse(arg.annotation).strip()
|
|
178
|
+
target_model_name = ann_str.replace("Optional[", "").replace("]", "").split("|")[0].strip()
|
|
179
|
+
break
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
if target_model_name:
|
|
184
|
+
# Find class definition
|
|
185
|
+
for c_node in ast.walk(tree):
|
|
186
|
+
if isinstance(c_node, ast.ClassDef) and c_node.name == target_model_name:
|
|
187
|
+
specs = extract_schema_fields_from_class_node(c_node, str(full_path))
|
|
188
|
+
for s in specs:
|
|
189
|
+
fields[s.name] = (s.type_name, s.nullable)
|
|
190
|
+
break
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# 2. Database columns from db_entities if no AST model found
|
|
195
|
+
if not fields:
|
|
196
|
+
# Check if queries exist for this handler
|
|
197
|
+
q_rows = con.execute(
|
|
198
|
+
"SELECT table_name FROM db_queries WHERE caller_symbol_id = ? OR caller_symbol_id LIKE ?",
|
|
199
|
+
(handler_canonical_id, f"%{handler_name}"),
|
|
200
|
+
).fetchall()
|
|
201
|
+
tables = [str(r[0]) for r in q_rows if r[0]]
|
|
202
|
+
if tables:
|
|
203
|
+
placeholders = ",".join("?" for _ in tables)
|
|
204
|
+
col_rows = con.execute(
|
|
205
|
+
f"SELECT column_name, data_type, nullable FROM db_entities WHERE kind = 'column' AND table_name IN ({placeholders})",
|
|
206
|
+
tables,
|
|
207
|
+
).fetchall()
|
|
208
|
+
for cr in col_rows:
|
|
209
|
+
c_name = str(cr[0])
|
|
210
|
+
c_type = str(cr[1] or "text")
|
|
211
|
+
c_null = bool(cr[2])
|
|
212
|
+
fields[c_name] = (c_type, c_null)
|
|
213
|
+
|
|
214
|
+
return fields
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def detect_api_contract_drift(
|
|
218
|
+
con: sqlite3.Connection,
|
|
219
|
+
repo_root: Path | None = None,
|
|
220
|
+
) -> ApiContractDriftReport:
|
|
221
|
+
"""Analyze client data fetches vs backend route registrations to find contract mismatches."""
|
|
222
|
+
# 1. Fetch backend routes
|
|
223
|
+
route_table = "framework_routes"
|
|
224
|
+
has_fw_routes = con.execute(
|
|
225
|
+
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='framework_routes'"
|
|
226
|
+
).fetchone()[0] > 0
|
|
227
|
+
if not has_fw_routes:
|
|
228
|
+
route_table = "raw_framework_routes"
|
|
229
|
+
|
|
230
|
+
backend_routes_rows = con.execute(
|
|
231
|
+
f"SELECT endpoint_id, framework, http_method, route_path, normalized_route, handler_name, handler_canonical_id, file_path "
|
|
232
|
+
f"FROM {route_table}"
|
|
233
|
+
).fetchall()
|
|
234
|
+
|
|
235
|
+
backend_routes: list[dict[str, Any]] = []
|
|
236
|
+
for r in backend_routes_rows:
|
|
237
|
+
raw_p = str(r["route_path"] or "")
|
|
238
|
+
method = str(r["http_method"] or "GET").upper()
|
|
239
|
+
norm_route = normalize_route(method, raw_p)
|
|
240
|
+
backend_routes.append({
|
|
241
|
+
"endpoint_id": str(r["endpoint_id"]),
|
|
242
|
+
"framework": str(r["framework"]),
|
|
243
|
+
"http_method": method,
|
|
244
|
+
"route_path": raw_p,
|
|
245
|
+
"normalized_route": norm_route,
|
|
246
|
+
"handler_name": str(r["handler_name"] or ""),
|
|
247
|
+
"handler_canonical_id": str(r["handler_canonical_id"] or ""),
|
|
248
|
+
"file_path": str(r["file_path"] or ""),
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
# 2. Fetch TypeScript interfaces from local_bindings
|
|
252
|
+
ts_interfaces: dict[str, list[tuple[str, str]]] = {}
|
|
253
|
+
if con.execute(
|
|
254
|
+
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='local_bindings'"
|
|
255
|
+
).fetchone()[0] > 0:
|
|
256
|
+
ts_rows = con.execute(
|
|
257
|
+
"SELECT target_name, dict_entries_json FROM local_bindings WHERE expr_kind = 'TS_INTERFACE'"
|
|
258
|
+
).fetchall()
|
|
259
|
+
for tr in ts_rows:
|
|
260
|
+
iname = str(tr[0])
|
|
261
|
+
try:
|
|
262
|
+
entries = json.loads(str(tr[1] or "[]"))
|
|
263
|
+
ts_interfaces[iname] = [(str(e[0]), str(e[1])) for e in entries if len(e) >= 2]
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
# 3. Fetch client fetch calls from local_bindings
|
|
268
|
+
client_calls_rows = con.execute(
|
|
269
|
+
"SELECT target_name, file_path, line, source_expr, attr_name, base_expr, scope "
|
|
270
|
+
"FROM local_bindings "
|
|
271
|
+
"WHERE expr_kind IN ('REACT_FETCH_ROUTE', 'TEST_ROUTE', 'HTML_FORM_ACTION', 'HTMX_REQUEST')"
|
|
272
|
+
).fetchall()
|
|
273
|
+
|
|
274
|
+
drifts: list[ApiContractDrift] = []
|
|
275
|
+
matched_backend_endpoints: set[str] = set()
|
|
276
|
+
total_matched_calls = 0
|
|
277
|
+
|
|
278
|
+
for r in client_calls_rows:
|
|
279
|
+
raw_client_url = str(r["target_name"] or "")
|
|
280
|
+
if not raw_client_url.startswith("/") and not raw_client_url.startswith("http"):
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if raw_client_url.startswith("http"):
|
|
284
|
+
m_path = re.search(r"https?://[^/]+(/[^?#]*)", raw_client_url)
|
|
285
|
+
client_path = m_path.group(1) if m_path else raw_client_url
|
|
286
|
+
else:
|
|
287
|
+
client_path = raw_client_url
|
|
288
|
+
|
|
289
|
+
client_file = str(r["file_path"] or "")
|
|
290
|
+
client_line = int(r["line"] or 1)
|
|
291
|
+
raw_m = str(r["base_expr"] or "").upper()
|
|
292
|
+
if raw_m in ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"):
|
|
293
|
+
client_method = raw_m
|
|
294
|
+
else:
|
|
295
|
+
client_method = "GET"
|
|
296
|
+
|
|
297
|
+
resp_type = str(r["attr_name"] or "").strip()
|
|
298
|
+
client_norm = normalize_route(client_method, client_path)
|
|
299
|
+
|
|
300
|
+
# Check topological matches
|
|
301
|
+
exact_matches: list[dict[str, Any]] = []
|
|
302
|
+
path_matches_wrong_method: list[dict[str, Any]] = []
|
|
303
|
+
|
|
304
|
+
for b in backend_routes:
|
|
305
|
+
matched, _ = match_route_topology(client_norm, b["normalized_route"], allow_proxy_prefix=True, match_methods=True)
|
|
306
|
+
if matched:
|
|
307
|
+
exact_matches.append(b)
|
|
308
|
+
else:
|
|
309
|
+
p_matched, _ = match_route_topology(client_norm, b["normalized_route"], allow_proxy_prefix=True, match_methods=False)
|
|
310
|
+
if p_matched:
|
|
311
|
+
path_matches_wrong_method.append(b)
|
|
312
|
+
|
|
313
|
+
if exact_matches:
|
|
314
|
+
total_matched_calls += 1
|
|
315
|
+
for em in exact_matches:
|
|
316
|
+
matched_backend_endpoints.add(em["endpoint_id"])
|
|
317
|
+
|
|
318
|
+
primary_backend = exact_matches[0]
|
|
319
|
+
|
|
320
|
+
# 4. Cross-Language TypeScript Interface <-> Backend Contract Verification
|
|
321
|
+
if resp_type and resp_type in ts_interfaces:
|
|
322
|
+
ts_fields = ts_interfaces[resp_type]
|
|
323
|
+
backend_fields = _resolve_backend_fields_for_handler(
|
|
324
|
+
con,
|
|
325
|
+
repo_root,
|
|
326
|
+
primary_backend["handler_canonical_id"],
|
|
327
|
+
primary_backend["handler_name"],
|
|
328
|
+
primary_backend["file_path"],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if backend_fields:
|
|
332
|
+
for f_name, f_type_str in ts_fields:
|
|
333
|
+
is_opt_client = f_type_str.endswith("?") or "null" in f_type_str or "undefined" in f_type_str
|
|
334
|
+
clean_ts_type = f_type_str.rstrip("?").strip()
|
|
335
|
+
|
|
336
|
+
# Check 1: Missing field in backend
|
|
337
|
+
if f_name not in backend_fields:
|
|
338
|
+
if not is_opt_client:
|
|
339
|
+
drifts.append(
|
|
340
|
+
ApiContractDrift(
|
|
341
|
+
kind="CROSS_LANGUAGE_FIELD_DRIFT",
|
|
342
|
+
client_route=client_path,
|
|
343
|
+
client_method=client_method,
|
|
344
|
+
client_file=client_file,
|
|
345
|
+
client_line=client_line,
|
|
346
|
+
client_type=resp_type,
|
|
347
|
+
backend_route=primary_backend["route_path"],
|
|
348
|
+
backend_methods=(primary_backend["http_method"],),
|
|
349
|
+
backend_handler=primary_backend["handler_name"],
|
|
350
|
+
backend_file=primary_backend["file_path"],
|
|
351
|
+
field_name=f_name,
|
|
352
|
+
client_field_type=f_type_str,
|
|
353
|
+
severity="CRITICAL",
|
|
354
|
+
message=(
|
|
355
|
+
f"Client TypeScript interface '{resp_type}' expects required field '{f_name}: {f_type_str}', "
|
|
356
|
+
f"but backend route '{primary_backend['route_path']}' ({primary_backend['handler_name']}) does not provide this field."
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
be_type, be_nullable = backend_fields[f_name]
|
|
362
|
+
|
|
363
|
+
# Check 2: Nullability Drift
|
|
364
|
+
if not is_opt_client and be_nullable:
|
|
365
|
+
drifts.append(
|
|
366
|
+
ApiContractDrift(
|
|
367
|
+
kind="NULLABILITY_DRIFT",
|
|
368
|
+
client_route=client_path,
|
|
369
|
+
client_method=client_method,
|
|
370
|
+
client_file=client_file,
|
|
371
|
+
client_line=client_line,
|
|
372
|
+
client_type=resp_type,
|
|
373
|
+
backend_route=primary_backend["route_path"],
|
|
374
|
+
backend_methods=(primary_backend["http_method"],),
|
|
375
|
+
backend_handler=primary_backend["handler_name"],
|
|
376
|
+
backend_file=primary_backend["file_path"],
|
|
377
|
+
field_name=f_name,
|
|
378
|
+
client_field_type=f_type_str,
|
|
379
|
+
backend_field_type=f"{be_type} (nullable)",
|
|
380
|
+
severity="WARNING",
|
|
381
|
+
message=(
|
|
382
|
+
f"Client TypeScript interface '{resp_type}' expects '{f_name}' to be non-nullable, "
|
|
383
|
+
f"but backend schema/column allows NULL/None."
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Check 3: Type Incompatibility
|
|
389
|
+
if not _are_ts_backend_types_compatible(clean_ts_type, be_type):
|
|
390
|
+
drifts.append(
|
|
391
|
+
ApiContractDrift(
|
|
392
|
+
kind="TYPE_INCOMPATIBILITY",
|
|
393
|
+
client_route=client_path,
|
|
394
|
+
client_method=client_method,
|
|
395
|
+
client_file=client_file,
|
|
396
|
+
client_line=client_line,
|
|
397
|
+
client_type=resp_type,
|
|
398
|
+
backend_route=primary_backend["route_path"],
|
|
399
|
+
backend_methods=(primary_backend["http_method"],),
|
|
400
|
+
backend_handler=primary_backend["handler_name"],
|
|
401
|
+
backend_file=primary_backend["file_path"],
|
|
402
|
+
field_name=f_name,
|
|
403
|
+
client_field_type=f_type_str,
|
|
404
|
+
backend_field_type=be_type,
|
|
405
|
+
severity="CRITICAL",
|
|
406
|
+
message=(
|
|
407
|
+
f"Type mismatch on field '{f_name}': client expects '{f_type_str}', "
|
|
408
|
+
f"but backend returns '{be_type}'."
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
elif path_matches_wrong_method:
|
|
414
|
+
first_m = path_matches_wrong_method[0]
|
|
415
|
+
allowed_methods = tuple(sorted({m["http_method"] for m in path_matches_wrong_method}))
|
|
416
|
+
drifts.append(
|
|
417
|
+
ApiContractDrift(
|
|
418
|
+
kind="HTTP_METHOD_MISMATCH",
|
|
419
|
+
client_route=client_path,
|
|
420
|
+
client_method=client_method,
|
|
421
|
+
client_file=client_file,
|
|
422
|
+
client_line=client_line,
|
|
423
|
+
backend_route=first_m["route_path"],
|
|
424
|
+
backend_methods=allowed_methods,
|
|
425
|
+
backend_handler=first_m["handler_name"],
|
|
426
|
+
backend_file=first_m["file_path"],
|
|
427
|
+
severity="CRITICAL",
|
|
428
|
+
message=(
|
|
429
|
+
f"Client sends '{client_method} {client_path}' at {client_file}:{client_line}, "
|
|
430
|
+
f"but backend handler '{first_m['handler_name']}' only accepts {list(allowed_methods)}."
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
# Check if this looks like an API route worthy of reporting as orphaned
|
|
436
|
+
if client_path.startswith("/api/") or client_path.startswith("/v1/") or client_path.startswith("/v2/") or "/api" in client_path:
|
|
437
|
+
drifts.append(
|
|
438
|
+
ApiContractDrift(
|
|
439
|
+
kind="ORPHANED_CLIENT_ROUTE",
|
|
440
|
+
client_route=client_path,
|
|
441
|
+
client_method=client_method,
|
|
442
|
+
client_file=client_file,
|
|
443
|
+
client_line=client_line,
|
|
444
|
+
severity="CRITICAL",
|
|
445
|
+
message=f"Client calls '{client_path}' at {client_file}:{client_line}, but no backend route handler is registered.",
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return ApiContractDriftReport(
|
|
450
|
+
has_drift=len(drifts) > 0,
|
|
451
|
+
total_client_calls=len(client_calls_rows),
|
|
452
|
+
total_backend_routes=len(backend_routes_rows),
|
|
453
|
+
matched_calls=total_matched_calls,
|
|
454
|
+
drifts=tuple(drifts),
|
|
455
|
+
)
|