codrspot-processor-mcp 0.1.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.
Files changed (100) hide show
  1. codepreproc_client/__init__.py +14 -0
  2. codepreproc_client/__main__.py +9 -0
  3. codepreproc_client/layer1_business/__init__.py +0 -0
  4. codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
  5. codepreproc_client/layer1_business/api_client/__init__.py +0 -0
  6. codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
  7. codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
  8. codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
  9. codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
  10. codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
  11. codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
  12. codepreproc_client/layer1_business/cli/__init__.py +0 -0
  13. codepreproc_client/layer1_business/cli/init.py +578 -0
  14. codepreproc_client/layer1_business/cli/main.py +78 -0
  15. codepreproc_client/layer1_business/config.py +104 -0
  16. codepreproc_client/layer1_business/git_utils.py +67 -0
  17. codepreproc_client/layer1_business/license/__init__.py +0 -0
  18. codepreproc_client/layer1_business/license/fingerprint.py +61 -0
  19. codepreproc_client/layer1_business/license/jwt_client.py +60 -0
  20. codepreproc_client/layer1_business/license/project_lock.py +44 -0
  21. codepreproc_client/layer1_business/local_registry.py +40 -0
  22. codepreproc_client/layer1_business/manifest/__init__.py +0 -0
  23. codepreproc_client/layer1_business/manifest/credentials.py +35 -0
  24. codepreproc_client/layer1_business/mcp/__init__.py +0 -0
  25. codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
  26. codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
  27. codepreproc_client/layer1_business/mcp/server.py +656 -0
  28. codepreproc_client/layer1_business/mcp/tools.py +1665 -0
  29. codepreproc_client/layer1_business/project_registry.py +99 -0
  30. codepreproc_client/layer2_tooling/__init__.py +0 -0
  31. codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
  32. codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
  33. codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
  34. codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
  35. codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
  36. codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
  37. codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
  38. codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
  39. codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
  40. codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
  41. codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
  42. codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
  43. codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
  44. codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
  45. codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
  46. codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
  47. codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
  48. codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
  49. codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
  50. codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
  51. codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
  52. codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
  53. codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
  54. codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
  55. codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
  56. codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
  57. codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
  58. codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
  59. codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
  60. codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
  61. codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
  62. codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
  63. codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
  64. codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
  65. codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
  66. codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
  67. codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
  68. codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
  69. codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
  70. codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
  71. codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
  72. codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
  73. codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
  74. codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
  75. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
  76. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
  77. codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
  78. codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
  79. codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
  80. codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
  81. codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
  82. codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
  83. codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
  84. codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
  85. codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
  86. codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
  87. codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
  88. codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
  89. codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
  90. codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
  91. codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
  92. codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
  93. codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
  94. codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
  95. codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
  96. codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
  97. codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
  98. contracts/__init__.py +0 -0
  99. contracts/dtos.py +1239 -0
  100. contracts/ir.py +102 -0
