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
@@ -0,0 +1,862 @@
1
+ """Pydantic, Django Form, and DB Schema Drift Detection Engine (`src/codegraph/database/schema_drift.py`).
2
+
3
+ Provides deterministic, AST-verified cross-layer drift detection:
4
+ - Route input schemas (Pydantic BaseModel, Django Form/ModelForm, DRF Serializer, dataclasses)
5
+ versus destination database table columns.
6
+ - Drift categories:
7
+ 1. MISSING_REQUIRED_COLUMN: DB column is NOT NULL with no default, but route schema lacks the field.
8
+ 2. NULLABILITY_MISMATCH: Route schema permits None/null, but DB column rejects NULLs.
9
+ 3. TYPE_INCOMPATIBILITY: Route schema type conflicts with DB column type (e.g., int vs DATE/TIMESTAMP).
10
+ 4. LENGTH_CONSTRAINT_DRIFT: Route schema allows max_length > DB column length limit.
11
+ 5. UNUSED_SCHEMA_FIELD: Route schema accepts a field that is never persisted to the table.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import json
17
+ import re
18
+ import sqlite3
19
+ from dataclasses import asdict, dataclass
20
+ from enum import StrEnum
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from codegraph.database.interrogation import get_route_db_lineage
25
+
26
+
27
+ class DriftSeverity(StrEnum):
28
+ CRITICAL = "CRITICAL"
29
+ WARNING = "WARNING"
30
+ INFO = "INFO"
31
+
32
+
33
+ class DriftType(StrEnum):
34
+ MISSING_REQUIRED_COLUMN = "MISSING_REQUIRED_COLUMN"
35
+ NULLABILITY_MISMATCH = "NULLABILITY_MISMATCH"
36
+ TYPE_INCOMPATIBILITY = "TYPE_INCOMPATIBILITY"
37
+ LENGTH_CONSTRAINT_DRIFT = "LENGTH_CONSTRAINT_DRIFT"
38
+ UNUSED_SCHEMA_FIELD = "UNUSED_SCHEMA_FIELD"
39
+ RUNTIME_MISSING_REQUIRED_COLUMN = "RUNTIME_MISSING_REQUIRED_COLUMN"
40
+ RUNTIME_UNKNOWN_COLUMN_WRITTEN = "RUNTIME_UNKNOWN_COLUMN_WRITTEN"
41
+ STATIC_RUNTIME_DIVERGENCE = "STATIC_RUNTIME_DIVERGENCE"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SchemaFieldSpec:
46
+ name: str
47
+ type_name: str
48
+ nullable: bool
49
+ required: bool
50
+ max_length: int | None = None
51
+ default_repr: str | None = None
52
+ file_path: str = ""
53
+ line: int = 0
54
+
55
+ def as_dict(self) -> dict[str, Any]:
56
+ return asdict(self)
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SchemaDriftIssue:
61
+ drift_type: str
62
+ field_name: str
63
+ column_name: str | None
64
+ severity: str
65
+ message: str
66
+ schema_field: dict[str, Any] | None
67
+ db_column: dict[str, Any] | None
68
+ evidence: str
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ return asdict(self)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # AST Extraction for Pydantic / dataclass / Form Schemas
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _IGNORED_AUTO_COLUMNS = frozenset({"id", "created_at", "updated_at", "created_on", "updated_on", "deleted_at"})
79
+ _IGNORED_SCHEMA_FIELDS = frozenset({"csrf_token", "csrfmiddlewaretoken", "captcha"})
80
+
81
+
82
+ def _unparse_ast(node: ast.AST | None) -> str:
83
+ if node is None:
84
+ return ""
85
+ try:
86
+ return ast.unparse(node).strip()
87
+ except Exception:
88
+ return ""
89
+
90
+
91
+ def _extract_type_info(annotation_node: ast.AST | None) -> tuple[str, bool]:
92
+ """Return (base_type_name, is_nullable)."""
93
+ if annotation_node is None:
94
+ return "Any", True
95
+
96
+ raw_str = _unparse_ast(annotation_node)
97
+
98
+ # Check for Optional[...] or None in Union
99
+ is_nullable = False
100
+ if "None" in raw_str or "Optional" in raw_str:
101
+ is_nullable = True
102
+
103
+ # Normalize base type
104
+ base = raw_str.replace("Optional[", "").replace("]", "")
105
+ parts = [p.strip() for p in base.split("|") if p.strip() and p.strip() != "None"]
106
+ base_clean = parts[0] if parts else "Any"
107
+
108
+ # Normalize common wrappers like Annotated[str, Field(...)]
109
+ if base_clean.startswith("Annotated["):
110
+ inner = base_clean[len("Annotated[") : -1]
111
+ base_clean = inner.split(",")[0].strip()
112
+
113
+ return base_clean, is_nullable
114
+
115
+
116
+ def _parse_field_call(call_node: ast.Call) -> tuple[bool, bool, int | None, str | None]:
117
+ """Parse a Field(...) or Form field call for (required, nullable, max_length, default_repr)."""
118
+ required = True
119
+ nullable = False
120
+ max_length: int | None = None
121
+ default_repr: str | None = None
122
+
123
+ # Positional args (e.g. Field(..., max_length=50) or Field(None))
124
+ if call_node.args:
125
+ first_arg = call_node.args[0]
126
+ arg_str = _unparse_ast(first_arg)
127
+ if arg_str == "...":
128
+ required = True
129
+ elif arg_str == "None":
130
+ required = False
131
+ nullable = True
132
+ default_repr = "None"
133
+ else:
134
+ required = False
135
+ default_repr = arg_str
136
+
137
+ for kw in call_node.keywords:
138
+ kw_name = kw.arg
139
+ kw_val = _unparse_ast(kw.value)
140
+ if kw_name == "default":
141
+ if kw_val == "...":
142
+ required = True
143
+ elif kw_val == "None":
144
+ required = False
145
+ nullable = True
146
+ default_repr = "None"
147
+ else:
148
+ required = False
149
+ default_repr = kw_val
150
+ elif kw_name == "default_factory":
151
+ required = False
152
+ default_repr = f"{kw_val}()"
153
+ elif kw_name == "max_length":
154
+ try:
155
+ max_length = int(kw_val)
156
+ except ValueError:
157
+ pass
158
+ elif kw_name == "required":
159
+ if kw_val in ("False", "0"):
160
+ required = False
161
+ nullable = True
162
+ elif kw_val in ("True", "1"):
163
+ required = True
164
+
165
+ return required, nullable, max_length, default_repr
166
+
167
+
168
+ def extract_schema_fields_from_class_node(
169
+ class_node: ast.ClassDef,
170
+ file_path: str,
171
+ ) -> list[SchemaFieldSpec]:
172
+ """Extract fields from a Pydantic BaseModel, dataclass, or Django Form AST node."""
173
+ fields: list[SchemaFieldSpec] = []
174
+
175
+ for stmt in class_node.body:
176
+ # 1. Annotated assignments: field: type [= default]
177
+ if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
178
+ f_name = stmt.target.id
179
+ if f_name.startswith("_"):
180
+ continue
181
+
182
+ base_type, is_nullable = _extract_type_info(stmt.annotation)
183
+ required = True
184
+ max_length: int | None = None
185
+ default_repr: str | None = None
186
+
187
+ if stmt.value is not None:
188
+ val_str = _unparse_ast(stmt.value)
189
+ if isinstance(stmt.value, ast.Call):
190
+ # Check for Field(...) or forms.CharField(...)
191
+ call_func_name = _unparse_ast(stmt.value.func)
192
+ if "Field" in call_func_name or "forms." in call_func_name or "serializers." in call_func_name:
193
+ req, null, m_len, d_rep = _parse_field_call(stmt.value)
194
+ required = req
195
+ if null:
196
+ is_nullable = True
197
+ if m_len is not None:
198
+ max_length = m_len
199
+ default_repr = d_rep
200
+ else:
201
+ required = False
202
+ default_repr = val_str
203
+ elif val_str == "...":
204
+ required = True
205
+ elif val_str == "None":
206
+ required = False
207
+ is_nullable = True
208
+ default_repr = "None"
209
+ else:
210
+ required = False
211
+ default_repr = val_str
212
+
213
+ fields.append(
214
+ SchemaFieldSpec(
215
+ name=f_name,
216
+ type_name=base_type,
217
+ nullable=is_nullable,
218
+ required=required,
219
+ max_length=max_length,
220
+ default_repr=default_repr,
221
+ file_path=file_path,
222
+ line=stmt.lineno,
223
+ )
224
+ )
225
+
226
+ # 2. Assign statements (e.g. Django forms / DRF serializers: name = forms.CharField(...))
227
+ elif isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
228
+ f_name = stmt.targets[0].id
229
+ if f_name.startswith("_"):
230
+ continue
231
+
232
+ if isinstance(stmt.value, ast.Call):
233
+ func_name = _unparse_ast(stmt.value.func)
234
+ # Map Django / DRF fields to types
235
+ inferred_type = "str"
236
+ if "Integer" in func_name or "BigInteger" in func_name:
237
+ inferred_type = "int"
238
+ elif "Float" in func_name or "Decimal" in func_name:
239
+ inferred_type = "float"
240
+ elif "Boolean" in func_name:
241
+ inferred_type = "bool"
242
+ elif "Date" in func_name:
243
+ inferred_type = "date"
244
+ elif "Time" in func_name:
245
+ inferred_type = "datetime"
246
+ elif "JSON" in func_name:
247
+ inferred_type = "dict"
248
+ elif "UUID" in func_name:
249
+ inferred_type = "UUID"
250
+
251
+ req, null, m_len, d_rep = _parse_field_call(stmt.value)
252
+ fields.append(
253
+ SchemaFieldSpec(
254
+ name=f_name,
255
+ type_name=inferred_type,
256
+ nullable=null,
257
+ required=req,
258
+ max_length=m_len,
259
+ default_repr=d_rep,
260
+ file_path=file_path,
261
+ line=stmt.lineno,
262
+ )
263
+ )
264
+
265
+ return fields
266
+
267
+
268
+ def extract_schema_fields_from_file(
269
+ file_path: Path,
270
+ class_name: str,
271
+ ) -> list[SchemaFieldSpec]:
272
+ """Parse file_path and extract SchemaFieldSpecs for class_name."""
273
+ if not file_path.exists():
274
+ return []
275
+
276
+ try:
277
+ source = file_path.read_text(encoding="utf-8", errors="replace")
278
+ tree = ast.parse(source, filename=str(file_path))
279
+ except Exception:
280
+ return []
281
+
282
+ for node in ast.walk(tree):
283
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
284
+ return extract_schema_fields_from_class_node(node, str(file_path))
285
+
286
+ return []
287
+
288
+
289
+ def extract_dynamic_request_fields(
290
+ func_node: ast.FunctionDef | ast.AsyncFunctionDef,
291
+ file_path: str,
292
+ ) -> list[SchemaFieldSpec]:
293
+ """Extract dynamic form/json/query parameters accessed on Flask/FastAPI/Django request objects."""
294
+ fields: dict[str, SchemaFieldSpec] = {}
295
+
296
+ for node in ast.walk(func_node):
297
+ # 1. request.form.get("key") or request.json.get("key") or request.args.get("key")
298
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get":
299
+ base = node.func.value
300
+ if isinstance(base, ast.Attribute) and base.attr in ("form", "args", "json", "values", "GET", "POST"):
301
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
302
+ f_name = node.args[0].value
303
+ has_default = len(node.args) > 1 or any(kw.arg == "default" for kw in node.keywords)
304
+ if f_name not in fields:
305
+ fields[f_name] = SchemaFieldSpec(
306
+ name=f_name,
307
+ type_name="Any",
308
+ nullable=True,
309
+ required=not has_default,
310
+ default_repr="None" if not has_default else "...",
311
+ file_path=file_path,
312
+ line=node.lineno,
313
+ )
314
+ # Also handle request.get_json().get("name")
315
+ elif isinstance(base, ast.Call) and isinstance(base.func, ast.Attribute) and base.func.attr == "get_json":
316
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
317
+ f_name = node.args[0].value
318
+ if f_name not in fields:
319
+ fields[f_name] = SchemaFieldSpec(
320
+ name=f_name,
321
+ type_name="Any",
322
+ nullable=True,
323
+ required=False,
324
+ file_path=file_path,
325
+ line=node.lineno,
326
+ )
327
+ # 2. request.form["key"] or request.json["key"]
328
+ elif isinstance(node, ast.Subscript) and isinstance(node.value, ast.Attribute) and node.value.attr in ("form", "args", "json", "values", "GET", "POST"):
329
+ slice_node = node.slice
330
+ if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str):
331
+ f_name = slice_node.value
332
+ fields[f_name] = SchemaFieldSpec(
333
+ name=f_name,
334
+ type_name="Any",
335
+ nullable=False,
336
+ required=True,
337
+ file_path=file_path,
338
+ line=node.lineno,
339
+ )
340
+
341
+ return list(fields.values())
342
+
343
+
344
+ def resolve_handler_input_schema(
345
+ con: sqlite3.Connection,
346
+ repo_path: Path,
347
+ handler_symbol_or_path: str,
348
+ ) -> tuple[str, list[SchemaFieldSpec]] | None:
349
+ """Find the route handler AST, discover parameter type annotations, and extract the input schema."""
350
+ # Look up handler in symbols table
351
+ sym_row = con.execute(
352
+ "SELECT name, path, start_line, end_line, signature FROM symbols "
353
+ "WHERE (canonical_id = ? OR qualified_name = ? OR name = ?) AND kind in ('function', 'method') LIMIT 1",
354
+ (handler_symbol_or_path, handler_symbol_or_path, handler_symbol_or_path),
355
+ ).fetchone()
356
+
357
+ if not sym_row:
358
+ # Try finding handler via framework_routes
359
+ rt_row = con.execute(
360
+ "SELECT handler_name, file_path, line FROM framework_routes "
361
+ "WHERE (handler_name = ? OR handler_canonical_id = ? OR route_path = ?) LIMIT 1",
362
+ (handler_symbol_or_path, handler_symbol_or_path, handler_symbol_or_path),
363
+ ).fetchone()
364
+ if rt_row:
365
+ h_path = repo_path / str(rt_row[1])
366
+ h_name = str(rt_row[0])
367
+ else:
368
+ return None
369
+ else:
370
+ h_path = repo_path / str(sym_row["path"])
371
+ h_name = str(sym_row["name"])
372
+
373
+ if not h_path.exists():
374
+ return None
375
+
376
+ try:
377
+ source = h_path.read_text(encoding="utf-8", errors="replace")
378
+ tree = ast.parse(source, filename=str(h_path))
379
+ except Exception:
380
+ return None
381
+
382
+ handler_fn: ast.FunctionDef | ast.AsyncFunctionDef | None = None
383
+ for node in ast.walk(tree):
384
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == h_name:
385
+ handler_fn = node
386
+ break
387
+
388
+ if not handler_fn:
389
+ return None
390
+
391
+ # Inspect function parameters for schema types
392
+ # Exclude request, db, session, self, cls
393
+ excluded_params = {"request", "db", "session", "self", "cls", "background_tasks", "response"}
394
+ for arg in handler_fn.args.args:
395
+ if arg.arg in excluded_params:
396
+ continue
397
+ if arg.annotation:
398
+ ann_str = _unparse_ast(arg.annotation)
399
+ # Remove Optional / Union wrappers
400
+ schema_type = ann_str.replace("Optional[", "").replace("]", "").split("|")[0].strip()
401
+ # If schema_type is a defined class in this file or imported
402
+ # 1. Check current file for class definition
403
+ for c_node in ast.walk(tree):
404
+ if isinstance(c_node, ast.ClassDef) and c_node.name == schema_type:
405
+ specs = extract_schema_fields_from_class_node(c_node, str(h_path))
406
+ if specs:
407
+ return schema_type, specs
408
+
409
+ # 2. Direct lookup in symbols table for ClassDef
410
+ cls_row = con.execute(
411
+ "SELECT path FROM symbols WHERE name = ? AND kind = 'class' LIMIT 1",
412
+ (schema_type,),
413
+ ).fetchone()
414
+ if cls_row:
415
+ c_path = repo_path / str(cls_row[0])
416
+ specs = extract_schema_fields_from_file(c_path, schema_type)
417
+ if specs:
418
+ return schema_type, specs
419
+
420
+ # 3. Check imports table in SQLite
421
+ try:
422
+ imp_rows = con.execute(
423
+ "SELECT module FROM imports WHERE source_path = ? AND (module LIKE ? OR module = ?)",
424
+ (str(h_path.relative_to(repo_path)), f"%{schema_type}%", schema_type),
425
+ ).fetchall()
426
+ for imp in imp_rows:
427
+ mod_str = str(imp[0])
428
+ pot_rel = mod_str.replace(".", "/") + ".py"
429
+ pot_path = repo_path / pot_rel
430
+ if pot_path.exists():
431
+ specs = extract_schema_fields_from_file(pot_path, schema_type)
432
+ if specs:
433
+ return schema_type, specs
434
+ except sqlite3.OperationalError:
435
+ pass
436
+
437
+ # 4. Fallback: Dynamic payload extraction in handler body (Flask request.form / request.json / request.args)
438
+ dynamic_specs = extract_dynamic_request_fields(handler_fn, str(h_path))
439
+ if dynamic_specs:
440
+ return f"{h_name}:DynamicPayload", dynamic_specs
441
+
442
+ return None
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Type Compatibility Matrix
447
+ # ---------------------------------------------------------------------------
448
+
449
+ _COMPATIBLE_TYPES: dict[str, set[str]] = {
450
+ "int": {"integer", "int", "bigint", "smallint", "serial", "bigserial", "tinyint", "integerfield"},
451
+ "str": {"varchar", "text", "char", "character varying", "string", "citext", "charfield", "emailfield"},
452
+ "float": {"float", "double", "real", "numeric", "decimal", "double precision", "floatfield", "decimalfield"},
453
+ "bool": {"boolean", "bool", "tinyint", "booleanfield"},
454
+ "date": {"date", "datefield"},
455
+ "datetime": {"timestamp", "timestamptz", "datetime", "timestamp with time zone", "timestamp without time zone", "datetimefield"},
456
+ "dict": {"json", "jsonb", "jsonfield"},
457
+ "list": {"json", "jsonb", "array"},
458
+ "UUID": {"uuid", "varchar", "char", "text", "uuidfield"},
459
+ "Any": set(), # Compatible with all
460
+ }
461
+
462
+
463
+ def _are_types_compatible(schema_type: str, db_col_type: str) -> bool:
464
+ s_norm = schema_type.lower()
465
+ d_norm = db_col_type.lower().split("(")[0].strip()
466
+
467
+ if s_norm in ("any", "object") or d_norm in ("any", "unknown", ""):
468
+ return True
469
+
470
+ # Normalize schema type name
471
+ target_key = "str"
472
+ if s_norm in ("int", "integer"):
473
+ target_key = "int"
474
+ elif s_norm in ("float", "decimal"):
475
+ target_key = "float"
476
+ elif s_norm in ("bool", "boolean"):
477
+ target_key = "bool"
478
+ elif s_norm in ("datetime",):
479
+ target_key = "datetime"
480
+ elif s_norm in ("date",):
481
+ target_key = "date"
482
+ elif s_norm in ("dict", "mapping"):
483
+ target_key = "dict"
484
+ elif s_norm in ("list", "sequence"):
485
+ target_key = "list"
486
+ elif s_norm in ("uuid",):
487
+ target_key = "UUID"
488
+
489
+ allowed = _COMPATIBLE_TYPES.get(target_key, set())
490
+ if not allowed:
491
+ return True
492
+
493
+ return any(a in d_norm for a in allowed)
494
+
495
+
496
+ def _extract_db_varchar_length(data_type: str) -> int | None:
497
+ m = re.search(r"(?:varchar|char|character varying|string|charfield)\s*\(\s*(\d+)\s*\)", data_type, re.IGNORECASE)
498
+ if m:
499
+ try:
500
+ return int(m.group(1))
501
+ except ValueError:
502
+ pass
503
+ return None
504
+
505
+
506
+ # ---------------------------------------------------------------------------
507
+ # Schema Drift Interrogation Core
508
+ # ---------------------------------------------------------------------------
509
+
510
+
511
+ def detect_schema_drift(
512
+ con: sqlite3.Connection,
513
+ repo_path: Path,
514
+ *,
515
+ route_or_handler: str,
516
+ target_table: str | None = None,
517
+ schema_name: str | None = None,
518
+ ) -> dict[str, Any]:
519
+ """Detect schema validation drift between a route's input schema and the destination database table.
520
+
521
+ Deterministic verification of:
522
+ - MISSING_REQUIRED_COLUMN
523
+ - NULLABILITY_MISMATCH
524
+ - TYPE_INCOMPATIBILITY
525
+ - LENGTH_CONSTRAINT_DRIFT
526
+ - UNUSED_SCHEMA_FIELD
527
+ """
528
+ # 1. Determine destination table via get_route_db_lineage if not explicitly provided
529
+ resolved_table = target_table
530
+ lineage = None
531
+ if not resolved_table:
532
+ lineage = get_route_db_lineage(con, route_or_handler)
533
+ writes = lineage.get("db_writes", [])
534
+ if writes:
535
+ resolved_table = writes[0].get("table")
536
+ elif lineage.get("tables"):
537
+ resolved_table = lineage["tables"][0]
538
+
539
+ # 1b. Fallback: check runtime observations & edges if static lineage did not resolve table
540
+ if not resolved_table:
541
+ rt_row = con.execute(
542
+ "SELECT db_table FROM runtime_observations "
543
+ "WHERE (route_path = ? OR handler_symbol = ? OR caller_symbol = ? OR route_path LIKE ? OR handler_symbol LIKE ?) "
544
+ "AND db_table != '' "
545
+ "ORDER BY runtime_generation DESC, rowid DESC LIMIT 1",
546
+ (route_or_handler, route_or_handler, route_or_handler, f"%{route_or_handler}%", f"%{route_or_handler}%"),
547
+ ).fetchone()
548
+ if rt_row and rt_row[0]:
549
+ resolved_table = str(rt_row[0])
550
+ else:
551
+ edge_row = con.execute(
552
+ "SELECT target FROM runtime_edges "
553
+ "WHERE (source = ? OR source LIKE ?) AND relationship IN ('WRITES_TABLE', 'READS_TABLE') "
554
+ "ORDER BY observation_count DESC LIMIT 1",
555
+ (route_or_handler, f"%{route_or_handler}%"),
556
+ ).fetchone()
557
+ if edge_row and edge_row[0]:
558
+ resolved_table = str(edge_row[0]).split(".")[-1]
559
+
560
+ if not resolved_table:
561
+ return {
562
+ "status": "error",
563
+ "route_or_handler": route_or_handler,
564
+ "drift_count": 0,
565
+ "issues": [],
566
+ "error": "No destination database table found for route or handler.",
567
+ }
568
+
569
+ # 2. Extract input schema fields
570
+ fields: list[SchemaFieldSpec] = []
571
+ resolved_schema_name = schema_name
572
+ schema_mode = "STATIC_TYPED"
573
+
574
+ if resolved_schema_name:
575
+ cls_row = con.execute(
576
+ "SELECT path FROM symbols WHERE name = ? AND kind = 'class' LIMIT 1",
577
+ (resolved_schema_name,),
578
+ ).fetchone()
579
+ if cls_row:
580
+ fields = extract_schema_fields_from_file(repo_path / str(cls_row[0]), resolved_schema_name)
581
+ else:
582
+ resolved = resolve_handler_input_schema(con, repo_path, route_or_handler)
583
+ if resolved:
584
+ resolved_schema_name, fields = resolved
585
+ if ":DynamicPayload" in resolved_schema_name:
586
+ schema_mode = "DYNAMIC_AST"
587
+
588
+ # Query runtime SQL observations for this route/handler and target table
589
+ rt_sql_rows = con.execute(
590
+ "SELECT db_operation, db_table, db_columns_json, normalized_sql, duration_ms, event_id "
591
+ "FROM runtime_observations "
592
+ "WHERE (route_path = ? OR handler_symbol = ? OR caller_symbol = ? OR route_path LIKE ? OR handler_symbol LIKE ?) "
593
+ "AND (db_table = ? OR db_table LIKE ?) AND normalized_sql != '' "
594
+ "ORDER BY runtime_generation DESC, rowid DESC",
595
+ (route_or_handler, route_or_handler, route_or_handler, f"%{route_or_handler}%", f"%{route_or_handler}%", resolved_table, f"%{resolved_table}%"),
596
+ ).fetchall()
597
+
598
+ # If static/dynamic fields could not be found, attempt to build runtime schema fields from observed SQL
599
+ if not fields or not resolved_schema_name:
600
+ if rt_sql_rows:
601
+ seen_rt_cols: set[str] = set()
602
+ for r in rt_sql_rows:
603
+ try:
604
+ cols_list = json.loads(r[2] or "[]")
605
+ except Exception:
606
+ cols_list = []
607
+ for c in cols_list:
608
+ if c and c.lower() not in seen_rt_cols:
609
+ seen_rt_cols.add(c.lower())
610
+ fields.append(
611
+ SchemaFieldSpec(
612
+ name=c,
613
+ type_name="Any",
614
+ nullable=False,
615
+ required=True,
616
+ default_repr="<runtime_sql>",
617
+ file_path="[RUNTIME_OBSERVED]",
618
+ line=0,
619
+ )
620
+ )
621
+ if fields:
622
+ resolved_schema_name = f"RuntimeSQL:{resolved_table}"
623
+ schema_mode = "RUNTIME_OBSERVED"
624
+
625
+ if not fields or not resolved_schema_name:
626
+ return {
627
+ "status": "error",
628
+ "route_or_handler": route_or_handler,
629
+ "target_table": resolved_table,
630
+ "drift_count": 0,
631
+ "issues": [],
632
+ "error": "Could not locate static schema class, dynamic request fields, or runtime SQL traces for handler.",
633
+ }
634
+
635
+ # 3. Retrieve database table columns from db_entities table
636
+ col_rows = con.execute(
637
+ "SELECT column_name, data_type, nullable, default_value, is_primary_key, is_foreign_key, file_path, start_line, evidence "
638
+ "FROM db_entities WHERE table_name = ? AND (LOWER(kind) = 'column' OR kind LIKE '%COLUMN%') ORDER BY column_name ASC",
639
+ (resolved_table,),
640
+ ).fetchall()
641
+
642
+ if not col_rows:
643
+ return {
644
+ "status": "error",
645
+ "route_or_handler": route_or_handler,
646
+ "target_table": resolved_table,
647
+ "schema_name": resolved_schema_name,
648
+ "drift_count": 0,
649
+ "issues": [],
650
+ "error": f"No indexed columns found for destination table '{resolved_table}'.",
651
+ }
652
+
653
+ cols_by_name: dict[str, dict[str, Any]] = {}
654
+ for r in col_rows:
655
+ c_name = str(r[0]).lower()
656
+ cols_by_name[c_name] = {
657
+ "column_name": str(r[0]),
658
+ "data_type": str(r[1] or ""),
659
+ "nullable": bool(r[2]),
660
+ "default_value": r[3],
661
+ "is_primary_key": bool(r[4]),
662
+ "is_foreign_key": bool(r[5]),
663
+ "file_path": str(r[6]),
664
+ "line": int(r[7]),
665
+ "evidence": str(r[8]),
666
+ }
667
+
668
+ issues: list[SchemaDriftIssue] = []
669
+ fields_by_name = {f.name.lower(): f for f in fields}
670
+
671
+ # 4. Check for MISSING_REQUIRED_COLUMN in schema
672
+ for c_lower, c_spec in cols_by_name.items():
673
+ if c_lower in _IGNORED_AUTO_COLUMNS or c_spec["is_primary_key"]:
674
+ continue
675
+ # Column is required if nullable is False and no default is configured
676
+ col_required = (not c_spec["nullable"]) and (c_spec["default_value"] is None)
677
+ if col_required and c_lower not in fields_by_name:
678
+ issues.append(
679
+ SchemaDriftIssue(
680
+ drift_type=DriftType.MISSING_REQUIRED_COLUMN.value,
681
+ field_name=c_spec["column_name"],
682
+ column_name=c_spec["column_name"],
683
+ severity=DriftSeverity.CRITICAL.value,
684
+ message=(
685
+ f"Database column '{resolved_table}.{c_spec['column_name']}' is NOT NULL with no default, "
686
+ f"but input schema '{resolved_schema_name}' does not define this field."
687
+ ),
688
+ schema_field=None,
689
+ db_column=c_spec,
690
+ evidence=c_spec["evidence"],
691
+ )
692
+ )
693
+
694
+ # 5. Check schema fields against matched columns
695
+ for f_lower, f_spec in fields_by_name.items():
696
+ if f_lower in _IGNORED_SCHEMA_FIELDS:
697
+ continue
698
+
699
+ matched_col = cols_by_name.get(f_lower)
700
+ if not matched_col:
701
+ issues.append(
702
+ SchemaDriftIssue(
703
+ drift_type=DriftType.UNUSED_SCHEMA_FIELD.value,
704
+ field_name=f_spec.name,
705
+ column_name=None,
706
+ severity=DriftSeverity.INFO.value,
707
+ message=(
708
+ f"Schema field '{resolved_schema_name}.{f_spec.name}' is accepted by route, "
709
+ f"but no matching column exists in destination table '{resolved_table}'."
710
+ ),
711
+ schema_field=f_spec.as_dict(),
712
+ db_column=None,
713
+ evidence=f"{f_spec.file_path}:{f_spec.line}",
714
+ )
715
+ )
716
+ continue
717
+
718
+ # NULLABILITY_MISMATCH: schema is nullable/optional, but DB rejects NULLs without default
719
+ db_rejects_null = (not matched_col["nullable"]) and (matched_col["default_value"] is None)
720
+ if f_spec.nullable and db_rejects_null:
721
+ issues.append(
722
+ SchemaDriftIssue(
723
+ drift_type=DriftType.NULLABILITY_MISMATCH.value,
724
+ field_name=f_spec.name,
725
+ column_name=matched_col["column_name"],
726
+ severity=DriftSeverity.CRITICAL.value,
727
+ message=(
728
+ f"Schema field '{f_spec.name}' allows None/null, but DB column '{matched_col['column_name']}' "
729
+ f"is NOT NULL with no default value."
730
+ ),
731
+ schema_field=f_spec.as_dict(),
732
+ db_column=matched_col,
733
+ evidence=f"Schema: {f_spec.file_path}:{f_spec.line} | DB: {matched_col['evidence']}",
734
+ )
735
+ )
736
+
737
+ # TYPE_INCOMPATIBILITY
738
+ if not _are_types_compatible(f_spec.type_name, matched_col["data_type"]):
739
+ issues.append(
740
+ SchemaDriftIssue(
741
+ drift_type=DriftType.TYPE_INCOMPATIBILITY.value,
742
+ field_name=f_spec.name,
743
+ column_name=matched_col["column_name"],
744
+ severity=DriftSeverity.CRITICAL.value,
745
+ message=(
746
+ f"Type mismatch: Schema field '{f_spec.name}' type '{f_spec.type_name}' "
747
+ f"is incompatible with database column type '{matched_col['data_type']}'."
748
+ ),
749
+ schema_field=f_spec.as_dict(),
750
+ db_column=matched_col,
751
+ evidence=f"Schema: {f_spec.type_name} | DB: {matched_col['data_type']}",
752
+ )
753
+ )
754
+
755
+ # LENGTH_CONSTRAINT_DRIFT
756
+ col_max_len = _extract_db_varchar_length(matched_col["data_type"])
757
+ if col_max_len is not None and f_spec.max_length is not None and f_spec.max_length > col_max_len:
758
+ issues.append(
759
+ SchemaDriftIssue(
760
+ drift_type=DriftType.LENGTH_CONSTRAINT_DRIFT.value,
761
+ field_name=f_spec.name,
762
+ column_name=matched_col["column_name"],
763
+ severity=DriftSeverity.WARNING.value,
764
+ message=(
765
+ f"Length constraint drift: Schema allows max_length={f_spec.max_length}, "
766
+ f"but DB column '{matched_col['column_name']}' is restricted to {col_max_len} characters."
767
+ ),
768
+ schema_field=f_spec.as_dict(),
769
+ db_column=matched_col,
770
+ evidence=f"Schema max_length={f_spec.max_length} vs DB {matched_col['data_type']}",
771
+ )
772
+ )
773
+
774
+ # 6. Reconcile runtime SQL execution against destination database table columns
775
+ runtime_cols_written: set[str] = set()
776
+ last_sql_sample = ""
777
+ for r in rt_sql_rows:
778
+ op = str(r[0] or "").upper()
779
+ sql_txt = str(r[3] or "")
780
+ if not last_sql_sample and sql_txt:
781
+ last_sql_sample = sql_txt
782
+ try:
783
+ c_list = [str(x) for x in json.loads(r[2] or "[]")]
784
+ except Exception:
785
+ c_list = []
786
+ for c in c_list:
787
+ if c:
788
+ runtime_cols_written.add(c.lower())
789
+
790
+ # If operation is INSERT: check if any required NOT NULL column in DB was omitted
791
+ if op == "INSERT" and c_list:
792
+ inserted_lower = {c.lower() for c in c_list}
793
+ for c_lower, c_spec in cols_by_name.items():
794
+ if c_lower in _IGNORED_AUTO_COLUMNS or c_spec["is_primary_key"]:
795
+ continue
796
+ col_required = (not c_spec["nullable"]) and (c_spec["default_value"] is None)
797
+ if col_required and c_lower not in inserted_lower:
798
+ if not any(i.drift_type == DriftType.RUNTIME_MISSING_REQUIRED_COLUMN.value and i.column_name == c_spec["column_name"] for i in issues):
799
+ issues.append(
800
+ SchemaDriftIssue(
801
+ drift_type=DriftType.RUNTIME_MISSING_REQUIRED_COLUMN.value,
802
+ field_name=c_spec["column_name"],
803
+ column_name=c_spec["column_name"],
804
+ severity=DriftSeverity.CRITICAL.value,
805
+ message=(
806
+ f"[RUNTIME_OBSERVED] Executed query '{sql_txt}' omitted required NOT NULL column "
807
+ f"'{resolved_table}.{c_spec['column_name']}'."
808
+ ),
809
+ schema_field=None,
810
+ db_column=c_spec,
811
+ evidence=f"Runtime SQL: {sql_txt} | DB: {c_spec['evidence']}",
812
+ )
813
+ )
814
+
815
+ # Check if query wrote to unknown column not in DB
816
+ for written_c in c_list:
817
+ if written_c.lower() not in cols_by_name:
818
+ if not any(i.drift_type == DriftType.RUNTIME_UNKNOWN_COLUMN_WRITTEN.value and i.field_name == written_c for i in issues):
819
+ issues.append(
820
+ SchemaDriftIssue(
821
+ drift_type=DriftType.RUNTIME_UNKNOWN_COLUMN_WRITTEN.value,
822
+ field_name=written_c,
823
+ column_name=None,
824
+ severity=DriftSeverity.CRITICAL.value,
825
+ message=(
826
+ f"[RUNTIME_OBSERVED] Executed query '{sql_txt}' wrote to unknown column '{written_c}' "
827
+ f"not present in destination table '{resolved_table}'."
828
+ ),
829
+ schema_field=None,
830
+ db_column=None,
831
+ evidence=f"Runtime SQL: {sql_txt}",
832
+ )
833
+ )
834
+
835
+ # Deterministic sorting
836
+ severity_order = {DriftSeverity.CRITICAL.value: 0, DriftSeverity.WARNING.value: 1, DriftSeverity.INFO.value: 2}
837
+ sorted_issues = sorted(
838
+ issues,
839
+ key=lambda x: (severity_order.get(x.severity, 99), x.drift_type, x.field_name),
840
+ )
841
+
842
+ runtime_recon = {
843
+ "is_observed": bool(rt_sql_rows),
844
+ "observation_count": len(rt_sql_rows),
845
+ "last_sql_sample": last_sql_sample if last_sql_sample else None,
846
+ "runtime_columns_written": sorted(list(runtime_cols_written)),
847
+ "status": "CONFIRMED_RUNTIME_PATH" if rt_sql_rows else "NOT_OBSERVED_AT_RUNTIME",
848
+ }
849
+
850
+ return {
851
+ "status": "ok",
852
+ "route_or_handler": route_or_handler,
853
+ "target_table": resolved_table,
854
+ "schema_name": resolved_schema_name,
855
+ "schema_mode": schema_mode,
856
+ "drift_count": len(sorted_issues),
857
+ "critical_count": sum(1 for i in sorted_issues if i.severity == DriftSeverity.CRITICAL.value),
858
+ "warning_count": sum(1 for i in sorted_issues if i.severity == DriftSeverity.WARNING.value),
859
+ "info_count": sum(1 for i in sorted_issues if i.severity == DriftSeverity.INFO.value),
860
+ "issues": [i.as_dict() for i in sorted_issues],
861
+ "runtime_reconciliation": runtime_recon,
862
+ }