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.
@@ -0,0 +1,267 @@
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": "tsx",
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
+ "tsx": [
53
+ ("function_declaration", "function"),
54
+ ("class_declaration", "class"),
55
+ ("method_definition", "method"),
56
+ ("function_expression", "function"),
57
+ ("interface_declaration", "interface"),
58
+ ("type_alias_declaration", "type"),
59
+ ("enum_declaration", "enum"),
60
+ ],
61
+ "go": [
62
+ ("function_declaration", "function"),
63
+ ("method_declaration", "method"),
64
+ ("type_spec", "type"),
65
+ ],
66
+ "rust": [
67
+ ("function_item", "function"),
68
+ ("struct_item", "struct"),
69
+ ("enum_item", "enum"),
70
+ ("trait_item", "trait"),
71
+ ],
72
+ "java": [
73
+ ("class_declaration", "class"),
74
+ ("interface_declaration", "interface"),
75
+ ("enum_declaration", "enum"),
76
+ ("method_declaration", "method"),
77
+ ],
78
+ }
79
+
80
+ # Some grammars name their declaration nodes with a ``type_identifier``
81
+ # instead of an ``identifier`` (e.g. TypeScript type aliases, Go type specs);
82
+ # the query patterns below use the correct node type per language.
83
+ # Grammars disagree about the node type used for a declaration's name; a query
84
+ # naming the wrong one is rejected outright ("Impossible pattern"), which broke
85
+ # every multi-language scan on the 0.25 grammars.
86
+ _NAME_NODE_TYPES: dict[str, dict[str, str]] = {
87
+ "javascript": {"method_definition": "property_identifier"},
88
+ "typescript": {
89
+ "class_declaration": "type_identifier",
90
+ "interface_declaration": "type_identifier",
91
+ "type_alias_declaration": "type_identifier",
92
+ "method_definition": "property_identifier",
93
+ },
94
+ "tsx": {
95
+ "class_declaration": "type_identifier",
96
+ "interface_declaration": "type_identifier",
97
+ "type_alias_declaration": "type_identifier",
98
+ "method_definition": "property_identifier",
99
+ },
100
+ "go": {
101
+ "method_declaration": "field_identifier",
102
+ "type_spec": "type_identifier",
103
+ },
104
+ "rust": {
105
+ "struct_item": "type_identifier",
106
+ "enum_item": "type_identifier",
107
+ "trait_item": "type_identifier",
108
+ },
109
+ }
110
+
111
+ # tree-sitter-typescript ships two grammars in one module and exposes them as
112
+ # named factories rather than a generic ``language()``.
113
+ _LANGUAGE_FACTORIES: dict[str, tuple[str, ...]] = {
114
+ "typescript": ("language_typescript", "language"),
115
+ "tsx": ("language_tsx", "language"),
116
+ }
117
+
118
+ _QUERIES: dict[str, object] = {}
119
+
120
+
121
+ class TreeSitterUnavailable(RuntimeError):
122
+ """Raised when tree-sitter (or a language grammar) is not installed."""
123
+
124
+
125
+ def detect_language(path: str | Path) -> str | None:
126
+ """Return the language name for *path* by extension, or None."""
127
+ return LANG_BY_EXT.get(Path(path).suffix.lower())
128
+
129
+
130
+ def is_supported(path: str | Path) -> bool:
131
+ return detect_language(path) is not None
132
+
133
+
134
+ def _import_tree_sitter(): # pragma: no cover - requires optional extras
135
+ try:
136
+ from tree_sitter import Language, Parser # noqa: F401
137
+
138
+ return Language, Parser
139
+ except ImportError as exc:
140
+ raise TreeSitterUnavailable(
141
+ "tree-sitter is not installed. Run: pip install -e '.[multi-language]'"
142
+ ) from exc
143
+
144
+
145
+ def _load_language(name: str) -> object: # pragma: no cover - requires optional extras
146
+ """Load a language grammar, supporting tree-sitter >= 0.21 binding APIs."""
147
+ module_name = (
148
+ "tree_sitter_typescript" if name in ("typescript", "tsx") else f"tree_sitter_{name}"
149
+ )
150
+ try:
151
+ module = importlib.import_module(module_name)
152
+ except ImportError as exc:
153
+ raise TreeSitterUnavailable(
154
+ f"Grammar for '{name}' not installed (tree-sitter-{name} package)"
155
+ ) from exc
156
+
157
+ factory = None
158
+ for attr in _LANGUAGE_FACTORIES.get(name, ("language",)):
159
+ factory = getattr(module, attr, None)
160
+ if factory is not None:
161
+ break
162
+ if factory is None: # old-style / unknown bindings
163
+ raise TreeSitterUnavailable(
164
+ f"Unsupported tree-sitter binding for '{name}' (no language factory)"
165
+ )
166
+ lang = factory()
167
+ # tree-sitter >= 0.22 returns a Language instance; 0.21 returns a handle.
168
+ try:
169
+ from tree_sitter import Language
170
+
171
+ if not isinstance(lang, Language):
172
+ lang = Language(lang)
173
+ except Exception: # noqa: BLE001
174
+ pass
175
+ return lang
176
+
177
+
178
+ def _query_for(name: str, language) -> object: # pragma: no cover - optional extras
179
+ if name in _QUERIES:
180
+ return _QUERIES[name]
181
+ name_node_overrides = _NAME_NODE_TYPES.get(name, {})
182
+ parts = [
183
+ f"({node} name: ({name_node_overrides.get(node, 'identifier')}) @name.{kind})"
184
+ for node, kind in _SYMBOL_RULES[name]
185
+ ]
186
+ source = " ".join(parts)
187
+ # ``Language.query(source)`` is the instance API on 0.21-0.25; calling it
188
+ # on the class (as this did) passes the string as ``self`` and fails on
189
+ # 0.25 with "descriptor 'query' ... doesn't apply to a 'str' object".
190
+ # 0.25 deprecates it in favour of the Query constructor, so prefer that
191
+ # form where the installed binding accepts it.
192
+ query = None
193
+ try:
194
+ from tree_sitter import Query
195
+ except ImportError:
196
+ Query = None # type: ignore[assignment,misc]
197
+ if Query is not None:
198
+ try:
199
+ query = Query(language, source)
200
+ except (TypeError, ValueError):
201
+ query = None
202
+ if query is None:
203
+ query = language.query(source)
204
+ _QUERIES[name] = query
205
+ return query
206
+
207
+
208
+ def _captures(query, node) -> list[tuple[object, str]]:
209
+ """Return ``(node, capture-name)`` pairs across tree-sitter versions.
210
+
211
+ 0.21-0.24 expose ``Query.captures(node)`` returning a list of tuples; 0.25
212
+ removed that method in favour of ``QueryCursor(query).captures(node)``,
213
+ which returns ``{capture_name: [node, ...]}``.
214
+ """
215
+ if hasattr(query, "captures"):
216
+ raw = query.captures(node)
217
+ return [(item[0], str(item[1])) for item in raw]
218
+ try:
219
+ from tree_sitter import QueryCursor
220
+ except ImportError as exc: # pragma: no cover - impossible on supported versions
221
+ raise TreeSitterUnavailable("tree-sitter QueryCursor API unavailable") from exc
222
+ raw = QueryCursor(query).captures(node)
223
+ pairs: list[tuple[object, str]] = []
224
+ for tag, items in raw.items():
225
+ for item in items:
226
+ pairs.append((item[0] if isinstance(item, tuple) else item, str(tag)))
227
+ return pairs
228
+
229
+
230
+ def extract_symbols(path: str | Path) -> list[dict]: # pragma: no cover - requires optional extras
231
+ """Extract ``{name, line, kind}`` symbols from a non-Python source file.
232
+
233
+ Raises ``TreeSitterUnavailable`` when the optional dependency is missing.
234
+ """
235
+ path = Path(path)
236
+ lang = detect_language(path)
237
+ if lang is None:
238
+ raise ValueError(f"Unsupported language for {path}")
239
+
240
+ Language, Parser = _import_tree_sitter()
241
+ grammar = _load_language(lang)
242
+ parser = Parser()
243
+ try:
244
+ parser.language = grammar
245
+ except AttributeError: # tree-sitter 0.21
246
+ parser.set_language(grammar)
247
+ source = path.read_bytes()
248
+
249
+ tree = parser.parse(source)
250
+ query = _query_for(lang, grammar)
251
+ captures = _captures(query, tree.root_node)
252
+
253
+ symbols: list[dict] = []
254
+ for node, tag in captures:
255
+ if not tag.startswith("name."):
256
+ continue
257
+ name = node.text.decode("utf-8", errors="replace")
258
+ if not name:
259
+ continue
260
+ symbols.append(
261
+ {
262
+ "name": name,
263
+ "line": node.start_point[0] + 1,
264
+ "kind": tag.split(".", 1)[1],
265
+ }
266
+ )
267
+ return symbols
@@ -0,0 +1,47 @@
1
+ """Relation types and shared data structures for structural code analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ __all__ = ["RelationType", "StructEdge", "ParseResult"]
9
+
10
+
11
+ class RelationType(Enum):
12
+ DEF_CALL = "def_call"
13
+ VAR_DEF_USE = "var_def_use"
14
+ AST_PARENT_CHILD = "ast_parent_child"
15
+ IMPORT = "import"
16
+ IMPORT_REF = "import_ref"
17
+ SAME_SCOPE = "same_scope"
18
+ CROSS_FILE_REF = "cross_file_ref" # def in file_a -> call in file_b
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class StructEdge:
23
+ src_start: int
24
+ src_end: int
25
+ tgt_start: int
26
+ tgt_end: int
27
+ relation_type: str
28
+ name: str | None = None
29
+ # For cross-file edges: which file the source/target belongs to
30
+ src_file: str | None = None
31
+ tgt_file: str | None = None
32
+
33
+
34
+ @dataclass
35
+ class ParseResult:
36
+ edges: list[StructEdge]
37
+ func_defs: dict[str, tuple[int, int]]
38
+ class_defs: dict[str, tuple[int, int]]
39
+ var_defs: dict[str, tuple[int, int]]
40
+ imports: set[str]
41
+ # Cross-file: symbols imported from / exported to other files
42
+ imported_symbols: dict[str, str] | None = None # name -> source_module
43
+ exported_symbols: set[str] | None = None # names defined here, usable by others
44
+ # class qname -> syntactic base names (dotted, as written). Recorded at
45
+ # parse time so the symbol index can answer "is any class a subclass of
46
+ # X" without re-parsing every class-defining file per verification.
47
+ class_bases: dict[str, list[str]] | None = None
@@ -0,0 +1,262 @@
1
+ """Shared AST utilities for HonestCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib
7
+ import importlib.util
8
+ import sys
9
+ import time
10
+ import tokenize
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterator
17
+
18
+ __all__ = [
19
+ "annotation_name",
20
+ "call_base",
21
+ "call_name",
22
+ "importable_root",
23
+ "iter_python_files",
24
+ "module_name_for",
25
+ "prune_cache_files",
26
+ "read_python_source",
27
+ "stdlib_resolves",
28
+ "stdlib_roots",
29
+ ]
30
+
31
+
32
+ @lru_cache(maxsize=1)
33
+ def stdlib_roots() -> frozenset[str]:
34
+ """Top-level module names shipped with the interpreter.
35
+
36
+ Used to trust imports from the standard library: ``from typing import
37
+ TypeVar`` cannot be an invented symbol, but without this the verifier
38
+ rejected every stdlib import whose package was not in the dependency list
39
+ and reported real code as hallucinated.
40
+ """
41
+ names = getattr(sys, "stdlib_module_names", None)
42
+ if names:
43
+ return frozenset(names)
44
+ return frozenset(sys.builtin_module_names)
45
+
46
+
47
+ @lru_cache(maxsize=4096)
48
+ def stdlib_resolves(dotted: str) -> bool | None:
49
+ """Whether *dotted* (e.g. ``typing.TypeVar``) resolves inside the stdlib.
50
+
51
+ Returns ``True`` when the attribute exists, ``False`` when the module can
52
+ be imported but demonstrably lacks it (an invented stdlib symbol), and
53
+ ``None`` when the question cannot be answered on this platform — callers
54
+ must then stay silent rather than guess. The standard library is real,
55
+ importable code, so unlike third-party packages it can be checked exactly;
56
+ only stdlib-only names are ever imported here.
57
+ """
58
+ parts = dotted.split(".")
59
+ if not parts or not parts[0] or parts[0] not in stdlib_roots():
60
+ return None
61
+
62
+ obj = None
63
+ consumed = 0
64
+ # Import the longest importable module prefix: ``urllib.parse.urlparse``
65
+ # needs ``import urllib.parse`` — a getattr walk from ``urllib`` alone
66
+ # would miss the submodule.
67
+ for i in range(len(parts), 0, -1):
68
+ prefix = ".".join(parts[:i])
69
+ try:
70
+ obj = importlib.import_module(prefix)
71
+ consumed = i
72
+ break
73
+ except ImportError:
74
+ continue
75
+ except Exception: # noqa: BLE001 - broken stdlib module, cannot judge
76
+ return None
77
+ if obj is None:
78
+ return None
79
+
80
+ for part in parts[consumed:]:
81
+ try:
82
+ obj = getattr(obj, part)
83
+ except AttributeError:
84
+ return False
85
+ except Exception: # noqa: BLE001 - property/module hook misbehaving
86
+ return None
87
+ return True
88
+
89
+
90
+ @lru_cache(maxsize=2048)
91
+ def importable_root(name: str) -> bool:
92
+ """Whether the top-level module *name* is importable in this interpreter.
93
+
94
+ Used to recognise an undeclared third-party import root as real code: the
95
+ package exists in the environment even though no manifest declares it
96
+ (``from requests import get`` in a project whose ``requirements.txt`` only
97
+ lists pyyaml is an undeclared dependency, not a hallucination). Only the
98
+ top-level name is ever probed, and ``find_spec`` does not execute the
99
+ module — parent packages are not imported either.
100
+ """
101
+ if not name or not name.isidentifier():
102
+ return False
103
+ if name in stdlib_roots():
104
+ return True
105
+ try:
106
+ return importlib.util.find_spec(name) is not None
107
+ except (ImportError, ValueError, AttributeError, TypeError, OSError):
108
+ return False
109
+
110
+
111
+ # Directories that never contain project source worth indexing. Hidden
112
+ # directories (``.venv``, ``.git``, ``.tox``, ...) are skipped by prefix, and the
113
+ # named ones below cover the common non-hidden artifact trees. Without this,
114
+ # following the README's own development setup (``python -m venv .venv``) made
115
+ # every scan index the entire virtual environment.
116
+ _SKIP_DIR_NAMES = frozenset(
117
+ {
118
+ "__pycache__",
119
+ "node_modules",
120
+ "site-packages",
121
+ "dist-packages",
122
+ "venv",
123
+ "build",
124
+ "dist",
125
+ "htmlcov",
126
+ }
127
+ )
128
+
129
+
130
+ def _is_skipped_part(part: str) -> bool:
131
+ return part.startswith(".") or part in _SKIP_DIR_NAMES or part.endswith(".egg-info")
132
+
133
+
134
+ def iter_python_files(root: Path | str) -> Iterator[Path]:
135
+ """Yield every indexable ``.py`` file under *root*, sorted by path.
136
+
137
+ Skips hidden directories (which covers ``.venv``/``.git``/``.tox`` and the
138
+ other tool caches), virtual environments and build artifacts. This is the
139
+ single traversal used by the symbol index, the call graph, the watcher and
140
+ the MCP tools so their notions of "the project's files" cannot drift apart.
141
+ """
142
+ root = Path(root)
143
+ for path in sorted(root.rglob("*.py")):
144
+ try:
145
+ rel = path.relative_to(root)
146
+ except ValueError:
147
+ continue
148
+ if any(_is_skipped_part(part) for part in rel.parts[:-1]):
149
+ continue
150
+ if path.name.startswith(".") or path.name in _SKIP_DIR_NAMES:
151
+ continue
152
+ yield path
153
+
154
+
155
+ def read_python_source(path: Path | str) -> str | None:
156
+ """Read Python source honouring its PEP 263 coding declaration.
157
+
158
+ ``Path.read_text(encoding="utf-8")`` raises on any legally non-UTF-8 source
159
+ file, and a single such file used to abort an entire project index build.
160
+ ``tokenize.open`` decodes exactly what the interpreter itself would, and a
161
+ genuinely unreadable file returns ``None`` so callers can skip it instead of
162
+ failing the scan.
163
+ """
164
+ try:
165
+ with tokenize.open(str(path)) as handle:
166
+ return handle.read()
167
+ except (OSError, SyntaxError, UnicodeDecodeError, LookupError):
168
+ return None
169
+
170
+
171
+ def prune_cache_files(
172
+ directory: Path | str, patterns: tuple[str, ...], max_age_days: int = 30
173
+ ) -> None:
174
+ """Delete cache files matching *patterns* that have not been touched recently.
175
+
176
+ Symbol/graph caches are stored one file per project root and are entirely
177
+ regenerable. Without a bound they accumulate one entry per temporary or
178
+ abandoned project forever. Only genuinely old files are removed, so an
179
+ active project's cache is never collected; a deletion that loses a race
180
+ with another process is ignored.
181
+ """
182
+ directory = Path(directory)
183
+ if not directory.is_dir():
184
+ return
185
+ cutoff = time.time() - max_age_days * 86400
186
+ for pattern in patterns:
187
+ for path in directory.glob(pattern):
188
+ try:
189
+ if path.is_file() and path.stat().st_mtime < cutoff:
190
+ path.unlink()
191
+ except OSError:
192
+ continue
193
+
194
+
195
+ def call_name(node: ast.expr) -> str | None:
196
+ """Return a dotted name for a call target (e.g. ``obj.method``).
197
+
198
+ Returns ``None`` for complex expressions (e.g. ``(a + b).foo()``).
199
+ """
200
+ if isinstance(node, ast.Name):
201
+ return node.id
202
+ if isinstance(node, ast.Attribute):
203
+ parts: list[str] = []
204
+ current: ast.expr = node
205
+ while isinstance(current, ast.Attribute):
206
+ parts.append(current.attr)
207
+ current = current.value
208
+ if isinstance(current, ast.Name):
209
+ parts.append(current.id)
210
+ return ".".join(reversed(parts))
211
+ return None
212
+
213
+
214
+ def annotation_name(node: ast.expr) -> str | None:
215
+ """Return a dotted name for an annotation (handles string annotations).
216
+
217
+ Lives here rather than in ``verify.surface`` so the symbol index can use
218
+ it too: recording class base names at index time is what keeps
219
+ ``may_be_subclassed`` from parsing the whole project on every scan.
220
+ """
221
+ if isinstance(node, ast.Name):
222
+ return node.id
223
+ if isinstance(node, ast.Attribute):
224
+ parts: list[str] = []
225
+ cur: ast.expr = node
226
+ while isinstance(cur, ast.Attribute):
227
+ parts.append(cur.attr)
228
+ cur = cur.value
229
+ if isinstance(cur, ast.Name):
230
+ parts.append(cur.id)
231
+ return ".".join(reversed(parts))
232
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
233
+ # ``from __future__ import annotations`` stores annotations verbatim.
234
+ text = node.value.strip()
235
+ if text.replace(".", "").replace("_", "").isidentifier():
236
+ return text
237
+ if isinstance(node, ast.Subscript): # Optional[UserClient], list[UserClient], ...
238
+ return annotation_name(node.value)
239
+ return None
240
+
241
+
242
+ def call_base(name: str) -> str:
243
+ """Return the first component of a dotted name."""
244
+ return name.split(".")[0]
245
+
246
+
247
+ def module_name_for(path: Path, root: Path) -> str:
248
+ """Infer a dotted module name for *path* relative to *root*.
249
+
250
+ Only strips the ``src`` prefix when it is a standalone directory component
251
+ (i.e. the standard ``src`` layout) and there are further path segments.
252
+ """
253
+ try:
254
+ rel = path.resolve().relative_to(root.resolve())
255
+ except ValueError:
256
+ rel = path
257
+ parts = list(rel.with_suffix("").parts)
258
+ if len(parts) > 1 and parts[0] == "src":
259
+ parts = parts[1:]
260
+ if parts and parts[-1] == "__init__":
261
+ parts.pop()
262
+ return ".".join(parts)
@@ -0,0 +1,19 @@
1
+ """Repository grounding — the deterministic core of HonestCode.
2
+
3
+ Everything an AI agent writes is a claim about the repository it is working in.
4
+ This package checks those claims against the repository itself:
5
+
6
+ * :mod:`honestcode.verify.surface` reconstructs a class's member surface
7
+ * :mod:`honestcode.verify.verify` walks call sites and resolves receivers
8
+ * :mod:`honestcode.verify.evidence` emits the agent-facing protocol
9
+
10
+ No LLM calls, no network, no heuristics that cannot be explained.
11
+ """
12
+
13
+ from honestcode.verify.evidence import (
14
+ Finding,
15
+ build_report,
16
+ render_text,
17
+ )
18
+
19
+ __all__ = ["Finding", "build_report", "render_text"]