honestcode 0.5.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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +428 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +276 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/guards/__init__.py +104 -0
- honestcode/guards/guard.py +777 -0
- honestcode/guards/store.py +457 -0
- honestcode/guards/taxonomy.py +228 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +200 -0
- honestcode/honest/router.py +205 -0
- honestcode/honest/symbol_index.py +446 -0
- honestcode/integrations/__init__.py +14 -0
- honestcode/integrations/pydantic_ai.py +304 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +290 -0
- honestcode/mcp/server.py +357 -0
- honestcode/mcp/tools.py +2002 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +334 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +353 -0
- honestcode/structure/multi_lang.py +267 -0
- honestcode/structure/relations.py +47 -0
- honestcode/structure/utils.py +262 -0
- honestcode/verify/__init__.py +19 -0
- honestcode/verify/evidence.py +195 -0
- honestcode/verify/surface.py +497 -0
- honestcode/verify/verify.py +1074 -0
- honestcode-0.5.0.dist-info/METADATA +669 -0
- honestcode-0.5.0.dist-info/RECORD +36 -0
- honestcode-0.5.0.dist-info/WHEEL +5 -0
- honestcode-0.5.0.dist-info/entry_points.txt +3 -0
- honestcode-0.5.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.5.0.dist-info/top_level.txt +1 -0
honestcode/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sandboxed code execution for HonestCode.
|
|
3
|
+
|
|
4
|
+
Executes 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 and job CPU-time limits, plus
|
|
7
|
+
kill-on-close, applied via ctypes (best effort; silently skipped when the
|
|
8
|
+
Job Object API is unavailable)
|
|
9
|
+
- Both: wall-clock timeout, isolated temp directory, restricted PYTHONPATH
|
|
10
|
+
|
|
11
|
+
Threat model — read this before trusting it: the sandbox is a guardrail
|
|
12
|
+
against *accidents* (infinite loops, fork bombs, runaway memory, typo'd
|
|
13
|
+
paths), not a security boundary. The child runs as the current user with full
|
|
14
|
+
file-system and network access, and environment scrubbing is name-based
|
|
15
|
+
guesswork. Do not execute code from untrusted authors inside it; use a real
|
|
16
|
+
isolation layer (container, VM) for that.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
__all__ = ["ExecutionResult", "SandboxExecutor"]
|
|
34
|
+
|
|
35
|
+
# Environment variable names containing these markers are scrubbed before
|
|
36
|
+
# the sandboxed subprocess starts. This is best-effort name guessing: it
|
|
37
|
+
# catches the common credential names but cannot know every scheme a secret
|
|
38
|
+
# might hide under (see the threat model above).
|
|
39
|
+
_SENSITIVE_ENV_MARKERS = (
|
|
40
|
+
"KEY",
|
|
41
|
+
"TOKEN",
|
|
42
|
+
"SECRET",
|
|
43
|
+
"PASSWORD",
|
|
44
|
+
"PASSWD",
|
|
45
|
+
"CREDENTIAL",
|
|
46
|
+
"AUTH",
|
|
47
|
+
"SIGNATURE",
|
|
48
|
+
"DATABASE",
|
|
49
|
+
"DSN",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ExecutionResult:
|
|
55
|
+
success: bool
|
|
56
|
+
stdout: str = ""
|
|
57
|
+
stderr: str = ""
|
|
58
|
+
output: str = ""
|
|
59
|
+
error: str = ""
|
|
60
|
+
error_type: str = ""
|
|
61
|
+
runtime_seconds: float = 0.0
|
|
62
|
+
details: dict = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
return {
|
|
66
|
+
"success": self.success,
|
|
67
|
+
"stdout": self.stdout,
|
|
68
|
+
"stderr": self.stderr,
|
|
69
|
+
"output": self.output or self.stdout,
|
|
70
|
+
"error": self.error or self.stderr,
|
|
71
|
+
"error_type": self.error_type,
|
|
72
|
+
"runtime_seconds": self.runtime_seconds,
|
|
73
|
+
**self.details,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SandboxExecutor:
|
|
78
|
+
"""Execute Python code snippets in a restricted subprocess."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, timeout: int | None = None, memory_mb: int | None = None):
|
|
81
|
+
# Environment overrides (HONESTCODE_TIMEOUT / HONESTCODE_MEMORY_MB)
|
|
82
|
+
# apply when the caller does not pass explicit values.
|
|
83
|
+
env_timeout = os.environ.get("HONESTCODE_TIMEOUT", "")
|
|
84
|
+
self.timeout = (
|
|
85
|
+
timeout if timeout is not None else int(env_timeout) if env_timeout.isdigit() else 10
|
|
86
|
+
)
|
|
87
|
+
env_memory = os.environ.get("HONESTCODE_MEMORY_MB", "")
|
|
88
|
+
self.memory_mb = (
|
|
89
|
+
memory_mb if memory_mb is not None else int(env_memory) if env_memory.isdigit() else 256
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def execute(
|
|
93
|
+
self,
|
|
94
|
+
code: str,
|
|
95
|
+
prelude: str = "",
|
|
96
|
+
known_names: set[str] | None = None,
|
|
97
|
+
) -> ExecutionResult:
|
|
98
|
+
"""Run *code* in a fresh Python interpreter and return the result."""
|
|
99
|
+
known_names = known_names or set()
|
|
100
|
+
start = time.perf_counter()
|
|
101
|
+
|
|
102
|
+
with tempfile.TemporaryDirectory(prefix="honestcode_sandbox_") as tmpdir:
|
|
103
|
+
script_path = Path(tmpdir) / "script.py"
|
|
104
|
+
full_code = f"{prelude}\n{code}\n"
|
|
105
|
+
script_path.write_text(full_code, encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
env = {
|
|
108
|
+
k: v
|
|
109
|
+
for k, v in os.environ.items()
|
|
110
|
+
if not any(marker in k.upper() for marker in _SENSITIVE_ENV_MARKERS)
|
|
111
|
+
}
|
|
112
|
+
env["PYTHONPATH"] = ""
|
|
113
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
114
|
+
|
|
115
|
+
preexec = self._get_preexec_fn()
|
|
116
|
+
creationflags = self._get_creationflags()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
proc = subprocess.Popen(
|
|
120
|
+
[sys.executable, "-u", str(script_path)],
|
|
121
|
+
cwd=tmpdir,
|
|
122
|
+
env=env,
|
|
123
|
+
stdout=subprocess.PIPE,
|
|
124
|
+
stderr=subprocess.PIPE,
|
|
125
|
+
text=True,
|
|
126
|
+
preexec_fn=preexec,
|
|
127
|
+
creationflags=creationflags,
|
|
128
|
+
)
|
|
129
|
+
except Exception as e: # noqa: BLE001
|
|
130
|
+
return ExecutionResult(
|
|
131
|
+
success=False,
|
|
132
|
+
error=f"Sandbox failed: {e}",
|
|
133
|
+
runtime_seconds=round(time.perf_counter() - start, 3),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Windows: bind the child to a Job Object *after* spawn (the
|
|
137
|
+
# POSIX path applies limits pre-exec instead).
|
|
138
|
+
job_handle = self._apply_windows_job(proc)
|
|
139
|
+
try:
|
|
140
|
+
try:
|
|
141
|
+
stdout, stderr = proc.communicate(timeout=self.timeout)
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
proc.kill()
|
|
144
|
+
stdout, stderr = proc.communicate()
|
|
145
|
+
return ExecutionResult(
|
|
146
|
+
success=False,
|
|
147
|
+
error=f"Execution timed out after {self.timeout}s",
|
|
148
|
+
stderr=(stderr or "")[:50_000],
|
|
149
|
+
runtime_seconds=round(time.perf_counter() - start, 3),
|
|
150
|
+
details={"timeout": True},
|
|
151
|
+
)
|
|
152
|
+
finally:
|
|
153
|
+
self._release_windows_job(job_handle)
|
|
154
|
+
|
|
155
|
+
runtime = time.perf_counter() - start
|
|
156
|
+
stdout = stdout or ""
|
|
157
|
+
stderr = stderr or ""
|
|
158
|
+
success = proc.returncode == 0
|
|
159
|
+
err_text = stderr[:50_000] if not success else ""
|
|
160
|
+
error_type = ""
|
|
161
|
+
if not success and err_text:
|
|
162
|
+
m = re.search(r"^(\w+Error|\w+Exception)", err_text, re.MULTILINE)
|
|
163
|
+
if m:
|
|
164
|
+
error_type = m.group(1)
|
|
165
|
+
return ExecutionResult(
|
|
166
|
+
success=success,
|
|
167
|
+
stdout=stdout[:50_000],
|
|
168
|
+
stderr=stderr[:50_000],
|
|
169
|
+
output=stdout[:50_000],
|
|
170
|
+
error=err_text,
|
|
171
|
+
error_type=error_type,
|
|
172
|
+
runtime_seconds=round(runtime, 3),
|
|
173
|
+
details={"returncode": proc.returncode, "known_names": sorted(known_names)},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _get_preexec_fn(self):
|
|
177
|
+
"""Return a POSIX preexec_fn that sets resource limits, if available.
|
|
178
|
+
|
|
179
|
+
Each limit is applied best-effort: some platforms (e.g. macOS CI
|
|
180
|
+
runners) reject certain ``setrlimit`` calls, and a single failure
|
|
181
|
+
inside ``preexec_fn`` kills the child before it can run at all.
|
|
182
|
+
"""
|
|
183
|
+
if sys.platform == "win32":
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
import resource
|
|
187
|
+
except Exception: # noqa: BLE001
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def limit_resources():
|
|
191
|
+
def _setrlimit(name, soft, hard):
|
|
192
|
+
try:
|
|
193
|
+
resource.setrlimit(getattr(resource, name), (soft, hard))
|
|
194
|
+
except (ValueError, OSError):
|
|
195
|
+
logger.warning("Could not set %s in sandbox: %s", name, sys.exc_info()[1])
|
|
196
|
+
|
|
197
|
+
# Memory limit
|
|
198
|
+
max_bytes = self.memory_mb * 1024 * 1024
|
|
199
|
+
_setrlimit("RLIMIT_AS", max_bytes, max_bytes)
|
|
200
|
+
# CPU time limit (soft = timeout, hard = timeout + 2s buffer)
|
|
201
|
+
_setrlimit("RLIMIT_CPU", self.timeout, self.timeout + 2)
|
|
202
|
+
# Limit open files to prevent fd exhaustion
|
|
203
|
+
_setrlimit("RLIMIT_NOFILE", 64, 64)
|
|
204
|
+
|
|
205
|
+
return limit_resources
|
|
206
|
+
|
|
207
|
+
def _get_creationflags(self) -> int:
|
|
208
|
+
"""Return Windows-specific creation flags for process isolation."""
|
|
209
|
+
if sys.platform != "win32":
|
|
210
|
+
return 0
|
|
211
|
+
# CREATE_NO_WINDOW: don't open a console window
|
|
212
|
+
# BELOW_NORMAL_PRIORITY_CLASS: reduce scheduling priority
|
|
213
|
+
return 0x08000000 | 0x00008000
|
|
214
|
+
|
|
215
|
+
def _apply_windows_job(self, proc: subprocess.Popen) -> int | None:
|
|
216
|
+
"""Assign *proc* to a Job Object with memory/CPU limits (Windows only).
|
|
217
|
+
|
|
218
|
+
Best effort by design: returns the job handle (to be released by
|
|
219
|
+
:meth:`_release_windows_job`), or ``None`` when anything about the
|
|
220
|
+
platform or API call refuses to cooperate — the wall-clock timeout
|
|
221
|
+
still applies in that case. ``KILL_ON_JOB_CLOSE`` also ties the
|
|
222
|
+
child's life to this process, so no orphan survives a crash.
|
|
223
|
+
"""
|
|
224
|
+
if sys.platform != "win32":
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
import ctypes
|
|
228
|
+
from ctypes import wintypes
|
|
229
|
+
|
|
230
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
231
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
232
|
+
kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
233
|
+
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
234
|
+
kernel32.SetInformationJobObject.argtypes = [
|
|
235
|
+
wintypes.HANDLE,
|
|
236
|
+
ctypes.c_int,
|
|
237
|
+
ctypes.c_void_p,
|
|
238
|
+
wintypes.DWORD,
|
|
239
|
+
]
|
|
240
|
+
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
241
|
+
kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
242
|
+
|
|
243
|
+
class IO_COUNTERS(ctypes.Structure):
|
|
244
|
+
_fields_ = [
|
|
245
|
+
(name, ctypes.c_uint64)
|
|
246
|
+
for name in (
|
|
247
|
+
"ReadOperationCount",
|
|
248
|
+
"WriteOperationCount",
|
|
249
|
+
"OtherOperationCount",
|
|
250
|
+
"ReadTransferCount",
|
|
251
|
+
"WriteTransferCount",
|
|
252
|
+
"OtherTransferCount",
|
|
253
|
+
)
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
257
|
+
_fields_ = [
|
|
258
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
259
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
260
|
+
("LimitFlags", wintypes.DWORD),
|
|
261
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
262
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
263
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
264
|
+
("Affinity", ctypes.c_size_t),
|
|
265
|
+
("PriorityClass", wintypes.DWORD),
|
|
266
|
+
("SchedulingClass", wintypes.DWORD),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
270
|
+
_fields_ = [
|
|
271
|
+
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
|
272
|
+
("IoInfo", IO_COUNTERS),
|
|
273
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
274
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
275
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
276
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
JobObjectExtendedLimitInformation = 9
|
|
280
|
+
JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x0100
|
|
281
|
+
# 0x0200 is JOB_OBJECT_LIMIT_JOB_MEMORY, not JOB_TIME. With the
|
|
282
|
+
# wrong flag the call set a job-memory limit of zero and failed
|
|
283
|
+
# with ERROR_INVALID_PARAMETER (87), so *none* of the limits —
|
|
284
|
+
# memory, CPU, kill-on-close — were applied on Windows.
|
|
285
|
+
JOB_OBJECT_LIMIT_JOB_TIME = 0x0004
|
|
286
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
287
|
+
|
|
288
|
+
job = kernel32.CreateJobObjectW(None, None)
|
|
289
|
+
if not job:
|
|
290
|
+
return None
|
|
291
|
+
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
292
|
+
info.BasicLimitInformation.LimitFlags = (
|
|
293
|
+
JOB_OBJECT_LIMIT_PROCESS_MEMORY
|
|
294
|
+
| JOB_OBJECT_LIMIT_JOB_TIME
|
|
295
|
+
| JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
296
|
+
)
|
|
297
|
+
info.ProcessMemoryLimit = self.memory_mb * 1024 * 1024
|
|
298
|
+
# Job user CPU time is expressed in 100ns units.
|
|
299
|
+
info.BasicLimitInformation.PerJobUserTimeLimit = int(self.timeout) * 10_000_000
|
|
300
|
+
if not kernel32.SetInformationJobObject(
|
|
301
|
+
job, JobObjectExtendedLimitInformation, ctypes.byref(info), ctypes.sizeof(info)
|
|
302
|
+
):
|
|
303
|
+
kernel32.CloseHandle(job)
|
|
304
|
+
return None
|
|
305
|
+
if not kernel32.AssignProcessToJobObject(job, int(proc._handle)):
|
|
306
|
+
kernel32.CloseHandle(job)
|
|
307
|
+
return None
|
|
308
|
+
return job
|
|
309
|
+
except Exception as e: # noqa: BLE001
|
|
310
|
+
logger.debug("Windows Job Object unavailable: %s", e)
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
def _release_windows_job(self, job_handle: int | None) -> None:
|
|
314
|
+
"""Close the Job Object handle once the child has been reaped.
|
|
315
|
+
|
|
316
|
+
Closing is what triggers ``KILL_ON_JOB_CLOSE``; by the time this runs
|
|
317
|
+
the child has exited or been killed, so the job object is empty.
|
|
318
|
+
"""
|
|
319
|
+
if not job_handle:
|
|
320
|
+
return
|
|
321
|
+
try:
|
|
322
|
+
import ctypes
|
|
323
|
+
from ctypes import wintypes
|
|
324
|
+
|
|
325
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
326
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
327
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
328
|
+
kernel32.CloseHandle(job_handle)
|
|
329
|
+
except Exception as e: # noqa: BLE001
|
|
330
|
+
logger.debug("Could not close job handle: %s", e)
|
|
331
|
+
|
|
332
|
+
def validate_snippet(self, code: str, known_names: set[str]) -> ExecutionResult:
|
|
333
|
+
"""Quickly run a snippet to see if it raises a NameError for unknown symbols."""
|
|
334
|
+
return self.execute(code=code, known_names=known_names)
|
|
@@ -0,0 +1,353 @@
|
|
|
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 annotation_name, call_name, read_python_source
|
|
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
|
+
|
|
37
|
+
Source is decoded through :func:`read_python_source`, so a legal
|
|
38
|
+
non-UTF-8 file (a PEP 263 coding cookie) is read correctly and an
|
|
39
|
+
undecodable one is skipped rather than aborting the whole project index.
|
|
40
|
+
"""
|
|
41
|
+
source = read_python_source(file_path)
|
|
42
|
+
if source is None:
|
|
43
|
+
return ParseResult(
|
|
44
|
+
edges=[],
|
|
45
|
+
func_defs={},
|
|
46
|
+
class_defs={},
|
|
47
|
+
var_defs={},
|
|
48
|
+
imports=set(),
|
|
49
|
+
)
|
|
50
|
+
return self.parse_source(source)
|
|
51
|
+
|
|
52
|
+
def parse_source(self, source: str) -> ParseResult:
|
|
53
|
+
"""Parse raw source code and return a ``ParseResult``."""
|
|
54
|
+
try:
|
|
55
|
+
tree = ast.parse(source)
|
|
56
|
+
except SyntaxError:
|
|
57
|
+
# Fallback: return empty result so callers can continue.
|
|
58
|
+
return ParseResult(
|
|
59
|
+
edges=[],
|
|
60
|
+
func_defs={},
|
|
61
|
+
class_defs={},
|
|
62
|
+
var_defs={},
|
|
63
|
+
imports=set(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
func_defs: dict[str, tuple[int, int]] = {}
|
|
67
|
+
class_defs: dict[str, tuple[int, int]] = {}
|
|
68
|
+
var_defs: dict[str, tuple[int, int]] = {}
|
|
69
|
+
imports: set[str] = set()
|
|
70
|
+
imported_symbols: dict[str, str] = {}
|
|
71
|
+
class_bases: dict[str, list[str]] = {}
|
|
72
|
+
edges: list[StructEdge] = []
|
|
73
|
+
|
|
74
|
+
self._walk(
|
|
75
|
+
tree,
|
|
76
|
+
source,
|
|
77
|
+
scope="",
|
|
78
|
+
func_defs=func_defs,
|
|
79
|
+
class_defs=class_defs,
|
|
80
|
+
var_defs=var_defs,
|
|
81
|
+
imports=imports,
|
|
82
|
+
imported_symbols=imported_symbols,
|
|
83
|
+
edges=edges,
|
|
84
|
+
class_bases=class_bases,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return ParseResult(
|
|
88
|
+
edges=edges,
|
|
89
|
+
func_defs=func_defs,
|
|
90
|
+
class_defs=class_defs,
|
|
91
|
+
var_defs=var_defs,
|
|
92
|
+
imports=imports,
|
|
93
|
+
imported_symbols=imported_symbols,
|
|
94
|
+
exported_symbols={n for n in func_defs if not n.startswith("_")}
|
|
95
|
+
| {n for n in class_defs if not n.startswith("_")},
|
|
96
|
+
class_bases=class_bases,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _walk(
|
|
100
|
+
self,
|
|
101
|
+
node: ast.AST,
|
|
102
|
+
source: str,
|
|
103
|
+
scope: str,
|
|
104
|
+
func_defs: dict[str, tuple[int, int]],
|
|
105
|
+
class_defs: dict[str, tuple[int, int]],
|
|
106
|
+
var_defs: dict[str, tuple[int, int]],
|
|
107
|
+
imports: set[str],
|
|
108
|
+
imported_symbols: dict[str, str],
|
|
109
|
+
edges: list[StructEdge],
|
|
110
|
+
class_bases: dict[str, list[str]],
|
|
111
|
+
in_function: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Recursively walk the AST collecting definitions and edges.
|
|
114
|
+
|
|
115
|
+
``in_function`` gates variable recording: assignments inside a function
|
|
116
|
+
body are locals, not project symbols, so they are skipped (module-level
|
|
117
|
+
and class-body assignments are still recorded).
|
|
118
|
+
"""
|
|
119
|
+
for child in ast.iter_child_nodes(node):
|
|
120
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
121
|
+
name = self._scoped_name(scope, child.name)
|
|
122
|
+
func_defs[name] = (child.lineno - 1, child.end_lineno - 1)
|
|
123
|
+
self._collect_local_edges(child, name, func_defs, class_defs, var_defs, edges)
|
|
124
|
+
self._walk(
|
|
125
|
+
child,
|
|
126
|
+
source,
|
|
127
|
+
scope=name,
|
|
128
|
+
func_defs=func_defs,
|
|
129
|
+
class_defs=class_defs,
|
|
130
|
+
var_defs=var_defs,
|
|
131
|
+
imports=imports,
|
|
132
|
+
imported_symbols=imported_symbols,
|
|
133
|
+
edges=edges,
|
|
134
|
+
class_bases=class_bases,
|
|
135
|
+
in_function=True,
|
|
136
|
+
)
|
|
137
|
+
elif isinstance(child, ast.ClassDef):
|
|
138
|
+
name = self._scoped_name(scope, child.name)
|
|
139
|
+
class_defs[name] = (child.lineno - 1, child.end_lineno - 1)
|
|
140
|
+
self._collect_local_edges(child, name, func_defs, class_defs, var_defs, edges)
|
|
141
|
+
bases = [b for b in (annotation_name(x) for x in child.bases) if b]
|
|
142
|
+
if bases:
|
|
143
|
+
class_bases[name] = bases
|
|
144
|
+
self._walk(
|
|
145
|
+
child,
|
|
146
|
+
source,
|
|
147
|
+
scope=name,
|
|
148
|
+
func_defs=func_defs,
|
|
149
|
+
class_defs=class_defs,
|
|
150
|
+
var_defs=var_defs,
|
|
151
|
+
imports=imports,
|
|
152
|
+
imported_symbols=imported_symbols,
|
|
153
|
+
edges=edges,
|
|
154
|
+
class_bases=class_bases,
|
|
155
|
+
# Class bodies hold class attributes, not function locals —
|
|
156
|
+
# even when the class itself is nested in a function.
|
|
157
|
+
in_function=False,
|
|
158
|
+
)
|
|
159
|
+
elif isinstance(child, ast.Import):
|
|
160
|
+
for alias in child.names:
|
|
161
|
+
imports.add(alias.name.split(".")[0])
|
|
162
|
+
asname = alias.asname or alias.name.split(".")[-1]
|
|
163
|
+
imported_symbols[asname] = alias.name
|
|
164
|
+
elif isinstance(child, ast.ImportFrom):
|
|
165
|
+
module = child.module or ""
|
|
166
|
+
if child.level:
|
|
167
|
+
imports.add(module.split(".")[0] if module else ".")
|
|
168
|
+
else:
|
|
169
|
+
imports.add(module.split(".")[0])
|
|
170
|
+
for alias in child.names:
|
|
171
|
+
if alias.name == "*":
|
|
172
|
+
# Star imports are keyed by their source module so
|
|
173
|
+
# several in one file do not overwrite each other;
|
|
174
|
+
# the verifier expands them against the symbol index.
|
|
175
|
+
imported_symbols[f"*{module}"] = module
|
|
176
|
+
continue
|
|
177
|
+
asname = alias.asname or alias.name
|
|
178
|
+
imported_symbols[asname] = f"{module}.{alias.name}" if module else alias.name
|
|
179
|
+
elif isinstance(child, ast.Assign) and not in_function:
|
|
180
|
+
for target in child.targets:
|
|
181
|
+
self._record_var_def(target, scope, var_defs)
|
|
182
|
+
elif isinstance(child, ast.AnnAssign) and not in_function:
|
|
183
|
+
if child.target is not None:
|
|
184
|
+
self._record_var_def(child.target, scope, var_defs)
|
|
185
|
+
else:
|
|
186
|
+
self._walk(
|
|
187
|
+
child,
|
|
188
|
+
source,
|
|
189
|
+
scope,
|
|
190
|
+
func_defs=func_defs,
|
|
191
|
+
class_defs=class_defs,
|
|
192
|
+
var_defs=var_defs,
|
|
193
|
+
imports=imports,
|
|
194
|
+
imported_symbols=imported_symbols,
|
|
195
|
+
edges=edges,
|
|
196
|
+
class_bases=class_bases,
|
|
197
|
+
in_function=in_function,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _record_var_def(
|
|
201
|
+
self,
|
|
202
|
+
target: ast.expr,
|
|
203
|
+
scope: str,
|
|
204
|
+
var_defs: dict[str, tuple[int, int]],
|
|
205
|
+
) -> None:
|
|
206
|
+
if isinstance(target, ast.Name):
|
|
207
|
+
name = self._scoped_name(scope, target.id)
|
|
208
|
+
var_defs[name] = (target.lineno - 1, target.lineno - 1)
|
|
209
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
210
|
+
for elt in target.elts:
|
|
211
|
+
self._record_var_def(elt, scope, var_defs)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _scoped_name(scope: str, name: str) -> str:
|
|
215
|
+
return f"{scope}.{name}" if scope else name
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _iter_own_scope(node: ast.AST):
|
|
219
|
+
"""Yield every node under *node* without entering nested scopes.
|
|
220
|
+
|
|
221
|
+
Nested ``FunctionDef`` / ``ClassDef`` nodes are yielded but not
|
|
222
|
+
descended into — each of them gets its own ``_collect_local_edges``
|
|
223
|
+
pass when ``_walk`` reaches it. Walking full subtrees here made
|
|
224
|
+
extraction quadratic: a 3000-line file took ~0.7s because every
|
|
225
|
+
enclosing scope re-walked all of its descendants (313k AST visits
|
|
226
|
+
for one parse).
|
|
227
|
+
"""
|
|
228
|
+
stack = list(ast.iter_child_nodes(node))
|
|
229
|
+
while stack:
|
|
230
|
+
cur = stack.pop()
|
|
231
|
+
yield cur
|
|
232
|
+
if not isinstance(cur, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
233
|
+
stack.extend(ast.iter_child_nodes(cur))
|
|
234
|
+
|
|
235
|
+
def _collect_local_edges(
|
|
236
|
+
self,
|
|
237
|
+
node: ast.AST,
|
|
238
|
+
context: str,
|
|
239
|
+
func_defs: dict[str, tuple[int, int]],
|
|
240
|
+
class_defs: dict[str, tuple[int, int]],
|
|
241
|
+
var_defs: dict[str, tuple[int, int]],
|
|
242
|
+
edges: list[StructEdge],
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Create edges from references inside *node* to locally defined symbols."""
|
|
245
|
+
local_names = set(func_defs.keys()) | set(class_defs.keys()) | set(var_defs.keys())
|
|
246
|
+
|
|
247
|
+
for child in self._iter_own_scope(node):
|
|
248
|
+
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
|
|
249
|
+
if child.id in local_names:
|
|
250
|
+
edges.append(
|
|
251
|
+
StructEdge(
|
|
252
|
+
src_start=child.lineno - 1,
|
|
253
|
+
src_end=child.lineno - 1,
|
|
254
|
+
tgt_start=func_defs.get(child.id, (0, 0))[0],
|
|
255
|
+
tgt_end=func_defs.get(child.id, (0, 0))[1],
|
|
256
|
+
relation_type="same_scope",
|
|
257
|
+
name=child.id,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
elif isinstance(child, ast.Attribute) and isinstance(child.ctx, ast.Load):
|
|
261
|
+
root_name = self._attribute_root(child)
|
|
262
|
+
if root_name and root_name in local_names:
|
|
263
|
+
edges.append(
|
|
264
|
+
StructEdge(
|
|
265
|
+
src_start=child.lineno - 1,
|
|
266
|
+
src_end=child.lineno - 1,
|
|
267
|
+
tgt_start=func_defs.get(root_name, (0, 0))[0],
|
|
268
|
+
tgt_end=func_defs.get(root_name, (0, 0))[1],
|
|
269
|
+
relation_type="same_scope",
|
|
270
|
+
name=root_name,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _attribute_root(node: ast.Attribute) -> str | None:
|
|
276
|
+
"""Return the leftmost Name of an attribute chain, if any."""
|
|
277
|
+
current: ast.expr = node.value
|
|
278
|
+
while isinstance(current, ast.Attribute):
|
|
279
|
+
current = current.value
|
|
280
|
+
return current.id if isinstance(current, ast.Name) else None
|
|
281
|
+
|
|
282
|
+
def scan_file(self, file_path: str | Path) -> list[dict]:
|
|
283
|
+
"""Return a list of obvious issues in a single file.
|
|
284
|
+
|
|
285
|
+
Kept for backwards compatibility; prefer the AST-based scan in
|
|
286
|
+
``honestcode.mcp.tools`` for richer diagnostics.
|
|
287
|
+
"""
|
|
288
|
+
p = Path(file_path)
|
|
289
|
+
res = self.parse_file(p)
|
|
290
|
+
source = p.read_text(encoding="utf-8")
|
|
291
|
+
|
|
292
|
+
defined = set(res.func_defs.keys()) | set(res.class_defs.keys()) | set(res.var_defs.keys())
|
|
293
|
+
defined |= res.imports
|
|
294
|
+
defined |= {
|
|
295
|
+
"print",
|
|
296
|
+
"len",
|
|
297
|
+
"range",
|
|
298
|
+
"enumerate",
|
|
299
|
+
"zip",
|
|
300
|
+
"map",
|
|
301
|
+
"filter",
|
|
302
|
+
"sorted",
|
|
303
|
+
"sum",
|
|
304
|
+
"min",
|
|
305
|
+
"max",
|
|
306
|
+
"abs",
|
|
307
|
+
"round",
|
|
308
|
+
"int",
|
|
309
|
+
"str",
|
|
310
|
+
"float",
|
|
311
|
+
"bool",
|
|
312
|
+
"list",
|
|
313
|
+
"dict",
|
|
314
|
+
"set",
|
|
315
|
+
"tuple",
|
|
316
|
+
"type",
|
|
317
|
+
"isinstance",
|
|
318
|
+
"hasattr",
|
|
319
|
+
"getattr",
|
|
320
|
+
"open",
|
|
321
|
+
"input",
|
|
322
|
+
"repr",
|
|
323
|
+
"vars",
|
|
324
|
+
"locals",
|
|
325
|
+
"globals",
|
|
326
|
+
"dir",
|
|
327
|
+
"super",
|
|
328
|
+
"Exception",
|
|
329
|
+
"ValueError",
|
|
330
|
+
"TypeError",
|
|
331
|
+
"KeyError",
|
|
332
|
+
"IndexError",
|
|
333
|
+
"AttributeError",
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
issues: list[dict] = []
|
|
337
|
+
try:
|
|
338
|
+
tree = ast.parse(source)
|
|
339
|
+
except SyntaxError:
|
|
340
|
+
return issues
|
|
341
|
+
|
|
342
|
+
for node in ast.walk(tree):
|
|
343
|
+
if not isinstance(node, ast.Call):
|
|
344
|
+
continue
|
|
345
|
+
name = call_name(node.func)
|
|
346
|
+
if not name:
|
|
347
|
+
continue
|
|
348
|
+
base = name.split(".")[0]
|
|
349
|
+
if base in defined or name in defined:
|
|
350
|
+
continue
|
|
351
|
+
issues.append({"type": "undefined_call", "name": name, "line": node.lineno})
|
|
352
|
+
|
|
353
|
+
return issues
|