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,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
|
+
]
|