codegraph-engine 2.2.1__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.
@@ -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()