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.
- codepreproc_client/__init__.py +14 -0
- codepreproc_client/__main__.py +9 -0
- codepreproc_client/layer1_business/__init__.py +0 -0
- codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
- codepreproc_client/layer1_business/api_client/__init__.py +0 -0
- codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
- codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
- codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
- codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
- codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
- codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
- codepreproc_client/layer1_business/cli/__init__.py +0 -0
- codepreproc_client/layer1_business/cli/init.py +578 -0
- codepreproc_client/layer1_business/cli/main.py +78 -0
- codepreproc_client/layer1_business/config.py +104 -0
- codepreproc_client/layer1_business/git_utils.py +67 -0
- codepreproc_client/layer1_business/license/__init__.py +0 -0
- codepreproc_client/layer1_business/license/fingerprint.py +61 -0
- codepreproc_client/layer1_business/license/jwt_client.py +60 -0
- codepreproc_client/layer1_business/license/project_lock.py +44 -0
- codepreproc_client/layer1_business/local_registry.py +40 -0
- codepreproc_client/layer1_business/manifest/__init__.py +0 -0
- codepreproc_client/layer1_business/manifest/credentials.py +35 -0
- codepreproc_client/layer1_business/mcp/__init__.py +0 -0
- codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
- codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
- codepreproc_client/layer1_business/mcp/server.py +656 -0
- codepreproc_client/layer1_business/mcp/tools.py +1665 -0
- codepreproc_client/layer1_business/project_registry.py +99 -0
- codepreproc_client/layer2_tooling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
- codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
- codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
- codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
- codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
- codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
- codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
- codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
- codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
- codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
- codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
- codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
- codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
- codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
- codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
- codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
- codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
- codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
- codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
- codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
- codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
- codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
- codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
- codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
- codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
- codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
- codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
- codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
- codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
- codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
- codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
- codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
- codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
- codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
- codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
- codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
- codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
- codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
- codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
- codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
- codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
- codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
- codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
- codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
- contracts/__init__.py +0 -0
- contracts/dtos.py +1239 -0
- 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
|
|
File without changes
|
|
File without changes
|