ckgraphify 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.
graphify/benchmark.py ADDED
@@ -0,0 +1,152 @@
1
+ """Token-reduction benchmark - measures how much context graphify saves vs naive full-corpus approach."""
2
+ from __future__ import annotations
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ import networkx as nx
7
+ from networkx.readwrite import json_graph
8
+
9
+ from graphify.build import edge_data
10
+
11
+
12
+ _CHARS_PER_TOKEN = 4 # standard approximation
13
+
14
+
15
+ def _safe(unicode_char: str, ascii_fallback: str) -> str:
16
+ """Return unicode_char if stdout can encode it, else ascii_fallback.
17
+
18
+ Windows consoles often default to cp1252 which cannot encode box-drawing
19
+ or arrow glyphs; printing them raises UnicodeEncodeError mid-output.
20
+ """
21
+ encoding = getattr(sys.stdout, "encoding", None) or ""
22
+ try:
23
+ unicode_char.encode(encoding)
24
+ return unicode_char
25
+ except (UnicodeEncodeError, LookupError):
26
+ return ascii_fallback
27
+
28
+
29
+ def _hr(width: int = 50) -> str:
30
+ """Horizontal rule that survives non-UTF-8 stdout (e.g. Windows cp1252 console)."""
31
+ return _safe("─", "-") * width
32
+
33
+
34
+ def _estimate_tokens(text: str) -> int:
35
+ return max(1, len(text) // _CHARS_PER_TOKEN)
36
+
37
+
38
+ def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int:
39
+ """Run BFS from best-matching nodes and return estimated tokens in the subgraph context."""
40
+ terms = [t.lower() for t in question.split() if len(t) > 2]
41
+ scored = []
42
+ for nid, data in G.nodes(data=True):
43
+ label = data.get("label", "").lower()
44
+ score = sum(1 for t in terms if t in label)
45
+ if score > 0:
46
+ scored.append((score, nid))
47
+ scored.sort(reverse=True)
48
+ start_nodes = [nid for _, nid in scored[:3]]
49
+ if not start_nodes:
50
+ return 0
51
+
52
+ visited: set[str] = set(start_nodes)
53
+ frontier = set(start_nodes)
54
+ edges_seen: list[tuple] = []
55
+ for _ in range(depth):
56
+ next_frontier: set[str] = set()
57
+ for n in frontier:
58
+ for neighbor in G.neighbors(n):
59
+ if neighbor not in visited:
60
+ next_frontier.add(neighbor)
61
+ edges_seen.append((n, neighbor))
62
+ visited.update(next_frontier)
63
+ frontier = next_frontier
64
+
65
+ lines = []
66
+ for nid in visited:
67
+ d = G.nodes[nid]
68
+ lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}")
69
+ for u, v in edges_seen:
70
+ if u in visited and v in visited:
71
+ d = edge_data(G, u, v)
72
+ lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}")
73
+
74
+ return _estimate_tokens("\n".join(lines))
75
+
76
+
77
+ _SAMPLE_QUESTIONS = [
78
+ "how does authentication work",
79
+ "what is the main entry point",
80
+ "how are errors handled",
81
+ "what connects the data layer to the api",
82
+ "what are the core abstractions",
83
+ ]
84
+
85
+
86
+ def run_benchmark(
87
+ graph_path: str = "graphify-out/graph.json",
88
+ corpus_words: int | None = None,
89
+ questions: list[str] | None = None,
90
+ ) -> dict:
91
+ """Measure token reduction: corpus tokens vs graphify query tokens.
92
+
93
+ Args:
94
+ graph_path: path to the built graph
95
+ corpus_words: total word count from detect() output; if None, estimated from graph
96
+ questions: list of questions to benchmark; defaults to _SAMPLE_QUESTIONS
97
+
98
+ Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question
99
+ """
100
+ data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
101
+ try:
102
+ G = json_graph.node_link_graph(data, edges="links")
103
+ except TypeError:
104
+ G = json_graph.node_link_graph(data)
105
+
106
+ if corpus_words is None:
107
+ # Rough estimate: each node label is ~3 words, plus source context
108
+ corpus_words = G.number_of_nodes() * 50
109
+
110
+ corpus_tokens = corpus_words * 100 // 75 # words → tokens (100 words ≈ 133 tokens)
111
+
112
+ qs = questions or _SAMPLE_QUESTIONS
113
+ per_question = []
114
+ for q in qs:
115
+ qt = _query_subgraph_tokens(G, q)
116
+ if qt > 0:
117
+ per_question.append({"question": q, "query_tokens": qt, "reduction": round(corpus_tokens / qt, 1)})
118
+
119
+ if not per_question:
120
+ return {"error": "No matching nodes found for sample questions. Build the graph first."}
121
+
122
+ avg_query_tokens = sum(p["query_tokens"] for p in per_question) // len(per_question)
123
+ reduction_ratio = round(corpus_tokens / avg_query_tokens, 1) if avg_query_tokens > 0 else 0
124
+
125
+ return {
126
+ "corpus_tokens": corpus_tokens,
127
+ "corpus_words": corpus_words,
128
+ "nodes": G.number_of_nodes(),
129
+ "edges": G.number_of_edges(),
130
+ "avg_query_tokens": avg_query_tokens,
131
+ "reduction_ratio": reduction_ratio,
132
+ "per_question": per_question,
133
+ }
134
+
135
+
136
+ def print_benchmark(result: dict) -> None:
137
+ """Print a human-readable benchmark report."""
138
+ if "error" in result:
139
+ print(f"Benchmark error: {result['error']}")
140
+ return
141
+
142
+ print(f"\ngraphify token reduction benchmark")
143
+ print(_hr(50))
144
+ arrow = _safe("→", "->")
145
+ print(f" Corpus: {result['corpus_words']:,} words {arrow} ~{result['corpus_tokens']:,} tokens (naive)")
146
+ print(f" Graph: {result['nodes']:,} nodes, {result['edges']:,} edges")
147
+ print(f" Avg query cost: ~{result['avg_query_tokens']:,} tokens")
148
+ print(f" Reduction: {result['reduction_ratio']}x fewer tokens per query")
149
+ print(f"\n Per question:")
150
+ for p in result["per_question"]:
151
+ print(f" [{p['reduction']}x] {p['question'][:55]}")
152
+ print()
@@ -0,0 +1,517 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+
11
+ _FRONTEND_EXTS = {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".mjs", ".cjs"}
12
+ _BACKEND_EXTS = {".java", ".kt", ".go", ".py", ".php", ".rb", ".cs", ".scala"}
13
+ _SKIP_DIRS = {
14
+ ".git",
15
+ "node_modules",
16
+ "dist",
17
+ "build",
18
+ "target",
19
+ ".next",
20
+ ".nuxt",
21
+ ".idea",
22
+ ".vscode",
23
+ "__pycache__",
24
+ ".venv",
25
+ "venv",
26
+ }
27
+
28
+ # Strong signal: literal mtop.* token.
29
+ _MTOP_LITERAL_RE = re.compile(r"\b(mtop\.[A-Za-z0-9_.-]{3,})\b")
30
+ # Fallback signal: api/method assignment with dotted value.
31
+ _API_ASSIGN_RE = re.compile(
32
+ r"(?i)\b(?:api|apiname|method|mtopapi)\b\s*[:=]\s*['\"]([A-Za-z0-9_.-]+)['\"]"
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ApiFact:
38
+ repo: str
39
+ rel_path: str
40
+ line: int
41
+ api: str
42
+ source: str # literal|assign
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class BridgeStats:
47
+ repos_scanned: int
48
+ files_scanned: int
49
+ frontend_facts: int
50
+ backend_facts: int
51
+ api_nodes_added: int
52
+ edge_calls_added: int
53
+ edge_impl_added: int
54
+ unresolved_frontend: int
55
+ unresolved_backend: int
56
+
57
+
58
+ def _norm_path(p: str | None) -> str:
59
+ return (p or "").replace("\\", "/")
60
+
61
+
62
+ def _norm_api(raw: str) -> str:
63
+ v = raw.strip().strip("'\"")
64
+ if not v:
65
+ return ""
66
+ if v.lower().startswith("mtop."):
67
+ return v
68
+ # Heuristic: dotted API names usually represent method paths.
69
+ if v.count(".") >= 2:
70
+ return f"mtop.{v}"
71
+ return ""
72
+
73
+
74
+ def _edge_key(e: dict) -> tuple[str, str, str, str]:
75
+ return (
76
+ str(e.get("source", "")),
77
+ str(e.get("target", "")),
78
+ str(e.get("relation", "")),
79
+ _norm_path(str(e.get("source_file", ""))),
80
+ )
81
+
82
+
83
+ def _load_graph(path: Path) -> tuple[dict, str]:
84
+ data = json.loads(path.read_text(encoding="utf-8"))
85
+ if "nodes" not in data or not isinstance(data["nodes"], list):
86
+ raise ValueError("graph json missing 'nodes' list")
87
+ edge_key = "links" if "links" in data else "edges"
88
+ if edge_key not in data or not isinstance(data[edge_key], list):
89
+ raise ValueError("graph json missing 'links'/'edges' list")
90
+ return data, edge_key
91
+
92
+
93
+ def _save_graph(path: Path, data: dict) -> None:
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
96
+
97
+
98
+ def _repo_tags_from_graph(nodes: list[dict]) -> list[str]:
99
+ tags = sorted({str(n.get("repo", "")).strip() for n in nodes if str(n.get("repo", "")).strip()})
100
+ if tags:
101
+ return tags
102
+ # Fallback for non-prefixed graphs: no repo partition available.
103
+ return ["default"]
104
+
105
+
106
+ def _thin_graph_data(data: dict, edge_key: str, *, hops: int = 1) -> dict:
107
+ """Return a thin subgraph centered on mtop bridge chain nodes.
108
+
109
+ Keep:
110
+ - mtop API nodes (bridge_kind=mtop_api)
111
+ - repo navigation nodes (bridge_kind=repo)
112
+ - bridge edge endpoints (calls_mtop / implemented_by / depends_on_repo)
113
+ - optional neighborhood expansion by `hops` along existing edges.
114
+ """
115
+ nodes: list[dict] = data.get("nodes", [])
116
+ edges: list[dict] = data.get(edge_key, [])
117
+ by_id = {str(n.get("id", "")): n for n in nodes}
118
+
119
+ core_ids: set[str] = set()
120
+ for n in nodes:
121
+ nid = str(n.get("id", ""))
122
+ bk = str(n.get("bridge_kind", ""))
123
+ if bk in {"mtop_api", "repo"}:
124
+ core_ids.add(nid)
125
+
126
+ bridge_rels = {"calls_mtop", "implemented_by", "depends_on_repo"}
127
+ for e in edges:
128
+ if str(e.get("relation", "")) in bridge_rels:
129
+ core_ids.add(str(e.get("source", "")))
130
+ core_ids.add(str(e.get("target", "")))
131
+
132
+ # Expand by small neighborhood to preserve local navigability.
133
+ if hops > 0 and core_ids:
134
+ adj: dict[str, set[str]] = {}
135
+ for e in edges:
136
+ s = str(e.get("source", ""))
137
+ t = str(e.get("target", ""))
138
+ if not s or not t:
139
+ continue
140
+ adj.setdefault(s, set()).add(t)
141
+ adj.setdefault(t, set()).add(s)
142
+ frontier = set(core_ids)
143
+ keep_ids = set(core_ids)
144
+ for _ in range(hops):
145
+ nxt: set[str] = set()
146
+ for nid in frontier:
147
+ nxt.update(adj.get(nid, set()))
148
+ nxt -= keep_ids
149
+ if not nxt:
150
+ break
151
+ keep_ids.update(nxt)
152
+ frontier = nxt
153
+ else:
154
+ keep_ids = core_ids
155
+
156
+ thin_nodes = [by_id[nid] for nid in keep_ids if nid in by_id]
157
+ thin_edges = []
158
+ for e in edges:
159
+ s = str(e.get("source", ""))
160
+ t = str(e.get("target", ""))
161
+ if s in keep_ids and t in keep_ids:
162
+ thin_edges.append(e)
163
+
164
+ out = dict(data)
165
+ out["nodes"] = thin_nodes
166
+ out[edge_key] = thin_edges
167
+ out["bridge_thin"] = {
168
+ "enabled": True,
169
+ "hops": hops,
170
+ "seed_nodes": len(core_ids),
171
+ "nodes": len(thin_nodes),
172
+ "edges": len(thin_edges),
173
+ }
174
+ return out
175
+
176
+
177
+ def _iter_code_files(repo_path: Path, *, max_file_size: int) -> Iterable[Path]:
178
+ for root, dirs, files in os.walk(repo_path):
179
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS and not d.startswith(".cache")]
180
+ root_path = Path(root)
181
+ for name in files:
182
+ p = root_path / name
183
+ if p.suffix.lower() not in (_FRONTEND_EXTS | _BACKEND_EXTS):
184
+ continue
185
+ try:
186
+ if p.stat().st_size > max_file_size:
187
+ continue
188
+ except OSError:
189
+ continue
190
+ yield p
191
+
192
+
193
+ def _extract_api_facts(repo: str, repo_path: Path, file_path: Path) -> tuple[list[ApiFact], list[ApiFact]]:
194
+ rel = _norm_path(str(file_path.relative_to(repo_path)))
195
+ ext = file_path.suffix.lower()
196
+ is_frontend = ext in _FRONTEND_EXTS
197
+ is_backend = ext in _BACKEND_EXTS
198
+ if not is_frontend and not is_backend:
199
+ return [], []
200
+
201
+ try:
202
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
203
+ except OSError:
204
+ return [], []
205
+
206
+ frontend: list[ApiFact] = []
207
+ backend: list[ApiFact] = []
208
+
209
+ for lineno, line in enumerate(text.splitlines(), 1):
210
+ for m in _MTOP_LITERAL_RE.finditer(line):
211
+ api = _norm_api(m.group(1))
212
+ if not api:
213
+ continue
214
+ fact = ApiFact(repo=repo, rel_path=rel, line=lineno, api=api, source="literal")
215
+ if is_frontend:
216
+ frontend.append(fact)
217
+ if is_backend:
218
+ backend.append(fact)
219
+
220
+ # Assignment-based fallback (weaker than literal mtop token).
221
+ for m in _API_ASSIGN_RE.finditer(line):
222
+ api = _norm_api(m.group(1))
223
+ if not api:
224
+ continue
225
+ fact = ApiFact(repo=repo, rel_path=rel, line=lineno, api=api, source="assign")
226
+ if is_frontend:
227
+ frontend.append(fact)
228
+ if is_backend:
229
+ backend.append(fact)
230
+
231
+ return frontend, backend
232
+
233
+
234
+ def _parse_line_num(source_location: object) -> int | None:
235
+ if not isinstance(source_location, str):
236
+ return None
237
+ m = re.search(r"L(\d+)", source_location)
238
+ if not m:
239
+ return None
240
+ try:
241
+ return int(m.group(1))
242
+ except ValueError:
243
+ return None
244
+
245
+
246
+ def _pick_symbol_node(
247
+ nodes: list[dict],
248
+ by_repo_source: dict[tuple[str, str], list[int]],
249
+ repo: str,
250
+ rel_path: str,
251
+ line: int,
252
+ ) -> str | None:
253
+ key = (repo, _norm_path(rel_path))
254
+ idxs = by_repo_source.get(key, [])
255
+ if not idxs:
256
+ return None
257
+
258
+ basename = Path(rel_path).name
259
+
260
+ def score(idx: int) -> tuple[int, int, int]:
261
+ n = nodes[idx]
262
+ s = 0
263
+ if n.get("file_type") == "code":
264
+ s += 50
265
+ label = str(n.get("label", ""))
266
+ # Prefer symbols over file-name wrapper nodes.
267
+ if label and label != basename:
268
+ s += 30
269
+ ln = _parse_line_num(n.get("source_location"))
270
+ if ln is not None:
271
+ s += 10
272
+ if ln <= line:
273
+ s += 5
274
+ dist = abs(line - ln)
275
+ else:
276
+ dist = 1_000_000
277
+ # Higher score first, then closer line.
278
+ return (s, -dist, -idx)
279
+
280
+ best_idx = max(idxs, key=score)
281
+ return str(nodes[best_idx].get("id", "")) or None
282
+
283
+
284
+ def _api_node_id(api: str) -> str:
285
+ return "bridge_mtop_api__" + re.sub(r"[^a-z0-9]+", "_", api.lower()).strip("_")
286
+
287
+
288
+ def _repo_node_id(repo: str) -> str:
289
+ return "bridge_repo__" + re.sub(r"[^a-z0-9]+", "_", repo.lower()).strip("_")
290
+
291
+
292
+ def _ensure_api_node(nodes: list[dict], node_ids: set[str], api: str) -> tuple[str, bool]:
293
+ nid = _api_node_id(api)
294
+ if nid in node_ids:
295
+ return nid, False
296
+ node_ids.add(nid)
297
+ nodes.append(
298
+ {
299
+ "id": nid,
300
+ "label": api,
301
+ "norm_label": api.lower(),
302
+ "file_type": "concept",
303
+ "source_file": "bridge-mtop://derived",
304
+ "source_location": None,
305
+ "bridge_kind": "mtop_api",
306
+ "bridge_source": "bridge-mtop",
307
+ }
308
+ )
309
+ return nid, True
310
+
311
+
312
+ def _ensure_repo_node(nodes: list[dict], node_ids: set[str], repo: str) -> tuple[str, bool]:
313
+ nid = _repo_node_id(repo)
314
+ if nid in node_ids:
315
+ return nid, False
316
+ node_ids.add(nid)
317
+ nodes.append(
318
+ {
319
+ "id": nid,
320
+ "label": f"repo:{repo}",
321
+ "norm_label": f"repo:{repo}".lower(),
322
+ "file_type": "concept",
323
+ "source_file": "bridge-mtop://derived",
324
+ "source_location": None,
325
+ "bridge_kind": "repo",
326
+ "bridge_source": "bridge-mtop",
327
+ "repo": repo,
328
+ }
329
+ )
330
+ return nid, True
331
+
332
+
333
+ def bridge_mtop(
334
+ *,
335
+ graph_in: Path,
336
+ graph_out: Path,
337
+ repos_root: Path,
338
+ report_path: Path | None = None,
339
+ max_file_size: int = 1_000_000,
340
+ repo_tags: list[str] | None = None,
341
+ thin_out_path: Path | None = None,
342
+ thin_hops: int = 1,
343
+ ) -> BridgeStats:
344
+ data, edge_key = _load_graph(graph_in)
345
+ nodes: list[dict] = data["nodes"]
346
+ edges: list[dict] = data[edge_key]
347
+
348
+ node_ids = {str(n.get("id", "")) for n in nodes}
349
+
350
+ # Index nodes by (repo, source_file).
351
+ by_repo_source: dict[tuple[str, str], list[int]] = {}
352
+ for i, n in enumerate(nodes):
353
+ repo = str(n.get("repo", "")).strip() or "default"
354
+ src = _norm_path(str(n.get("source_file", "")))
355
+ if not src:
356
+ continue
357
+ by_repo_source.setdefault((repo, src), []).append(i)
358
+
359
+ tags = repo_tags or _repo_tags_from_graph(nodes)
360
+ frontend_facts: list[ApiFact] = []
361
+ backend_facts: list[ApiFact] = []
362
+ files_scanned = 0
363
+ repos_scanned = 0
364
+
365
+ for tag in tags:
366
+ repo_path = repos_root / tag if tag != "default" else repos_root
367
+ if not repo_path.exists():
368
+ continue
369
+ repos_scanned += 1
370
+ for p in _iter_code_files(repo_path, max_file_size=max_file_size):
371
+ files_scanned += 1
372
+ fe, be = _extract_api_facts(tag, repo_path, p)
373
+ frontend_facts.extend(fe)
374
+ backend_facts.extend(be)
375
+
376
+ # De-duplicate facts by (repo, rel_path, line, api, source).
377
+ frontend_facts = list(dict.fromkeys(frontend_facts))
378
+ backend_facts = list(dict.fromkeys(backend_facts))
379
+
380
+ existing_edges = {_edge_key(e) for e in edges}
381
+
382
+ api_nodes_added = 0
383
+ edge_calls_added = 0
384
+ edge_impl_added = 0
385
+ unresolved_frontend = 0
386
+ unresolved_backend = 0
387
+ repo_pair_counts: dict[tuple[str, str], int] = {}
388
+
389
+ backend_by_api: dict[str, list[str]] = {}
390
+ for fact in backend_facts:
391
+ target = _pick_symbol_node(nodes, by_repo_source, fact.repo, fact.rel_path, fact.line)
392
+ if not target:
393
+ unresolved_backend += 1
394
+ continue
395
+ backend_by_api.setdefault(fact.api, []).append(target)
396
+
397
+ for fact in frontend_facts:
398
+ source = _pick_symbol_node(nodes, by_repo_source, fact.repo, fact.rel_path, fact.line)
399
+ if not source:
400
+ unresolved_frontend += 1
401
+ continue
402
+
403
+ api_nid, created = _ensure_api_node(nodes, node_ids, fact.api)
404
+ if created:
405
+ api_nodes_added += 1
406
+
407
+ e = {
408
+ "source": source,
409
+ "target": api_nid,
410
+ "relation": "calls_mtop",
411
+ "context": "mtop_call",
412
+ "confidence": "EXTRACTED" if fact.source == "literal" else "INFERRED",
413
+ "confidence_score": 1.0 if fact.source == "literal" else 0.8,
414
+ "source_file": _norm_path(f"{fact.repo}/{fact.rel_path}"),
415
+ "source_location": f"L{fact.line}",
416
+ "bridge_source": "bridge-mtop",
417
+ }
418
+ k = _edge_key(e)
419
+ if k not in existing_edges:
420
+ edges.append(e)
421
+ existing_edges.add(k)
422
+ edge_calls_added += 1
423
+
424
+ # One API can be implemented in multiple repos/services.
425
+ for api, impl_nodes in backend_by_api.items():
426
+ api_nid, created = _ensure_api_node(nodes, node_ids, api)
427
+ if created:
428
+ api_nodes_added += 1
429
+ for impl in set(impl_nodes):
430
+ e = {
431
+ "source": api_nid,
432
+ "target": impl,
433
+ "relation": "implemented_by",
434
+ "context": "mtop_impl",
435
+ "confidence": "EXTRACTED",
436
+ "confidence_score": 1.0,
437
+ "source_file": "bridge-mtop://derived",
438
+ "source_location": None,
439
+ "bridge_source": "bridge-mtop",
440
+ }
441
+ k = _edge_key(e)
442
+ if k not in existing_edges:
443
+ edges.append(e)
444
+ existing_edges.add(k)
445
+ edge_impl_added += 1
446
+
447
+ # Optional repo-level navigation edges derived from API bridges.
448
+ # A->B means frontend repo A calls an mtop API implemented in backend repo B.
449
+ impl_repo_by_api: dict[str, set[str]] = {}
450
+ for fact in backend_facts:
451
+ if fact.api in backend_by_api:
452
+ impl_repo_by_api.setdefault(fact.api, set()).add(fact.repo)
453
+ for fact in frontend_facts:
454
+ for be_repo in impl_repo_by_api.get(fact.api, set()):
455
+ if be_repo == fact.repo:
456
+ continue
457
+ repo_pair_counts[(fact.repo, be_repo)] = repo_pair_counts.get((fact.repo, be_repo), 0) + 1
458
+ for (fe_repo, be_repo), count in repo_pair_counts.items():
459
+ src_repo_nid, _ = _ensure_repo_node(nodes, node_ids, fe_repo)
460
+ tgt_repo_nid, _ = _ensure_repo_node(nodes, node_ids, be_repo)
461
+ e = {
462
+ "source": src_repo_nid,
463
+ "target": tgt_repo_nid,
464
+ "relation": "depends_on_repo",
465
+ "context": "cross_repo_mtop",
466
+ "confidence": "EXTRACTED",
467
+ "confidence_score": 1.0,
468
+ "source_file": "bridge-mtop://derived",
469
+ "source_location": None,
470
+ "bridge_source": "bridge-mtop",
471
+ "weight": float(count),
472
+ }
473
+ k = _edge_key(e)
474
+ if k not in existing_edges:
475
+ edges.append(e)
476
+ existing_edges.add(k)
477
+
478
+ stats = BridgeStats(
479
+ repos_scanned=repos_scanned,
480
+ files_scanned=files_scanned,
481
+ frontend_facts=len(frontend_facts),
482
+ backend_facts=len(backend_facts),
483
+ api_nodes_added=api_nodes_added,
484
+ edge_calls_added=edge_calls_added,
485
+ edge_impl_added=edge_impl_added,
486
+ unresolved_frontend=unresolved_frontend,
487
+ unresolved_backend=unresolved_backend,
488
+ )
489
+
490
+ data["nodes"] = nodes
491
+ data[edge_key] = edges
492
+ _save_graph(graph_out, data)
493
+ if thin_out_path is not None:
494
+ _save_graph(thin_out_path, _thin_graph_data(data, edge_key, hops=max(0, thin_hops)))
495
+
496
+ if report_path is not None:
497
+ report_path.parent.mkdir(parents=True, exist_ok=True)
498
+ report_path.write_text(
499
+ json.dumps(
500
+ {
501
+ "repos_scanned": stats.repos_scanned,
502
+ "files_scanned": stats.files_scanned,
503
+ "frontend_facts": stats.frontend_facts,
504
+ "backend_facts": stats.backend_facts,
505
+ "api_nodes_added": stats.api_nodes_added,
506
+ "edge_calls_added": stats.edge_calls_added,
507
+ "edge_impl_added": stats.edge_impl_added,
508
+ "unresolved_frontend": stats.unresolved_frontend,
509
+ "unresolved_backend": stats.unresolved_backend,
510
+ },
511
+ indent=2,
512
+ ensure_ascii=False,
513
+ ),
514
+ encoding="utf-8",
515
+ )
516
+
517
+ return stats