codevira 1.6.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.
- codevira-1.6.0.dist-info/LICENSE +21 -0
- codevira-1.6.0.dist-info/METADATA +477 -0
- codevira-1.6.0.dist-info/RECORD +58 -0
- codevira-1.6.0.dist-info/WHEEL +5 -0
- codevira-1.6.0.dist-info/entry_points.txt +2 -0
- codevira-1.6.0.dist-info/top_level.txt +2 -0
- indexer/__init__.py +1 -0
- indexer/chunker.py +428 -0
- indexer/global_db.py +197 -0
- indexer/graph_generator.py +380 -0
- indexer/index_codebase.py +588 -0
- indexer/outcome_tracker.py +172 -0
- indexer/rule_learner.py +186 -0
- indexer/sqlite_graph.py +640 -0
- indexer/treesitter_parser.py +423 -0
- mcp_server/__init__.py +1 -0
- mcp_server/__main__.py +20 -0
- mcp_server/auto_init.py +257 -0
- mcp_server/cli.py +622 -0
- mcp_server/crash_logger.py +236 -0
- mcp_server/data/__init__.py +1 -0
- mcp_server/data/agents/builder.md +84 -0
- mcp_server/data/agents/developer.md +111 -0
- mcp_server/data/agents/documenter.md +138 -0
- mcp_server/data/agents/orchestrator.md +96 -0
- mcp_server/data/agents/planner.md +106 -0
- mcp_server/data/agents/reviewer.md +82 -0
- mcp_server/data/agents/tester.md +83 -0
- mcp_server/data/config.example.yaml +33 -0
- mcp_server/data/rules/coding-standards.md +48 -0
- mcp_server/data/rules/engineering-excellence.md +28 -0
- mcp_server/data/rules/git-cicd-governance.md +32 -0
- mcp_server/data/rules/git_commits.md +130 -0
- mcp_server/data/rules/incremental-updates.md +5 -0
- mcp_server/data/rules/master_rule.md +187 -0
- mcp_server/data/rules/multi-language.md +19 -0
- mcp_server/data/rules/persistence.md +21 -0
- mcp_server/data/rules/resilience-observability.md +17 -0
- mcp_server/data/rules/smoke-testing.md +48 -0
- mcp_server/data/rules/testing-standards.md +23 -0
- mcp_server/detect.py +284 -0
- mcp_server/gitignore.py +284 -0
- mcp_server/global_sync.py +187 -0
- mcp_server/http_server.py +341 -0
- mcp_server/ide_inject.py +444 -0
- mcp_server/launchd.py +156 -0
- mcp_server/migrate.py +215 -0
- mcp_server/paths.py +256 -0
- mcp_server/prompts.py +136 -0
- mcp_server/server.py +1049 -0
- mcp_server/tools/__init__.py +0 -0
- mcp_server/tools/changesets.py +223 -0
- mcp_server/tools/code_reader.py +335 -0
- mcp_server/tools/graph.py +637 -0
- mcp_server/tools/learning.py +238 -0
- mcp_server/tools/playbook.py +89 -0
- mcp_server/tools/roadmap.py +599 -0
- mcp_server/tools/search.py +145 -0
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP tools for managing multi-file change sets.
|
|
3
|
+
Tracks in-progress fixes that span multiple files across sessions.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from mcp_server.paths import get_data_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _changesets_dir() -> Path:
|
|
17
|
+
return get_data_dir() / "graph" / "changesets"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _changeset_path(changeset_id: str) -> Path:
|
|
21
|
+
return _changesets_dir() / f"{changeset_id}.yaml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def start_changeset(
|
|
25
|
+
changeset_id: str,
|
|
26
|
+
description: str,
|
|
27
|
+
files: list[str],
|
|
28
|
+
trigger: str = "medium_change",
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""
|
|
31
|
+
Begin tracking a multi-file fix. Call BEFORE touching any files.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
changeset_id: Short slug (e.g. "synonym-pipeline-fix")
|
|
35
|
+
description: What this changeset does
|
|
36
|
+
files: All files that will be modified (include ones not yet modified)
|
|
37
|
+
trigger: small_fix | medium_change | large_change
|
|
38
|
+
|
|
39
|
+
Creates .agents/graph/changesets/{id}.yaml
|
|
40
|
+
"""
|
|
41
|
+
_changesets_dir().mkdir(parents=True, exist_ok=True)
|
|
42
|
+
path = _changeset_path(changeset_id)
|
|
43
|
+
|
|
44
|
+
if path.exists():
|
|
45
|
+
return {
|
|
46
|
+
"success": False,
|
|
47
|
+
"message": f"Changeset '{changeset_id}' already exists. Use a different ID or complete the existing one.",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
data = {
|
|
51
|
+
"id": changeset_id,
|
|
52
|
+
"status": "in_progress",
|
|
53
|
+
"created": date.today().isoformat(),
|
|
54
|
+
"trigger": trigger,
|
|
55
|
+
"description": description,
|
|
56
|
+
"files_modified": [],
|
|
57
|
+
"files_pending": files,
|
|
58
|
+
"blocker": None,
|
|
59
|
+
"decisions": [],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
with open(path, "w") as f:
|
|
63
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
64
|
+
|
|
65
|
+
return {"success": True, "changeset_id": changeset_id, "tracking": files}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def update_changeset_progress(
|
|
69
|
+
changeset_id: str,
|
|
70
|
+
file_done: str,
|
|
71
|
+
blocker: str | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
Move a file from files_pending to files_modified.
|
|
75
|
+
Call after each file is completed within the changeset.
|
|
76
|
+
"""
|
|
77
|
+
path = _changeset_path(changeset_id)
|
|
78
|
+
if not path.exists():
|
|
79
|
+
return {"success": False, "message": f"Changeset '{changeset_id}' not found."}
|
|
80
|
+
|
|
81
|
+
with open(path) as f:
|
|
82
|
+
data = yaml.safe_load(f)
|
|
83
|
+
|
|
84
|
+
pending = data.get("files_pending", [])
|
|
85
|
+
modified = data.get("files_modified", [])
|
|
86
|
+
|
|
87
|
+
if file_done in pending:
|
|
88
|
+
pending.remove(file_done)
|
|
89
|
+
if file_done not in modified:
|
|
90
|
+
modified.append(file_done)
|
|
91
|
+
|
|
92
|
+
data["files_pending"] = pending
|
|
93
|
+
data["files_modified"] = modified
|
|
94
|
+
if blocker is not None:
|
|
95
|
+
data["blocker"] = blocker
|
|
96
|
+
|
|
97
|
+
with open(path, "w") as f:
|
|
98
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"success": True,
|
|
102
|
+
"files_done": len(modified),
|
|
103
|
+
"files_remaining": len(pending),
|
|
104
|
+
"blocker": blocker,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def complete_changeset(changeset_id: str, decisions: list[str]) -> dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Mark a changeset as complete. Call at session end after all files are done.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
changeset_id: The changeset to complete
|
|
114
|
+
decisions: Key decisions made (e.g. "synonyms in text only, not metadata")
|
|
115
|
+
These are written to the changeset record for future agents.
|
|
116
|
+
"""
|
|
117
|
+
path = _changeset_path(changeset_id)
|
|
118
|
+
if not path.exists():
|
|
119
|
+
return {"success": False, "message": f"Changeset '{changeset_id}' not found."}
|
|
120
|
+
|
|
121
|
+
with open(path) as f:
|
|
122
|
+
data = yaml.safe_load(f)
|
|
123
|
+
|
|
124
|
+
if data.get("files_pending"):
|
|
125
|
+
return {
|
|
126
|
+
"success": False,
|
|
127
|
+
"message": f"Cannot complete — {len(data['files_pending'])} files still pending: {data['files_pending']}",
|
|
128
|
+
"hint": "Either finish the pending files or document the blocker with update_changeset_progress.",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
data["status"] = "complete"
|
|
132
|
+
data["completed"] = date.today().isoformat()
|
|
133
|
+
data["decisions"] = decisions
|
|
134
|
+
data["blocker"] = None
|
|
135
|
+
|
|
136
|
+
with open(path, "w") as f:
|
|
137
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
138
|
+
|
|
139
|
+
return {"success": True, "changeset_id": changeset_id, "decisions_recorded": len(decisions)}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_changeset(changeset_id: str) -> dict[str, Any]:
|
|
143
|
+
"""Get the current state of a changeset."""
|
|
144
|
+
path = _changeset_path(changeset_id)
|
|
145
|
+
if not path.exists():
|
|
146
|
+
return {"found": False, "message": f"Changeset '{changeset_id}' not found."}
|
|
147
|
+
|
|
148
|
+
with open(path) as f:
|
|
149
|
+
data = yaml.safe_load(f)
|
|
150
|
+
return {"found": True, "changeset": data}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def list_open_changesets() -> dict[str, Any]:
|
|
154
|
+
"""List all in-progress changesets. Call at session start to check for unfinished work."""
|
|
155
|
+
_changesets_dir().mkdir(parents=True, exist_ok=True)
|
|
156
|
+
open_cs = []
|
|
157
|
+
for yaml_file in _changesets_dir().glob("*.yaml"):
|
|
158
|
+
try:
|
|
159
|
+
with open(yaml_file) as f:
|
|
160
|
+
data = yaml.safe_load(f)
|
|
161
|
+
if data and data.get("status") == "in_progress":
|
|
162
|
+
open_cs.append({
|
|
163
|
+
"id": data["id"],
|
|
164
|
+
"description": data.get("description", ""),
|
|
165
|
+
"created": data.get("created", ""),
|
|
166
|
+
"files_pending": data.get("files_pending", []),
|
|
167
|
+
"blocker": data.get("blocker"),
|
|
168
|
+
})
|
|
169
|
+
except Exception as e:
|
|
170
|
+
try:
|
|
171
|
+
from mcp_server.crash_logger import log_crash
|
|
172
|
+
log_crash(e, context=f"list_open_changesets: reading {yaml_file.name}")
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"open_changesets": open_cs,
|
|
178
|
+
"count": len(open_cs),
|
|
179
|
+
"warning": "Complete or document blockers before starting new work." if open_cs else None,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def update_node_after_change(file_path: str, changes: dict[str, Any]) -> dict[str, Any]:
|
|
184
|
+
"""
|
|
185
|
+
Update a graph node's metadata after making changes to that file.
|
|
186
|
+
Called by the documenter agent at session end.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
file_path: The file that was changed
|
|
190
|
+
changes: Dict with any of: last_changed_by, new_rules (list), new_connections (list)
|
|
191
|
+
"""
|
|
192
|
+
from mcp_server.tools.graph import update_node
|
|
193
|
+
|
|
194
|
+
translated_changes: dict[str, Any] = {}
|
|
195
|
+
passthrough_fields = {"key_functions", "stability", "do_not_revert"}
|
|
196
|
+
|
|
197
|
+
for field in passthrough_fields:
|
|
198
|
+
if field in changes:
|
|
199
|
+
translated_changes[field] = changes[field]
|
|
200
|
+
|
|
201
|
+
if "new_rules" in changes:
|
|
202
|
+
translated_changes["rules"] = changes["new_rules"]
|
|
203
|
+
if "new_connections" in changes:
|
|
204
|
+
translated_changes["connects_to"] = changes["new_connections"]
|
|
205
|
+
|
|
206
|
+
result = update_node(file_path, translated_changes)
|
|
207
|
+
|
|
208
|
+
if "error" in result:
|
|
209
|
+
return {
|
|
210
|
+
"success": False,
|
|
211
|
+
"message": result["error"],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
response = {
|
|
215
|
+
"success": True,
|
|
216
|
+
"updated_node": file_path,
|
|
217
|
+
}
|
|
218
|
+
if "last_changed_by" in changes:
|
|
219
|
+
response["note"] = (
|
|
220
|
+
"Node metadata updated. 'last_changed_by' is not persisted in the SQLite graph schema."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return response
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
code_reader.py — MCP tools for reading source files
|
|
3
|
+
|
|
4
|
+
get_signature(file_path) → skeleton: all public symbols, signatures, docstrings, line ranges
|
|
5
|
+
get_code(file_path, symbol) → full source of one named function or class from disk
|
|
6
|
+
|
|
7
|
+
Always reads from disk. No index, no ChromaDB, no staleness risk.
|
|
8
|
+
|
|
9
|
+
Language support:
|
|
10
|
+
- Python: stdlib ast module (full support)
|
|
11
|
+
- TypeScript, Go, Rust: tree-sitter grammars via treesitter_parser
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from mcp_server.paths import get_project_root
|
|
19
|
+
from indexer.treesitter_parser import (
|
|
20
|
+
parse_file as ts_parse_file,
|
|
21
|
+
get_symbol_source as ts_get_symbol_source,
|
|
22
|
+
get_language as ts_get_language,
|
|
23
|
+
EXTENSION_MAP as TS_EXTENSION_MAP,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve(file_path: str) -> Path:
|
|
28
|
+
"""Resolve relative file path to absolute using the project root."""
|
|
29
|
+
p = Path(file_path)
|
|
30
|
+
if p.is_absolute():
|
|
31
|
+
return p
|
|
32
|
+
return get_project_root() / p
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_private(name: str) -> bool:
|
|
36
|
+
return name.startswith("_")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _node_kind(node: ast.AST) -> str:
|
|
40
|
+
if isinstance(node, ast.ClassDef):
|
|
41
|
+
return "class"
|
|
42
|
+
return "function"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _signature_line(source_lines: list[str], node: ast.AST) -> str:
|
|
46
|
+
"""Return the def/class line (first line of the node), stripped."""
|
|
47
|
+
return source_lines[node.lineno - 1].strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_signature(file_path: str) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Get the skeleton of a source file: all public function and class names,
|
|
53
|
+
their signatures, docstrings, and line ranges.
|
|
54
|
+
|
|
55
|
+
Supports Python, TypeScript, Go, and Rust.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
file_path: Relative path from project root (e.g. 'src/services/generator.py')
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
dict with module_docstring, symbols list, and file metadata
|
|
62
|
+
"""
|
|
63
|
+
abs_path = _resolve(file_path)
|
|
64
|
+
|
|
65
|
+
if not abs_path.exists():
|
|
66
|
+
return {
|
|
67
|
+
"found": False,
|
|
68
|
+
"file_path": file_path,
|
|
69
|
+
"error": f"File not found: {abs_path}",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ext = abs_path.suffix.lower()
|
|
73
|
+
|
|
74
|
+
# Non-Python: dispatch to tree-sitter
|
|
75
|
+
if ext in TS_EXTENSION_MAP:
|
|
76
|
+
return _get_signature_treesitter(file_path, abs_path)
|
|
77
|
+
|
|
78
|
+
# Python: existing ast-based extraction
|
|
79
|
+
if ext == ".py":
|
|
80
|
+
return _get_signature_python(file_path, abs_path)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"found": False,
|
|
84
|
+
"file_path": file_path,
|
|
85
|
+
"error": f"Unsupported file type: {ext}. Supported: .py, .ts, .tsx, .go, .rs",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_signature_treesitter(file_path: str, abs_path: Path) -> dict:
|
|
90
|
+
"""Get file skeleton using tree-sitter for TS/Go/Rust files."""
|
|
91
|
+
try:
|
|
92
|
+
parsed = ts_parse_file(str(abs_path))
|
|
93
|
+
except (ValueError, FileNotFoundError) as e:
|
|
94
|
+
return {
|
|
95
|
+
"found": False,
|
|
96
|
+
"file_path": file_path,
|
|
97
|
+
"error": str(e),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
symbols = []
|
|
101
|
+
for sym in parsed.symbols:
|
|
102
|
+
if not sym.is_public:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
entry = {
|
|
106
|
+
"name": sym.name,
|
|
107
|
+
"kind": sym.kind,
|
|
108
|
+
"signature_line": sym.signature_line,
|
|
109
|
+
"start_line": sym.start_line,
|
|
110
|
+
"end_line": sym.end_line,
|
|
111
|
+
}
|
|
112
|
+
if sym.docstring:
|
|
113
|
+
first_line = sym.docstring.strip().splitlines()[0]
|
|
114
|
+
entry["docstring"] = first_line
|
|
115
|
+
|
|
116
|
+
if sym.methods:
|
|
117
|
+
entry["public_methods"] = sym.methods
|
|
118
|
+
|
|
119
|
+
symbols.append(entry)
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"found": True,
|
|
123
|
+
"file_path": file_path,
|
|
124
|
+
"language": parsed.language,
|
|
125
|
+
"module_docstring": parsed.module_docstring,
|
|
126
|
+
"symbol_count": len(symbols),
|
|
127
|
+
"symbols": symbols,
|
|
128
|
+
"hint": "Use get_code(file_path, symbol) to read the full body of any symbol listed above.",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_signature_python(file_path: str, abs_path: Path) -> dict:
|
|
133
|
+
"""Get file skeleton using Python ast module."""
|
|
134
|
+
source = abs_path.read_text(encoding="utf-8")
|
|
135
|
+
source_lines = source.splitlines()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
tree = ast.parse(source, filename=str(abs_path))
|
|
139
|
+
except SyntaxError as e:
|
|
140
|
+
return {
|
|
141
|
+
"found": False,
|
|
142
|
+
"file_path": file_path,
|
|
143
|
+
"error": f"Syntax error: {e}",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module_docstring = ast.get_docstring(tree) or None
|
|
147
|
+
|
|
148
|
+
symbols = []
|
|
149
|
+
for node in tree.body:
|
|
150
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
151
|
+
continue
|
|
152
|
+
if _is_private(node.name):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
kind = _node_kind(node)
|
|
156
|
+
sig_line = _signature_line(source_lines, node)
|
|
157
|
+
docstring = ast.get_docstring(node) or None
|
|
158
|
+
|
|
159
|
+
entry = {
|
|
160
|
+
"name": node.name,
|
|
161
|
+
"kind": kind,
|
|
162
|
+
"signature_line": sig_line,
|
|
163
|
+
"start_line": node.lineno,
|
|
164
|
+
"end_line": node.end_lineno,
|
|
165
|
+
}
|
|
166
|
+
if docstring:
|
|
167
|
+
first_line = docstring.strip().splitlines()[0]
|
|
168
|
+
entry["docstring"] = first_line
|
|
169
|
+
|
|
170
|
+
if kind == "class":
|
|
171
|
+
methods = []
|
|
172
|
+
for child in ast.walk(node):
|
|
173
|
+
if child is node:
|
|
174
|
+
continue
|
|
175
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
176
|
+
if not _is_private(child.name) and child.name != "__init__":
|
|
177
|
+
methods.append(child.name)
|
|
178
|
+
if methods:
|
|
179
|
+
entry["public_methods"] = methods
|
|
180
|
+
|
|
181
|
+
symbols.append(entry)
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
"found": True,
|
|
185
|
+
"file_path": file_path,
|
|
186
|
+
"module_docstring": module_docstring,
|
|
187
|
+
"symbol_count": len(symbols),
|
|
188
|
+
"symbols": symbols,
|
|
189
|
+
"hint": "Use get_code(file_path, symbol) to read the full body of any symbol listed above.",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_code(file_path: str, symbol: str | None = None) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Get the full source of a single function or class by name.
|
|
196
|
+
Always reads from disk — always current.
|
|
197
|
+
|
|
198
|
+
Supports Python, TypeScript, Go, and Rust.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
file_path: Relative path from project root
|
|
202
|
+
symbol: Function or class name. Omit to get module-level constants/assignments only.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
dict with source, start_line, end_line, kind, docstring
|
|
206
|
+
"""
|
|
207
|
+
abs_path = _resolve(file_path)
|
|
208
|
+
|
|
209
|
+
if not abs_path.exists():
|
|
210
|
+
return {
|
|
211
|
+
"found": False,
|
|
212
|
+
"file_path": file_path,
|
|
213
|
+
"symbol": symbol,
|
|
214
|
+
"error": f"File not found: {abs_path}",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ext = abs_path.suffix.lower()
|
|
218
|
+
|
|
219
|
+
# Non-Python: dispatch to tree-sitter
|
|
220
|
+
if ext in TS_EXTENSION_MAP:
|
|
221
|
+
return _get_code_treesitter(file_path, abs_path, symbol)
|
|
222
|
+
|
|
223
|
+
# Python: existing ast-based extraction
|
|
224
|
+
if ext == ".py":
|
|
225
|
+
return _get_code_python(file_path, abs_path, symbol)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
"found": False,
|
|
229
|
+
"file_path": file_path,
|
|
230
|
+
"symbol": symbol,
|
|
231
|
+
"error": f"Unsupported file type: {ext}. Supported: .py, .ts, .tsx, .go, .rs",
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _get_code_treesitter(file_path: str, abs_path: Path, symbol: str | None) -> dict:
|
|
236
|
+
"""Get symbol source using tree-sitter for TS/Go/Rust files."""
|
|
237
|
+
# symbol=None → return module-level info
|
|
238
|
+
if symbol is None:
|
|
239
|
+
try:
|
|
240
|
+
parsed = ts_parse_file(str(abs_path))
|
|
241
|
+
except (ValueError, FileNotFoundError) as e:
|
|
242
|
+
return {
|
|
243
|
+
"found": False,
|
|
244
|
+
"file_path": file_path,
|
|
245
|
+
"symbol": None,
|
|
246
|
+
"error": str(e),
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
"found": True,
|
|
250
|
+
"file_path": file_path,
|
|
251
|
+
"symbol": None,
|
|
252
|
+
"kind": "module_info",
|
|
253
|
+
"language": parsed.language,
|
|
254
|
+
"module_docstring": parsed.module_docstring,
|
|
255
|
+
"imports": [imp.module for imp in parsed.imports],
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Named symbol lookup
|
|
259
|
+
result = ts_get_symbol_source(str(abs_path), symbol)
|
|
260
|
+
result["file_path"] = file_path
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_code_python(file_path: str, abs_path: Path, symbol: str | None) -> dict:
|
|
265
|
+
"""Get symbol source using Python ast module."""
|
|
266
|
+
source = abs_path.read_text(encoding="utf-8")
|
|
267
|
+
source_lines = source.splitlines()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
tree = ast.parse(source, filename=str(abs_path))
|
|
271
|
+
except SyntaxError as e:
|
|
272
|
+
return {
|
|
273
|
+
"found": False,
|
|
274
|
+
"file_path": file_path,
|
|
275
|
+
"symbol": symbol,
|
|
276
|
+
"error": f"Syntax error: {e}",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# symbol=None → return module-level non-function, non-class content
|
|
280
|
+
if symbol is None:
|
|
281
|
+
module_docstring = ast.get_docstring(tree) or None
|
|
282
|
+
assignments = []
|
|
283
|
+
for node in tree.body:
|
|
284
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
|
285
|
+
lines = source_lines[node.lineno - 1 : node.end_lineno]
|
|
286
|
+
assignments.append({
|
|
287
|
+
"start_line": node.lineno,
|
|
288
|
+
"end_line": node.end_lineno,
|
|
289
|
+
"source": "\n".join(lines),
|
|
290
|
+
})
|
|
291
|
+
return {
|
|
292
|
+
"found": True,
|
|
293
|
+
"file_path": file_path,
|
|
294
|
+
"symbol": None,
|
|
295
|
+
"kind": "module_constants",
|
|
296
|
+
"module_docstring": module_docstring,
|
|
297
|
+
"assignments": assignments,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Walk all nodes to find functions inside classes too
|
|
301
|
+
for node in ast.walk(tree):
|
|
302
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
303
|
+
continue
|
|
304
|
+
if node.name != symbol:
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
kind = _node_kind(node)
|
|
308
|
+
docstring = ast.get_docstring(node) or None
|
|
309
|
+
lines = source_lines[node.lineno - 1 : node.end_lineno]
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"found": True,
|
|
313
|
+
"file_path": file_path,
|
|
314
|
+
"symbol": symbol,
|
|
315
|
+
"kind": kind,
|
|
316
|
+
"start_line": node.lineno,
|
|
317
|
+
"end_line": node.end_lineno,
|
|
318
|
+
"docstring": docstring,
|
|
319
|
+
"source": "\n".join(lines),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Symbol not found — provide available symbol names as a hint
|
|
323
|
+
available = []
|
|
324
|
+
for node in ast.walk(tree):
|
|
325
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
326
|
+
available.append(node.name)
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"found": False,
|
|
330
|
+
"file_path": file_path,
|
|
331
|
+
"symbol": symbol,
|
|
332
|
+
"error": f"Symbol '{symbol}' not found in {file_path}",
|
|
333
|
+
"available_symbols": sorted(set(available)),
|
|
334
|
+
"hint": "Call get_signature(file_path) to see public symbols with line ranges.",
|
|
335
|
+
}
|