codegraph-engine 2.2.0__py3-none-any.whl → 2.3.0__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/agent_capabilities.py +245 -4
- codegraph/branch_comparison.py +197 -0
- codegraph/change_impact.py +337 -0
- codegraph/cli.py +238 -0
- codegraph/context_freshness.py +349 -0
- codegraph/git_state.py +543 -0
- codegraph/indexing/parser.py +55 -21
- codegraph/mcp/server.py +69 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/structural_diff.py +540 -0
- codegraph/symbol_history.py +275 -0
- codegraph_engine-2.3.0.dist-info/METADATA +733 -0
- {codegraph_engine-2.2.0.dist-info → codegraph_engine-2.3.0.dist-info}/RECORD +18 -11
- codegraph_engine-2.2.0.dist-info/METADATA +0 -642
- {codegraph_engine-2.2.0.dist-info → codegraph_engine-2.3.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.2.0.dist-info → codegraph_engine-2.3.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.2.0.dist-info → codegraph_engine-2.3.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.2.0.dist-info → codegraph_engine-2.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Deep Change Impact Analysis Engine.
|
|
2
|
+
|
|
3
|
+
Extends existing Git impact analysis by tracing complete downstream chains:
|
|
4
|
+
changed symbol
|
|
5
|
+
↓
|
|
6
|
+
callers (direct & transitive)
|
|
7
|
+
↓
|
|
8
|
+
callees
|
|
9
|
+
↓
|
|
10
|
+
routes & endpoints
|
|
11
|
+
↓
|
|
12
|
+
tests & test suites
|
|
13
|
+
↓
|
|
14
|
+
DB readers & writers
|
|
15
|
+
↓
|
|
16
|
+
downstream package dependencies
|
|
17
|
+
|
|
18
|
+
Every item carries explicit evidence, relationship type, source location,
|
|
19
|
+
and confidence/epistemic state (FACT, POSSIBLE, UNKNOWN).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sqlite3
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from codegraph.graph.traversal import find_callees, find_callers, find_related_tests
|
|
29
|
+
from codegraph.monorepo import detect_workspace
|
|
30
|
+
from codegraph.structural_diff import compare_revisions
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ImpactedEntity:
|
|
35
|
+
name: str
|
|
36
|
+
kind: str # "CALLER" | "CALLEE" | "ROUTE" | "TEST" | "DB_WRITER" | "DB_READER" | "PACKAGE"
|
|
37
|
+
file: str
|
|
38
|
+
line: int | None
|
|
39
|
+
relationship: str # "CALLS" | "HANDLED_BY" | "TESTS" | "WRITES_TABLE" | "READS_TABLE" | "DEPENDS_ON"
|
|
40
|
+
depth: int
|
|
41
|
+
confidence: str # "FACT" | "POSSIBLE" | "UNKNOWN"
|
|
42
|
+
reason: str
|
|
43
|
+
evidence: str
|
|
44
|
+
|
|
45
|
+
def as_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"name": self.name,
|
|
48
|
+
"kind": self.kind,
|
|
49
|
+
"file": self.file,
|
|
50
|
+
"line": self.line,
|
|
51
|
+
"relationship": self.relationship,
|
|
52
|
+
"depth": self.depth,
|
|
53
|
+
"confidence": self.confidence,
|
|
54
|
+
"reason": self.reason,
|
|
55
|
+
"evidence": self.evidence,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class DeepImpactReport:
|
|
61
|
+
base_ref: str
|
|
62
|
+
head_ref: str
|
|
63
|
+
changed_files: list[str] = field(default_factory=list)
|
|
64
|
+
changed_symbols: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
direct_callers: list[ImpactedEntity] = field(default_factory=list)
|
|
66
|
+
transitive_callers: list[ImpactedEntity] = field(default_factory=list)
|
|
67
|
+
affected_callees: list[ImpactedEntity] = field(default_factory=list)
|
|
68
|
+
affected_routes: list[ImpactedEntity] = field(default_factory=list)
|
|
69
|
+
affected_tests: list[ImpactedEntity] = field(default_factory=list)
|
|
70
|
+
db_readers: list[ImpactedEntity] = field(default_factory=list)
|
|
71
|
+
db_writers: list[ImpactedEntity] = field(default_factory=list)
|
|
72
|
+
affected_packages: list[str] = field(default_factory=list)
|
|
73
|
+
total_impacted_count: int = 0
|
|
74
|
+
blast_radius_score: float = 0.0 # 0.0 - 100.0 scale
|
|
75
|
+
|
|
76
|
+
def as_dict(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"base_ref": self.base_ref,
|
|
79
|
+
"head_ref": self.head_ref,
|
|
80
|
+
"changed_files": self.changed_files,
|
|
81
|
+
"changed_symbols": self.changed_symbols,
|
|
82
|
+
"direct_callers": [c.as_dict() for c in self.direct_callers],
|
|
83
|
+
"transitive_callers": [c.as_dict() for c in self.transitive_callers],
|
|
84
|
+
"affected_callees": [c.as_dict() for c in self.affected_callees],
|
|
85
|
+
"affected_routes": [r.as_dict() for r in self.affected_routes],
|
|
86
|
+
"affected_tests": [t.as_dict() for t in self.affected_tests],
|
|
87
|
+
"db_readers": [d.as_dict() for d in self.db_readers],
|
|
88
|
+
"db_writers": [d.as_dict() for d in self.db_writers],
|
|
89
|
+
"affected_packages": self.affected_packages,
|
|
90
|
+
"total_impacted_count": self.total_impacted_count,
|
|
91
|
+
"blast_radius_score": self.blast_radius_score,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_deep_change_impact(
|
|
96
|
+
repository: Path,
|
|
97
|
+
con: sqlite3.Connection,
|
|
98
|
+
base: str = "HEAD~1",
|
|
99
|
+
head: str = "HEAD",
|
|
100
|
+
max_depth: int = 2,
|
|
101
|
+
max_results: int = 50,
|
|
102
|
+
) -> DeepImpactReport:
|
|
103
|
+
"""Analyze full blast radius of git revision changes across AST, routes, tests, DB, and packages."""
|
|
104
|
+
# Fast path: identical refs have zero changes
|
|
105
|
+
if base == head and head != "WORKING_TREE":
|
|
106
|
+
return DeepImpactReport(base_ref=base, head_ref=head)
|
|
107
|
+
|
|
108
|
+
diff_report = compare_revisions(repository, base=base, head=head, con=con)
|
|
109
|
+
|
|
110
|
+
changed_symbols_list: list[dict[str, Any]] = [
|
|
111
|
+
s.as_dict() for s in (diff_report.changed_symbols + diff_report.added_symbols + diff_report.removed_symbols)
|
|
112
|
+
]
|
|
113
|
+
changed_files = diff_report.modified_files + diff_report.added_files + diff_report.deleted_files
|
|
114
|
+
|
|
115
|
+
if not changed_symbols_list and not changed_files:
|
|
116
|
+
return DeepImpactReport(base_ref=base, head_ref=head)
|
|
117
|
+
|
|
118
|
+
direct_callers: list[ImpactedEntity] = []
|
|
119
|
+
transitive_callers: list[ImpactedEntity] = []
|
|
120
|
+
affected_callees: list[ImpactedEntity] = []
|
|
121
|
+
affected_routes: list[ImpactedEntity] = []
|
|
122
|
+
affected_tests: list[ImpactedEntity] = []
|
|
123
|
+
db_readers: list[ImpactedEntity] = []
|
|
124
|
+
db_writers: list[ImpactedEntity] = []
|
|
125
|
+
|
|
126
|
+
seen_keys: set[str] = set()
|
|
127
|
+
processed_symbols: set[str] = set()
|
|
128
|
+
processed_transitive: set[str] = set()
|
|
129
|
+
|
|
130
|
+
for s_info in changed_symbols_list[:max_results]:
|
|
131
|
+
s_name = s_info["name"]
|
|
132
|
+
s_path = s_info["path"]
|
|
133
|
+
|
|
134
|
+
if not s_name or s_name in processed_symbols:
|
|
135
|
+
continue
|
|
136
|
+
processed_symbols.add(s_name)
|
|
137
|
+
|
|
138
|
+
# 1. Direct callers (1-hop)
|
|
139
|
+
raw_callers = find_callers(con, s_name, max_results=max_results)
|
|
140
|
+
for c in raw_callers:
|
|
141
|
+
c_name = c.get("caller") or c.get("symbol") or ""
|
|
142
|
+
c_file = c.get("file") or ""
|
|
143
|
+
c_line = c.get("line")
|
|
144
|
+
c_key = f"CALLER:1:{c_file}:{c_name}"
|
|
145
|
+
if c_key not in seen_keys:
|
|
146
|
+
seen_keys.add(c_key)
|
|
147
|
+
direct_callers.append(
|
|
148
|
+
ImpactedEntity(
|
|
149
|
+
name=c_name,
|
|
150
|
+
kind="CALLER",
|
|
151
|
+
file=c_file,
|
|
152
|
+
line=c_line,
|
|
153
|
+
relationship="CALLS",
|
|
154
|
+
depth=1,
|
|
155
|
+
confidence="FACT",
|
|
156
|
+
reason=f"Invokes modified symbol {s_name}",
|
|
157
|
+
evidence=f"{c_file}:{c_line} -> {s_name}",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 2. Transitive callers (2-hop) if max_depth >= 2
|
|
162
|
+
if max_depth >= 2 and c_name and c_name not in processed_transitive:
|
|
163
|
+
processed_transitive.add(c_name)
|
|
164
|
+
raw_trans = find_callers(con, c_name, max_results=10)
|
|
165
|
+
for tc in raw_trans:
|
|
166
|
+
tc_name = tc.get("caller") or tc.get("symbol") or ""
|
|
167
|
+
tc_file = tc.get("file") or ""
|
|
168
|
+
tc_line = tc.get("line")
|
|
169
|
+
tc_key = f"CALLER:2:{tc_file}:{tc_name}"
|
|
170
|
+
if tc_key not in seen_keys and tc_name != s_name:
|
|
171
|
+
seen_keys.add(tc_key)
|
|
172
|
+
transitive_callers.append(
|
|
173
|
+
ImpactedEntity(
|
|
174
|
+
name=tc_name,
|
|
175
|
+
kind="CALLER",
|
|
176
|
+
file=tc_file,
|
|
177
|
+
line=tc_line,
|
|
178
|
+
relationship="CALLS",
|
|
179
|
+
depth=2,
|
|
180
|
+
confidence="FACT",
|
|
181
|
+
reason=f"Transitively invokes {s_name} via {c_name}",
|
|
182
|
+
evidence=f"{tc_file}:{tc_line} -> {c_name} -> {s_name}",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# 3. Direct callees
|
|
187
|
+
raw_callees = find_callees(con, s_name, max_results=max_results)
|
|
188
|
+
for ce in raw_callees:
|
|
189
|
+
ce_name = ce.get("callee") or ce.get("symbol") or ""
|
|
190
|
+
ce_file = ce.get("file") or ""
|
|
191
|
+
ce_line = ce.get("line")
|
|
192
|
+
ce_key = f"CALLEE:{ce_file}:{ce_name}"
|
|
193
|
+
if ce_key not in seen_keys:
|
|
194
|
+
seen_keys.add(ce_key)
|
|
195
|
+
affected_callees.append(
|
|
196
|
+
ImpactedEntity(
|
|
197
|
+
name=ce_name,
|
|
198
|
+
kind="CALLEE",
|
|
199
|
+
file=ce_file,
|
|
200
|
+
line=ce_line,
|
|
201
|
+
relationship="CALLED_BY",
|
|
202
|
+
depth=1,
|
|
203
|
+
confidence="FACT",
|
|
204
|
+
reason=f"Called by modified symbol {s_name}",
|
|
205
|
+
evidence=f"{s_path} -> {ce_name}",
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# 4. Route impact: does this symbol handle an endpoint or get called by a route?
|
|
210
|
+
try:
|
|
211
|
+
route_rows = con.execute(
|
|
212
|
+
"SELECT path, method, handler_name, file FROM routes WHERE handler_name=? OR symbol=?",
|
|
213
|
+
(s_name, s_name),
|
|
214
|
+
).fetchall()
|
|
215
|
+
for rr in route_rows:
|
|
216
|
+
r_key = f"ROUTE:{rr[0]}:{rr[1]}"
|
|
217
|
+
if r_key not in seen_keys:
|
|
218
|
+
seen_keys.add(r_key)
|
|
219
|
+
affected_routes.append(
|
|
220
|
+
ImpactedEntity(
|
|
221
|
+
name=f"{rr[1]} {rr[0]}",
|
|
222
|
+
kind="ROUTE",
|
|
223
|
+
file=rr[3],
|
|
224
|
+
line=None,
|
|
225
|
+
relationship="ROUTES_TO",
|
|
226
|
+
depth=1,
|
|
227
|
+
confidence="FACT",
|
|
228
|
+
reason=f"Endpoint handled by changed symbol {s_name}",
|
|
229
|
+
evidence=f"Route {rr[1]} {rr[0]} -> {s_name}",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
# 5. Tests targeting this symbol
|
|
236
|
+
tests = find_related_tests(con, s_name, max_results=20)
|
|
237
|
+
for t in tests:
|
|
238
|
+
t_name = str(t.get("symbol") or t.get("test") or "")
|
|
239
|
+
t_file = str(t.get("file") or "")
|
|
240
|
+
t_line_raw = t.get("line")
|
|
241
|
+
t_line = int(t_line_raw) if isinstance(t_line_raw, (int, str)) and str(t_line_raw).isdigit() else None
|
|
242
|
+
t_key = f"TEST:{t_file}:{t_name}"
|
|
243
|
+
if t_key not in seen_keys:
|
|
244
|
+
seen_keys.add(t_key)
|
|
245
|
+
affected_tests.append(
|
|
246
|
+
ImpactedEntity(
|
|
247
|
+
name=t_name,
|
|
248
|
+
kind="TEST",
|
|
249
|
+
file=t_file,
|
|
250
|
+
line=t_line,
|
|
251
|
+
relationship="TESTS",
|
|
252
|
+
depth=1,
|
|
253
|
+
confidence="FACT",
|
|
254
|
+
reason=f"Test targets modified symbol {s_name}",
|
|
255
|
+
evidence=f"{t_file}:{t_line} tests {s_name}",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# 6. Database readers and writers
|
|
260
|
+
try:
|
|
261
|
+
db_rows = con.execute(
|
|
262
|
+
"SELECT table_name, operation, file, line FROM database_queries WHERE symbol=? OR caller_symbol=?",
|
|
263
|
+
(s_name, s_name),
|
|
264
|
+
).fetchall()
|
|
265
|
+
for dr in db_rows:
|
|
266
|
+
tbl, op, f_path, l_num = dr[0], dr[1], dr[2], dr[3]
|
|
267
|
+
is_mutating = op.upper() in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER")
|
|
268
|
+
kind = "DB_WRITER" if is_mutating else "DB_READER"
|
|
269
|
+
rel = "WRITES_TABLE" if is_mutating else "READS_TABLE"
|
|
270
|
+
db_key = f"{kind}:{tbl}:{op}:{s_name}"
|
|
271
|
+
if db_key not in seen_keys:
|
|
272
|
+
seen_keys.add(db_key)
|
|
273
|
+
entity = ImpactedEntity(
|
|
274
|
+
name=f"{op} {tbl}",
|
|
275
|
+
kind=kind,
|
|
276
|
+
file=f_path or s_path,
|
|
277
|
+
line=l_num,
|
|
278
|
+
relationship=rel,
|
|
279
|
+
depth=1,
|
|
280
|
+
confidence="FACT",
|
|
281
|
+
reason=f"{s_name} executes {op} on {tbl}",
|
|
282
|
+
evidence=f"{s_name} -> {op} {tbl} ({f_path}:{l_num})",
|
|
283
|
+
)
|
|
284
|
+
if is_mutating:
|
|
285
|
+
db_writers.append(entity)
|
|
286
|
+
else:
|
|
287
|
+
db_readers.append(entity)
|
|
288
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
# 7. Monorepo package impact
|
|
292
|
+
ws = detect_workspace(repository, con=con)
|
|
293
|
+
affected_packages: set[str] = set()
|
|
294
|
+
for p in changed_files:
|
|
295
|
+
pkg = ws.get_package_for_file(p)
|
|
296
|
+
if pkg:
|
|
297
|
+
affected_packages.add(pkg.package_id)
|
|
298
|
+
for dep in ws.get_dependent_packages(pkg.package_id):
|
|
299
|
+
affected_packages.add(dep.package_id)
|
|
300
|
+
|
|
301
|
+
total_count = (
|
|
302
|
+
len(direct_callers)
|
|
303
|
+
+ len(transitive_callers)
|
|
304
|
+
+ len(affected_callees)
|
|
305
|
+
+ len(affected_routes)
|
|
306
|
+
+ len(affected_tests)
|
|
307
|
+
+ len(db_readers)
|
|
308
|
+
+ len(db_writers)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Blast radius score calculation: 0 - 100
|
|
312
|
+
# Factors: number of direct callers, routes impacted, db writers impacted, package boundaries crossed
|
|
313
|
+
score = min(
|
|
314
|
+
100.0,
|
|
315
|
+
(len(direct_callers) * 3.5)
|
|
316
|
+
+ (len(transitive_callers) * 1.5)
|
|
317
|
+
+ (len(affected_routes) * 8.0)
|
|
318
|
+
+ (len(db_writers) * 6.0)
|
|
319
|
+
+ (len(affected_packages) * 5.0),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return DeepImpactReport(
|
|
323
|
+
base_ref=base,
|
|
324
|
+
head_ref=head,
|
|
325
|
+
changed_files=changed_files,
|
|
326
|
+
changed_symbols=changed_symbols_list,
|
|
327
|
+
direct_callers=direct_callers[:max_results],
|
|
328
|
+
transitive_callers=transitive_callers[:max_results],
|
|
329
|
+
affected_callees=affected_callees[:max_results],
|
|
330
|
+
affected_routes=affected_routes,
|
|
331
|
+
affected_tests=affected_tests[:max_results],
|
|
332
|
+
db_readers=db_readers,
|
|
333
|
+
db_writers=db_writers,
|
|
334
|
+
affected_packages=sorted(affected_packages),
|
|
335
|
+
total_impacted_count=total_count,
|
|
336
|
+
blast_radius_score=round(score, 1),
|
|
337
|
+
)
|
codegraph/cli.py
CHANGED
|
@@ -1313,5 +1313,243 @@ def uninit(
|
|
|
1313
1313
|
cli_echo(report.format_human())
|
|
1314
1314
|
|
|
1315
1315
|
|
|
1316
|
+
@app.command("git-state")
|
|
1317
|
+
def git_state_cmd(
|
|
1318
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
1319
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1320
|
+
sync: Annotated[bool, typer.Option("--sync", help="Incrementally synchronize affected graph state if dirty/stale")] = False,
|
|
1321
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1322
|
+
) -> None:
|
|
1323
|
+
"""Track current Git branch, HEAD, indexed commit, working-tree modifications, and freshness states (CLEAN, DIRTY, STALE, REINDEXING, ERROR)."""
|
|
1324
|
+
from codegraph.git_state import get_git_state, incremental_git_sync
|
|
1325
|
+
|
|
1326
|
+
repo = _resolve_repo(path, repository)
|
|
1327
|
+
indexer = _indexer(repo)
|
|
1328
|
+
|
|
1329
|
+
if sync:
|
|
1330
|
+
with indexer.session() as con:
|
|
1331
|
+
res = incremental_git_sync(repo, con=con)
|
|
1332
|
+
if json_output:
|
|
1333
|
+
cli_echo(json.dumps(res.as_dict(), indent=2), json_mode=True)
|
|
1334
|
+
else:
|
|
1335
|
+
cli_echo(f"Incremental Sync: {res.status.upper()} ({res.duration_ms:.1f}ms) - {res.detail}")
|
|
1336
|
+
return
|
|
1337
|
+
|
|
1338
|
+
with indexer.session() as con:
|
|
1339
|
+
rep = get_git_state(repo, con=con)
|
|
1340
|
+
|
|
1341
|
+
if json_output:
|
|
1342
|
+
cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
|
|
1343
|
+
else:
|
|
1344
|
+
cli_echo(f"Git Repository: {repo}")
|
|
1345
|
+
cli_echo(f"Branch: {rep.branch or 'N/A'}")
|
|
1346
|
+
cli_echo(f"Current HEAD: {rep.current_head[:8] if rep.current_head else 'N/A'}")
|
|
1347
|
+
cli_echo(f"Indexed HEAD: {rep.indexed_head[:8] if rep.indexed_head else 'Not indexed'}")
|
|
1348
|
+
cli_echo(f"Freshness: {rep.freshness.value}")
|
|
1349
|
+
cli_echo(f"Status Detail: {rep.detail}")
|
|
1350
|
+
if rep.working_tree.is_dirty:
|
|
1351
|
+
cli_echo("\nWorking Tree Changes:")
|
|
1352
|
+
if rep.working_tree.modified_files:
|
|
1353
|
+
cli_echo(f" Modified: {len(rep.working_tree.modified_files)} files ({', '.join(rep.working_tree.modified_files[:3])}...)")
|
|
1354
|
+
if rep.working_tree.staged_files:
|
|
1355
|
+
cli_echo(f" Staged: {len(rep.working_tree.staged_files)} files ({', '.join(rep.working_tree.staged_files[:3])}...)")
|
|
1356
|
+
if rep.working_tree.untracked_files:
|
|
1357
|
+
cli_echo(f" Untracked: {len(rep.working_tree.untracked_files)} files ({', '.join(rep.working_tree.untracked_files[:3])}...)")
|
|
1358
|
+
if rep.working_tree.deleted_files:
|
|
1359
|
+
cli_echo(f" Deleted: {len(rep.working_tree.deleted_files)} files")
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
@app.command("git-diff")
|
|
1363
|
+
def git_diff_cmd(
|
|
1364
|
+
base: Annotated[str, typer.Argument(help="Base Git ref or commit (default: HEAD~1)")] = "HEAD~1",
|
|
1365
|
+
head: Annotated[str, typer.Argument(help="Head Git ref, commit, or branch (default: HEAD)")] = "HEAD",
|
|
1366
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1367
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1368
|
+
branch_comparison: Annotated[bool, typer.Option("--branch", "-b", help="Perform hierarchical branch architecture comparison")] = False,
|
|
1369
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1370
|
+
) -> None:
|
|
1371
|
+
"""Compute structural AST-level difference between Git revisions, commits, or branches."""
|
|
1372
|
+
repo = _resolve_repo(path, repository)
|
|
1373
|
+
indexer = _indexer(repo)
|
|
1374
|
+
|
|
1375
|
+
if branch_comparison:
|
|
1376
|
+
from codegraph.branch_comparison import compare_branch_architecture
|
|
1377
|
+
with indexer.session() as con:
|
|
1378
|
+
b_rep = compare_branch_architecture(repo, base_branch=base, head_branch=head, con=con)
|
|
1379
|
+
if json_output:
|
|
1380
|
+
cli_echo(json.dumps(b_rep.as_dict(), indent=2), json_mode=True)
|
|
1381
|
+
else:
|
|
1382
|
+
cli_echo(f"Branch Architecture Comparison: {base} -> {head}")
|
|
1383
|
+
cli_echo(f"Packages: {b_rep.total_packages} | Symbols: {b_rep.total_symbols} (~{b_rep.estimated_tokens} tokens)")
|
|
1384
|
+
for pkg in b_rep.packages:
|
|
1385
|
+
cli_echo(f"\nPackage: {pkg.package_id}")
|
|
1386
|
+
for mod in pkg.modules:
|
|
1387
|
+
cli_echo(f" Module: {mod.module} ({mod.file_path})")
|
|
1388
|
+
for s in mod.symbols:
|
|
1389
|
+
cli_echo(f" [{s.change_type}] {s.kind} {s.name}")
|
|
1390
|
+
for rel in s.relationships:
|
|
1391
|
+
cli_echo(f" -> {rel}")
|
|
1392
|
+
return
|
|
1393
|
+
|
|
1394
|
+
from codegraph.structural_diff import compare_revisions
|
|
1395
|
+
with indexer.session() as con:
|
|
1396
|
+
s_rep = compare_revisions(repo, base=base, head=head, con=con)
|
|
1397
|
+
|
|
1398
|
+
if json_output:
|
|
1399
|
+
cli_echo(json.dumps(s_rep.as_dict(), indent=2), json_mode=True)
|
|
1400
|
+
else:
|
|
1401
|
+
cli_echo(f"Structural Diff: {base} -> {head}")
|
|
1402
|
+
cli_echo(f"Files Changed: {s_rep.total_file_changes} (Added: {len(s_rep.added_files)}, Modified: {len(s_rep.modified_files)}, Deleted: {len(s_rep.deleted_files)}, Renamed: {len(s_rep.renamed_files)})")
|
|
1403
|
+
cli_echo(f"Symbols Changed: {s_rep.total_symbol_changes} (Added: {len(s_rep.added_symbols)}, Modified: {len(s_rep.changed_symbols)}, Removed: {len(s_rep.removed_symbols)})")
|
|
1404
|
+
if s_rep.changed_routes:
|
|
1405
|
+
cli_echo(f"Changed Routes: {len(s_rep.changed_routes)}")
|
|
1406
|
+
for r in s_rep.changed_routes:
|
|
1407
|
+
cli_echo(f" [{r.change_type}] {r.method} {r.path} -> {r.handler}")
|
|
1408
|
+
if s_rep.affected_tests:
|
|
1409
|
+
cli_echo(f"Affected Tests: {len(s_rep.affected_tests)} ({', '.join(s_rep.affected_tests[:5])})")
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
@app.command("git-impact")
|
|
1413
|
+
def git_impact_cmd(
|
|
1414
|
+
base: Annotated[str, typer.Argument(help="Base Git ref (default: HEAD~1)")] = "HEAD~1",
|
|
1415
|
+
head: Annotated[str, typer.Argument(help="Head Git ref (default: HEAD)")] = "HEAD",
|
|
1416
|
+
depth: Annotated[int, typer.Option("--depth", "-d", help="Max caller traversal depth")] = 2,
|
|
1417
|
+
max_results: Annotated[int, typer.Option("--max-results", "-m", help="Max items per category")] = 30,
|
|
1418
|
+
tree: Annotated[bool, typer.Option("--tree", "-t", help="Display visual impact tree")] = False,
|
|
1419
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1420
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1421
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1422
|
+
) -> None:
|
|
1423
|
+
"""Compute deep downstream change impact across callers, callees, framework routes, tests, and DB."""
|
|
1424
|
+
from codegraph.change_impact import get_deep_change_impact
|
|
1425
|
+
|
|
1426
|
+
repo = _resolve_repo(path, repository)
|
|
1427
|
+
indexer = _indexer(repo)
|
|
1428
|
+
|
|
1429
|
+
with indexer.session() as con:
|
|
1430
|
+
imp = get_deep_change_impact(repo, con, base=base, head=head, max_depth=depth, max_results=max_results)
|
|
1431
|
+
|
|
1432
|
+
if json_output:
|
|
1433
|
+
cli_echo(json.dumps(imp.as_dict(), indent=2), json_mode=True)
|
|
1434
|
+
else:
|
|
1435
|
+
cli_echo(f"Deep Change Impact Analysis: {base} -> {head}")
|
|
1436
|
+
cli_echo(f"Blast Radius Score: {imp.blast_radius_score} / 100")
|
|
1437
|
+
cli_echo(f"Total Impacted Items: {imp.total_impacted_count}")
|
|
1438
|
+
cli_echo(f"Direct Callers: {len(imp.direct_callers)}")
|
|
1439
|
+
cli_echo(f"Transitive Callers: {len(imp.transitive_callers)}")
|
|
1440
|
+
cli_echo(f"Affected Routes: {len(imp.affected_routes)}")
|
|
1441
|
+
cli_echo(f"Covering Tests: {len(imp.affected_tests)}")
|
|
1442
|
+
cli_echo(f"DB Writers: {len(imp.db_writers)}")
|
|
1443
|
+
cli_echo(f"Monorepo Packages: {len(imp.affected_packages)}")
|
|
1444
|
+
|
|
1445
|
+
if tree or imp.total_impacted_count > 0:
|
|
1446
|
+
cli_echo("\nImpact Tree:")
|
|
1447
|
+
for s in imp.changed_symbols[:10]:
|
|
1448
|
+
cli_echo(f" └── 📄 [{s.get('change_type', 'MODIFIED')}] {s.get('path', '')}::{s.get('name', '')}")
|
|
1449
|
+
for c in imp.direct_callers[:8]:
|
|
1450
|
+
cli_echo(f" ├── 📞 CALLER: {c.file}::{c.name} (depth 1)")
|
|
1451
|
+
for r in imp.affected_routes[:5]:
|
|
1452
|
+
cli_echo(f" ├── 🌐 ROUTE: {r.name} -> {r.file}")
|
|
1453
|
+
for t in imp.affected_tests[:5]:
|
|
1454
|
+
cli_echo(f" ├── 🧪 TEST: {t.file}::{t.name}")
|
|
1455
|
+
for db in imp.db_writers[:5]:
|
|
1456
|
+
cli_echo(f" └── 💾 DB: {db.relationship} {db.name} in {db.file}")
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
@app.command("git-conflicts")
|
|
1460
|
+
def git_conflicts_cmd(
|
|
1461
|
+
base: Annotated[str, typer.Argument(help="Base Git branch (default: main)")] = "main",
|
|
1462
|
+
head: Annotated[str, typer.Argument(help="Head Git branch (default: HEAD)")] = "HEAD",
|
|
1463
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1464
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1465
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1466
|
+
) -> None:
|
|
1467
|
+
"""Detect semantic and contract merge conflicts between branches (calling deleted symbols, broken call signatures, or missing route handlers)."""
|
|
1468
|
+
from codegraph.semantic_conflicts import detect_semantic_conflicts
|
|
1469
|
+
|
|
1470
|
+
repo = _resolve_repo(path, repository)
|
|
1471
|
+
indexer = _indexer(repo)
|
|
1472
|
+
|
|
1473
|
+
with indexer.session() as con:
|
|
1474
|
+
rep = detect_semantic_conflicts(repo, base_branch=base, head_branch=head, con=con)
|
|
1475
|
+
|
|
1476
|
+
if json_output:
|
|
1477
|
+
cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
|
|
1478
|
+
else:
|
|
1479
|
+
cli_echo(f"Semantic Conflict Analysis: {base} <-> {head}")
|
|
1480
|
+
cli_echo(f"Status: {'⚠️ CONFLICTS DETECTED' if rep.has_conflicts else '✅ CLEAN (0 conflicts)'}")
|
|
1481
|
+
cli_echo(f"Total Conflicts: {rep.total_conflicts}")
|
|
1482
|
+
cli_echo(f"Summary: {rep.summary}")
|
|
1483
|
+
if rep.conflicts:
|
|
1484
|
+
cli_echo("\nDetected Semantic Conflicts:")
|
|
1485
|
+
for c in rep.conflicts:
|
|
1486
|
+
cli_echo(f"\n [{c.severity.value}] {c.conflict_type.value}: {c.symbol}")
|
|
1487
|
+
cli_echo(f" Description: {c.description}")
|
|
1488
|
+
cli_echo(f" Base ({base}): {c.base_file} ({c.base_evidence})")
|
|
1489
|
+
cli_echo(f" Head ({head}): {c.head_file} ({c.head_evidence})")
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
@app.command("check-freshness")
|
|
1493
|
+
def check_freshness_cli_cmd(
|
|
1494
|
+
task: Annotated[str, typer.Argument(help="Task prompt to verify context freshness for")] = "",
|
|
1495
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1496
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1497
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1498
|
+
) -> None:
|
|
1499
|
+
"""Validate whether compiled context remains VALID, PARTIALLY_STALE, or STALE against repository changes."""
|
|
1500
|
+
from codegraph.context import get_context
|
|
1501
|
+
from codegraph.context_freshness import check_context_freshness
|
|
1502
|
+
|
|
1503
|
+
repo = _resolve_repo(path, repository)
|
|
1504
|
+
indexer = _indexer(repo)
|
|
1505
|
+
|
|
1506
|
+
with indexer.session() as con:
|
|
1507
|
+
# Generate baseline context if task provided
|
|
1508
|
+
ctx = get_context(con, repo, task or "general repository overview", mode="FAST")
|
|
1509
|
+
rep = check_context_freshness(ctx, repo, con=con)
|
|
1510
|
+
|
|
1511
|
+
if json_output:
|
|
1512
|
+
cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
|
|
1513
|
+
else:
|
|
1514
|
+
cli_echo(f"Context Freshness Status: {rep.status.value}")
|
|
1515
|
+
cli_echo(f"Reason: {rep.reason}")
|
|
1516
|
+
cli_echo(f"Recommended Action: {rep.recommended_action}")
|
|
1517
|
+
if rep.changed_relevant_files:
|
|
1518
|
+
cli_echo(f"Changed Relevant Files: {', '.join(rep.changed_relevant_files)}")
|
|
1519
|
+
if rep.unrelated_changed_files:
|
|
1520
|
+
cli_echo(f"Unrelated Changed Files: {len(rep.unrelated_changed_files)} file(s)")
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
@app.command("symbol-history")
|
|
1524
|
+
def symbol_history_cli_cmd(
|
|
1525
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name to trace across Git history")],
|
|
1526
|
+
file_path: Annotated[str | None, typer.Option("--file", "-f", help="File path where symbol is located")] = None,
|
|
1527
|
+
max_commits: Annotated[int, typer.Option("--max-commits", "-m", help="Max commits to inspect")] = 15,
|
|
1528
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1529
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1530
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1531
|
+
) -> None:
|
|
1532
|
+
"""Trace deterministic symbol evolution across Git history: introduced, modified, moved, renamed, or deleted."""
|
|
1533
|
+
from codegraph.symbol_history import trace_symbol_history
|
|
1534
|
+
|
|
1535
|
+
repo = _resolve_repo(path, repository)
|
|
1536
|
+
indexer = _indexer(repo)
|
|
1537
|
+
|
|
1538
|
+
with indexer.session() as con:
|
|
1539
|
+
rep = trace_symbol_history(repo, symbol=symbol, path=file_path, con=con, max_commits=max_commits)
|
|
1540
|
+
|
|
1541
|
+
if json_output:
|
|
1542
|
+
cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
|
|
1543
|
+
else:
|
|
1544
|
+
cli_echo(f"Symbol History: {symbol}")
|
|
1545
|
+
cli_echo(f"Status: {rep.status} ({rep.detail})")
|
|
1546
|
+
for ev in rep.events:
|
|
1547
|
+
cli_echo(f"\n [{ev.event_type}] Commit {ev.commit_sha[:8]} on {ev.date} by {ev.author}")
|
|
1548
|
+
cli_echo(f" Message: {ev.message}")
|
|
1549
|
+
cli_echo(f" Location: {ev.path}")
|
|
1550
|
+
cli_echo(f" Evidence: {ev.evidence} (confidence: {ev.confidence})")
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
|
|
1316
1554
|
if __name__ == "__main__":
|
|
1317
1555
|
app()
|