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,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project-wide symbol index.
|
|
3
|
+
|
|
4
|
+
Builds a unified view of every function, class, and variable defined in a
|
|
5
|
+
project, and caches the result to disk.
|
|
6
|
+
|
|
7
|
+
Invalidation is metadata-driven: the cache records each file's
|
|
8
|
+
``(mtime_ns, size)`` and re-parses only files whose metadata changed, so an
|
|
9
|
+
agent that just edited one file gets a fresh index without a full re-parse.
|
|
10
|
+
This is what makes auto-indexing on every ``scan_file`` affordable — and it
|
|
11
|
+
means a long-lived MCP server session never verifies against a stale index
|
|
12
|
+
(the previous design cached forever and produced false "undefined symbol"
|
|
13
|
+
reports for symbols the agent had just written).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
from dataclasses import asdict, dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from honestcode.structure.extractor import StructureExtractor
|
|
27
|
+
from honestcode.structure.utils import iter_python_files, module_name_for, prune_cache_files
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = ["SymbolInfo", "SymbolIndex", "ProjectIndex", "get_project_index"]
|
|
32
|
+
|
|
33
|
+
# Bumped whenever the cache payload gains a field the verifier relies on
|
|
34
|
+
# (v2 added file_names/module_symbols for re-export resolution; v3 added
|
|
35
|
+
# file_class_bases/class_base_names for subclass detection); older cache
|
|
36
|
+
# files are ignored so a stale on-disk index cannot silently degrade results.
|
|
37
|
+
_INDEX_CACHE_VERSION = 3
|
|
38
|
+
|
|
39
|
+
# The cache file name is ``<root name>_<16 hex chars>.json``; the glob is
|
|
40
|
+
# narrow so pruning a user-supplied cache directory cannot touch unrelated
|
|
41
|
+
# JSON files that happen to live there.
|
|
42
|
+
_INDEX_CACHE_GLOB = "*_" + "[0-9a-f]" * 16 + ".json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SymbolInfo:
|
|
47
|
+
file: str
|
|
48
|
+
line: int
|
|
49
|
+
kind: str
|
|
50
|
+
exported: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SymbolIndex:
|
|
55
|
+
symbols: dict[str, SymbolInfo] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def exported_symbols(self) -> dict[str, SymbolInfo]:
|
|
59
|
+
return {n: i for n, i in self.symbols.items() if i.exported}
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def local_symbols(self) -> dict[str, SymbolInfo]:
|
|
63
|
+
return {n: i for n, i in self.symbols.items() if not i.exported}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ProjectIndex:
|
|
68
|
+
root: str
|
|
69
|
+
symbols: dict[str, SymbolInfo]
|
|
70
|
+
files: list[str]
|
|
71
|
+
cache_key: str = ""
|
|
72
|
+
cached: bool = False
|
|
73
|
+
# rel path (posix) -> [mtime_ns, size]; used for cheap freshness checks.
|
|
74
|
+
stats: dict[str, list[int]] = field(default_factory=dict)
|
|
75
|
+
# rel path (posix) -> {qualified name -> SymbolInfo}; enables incremental
|
|
76
|
+
# rebuilds: unchanged files reuse their per-file symbols without parsing.
|
|
77
|
+
file_symbols: dict[str, dict[str, SymbolInfo]] = field(default_factory=dict)
|
|
78
|
+
# rel path (posix) -> names bound at that module's top level (definitions
|
|
79
|
+
# plus imported aliases). Makes re-exports resolvable: in
|
|
80
|
+
# ``from .compat import urlparse`` the name ``urlparse`` is not a symbol
|
|
81
|
+
# of ``requests.compat`` (it is itself an import there) but it is bound in
|
|
82
|
+
# that module, so the import is grounded.
|
|
83
|
+
file_names: dict[str, set[str]] = field(default_factory=dict)
|
|
84
|
+
# module qname -> module-level bound names (aggregated from file_names).
|
|
85
|
+
module_symbols: dict[str, set[str]] = field(default_factory=dict)
|
|
86
|
+
# rel path (posix) -> short names every class in that file inherits from.
|
|
87
|
+
# Lets ``may_be_subclassed`` answer from the index instead of parsing every
|
|
88
|
+
# class-defining file at verification time (a cold scan on a few-hundred-
|
|
89
|
+
# file project paid seconds for that gate).
|
|
90
|
+
file_class_bases: dict[str, set[str]] = field(default_factory=dict)
|
|
91
|
+
# Union of every base short name in the project.
|
|
92
|
+
class_base_names: set[str] = field(default_factory=set)
|
|
93
|
+
|
|
94
|
+
def to_sqlite(self, db_path: str | Path) -> None:
|
|
95
|
+
import sqlite3
|
|
96
|
+
|
|
97
|
+
conn = sqlite3.connect(str(db_path))
|
|
98
|
+
conn.execute("DROP TABLE IF EXISTS symbols")
|
|
99
|
+
conn.execute(
|
|
100
|
+
"CREATE TABLE symbols (name TEXT PRIMARY KEY, file TEXT, line INTEGER, kind TEXT, exported INTEGER)" # noqa: E501
|
|
101
|
+
)
|
|
102
|
+
for name, info in self.symbols.items():
|
|
103
|
+
conn.execute(
|
|
104
|
+
"INSERT OR REPLACE INTO symbols VALUES (?, ?, ?, ?, ?)",
|
|
105
|
+
(name, info.file, info.line, info.kind, int(info.exported)),
|
|
106
|
+
)
|
|
107
|
+
conn.execute("DROP TABLE IF EXISTS meta")
|
|
108
|
+
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
109
|
+
conn.execute("INSERT INTO meta VALUES (?, ?)", ("root", self.root))
|
|
110
|
+
conn.execute("INSERT INTO meta VALUES (?, ?)", ("files", json.dumps(self.files)))
|
|
111
|
+
conn.commit()
|
|
112
|
+
conn.close()
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_sqlite(cls, db_path: str | Path) -> ProjectIndex | None:
|
|
116
|
+
import sqlite3
|
|
117
|
+
|
|
118
|
+
p = Path(db_path)
|
|
119
|
+
if not p.exists():
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
conn = sqlite3.connect(str(p))
|
|
123
|
+
cur = conn.cursor()
|
|
124
|
+
cur.execute("SELECT name, file, line, kind, exported FROM symbols")
|
|
125
|
+
symbols = {
|
|
126
|
+
name: SymbolInfo(file=f, line=line, kind=kind, exported=bool(exported))
|
|
127
|
+
for name, f, line, kind, exported in cur.fetchall()
|
|
128
|
+
}
|
|
129
|
+
cur.execute("SELECT key, value FROM meta")
|
|
130
|
+
meta = dict(cur.fetchall())
|
|
131
|
+
conn.close()
|
|
132
|
+
return cls(
|
|
133
|
+
root=meta.get("root", ""),
|
|
134
|
+
symbols=symbols,
|
|
135
|
+
files=json.loads(meta.get("files", "[]")),
|
|
136
|
+
)
|
|
137
|
+
except Exception as e: # noqa: BLE001
|
|
138
|
+
logger.warning("Failed to load SQLite index from %s: %s", db_path, e)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _iter_py_files(root: Path) -> dict[str, Path]:
|
|
143
|
+
"""Map every indexable .py file (posix rel path) to its absolute path.
|
|
144
|
+
|
|
145
|
+
The shared traversal skips virtual environments, hidden tool directories and
|
|
146
|
+
build artifacts; see :func:`honestcode.structure.utils.iter_python_files`.
|
|
147
|
+
"""
|
|
148
|
+
files: dict[str, Path] = {}
|
|
149
|
+
for p in iter_python_files(root):
|
|
150
|
+
try:
|
|
151
|
+
rel = p.relative_to(root).as_posix()
|
|
152
|
+
except ValueError:
|
|
153
|
+
continue
|
|
154
|
+
files[rel] = p
|
|
155
|
+
return files
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _stat_of(p: Path) -> list[int] | None:
|
|
159
|
+
try:
|
|
160
|
+
st = p.stat()
|
|
161
|
+
except OSError:
|
|
162
|
+
return None
|
|
163
|
+
return [st.st_mtime_ns, st.st_size]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _tree_stats(root: Path) -> dict[str, list[int]]:
|
|
167
|
+
"""Current ``(mtime_ns, size)`` for every indexable file, keyed by rel path."""
|
|
168
|
+
out: dict[str, list[int]] = {}
|
|
169
|
+
for rel, p in _iter_py_files(root).items():
|
|
170
|
+
st = _stat_of(p)
|
|
171
|
+
if st is not None:
|
|
172
|
+
out[rel] = st
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _stats_fresh(root: Path, stats: dict[str, list[int]]) -> bool:
|
|
177
|
+
"""True when no indexed file was added, removed, or edited since *stats*.
|
|
178
|
+
|
|
179
|
+
Compares ``(mtime_ns, size)`` only — no file contents are read. A file
|
|
180
|
+
rewritten twice within the same mtime tick *and* at the same size is the
|
|
181
|
+
one case this misses; the previous design (hashing every file on every
|
|
182
|
+
check) caught it but made each scan pay a full-tree read.
|
|
183
|
+
"""
|
|
184
|
+
current = _tree_stats(root)
|
|
185
|
+
return current == stats
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _index_cache_key(stats: dict[str, list[int]]) -> str:
|
|
189
|
+
"""Deterministic key over the tree's file list and metadata (no file reads)."""
|
|
190
|
+
h = hashlib.sha256()
|
|
191
|
+
for rel in sorted(stats):
|
|
192
|
+
h.update(f"{rel}:{stats[rel][0]}:{stats[rel][1]}".encode())
|
|
193
|
+
return h.hexdigest()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class ProjectIndexJSON:
|
|
198
|
+
"""Serialization helpers for the on-disk JSON cache."""
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def dump(idx: ProjectIndex) -> dict:
|
|
202
|
+
return {
|
|
203
|
+
"version": _INDEX_CACHE_VERSION,
|
|
204
|
+
"root": idx.root,
|
|
205
|
+
"symbols": {n: asdict(i) for n, i in idx.symbols.items()},
|
|
206
|
+
"files": idx.files,
|
|
207
|
+
"cache_key": idx.cache_key,
|
|
208
|
+
"stats": idx.stats,
|
|
209
|
+
"file_symbols": {
|
|
210
|
+
rel: {n: asdict(i) for n, i in syms.items()}
|
|
211
|
+
for rel, syms in idx.file_symbols.items()
|
|
212
|
+
},
|
|
213
|
+
"file_names": {rel: sorted(names) for rel, names in idx.file_names.items()},
|
|
214
|
+
"file_class_bases": {rel: sorted(names) for rel, names in idx.file_class_bases.items()},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def load(data: dict) -> ProjectIndex:
|
|
219
|
+
symbols = {n: SymbolInfo(**i) for n, i in data.get("symbols", {}).items()}
|
|
220
|
+
file_symbols = {
|
|
221
|
+
rel: {n: SymbolInfo(**i) for n, i in syms.items()}
|
|
222
|
+
for rel, syms in data.get("file_symbols", {}).items()
|
|
223
|
+
}
|
|
224
|
+
root = data.get("root", "")
|
|
225
|
+
file_names = {rel: set(names) for rel, names in data.get("file_names", {}).items()}
|
|
226
|
+
file_class_bases = {
|
|
227
|
+
rel: set(names) for rel, names in data.get("file_class_bases", {}).items()
|
|
228
|
+
}
|
|
229
|
+
return ProjectIndex(
|
|
230
|
+
root=root,
|
|
231
|
+
symbols=symbols,
|
|
232
|
+
files=data.get("files", []),
|
|
233
|
+
cache_key=data.get("cache_key", ""),
|
|
234
|
+
stats={k: list(v) for k, v in data.get("stats", {}).items()},
|
|
235
|
+
file_symbols=file_symbols,
|
|
236
|
+
file_names=file_names,
|
|
237
|
+
file_class_bases=file_class_bases,
|
|
238
|
+
class_base_names=set().union(*file_class_bases.values()) if file_class_bases else set(),
|
|
239
|
+
module_symbols=_build_module_symbols(root, file_names),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _module_name(file: Path, root: Path) -> str:
|
|
244
|
+
"""Infer a dotted module name from *file* relative to *root*.
|
|
245
|
+
|
|
246
|
+
Kept as a thin alias; the single implementation lives in
|
|
247
|
+
:func:`honestcode.structure.utils.module_name_for`.
|
|
248
|
+
"""
|
|
249
|
+
return module_name_for(file, root)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _merge(rel: str, module: str, res) -> tuple[dict[str, SymbolInfo], set[str], set[str]]:
|
|
253
|
+
"""Turn a ParseResult into ``({qualified name: SymbolInfo}, bound names, base names)``.
|
|
254
|
+
|
|
255
|
+
The second element lists every name bound at the module's top level —
|
|
256
|
+
definitions plus imported aliases — which is what makes re-exports
|
|
257
|
+
(``from .compat import urlparse``) resolvable against the index. The third
|
|
258
|
+
lists the short names every class in the file inherits from, so subclass
|
|
259
|
+
detection never has to re-parse the file.
|
|
260
|
+
"""
|
|
261
|
+
symbols: dict[str, SymbolInfo] = {}
|
|
262
|
+
names: set[str] = set()
|
|
263
|
+
|
|
264
|
+
def _add(name: str, line: int, kind: str) -> None:
|
|
265
|
+
full_name = f"{module}.{name}" if module and name else (name or module)
|
|
266
|
+
exported = not name.split(".")[-1].startswith("_") if name else True
|
|
267
|
+
symbols[full_name] = SymbolInfo(file=rel, line=line, kind=kind, exported=exported)
|
|
268
|
+
if "." not in name and name:
|
|
269
|
+
names.add(name)
|
|
270
|
+
|
|
271
|
+
for fn, (s, _e) in res.func_defs.items():
|
|
272
|
+
_add(fn, s + 1, "function")
|
|
273
|
+
for cn, (s, _e) in res.class_defs.items():
|
|
274
|
+
_add(cn, s + 1, "class")
|
|
275
|
+
for vn, (s, _e) in res.var_defs.items():
|
|
276
|
+
_add(vn, s + 1, "variable")
|
|
277
|
+
names.update((res.imported_symbols or {}).keys())
|
|
278
|
+
|
|
279
|
+
bases = {b.rsplit(".", 1)[-1] for bases in (res.class_bases or {}).values() for b in bases}
|
|
280
|
+
return symbols, names, bases
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _index_directory(
|
|
284
|
+
root: Path,
|
|
285
|
+
extractor: StructureExtractor,
|
|
286
|
+
previous: ProjectIndex | None = None,
|
|
287
|
+
) -> ProjectIndex:
|
|
288
|
+
"""Build the index, reusing per-file symbols for files whose metadata is
|
|
289
|
+
unchanged since *previous* (incremental rebuild)."""
|
|
290
|
+
files_map = _iter_py_files(root)
|
|
291
|
+
stats = {rel: st for rel in files_map if (st := _stat_of(files_map[rel])) is not None}
|
|
292
|
+
prev_stats = previous.stats if previous else {}
|
|
293
|
+
prev_syms = previous.file_symbols if previous else {}
|
|
294
|
+
prev_names = previous.file_names if previous else {}
|
|
295
|
+
prev_bases = previous.file_class_bases if previous else {}
|
|
296
|
+
|
|
297
|
+
symbols: dict[str, SymbolInfo] = {}
|
|
298
|
+
file_symbols: dict[str, dict[str, SymbolInfo]] = {}
|
|
299
|
+
file_names: dict[str, set[str]] = {}
|
|
300
|
+
file_class_bases: dict[str, set[str]] = {}
|
|
301
|
+
files: list[str] = []
|
|
302
|
+
reused = 0
|
|
303
|
+
for rel in sorted(files_map):
|
|
304
|
+
files.append(rel)
|
|
305
|
+
unchanged = prev_stats.get(rel) == stats.get(rel)
|
|
306
|
+
if unchanged and rel in prev_syms and rel in prev_names:
|
|
307
|
+
file_symbols[rel] = prev_syms[rel]
|
|
308
|
+
file_names[rel] = prev_names[rel]
|
|
309
|
+
file_class_bases[rel] = prev_bases.get(rel, set())
|
|
310
|
+
reused += 1
|
|
311
|
+
else:
|
|
312
|
+
module = module_name_for(files_map[rel], root)
|
|
313
|
+
per_file, names, bases = _merge(rel, module, extractor.parse_file(files_map[rel]))
|
|
314
|
+
file_symbols[rel] = per_file
|
|
315
|
+
file_names[rel] = names
|
|
316
|
+
file_class_bases[rel] = bases
|
|
317
|
+
symbols.update(file_symbols[rel])
|
|
318
|
+
|
|
319
|
+
if previous is not None and reused:
|
|
320
|
+
logger.info(
|
|
321
|
+
"Incremental index for %s: %d file(s) re-parsed, %d reused",
|
|
322
|
+
root,
|
|
323
|
+
len(files) - reused,
|
|
324
|
+
reused,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return ProjectIndex(
|
|
328
|
+
root=str(root),
|
|
329
|
+
symbols=symbols,
|
|
330
|
+
files=files,
|
|
331
|
+
cache_key=_index_cache_key(stats),
|
|
332
|
+
stats=stats,
|
|
333
|
+
file_symbols=file_symbols,
|
|
334
|
+
file_names=file_names,
|
|
335
|
+
file_class_bases=file_class_bases,
|
|
336
|
+
class_base_names=(set().union(*file_class_bases.values()) if file_class_bases else set()),
|
|
337
|
+
module_symbols=_build_module_symbols(str(root), file_names),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _build_module_symbols(root: str, file_names: dict[str, set[str]]) -> dict[str, set[str]]:
|
|
342
|
+
"""Aggregate per-file bound names into ``module qname -> names``."""
|
|
343
|
+
root_path = Path(root) if root else None
|
|
344
|
+
out: dict[str, set[str]] = {}
|
|
345
|
+
for rel, names in file_names.items():
|
|
346
|
+
if not names:
|
|
347
|
+
continue
|
|
348
|
+
module = module_name_for(root_path / rel, root_path) if root_path else ""
|
|
349
|
+
if not module:
|
|
350
|
+
continue
|
|
351
|
+
out.setdefault(module, set()).update(names)
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Module-level cache to avoid re-indexing within a single process. Entries are
|
|
356
|
+
# validated against the tree's metadata on every access (see get_project_index),
|
|
357
|
+
# so an edited project is rebuilt instead of served stale. The lock guards the
|
|
358
|
+
# dict itself: FastMCP runs sync tools on worker threads and the watcher thread
|
|
359
|
+
# writes here, so concurrent get/pop/set must not interleave.
|
|
360
|
+
_index_cache: dict[str, ProjectIndex] = {}
|
|
361
|
+
_index_cache_lock = threading.Lock()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_project_index(
|
|
365
|
+
root: str | Path,
|
|
366
|
+
force_rebuild: bool = False,
|
|
367
|
+
cache_dir: str | Path | None = None,
|
|
368
|
+
) -> ProjectIndex:
|
|
369
|
+
"""Return (and cache) the project index for *root*.
|
|
370
|
+
|
|
371
|
+
``ProjectIndex.cached`` reports whether the result was served unchanged
|
|
372
|
+
from the in-memory or on-disk cache (``False`` when it was (re)built).
|
|
373
|
+
|
|
374
|
+
The in-memory entry is validated against the tree's ``(mtime_ns, size)``
|
|
375
|
+
metadata on every call, so callers never receive an index that predates an
|
|
376
|
+
edit. The on-disk cache is used the same way.
|
|
377
|
+
"""
|
|
378
|
+
root = Path(root)
|
|
379
|
+
resolved = root.resolve()
|
|
380
|
+
cache_dir = Path(
|
|
381
|
+
cache_dir
|
|
382
|
+
or os.environ.get("HONESTCODE_INDEX_DIR")
|
|
383
|
+
or os.path.expanduser("~/.cache/honestcode")
|
|
384
|
+
)
|
|
385
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
cache_path = cache_dir / f"{resolved.name}_{_stable_hash(str(resolved))}.json"
|
|
387
|
+
|
|
388
|
+
previous: ProjectIndex | None = None
|
|
389
|
+
if not force_rebuild:
|
|
390
|
+
with _index_cache_lock:
|
|
391
|
+
cached = _index_cache.get(str(resolved))
|
|
392
|
+
if cached is not None:
|
|
393
|
+
if _stats_fresh(resolved, cached.stats):
|
|
394
|
+
# Mark the shared instance so callers can tell that this
|
|
395
|
+
# response was served unchanged (a rebuild creates a fresh
|
|
396
|
+
# object with cached=False).
|
|
397
|
+
cached.cached = True
|
|
398
|
+
return cached
|
|
399
|
+
previous = cached # stale: fall through to an incremental rebuild
|
|
400
|
+
_index_cache.pop(str(resolved), None)
|
|
401
|
+
|
|
402
|
+
if previous is None and cache_path.exists():
|
|
403
|
+
try:
|
|
404
|
+
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
405
|
+
if data.get("stats") is not None and data.get("version") == _INDEX_CACHE_VERSION:
|
|
406
|
+
disk_idx = ProjectIndexJSON.load(data)
|
|
407
|
+
if _stats_fresh(resolved, disk_idx.stats):
|
|
408
|
+
disk_idx.cached = True
|
|
409
|
+
_index_cache[str(resolved)] = disk_idx
|
|
410
|
+
return disk_idx
|
|
411
|
+
previous = disk_idx # reuse its per-file symbols
|
|
412
|
+
except Exception as e: # noqa: BLE001
|
|
413
|
+
logger.warning("Failed to load cache %s: %s", cache_path, e)
|
|
414
|
+
|
|
415
|
+
extractor = StructureExtractor()
|
|
416
|
+
idx = _index_directory(resolved, extractor, previous=previous)
|
|
417
|
+
idx.cached = False
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
cache_path.write_text(
|
|
421
|
+
json.dumps(ProjectIndexJSON.dump(idx), indent=2),
|
|
422
|
+
encoding="utf-8",
|
|
423
|
+
)
|
|
424
|
+
prune_cache_files(cache_dir, (_INDEX_CACHE_GLOB,))
|
|
425
|
+
except Exception as e: # noqa: BLE001
|
|
426
|
+
logger.warning("Failed to write cache %s: %s", cache_path, e)
|
|
427
|
+
|
|
428
|
+
with _index_cache_lock:
|
|
429
|
+
_index_cache[str(resolved)] = idx
|
|
430
|
+
return idx
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _stable_hash(text: str) -> str:
|
|
434
|
+
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _file_hashes(root: Path) -> dict[str, str]:
|
|
438
|
+
hashes: dict[str, str] = {}
|
|
439
|
+
for p in sorted(root.rglob("*.py")):
|
|
440
|
+
if p.name.startswith(".") or "__pycache__" in p.parts:
|
|
441
|
+
continue
|
|
442
|
+
try:
|
|
443
|
+
hashes[str(p.relative_to(root))] = hashlib.sha256(p.read_bytes()).hexdigest()
|
|
444
|
+
except Exception: # noqa: BLE001
|
|
445
|
+
continue
|
|
446
|
+
return hashes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Framework integrations for HonestCode.
|
|
2
|
+
|
|
3
|
+
Each module here binds one framework to something in the core. They are kept
|
|
4
|
+
out of ``honestcode.guards`` because that package is deliberately stdlib-only
|
|
5
|
+
and framework-agnostic — a rule ``tests/test_guards.py`` enforces — and out of
|
|
6
|
+
``honestcode.verify`` because that is static analysis, whereas these wrap live
|
|
7
|
+
execution.
|
|
8
|
+
|
|
9
|
+
Import the integration you need directly::
|
|
10
|
+
|
|
11
|
+
from honestcode.integrations.pydantic_ai import EffectGuardCapability
|
|
12
|
+
|
|
13
|
+
Each module raises a helpful ``ImportError`` if its framework is not installed.
|
|
14
|
+
"""
|