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,276 @@
1
+ """Persistent call-graph and full-text search index backed by SQLite.
2
+
3
+ ``GraphCache`` persists the project-wide call graph (definitions + references)
4
+ to a SQLite database keyed by a stable hash of the project root. Freshness is
5
+ decided by comparing stored ``(mtime_ns, size)`` metadata for every ``.py``
6
+ file against the current tree, so graph queries (``explore_call_graph``,
7
+ ``find_dead_code``, ``impact``, ``affected``) read from disk instead of
8
+ re-parsing the project on every call. Metadata rather than content hashing:
9
+ hashing the whole tree on every query made the read-only graph tools pay a
10
+ full-tree read, and the same-file-rewritten-within-one-mtime-tick case it would
11
+ catch is not worth that cost.
12
+
13
+ The same database hosts a FTS5 virtual table (trigram tokenizer) over file
14
+ contents. ``search_code`` uses it to shrink a regex scan to a *superset* of
15
+ candidate files: it extracts a literal substring the pattern requires and
16
+ matches it as a substring, so even matches inside longer tokens are found —
17
+ a plain token-prefix index would have missed those. When no required literal
18
+ can be derived (or FTS5 is unavailable), the caller falls back to a full scan,
19
+ keeping results identical to an unindexed search in all cases.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import logging
27
+ import sqlite3
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+ from honestcode.structure.utils import iter_python_files, prune_cache_files, read_python_source
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ __all__ = ["CallGraph", "GraphCache"]
36
+
37
+ # v2 replaced whole-tree content hashes with (mtime_ns, size) metadata.
38
+ _DB_VERSION = 2
39
+
40
+ # ``<root name>_<16 hex chars>.graph.db`` — narrow on purpose so pruning a
41
+ # user-supplied cache directory cannot delete unrelated SQLite files.
42
+ _GRAPH_CACHE_GLOB = "*_" + "[0-9a-f]" * 16 + ".graph.db"
43
+
44
+
45
+ @dataclass
46
+ class CallGraph:
47
+ """Project-wide definition/reference graph.
48
+
49
+ ``definitions`` maps a symbol name to ``(file, line, end_line, kind)``.
50
+ ``references`` maps a symbol name to ``(file, line, kind, context)`` where
51
+ ``context`` is the callee when the reference is a call site (else ``None``).
52
+ """
53
+
54
+ definitions: dict[str, list[tuple[Path, int, int, str]]] = field(default_factory=dict)
55
+ references: dict[str, list[tuple[Path, int, str, str | None]]] = field(default_factory=dict)
56
+
57
+
58
+ def _python_files(root: Path) -> list[Path]:
59
+ return list(iter_python_files(root))
60
+
61
+
62
+ def _file_stats(root: Path) -> dict[str, list[int]]:
63
+ """Map each relative .py path to ``[mtime_ns, size]``."""
64
+ stats: dict[str, list[int]] = {}
65
+ for p in _python_files(root):
66
+ try:
67
+ st = p.stat()
68
+ except OSError:
69
+ continue
70
+ stats[p.relative_to(root).as_posix()] = [st.st_mtime_ns, st.st_size]
71
+ return stats
72
+
73
+
74
+ def _stable_hash(text: str) -> str:
75
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
76
+
77
+
78
+ class GraphCache:
79
+ """SQLite-backed cache of a project's call graph + FTS5 file index."""
80
+
81
+ def __init__(self, path: Path) -> None:
82
+ self.path = path
83
+
84
+ @classmethod
85
+ def for_root(cls, root: Path, cache_dir: Path) -> GraphCache:
86
+ resolved = root.resolve()
87
+ cache_dir.mkdir(parents=True, exist_ok=True)
88
+ return cls(cache_dir / f"{resolved.name}_{_stable_hash(str(resolved))}.graph.db")
89
+
90
+ # -- freshness ---------------------------------------------------------
91
+
92
+ def is_fresh(self, root: Path) -> bool:
93
+ """True when the cached graph covers exactly the current file tree."""
94
+ if not self.path.exists():
95
+ return False
96
+ conn = None
97
+ try:
98
+ conn = sqlite3.connect(str(self.path))
99
+ cur = conn.cursor()
100
+ row = cur.execute("SELECT value FROM meta WHERE key='stats'").fetchone()
101
+ root_row = cur.execute("SELECT value FROM meta WHERE key='root'").fetchone()
102
+ version_row = cur.execute("SELECT value FROM meta WHERE key='version'").fetchone()
103
+ except sqlite3.Error:
104
+ return False
105
+ finally:
106
+ if conn is not None:
107
+ conn.close()
108
+ if row is None or root_row is None or version_row is None:
109
+ return False
110
+ if version_row[0] != str(_DB_VERSION):
111
+ return False
112
+ if root_row[0] != str(root.resolve()):
113
+ return False
114
+ try:
115
+ stored = json.loads(row[0])
116
+ except (json.JSONDecodeError, TypeError):
117
+ return False
118
+ return stored == _file_stats(root)
119
+
120
+ # -- persistence -------------------------------------------------------
121
+
122
+ def save(self, graph: CallGraph, root: Path) -> None:
123
+ """Persist the graph plus a FTS5 index of the current file tree."""
124
+ root = root.resolve()
125
+ stats = _file_stats(root)
126
+ files = sorted(stats.keys())
127
+ conn = sqlite3.connect(str(self.path))
128
+ try:
129
+ conn.execute("DROP TABLE IF EXISTS definitions")
130
+ conn.execute("DROP TABLE IF EXISTS refs")
131
+ conn.execute("DROP TABLE IF EXISTS meta")
132
+ conn.execute(
133
+ "CREATE TABLE definitions ("
134
+ "symbol TEXT NOT NULL, file TEXT NOT NULL, "
135
+ "line INTEGER NOT NULL, end_line INTEGER NOT NULL, kind TEXT NOT NULL)"
136
+ )
137
+ conn.execute(
138
+ "CREATE TABLE refs ("
139
+ "caller TEXT NOT NULL, file TEXT NOT NULL, "
140
+ "line INTEGER NOT NULL, kind TEXT NOT NULL, context TEXT)"
141
+ )
142
+ conn.executemany(
143
+ "INSERT INTO definitions VALUES (?, ?, ?, ?, ?)",
144
+ [
145
+ (name, str(p), line, end_line, kind)
146
+ for name, locs in graph.definitions.items()
147
+ for p, line, end_line, kind in locs
148
+ ],
149
+ )
150
+ conn.executemany(
151
+ "INSERT INTO refs VALUES (?, ?, ?, ?, ?)",
152
+ [
153
+ (name, str(p), line, kind, context)
154
+ for name, locs in graph.references.items()
155
+ for p, line, kind, context in locs
156
+ ],
157
+ )
158
+ conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
159
+ conn.executemany(
160
+ "INSERT INTO meta VALUES (?, ?)",
161
+ [
162
+ ("version", str(_DB_VERSION)),
163
+ ("root", str(root)),
164
+ ("files", json.dumps(files)),
165
+ ("stats", json.dumps(stats)),
166
+ ],
167
+ )
168
+ conn.commit()
169
+ self._rebuild_fts(conn, root, files)
170
+ finally:
171
+ conn.close()
172
+ prune_cache_files(self.path.parent, (_GRAPH_CACHE_GLOB,))
173
+
174
+ def load(self) -> CallGraph | None:
175
+ """Load the graph from disk, or ``None`` if missing/corrupt."""
176
+ if not self.path.exists():
177
+ return None
178
+ try:
179
+ conn = sqlite3.connect(str(self.path))
180
+ cur = conn.cursor()
181
+ definitions: dict[str, list[tuple[Path, int, int, str]]] = {}
182
+ for name, f, line, end_line, kind in cur.execute(
183
+ "SELECT symbol, file, line, end_line, kind FROM definitions"
184
+ ):
185
+ definitions.setdefault(name, []).append((Path(f), line, end_line, kind))
186
+ references: dict[str, list[tuple[Path, int, str, str | None]]] = {}
187
+ for name, f, line, kind, context in cur.execute(
188
+ "SELECT caller, file, line, kind, context FROM refs"
189
+ ):
190
+ references.setdefault(name, []).append((Path(f), line, kind, context))
191
+ conn.close()
192
+ return CallGraph(definitions=definitions, references=references)
193
+ except sqlite3.Error as exc:
194
+ logger.warning("Failed to load graph cache %s: %s", self.path, exc)
195
+ return None
196
+
197
+ # -- FTS5 full-text search --------------------------------------------
198
+
199
+ def _rebuild_fts(self, conn: sqlite3.Connection, root: Path, files: list[str]) -> None:
200
+ try:
201
+ conn.execute("DROP TABLE IF EXISTS fts_files")
202
+ conn.execute(
203
+ "CREATE VIRTUAL TABLE fts_files USING fts5("
204
+ "path UNINDEXED, content, tokenize='trigram')"
205
+ )
206
+ except sqlite3.Error:
207
+ logger.debug("FTS5 unavailable; search_code will fall back to regex scans")
208
+ return
209
+ for rel in files:
210
+ content = read_python_source(root / rel)
211
+ if content is None:
212
+ continue
213
+ conn.execute("INSERT INTO fts_files (path, content) VALUES (?, ?)", (rel, content))
214
+ conn.commit()
215
+
216
+ @staticmethod
217
+ def _required_literal(pattern: str) -> str | None:
218
+ """Return a literal substring every match of *pattern* must contain.
219
+
220
+ Conservative by design: a maximal ``[A-Za-z0-9_]+`` run counts only
221
+ when both neighbours are plain literal characters (alphanumeric,
222
+ underscore, space, or a string boundary). Anything else — a quantifier
223
+ after the run (``foo*`` can match ``fo``), an alternation, an escaped
224
+ metachar before it — disqualifies the run, because guessing wrong here
225
+ would silently drop real matches.
226
+
227
+ Returns ``None`` when no run of >= 3 characters qualifies (the trigram
228
+ index cannot help below three characters).
229
+ """
230
+ literal_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_ ")
231
+ best = ""
232
+ n = len(pattern)
233
+ i = 0
234
+ while i < n:
235
+ if pattern[i] not in literal_chars or pattern[i] == " ":
236
+ i += 1
237
+ continue
238
+ j = i
239
+ while j < n and pattern[j] in literal_chars and pattern[j] != " ":
240
+ j += 1
241
+ before_ok = i == 0 or pattern[i - 1] in (" ",)
242
+ after_ok = j == n or pattern[j] in (" ",)
243
+ if before_ok and after_ok and (j - i) > len(best):
244
+ best = pattern[i:j]
245
+ i = j
246
+ return best if len(best) >= 3 else None
247
+
248
+ def search_candidate_files(self, pattern: str) -> list[str] | None:
249
+ """Return files that *may* match ``pattern`` using FTS5 substring search.
250
+
251
+ Uses the trigram index to find files containing the literal substring
252
+ that :meth:`_required_literal` proved the pattern requires. Because the
253
+ match is substring-based (not token-prefix), occurrences inside longer
254
+ tokens are still found and the candidate set is a true superset; when
255
+ no required literal can be derived, ``None`` tells the caller to fall
256
+ back to a full scan. Returns an empty list when the required literal
257
+ occurs nowhere (short-circuit to no results).
258
+ """
259
+ literal = self._required_literal(pattern)
260
+ if literal is None:
261
+ return None
262
+ try:
263
+ conn = sqlite3.connect(str(self.path))
264
+ try:
265
+ cur = conn.cursor()
266
+ paths: list[str] = []
267
+ for (path,) in cur.execute(
268
+ "SELECT path FROM fts_files WHERE fts_files MATCH ?",
269
+ (f'"{literal}"',),
270
+ ):
271
+ paths.append(path)
272
+ finally:
273
+ conn.close()
274
+ except sqlite3.Error:
275
+ return None
276
+ return sorted(set(paths))
@@ -0,0 +1,116 @@
1
+ """Zero-dependency file watcher for automatic re-indexing.
2
+
3
+ Polls the source tree's ``.py`` file mtimes on a background thread and fires
4
+ an ``on_change`` callback once files have stayed unchanged for ``debounce``
5
+ seconds. A poll-based design keeps the project dependency-free (pure stdlib),
6
+ cross-platform, and good enough for local dev machines — a single project of a
7
+ few thousand files is fully rescanned in well under a second.
8
+
9
+ Usage::
10
+
11
+ watcher = ProjectWatcher(root, on_change=lambda changed: ...)
12
+ watcher.start()
13
+ ...
14
+ watcher.stop()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import threading
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from honestcode.structure.utils import iter_python_files
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _take_snapshot(root: Path) -> dict[Path, float]:
30
+ """Map each .py file to its mtime (ns), for change detection."""
31
+ snap: dict[Path, float] = {}
32
+ for p in iter_python_files(root):
33
+ try:
34
+ snap[p] = p.stat().st_mtime_ns
35
+ except OSError:
36
+ continue
37
+ return snap
38
+
39
+
40
+ class ProjectWatcher:
41
+ """Watch a project for source changes and fire a debounced callback.
42
+
43
+ ``on_change(changed: list[Path])`` is invoked on the watcher thread once
44
+ the tree is quiescent for ``debounce`` seconds. The callback must be quick
45
+ or move heavy work to its own thread.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ root: Path,
51
+ on_change,
52
+ debounce: float = 2.0,
53
+ poll_interval: float = 0.5,
54
+ name: str = "honestcode-watcher",
55
+ ) -> None:
56
+ self.root = Path(root)
57
+ self.on_change = on_change
58
+ self.debounce = debounce
59
+ self.poll_interval = poll_interval
60
+ self._snapshot = _take_snapshot(self.root)
61
+ self._pending: list[Path] = []
62
+ self._last_change: float | None = None
63
+ self._stop = threading.Event()
64
+ self._thread = threading.Thread(target=self._run, name=name, daemon=True)
65
+
66
+ # -- lifecycle ---------------------------------------------------------
67
+
68
+ def start(self) -> ProjectWatcher:
69
+ self._thread.start()
70
+ logger.info("Watcher started for %s (debounce %.1fs)", self.root, self.debounce)
71
+ return self
72
+
73
+ def stop(self) -> None:
74
+ self._stop.set()
75
+ self._thread.join(timeout=5)
76
+ if self._thread.is_alive():
77
+ logger.warning("Watcher thread for %s did not stop within 5s", self.root)
78
+ else:
79
+ logger.info("Watcher stopped for %s", self.root)
80
+
81
+ # -- internals ---------------------------------------------------------
82
+
83
+ def _run(self) -> None:
84
+ while not self._stop.is_set():
85
+ self._stop.wait(self.poll_interval)
86
+ self._scan_once()
87
+
88
+ def _scan_once(self) -> None:
89
+ now = _take_snapshot(self.root)
90
+ changed: list[Path] = []
91
+ old = self._snapshot
92
+ for p in old:
93
+ if p not in now:
94
+ changed.append(p)
95
+ for p, mtime in now.items():
96
+ if old.get(p) != mtime:
97
+ changed.append(p)
98
+ self._snapshot = now
99
+
100
+ if changed:
101
+ self._pending.extend(changed)
102
+ self._last_change = time.monotonic()
103
+ logger.debug("Detected %d changed files", len(changed))
104
+ return
105
+
106
+ if (
107
+ self._pending
108
+ and self._last_change is not None
109
+ and (time.monotonic() - self._last_change) >= self.debounce
110
+ ):
111
+ pending, self._pending = self._pending, []
112
+ self._last_change = None
113
+ try:
114
+ self.on_change(pending)
115
+ except Exception: # noqa: BLE001
116
+ logger.exception("on_change callback failed")
@@ -0,0 +1,104 @@
1
+ """Crash-safe wrappers for agent side effects.
2
+
3
+ An agent that retries a tool call after a crash can charge a card twice, send a
4
+ message twice, or deploy twice. Durable-execution engines do not solve this for
5
+ you — Temporal and Restate both guarantee deterministic *replay of
6
+ orchestration* and leave exactly-once external effects to the application. This
7
+ package is that piece: classify the effect, key it, persist the key, and refuse
8
+ to run it again.
9
+
10
+ ::
11
+
12
+ from honestcode.guards import EffectStore, SideEffectGuard
13
+
14
+ guard = SideEffectGuard(session_id="sess-1", store_path=".agent/effects.db")
15
+
16
+ outcome = guard.run(
17
+ "send_invoice",
18
+ target="cus_123",
19
+ payload={"amount_cents": 4200},
20
+ execute=lambda: billing.send_invoice("cus_123", 4200),
21
+ approve_irreversible=True,
22
+ )
23
+
24
+ outcome.executed # False on the second call — a receipt, not a charge
25
+ outcome.effect_id # stable across restarts, because the key is on disk
26
+
27
+ Every effect class is treated as irreversible until classified otherwise, which
28
+ is why ``send_invoice`` needs the explicit approval above.
29
+
30
+ Isolation
31
+ ---------
32
+
33
+ This subpackage is deliberately self-contained and is expected to be extracted
34
+ into its own distribution:
35
+
36
+ * It imports **only the standard library** plus its own siblings. It must never
37
+ import ``honestcode.*`` outside ``honestcode.guards.*``, because the rest of
38
+ HonestCode answers questions about code while this package wraps code that
39
+ changes the world — different lifecycle, different blast radius.
40
+ * It is **not registered as an MCP tool**. HonestCode's MCP surface is reached
41
+ through client configuration files; a guard belongs in the caller's own
42
+ process, on its own tool-call path.
43
+ * It has **no dependency on HonestCode's protocols** — findings, reports, and
44
+ the ``Finding`` shape are all absent here. A guard returns receipts.
45
+ """
46
+
47
+ from honestcode.guards.guard import (
48
+ EffectNotRunnable,
49
+ ExecutionOutcome,
50
+ GuardError,
51
+ Precondition,
52
+ PreconditionFailed,
53
+ RecoveryDecision,
54
+ SideEffectGuard,
55
+ durable_scope,
56
+ precondition,
57
+ strategy_for,
58
+ )
59
+ from honestcode.guards.store import (
60
+ ACTIVE_STATUSES,
61
+ COMMITTED,
62
+ COMPENSATED,
63
+ EXECUTING,
64
+ FAILED,
65
+ PENDING,
66
+ EffectRecord,
67
+ EffectStatus,
68
+ EffectStore,
69
+ StoreError,
70
+ )
71
+ from honestcode.guards.taxonomy import (
72
+ EffectClass,
73
+ RecoveryStrategy,
74
+ classify,
75
+ )
76
+
77
+ __all__ = [
78
+ # taxonomy
79
+ "EffectClass",
80
+ "RecoveryStrategy",
81
+ "classify",
82
+ # store
83
+ "EffectRecord",
84
+ "EffectStatus",
85
+ "EffectStore",
86
+ "StoreError",
87
+ "ACTIVE_STATUSES",
88
+ "PENDING",
89
+ "EXECUTING",
90
+ "COMMITTED",
91
+ "FAILED",
92
+ "COMPENSATED",
93
+ # guard
94
+ "ExecutionOutcome",
95
+ "GuardError",
96
+ "Precondition",
97
+ "PreconditionFailed",
98
+ "EffectNotRunnable",
99
+ "RecoveryDecision",
100
+ "SideEffectGuard",
101
+ "durable_scope",
102
+ "precondition",
103
+ "strategy_for",
104
+ ]