ctxgraph 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.
- ctxgraph/__init__.py +0 -0
- ctxgraph/analyzers/__init__.py +0 -0
- ctxgraph/analyzers/python/__init__.py +0 -0
- ctxgraph/analyzers/python/importer.py +140 -0
- ctxgraph/analyzers/python/semantic.py +75 -0
- ctxgraph/analyzers/python/symbols.py +221 -0
- ctxgraph/capsule/__init__.py +0 -0
- ctxgraph/capsule/renderer.py +183 -0
- ctxgraph/cli/__init__.py +0 -0
- ctxgraph/cli/main.py +253 -0
- ctxgraph/clients/__init__.py +0 -0
- ctxgraph/clients/models.py +44 -0
- ctxgraph/config/__init__.py +0 -0
- ctxgraph/config/providers.py +150 -0
- ctxgraph/config/settings.py +232 -0
- ctxgraph/exclude/__init__.py +0 -0
- ctxgraph/exclude/patterns.py +75 -0
- ctxgraph/graph/__init__.py +0 -0
- ctxgraph/graph/builder.py +76 -0
- ctxgraph/graph/models.py +83 -0
- ctxgraph/graph/query.py +135 -0
- ctxgraph/graph/storage.py +224 -0
- ctxgraph/mcp/__init__.py +0 -0
- ctxgraph/mcp/server.py +216 -0
- ctxgraph/view/__init__.py +0 -0
- ctxgraph/view/visualizer.py +282 -0
- ctxgraph/wrapper/__init__.py +0 -0
- ctxgraph/wrapper/claude.py +139 -0
- ctxgraph-0.1.0.dist-info/METADATA +448 -0
- ctxgraph-0.1.0.dist-info/RECORD +33 -0
- ctxgraph-0.1.0.dist-info/WHEEL +5 -0
- ctxgraph-0.1.0.dist-info/entry_points.txt +3 -0
- ctxgraph-0.1.0.dist-info/top_level.txt +1 -0
ctxgraph/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ctxgraph.graph.models import Edge, Graph, Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def analyze_imports(file_path: Path, root_path: Path) -> Graph:
|
|
11
|
+
graph = Graph()
|
|
12
|
+
rel_path = _relative_path(file_path, root_path)
|
|
13
|
+
file_node_id = f"file:{rel_path}"
|
|
14
|
+
|
|
15
|
+
graph.add_node(
|
|
16
|
+
Node(
|
|
17
|
+
id=file_node_id,
|
|
18
|
+
type="file",
|
|
19
|
+
name=file_path.name,
|
|
20
|
+
path=rel_path,
|
|
21
|
+
summary=None,
|
|
22
|
+
importance=0.5,
|
|
23
|
+
size_bytes=file_path.stat().st_size,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
29
|
+
except SyntaxError:
|
|
30
|
+
return graph
|
|
31
|
+
|
|
32
|
+
for node in ast.walk(tree):
|
|
33
|
+
if isinstance(node, ast.Import):
|
|
34
|
+
for alias in node.names:
|
|
35
|
+
target = _resolve_import_target(alias.name, root_path)
|
|
36
|
+
if target:
|
|
37
|
+
_add_import_edge(graph, rel_path, file_node_id, target, alias.name)
|
|
38
|
+
|
|
39
|
+
elif isinstance(node, ast.ImportFrom):
|
|
40
|
+
if node.module is None:
|
|
41
|
+
continue
|
|
42
|
+
if node.level and node.level > 0:
|
|
43
|
+
target = _resolve_relative_import(
|
|
44
|
+
node.module, node.level, rel_path, root_path
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
target = _resolve_import_target(node.module, root_path)
|
|
48
|
+
|
|
49
|
+
if target:
|
|
50
|
+
for alias in node.names:
|
|
51
|
+
symbol_name = alias.asname or alias.name
|
|
52
|
+
edge_label = f"{node.module}.{alias.name}"
|
|
53
|
+
_add_import_edge(
|
|
54
|
+
graph, rel_path, file_node_id, target, edge_label
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return graph
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _add_import_edge(
|
|
61
|
+
graph: Graph,
|
|
62
|
+
source_rel: str,
|
|
63
|
+
source_id: str,
|
|
64
|
+
target_rel: str,
|
|
65
|
+
label: str,
|
|
66
|
+
):
|
|
67
|
+
target_id = f"file:{target_rel}"
|
|
68
|
+
if target_id not in graph.nodes:
|
|
69
|
+
graph.add_node(
|
|
70
|
+
Node(
|
|
71
|
+
id=target_id,
|
|
72
|
+
type="file",
|
|
73
|
+
name=Path(target_rel).name,
|
|
74
|
+
path=target_rel,
|
|
75
|
+
summary=None,
|
|
76
|
+
importance=0.3,
|
|
77
|
+
size_bytes=0,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
graph.add_edge(
|
|
81
|
+
Edge(
|
|
82
|
+
source_id=source_id,
|
|
83
|
+
target_id=target_id,
|
|
84
|
+
relation="imports",
|
|
85
|
+
weight=1.0,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_import_target(module_name: str, root_path: Path) -> Optional[str]:
|
|
91
|
+
package_path = module_name.replace(".", "/")
|
|
92
|
+
root_name = root_path.name
|
|
93
|
+
extensions = [".py", ".pyw"]
|
|
94
|
+
|
|
95
|
+
candidates = [package_path]
|
|
96
|
+
parts = module_name.split(".")
|
|
97
|
+
if len(parts) > 1 and parts[0] == root_name:
|
|
98
|
+
candidates.append("/".join(parts[1:]))
|
|
99
|
+
|
|
100
|
+
for base in candidates:
|
|
101
|
+
for ext in extensions:
|
|
102
|
+
candidate = f"{base}{ext}"
|
|
103
|
+
if _exists_in_project(candidate, root_path):
|
|
104
|
+
return candidate
|
|
105
|
+
init_candidate = f"{base}/__init__{ext}"
|
|
106
|
+
if _exists_in_project(init_candidate, root_path):
|
|
107
|
+
return init_candidate
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_relative_import(
|
|
112
|
+
module_name: str, level: int, source_rel: str, root_path: Path
|
|
113
|
+
) -> Optional[str]:
|
|
114
|
+
parts = Path(source_rel).parts
|
|
115
|
+
if level > len(parts):
|
|
116
|
+
return None
|
|
117
|
+
base = "/".join(parts[: len(parts) - level])
|
|
118
|
+
if module_name:
|
|
119
|
+
base = f"{base}/{module_name.replace('.', '/')}"
|
|
120
|
+
extensions = [".py", ".pyw"]
|
|
121
|
+
for ext in extensions:
|
|
122
|
+
candidate = f"{base}{ext}"
|
|
123
|
+
if _exists_in_project(candidate, root_path):
|
|
124
|
+
return candidate
|
|
125
|
+
init_candidate = f"{base}/__init__{ext}"
|
|
126
|
+
if _exists_in_project(init_candidate, root_path):
|
|
127
|
+
return init_candidate
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _exists_in_project(candidate_path: str, root_path: Path) -> bool:
|
|
132
|
+
full = root_path / candidate_path
|
|
133
|
+
return full.exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _relative_path(file_path: Path, root_path: Path) -> str:
|
|
137
|
+
try:
|
|
138
|
+
return str(file_path.relative_to(root_path)).replace("\\", "/")
|
|
139
|
+
except ValueError:
|
|
140
|
+
return file_path.name
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ctxgraph.graph.models import Node
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def enrich_node_summary(node: Node, file_path: Path) -> str:
|
|
10
|
+
if node.summary:
|
|
11
|
+
return node.summary
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
15
|
+
tree = ast.parse(source)
|
|
16
|
+
except (SyntaxError, FileNotFoundError):
|
|
17
|
+
return node.summary or ""
|
|
18
|
+
|
|
19
|
+
if node.type == "file":
|
|
20
|
+
return _summarize_file(tree, file_path)
|
|
21
|
+
elif node.type == "class":
|
|
22
|
+
return _summarize_class(tree, node.name)
|
|
23
|
+
elif node.type == "function":
|
|
24
|
+
return _summarize_function(tree, node.name)
|
|
25
|
+
|
|
26
|
+
return node.summary or ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _summarize_file(tree: ast.AST, file_path: Path) -> str:
|
|
30
|
+
doc = ast.get_docstring(tree)
|
|
31
|
+
if doc:
|
|
32
|
+
return doc.split("\n\n")[0][:200]
|
|
33
|
+
|
|
34
|
+
classes = []
|
|
35
|
+
funcs = []
|
|
36
|
+
for node in ast.iter_child_nodes(tree):
|
|
37
|
+
if isinstance(node, ast.ClassDef):
|
|
38
|
+
classes.append(node.name)
|
|
39
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
40
|
+
funcs.append(node.name)
|
|
41
|
+
|
|
42
|
+
parts = []
|
|
43
|
+
if classes:
|
|
44
|
+
parts.append(f"Defines classes: {', '.join(classes)}")
|
|
45
|
+
if funcs:
|
|
46
|
+
parts.append(f"Defines functions: {', '.join(funcs)}")
|
|
47
|
+
return "; ".join(parts) if parts else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _summarize_class(tree: ast.AST, class_name: str) -> str:
|
|
51
|
+
for node in ast.walk(tree):
|
|
52
|
+
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
|
53
|
+
doc = ast.get_docstring(node)
|
|
54
|
+
if doc:
|
|
55
|
+
return doc.split("\n\n")[0][:200]
|
|
56
|
+
methods = [
|
|
57
|
+
c.name
|
|
58
|
+
for c in ast.iter_child_nodes(node)
|
|
59
|
+
if isinstance(c, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
60
|
+
]
|
|
61
|
+
if methods:
|
|
62
|
+
return f"Methods: {', '.join(methods)}"
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _summarize_function(tree: ast.AST, func_name: str) -> str:
|
|
67
|
+
for node in ast.walk(tree):
|
|
68
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
|
|
69
|
+
doc = ast.get_docstring(node)
|
|
70
|
+
if doc:
|
|
71
|
+
return doc.split("\n\n")[0][:200]
|
|
72
|
+
args = [a.arg for a in node.args.args]
|
|
73
|
+
if args:
|
|
74
|
+
return f"Args: {', '.join(args)}"
|
|
75
|
+
return ""
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ctxgraph.graph.models import Edge, Graph, Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def analyze_symbols(file_path: Path, root_path: Path) -> Graph:
|
|
11
|
+
graph = Graph()
|
|
12
|
+
rel_path = _relative_path(file_path, root_path)
|
|
13
|
+
file_node_id = f"file:{rel_path}"
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
17
|
+
tree = ast.parse(source)
|
|
18
|
+
except SyntaxError:
|
|
19
|
+
return graph
|
|
20
|
+
|
|
21
|
+
lines = source.split("\n")
|
|
22
|
+
|
|
23
|
+
for node in ast.iter_child_nodes(tree):
|
|
24
|
+
if isinstance(node, ast.ClassDef):
|
|
25
|
+
_process_class(node, graph, file_node_id, rel_path, lines)
|
|
26
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
27
|
+
_process_function(node, graph, file_node_id, rel_path, lines)
|
|
28
|
+
|
|
29
|
+
_process_calls(tree, graph, rel_path)
|
|
30
|
+
|
|
31
|
+
return graph
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _process_class(
|
|
35
|
+
node: ast.ClassDef,
|
|
36
|
+
graph: Graph,
|
|
37
|
+
file_node_id: str,
|
|
38
|
+
rel_path: str,
|
|
39
|
+
lines: list[str],
|
|
40
|
+
):
|
|
41
|
+
class_id = f"class:{rel_path}::{node.name}"
|
|
42
|
+
summary = _extract_docstring(node)
|
|
43
|
+
bases = _get_base_names(node)
|
|
44
|
+
|
|
45
|
+
graph.add_node(
|
|
46
|
+
Node(
|
|
47
|
+
id=class_id,
|
|
48
|
+
type="class",
|
|
49
|
+
name=node.name,
|
|
50
|
+
path=rel_path,
|
|
51
|
+
parent_id=file_node_id,
|
|
52
|
+
summary=summary,
|
|
53
|
+
importance=0.6,
|
|
54
|
+
lineno=node.lineno,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
graph.add_edge(
|
|
58
|
+
Edge(
|
|
59
|
+
source_id=file_node_id,
|
|
60
|
+
target_id=class_id,
|
|
61
|
+
relation="defines",
|
|
62
|
+
weight=1.0,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for base in bases:
|
|
67
|
+
graph.add_edge(
|
|
68
|
+
Edge(
|
|
69
|
+
source_id=class_id,
|
|
70
|
+
target_id=base,
|
|
71
|
+
relation="extends",
|
|
72
|
+
weight=0.8,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for child in ast.iter_child_nodes(node):
|
|
77
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
78
|
+
_process_method(child, graph, class_id, rel_path, lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _process_function(
|
|
82
|
+
node: (ast.FunctionDef | ast.AsyncFunctionDef),
|
|
83
|
+
graph: Graph,
|
|
84
|
+
file_node_id: str,
|
|
85
|
+
rel_path: str,
|
|
86
|
+
lines: list[str],
|
|
87
|
+
):
|
|
88
|
+
func_id = f"func:{rel_path}::{node.name}"
|
|
89
|
+
summary = _extract_docstring(node)
|
|
90
|
+
|
|
91
|
+
graph.add_node(
|
|
92
|
+
Node(
|
|
93
|
+
id=func_id,
|
|
94
|
+
type="function",
|
|
95
|
+
name=node.name,
|
|
96
|
+
path=rel_path,
|
|
97
|
+
parent_id=file_node_id,
|
|
98
|
+
summary=summary,
|
|
99
|
+
importance=0.5,
|
|
100
|
+
lineno=node.lineno,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
graph.add_edge(
|
|
104
|
+
Edge(
|
|
105
|
+
source_id=file_node_id,
|
|
106
|
+
target_id=func_id,
|
|
107
|
+
relation="defines",
|
|
108
|
+
weight=1.0,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _process_method(
|
|
114
|
+
node: (ast.FunctionDef | ast.AsyncFunctionDef),
|
|
115
|
+
graph: Graph,
|
|
116
|
+
class_id: str,
|
|
117
|
+
rel_path: str,
|
|
118
|
+
lines: list[str],
|
|
119
|
+
):
|
|
120
|
+
method_id = f"func:{rel_path}::{node.name}"
|
|
121
|
+
summary = _extract_docstring(node)
|
|
122
|
+
|
|
123
|
+
graph.add_node(
|
|
124
|
+
Node(
|
|
125
|
+
id=method_id,
|
|
126
|
+
type="function",
|
|
127
|
+
name=node.name,
|
|
128
|
+
path=rel_path,
|
|
129
|
+
parent_id=class_id,
|
|
130
|
+
summary=summary,
|
|
131
|
+
importance=0.5,
|
|
132
|
+
lineno=node.lineno,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
graph.add_edge(
|
|
136
|
+
Edge(
|
|
137
|
+
source_id=class_id,
|
|
138
|
+
target_id=method_id,
|
|
139
|
+
relation="defines",
|
|
140
|
+
weight=1.0,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _process_calls(tree: ast.AST, graph: Graph, rel_path: str):
|
|
146
|
+
for node in ast.walk(tree):
|
|
147
|
+
if isinstance(node, ast.Call):
|
|
148
|
+
func_name = _get_call_name(node.func)
|
|
149
|
+
if func_name:
|
|
150
|
+
caller_id = _find_enclosing_symbol(tree, node, rel_path)
|
|
151
|
+
if caller_id:
|
|
152
|
+
callee_node = _find_target_node(func_name, graph)
|
|
153
|
+
if callee_node:
|
|
154
|
+
graph.add_edge(
|
|
155
|
+
Edge(
|
|
156
|
+
source_id=caller_id,
|
|
157
|
+
target_id=callee_node.id,
|
|
158
|
+
relation="calls",
|
|
159
|
+
weight=0.7,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _get_call_name(node: ast.AST) -> Optional[str]:
|
|
165
|
+
if isinstance(node, ast.Name):
|
|
166
|
+
return node.id
|
|
167
|
+
elif isinstance(node, ast.Attribute):
|
|
168
|
+
return node.attr
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _find_enclosing_symbol(
|
|
173
|
+
tree: ast.AST, target_node: ast.AST, rel_path: str
|
|
174
|
+
) -> Optional[str]:
|
|
175
|
+
for node in ast.walk(tree):
|
|
176
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
177
|
+
if _contains_node(node, target_node):
|
|
178
|
+
return f"func:{rel_path}::{node.name}"
|
|
179
|
+
elif isinstance(node, ast.ClassDef):
|
|
180
|
+
for child in ast.iter_child_nodes(node):
|
|
181
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
182
|
+
if _contains_node(child, target_node):
|
|
183
|
+
return f"func:{rel_path}::{child.name}"
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _contains_node(container: ast.AST, target: ast.AST) -> bool:
|
|
188
|
+
for node in ast.walk(container):
|
|
189
|
+
if node is target:
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _find_target_node(name: str, graph: Graph) -> Optional[Node]:
|
|
195
|
+
for node in graph.nodes.values():
|
|
196
|
+
if node.type in ("function", "class") and node.name == name:
|
|
197
|
+
return node
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_docstring(node: ast.AST) -> Optional[str]:
|
|
202
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
203
|
+
doc = ast.get_docstring(node)
|
|
204
|
+
if doc:
|
|
205
|
+
return doc.split("\n\n")[0][:200]
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _get_base_names(node: ast.ClassDef) -> list[str]:
|
|
210
|
+
bases = []
|
|
211
|
+
for base in node.bases:
|
|
212
|
+
if isinstance(base, ast.Name):
|
|
213
|
+
bases.append(f"class:{base.id}")
|
|
214
|
+
return bases
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _relative_path(file_path: Path, root_path: Path) -> str:
|
|
218
|
+
try:
|
|
219
|
+
return str(file_path.relative_to(root_path)).replace("\\", "/")
|
|
220
|
+
except ValueError:
|
|
221
|
+
return file_path.name
|
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ctxgraph.graph.models import Node
|
|
7
|
+
from ctxgraph.graph.storage import Storage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_capsule(
|
|
11
|
+
storage: Storage,
|
|
12
|
+
query: str,
|
|
13
|
+
max_nodes: int = 15,
|
|
14
|
+
include_deps: bool = True,
|
|
15
|
+
) -> str:
|
|
16
|
+
from ctxgraph.graph.query import generate_context_subgraph
|
|
17
|
+
|
|
18
|
+
nodes, edges = generate_context_subgraph(storage, query, max_nodes)
|
|
19
|
+
if not nodes:
|
|
20
|
+
return _empty_capsule(query)
|
|
21
|
+
|
|
22
|
+
return _build_dsl(nodes, edges, storage, query)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_project_overview(
|
|
26
|
+
storage: Storage,
|
|
27
|
+
max_files: int = 30,
|
|
28
|
+
) -> str:
|
|
29
|
+
all_nodes = storage.get_all_nodes()
|
|
30
|
+
file_nodes = [n for n in all_nodes if n.type == "file"][:max_files]
|
|
31
|
+
|
|
32
|
+
lines = ["[CTX]Project Overview", ""]
|
|
33
|
+
for node in file_nodes:
|
|
34
|
+
summary = node.summary or ""
|
|
35
|
+
lines.append(f"[F]{node.path or node.name}")
|
|
36
|
+
if summary:
|
|
37
|
+
lines.append(f" D:{summary}")
|
|
38
|
+
|
|
39
|
+
children = [
|
|
40
|
+
n for n in all_nodes
|
|
41
|
+
if n.parent_id == node.id and n.type in ("class", "function")
|
|
42
|
+
]
|
|
43
|
+
if children:
|
|
44
|
+
names = [c.name for c in children[:8]]
|
|
45
|
+
lines.append(f" S:{', '.join(names)}")
|
|
46
|
+
|
|
47
|
+
lines.append("")
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_dsl(
|
|
52
|
+
nodes: list[Node],
|
|
53
|
+
edges: list[tuple[str, str, str]],
|
|
54
|
+
storage: Storage,
|
|
55
|
+
query: str,
|
|
56
|
+
) -> str:
|
|
57
|
+
lines = [f"[CTX]{query}", ""]
|
|
58
|
+
|
|
59
|
+
type_order = {"file": 0, "module": 1, "class": 2, "function": 3}
|
|
60
|
+
nodes_sorted = sorted(nodes, key=lambda n: type_order.get(n.type, 9))
|
|
61
|
+
|
|
62
|
+
file_nodes = [n for n in nodes_sorted if n.type == "file"]
|
|
63
|
+
symbol_nodes = [n for n in nodes_sorted if n.type != "file"]
|
|
64
|
+
|
|
65
|
+
deps_by_file: dict[str, list[str]] = defaultdict(list)
|
|
66
|
+
calls_by_file: dict[str, list[str]] = defaultdict(list)
|
|
67
|
+
for src, tgt, rel in edges:
|
|
68
|
+
src_file = _find_file_for(src, nodes)
|
|
69
|
+
if rel == "imports":
|
|
70
|
+
deps_by_file[src].append(tgt)
|
|
71
|
+
elif rel == "calls":
|
|
72
|
+
calls_by_file[src].append(tgt)
|
|
73
|
+
|
|
74
|
+
seen_files = set()
|
|
75
|
+
for node in file_nodes:
|
|
76
|
+
if node.path in seen_files:
|
|
77
|
+
continue
|
|
78
|
+
seen_files.add(node.path or node.name)
|
|
79
|
+
_render_file_node(lines, node, deps_by_file, storage)
|
|
80
|
+
|
|
81
|
+
seen_symbols = set()
|
|
82
|
+
for node in symbol_nodes:
|
|
83
|
+
if node.id in seen_symbols:
|
|
84
|
+
continue
|
|
85
|
+
seen_symbols.add(node.id)
|
|
86
|
+
_render_symbol_node(lines, node)
|
|
87
|
+
|
|
88
|
+
dep_lines = _render_dependency_edges(edges, nodes)
|
|
89
|
+
lines.extend(dep_lines)
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _render_file_node(
|
|
95
|
+
lines: list[str],
|
|
96
|
+
node: Node,
|
|
97
|
+
deps_by_file: dict[str, list[str]],
|
|
98
|
+
storage: Optional[Storage] = None,
|
|
99
|
+
):
|
|
100
|
+
path = node.path or node.name
|
|
101
|
+
lines.append(f"[F]{path}")
|
|
102
|
+
|
|
103
|
+
if node.summary:
|
|
104
|
+
lines.append(f" D:{node.summary}")
|
|
105
|
+
|
|
106
|
+
if storage:
|
|
107
|
+
children = [
|
|
108
|
+
n
|
|
109
|
+
for n in storage.get_all_nodes()
|
|
110
|
+
if n.parent_id == node.id and n.type in ("class", "function")
|
|
111
|
+
]
|
|
112
|
+
if children:
|
|
113
|
+
symbol_names = [c.name for c in children[:10]]
|
|
114
|
+
lines.append(f" S:{', '.join(symbol_names)}")
|
|
115
|
+
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _render_symbol_node(lines: list[str], node: Node):
|
|
120
|
+
type_tag = {"class": "[C]", "function": "[M]"}
|
|
121
|
+
tag = type_tag.get(node.type, "[S]")
|
|
122
|
+
name = node.name
|
|
123
|
+
|
|
124
|
+
if node.parent_id and "::" not in node.parent_id:
|
|
125
|
+
parent_short = node.parent_id.split(":")[-1] if ":" in node.parent_id else node.parent_id
|
|
126
|
+
name = f"{parent_short}.{node.name}"
|
|
127
|
+
|
|
128
|
+
lines.append(f"{tag}{name}")
|
|
129
|
+
if node.summary:
|
|
130
|
+
lines.append(f" D:{node.summary}")
|
|
131
|
+
lines.append("")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _render_dependency_edges(
|
|
135
|
+
edges: list[tuple[str, str, str]],
|
|
136
|
+
nodes: list[Node],
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
node_map = {n.id: n for n in nodes}
|
|
139
|
+
dep_lines = []
|
|
140
|
+
|
|
141
|
+
import_edges = [(s, t) for s, t, r in edges if r == "imports"]
|
|
142
|
+
if import_edges:
|
|
143
|
+
dep_lines.append("[DEP]")
|
|
144
|
+
for src, tgt in import_edges[:10]:
|
|
145
|
+
src_name = _short_name(src, node_map)
|
|
146
|
+
tgt_name = _short_name(tgt, node_map)
|
|
147
|
+
if src_name and tgt_name:
|
|
148
|
+
dep_lines.append(f" {src_name} -> {tgt_name}")
|
|
149
|
+
|
|
150
|
+
call_edges = [(s, t) for s, t, r in edges if r == "calls"]
|
|
151
|
+
if call_edges:
|
|
152
|
+
dep_lines.append("[CAL]")
|
|
153
|
+
for src, tgt in call_edges[:10]:
|
|
154
|
+
src_name = _short_name(src, node_map)
|
|
155
|
+
tgt_name = _short_name(tgt, node_map)
|
|
156
|
+
if src_name and tgt_name:
|
|
157
|
+
dep_lines.append(f" {src_name} -> {tgt_name}")
|
|
158
|
+
|
|
159
|
+
return dep_lines
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _short_name(node_id: str, node_map: dict[str, Node]) -> Optional[str]:
|
|
163
|
+
if node_id in node_map:
|
|
164
|
+
n = node_map[node_id]
|
|
165
|
+
if n.type == "file":
|
|
166
|
+
return n.path or n.name
|
|
167
|
+
return f"{n.path}:{n.name}" if n.path else n.name
|
|
168
|
+
return node_id.split(":")[-1] if ":" in node_id else node_id
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _find_file_for(node_id: str, nodes: list[Node]) -> str:
|
|
172
|
+
for n in nodes:
|
|
173
|
+
if n.id == node_id or (n.parent_id and n.parent_id == node_id):
|
|
174
|
+
return n.path or n.name
|
|
175
|
+
return node_id
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _empty_capsule(query: str) -> str:
|
|
179
|
+
return (
|
|
180
|
+
f"[CTX]{query}\n\n"
|
|
181
|
+
"[INFO]No relevant context found in the graph.\n"
|
|
182
|
+
"[INFO]Run `ctx build` first to generate the knowledge graph.\n"
|
|
183
|
+
)
|
ctxgraph/cli/__init__.py
ADDED
|
File without changes
|