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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """CodeGraph MCP: local, evidence-backed codebase intelligence."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.0"
4
4
  from codegraph.errors import (
5
5
  CodeGraphError,
6
6
  ErrorCode,
@@ -152,6 +152,12 @@ ROUTING_MANIFEST: dict[str, str] = {
152
152
  "runtime_ingest": "ingest_runtime_traces",
153
153
  "runtime_trace": "get_runtime_trace",
154
154
  "runtime_reconcile": "reconcile_static_runtime",
155
+ "git_state": "get_git_state",
156
+ "git_diff": "compare_git",
157
+ "change_impact_deep": "get_change_impact",
158
+ "context_freshness": "check_context_freshness",
159
+ "symbol_history": "trace_symbol_history",
160
+ "semantic_conflicts": "detect_semantic_conflicts",
155
161
  }
156
162
 
157
163
  DEFAULT_AGENT_PROFILE_TOOLS: tuple[str, ...] = (
@@ -2390,6 +2396,235 @@ TOOL_CAPABILITY_REGISTRY: tuple[ToolCapabilitySpec, ...] = (
2390
2396
  example_call='reconcile_static_runtime(route="/api/scan-bill")',
2391
2397
  example_interpretation="Inspect confirmed_runtime_paths, static_runtime_conflicts, not_observed_at_runtime, and runtime_only_observed.",
2392
2398
  ),
2399
+ # 57. get_git_state
2400
+ ToolCapabilitySpec(
2401
+ tool_name="get_git_state",
2402
+ capability="git_state_tracking",
2403
+ description=(
2404
+ "Track current Git branch, HEAD commit, indexed commit, working tree modifications, staged files, and explicit freshness states (CLEAN, DIRTY, STALE, REINDEXING, ERROR). "
2405
+ "Use when asking if the index is up to date, which files changed, or what git branch is active. "
2406
+ "Does not perform a full repository scan when an incremental update is safe."
2407
+ ),
2408
+ task_types=(
2409
+ AgentTaskCategory.DIAGNOSTIC.value,
2410
+ AgentTaskCategory.CHANGE_IMPACT.value,
2411
+ ),
2412
+ required_inputs=(),
2413
+ optional_inputs=(),
2414
+ expected_output_type="GitStateReport",
2415
+ relationship_types_returned=(),
2416
+ evidence_guarantees="Deterministic Git rev-parse and status porcelain v1 inspection.",
2417
+ does_not_prove="Does not prove semantic impact; reports file-level and commit-level freshness.",
2418
+ useful_situations=(
2419
+ "Checking if repository has uncommitted changes or if re-indexing is required after pulling git commits",
2420
+ ),
2421
+ avoid_when=(
2422
+ "You need symbol-level blast radius rather than repository freshness status",
2423
+ ),
2424
+ profiles=("full",),
2425
+ minimal_invocation="get_git_state()",
2426
+ advanced_invocation="get_git_state()",
2427
+ result_fields=("is_git", "branch", "current_head", "indexed_head", "freshness", "working_tree", "reindex_required", "detail"),
2428
+ typical_followup="compare_git or get_change_impact if files were modified",
2429
+ common_mistakes=(
2430
+ "Assuming CLEAN means all tests pass; CLEAN only indicates index matches working tree and HEAD",
2431
+ ),
2432
+ example_request="Is my CodeGraph index currently up to date with Git HEAD?",
2433
+ example_call="get_git_state()",
2434
+ example_interpretation="Inspect freshness (CLEAN, DIRTY, or STALE) and working_tree.modified_files.",
2435
+ ),
2436
+ # 58. compare_git
2437
+ ToolCapabilitySpec(
2438
+ tool_name="compare_git",
2439
+ capability="structural_git_diff",
2440
+ description=(
2441
+ "Compare two Git revisions, commits, or branches structurally. "
2442
+ "Detects added/deleted/modified/renamed files, AST symbol changes, relationship diffs, routes, and affected tests. "
2443
+ "Use when comparing branches or commits structurally. "
2444
+ "Does not execute repository code."
2445
+ ),
2446
+ task_types=(
2447
+ AgentTaskCategory.CHANGE_IMPACT.value,
2448
+ AgentTaskCategory.ARCHITECTURE.value,
2449
+ ),
2450
+ required_inputs=(),
2451
+ optional_inputs=("base", "head", "branch_comparison"),
2452
+ expected_output_type="StructuralDiffResult",
2453
+ relationship_types_returned=("CALLS", "IMPORTS", "ROUTES_TO", "TESTS"),
2454
+ evidence_guarantees="AST parsing of before/after revisions with exact file:line citations and similarity scores.",
2455
+ does_not_prove="Does not execute code or run test assertions.",
2456
+ useful_situations=(
2457
+ "Understanding structural changes between two branches or commits without parsing raw diff text",
2458
+ ),
2459
+ avoid_when=(
2460
+ "You only need high-level commit log summaries without AST symbol diffs",
2461
+ ),
2462
+ profiles=("full",),
2463
+ minimal_invocation='compare_git(base="HEAD~1", head="HEAD")',
2464
+ advanced_invocation='compare_git(base="main", head="my-feature", branch_comparison=True)',
2465
+ result_fields=("base_ref", "head_ref", "added_files", "deleted_files", "modified_files", "renamed_files", "added_symbols", "removed_symbols", "changed_symbols", "changed_routes", "affected_tests"),
2466
+ typical_followup="get_change_impact to find downstream callers of changed symbols",
2467
+ common_mistakes=(
2468
+ "Expecting compare_git to format unified diff text; it outputs AST and structural symbol objects",
2469
+ ),
2470
+ example_request="What symbols, routes, and tests changed structurally between main and this branch?",
2471
+ example_call='compare_git(base="main", head="HEAD", branch_comparison=True)',
2472
+ example_interpretation="Inspect packages, modules, changed_symbols, and changed_routes.",
2473
+ ),
2474
+ # 59. get_change_impact
2475
+ ToolCapabilitySpec(
2476
+ tool_name="get_change_impact",
2477
+ capability="deep_change_impact_analysis",
2478
+ description=(
2479
+ "Compute deep downstream change impact across callers, callees, framework routes, covering tests, mutating database queries, and monorepo packages. "
2480
+ "Returns ranked entities with explicit confidence (FACT, POSSIBLE, UNKNOWN) and blast radius score. "
2481
+ "Use when evaluating ripple effects of git commits. "
2482
+ "Does not execute repository test suites."
2483
+ ),
2484
+ task_types=(
2485
+ AgentTaskCategory.CHANGE_IMPACT.value,
2486
+ AgentTaskCategory.TEST_DISCOVERY.value,
2487
+ AgentTaskCategory.DEBUG.value,
2488
+ ),
2489
+ required_inputs=(),
2490
+ optional_inputs=("base", "head", "max_depth", "max_results"),
2491
+ expected_output_type="DeepImpactResult",
2492
+ relationship_types_returned=("CALLS", "CALLED_BY", "ROUTES_TO", "TESTS", "WRITES_TABLE", "READS_TABLE", "DEPENDS_ON"),
2493
+ evidence_guarantees="Complete graph traversal connecting modified AST symbols to callers, routes, tests, and DB queries.",
2494
+ does_not_prove="Does not prove runtime execution frequencies; reports static graph reachability.",
2495
+ useful_situations=(
2496
+ "Evaluating what code, routes, database writes, and tests will break if git changes are merged",
2497
+ ),
2498
+ avoid_when=(
2499
+ "No git changes exist and you are analyzing a single untouched function",
2500
+ ),
2501
+ profiles=("full",),
2502
+ minimal_invocation="get_change_impact()",
2503
+ advanced_invocation='get_change_impact(base="HEAD~3", head="HEAD", max_depth=2, max_results=50)',
2504
+ result_fields=("base_ref", "head_ref", "changed_files", "changed_symbols", "direct_callers", "transitive_callers", "affected_routes", "affected_tests", "db_writers", "blast_radius_score"),
2505
+ typical_followup="find_tests or get_file on affected_routes / direct_callers",
2506
+ common_mistakes=(
2507
+ "Relying solely on textual search instead of graph reachability for blast radius analysis",
2508
+ ),
2509
+ example_request="What is the blast radius and which routes, callers, and tests are affected by my branch?",
2510
+ example_call='get_change_impact(base="HEAD~1", head="HEAD")',
2511
+ example_interpretation="Inspect blast_radius_score, direct_callers, affected_routes, and affected_tests.",
2512
+ ),
2513
+ # 60. check_context_freshness
2514
+ ToolCapabilitySpec(
2515
+ tool_name="check_context_freshness",
2516
+ capability="context_freshness_validation",
2517
+ description=(
2518
+ "Validate whether a previously compiled context packet or task context is still VALID, PARTIALLY_STALE, or STALE. "
2519
+ "Guarantees that changes to unrelated files keep context VALID. "
2520
+ "Use when checking if previously compiled context remains valid after edits. "
2521
+ "Does not invalidate context when only unrelated files change."
2522
+ ),
2523
+ task_types=(
2524
+ AgentTaskCategory.DIAGNOSTIC.value,
2525
+ AgentTaskCategory.CHANGE_IMPACT.value,
2526
+ ),
2527
+ required_inputs=(),
2528
+ optional_inputs=("task", "context_packet"),
2529
+ expected_output_type="ContextFreshnessResult",
2530
+ relationship_types_returned=(),
2531
+ evidence_guarantees="Intersection of Git diff line/file changes against exact symbol and file references in context packet.",
2532
+ does_not_prove="Does not recompile context; reports freshness validity and recommended action.",
2533
+ useful_situations=(
2534
+ "Checking if previously cached or generated AI context packet is still safe to use after editing files",
2535
+ ),
2536
+ avoid_when=(
2537
+ "Compiling brand-new context from scratch for an initial prompt",
2538
+ ),
2539
+ profiles=("full",),
2540
+ minimal_invocation='check_context_freshness(task="Fix auth timeout")',
2541
+ advanced_invocation="check_context_freshness(context_packet=my_packet)",
2542
+ result_fields=("status", "base_commit", "current_head", "repository_dirty", "context_files", "changed_relevant_files", "unrelated_changed_files", "invalidated_symbols", "reason", "recommended_action"),
2543
+ typical_followup="get_context if status is STALE or PARTIALLY_STALE",
2544
+ common_mistakes=(
2545
+ "Assuming any file change invalidates context; unrelated changes leave context VALID",
2546
+ ),
2547
+ example_request="Is my current context packet still valid after my recent git modifications?",
2548
+ example_call='check_context_freshness(task="Fix auth timeout")',
2549
+ example_interpretation="Inspect status: VALID means safe to proceed, STALE means recompile required.",
2550
+ ),
2551
+ # 61. trace_symbol_history
2552
+ ToolCapabilitySpec(
2553
+ tool_name="trace_symbol_history",
2554
+ capability="symbol_history_tracing",
2555
+ description=(
2556
+ "Trace deterministic symbol evolution across Git history: introduced, modified, moved across files, renamed, or deleted. "
2557
+ "Uses AST structure and body hashes to differentiate FACT from POSSIBLE/AMBIGUOUS renames. "
2558
+ "Use when discovering when a symbol was added, moved, or renamed. "
2559
+ "Does not guess ambiguous renames without evidence."
2560
+ ),
2561
+ task_types=(
2562
+ AgentTaskCategory.SYMBOL_LOOKUP.value,
2563
+ AgentTaskCategory.CHANGE_IMPACT.value,
2564
+ ),
2565
+ required_inputs=("symbol",),
2566
+ optional_inputs=("path", "max_commits"),
2567
+ expected_output_type="SymbolHistoryResult",
2568
+ relationship_types_returned=(),
2569
+ evidence_guarantees="Git log history correlated with AST body hashes and file rename metadata.",
2570
+ does_not_prove="Does not guarantee intent behind refactors; reports structural code evolution.",
2571
+ useful_situations=(
2572
+ "Tracing when a function was introduced, modified, moved to a different module, or renamed",
2573
+ ),
2574
+ avoid_when=(
2575
+ "You only need current static callers and callees in the working tree",
2576
+ ),
2577
+ profiles=("full",),
2578
+ minimal_invocation='trace_symbol_history(symbol="AuthService")',
2579
+ advanced_invocation='trace_symbol_history(symbol="AuthService", path="src/auth.py", max_commits=20)',
2580
+ result_fields=("symbol", "current_path", "events", "total_commits_evaluated", "status", "detail"),
2581
+ typical_followup="get_symbol or get_file on historical commit",
2582
+ common_mistakes=(
2583
+ "Assuming all renames are FACT; ambiguous renames are explicitly flagged as AMBIGUOUS or POSSIBLE",
2584
+ ),
2585
+ example_request="Trace the history and renames of AuthService across git commits.",
2586
+ example_call='trace_symbol_history(symbol="AuthService")',
2587
+ example_interpretation="Inspect events list for INTRODUCED, MODIFIED, MOVED, or RENAMED occurrences.",
2588
+ ),
2589
+ # 62. detect_semantic_conflicts
2590
+ ToolCapabilitySpec(
2591
+ tool_name="detect_semantic_conflicts",
2592
+ capability="semantic_merge_conflict_detection",
2593
+ description=(
2594
+ "Detect semantic and contract conflicts between Git branches (calling deleted symbols, broken call signatures, or missing route handlers). "
2595
+ "Use when verifying whether branches can be safely merged without semantic breakage. "
2596
+ "Does not report false conflicts when branches are structurally compatible."
2597
+ ),
2598
+ task_types=(
2599
+ AgentTaskCategory.CHANGE_IMPACT.value,
2600
+ AgentTaskCategory.ARCHITECTURE.value,
2601
+ AgentTaskCategory.DEBUG.value,
2602
+ ),
2603
+ required_inputs=(),
2604
+ optional_inputs=("base_branch", "head_branch"),
2605
+ expected_output_type="SemanticConflictResult",
2606
+ relationship_types_returned=("CALLS", "IMPORTS", "ROUTES_TO"),
2607
+ evidence_guarantees="Deterministic cross-branch AST call-site and definition verification.",
2608
+ does_not_prove="Does not execute code; reports contract incompatibility between revisions.",
2609
+ useful_situations=(
2610
+ "Checking if a feature branch introduces calls to symbols deleted or modified on main",
2611
+ "Pre-merge validation before merging PRs to prevent semantic regression",
2612
+ ),
2613
+ avoid_when=(
2614
+ "Branches are known to be identical or when checking working tree cleanliness",
2615
+ ),
2616
+ profiles=("full",),
2617
+ minimal_invocation='detect_semantic_conflicts(base_branch="main", head_branch="feature")',
2618
+ advanced_invocation='detect_semantic_conflicts(base_branch="main", head_branch="HEAD")',
2619
+ result_fields=("base_branch", "head_branch", "has_conflicts", "total_conflicts", "conflicts", "summary"),
2620
+ typical_followup="get_file or get_symbol on conflicted symbols",
2621
+ common_mistakes=(
2622
+ "Assuming Git text merge passes mean no semantic conflicts; semantic conflicts occur even with 0 text conflicts",
2623
+ ),
2624
+ example_request="Are there any semantic merge conflicts between main and this branch?",
2625
+ example_call='detect_semantic_conflicts(base_branch="main", head_branch="HEAD")',
2626
+ example_interpretation="Inspect has_conflicts and conflicts list for DELETED_SYMBOL_REFERENCED or CALL_SIGNATURE_MISMATCH.",
2627
+ ),
2393
2628
  )
2394
2629
 
2395
2630
  _TOOL_BY_NAME: dict[str, ToolCapabilitySpec] = {t.tool_name: t for t in TOOL_CAPABILITY_REGISTRY}
@@ -2449,10 +2684,10 @@ CAPABILITY_MATRIX: dict[AgentTaskCategory, TaskCapabilityRule] = {
2449
2684
  category=AgentTaskCategory.CHANGE_IMPACT,
2450
2685
  should_use_codegraph=True,
2451
2686
  primary_tool="get_git_impact",
2452
- acceptable_tools=("get_git_impact", "analyze_impact", "analyze_change_impact", "get_dependents", "get_callers", "find_related_tests", "find_tests", "get_context"),
2453
- recommended_sequence=("resolve_symbol", "get_git_impact"),
2687
+ acceptable_tools=("get_git_impact", "get_change_impact", "compare_git", "get_git_state", "check_context_freshness", "trace_symbol_history", "analyze_impact", "analyze_change_impact", "get_dependents", "get_callers", "find_related_tests", "find_tests", "get_context"),
2688
+ recommended_sequence=("get_git_state", "compare_git", "get_change_impact"),
2454
2689
  direct_inspection_acceptable=False,
2455
- rationale="Use get_git_impact or analyze_impact to deterministically compute affected callers, packages, and tests.",
2690
+ rationale="Use get_git_state, compare_git, and get_change_impact to deterministically compute structural diffs, affected callers, routes, packages, and tests.",
2456
2691
  ),
2457
2692
  AgentTaskCategory.TEST_DISCOVERY: TaskCapabilityRule(
2458
2693
  category=AgentTaskCategory.TEST_DISCOVERY,
@@ -2696,12 +2931,15 @@ MCP_PROFILE_RECOMMENDATIONS: dict[str, dict[str, object]] = {
2696
2931
  },
2697
2932
  "full": {
2698
2933
  "profile": "full",
2699
- "tool_count": 56,
2934
+ "tool_count": 62,
2700
2935
  "recommended_for": "Complete diagnostic, database intelligence, runtime reconciliation, benchmark, and repository administration sessions",
2701
2936
  "included_tools": [
2702
2937
  "analyze_change_impact",
2703
2938
  "analyze_impact",
2939
+ "check_context_freshness",
2940
+ "compare_git",
2704
2941
  "compile_task",
2942
+ "detect_semantic_conflicts",
2705
2943
  "find_callees",
2706
2944
  "find_callers",
2707
2945
  "find_db_callers",
@@ -2721,6 +2959,7 @@ MCP_PROFILE_RECOMMENDATIONS: dict[str, dict[str, object]] = {
2721
2959
  "get_call_graph",
2722
2960
  "get_callees",
2723
2961
  "get_callers",
2962
+ "get_change_impact",
2724
2963
  "get_context",
2725
2964
  "get_db_impact",
2726
2965
  "get_db_schema",
@@ -2733,6 +2972,7 @@ MCP_PROFILE_RECOMMENDATIONS: dict[str, dict[str, object]] = {
2733
2972
  "get_file_history",
2734
2973
  "get_file_symbols",
2735
2974
  "get_git_impact",
2975
+ "get_git_state",
2736
2976
  "get_graph",
2737
2977
  "get_imports",
2738
2978
  "get_project_structure",
@@ -2754,6 +2994,7 @@ MCP_PROFILE_RECOMMENDATIONS: dict[str, dict[str, object]] = {
2754
2994
  "trace_call",
2755
2995
  "trace_flow",
2756
2996
  "trace_path",
2997
+ "trace_symbol_history",
2757
2998
  "verify_evidence",
2758
2999
  ],
2759
3000
  },
@@ -0,0 +1,197 @@
1
+ """Branch Architecture Comparison Engine.
2
+
3
+ Provides hierarchical structural comparisons between Git branches:
4
+ package
5
+ ↓
6
+ module
7
+ ↓
8
+ symbol
9
+ ↓
10
+ relationships (routes, tests, DB writes, calls)
11
+
12
+ Supports:
13
+ - deterministic ordering
14
+ - token-budget awareness (max_tokens)
15
+ - cursor-based pagination
16
+ - explicit summary of changed routes, services, database models, and affected tests
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import sqlite3
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from codegraph.monorepo import detect_workspace
26
+ from codegraph.structural_diff import compare_branches
27
+
28
+
29
+ @dataclass
30
+ class SymbolChangeItem:
31
+ name: str
32
+ kind: str
33
+ change_type: str # "ADDED" | "MODIFIED" | "REMOVED"
34
+ line: int | None
35
+ relationships: list[str] = field(default_factory=list)
36
+
37
+ def as_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "name": self.name,
40
+ "kind": self.kind,
41
+ "change_type": self.change_type,
42
+ "line": self.line,
43
+ "relationships": self.relationships,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class ModuleChangeItem:
49
+ module: str
50
+ file_path: str
51
+ symbols: list[SymbolChangeItem] = field(default_factory=list)
52
+
53
+ def as_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "module": self.module,
56
+ "file_path": self.file_path,
57
+ "symbols": [s.as_dict() for s in self.symbols],
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class PackageChangeItem:
63
+ package_id: str
64
+ modules: list[ModuleChangeItem] = field(default_factory=list)
65
+
66
+ def as_dict(self) -> dict[str, Any]:
67
+ return {
68
+ "package_id": self.package_id,
69
+ "modules": [m.as_dict() for m in self.modules],
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class BranchArchitectureReport:
75
+ base_branch: str
76
+ head_branch: str
77
+ packages: list[PackageChangeItem] = field(default_factory=list)
78
+ changed_routes: list[dict[str, str]] = field(default_factory=list)
79
+ changed_services: list[str] = field(default_factory=list)
80
+ changed_db: list[dict[str, str]] = field(default_factory=list)
81
+ affected_tests: list[str] = field(default_factory=list)
82
+ next_cursor: str | None = None
83
+ total_packages: int = 0
84
+ total_symbols: int = 0
85
+ estimated_tokens: int = 0
86
+
87
+ def as_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "base_branch": self.base_branch,
90
+ "head_branch": self.head_branch,
91
+ "packages": [p.as_dict() for p in self.packages],
92
+ "changed_routes": self.changed_routes,
93
+ "changed_services": self.changed_services,
94
+ "changed_db": self.changed_db,
95
+ "affected_tests": self.affected_tests,
96
+ "next_cursor": self.next_cursor,
97
+ "total_packages": self.total_packages,
98
+ "total_symbols": self.total_symbols,
99
+ "estimated_tokens": self.estimated_tokens,
100
+ }
101
+
102
+
103
+ def compare_branch_architecture(
104
+ repository: Path,
105
+ base_branch: str = "main",
106
+ head_branch: str = "HEAD",
107
+ con: sqlite3.Connection | None = None,
108
+ limit: int = 50,
109
+ cursor: str | None = None,
110
+ max_tokens: int = 4000,
111
+ ) -> BranchArchitectureReport:
112
+ """Compare two branches and return hierarchical architecture diff."""
113
+ if base_branch == head_branch:
114
+ return BranchArchitectureReport(base_branch=base_branch, head_branch=head_branch)
115
+
116
+ # 1. Run structural branch diff
117
+ diff_report = compare_branches(repository, base_branch=base_branch, head_branch=head_branch, con=con)
118
+
119
+ # 2. Workspace package mapper
120
+ ws = detect_workspace(repository, con=con)
121
+
122
+ # 3. Build hierarchical package -> module -> symbol map
123
+ package_map: dict[str, dict[str, list[SymbolChangeItem]]] = {}
124
+ services: set[str] = set()
125
+
126
+ all_symbols = diff_report.added_symbols + diff_report.changed_symbols + diff_report.removed_symbols
127
+ # Sort deterministically by path and name
128
+ all_symbols.sort(key=lambda s: (s.path, s.name))
129
+
130
+ # Apply cursor pagination if cursor provided
131
+ start_idx = 0
132
+ if cursor and cursor.isdigit():
133
+ start_idx = int(cursor)
134
+
135
+ paged_symbols = all_symbols[start_idx : start_idx + limit]
136
+ next_cursor = str(start_idx + limit) if (start_idx + limit) < len(all_symbols) else None
137
+
138
+ for s in paged_symbols:
139
+ pkg = ws.get_package_for_file(s.path)
140
+ pkg_id = pkg.package_id if pkg else "root"
141
+
142
+ if pkg_id not in package_map:
143
+ package_map[pkg_id] = {}
144
+ if s.path not in package_map[pkg_id]:
145
+ package_map[pkg_id][s.path] = []
146
+
147
+ # Find associated relationships
148
+ rels: list[str] = []
149
+ for r in diff_report.changed_routes:
150
+ if r.handler == s.name:
151
+ rels.append(f"{r.change_type} ROUTE {r.method} {r.path}")
152
+ for db in diff_report.db_impact:
153
+ if db.get("symbol") == s.name:
154
+ rels.append(f"{db.get('change_type')} DB {db.get('operation')} {db.get('table')}")
155
+
156
+ if "service" in s.name.lower() or "service" in s.path.lower():
157
+ services.add(s.name)
158
+
159
+ package_map[pkg_id][s.path].append(
160
+ SymbolChangeItem(
161
+ name=s.name,
162
+ kind=s.kind,
163
+ change_type=s.change_type,
164
+ line=s.new_line or s.old_line,
165
+ relationships=rels,
166
+ )
167
+ )
168
+
169
+ # Build ordered PackageChangeItem objects
170
+ package_items: list[PackageChangeItem] = []
171
+ total_sym_count = 0
172
+
173
+ for pkg_id in sorted(package_map.keys()):
174
+ module_items: list[ModuleChangeItem] = []
175
+ for f_path in sorted(package_map[pkg_id].keys()):
176
+ mod_name = Path(f_path).stem
177
+ sym_items = package_map[pkg_id][f_path]
178
+ total_sym_count += len(sym_items)
179
+ module_items.append(ModuleChangeItem(module=mod_name, file_path=f_path, symbols=sym_items))
180
+ package_items.append(PackageChangeItem(package_id=pkg_id, modules=module_items))
181
+
182
+ # Rough token estimation: ~25 tokens per symbol + overhead
183
+ token_est = total_sym_count * 25 + len(diff_report.changed_routes) * 15 + 150
184
+
185
+ return BranchArchitectureReport(
186
+ base_branch=base_branch,
187
+ head_branch=head_branch,
188
+ packages=package_items,
189
+ changed_routes=[r.as_dict() for r in diff_report.changed_routes],
190
+ changed_services=sorted(services),
191
+ changed_db=diff_report.db_impact,
192
+ affected_tests=diff_report.affected_tests,
193
+ next_cursor=next_cursor,
194
+ total_packages=len(package_items),
195
+ total_symbols=total_sym_count,
196
+ estimated_tokens=token_est,
197
+ )