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.
- ckgraphify-0.1.0.dist-info/METADATA +608 -0
- ckgraphify-0.1.0.dist-info/RECORD +42 -0
- ckgraphify-0.1.0.dist-info/WHEEL +5 -0
- ckgraphify-0.1.0.dist-info/entry_points.txt +2 -0
- ckgraphify-0.1.0.dist-info/licenses/LICENSE +21 -0
- ckgraphify-0.1.0.dist-info/top_level.txt +2 -0
- graphify/__init__.py +28 -0
- graphify/__main__.py +2885 -0
- graphify/analyze.py +575 -0
- graphify/benchmark.py +152 -0
- graphify/bridge_mtop.py +517 -0
- graphify/build.py +353 -0
- graphify/business_map.py +1420 -0
- graphify/cache.py +244 -0
- graphify/callflow_html.py +2014 -0
- graphify/cluster.py +221 -0
- graphify/dedup.py +355 -0
- graphify/detect.py +877 -0
- graphify/export.py +1264 -0
- graphify/extract.py +6094 -0
- graphify/global_graph.py +155 -0
- graphify/google_workspace.py +223 -0
- graphify/graph_main_backend.py +1443 -0
- graphify/graph_main_frontend.py +1227 -0
- graphify/graph_main_html.py +597 -0
- graphify/graph_main_merge.py +493 -0
- graphify/graph_main_trace.py +322 -0
- graphify/hooks.py +282 -0
- graphify/ingest.py +331 -0
- graphify/llm.py +1076 -0
- graphify/manifest.py +4 -0
- graphify/report.py +196 -0
- graphify/security.py +242 -0
- graphify/serve.py +670 -0
- graphify/transcribe.py +184 -0
- graphify/tree_html.py +580 -0
- graphify/validate.py +72 -0
- graphify/watch.py +705 -0
- graphify/wiki.py +255 -0
- skill/__init__.py +1 -0
- skill/skill-codex.md +147 -0
- skill/skill.md +151 -0
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()
|
graphify/bridge_mtop.py
ADDED
|
@@ -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
|