honestcode 0.2.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.
Potentially problematic release.
This version of honestcode might be problematic. Click here for more details.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +338 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +229 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +157 -0
- honestcode/honest/router.py +200 -0
- honestcode/honest/symbol_index.py +291 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +141 -0
- honestcode/mcp/server.py +305 -0
- honestcode/mcp/tools.py +1267 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +195 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +296 -0
- honestcode/structure/multi_lang.py +180 -0
- honestcode/structure/relations.py +43 -0
- honestcode/structure/utils.py +31 -0
- honestcode-0.2.0.dist-info/METADATA +811 -0
- honestcode-0.2.0.dist-info/RECORD +26 -0
- honestcode-0.2.0.dist-info/WHEEL +5 -0
- honestcode-0.2.0.dist-info/entry_points.txt +3 -0
- honestcode-0.2.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.2.0.dist-info/top_level.txt +1 -0
honestcode/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sandboxed code execution for HonestCode.
|
|
3
|
+
|
|
4
|
+
Executes untrusted code in a fresh Python subprocess with resource limits:
|
|
5
|
+
- POSIX: RLIMIT_AS (memory), RLIMIT_CPU (cpu time), RLIMIT_NOFILE (open files)
|
|
6
|
+
- Windows: Job object with process/memory limits (best-effort via ctypes)
|
|
7
|
+
- Both: timeout, isolated temp directory, restricted PYTHONPATH
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
__all__ = ["ExecutionResult", "SandboxExecutor"]
|
|
25
|
+
|
|
26
|
+
# Environment variable names containing these markers are scrubbed before
|
|
27
|
+
# the sandboxed subprocess starts, so secrets (API keys, tokens, passwords)
|
|
28
|
+
# never reach code being executed.
|
|
29
|
+
_SENSITIVE_ENV_MARKERS = (
|
|
30
|
+
"KEY",
|
|
31
|
+
"TOKEN",
|
|
32
|
+
"SECRET",
|
|
33
|
+
"PASSWORD",
|
|
34
|
+
"PASSWD",
|
|
35
|
+
"CREDENTIAL",
|
|
36
|
+
"AUTH",
|
|
37
|
+
"SIGNATURE",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ExecutionResult:
|
|
43
|
+
success: bool
|
|
44
|
+
stdout: str = ""
|
|
45
|
+
stderr: str = ""
|
|
46
|
+
output: str = ""
|
|
47
|
+
error: str = ""
|
|
48
|
+
error_type: str = ""
|
|
49
|
+
runtime_seconds: float = 0.0
|
|
50
|
+
details: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"success": self.success,
|
|
55
|
+
"stdout": self.stdout,
|
|
56
|
+
"stderr": self.stderr,
|
|
57
|
+
"output": self.output or self.stdout,
|
|
58
|
+
"error": self.error or self.stderr,
|
|
59
|
+
"error_type": self.error_type,
|
|
60
|
+
"runtime_seconds": self.runtime_seconds,
|
|
61
|
+
**self.details,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SandboxExecutor:
|
|
66
|
+
"""Execute Python code snippets in a restricted subprocess."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, timeout: int | None = None, memory_mb: int | None = None):
|
|
69
|
+
# Environment overrides (HONESTCODE_TIMEOUT / HONESTCODE_MEMORY_MB)
|
|
70
|
+
# apply when the caller does not pass explicit values.
|
|
71
|
+
env_timeout = os.environ.get("HONESTCODE_TIMEOUT", "")
|
|
72
|
+
self.timeout = (
|
|
73
|
+
timeout if timeout is not None else int(env_timeout) if env_timeout.isdigit() else 10
|
|
74
|
+
)
|
|
75
|
+
env_memory = os.environ.get("HONESTCODE_MEMORY_MB", "")
|
|
76
|
+
self.memory_mb = (
|
|
77
|
+
memory_mb if memory_mb is not None else int(env_memory) if env_memory.isdigit() else 256
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def execute(
|
|
81
|
+
self,
|
|
82
|
+
code: str,
|
|
83
|
+
prelude: str = "",
|
|
84
|
+
known_names: set[str] | None = None,
|
|
85
|
+
) -> ExecutionResult:
|
|
86
|
+
"""Run *code* in a fresh Python interpreter and return the result."""
|
|
87
|
+
known_names = known_names or set()
|
|
88
|
+
start = time.perf_counter()
|
|
89
|
+
|
|
90
|
+
with tempfile.TemporaryDirectory(prefix="honestcode_sandbox_") as tmpdir:
|
|
91
|
+
script_path = Path(tmpdir) / "script.py"
|
|
92
|
+
full_code = f"{prelude}\n{code}\n"
|
|
93
|
+
script_path.write_text(full_code, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
env = {
|
|
96
|
+
k: v
|
|
97
|
+
for k, v in os.environ.items()
|
|
98
|
+
if not any(marker in k.upper() for marker in _SENSITIVE_ENV_MARKERS)
|
|
99
|
+
}
|
|
100
|
+
env["PYTHONPATH"] = ""
|
|
101
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
102
|
+
|
|
103
|
+
preexec = self._get_preexec_fn()
|
|
104
|
+
creationflags = self._get_creationflags()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
proc = subprocess.run(
|
|
108
|
+
[sys.executable, "-u", str(script_path)],
|
|
109
|
+
cwd=tmpdir,
|
|
110
|
+
env=env,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=self.timeout,
|
|
114
|
+
preexec_fn=preexec,
|
|
115
|
+
creationflags=creationflags,
|
|
116
|
+
)
|
|
117
|
+
runtime = time.perf_counter() - start
|
|
118
|
+
stdout = proc.stdout or ""
|
|
119
|
+
stderr = proc.stderr or ""
|
|
120
|
+
success = proc.returncode == 0
|
|
121
|
+
err_text = stderr[:50_000] if not success else ""
|
|
122
|
+
error_type = ""
|
|
123
|
+
if not success and err_text:
|
|
124
|
+
m = re.search(r"^(\w+Error|\w+Exception)", err_text, re.MULTILINE)
|
|
125
|
+
if m:
|
|
126
|
+
error_type = m.group(1)
|
|
127
|
+
return ExecutionResult(
|
|
128
|
+
success=success,
|
|
129
|
+
stdout=stdout[:50_000],
|
|
130
|
+
stderr=stderr[:50_000],
|
|
131
|
+
output=stdout[:50_000],
|
|
132
|
+
error=err_text,
|
|
133
|
+
error_type=error_type,
|
|
134
|
+
runtime_seconds=round(runtime, 3),
|
|
135
|
+
details={"returncode": proc.returncode, "known_names": sorted(known_names)},
|
|
136
|
+
)
|
|
137
|
+
except subprocess.TimeoutExpired as e:
|
|
138
|
+
runtime = time.perf_counter() - start
|
|
139
|
+
return ExecutionResult(
|
|
140
|
+
success=False,
|
|
141
|
+
error=f"Execution timed out after {self.timeout}s",
|
|
142
|
+
stderr=e.stderr or "",
|
|
143
|
+
runtime_seconds=round(runtime, 3),
|
|
144
|
+
details={"timeout": True},
|
|
145
|
+
)
|
|
146
|
+
except Exception as e: # noqa: BLE001
|
|
147
|
+
runtime = time.perf_counter() - start
|
|
148
|
+
return ExecutionResult(
|
|
149
|
+
success=False,
|
|
150
|
+
error=f"Sandbox failed: {e}",
|
|
151
|
+
runtime_seconds=round(runtime, 3),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _get_preexec_fn(self):
|
|
155
|
+
"""Return a POSIX preexec_fn that sets resource limits, if available.
|
|
156
|
+
|
|
157
|
+
Each limit is applied best-effort: some platforms (e.g. macOS CI
|
|
158
|
+
runners) reject certain ``setrlimit`` calls, and a single failure
|
|
159
|
+
inside ``preexec_fn`` kills the child before it can run at all.
|
|
160
|
+
"""
|
|
161
|
+
if sys.platform == "win32":
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
import resource
|
|
165
|
+
except Exception: # noqa: BLE001
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def limit_resources():
|
|
169
|
+
def _setrlimit(name, soft, hard):
|
|
170
|
+
try:
|
|
171
|
+
resource.setrlimit(getattr(resource, name), (soft, hard))
|
|
172
|
+
except (ValueError, OSError):
|
|
173
|
+
logger.warning("Could not set %s in sandbox: %s", name, sys.exc_info()[1])
|
|
174
|
+
|
|
175
|
+
# Memory limit
|
|
176
|
+
max_bytes = self.memory_mb * 1024 * 1024
|
|
177
|
+
_setrlimit("RLIMIT_AS", max_bytes, max_bytes)
|
|
178
|
+
# CPU time limit (soft = timeout, hard = timeout + 2s buffer)
|
|
179
|
+
_setrlimit("RLIMIT_CPU", self.timeout, self.timeout + 2)
|
|
180
|
+
# Limit open files to prevent fd exhaustion
|
|
181
|
+
_setrlimit("RLIMIT_NOFILE", 64, 64)
|
|
182
|
+
|
|
183
|
+
return limit_resources
|
|
184
|
+
|
|
185
|
+
def _get_creationflags(self) -> int:
|
|
186
|
+
"""Return Windows-specific creation flags for process isolation."""
|
|
187
|
+
if sys.platform != "win32":
|
|
188
|
+
return 0
|
|
189
|
+
# CREATE_NO_WINDOW: don't open a console window
|
|
190
|
+
# BELOW_NORMAL_PRIORITY_CLASS: reduce scheduling priority
|
|
191
|
+
return 0x08000000 | 0x00008000
|
|
192
|
+
|
|
193
|
+
def validate_snippet(self, code: str, known_names: set[str]) -> ExecutionResult:
|
|
194
|
+
"""Quickly run a snippet to see if it raises a NameError for unknown symbols."""
|
|
195
|
+
return self.execute(code=code, known_names=known_names)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse Python source files into ``ParseResult`` objects.
|
|
3
|
+
|
|
4
|
+
Provides AST-based extraction of functions, classes, variables, imports,
|
|
5
|
+
imports and intra-file reference edges with minimal false positives.
|
|
6
|
+
|
|
7
|
+
The implementation uses the standard-library ``ast`` module directly; no
|
|
8
|
+
native parser dependency is required. A ``parser`` constructor argument is
|
|
9
|
+
accepted for callers who want to plug in a tree-sitter-backed parser that
|
|
10
|
+
follows the same interface, but it is optional.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from honestcode.structure.relations import ParseResult, StructEdge
|
|
19
|
+
from honestcode.structure.utils import call_name
|
|
20
|
+
|
|
21
|
+
__all__ = ["StructureExtractor"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StructureExtractor:
|
|
25
|
+
"""Extract functions, classes, variables, imports and local edges from a Python file."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, parser=None):
|
|
28
|
+
# ``parser`` is accepted for backwards compatibility and for callers
|
|
29
|
+
# who want to plug in an alternate parser. The default implementation
|
|
30
|
+
# uses the standard-library ``ast`` module and does not require any
|
|
31
|
+
# native dependency.
|
|
32
|
+
self.parser = parser
|
|
33
|
+
|
|
34
|
+
def parse_file(self, file_path: str | Path) -> ParseResult:
|
|
35
|
+
"""Parse *file_path* and return a ``ParseResult``."""
|
|
36
|
+
p = Path(file_path)
|
|
37
|
+
source = p.read_text(encoding="utf-8")
|
|
38
|
+
return self.parse_source(source)
|
|
39
|
+
|
|
40
|
+
def parse_source(self, source: str) -> ParseResult:
|
|
41
|
+
"""Parse raw source code and return a ``ParseResult``."""
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(source)
|
|
44
|
+
except SyntaxError:
|
|
45
|
+
# Fallback: return empty result so callers can continue.
|
|
46
|
+
return ParseResult(
|
|
47
|
+
edges=[],
|
|
48
|
+
func_defs={},
|
|
49
|
+
class_defs={},
|
|
50
|
+
var_defs={},
|
|
51
|
+
imports=set(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
func_defs: dict[str, tuple[int, int]] = {}
|
|
55
|
+
class_defs: dict[str, tuple[int, int]] = {}
|
|
56
|
+
var_defs: dict[str, tuple[int, int]] = {}
|
|
57
|
+
imports: set[str] = set()
|
|
58
|
+
imported_symbols: dict[str, str] = {}
|
|
59
|
+
edges: list[StructEdge] = []
|
|
60
|
+
|
|
61
|
+
self._walk(
|
|
62
|
+
tree,
|
|
63
|
+
source,
|
|
64
|
+
scope="",
|
|
65
|
+
func_defs=func_defs,
|
|
66
|
+
class_defs=class_defs,
|
|
67
|
+
var_defs=var_defs,
|
|
68
|
+
imports=imports,
|
|
69
|
+
imported_symbols=imported_symbols,
|
|
70
|
+
edges=edges,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return ParseResult(
|
|
74
|
+
edges=edges,
|
|
75
|
+
func_defs=func_defs,
|
|
76
|
+
class_defs=class_defs,
|
|
77
|
+
var_defs=var_defs,
|
|
78
|
+
imports=imports,
|
|
79
|
+
imported_symbols=imported_symbols,
|
|
80
|
+
exported_symbols={n for n in func_defs if not n.startswith("_")}
|
|
81
|
+
| {n for n in class_defs if not n.startswith("_")},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _walk(
|
|
85
|
+
self,
|
|
86
|
+
node: ast.AST,
|
|
87
|
+
source: str,
|
|
88
|
+
scope: str,
|
|
89
|
+
func_defs: dict[str, tuple[int, int]],
|
|
90
|
+
class_defs: dict[str, tuple[int, int]],
|
|
91
|
+
var_defs: dict[str, tuple[int, int]],
|
|
92
|
+
imports: set[str],
|
|
93
|
+
imported_symbols: dict[str, str],
|
|
94
|
+
edges: list[StructEdge],
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Recursively walk the AST collecting definitions and edges."""
|
|
97
|
+
for child in ast.iter_child_nodes(node):
|
|
98
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
99
|
+
name = self._scoped_name(scope, child.name)
|
|
100
|
+
func_defs[name] = (child.lineno - 1, child.end_lineno - 1)
|
|
101
|
+
self._collect_local_edges(child, name, func_defs, class_defs, var_defs, edges)
|
|
102
|
+
self._walk(
|
|
103
|
+
child,
|
|
104
|
+
source,
|
|
105
|
+
scope=name,
|
|
106
|
+
func_defs=func_defs,
|
|
107
|
+
class_defs=class_defs,
|
|
108
|
+
var_defs=var_defs,
|
|
109
|
+
imports=imports,
|
|
110
|
+
imported_symbols=imported_symbols,
|
|
111
|
+
edges=edges,
|
|
112
|
+
)
|
|
113
|
+
elif isinstance(child, ast.ClassDef):
|
|
114
|
+
name = self._scoped_name(scope, child.name)
|
|
115
|
+
class_defs[name] = (child.lineno - 1, child.end_lineno - 1)
|
|
116
|
+
self._collect_local_edges(child, name, func_defs, class_defs, var_defs, edges)
|
|
117
|
+
self._walk(
|
|
118
|
+
child,
|
|
119
|
+
source,
|
|
120
|
+
scope=name,
|
|
121
|
+
func_defs=func_defs,
|
|
122
|
+
class_defs=class_defs,
|
|
123
|
+
var_defs=var_defs,
|
|
124
|
+
imports=imports,
|
|
125
|
+
imported_symbols=imported_symbols,
|
|
126
|
+
edges=edges,
|
|
127
|
+
)
|
|
128
|
+
elif isinstance(child, ast.Import):
|
|
129
|
+
for alias in child.names:
|
|
130
|
+
imports.add(alias.name.split(".")[0])
|
|
131
|
+
asname = alias.asname or alias.name.split(".")[-1]
|
|
132
|
+
imported_symbols[asname] = alias.name
|
|
133
|
+
elif isinstance(child, ast.ImportFrom):
|
|
134
|
+
module = child.module or ""
|
|
135
|
+
if child.level:
|
|
136
|
+
imports.add(module.split(".")[0] if module else ".")
|
|
137
|
+
else:
|
|
138
|
+
imports.add(module.split(".")[0])
|
|
139
|
+
for alias in child.names:
|
|
140
|
+
asname = alias.asname or alias.name
|
|
141
|
+
imported_symbols[asname] = f"{module}.{alias.name}" if module else alias.name
|
|
142
|
+
elif isinstance(child, ast.Assign):
|
|
143
|
+
for target in child.targets:
|
|
144
|
+
self._record_var_def(target, scope, var_defs)
|
|
145
|
+
elif isinstance(child, ast.AnnAssign):
|
|
146
|
+
if child.target is not None:
|
|
147
|
+
self._record_var_def(child.target, scope, var_defs)
|
|
148
|
+
else:
|
|
149
|
+
self._walk(
|
|
150
|
+
child,
|
|
151
|
+
source,
|
|
152
|
+
scope,
|
|
153
|
+
func_defs=func_defs,
|
|
154
|
+
class_defs=class_defs,
|
|
155
|
+
var_defs=var_defs,
|
|
156
|
+
imports=imports,
|
|
157
|
+
imported_symbols=imported_symbols,
|
|
158
|
+
edges=edges,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _record_var_def(
|
|
162
|
+
self,
|
|
163
|
+
target: ast.expr,
|
|
164
|
+
scope: str,
|
|
165
|
+
var_defs: dict[str, tuple[int, int]],
|
|
166
|
+
) -> None:
|
|
167
|
+
if isinstance(target, ast.Name):
|
|
168
|
+
name = self._scoped_name(scope, target.id)
|
|
169
|
+
var_defs[name] = (target.lineno - 1, target.lineno - 1)
|
|
170
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
171
|
+
for elt in target.elts:
|
|
172
|
+
self._record_var_def(elt, scope, var_defs)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _scoped_name(scope: str, name: str) -> str:
|
|
176
|
+
return f"{scope}.{name}" if scope else name
|
|
177
|
+
|
|
178
|
+
def _collect_local_edges(
|
|
179
|
+
self,
|
|
180
|
+
node: ast.AST,
|
|
181
|
+
context: str,
|
|
182
|
+
func_defs: dict[str, tuple[int, int]],
|
|
183
|
+
class_defs: dict[str, tuple[int, int]],
|
|
184
|
+
var_defs: dict[str, tuple[int, int]],
|
|
185
|
+
edges: list[StructEdge],
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Create edges from references inside *node* to locally defined symbols."""
|
|
188
|
+
local_names = set(func_defs.keys()) | set(class_defs.keys()) | set(var_defs.keys())
|
|
189
|
+
|
|
190
|
+
for child in ast.walk(node):
|
|
191
|
+
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
|
|
192
|
+
if child.id in local_names:
|
|
193
|
+
edges.append(
|
|
194
|
+
StructEdge(
|
|
195
|
+
src_start=child.lineno - 1,
|
|
196
|
+
src_end=child.lineno - 1,
|
|
197
|
+
tgt_start=func_defs.get(child.id, (0, 0))[0],
|
|
198
|
+
tgt_end=func_defs.get(child.id, (0, 0))[1],
|
|
199
|
+
relation_type="same_scope",
|
|
200
|
+
name=child.id,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
elif isinstance(child, ast.Attribute) and isinstance(child.ctx, ast.Load):
|
|
204
|
+
root_name = self._attribute_root(child)
|
|
205
|
+
if root_name and root_name in local_names:
|
|
206
|
+
edges.append(
|
|
207
|
+
StructEdge(
|
|
208
|
+
src_start=child.lineno - 1,
|
|
209
|
+
src_end=child.lineno - 1,
|
|
210
|
+
tgt_start=func_defs.get(root_name, (0, 0))[0],
|
|
211
|
+
tgt_end=func_defs.get(root_name, (0, 0))[1],
|
|
212
|
+
relation_type="same_scope",
|
|
213
|
+
name=root_name,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _attribute_root(node: ast.Attribute) -> str | None:
|
|
219
|
+
"""Return the leftmost Name of an attribute chain, if any."""
|
|
220
|
+
current: ast.expr = node.value
|
|
221
|
+
while isinstance(current, ast.Attribute):
|
|
222
|
+
current = current.value
|
|
223
|
+
return current.id if isinstance(current, ast.Name) else None
|
|
224
|
+
|
|
225
|
+
def scan_file(self, file_path: str | Path) -> list[dict]:
|
|
226
|
+
"""Return a list of obvious issues in a single file.
|
|
227
|
+
|
|
228
|
+
Kept for backwards compatibility; prefer the AST-based scan in
|
|
229
|
+
``honestcode.mcp.tools`` for richer diagnostics.
|
|
230
|
+
"""
|
|
231
|
+
p = Path(file_path)
|
|
232
|
+
res = self.parse_file(p)
|
|
233
|
+
source = p.read_text(encoding="utf-8")
|
|
234
|
+
|
|
235
|
+
defined = set(res.func_defs.keys()) | set(res.class_defs.keys()) | set(res.var_defs.keys())
|
|
236
|
+
defined |= res.imports
|
|
237
|
+
defined |= {
|
|
238
|
+
"print",
|
|
239
|
+
"len",
|
|
240
|
+
"range",
|
|
241
|
+
"enumerate",
|
|
242
|
+
"zip",
|
|
243
|
+
"map",
|
|
244
|
+
"filter",
|
|
245
|
+
"sorted",
|
|
246
|
+
"sum",
|
|
247
|
+
"min",
|
|
248
|
+
"max",
|
|
249
|
+
"abs",
|
|
250
|
+
"round",
|
|
251
|
+
"int",
|
|
252
|
+
"str",
|
|
253
|
+
"float",
|
|
254
|
+
"bool",
|
|
255
|
+
"list",
|
|
256
|
+
"dict",
|
|
257
|
+
"set",
|
|
258
|
+
"tuple",
|
|
259
|
+
"type",
|
|
260
|
+
"isinstance",
|
|
261
|
+
"hasattr",
|
|
262
|
+
"getattr",
|
|
263
|
+
"open",
|
|
264
|
+
"input",
|
|
265
|
+
"repr",
|
|
266
|
+
"vars",
|
|
267
|
+
"locals",
|
|
268
|
+
"globals",
|
|
269
|
+
"dir",
|
|
270
|
+
"super",
|
|
271
|
+
"Exception",
|
|
272
|
+
"ValueError",
|
|
273
|
+
"TypeError",
|
|
274
|
+
"KeyError",
|
|
275
|
+
"IndexError",
|
|
276
|
+
"AttributeError",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
issues: list[dict] = []
|
|
280
|
+
try:
|
|
281
|
+
tree = ast.parse(source)
|
|
282
|
+
except SyntaxError:
|
|
283
|
+
return issues
|
|
284
|
+
|
|
285
|
+
for node in ast.walk(tree):
|
|
286
|
+
if not isinstance(node, ast.Call):
|
|
287
|
+
continue
|
|
288
|
+
name = call_name(node.func)
|
|
289
|
+
if not name:
|
|
290
|
+
continue
|
|
291
|
+
base = name.split(".")[0]
|
|
292
|
+
if base in defined or name in defined:
|
|
293
|
+
continue
|
|
294
|
+
issues.append({"type": "undefined_call", "name": name, "line": node.lineno})
|
|
295
|
+
|
|
296
|
+
return issues
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Optional tree-sitter multi-language symbol extraction.
|
|
2
|
+
|
|
3
|
+
Python remains the fully supported language (stdlib ``ast``). For JavaScript,
|
|
4
|
+
TypeScript and a few other popular languages, symbol extraction is available
|
|
5
|
+
when the optional extras are installed::
|
|
6
|
+
|
|
7
|
+
pip install -e "honestcode[multi-language]"
|
|
8
|
+
|
|
9
|
+
Everything degrades gracefully: without tree-sitter, ``is_supported`` still
|
|
10
|
+
recognizes the file by extension, but ``extract_symbols`` raises
|
|
11
|
+
``TreeSitterUnavailable`` so callers can report a clear message instead of a
|
|
12
|
+
traceback.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import logging
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
LANG_BY_EXT: dict[str, str] = {
|
|
24
|
+
".js": "javascript",
|
|
25
|
+
".jsx": "javascript",
|
|
26
|
+
".mjs": "javascript",
|
|
27
|
+
".cjs": "javascript",
|
|
28
|
+
".ts": "typescript",
|
|
29
|
+
".tsx": "typescript",
|
|
30
|
+
".go": "go",
|
|
31
|
+
".rs": "rust",
|
|
32
|
+
".java": "java",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# node type -> symbol kind, per language (tree-sitter grammar node names).
|
|
36
|
+
_SYMBOL_RULES: dict[str, list[tuple[str, str]]] = {
|
|
37
|
+
"javascript": [
|
|
38
|
+
("function_declaration", "function"),
|
|
39
|
+
("class_declaration", "class"),
|
|
40
|
+
("method_definition", "method"),
|
|
41
|
+
("function_expression", "function"),
|
|
42
|
+
],
|
|
43
|
+
"typescript": [
|
|
44
|
+
("function_declaration", "function"),
|
|
45
|
+
("class_declaration", "class"),
|
|
46
|
+
("method_definition", "method"),
|
|
47
|
+
("function_expression", "function"),
|
|
48
|
+
("interface_declaration", "interface"),
|
|
49
|
+
("type_alias_declaration", "type"),
|
|
50
|
+
("enum_declaration", "enum"),
|
|
51
|
+
],
|
|
52
|
+
"go": [
|
|
53
|
+
("function_declaration", "function"),
|
|
54
|
+
("method_declaration", "method"),
|
|
55
|
+
("type_declaration", "type"),
|
|
56
|
+
("type_spec", "type"),
|
|
57
|
+
],
|
|
58
|
+
"rust": [
|
|
59
|
+
("function_item", "function"),
|
|
60
|
+
("struct_item", "struct"),
|
|
61
|
+
("enum_item", "enum"),
|
|
62
|
+
("trait_item", "trait"),
|
|
63
|
+
("impl_item", "impl"),
|
|
64
|
+
],
|
|
65
|
+
"java": [
|
|
66
|
+
("class_declaration", "class"),
|
|
67
|
+
("interface_declaration", "interface"),
|
|
68
|
+
("enum_declaration", "enum"),
|
|
69
|
+
("method_declaration", "method"),
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Some grammars name their declaration nodes with a ``type_identifier``
|
|
74
|
+
# instead of an ``identifier`` (e.g. TypeScript type aliases, Go type specs);
|
|
75
|
+
# the query patterns below use the correct node type per language.
|
|
76
|
+
_NAME_NODE_TYPES: dict[str, dict[str, str]] = {
|
|
77
|
+
"typescript": {"type_alias_declaration": "type_identifier"},
|
|
78
|
+
"go": {"type_spec": "type_identifier"},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_QUERIES: dict[str, object] = {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TreeSitterUnavailable(RuntimeError):
|
|
85
|
+
"""Raised when tree-sitter (or a language grammar) is not installed."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def detect_language(path: str | Path) -> str | None:
|
|
89
|
+
"""Return the language name for *path* by extension, or None."""
|
|
90
|
+
return LANG_BY_EXT.get(Path(path).suffix.lower())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_supported(path: str | Path) -> bool:
|
|
94
|
+
return detect_language(path) is not None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _import_tree_sitter(): # pragma: no cover - requires optional extras
|
|
98
|
+
try:
|
|
99
|
+
from tree_sitter import Language, Parser # noqa: F401
|
|
100
|
+
|
|
101
|
+
return Language, Parser
|
|
102
|
+
except ImportError as exc:
|
|
103
|
+
raise TreeSitterUnavailable(
|
|
104
|
+
"tree-sitter is not installed. Run: pip install -e '.[multi-language]'"
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_language(name: str) -> object: # pragma: no cover - requires optional extras
|
|
109
|
+
"""Load a language grammar, supporting tree-sitter >= 0.21 binding APIs."""
|
|
110
|
+
try:
|
|
111
|
+
module = importlib.import_module(f"tree_sitter_{name}")
|
|
112
|
+
except ImportError as exc:
|
|
113
|
+
raise TreeSitterUnavailable(
|
|
114
|
+
f"Grammar for '{name}' not installed (tree-sitter-{name} package)"
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
factory = getattr(module, "language", None)
|
|
118
|
+
if factory is None: # old-style bindings
|
|
119
|
+
raise TreeSitterUnavailable(f"Unsupported tree-sitter-{name} binding (no language())")
|
|
120
|
+
lang = factory()
|
|
121
|
+
# tree-sitter >= 0.22 returns a Language instance; 0.21 returns a handle.
|
|
122
|
+
try:
|
|
123
|
+
from tree_sitter import Language
|
|
124
|
+
|
|
125
|
+
if not isinstance(lang, Language):
|
|
126
|
+
lang = Language(lang)
|
|
127
|
+
except Exception: # noqa: BLE001
|
|
128
|
+
pass
|
|
129
|
+
return lang
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _query_for(name: str, Language) -> object: # pragma: no cover - requires optional extras
|
|
133
|
+
if name in _QUERIES:
|
|
134
|
+
return _QUERIES[name]
|
|
135
|
+
name_node_overrides = _NAME_NODE_TYPES.get(name, {})
|
|
136
|
+
parts = [
|
|
137
|
+
f"({node} name: ({name_node_overrides.get(node, 'identifier')}) @name.{kind})"
|
|
138
|
+
for node, kind in _SYMBOL_RULES[name]
|
|
139
|
+
]
|
|
140
|
+
query = Language.query(" ".join(parts))
|
|
141
|
+
_QUERIES[name] = query
|
|
142
|
+
return query
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_symbols(path: str | Path) -> list[dict]: # pragma: no cover - requires optional extras
|
|
146
|
+
"""Extract ``{name, line, kind}`` symbols from a non-Python source file.
|
|
147
|
+
|
|
148
|
+
Raises ``TreeSitterUnavailable`` when the optional dependency is missing.
|
|
149
|
+
"""
|
|
150
|
+
path = Path(path)
|
|
151
|
+
lang = detect_language(path)
|
|
152
|
+
if lang is None:
|
|
153
|
+
raise ValueError(f"Unsupported language for {path}")
|
|
154
|
+
|
|
155
|
+
Language, Parser = _import_tree_sitter()
|
|
156
|
+
grammar = _load_language(lang)
|
|
157
|
+
parser = Parser()
|
|
158
|
+
parser.language = grammar
|
|
159
|
+
source = path.read_bytes()
|
|
160
|
+
|
|
161
|
+
tree = parser.parse(source)
|
|
162
|
+
query = _query_for(lang, Language)
|
|
163
|
+
captures = query.captures(tree.root_node)
|
|
164
|
+
|
|
165
|
+
symbols: list[dict] = []
|
|
166
|
+
for node, tag in captures:
|
|
167
|
+
tag = str(tag)
|
|
168
|
+
if not tag.startswith("name."):
|
|
169
|
+
continue
|
|
170
|
+
name = node.text.decode("utf-8", errors="replace")
|
|
171
|
+
if not name:
|
|
172
|
+
continue
|
|
173
|
+
symbols.append(
|
|
174
|
+
{
|
|
175
|
+
"name": name,
|
|
176
|
+
"line": node.start_point[0] + 1,
|
|
177
|
+
"kind": tag.split(".", 1)[1],
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
return symbols
|