@@ -0,0 +1,189 @@
1
+ """Layer 2: disambiguation + lazy revalidation gate for resolved coupling facts.
2
+
3
+ Consumes the candidate-level superset from CouplingGraph and produces only
4
+ resolved, current edges. Two distinct passes, both over the affected subset only:
5
+
6
+ (a) DISAMBIGUATE — ambiguous edges (resolution_status="ambiguous") carry
7
+ competing_targets. We check which (project_id, chunk_id) pairs still exist
8
+ in their respective symbol tables. If exactly one survives, the edge is
9
+ promoted to resolved. If still multiple, it remains ambiguous (excluded
10
+ from the resolved output).
11
+
12
+ (b) REVALIDATE — edges whose dst_export_surface_epoch differs from the
13
+ destination's current export_surface_hash are stale. We re-look up
14
+ dst_symbol in the foreign table and, if found, emit a resolved edge with the
15
+ updated epoch and chunk_id. If the symbol is gone, the edge is dropped from
16
+ the resolved output.
17
+
18
+ Resolved edges are written back through upsert_edges so the store stays warm
19
+ for subsequent Layer 1 reads.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import sqlite3
26
+ from pathlib import Path
27
+
28
+ from codepreproc_client.layer2_tooling.coupling.domain import ResolutionEdge
29
+ from codepreproc_client.layer2_tooling.coupling.graph import CouplingGraph
30
+
31
+ _log = logging.getLogger("codepreproc.coupling")
32
+
33
+
34
+ def _get_export_surface_hash(db_path: Path) -> str | None:
35
+ """Read export_surface_hash (or last_indexed_sha fallback) from a project DB."""
36
+ if not db_path.exists():
37
+ return None
38
+ try:
39
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=2)
40
+ row = conn.execute(
41
+ "SELECT value FROM meta WHERE key = 'export_surface_hash'"
42
+ ).fetchone()
43
+ if not row:
44
+ row = conn.execute(
45
+ "SELECT value FROM meta WHERE key = 'last_indexed_sha'"
46
+ ).fetchone()
47
+ conn.close()
48
+ return row[0] if row else None
49
+ except Exception:
50
+ return None
51
+
52
+
53
+ def _load_chunk_ids(db_path: Path) -> set[str]:
54
+ """Return the live set of chunk_ids in a project's symbol table."""
55
+ if not db_path.exists():
56
+ return set()
57
+ try:
58
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=2)
59
+ rows = conn.execute("SELECT chunk_id FROM symbols").fetchall()
60
+ conn.close()
61
+ return {row[0] for row in rows}
62
+ except Exception:
63
+ return set()
64
+
65
+
66
+ def _load_symbol_to_chunk(db_path: Path) -> dict[str, str]:
67
+ """Return {symbol: chunk_id} for a project's current symbol table."""
68
+ if not db_path.exists():
69
+ return {}
70
+ try:
71
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=2)
72
+ rows = conn.execute("SELECT symbol, chunk_id FROM symbols").fetchall()
73
+ conn.close()
74
+ return {row[0]: row[1] for row in rows}
75
+ except Exception:
76
+ return {}
77
+
78
+
79
+ def apply_layer2_gate(
80
+ edges: list[ResolutionEdge],
81
+ indexes_dir: Path,
82
+ coupling_graph: CouplingGraph,
83
+ ) -> tuple[list[ResolutionEdge], int]:
84
+ """Disambiguate ambiguous edges and revalidate stale edges.
85
+
86
+ Processes only the affected subset (ambiguous or stale edges).
87
+ Resolved edges are written back through upsert_edges.
88
+
89
+ Returns (resolved_edges, revalidated_count) where resolved_edges contains
90
+ all clean edges plus the successfully disambiguated/revalidated ones.
91
+ """
92
+ if not edges:
93
+ return [], 0
94
+
95
+ # Collect current export_surface_hash per project and cache symbol tables lazily.
96
+ current_epochs: dict[str, str] = {}
97
+ _chunk_id_cache: dict[str, set[str]] = {}
98
+ _sym_to_chunk_cache: dict[str, dict[str, str]] = {}
99
+
100
+ for project_dir in indexes_dir.iterdir():
101
+ if not project_dir.is_dir():
102
+ continue
103
+ h = _get_export_surface_hash(project_dir / "ast_graph.sqlite")
104
+ if h:
105
+ current_epochs[project_dir.name] = h
106
+
107
+ def _chunk_ids(project_id: str) -> set[str]:
108
+ if project_id not in _chunk_id_cache:
109
+ _chunk_id_cache[project_id] = _load_chunk_ids(
110
+ indexes_dir / project_id / "ast_graph.sqlite"
111
+ )
112
+ return _chunk_id_cache[project_id]
113
+
114
+ def _sym_to_chunk(project_id: str) -> dict[str, str]:
115
+ if project_id not in _sym_to_chunk_cache:
116
+ _sym_to_chunk_cache[project_id] = _load_symbol_to_chunk(
117
+ indexes_dir / project_id / "ast_graph.sqlite"
118
+ )
119
+ return _sym_to_chunk_cache[project_id]
120
+
121
+ # Group ambiguous edges by (src_chunk_id, dst_symbol) so we process each
122
+ # competing set once rather than once per match.
123
+ ambiguous_groups: dict[tuple[str, str], list[ResolutionEdge]] = {}
124
+ passthrough: list[ResolutionEdge] = []
125
+ stale_resolved: list[ResolutionEdge] = []
126
+
127
+ for edge in edges:
128
+ current_epoch = current_epochs.get(edge.dst_project_id)
129
+ is_stale = current_epoch is not None and edge.dst_export_surface_epoch != current_epoch
130
+ is_ambiguous = edge.resolution_status == "ambiguous"
131
+
132
+ if is_ambiguous:
133
+ key = (edge.src_chunk_id, edge.dst_symbol)
134
+ ambiguous_groups.setdefault(key, []).append(edge)
135
+ elif is_stale:
136
+ # Revalidate: look up the symbol in the current dst symbol table.
137
+ sym_map = _sym_to_chunk(edge.dst_project_id)
138
+ new_chunk_id = sym_map.get(edge.dst_symbol)
139
+ if new_chunk_id is not None:
140
+ new_edge = edge.model_copy(update={
141
+ "dst_chunk_id": new_chunk_id,
142
+ "dst_export_surface_epoch": current_epoch,
143
+ "resolution_status": "resolved",
144
+ })
145
+ stale_resolved.append(new_edge)
146
+ # Symbol gone from dst → drop edge from resolved output (do not add to passthrough)
147
+ else:
148
+ passthrough.append(edge)
149
+
150
+ # Disambiguate each group.
151
+ disambiguated: list[ResolutionEdge] = []
152
+ for (src_chunk_id, dst_symbol), group in ambiguous_groups.items():
153
+ # competing_targets is the same on all edges in the group.
154
+ competing = group[0].competing_targets
155
+ survivors = [
156
+ (pid, cid)
157
+ for pid, cid in competing
158
+ if cid in _chunk_ids(pid)
159
+ ]
160
+ if len(survivors) == 1:
161
+ pid, cid = survivors[0]
162
+ new_epoch = current_epochs.get(pid, group[0].dst_export_surface_epoch)
163
+ # Find the original edge that pointed to this dst project to preserve edge_id
164
+ ref_edge = next(
165
+ (e for e in group if e.dst_project_id == pid and e.dst_chunk_id == cid),
166
+ group[0],
167
+ )
168
+ new_edge = ref_edge.model_copy(update={
169
+ "dst_project_id": pid,
170
+ "dst_chunk_id": cid,
171
+ "dst_export_surface_epoch": new_epoch,
172
+ "resolution_status": "resolved",
173
+ "competing_targets": [],
174
+ })
175
+ disambiguated.append(new_edge)
176
+ # else: still ambiguous or all gone → excluded from resolved output
177
+
178
+ to_write = stale_resolved + disambiguated
179
+ if to_write:
180
+ coupling_graph.upsert_edges(to_write)
181
+ _log.info(
182
+ "layer2_gate: %d edges revalidated/disambiguated (%d stale, %d ambiguous→resolved)",
183
+ len(to_write),
184
+ len(stale_resolved),
185
+ len(disambiguated),
186
+ )
187
+
188
+ resolved = passthrough + stale_resolved + disambiguated
189
+ return resolved, len(to_write)
@@ -0,0 +1,139 @@
1
+ """Coupling projection engine: Ca/Ce metrics and SCC detection via Tarjan.
2
+
3
+ Given a set of ResolutionEdges (already hydrated from CouplingGraph), this
4
+ module computes the read-model projection entirely server-side — no LLM
5
+ involvement, deterministic.
6
+
7
+ Tarjan runs on the repo-level directed graph (nodes = project_ids), not on
8
+ individual symbols, so the graph is always small (N registered projects).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+
15
+ from codepreproc_client.layer2_tooling.coupling.domain import (
16
+ CouplingMetricRow,
17
+ CouplingProjectSummary,
18
+ ResolutionEdge,
19
+ SCC,
20
+ )
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Tarjan SCC (iterative to avoid Python recursion limit on large graphs)
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def _tarjan_sccs(nodes: list[str], adj: dict[str, list[str]]) -> list[list[str]]:
29
+ """Return list of SCCs (as node lists) for a directed graph, largest first."""
30
+ index_counter = [0]
31
+ stack: list[str] = []
32
+ lowlinks: dict[str, int] = {}
33
+ index: dict[str, int] = {}
34
+ on_stack: dict[str, bool] = {}
35
+ sccs: list[list[str]] = []
36
+
37
+ def strongconnect(v: str) -> None:
38
+ index[v] = index_counter[0]
39
+ lowlinks[v] = index_counter[0]
40
+ index_counter[0] += 1
41
+ stack.append(v)
42
+ on_stack[v] = True
43
+
44
+ for w in adj.get(v, []):
45
+ if w not in index:
46
+ strongconnect(w)
47
+ lowlinks[v] = min(lowlinks[v], lowlinks[w])
48
+ elif on_stack.get(w):
49
+ lowlinks[v] = min(lowlinks[v], index[w])
50
+
51
+ if lowlinks[v] == index[v]:
52
+ scc: list[str] = []
53
+ while True:
54
+ w = stack.pop()
55
+ on_stack[w] = False
56
+ scc.append(w)
57
+ if w == v:
58
+ break
59
+ sccs.append(scc)
60
+
61
+ for node in nodes:
62
+ if node not in index:
63
+ strongconnect(node)
64
+
65
+ return sccs
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Main projector
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ class CouplingProjector:
74
+ """Project a set of ResolutionEdges into CouplingProjectSummary."""
75
+
76
+ def project(
77
+ self,
78
+ anchors: list[str],
79
+ edges: list[ResolutionEdge],
80
+ stale_count: int = 0,
81
+ ) -> CouplingProjectSummary:
82
+ """Compute metrics and SCCs from the given edge set.
83
+
84
+ anchors: the project_ids in the working set (symbol table keys).
85
+ edges: ResolutionEdges filtered to the working set.
86
+ stale_count: number of edges re-resolved during hydration (for info).
87
+ """
88
+ # Symbol table: short name = project_id (already short enough)
89
+ projects = {pid: pid for pid in anchors}
90
+
91
+ # Count edges per (src, dst) pair
92
+ pair_counts: dict[tuple[str, str], int] = defaultdict(int)
93
+ for edge in edges:
94
+ pair_counts[(edge.src_project_id, edge.dst_project_id)] += 1
95
+
96
+ # Build compact edge list: (src_id, dst_id, count)
97
+ compact_edges = [
98
+ (src, dst, count)
99
+ for (src, dst), count in sorted(pair_counts.items())
100
+ ]
101
+
102
+ # Ca/Ce per pair: from src's perspective
103
+ # Ce(src→dst): efferent — src depends on dst
104
+ # Ca(src→dst): afferent — edges going dst→src (i.e., dst depends on src)
105
+ ce_map: dict[tuple[str, str], int] = defaultdict(int)
106
+ ca_map: dict[tuple[str, str], int] = defaultdict(int)
107
+ for (src, dst), count in pair_counts.items():
108
+ ce_map[(src, dst)] += count
109
+ ca_map[(src, dst)] += pair_counts.get((dst, src), 0)
110
+
111
+ all_pairs = set(ce_map.keys()) | {(dst, src) for src, dst in ce_map}
112
+ metrics: list[CouplingMetricRow] = []
113
+ seen_pairs: set[tuple[str, str]] = set()
114
+ for src, dst in sorted(all_pairs):
115
+ if (src, dst) in seen_pairs:
116
+ continue
117
+ seen_pairs.add((src, dst))
118
+ ce = ce_map.get((src, dst), 0)
119
+ ca = pair_counts.get((dst, src), 0)
120
+ if ce > 0 or ca > 0:
121
+ metrics.append(CouplingMetricRow.compute(src, dst, ce, ca))
122
+
123
+ # Build adjacency for Tarjan (repo-level, directed)
124
+ adj: dict[str, list[str]] = defaultdict(list)
125
+ for src, dst in pair_counts:
126
+ if src != dst:
127
+ adj[src].append(dst)
128
+
129
+ raw_sccs = _tarjan_sccs(list(anchors), adj)
130
+ non_trivial = [SCC(members=scc) for scc in raw_sccs if len(scc) > 1]
131
+
132
+ return CouplingProjectSummary(
133
+ projects=projects,
134
+ edges=compact_edges,
135
+ metrics=metrics,
136
+ sccs=non_trivial,
137
+ total_edges=len(edges),
138
+ stale_edges_revalidated=stale_count,
139
+ )
@@ -0,0 +1,150 @@
1
+ """Cross-repo symbol resolution: called at index-time to emit ResolutionEdges.
2
+
3
+ Resolution strategy: load all other projects' symbol tables from their
4
+ AstGraph SQLite files (read-only) and perform a global namespace lookup.
5
+ On symbol name collision across projects, we take all matches and emit one
6
+ edge per match (conservative; duplicates are deduped by edge_id in the DB).
7
+
8
+ This is called from ProjectIndexer after the local AST graph is built,
9
+ with the full chunk set available. It is synchronous and CPU-bound; the
10
+ caller runs it with asyncio.to_thread().
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import sqlite3
17
+ from pathlib import Path
18
+
19
+ from codepreproc_client.layer2_tooling.coupling.domain import ResolutionEdge
20
+ from codepreproc_client.layer2_tooling.coupling.graph import CouplingGraph
21
+ from contracts.dtos import CodeChunk
22
+
23
+ _log = logging.getLogger("codepreproc.coupling")
24
+
25
+
26
+ def _load_foreign_symbols(indexes_dir: Path, exclude_project_id: str) -> dict[str, list[tuple[str, str, str]]]:
27
+ """Return {symbol: [(project_id, chunk_id, file_path)]} for all other projects.
28
+
29
+ Opens each project's ast_graph.sqlite read-only; skips missing or locked DBs.
30
+ """
31
+ foreign: dict[str, list[tuple[str, str, str]]] = {}
32
+ for project_dir in indexes_dir.iterdir():
33
+ if not project_dir.is_dir():
34
+ continue
35
+ project_id = project_dir.name
36
+ if project_id == exclude_project_id:
37
+ continue
38
+ db_path = project_dir / "ast_graph.sqlite"
39
+ if not db_path.exists():
40
+ continue
41
+ try:
42
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=2)
43
+ rows = conn.execute("SELECT chunk_id, symbol, file_path FROM symbols").fetchall()
44
+ conn.close()
45
+ except Exception as exc:
46
+ _log.debug("coupling_resolver: skipping %s — %s", project_id, exc)
47
+ continue
48
+ for chunk_id, symbol, file_path in rows:
49
+ foreign.setdefault(symbol, []).append((project_id, chunk_id, file_path))
50
+ return foreign
51
+
52
+
53
+ def emit_cross_repo_edges(
54
+ *,
55
+ src_project_id: str,
56
+ src_epoch: str,
57
+ chunks: list[CodeChunk],
58
+ indexes_dir: Path,
59
+ coupling_graph: CouplingGraph,
60
+ dst_epochs: dict[str, str],
61
+ ) -> int:
62
+ """Resolve cross-repo deps for all chunks and persist as ResolutionEdges.
63
+
64
+ Returns the number of new edges emitted.
65
+ """
66
+ if not chunks:
67
+ return 0
68
+
69
+ foreign = _load_foreign_symbols(indexes_dir, src_project_id)
70
+ if not foreign:
71
+ return 0
72
+
73
+ # Wipe old src edges before re-emitting (idempotent full-refresh)
74
+ coupling_graph.delete_edges_for_project(src_project_id)
75
+
76
+ edges: list[ResolutionEdge] = []
77
+ seen: set[tuple[str, str, str]] = set()
78
+
79
+ for chunk in chunks:
80
+ for dep_symbol in chunk.deps:
81
+ matches = foreign.get(dep_symbol)
82
+ if not matches:
83
+ continue
84
+ is_ambiguous = len(matches) > 1
85
+ # competing_targets lists all (project_id, chunk_id) matches so Layer 2
86
+ # can disambiguate without re-running the join.
87
+ competing: list[tuple[str, str]] = (
88
+ [(pid, cid) for pid, cid, _ in matches] if is_ambiguous else []
89
+ )
90
+ for dst_project_id, dst_chunk_id, _dst_file in matches:
91
+ key = (chunk.chunk_id, dst_project_id, dst_chunk_id)
92
+ if key in seen:
93
+ continue
94
+ seen.add(key)
95
+ dst_epoch = dst_epochs.get(dst_project_id, "unknown")
96
+ edges.append(
97
+ ResolutionEdge(
98
+ src_project_id=src_project_id,
99
+ src_chunk_id=chunk.chunk_id,
100
+ src_symbol=chunk.symbol,
101
+ dst_project_id=dst_project_id,
102
+ dst_chunk_id=dst_chunk_id,
103
+ dst_symbol=dep_symbol,
104
+ dst_export_surface_epoch=dst_epoch,
105
+ resolution_status="ambiguous" if is_ambiguous else "resolved",
106
+ competing_targets=competing,
107
+ )
108
+ )
109
+
110
+ if edges:
111
+ coupling_graph.upsert_edges(edges)
112
+ _log.info(
113
+ "coupling_resolver: %d cross-repo edges emitted for project=%s",
114
+ len(edges),
115
+ src_project_id,
116
+ )
117
+ return len(edges)
118
+
119
+
120
+ def collect_dst_epochs(indexes_dir: Path, exclude_project_id: str) -> dict[str, str]:
121
+ """Return {project_id: export_surface_hash} for all other indexed projects.
122
+
123
+ Falls back to last_indexed_sha for projects not yet reindexed after the
124
+ export-surface-hash migration, so stale detection remains functional.
125
+ """
126
+ epochs: dict[str, str] = {}
127
+ for project_dir in indexes_dir.iterdir():
128
+ if not project_dir.is_dir():
129
+ continue
130
+ project_id = project_dir.name
131
+ if project_id == exclude_project_id:
132
+ continue
133
+ db_path = project_dir / "ast_graph.sqlite"
134
+ if not db_path.exists():
135
+ continue
136
+ try:
137
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=2)
138
+ row = conn.execute(
139
+ "SELECT value FROM meta WHERE key = 'export_surface_hash'"
140
+ ).fetchone()
141
+ if not row:
142
+ row = conn.execute(
143
+ "SELECT value FROM meta WHERE key = 'last_indexed_sha'"
144
+ ).fetchone()
145
+ conn.close()
146
+ if row:
147
+ epochs[project_id] = row[0]
148
+ except Exception:
149
+ continue
150
+ return epochs