codegraph-engine 3.0.0__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 (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +859 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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
+ )