codegraph-engine 2.3.0__py3-none-any.whl → 3.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.
- codegraph/__init__.py +1 -1
- codegraph/__main__.py +7 -0
- codegraph/agent_brain.py +253 -2
- codegraph/agent_capabilities.py +1348 -67
- codegraph/agent_rules.py +2 -1
- codegraph/api_drift.py +455 -0
- codegraph/architecture_rules.py +123 -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/change_impact.py +320 -110
- codegraph/changelog.py +170 -0
- codegraph/cli.py +1605 -62
- codegraph/cli_output.py +17 -2
- codegraph/context.py +135 -24
- codegraph/context_freshness.py +2 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/dataflow_propagation.py +118 -0
- codegraph/dead_code.py +387 -0
- codegraph/epistemic.py +15 -0
- codegraph/evidence_contract.py +34 -3
- codegraph/federation.py +245 -0
- codegraph/frameworks.py +234 -12
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +2 -0
- codegraph/graph/traversal.py +150 -7
- codegraph/graph_csr.py +218 -0
- codegraph/incremental.py +150 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/classifier.py +5 -0
- codegraph/indexing/indexer.py +292 -38
- codegraph/indexing/languages/__init__.py +1 -0
- codegraph/indexing/languages/cpp.py +457 -0
- codegraph/indexing/languages/go.py +459 -0
- codegraph/indexing/languages/jvm.py +583 -0
- codegraph/indexing/languages/rust.py +507 -0
- codegraph/indexing/models.py +15 -1
- codegraph/indexing/parser.py +385 -9
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/protocols.py +220 -0
- codegraph/indexing/scanner.py +23 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +207 -39
- codegraph/lsp.py +318 -0
- codegraph/lsp_bridge/__init__.py +16 -0
- codegraph/lsp_bridge/bridge.py +102 -0
- codegraph/lsp_bridge/client.py +207 -0
- codegraph/lsp_bridge/models.py +71 -0
- codegraph/mcp/server.py +502 -29
- codegraph/memory/trajectory.py +282 -0
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/multi_agent/__init__.py +13 -0
- codegraph/multi_agent/coordinator.py +229 -0
- codegraph/multi_agent/models.py +76 -0
- codegraph/optimizer.py +42 -5
- codegraph/polymorphism.py +95 -0
- codegraph/pr_review.py +296 -0
- codegraph/process_lifecycle.py +50 -12
- codegraph/reachability.py +198 -0
- codegraph/reachability_index.py +74 -0
- codegraph/refactor/__init__.py +48 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/patcher.py +348 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/repair.py +233 -0
- codegraph/resolver.py +1357 -161
- codegraph/retrieval_policy.py +22 -1
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +7 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +25 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/otel_receiver.py +208 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/sandbox.py +215 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/security/supply_chain.py +188 -0
- codegraph/security/taint.py +421 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +2 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +2 -0
- codegraph/test_gen.py +437 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +876 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/METADATA +365 -67
- codegraph_engine-3.0.1.dist-info/RECORD +164 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/WHEEL +1 -1
- codegraph_engine-2.3.0.dist-info/RECORD +0 -94
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/top_level.txt +0 -0
codegraph/agent_rules.py
CHANGED
|
@@ -238,7 +238,7 @@ Select the smallest targeted tool that answers the question without over-fetchin
|
|
|
238
238
|
- **Symbol modification blast radius**: `analyze_impact(symbol=..., max_depth=3)`
|
|
239
239
|
- **Git commit diff impact**: `get_git_impact(base="HEAD~1", head="HEAD")`
|
|
240
240
|
- **Architecture & workspace packages**: `get_architecture()`
|
|
241
|
-
- **Multi-file debugging / DI / comprehensive task context**: `get_context(query=..., intent="DEBUG" | "TRACE" | "UNDERSTAND" | "IMPACT" | "ARCHITECTURE", max_tokens=4000, max_files=
|
|
241
|
+
- **Multi-file debugging / DI / comprehensive task context**: `get_context(query=..., intent="DEBUG" | "TRACE" | "UNDERSTAND" | "IMPACT" | "ARCHITECTURE", max_tokens=4000, max_files=25, max_lines=500)` (`task=...` is also supported as an alias)
|
|
242
242
|
|
|
243
243
|
---
|
|
244
244
|
|
|
@@ -338,6 +338,7 @@ Synthesize the final answer or apply the code edit:
|
|
|
338
338
|
| `STATIC_VERIFIED` | Directly proven by static SQL DDL/DML or migration parsing (`CREATE TABLE`, `INSERT INTO`, `MIGRATES_TABLE`) | Verified Fact (`FACT`) | Cite with SQL/migration file and line coordinates. |
|
|
339
339
|
| `FRAMEWORK_VERIFIED` | Proven by deterministic framework rules (`HANDLED_BY`, `MOUNTS`, `INJECTS`, `PROVIDES`, `MAPS_TO_TABLE`, `TASK_HANDLER`, `EVENT_LISTENER`) | Verified Fact (`FACT`) | Cite with framework relationship type; never collapse into `CALLS`. |
|
|
340
340
|
| `DATAFLOW_VERIFIED` | Proven by conservative local/attribute/registry binding (`BINDS_TO`, `RESOLVES_DEPENDENCY`, `REGISTERS`, `DISPATCHES_TO`) | Verified Fact (`FACT`) | Cite with resolved target and binding location. |
|
|
341
|
+
| `ROJO_VERIFIED` | Proven by deterministic Rojo `default.project.json` DataModel-to-filesystem mapping (`REQUIRES_MODULE`) | Verified Fact (`FACT`) | Cite with virtual DataModel path and resolved physical file. |
|
|
341
342
|
| `RUNTIME_OBSERVED` | Observed in an ingested runtime trace (`observation_count`, `first_seen`, `last_seen`, `runtime_generation`) | Observed Runtime Fact | Cite as runtime observation; never promote into static `AST_VERIFIED` proof. |
|
|
342
343
|
| `RUNTIME_UNOBSERVED` | Static edge not observed in the ingested runtime trace sample (`NOT_OBSERVED_AT_RUNTIME`) | Unobserved in Sample | Never claim the path is dead code or impossible at runtime. |
|
|
343
344
|
| `POSSIBLE` | Plausible static candidate (`POSSIBLE_CALLS`, `POSSIBLE_TABLE`, conditional registration, multiple providers) | Candidate Lead (`POSSIBLE`) | Inspect cited lines via `get_file` / `read_file` before making a definitive claim. |
|
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
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Declarative Architecture Invariant Engine for CodeGraph.
|
|
2
|
+
|
|
3
|
+
Enforces architectural rules, layered clean architecture boundaries, and forbidden dependencies:
|
|
4
|
+
- Reads declarative architectural rules from .codegraph/rules.yaml
|
|
5
|
+
- Checks forbidden import edges (e.g. controllers importing database tables directly)
|
|
6
|
+
- Enforces layer isolation (domain layer cannot import infrastructure or UI)
|
|
7
|
+
- Enforces naming conventions and required interface implementations
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RuleViolation:
|
|
21
|
+
"""An architectural invariant violation."""
|
|
22
|
+
|
|
23
|
+
rule_name: str
|
|
24
|
+
source_file: str
|
|
25
|
+
line: int
|
|
26
|
+
target_entity: str
|
|
27
|
+
severity: str # ERROR | WARNING
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
def as_dict(self) -> dict[str, Any]:
|
|
31
|
+
return asdict(self)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ArchitectureComplianceReport:
|
|
36
|
+
"""Consolidated compliance report across all architectural rules."""
|
|
37
|
+
|
|
38
|
+
workspace: str
|
|
39
|
+
status: str # COMPLIANT | VIOLATIONS_DETECTED
|
|
40
|
+
total_rules_checked: int
|
|
41
|
+
violations_count: int
|
|
42
|
+
violations: list[RuleViolation] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def as_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"workspace": self.workspace,
|
|
47
|
+
"status": self.status,
|
|
48
|
+
"total_rules_checked": self.total_rules_checked,
|
|
49
|
+
"violations_count": self.violations_count,
|
|
50
|
+
"violations": [v.as_dict() for v in self.violations],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def evaluate_architecture_rules(
|
|
55
|
+
repository: Path,
|
|
56
|
+
con: sqlite3.Connection,
|
|
57
|
+
rules: list[dict[str, Any]] | None = None,
|
|
58
|
+
) -> ArchitectureComplianceReport:
|
|
59
|
+
"""Evaluate architectural boundaries against the repository graph index."""
|
|
60
|
+
repo = repository.resolve()
|
|
61
|
+
violations: list[RuleViolation] = []
|
|
62
|
+
|
|
63
|
+
# If rules not passed, load from .codegraph/rules.yaml or rules.json
|
|
64
|
+
active_rules: list[dict[str, Any]] = rules or []
|
|
65
|
+
if not active_rules:
|
|
66
|
+
rule_file_json = repo / ".codegraph" / "rules.json"
|
|
67
|
+
if rule_file_json.is_file():
|
|
68
|
+
try:
|
|
69
|
+
active_rules = json.loads(rule_file_json.read_text(encoding="utf-8"))
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Default starter rules if none defined
|
|
74
|
+
if not active_rules:
|
|
75
|
+
active_rules = [
|
|
76
|
+
{
|
|
77
|
+
"name": "prevent-controller-to-db-leakage",
|
|
78
|
+
"source_pattern": "*controller*.*",
|
|
79
|
+
"cannot_import": ["*models*", "*db*", "*entities*"],
|
|
80
|
+
"message": "Controller must not import database models directly; route through services.",
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Query all imports from database
|
|
85
|
+
cur = con.cursor()
|
|
86
|
+
cur.execute("PRAGMA table_info(imports);")
|
|
87
|
+
imp_cols = {col[1] for col in cur.fetchall()}
|
|
88
|
+
src_col = "source_path" if "source_path" in imp_cols else "source_file"
|
|
89
|
+
cur.execute(f"SELECT {src_col}, module, name, line FROM imports;")
|
|
90
|
+
all_imports = cur.fetchall()
|
|
91
|
+
|
|
92
|
+
for r in active_rules:
|
|
93
|
+
name = r.get("name", "unnamed-rule")
|
|
94
|
+
src_pat = r.get("source_pattern", "*")
|
|
95
|
+
cannot_import = r.get("cannot_import", [])
|
|
96
|
+
msg = r.get("message", f"Rule '{name}' violated.")
|
|
97
|
+
|
|
98
|
+
for imp_source, imp_module, imp_name, imp_line in all_imports:
|
|
99
|
+
# Check if file matches source pattern
|
|
100
|
+
if fnmatch.fnmatch(imp_source, src_pat) or fnmatch.fnmatch(Path(imp_source).name, src_pat):
|
|
101
|
+
# Check forbidden targets
|
|
102
|
+
full_import_target = f"{imp_module}.{imp_name}" if imp_name else imp_module
|
|
103
|
+
for forbidden in cannot_import:
|
|
104
|
+
if fnmatch.fnmatch(full_import_target, forbidden) or fnmatch.fnmatch(imp_module, forbidden):
|
|
105
|
+
violations.append(
|
|
106
|
+
RuleViolation(
|
|
107
|
+
rule_name=name,
|
|
108
|
+
source_file=imp_source,
|
|
109
|
+
line=imp_line,
|
|
110
|
+
target_entity=full_import_target,
|
|
111
|
+
severity="ERROR",
|
|
112
|
+
message=msg,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
status = "COMPLIANT" if not violations else "VIOLATIONS_DETECTED"
|
|
117
|
+
return ArchitectureComplianceReport(
|
|
118
|
+
workspace=str(repo),
|
|
119
|
+
status=status,
|
|
120
|
+
total_rules_checked=len(active_rules),
|
|
121
|
+
violations_count=len(violations),
|
|
122
|
+
violations=violations,
|
|
123
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Asynchronous message queue and cross-service distributed task intelligence.
|
|
2
|
+
|
|
3
|
+
Provides static detection, queue linking, and OpenTelemetry distributed tracing
|
|
4
|
+
reconciliation for Kafka, Celery, AWS SQS, RabbitMQ, Redis Pub/Sub, and Streams.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from codegraph.async_queue.models import (
|
|
9
|
+
AsyncConsumerRecord,
|
|
10
|
+
AsyncMessagingSystem,
|
|
11
|
+
AsyncProducerRecord,
|
|
12
|
+
AsyncQueueEntity,
|
|
13
|
+
AsyncQueueEvidenceClass,
|
|
14
|
+
AsyncQueueLink,
|
|
15
|
+
)
|
|
16
|
+
from codegraph.async_queue.otel_models import (
|
|
17
|
+
OTelEvidenceStatus,
|
|
18
|
+
OTelSpanRecord,
|
|
19
|
+
TraceReconciliationResult,
|
|
20
|
+
)
|
|
21
|
+
from codegraph.async_queue.otel_parser import parse_otel_spans
|
|
22
|
+
from codegraph.async_queue.reconciler import OTelTraceReconciler, ensure_otel_tables
|
|
23
|
+
from codegraph.async_queue.scanner import AsyncQueueScanner, ensure_async_tables
|
|
24
|
+
from codegraph.async_queue.traversal import (
|
|
25
|
+
find_queue_consumers,
|
|
26
|
+
find_queue_producers,
|
|
27
|
+
list_async_queues,
|
|
28
|
+
trace_async_flow,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AsyncConsumerRecord",
|
|
33
|
+
"AsyncMessagingSystem",
|
|
34
|
+
"AsyncProducerRecord",
|
|
35
|
+
"AsyncQueueEntity",
|
|
36
|
+
"AsyncQueueEvidenceClass",
|
|
37
|
+
"AsyncQueueLink",
|
|
38
|
+
"AsyncQueueScanner",
|
|
39
|
+
"OTelEvidenceStatus",
|
|
40
|
+
"OTelSpanRecord",
|
|
41
|
+
"OTelTraceReconciler",
|
|
42
|
+
"TraceReconciliationResult",
|
|
43
|
+
"ensure_async_tables",
|
|
44
|
+
"ensure_otel_tables",
|
|
45
|
+
"find_queue_consumers",
|
|
46
|
+
"find_queue_producers",
|
|
47
|
+
"list_async_queues",
|
|
48
|
+
"parse_otel_spans",
|
|
49
|
+
"trace_async_flow",
|
|
50
|
+
]
|