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/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,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,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