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
|
@@ -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"]
|