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/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,6 @@
1
+ """Structural code analysis helpers."""
2
+
3
+ from honestcode.structure.extractor import StructureExtractor
4
+ from honestcode.structure.relations import ParseResult, StructEdge
5
+
6
+ __all__ = ["ParseResult", "StructEdge", "StructureExtractor"]
@@ -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