stepcheck-dev 0.1.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.
- backend/__init__.py +0 -0
- backend/audit.py +351 -0
- backend/cli.py +274 -0
- backend/kernel/__init__.py +614 -0
- backend/kernel/_gates.py +299 -0
- backend/kernel/_okf.py +663 -0
- backend/kernel/_sandbox.py +135 -0
- backend/kernel/_telemetry.py +52 -0
- backend/kernel/_tokens.py +7 -0
- backend/server.py +584 -0
- stepcheck_dev-0.1.0.dist-info/METADATA +123 -0
- stepcheck_dev-0.1.0.dist-info/RECORD +17 -0
- stepcheck_dev-0.1.0.dist-info/WHEEL +5 -0
- stepcheck_dev-0.1.0.dist-info/entry_points.txt +2 -0
- stepcheck_dev-0.1.0.dist-info/licenses/LICENSE +202 -0
- stepcheck_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
- stepcheck_dev-0.1.0.dist-info/top_level.txt +1 -0
backend/kernel/_gates.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Gate implementations. Private to StateKernel — reached only through commit()."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import builtins
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def required_export_names(dependent_source: str, target_module: str) -> set[str]:
|
|
10
|
+
"""Names a flat dependent imports from `target_module` (its mechanical contract).
|
|
11
|
+
Package-aware resolution lives in _okf.resolve_imports; this keeps the flat form."""
|
|
12
|
+
from . import _okf
|
|
13
|
+
|
|
14
|
+
return _okf.resolve_imports(
|
|
15
|
+
"<dependent>", "<dependent>.py", dependent_source, {target_module: target_module}
|
|
16
|
+
).get(target_module, set())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bound_names(tree: ast.Module) -> set[str]:
|
|
20
|
+
"""Every name bound at module top level: defs, classes, assignments, imports."""
|
|
21
|
+
names: set[str] = set()
|
|
22
|
+
for stmt in tree.body:
|
|
23
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
24
|
+
names.add(stmt.name)
|
|
25
|
+
elif isinstance(stmt, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
|
26
|
+
targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
|
|
27
|
+
for t in targets:
|
|
28
|
+
names.update(n.id for n in ast.walk(t) if isinstance(n, ast.Name))
|
|
29
|
+
elif isinstance(stmt, (ast.Import, ast.ImportFrom)):
|
|
30
|
+
for alias in stmt.names:
|
|
31
|
+
if alias.name != "*":
|
|
32
|
+
names.add(alias.asname or alias.name.split(".")[0])
|
|
33
|
+
return names
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def export_names(exports: list[str]) -> set[str]:
|
|
37
|
+
out = set()
|
|
38
|
+
for sig in exports:
|
|
39
|
+
head = sig.split("(")[0]
|
|
40
|
+
out.add(head.removeprefix("class ").strip())
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gate_b(node, staged_info, old_source: str, new_source: str, allow_deletion: bool) -> None:
|
|
45
|
+
"""Omission detection: symbol fingerprinting + mass-deletion guard.
|
|
46
|
+
|
|
47
|
+
Raises kernel.OmissionError. `allow_deletion=True` declares the deletion
|
|
48
|
+
and bypasses both checks (GLOSSARY: Gate B).
|
|
49
|
+
"""
|
|
50
|
+
from . import OmissionError, _okf
|
|
51
|
+
|
|
52
|
+
if allow_deletion:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
staged_symbols = set(staged_info.preserved_symbols)
|
|
56
|
+
dropped = [s for s in node.info.preserved_symbols if s not in staged_symbols]
|
|
57
|
+
if dropped:
|
|
58
|
+
raise OmissionError(
|
|
59
|
+
f"preserved symbol '{dropped[0]}' of '{node.info.node_id}' was silently "
|
|
60
|
+
f"dropped from the staged source",
|
|
61
|
+
subtype="INTRA_NODE_SYMBOL_DROPPED",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
old_n = _okf.statement_count(old_source)
|
|
65
|
+
new_n = _okf.statement_count(new_source)
|
|
66
|
+
if old_n and (old_n - new_n) / old_n > 0.15:
|
|
67
|
+
raise OmissionError(
|
|
68
|
+
f"staged source drops {old_n - new_n}/{old_n} AST statements "
|
|
69
|
+
f"(> 15%) without allow_deletion=True",
|
|
70
|
+
subtype="INTRA_NODE_MASS_DELETION",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def gate_a(node_id: str, locked: bool, source: str, nodes) -> "ast.Module":
|
|
75
|
+
"""Mechanical & locked protection. Returns the parsed tree on pass.
|
|
76
|
+
|
|
77
|
+
Raises kernel.MechanicalError on: locked mutation, syntax error, or a name a
|
|
78
|
+
dependent imports from this module (absolute or relative, through packages)
|
|
79
|
+
going missing from the staged module's top level.
|
|
80
|
+
"""
|
|
81
|
+
from . import MechanicalError, _okf # local import: avoid cycle at module load
|
|
82
|
+
|
|
83
|
+
if locked:
|
|
84
|
+
raise MechanicalError(
|
|
85
|
+
f"node '{node_id}' is LOCKED — mutations are rejected at Gate A"
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
tree = ast.parse(source)
|
|
89
|
+
except SyntaxError as exc:
|
|
90
|
+
raise MechanicalError(f"invalid AST syntax in '{node_id}': {exc.msg} (line {exc.lineno})")
|
|
91
|
+
|
|
92
|
+
paths = {nid: n.info.path for nid, n in nodes.items()}
|
|
93
|
+
modules = _okf.module_map(paths)
|
|
94
|
+
staged = _okf.analyze(node_id, paths.get(node_id, f"{node_id}.py"), source, modules)
|
|
95
|
+
bound = export_names(staged.exports) | _bound_names(tree)
|
|
96
|
+
for dep_id, dep_node in nodes.items():
|
|
97
|
+
if dep_id == node_id or node_id not in dep_node.info.deps:
|
|
98
|
+
continue
|
|
99
|
+
required = _okf.resolve_imports(
|
|
100
|
+
dep_id, dep_node.info.path, dep_node.info.source, modules
|
|
101
|
+
).get(node_id, set())
|
|
102
|
+
missing = sorted(required - bound)
|
|
103
|
+
if missing:
|
|
104
|
+
raise MechanicalError(
|
|
105
|
+
f"export '{missing[0]}' of '{node_id}' is required by dependent "
|
|
106
|
+
f"'{dep_id}' and is missing from the staged source"
|
|
107
|
+
)
|
|
108
|
+
return tree
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_CALL_RE = re.compile(r"^call:(.+?)\s+raises\s+([A-Za-z_][\w.]*)\s*$")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fill_new_required(expr: str, tree: ast.Module) -> str:
|
|
115
|
+
"""Add typed mocks for required parameters of a mined `fn(...)` call that the
|
|
116
|
+
staged signature gained, so the guard is exercised instead of a TypeError."""
|
|
117
|
+
from . import _okf
|
|
118
|
+
|
|
119
|
+
call = ast.parse(expr, mode="eval").body
|
|
120
|
+
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)):
|
|
121
|
+
return expr
|
|
122
|
+
if any(isinstance(a, ast.Starred) for a in call.args) or any(k.arg is None for k in call.keywords):
|
|
123
|
+
return expr
|
|
124
|
+
fn = next(
|
|
125
|
+
(
|
|
126
|
+
s
|
|
127
|
+
for s in tree.body
|
|
128
|
+
if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef)) and s.name == call.func.id
|
|
129
|
+
),
|
|
130
|
+
None,
|
|
131
|
+
)
|
|
132
|
+
if fn is None:
|
|
133
|
+
return expr
|
|
134
|
+
|
|
135
|
+
a = fn.args
|
|
136
|
+
positional = a.posonlyargs + a.args
|
|
137
|
+
required = positional[: len(positional) - len(a.defaults)]
|
|
138
|
+
required += [p for p, default in zip(a.kwonlyargs, a.kw_defaults) if default is None]
|
|
139
|
+
given = {k.arg for k in call.keywords} | {p.arg for p in positional[: len(call.args)]}
|
|
140
|
+
for p in required:
|
|
141
|
+
if p.arg in given:
|
|
142
|
+
continue
|
|
143
|
+
value = ast.parse(_okf.mock_value(p), mode="eval").body
|
|
144
|
+
if p in a.posonlyargs:
|
|
145
|
+
call.args.append(value)
|
|
146
|
+
else:
|
|
147
|
+
call.keywords.append(ast.keyword(arg=p.arg, value=value))
|
|
148
|
+
return ast.unparse(call)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _raises(fn, exc_name: str, ns: dict) -> bool:
|
|
152
|
+
from . import _sandbox
|
|
153
|
+
|
|
154
|
+
exc_type = ns.get(exc_name) or getattr(builtins, exc_name, None)
|
|
155
|
+
if not isinstance(exc_type, type):
|
|
156
|
+
raise NameError(f"unknown exception type {exc_name!r}")
|
|
157
|
+
try:
|
|
158
|
+
_complete(fn()) # an `async def` hands back a coroutine: run it so its guard fires
|
|
159
|
+
except _sandbox.ExecutionBudgetExceeded:
|
|
160
|
+
raise
|
|
161
|
+
except exc_type:
|
|
162
|
+
return True
|
|
163
|
+
except Exception: # includes RecursionError: wrong exception → invariant fails
|
|
164
|
+
return False
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _complete(value):
|
|
169
|
+
"""Run an awaitable to completion and return its result; plain values pass
|
|
170
|
+
through. Uses a private event loop. When this thread already runs a loop (the
|
|
171
|
+
gateway's), the private loop runs on a helper thread under its own execution
|
|
172
|
+
guard, because event loops cannot nest."""
|
|
173
|
+
import asyncio
|
|
174
|
+
import inspect
|
|
175
|
+
import threading
|
|
176
|
+
|
|
177
|
+
from . import _sandbox
|
|
178
|
+
|
|
179
|
+
if not inspect.isawaitable(value):
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
async def main():
|
|
183
|
+
return await value
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
asyncio.get_running_loop()
|
|
187
|
+
except RuntimeError:
|
|
188
|
+
return asyncio.run(main())
|
|
189
|
+
|
|
190
|
+
box: dict = {}
|
|
191
|
+
|
|
192
|
+
def worker() -> None:
|
|
193
|
+
try:
|
|
194
|
+
box["value"] = _sandbox.guarded(lambda: asyncio.run(main()))
|
|
195
|
+
except BaseException as exc: # re-raised on the caller's thread
|
|
196
|
+
box["error"] = exc
|
|
197
|
+
|
|
198
|
+
thread = threading.Thread(target=worker, name="stepcheck-await", daemon=True)
|
|
199
|
+
thread.start()
|
|
200
|
+
thread.join()
|
|
201
|
+
if "error" in box:
|
|
202
|
+
raise box["error"]
|
|
203
|
+
return box["value"]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def gate_c(
|
|
207
|
+
node_id: str,
|
|
208
|
+
invariants: list[str],
|
|
209
|
+
staged_source: str,
|
|
210
|
+
project_sources: dict[str, str] | None = None,
|
|
211
|
+
mined: list[str] | None = None,
|
|
212
|
+
project_paths: dict[str, str] | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Logical micro-invariants: every standing frame invariant must hold.
|
|
215
|
+
|
|
216
|
+
Each invariant is a deterministic Python expression evaluated with:
|
|
217
|
+
src — `ast.unparse(tree)`: whitespace/quote-normalized source with every
|
|
218
|
+
comment (including the invariant lines themselves) gone, so an
|
|
219
|
+
invariant can never satisfy itself and never trips on formatting;
|
|
220
|
+
tree — the parsed `ast.Module`;
|
|
221
|
+
ns — the staged module exec-loaded into an isolated namespace, with
|
|
222
|
+
project imports — absolute or relative; `project_paths` gives each
|
|
223
|
+
node's file so packages resolve — bound to canonical sources
|
|
224
|
+
(loaded lazily, only when an invariant mentions `ns` or uses `call:`);
|
|
225
|
+
ast, raises(fn, ExcName) — helpers; an awaitable result is run to
|
|
226
|
+
completion, so `async def` functions are checked like sync ones.
|
|
227
|
+
Sugar: `call:<expr> raises <Exc>` ≡ `raises(lambda: <expr>, "<Exc>")`
|
|
228
|
+
with `<expr>` evaluated inside `ns`. Auto-mined `call:` invariants also get
|
|
229
|
+
typed mocks for any required parameters the staged signature added.
|
|
230
|
+
A falsy result, or any exception, is a kernel.LogicalError.
|
|
231
|
+
"""
|
|
232
|
+
from . import LogicalError, _sandbox
|
|
233
|
+
|
|
234
|
+
tree = ast.parse(staged_source)
|
|
235
|
+
src = ast.unparse(tree)
|
|
236
|
+
needs_ns = any(inv.startswith("call:") or re.search(r"\bns\b", inv) for inv in invariants)
|
|
237
|
+
ns: dict | None = None
|
|
238
|
+
if needs_ns:
|
|
239
|
+
try:
|
|
240
|
+
ns = _sandbox.guarded(
|
|
241
|
+
lambda: _sandbox.load_namespace(
|
|
242
|
+
node_id, staged_source, project_sources or {}, project_paths
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
except _sandbox.ExecutionBudgetExceeded as exc:
|
|
246
|
+
raise LogicalError(
|
|
247
|
+
f"staged module '{node_id}' blew the Gate C execution budget while "
|
|
248
|
+
f"loading ({exc}) — runaway code at import time"
|
|
249
|
+
)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
raise LogicalError(
|
|
252
|
+
f"staged module '{node_id}' failed to load for callable invariants: {exc!r}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
for inv in invariants:
|
|
256
|
+
try:
|
|
257
|
+
m = _CALL_RE.match(inv)
|
|
258
|
+
if m:
|
|
259
|
+
expr, exc_name = m.group(1), m.group(2)
|
|
260
|
+
if mined and inv in mined:
|
|
261
|
+
expr = _fill_new_required(expr, tree)
|
|
262
|
+
code = compile(expr, "<okf-invariant>", "eval")
|
|
263
|
+
ok = _sandbox.guarded(
|
|
264
|
+
lambda: _raises(lambda: eval(code, dict(ns or {})), exc_name, ns or {}) # noqa: S307
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
scope = {
|
|
268
|
+
"src": src,
|
|
269
|
+
"tree": tree,
|
|
270
|
+
"ns": ns,
|
|
271
|
+
"ast": ast,
|
|
272
|
+
"raises": lambda fn, exc_name: _raises(fn, exc_name, ns or {}),
|
|
273
|
+
"len": len,
|
|
274
|
+
"all": all,
|
|
275
|
+
"any": any,
|
|
276
|
+
"isinstance": isinstance,
|
|
277
|
+
"getattr": getattr,
|
|
278
|
+
}
|
|
279
|
+
# helpers go in globals: generator expressions inside an
|
|
280
|
+
# invariant run in their own frame and see globals only.
|
|
281
|
+
ok = _sandbox.guarded(
|
|
282
|
+
lambda: bool(eval(inv, {"__builtins__": {}, **scope})) # noqa: S307
|
|
283
|
+
)
|
|
284
|
+
except LogicalError:
|
|
285
|
+
raise
|
|
286
|
+
except _sandbox.ExecutionBudgetExceeded as exc:
|
|
287
|
+
raise LogicalError(
|
|
288
|
+
f"invariant {inv!r} of '{node_id}' blew the Gate C execution budget "
|
|
289
|
+
f"({exc}) — runaway loop or recursion in the staged code"
|
|
290
|
+
)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
raise LogicalError(
|
|
293
|
+
f"invariant {inv!r} of '{node_id}' raised during evaluation: {exc!r}"
|
|
294
|
+
)
|
|
295
|
+
if not ok:
|
|
296
|
+
tag = " (auto-mined from a leading guard clause)" if mined and inv in mined else ""
|
|
297
|
+
raise LogicalError(
|
|
298
|
+
f"staged diff violates frame invariant {inv!r} of '{node_id}'{tag}"
|
|
299
|
+
)
|