ctxgraph-code 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.
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class Node:
9
+ id: str
10
+ type: str
11
+ name: str
12
+ path: Optional[str] = None
13
+ parent_id: Optional[str] = None
14
+ summary: Optional[str] = None
15
+ importance: float = 0.5
16
+ size_bytes: int = 0
17
+ lineno: int = 0
18
+
19
+ def __hash__(self):
20
+ return hash(self.id)
21
+
22
+ def __eq__(self, other):
23
+ return isinstance(other, Node) and self.id == other.id
24
+
25
+
26
+ @dataclass
27
+ class Edge:
28
+ source_id: str
29
+ target_id: str
30
+ relation: str
31
+ weight: float = 1.0
32
+
33
+ def __hash__(self):
34
+ return hash((self.source_id, self.target_id, self.relation))
35
+
36
+ def __eq__(self, other):
37
+ return (
38
+ isinstance(other, Edge)
39
+ and self.source_id == other.source_id
40
+ and self.target_id == other.target_id
41
+ and self.relation == other.relation
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class Graph:
47
+ nodes: dict[str, Node] = field(default_factory=dict)
48
+ edges: list[Edge] = field(default_factory=list)
49
+
50
+ def add_node(self, node: Node):
51
+ self.nodes[node.id] = node
52
+
53
+ def add_edge(self, edge: Edge):
54
+ self.edges.append(edge)
55
+
56
+ def get_node(self, node_id: str) -> Optional[Node]:
57
+ return self.nodes.get(node_id)
58
+
59
+ def get_edges_from(self, source_id: str) -> list[Edge]:
60
+ return [e for e in self.edges if e.source_id == source_id]
61
+
62
+ def get_edges_to(self, target_id: str) -> list[Edge]:
63
+ return [e for e in self.edges if e.target_id == target_id]
64
+
65
+ def get_neighbors(self, node_id: str) -> list[str]:
66
+ result = set()
67
+ for e in self.edges:
68
+ if e.source_id == node_id:
69
+ result.add(e.target_id)
70
+ if e.target_id == node_id:
71
+ result.add(e.source_id)
72
+ return list(result)
73
+
74
+ def merge(self, other: Graph):
75
+ for node_id, node in other.nodes.items():
76
+ if node_id not in self.nodes:
77
+ self.nodes[node_id] = node
78
+ existing = {(e.source_id, e.target_id, e.relation) for e in self.edges}
79
+ for e in other.edges:
80
+ key = (e.source_id, e.target_id, e.relation)
81
+ if key not in existing:
82
+ self.edges.append(e)
83
+ existing.add(key)
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ from ctxgraph_code.graph.models import Node
7
+ from ctxgraph_code.graph.storage import Storage
8
+
9
+
10
+ def search_relevant_nodes(
11
+ storage: Storage,
12
+ query: str,
13
+ max_nodes: int = 15,
14
+ max_depth: int = 2,
15
+ ) -> list[tuple[Node, float]]:
16
+ tokens = _tokenize(query)
17
+ if not tokens:
18
+ return []
19
+
20
+ scored: dict[str, float] = {}
21
+ seen_ids: set[str] = set()
22
+
23
+ matched_nodes = storage.search_nodes(query)
24
+ for node in matched_nodes:
25
+ score = _compute_relevance(node, tokens)
26
+ if score > 0:
27
+ scored[node.id] = score
28
+ seen_ids.add(node.id)
29
+
30
+ if not scored:
31
+ for token in tokens:
32
+ token_nodes = storage.search_nodes(token)
33
+ for node in token_nodes:
34
+ if node.id not in seen_ids:
35
+ seen_ids.add(node.id)
36
+ score = _compute_relevance(node, tokens)
37
+ if score > 0:
38
+ scored[node.id] = score
39
+
40
+ if not scored:
41
+ return []
42
+
43
+ seed_ids = set(scored.keys())
44
+ edge_ids = set()
45
+
46
+ for _ in range(max_depth):
47
+ edges = storage.get_edges_for_nodes(seed_ids | edge_ids)
48
+ new_ids = set()
49
+ for e in edges:
50
+ if e.source_id in (seed_ids | edge_ids):
51
+ new_ids.add(e.target_id)
52
+ if e.target_id in (seed_ids | edge_ids):
53
+ new_ids.add(e.source_id)
54
+ edge_ids |= new_ids
55
+
56
+ all_ids = seed_ids | edge_ids
57
+ for nid in edge_ids:
58
+ if nid not in scored:
59
+ node = storage.get_node(nid)
60
+ if node:
61
+ neighbors = _count_matched_neighbors(nid, storage, seed_ids)
62
+ scored[nid] = 0.1 * neighbors
63
+
64
+ ranked = sorted(scored.items(), key=lambda x: x[1], reverse=True)
65
+ ranked = ranked[:max_nodes]
66
+
67
+ result = []
68
+ for nid, score in ranked:
69
+ node = storage.get_node(nid)
70
+ if node:
71
+ result.append((node, round(score, 3)))
72
+
73
+ return result
74
+
75
+
76
+ def _tokenize(text: str) -> list[str]:
77
+ text = text.lower()
78
+ tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text)
79
+ stopwords = {
80
+ "the", "a", "an", "in", "on", "at", "to", "for", "of", "is",
81
+ "fix", "bug", "implement", "add", "change", "update", "remove",
82
+ "need", "want", "please", "can", "how", "what", "where", "why",
83
+ "this", "that", "with", "from", "by", "be", "has", "have", "do",
84
+ "does", "did", "will", "would", "could", "should", "may", "might",
85
+ "file", "function", "class", "code", "issue", "problem", "error",
86
+ "work", "make", "get", "set",
87
+ }
88
+ return [t for t in tokens if t not in stopwords and len(t) > 1]
89
+
90
+
91
+ def _compute_relevance(node: Node, tokens: list[str]) -> float:
92
+ score = 0.0
93
+ text = f"{node.name} {node.summary or ''} {node.path or ''}".lower()
94
+
95
+ for token in tokens:
96
+ if token in node.name.lower():
97
+ score += 2.0
98
+ count = text.count(token)
99
+ score += count * 0.5
100
+
101
+ if node.importance:
102
+ score *= (0.5 + node.importance)
103
+
104
+ return score
105
+
106
+
107
+ def _count_matched_neighbors(
108
+ node_id: str, storage: Storage, matched_ids: set[str]
109
+ ) -> int:
110
+ edges = storage.get_edges_for_nodes({node_id})
111
+ count = 0
112
+ for e in edges:
113
+ if e.source_id in matched_ids or e.target_id in matched_ids:
114
+ count += 1
115
+ return count
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ctxgraph_code.graph.models import Edge, Graph, Node
8
+
9
+
10
+ def _table_schema() -> str:
11
+ return """
12
+ CREATE TABLE IF NOT EXISTS nodes (
13
+ id TEXT PRIMARY KEY,
14
+ type TEXT NOT NULL,
15
+ name TEXT NOT NULL,
16
+ path TEXT,
17
+ parent_id TEXT,
18
+ summary TEXT,
19
+ importance REAL DEFAULT 0.5,
20
+ size_bytes INTEGER DEFAULT 0,
21
+ lineno INTEGER DEFAULT 0
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS edges (
25
+ source_id TEXT NOT NULL,
26
+ target_id TEXT NOT NULL,
27
+ relation TEXT NOT NULL,
28
+ weight REAL DEFAULT 1.0,
29
+ PRIMARY KEY (source_id, target_id, relation)
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
33
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
34
+ CREATE INDEX IF NOT EXISTS idx_nodes_path ON nodes(path);
35
+ CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
36
+
37
+ CREATE TABLE IF NOT EXISTS metadata (
38
+ key TEXT PRIMARY KEY,
39
+ value TEXT
40
+ );
41
+ """
42
+
43
+
44
+ class Storage:
45
+ def __init__(self, db_path: str | Path):
46
+ self.db_path = Path(db_path)
47
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
48
+ self._conn: Optional[sqlite3.Connection] = None
49
+
50
+ def connect(self):
51
+ self._conn = sqlite3.connect(str(self.db_path))
52
+ self._conn.execute("PRAGMA journal_mode=WAL")
53
+ self._conn.execute("PRAGMA synchronous=NORMAL")
54
+ self._conn.row_factory = sqlite3.Row
55
+ self._init_schema()
56
+
57
+ def _init_schema(self):
58
+ self._conn.executescript(_table_schema())
59
+ self._conn.commit()
60
+
61
+ def close(self):
62
+ if self._conn:
63
+ self._conn.close()
64
+ self._conn = None
65
+
66
+ @property
67
+ def conn(self) -> sqlite3.Connection:
68
+ if self._conn is None:
69
+ raise RuntimeError("Storage not connected. Call connect() first.")
70
+ return self._conn
71
+
72
+ def update_node_summary(self, node_id: str, summary: str):
73
+ self.conn.execute(
74
+ "UPDATE nodes SET summary = ? WHERE id = ?", (summary, node_id)
75
+ )
76
+ self.conn.commit()
77
+
78
+ def save_node(self, node: Node):
79
+ self.conn.execute(
80
+ """INSERT OR REPLACE INTO nodes
81
+ (id, type, name, path, parent_id, summary, importance, size_bytes, lineno)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
83
+ (
84
+ node.id,
85
+ node.type,
86
+ node.name,
87
+ node.path,
88
+ node.parent_id,
89
+ node.summary,
90
+ node.importance,
91
+ node.size_bytes,
92
+ node.lineno,
93
+ ),
94
+ )
95
+
96
+ def save_edge(self, edge: Edge):
97
+ self.conn.execute(
98
+ """INSERT OR REPLACE INTO edges
99
+ (source_id, target_id, relation, weight)
100
+ VALUES (?, ?, ?, ?)""",
101
+ (edge.source_id, edge.target_id, edge.relation, edge.weight),
102
+ )
103
+
104
+ def save_graph(self, graph: Graph):
105
+ for node in graph.nodes.values():
106
+ self.save_node(node)
107
+ for edge in graph.edges:
108
+ self.save_edge(edge)
109
+ self.conn.commit()
110
+
111
+ def get_node(self, node_id: str) -> Optional[Node]:
112
+ row = self.conn.execute(
113
+ "SELECT * FROM nodes WHERE id = ?", (node_id,)
114
+ ).fetchone()
115
+ if row is None:
116
+ return None
117
+ return Node(
118
+ id=row["id"],
119
+ type=row["type"],
120
+ name=row["name"],
121
+ path=row["path"],
122
+ parent_id=row["parent_id"],
123
+ summary=row["summary"],
124
+ importance=row["importance"],
125
+ size_bytes=row["size_bytes"],
126
+ lineno=row["lineno"],
127
+ )
128
+
129
+ def search_nodes(self, text: str) -> list[Node]:
130
+ query = f"%{text}%"
131
+ rows = self.conn.execute(
132
+ """SELECT * FROM nodes WHERE
133
+ name LIKE ? OR summary LIKE ? OR path LIKE ?
134
+ ORDER BY importance DESC
135
+ LIMIT 50""",
136
+ (query, query, query),
137
+ ).fetchall()
138
+ return [
139
+ Node(
140
+ id=r["id"],
141
+ type=r["type"],
142
+ name=r["name"],
143
+ path=r["path"],
144
+ parent_id=r["parent_id"],
145
+ summary=r["summary"],
146
+ importance=r["importance"],
147
+ size_bytes=r["size_bytes"],
148
+ lineno=r["lineno"],
149
+ )
150
+ for r in rows
151
+ ]
152
+
153
+ def get_edges_for_nodes(self, node_ids: set[str]) -> list[Edge]:
154
+ if not node_ids:
155
+ return []
156
+ placeholders = ",".join("?" for _ in node_ids)
157
+ rows = self.conn.execute(
158
+ f"""SELECT * FROM edges WHERE
159
+ source_id IN ({placeholders}) OR target_id IN ({placeholders})""",
160
+ list(node_ids) + list(node_ids),
161
+ ).fetchall()
162
+ return [
163
+ Edge(
164
+ source_id=r["source_id"],
165
+ target_id=r["target_id"],
166
+ relation=r["relation"],
167
+ weight=r["weight"],
168
+ )
169
+ for r in rows
170
+ ]
171
+
172
+ def get_all_nodes(self) -> list[Node]:
173
+ rows = self.conn.execute("SELECT * FROM nodes").fetchall()
174
+ return [
175
+ Node(
176
+ id=r["id"],
177
+ type=r["type"],
178
+ name=r["name"],
179
+ path=r["path"],
180
+ parent_id=r["parent_id"],
181
+ summary=r["summary"],
182
+ importance=r["importance"],
183
+ size_bytes=r["size_bytes"],
184
+ lineno=r["lineno"],
185
+ )
186
+ for r in rows
187
+ ]
188
+
189
+ def get_all_edges(self) -> list[Edge]:
190
+ rows = self.conn.execute("SELECT * FROM edges").fetchall()
191
+ return [
192
+ Edge(
193
+ source_id=r["source_id"],
194
+ target_id=r["target_id"],
195
+ relation=r["relation"],
196
+ weight=r["weight"],
197
+ )
198
+ for r in rows
199
+ ]
200
+
201
+ def stats(self) -> dict:
202
+ node_count = self.conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
203
+ edge_count = self.conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
204
+ type_counts = self.conn.execute(
205
+ "SELECT type, COUNT(*) as cnt FROM nodes GROUP BY type"
206
+ ).fetchall()
207
+ return {
208
+ "nodes": node_count,
209
+ "edges": edge_count,
210
+ "types": {r["type"]: r["cnt"] for r in type_counts},
211
+ }
212
+
213
+ def save_metadata(self, key: str, value: str):
214
+ self.conn.execute(
215
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
216
+ (key, value),
217
+ )
218
+ self.conn.commit()
219
+
220
+ def get_metadata(self, key: str) -> Optional[str]:
221
+ row = self.conn.execute(
222
+ "SELECT value FROM metadata WHERE key = ?", (key,)
223
+ ).fetchone()
224
+ return row["value"] if row else None
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import Optional
5
+
6
+ from ctxgraph_code.graph.models import Node
7
+ from ctxgraph_code.graph.storage import Storage
8
+
9
+
10
+ def render_overview(storage: Storage, max_files: int = 30) -> str:
11
+ all_nodes = storage.get_all_nodes()
12
+ file_nodes = [n for n in all_nodes if n.type == "file"][:max_files]
13
+
14
+ lines = ["Project Overview", ""]
15
+ for node in file_nodes:
16
+ summary = node.summary or ""
17
+ lines.append(f" [F] {node.path or node.name}")
18
+ if summary:
19
+ lines.append(f" {summary}")
20
+
21
+ children = [
22
+ n for n in all_nodes
23
+ if n.parent_id == node.id and n.type in ("class", "function")
24
+ ]
25
+ if children:
26
+ names = [c.name for c in children[:8]]
27
+ lines.append(f" Symbols: {', '.join(names)}")
28
+
29
+ lines.append("")
30
+ return "\n".join(lines)
31
+
32
+
33
+ def render_deps(storage: Storage, file_path: str) -> str:
34
+ all_nodes = storage.get_all_nodes()
35
+ all_edges = storage.get_all_edges()
36
+ node_id = f"file:{file_path}"
37
+
38
+ node = storage.get_node(node_id)
39
+ if not node:
40
+ return f"File not found in graph: {file_path}"
41
+
42
+ imports = []
43
+ imported_by = []
44
+ for e in all_edges:
45
+ if e.source_id == node_id and e.relation == "imports":
46
+ target = storage.get_node(e.target_id)
47
+ if target:
48
+ imports.append(target.path or target.name)
49
+ if e.target_id == node_id and e.relation == "imports":
50
+ source = storage.get_node(e.source_id)
51
+ if source:
52
+ imported_by.append(source.path or source.name)
53
+
54
+ lines = [f"Dependencies for: {file_path}", ""]
55
+
56
+ symbols = [n for n in all_nodes if n.parent_id == node_id]
57
+ if symbols:
58
+ class_names = [n.name for n in symbols if n.type == "class"]
59
+ func_names = [n.name for n in symbols if n.type == "function"]
60
+ if class_names:
61
+ lines.append(f" Classes: {', '.join(class_names)}")
62
+ if func_names:
63
+ lines.append(f" Functions: {', '.join(func_names)}")
64
+ lines.append("")
65
+
66
+ if imports:
67
+ lines.append(" Imports:")
68
+ for imp in sorted(imports):
69
+ lines.append(f" -> {imp}")
70
+ else:
71
+ lines.append(" Imports: (none)")
72
+
73
+ if imported_by:
74
+ lines.append("")
75
+ lines.append(" Imported by:")
76
+ for imp in sorted(imported_by):
77
+ lines.append(f" <- {imp}")
78
+
79
+ calls_made = []
80
+ called_by = []
81
+ for e in all_edges:
82
+ if e.source_id == node_id and e.relation == "calls":
83
+ target = storage.get_node(e.target_id)
84
+ if target:
85
+ calls_made.append(f"{target.name} ({target.path})")
86
+ if e.target_id == node_id and e.relation == "calls":
87
+ source = storage.get_node(e.source_id)
88
+ if source:
89
+ called_by.append(f"{source.name} ({source.path})")
90
+
91
+ if calls_made:
92
+ lines.append("")
93
+ lines.append(" Calls:")
94
+ for c in sorted(calls_made):
95
+ lines.append(f" -> {c}")
96
+
97
+ if called_by:
98
+ lines.append("")
99
+ lines.append(" Called by:")
100
+ for c in sorted(called_by):
101
+ lines.append(f" <- {c}")
102
+
103
+ return "\n".join(lines)
104
+
105
+
106
+ def render_usedby(storage: Storage, file_path: str) -> str:
107
+ node_id = f"file:{file_path}"
108
+ node = storage.get_node(node_id)
109
+ if not node:
110
+ return f"File not found in graph: {file_path}"
111
+
112
+ all_edges = storage.get_all_edges()
113
+
114
+ imported_by = []
115
+ called_by = []
116
+ for e in all_edges:
117
+ if e.target_id == node_id and e.relation == "imports":
118
+ source = storage.get_node(e.source_id)
119
+ if source:
120
+ imported_by.append(source.path or source.name)
121
+ if e.target_id == node_id and e.relation == "calls":
122
+ source = storage.get_node(e.source_id)
123
+ if source:
124
+ called_by.append(f"{source.name} ({source.path})")
125
+
126
+ lines = [f"References to: {file_path}", ""]
127
+ if imported_by:
128
+ lines.append(f" Imported by ({len(imported_by)}):")
129
+ for ref in sorted(imported_by):
130
+ lines.append(f" {ref}")
131
+ else:
132
+ lines.append(" Imported by: (none)")
133
+
134
+ if called_by:
135
+ lines.append("")
136
+ lines.append(f" Called by ({len(called_by)}):")
137
+ for ref in sorted(called_by):
138
+ lines.append(f" {ref}")
139
+
140
+ return "\n".join(lines)
141
+
142
+
143
+ def render_symbols(storage: Storage, file_path: str) -> str:
144
+ node_id = f"file:{file_path}"
145
+ node = storage.get_node(node_id)
146
+ if not node:
147
+ return f"File not found in graph: {file_path}"
148
+
149
+ all_nodes = storage.get_all_nodes()
150
+ symbols = [n for n in all_nodes if n.parent_id == node_id]
151
+
152
+ if not symbols:
153
+ return f"No symbols found in: {file_path}"
154
+
155
+ lines = [f"Symbols in: {file_path}", ""]
156
+ for s in symbols:
157
+ tag = "[C]" if s.type == "class" else "[M]"
158
+ summary = f" - {s.summary}" if s.summary else ""
159
+ lines.append(f" {tag} {s.name} (line {s.lineno}){summary}")
160
+ if s.type == "class":
161
+ methods = [n for n in all_nodes if n.parent_id == s.id]
162
+ if methods:
163
+ for m in methods:
164
+ ms = f" - {m.summary}" if m.summary else ""
165
+ lines.append(f" [M] {m.name} (line {m.lineno}){ms}")
166
+
167
+ return "\n".join(lines)
168
+
169
+
170
+ def render_context(storage: Storage, query: str, max_nodes: int = 15) -> str:
171
+ from ctxgraph_code.graph.query import search_relevant_nodes
172
+
173
+ ranked = search_relevant_nodes(storage, query, max_nodes)
174
+ if not ranked:
175
+ return f"No context found for: {query}"
176
+
177
+ all_nodes = storage.get_all_nodes()
178
+ all_edges = storage.get_all_edges()
179
+
180
+ node_ids = {n.id for n, _ in ranked}
181
+ relevant_edges = [
182
+ e for e in all_edges
183
+ if e.source_id in node_ids and e.target_id in node_ids
184
+ ]
185
+
186
+ lines = [f"Context: {query}", ""]
187
+
188
+ file_nodes = [n for n, _ in ranked if n.type == "file"]
189
+ symbol_nodes = [n for n, _ in ranked if n.type != "file"]
190
+
191
+ for node in file_nodes:
192
+ lines.append(f" [F] {node.path or node.name}")
193
+ if node.summary:
194
+ lines.append(f" {node.summary}")
195
+ children = [
196
+ n for n in all_nodes
197
+ if n.parent_id == node.id and n.type in ("class", "function")
198
+ ]
199
+ if children:
200
+ child_names = [c.name for c in children[:10]]
201
+ lines.append(f" Symbols: {', '.join(child_names)}")
202
+
203
+ if symbol_nodes:
204
+ lines.append("")
205
+ for node in symbol_nodes:
206
+ tag = "[C]" if node.type == "class" else "[M]"
207
+ name = node.name
208
+ if node.parent_id and "::" not in node.parent_id:
209
+ parent_short = node.parent_id.split(":")[-1] if ":" in node.parent_id else node.parent_id
210
+ name = f"{parent_short}.{node.name}"
211
+ lines.append(f" {tag} {name}")
212
+ if node.summary:
213
+ lines.append(f" {node.summary}")
214
+
215
+ import_edges = [(s, t) for s, t, r in [(e.source_id, e.target_id, e.relation) for e in relevant_edges] if r == "imports"]
216
+ call_edges = [(s, t) for s, t, r in [(e.source_id, e.target_id, e.relation) for e in relevant_edges] if r == "calls"]
217
+
218
+ if import_edges or call_edges:
219
+ lines.append("")
220
+ if import_edges:
221
+ lines.append(" Dependencies:")
222
+ for src, tgt in import_edges[:10]:
223
+ src_name = _short_name(src, {n.id: n for n in all_nodes})
224
+ tgt_name = _short_name(tgt, {n.id: n for n in all_nodes})
225
+ if src_name and tgt_name:
226
+ lines.append(f" {src_name} -> {tgt_name}")
227
+ if call_edges:
228
+ lines.append(" Calls:")
229
+ for src, tgt in call_edges[:10]:
230
+ src_name = _short_name(src, {n.id: n for n in all_nodes})
231
+ tgt_name = _short_name(tgt, {n.id: n for n in all_nodes})
232
+ if src_name and tgt_name:
233
+ lines.append(f" {src_name} -> {tgt_name}")
234
+
235
+ return "\n".join(lines)
236
+
237
+
238
+ def _short_name(node_id: str, node_map: dict[str, Node]) -> Optional[str]:
239
+ if node_id in node_map:
240
+ n = node_map[node_id]
241
+ if n.type == "file":
242
+ return n.path or n.name
243
+ return f"{n.path}:{n.name}" if n.path else n.name
244
+ return node_id.split(":")[-1] if ":" in node_id else node_id