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/_okf.py
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""OKF analysis + frame serialization. Private implementation of StateKernel.
|
|
2
|
+
|
|
3
|
+
Never mocked in tests (GLOSSARY "Testing rules"): exercised only through Seam 1.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import codecs
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import tokenize
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
_INVARIANT_RE = re.compile(r"#\s*okf-invariant:\s*(.+)$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ModuleInfo:
|
|
24
|
+
node_id: str
|
|
25
|
+
path: str
|
|
26
|
+
source: str
|
|
27
|
+
exports: list[str] = field(default_factory=list)
|
|
28
|
+
deps: list[str] = field(default_factory=list)
|
|
29
|
+
preserved_symbols: list[str] = field(default_factory=list)
|
|
30
|
+
invariants: list[str] = field(default_factory=list)
|
|
31
|
+
mined_invariants: list[str] = field(default_factory=list) # subset of invariants
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
35
|
+
args = ast.unparse(node.args)
|
|
36
|
+
ret = f" -> {ast.unparse(node.returns)}" if node.returns is not None else ""
|
|
37
|
+
return f"{node.name}({args}){ret}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def harvest_invariants(source: str) -> list[str]:
|
|
41
|
+
"""`# okf-invariant: <python-expr over `src`>` comments, in file order."""
|
|
42
|
+
found: list[str] = []
|
|
43
|
+
try:
|
|
44
|
+
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
45
|
+
if tok.type == tokenize.COMMENT:
|
|
46
|
+
m = _INVARIANT_RE.match(tok.string.strip())
|
|
47
|
+
if m:
|
|
48
|
+
found.append(m.group(1).strip())
|
|
49
|
+
except tokenize.TokenizeError:
|
|
50
|
+
pass
|
|
51
|
+
return found
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def analyze(node_id: str, path: str, source: str, project_modules: dict[str, str] | set[str]) -> ModuleInfo:
|
|
55
|
+
"""Parse one module into its OKF facts. Raises SyntaxError on broken source.
|
|
56
|
+
`project_modules` maps importable dotted names to node ids (see module_map);
|
|
57
|
+
a plain set of flat module names is accepted for backwards compatibility."""
|
|
58
|
+
tree = ast.parse(source)
|
|
59
|
+
modules = project_modules if isinstance(project_modules, dict) else {m: m for m in project_modules}
|
|
60
|
+
exports: list[str] = []
|
|
61
|
+
preserved: list[str] = []
|
|
62
|
+
|
|
63
|
+
for stmt in tree.body:
|
|
64
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
65
|
+
if not stmt.name.startswith("_"):
|
|
66
|
+
exports.append(_signature(stmt))
|
|
67
|
+
preserved.append(f"def {stmt.name}")
|
|
68
|
+
for dec in stmt.decorator_list:
|
|
69
|
+
name = ast.unparse(dec).split("(")[0]
|
|
70
|
+
sym = f"@{name}"
|
|
71
|
+
if sym not in preserved:
|
|
72
|
+
preserved.append(sym)
|
|
73
|
+
elif isinstance(stmt, ast.ClassDef):
|
|
74
|
+
if not stmt.name.startswith("_"):
|
|
75
|
+
bases = ", ".join(ast.unparse(b) for b in stmt.bases)
|
|
76
|
+
exports.append(f"class {stmt.name}({bases})" if bases else f"class {stmt.name}")
|
|
77
|
+
preserved.append(f"class {stmt.name}")
|
|
78
|
+
|
|
79
|
+
records = _import_records(node_id, path, tree, modules)
|
|
80
|
+
deps = sorted({nid for nid, _name, _local in records})
|
|
81
|
+
|
|
82
|
+
# Internal calls into project dependencies are preserved symbols too.
|
|
83
|
+
imported_names = {local for _nid, _name, local in records if local}
|
|
84
|
+
called = {
|
|
85
|
+
n.func.id
|
|
86
|
+
for n in ast.walk(tree)
|
|
87
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
|
88
|
+
}
|
|
89
|
+
for name in sorted(imported_names & called):
|
|
90
|
+
sym = f"call:{name}"
|
|
91
|
+
if sym not in preserved:
|
|
92
|
+
preserved.append(sym)
|
|
93
|
+
|
|
94
|
+
manual = harvest_invariants(source)
|
|
95
|
+
mined = [inv for inv in mine_guard_invariants(tree) if inv not in manual]
|
|
96
|
+
invariants = manual + mined
|
|
97
|
+
|
|
98
|
+
return ModuleInfo(
|
|
99
|
+
node_id=node_id,
|
|
100
|
+
path=path,
|
|
101
|
+
source=source,
|
|
102
|
+
exports=exports,
|
|
103
|
+
deps=deps,
|
|
104
|
+
preserved_symbols=preserved,
|
|
105
|
+
invariants=invariants,
|
|
106
|
+
mined_invariants=mined,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def statement_count(source: str) -> int:
|
|
111
|
+
return sum(1 for n in ast.walk(ast.parse(source)) if isinstance(n, ast.stmt))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def frame_markdown(
|
|
115
|
+
info: ModuleInfo,
|
|
116
|
+
status: str,
|
|
117
|
+
locked: bool,
|
|
118
|
+
verified: list[dict],
|
|
119
|
+
*,
|
|
120
|
+
focus: list[str] | None = None,
|
|
121
|
+
fold_history: bool = False,
|
|
122
|
+
symbol_index: bool = False,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""The OKF frame. On disk: full source + append-only verified[] chain.
|
|
125
|
+
In a CheckoutPayload (`fold_history=True`): superseded attestations fold
|
|
126
|
+
into one summary, and `focus` slices the body to an AST skeleton."""
|
|
127
|
+
meta: dict = {
|
|
128
|
+
"node_id": info.node_id,
|
|
129
|
+
"path": info.path,
|
|
130
|
+
"status": status,
|
|
131
|
+
"locked": locked,
|
|
132
|
+
"deps": info.deps,
|
|
133
|
+
"exports": info.exports,
|
|
134
|
+
"preserved_symbols": info.preserved_symbols,
|
|
135
|
+
"invariants": info.invariants,
|
|
136
|
+
"invariants_auto_mined": info.mined_invariants,
|
|
137
|
+
}
|
|
138
|
+
if fold_history:
|
|
139
|
+
latest = verified[-1] if verified else None
|
|
140
|
+
meta["attestation"] = {
|
|
141
|
+
"count": len(verified),
|
|
142
|
+
"latest_sha256": latest["payload"]["sha256"] if latest else None,
|
|
143
|
+
"latest_turn": latest["payload"]["turn"] if latest else None,
|
|
144
|
+
}
|
|
145
|
+
else:
|
|
146
|
+
meta["verified"] = verified
|
|
147
|
+
fm = yaml.safe_dump(meta, sort_keys=False, default_flow_style=False)
|
|
148
|
+
if focus is None:
|
|
149
|
+
body_src, note = info.source, ""
|
|
150
|
+
elif symbol_index:
|
|
151
|
+
body_src = skeleton(info.source, [])
|
|
152
|
+
names = [n for n in symbol_names(info.source)]
|
|
153
|
+
note = (
|
|
154
|
+
f" Source is {len(info.source.splitlines())} lines — over the checkout budget, so this"
|
|
155
|
+
f" frame was auto-skeletonized. Re-checkout with focus=[<name>, …] for full bodies."
|
|
156
|
+
f"\n\nSymbol index ({len(names)}): " + ", ".join(names)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
body_src = skeleton(info.source, focus)
|
|
160
|
+
note = (
|
|
161
|
+
f" Sliced to focus {focus!r}; other symbols are skeletons —"
|
|
162
|
+
" commit a `patch` (append / replace_symbol / delete_symbol) to fold edits in."
|
|
163
|
+
if focus
|
|
164
|
+
else " Pure skeleton; commit a `patch` to fold edits in."
|
|
165
|
+
)
|
|
166
|
+
body = (
|
|
167
|
+
f"# {info.node_id}\n\n"
|
|
168
|
+
f"Compacted OKF frame for `{info.path}` — "
|
|
169
|
+
f"{len(info.exports)} exports, {len(info.deps)} deps.{note}\n\n"
|
|
170
|
+
f"```python\n{body_src}```\n"
|
|
171
|
+
)
|
|
172
|
+
return f"---\n{fm}---\n\n{body}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def index_markdown(nodes: dict[str, "object"]) -> str:
|
|
176
|
+
"""nodes: node_id -> Node (duck-typed: .status, .info.deps)."""
|
|
177
|
+
edges = [
|
|
178
|
+
f"{nid} -> {dep}"
|
|
179
|
+
for nid, node in sorted(nodes.items())
|
|
180
|
+
for dep in node.info.deps
|
|
181
|
+
]
|
|
182
|
+
meta = {
|
|
183
|
+
"node_id": "__index__",
|
|
184
|
+
"nodes": sorted(nodes),
|
|
185
|
+
"edges": edges,
|
|
186
|
+
}
|
|
187
|
+
fm = yaml.safe_dump(meta, sort_keys=False, default_flow_style=False)
|
|
188
|
+
lines = [f"- {nid} ({node.status})" for nid, node in sorted(nodes.items())]
|
|
189
|
+
edge_lines = [f"- {e}" for e in edges]
|
|
190
|
+
return (
|
|
191
|
+
f"---\n{fm}---\n\n# OKF Index\n\n"
|
|
192
|
+
+ "\n".join(lines)
|
|
193
|
+
+ "\n\n## Edges\n\n"
|
|
194
|
+
+ ("\n".join(edge_lines) if edge_lines else "(none)")
|
|
195
|
+
+ "\n"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_frames(
|
|
200
|
+
okf_dir: Path, nodes: dict[str, "object"], written: dict[str, str] | None = None
|
|
201
|
+
) -> dict[str, str]:
|
|
202
|
+
"""Write one frame per node plus index.md. Returns {node_id | '__index__':
|
|
203
|
+
sha256 of the frame text}; pass that back as `written` on the next call and
|
|
204
|
+
frames whose text did not change are left alone (no disk write). Frames of
|
|
205
|
+
nodes this session does not track (left by an older session that named them
|
|
206
|
+
differently) are removed: the directory mirrors exactly one node set."""
|
|
207
|
+
import hashlib
|
|
208
|
+
|
|
209
|
+
okf_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
previous = written or {}
|
|
211
|
+
pages = {
|
|
212
|
+
nid: frame_markdown(node.info, node.status, node.locked, node.verified)
|
|
213
|
+
for nid, node in nodes.items()
|
|
214
|
+
}
|
|
215
|
+
pages["__index__"] = index_markdown(nodes)
|
|
216
|
+
shas: dict[str, str] = {}
|
|
217
|
+
for key, text in pages.items():
|
|
218
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
219
|
+
shas[key] = digest
|
|
220
|
+
if previous.get(key) != digest:
|
|
221
|
+
name = "index.md" if key == "__index__" else f"{key}.md"
|
|
222
|
+
(okf_dir / name).write_text(text, encoding="utf-8")
|
|
223
|
+
for stale in okf_dir.glob("*.md"):
|
|
224
|
+
if stale.name != "index.md" and stale.stem not in nodes:
|
|
225
|
+
stale.unlink()
|
|
226
|
+
return shas
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------- tracked-file round trip
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def decode_source(raw: bytes) -> str:
|
|
233
|
+
"""Tracked-file bytes -> canonical source: UTF-8, no BOM, LF line endings."""
|
|
234
|
+
text = raw.decode("utf-8").removeprefix("")
|
|
235
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def encode_like(source: str, existing: bytes) -> bytes:
|
|
239
|
+
"""Canonical source -> bytes in the existing file's BOM + line-ending style."""
|
|
240
|
+
crlf = existing.count(b"\r\n") * 2 > existing.count(b"\n")
|
|
241
|
+
data = (source.replace("\n", "\r\n") if crlf else source).encode("utf-8")
|
|
242
|
+
return codecs.BOM_UTF8 + data if existing.startswith(codecs.BOM_UTF8) else data
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def write_atomic(path: Path, data: bytes) -> None:
|
|
246
|
+
"""Replace `path` in one step so a crash cannot leave a half-written file."""
|
|
247
|
+
target = path.resolve()
|
|
248
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
tmp = target.with_name(target.name + ".stepcheck-tmp")
|
|
250
|
+
try:
|
|
251
|
+
tmp.write_bytes(data)
|
|
252
|
+
if target.exists():
|
|
253
|
+
shutil.copymode(target, tmp)
|
|
254
|
+
os.replace(tmp, target)
|
|
255
|
+
finally:
|
|
256
|
+
tmp.unlink(missing_ok=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ----------------------------------------------------- AST skeleton slicing
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _symbol_span(source_lines: list[str], node: ast.stmt) -> tuple[int, int]:
|
|
263
|
+
"""1-based inclusive line range of a top-level statement: its decorators plus
|
|
264
|
+
the comment lines sitting directly above (an `# okf-invariant:` travels with
|
|
265
|
+
its def). This is the range a focused checkout shows and a patch replaces."""
|
|
266
|
+
start = node.lineno
|
|
267
|
+
decos = getattr(node, "decorator_list", [])
|
|
268
|
+
if decos:
|
|
269
|
+
start = min(start, min(d.lineno for d in decos))
|
|
270
|
+
while start > 1 and source_lines[start - 2].strip().startswith("#"):
|
|
271
|
+
start -= 1
|
|
272
|
+
return start, node.end_lineno
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _segment(source_lines: list[str], node: ast.stmt) -> str:
|
|
276
|
+
"""Exact source text of a top-level statement, decorators + comments included."""
|
|
277
|
+
start, end = _symbol_span(source_lines, node)
|
|
278
|
+
return "".join(source_lines[start - 1 : end])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _skeleton_def(node: ast.FunctionDef | ast.AsyncFunctionDef, indent: str = "") -> str:
|
|
282
|
+
out = [f"{indent}@{ast.unparse(d)}\n" for d in node.decorator_list]
|
|
283
|
+
kw = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
|
|
284
|
+
out.append(f"{indent}{kw} {_signature(node)}:\n")
|
|
285
|
+
doc = ast.get_docstring(node)
|
|
286
|
+
if doc:
|
|
287
|
+
out.append(f'{indent} """{doc}"""\n')
|
|
288
|
+
out.append(f"{indent} ...\n")
|
|
289
|
+
return "".join(out)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _skeleton_class(node: ast.ClassDef) -> str:
|
|
293
|
+
bases = ", ".join(ast.unparse(b) for b in node.bases)
|
|
294
|
+
out = [f"@{ast.unparse(d)}\n" for d in node.decorator_list]
|
|
295
|
+
out.append(f"class {node.name}({bases}):\n" if bases else f"class {node.name}:\n")
|
|
296
|
+
doc = ast.get_docstring(node)
|
|
297
|
+
if doc:
|
|
298
|
+
out.append(f' """{doc}"""\n')
|
|
299
|
+
members = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
300
|
+
if not members and not doc:
|
|
301
|
+
out.append(" ...\n")
|
|
302
|
+
for m in members:
|
|
303
|
+
out.append(_skeleton_def(m, indent=" "))
|
|
304
|
+
return "".join(out)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _assign_names(node: ast.stmt) -> list[str]:
|
|
308
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
309
|
+
return [ast.unparse(t) for t in targets]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def skeleton(source: str, focus: list[str]) -> str:
|
|
313
|
+
"""Slice a module: `focus` defs/classes verbatim; every other def/class as
|
|
314
|
+
decorators + signature + docstring + `...`; runs of module-level
|
|
315
|
+
assignments folded into one summary line; imports kept verbatim."""
|
|
316
|
+
tree = ast.parse(source)
|
|
317
|
+
lines = source.splitlines(keepends=True)
|
|
318
|
+
keep = set(focus)
|
|
319
|
+
out: list[str] = []
|
|
320
|
+
pending_assigns: list[str] = []
|
|
321
|
+
|
|
322
|
+
def flush_assigns() -> None:
|
|
323
|
+
if not pending_assigns:
|
|
324
|
+
return
|
|
325
|
+
if len(pending_assigns) <= 2:
|
|
326
|
+
out.extend(f"{a} = ...\n" for a in pending_assigns)
|
|
327
|
+
else:
|
|
328
|
+
out.append(
|
|
329
|
+
f"# folded {len(pending_assigns)} module-level assignments: "
|
|
330
|
+
f"{pending_assigns[0]} … {pending_assigns[-1]} "
|
|
331
|
+
f"(checkout without focus for full source)\n"
|
|
332
|
+
)
|
|
333
|
+
pending_assigns.clear()
|
|
334
|
+
|
|
335
|
+
for stmt in tree.body:
|
|
336
|
+
if isinstance(stmt, (ast.Assign, ast.AnnAssign)):
|
|
337
|
+
pending_assigns.extend(_assign_names(stmt))
|
|
338
|
+
continue
|
|
339
|
+
flush_assigns()
|
|
340
|
+
if isinstance(stmt, (ast.Import, ast.ImportFrom)):
|
|
341
|
+
out.append(ast.unparse(stmt) + "\n")
|
|
342
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
343
|
+
out.append("\n\n" + (_segment(lines, stmt) if stmt.name in keep else _skeleton_def(stmt)))
|
|
344
|
+
elif isinstance(stmt, ast.ClassDef):
|
|
345
|
+
out.append("\n\n" + (_segment(lines, stmt) if stmt.name in keep else _skeleton_class(stmt)))
|
|
346
|
+
elif isinstance(stmt, ast.Expr) and isinstance(getattr(stmt, "value", None), ast.Constant):
|
|
347
|
+
out.append(ast.unparse(stmt) + "\n") # module docstring
|
|
348
|
+
else:
|
|
349
|
+
out.append("# " + ast.unparse(stmt).splitlines()[0][:60] + " …\n")
|
|
350
|
+
flush_assigns()
|
|
351
|
+
return "".join(out).lstrip("\n")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def interface_skeleton(source: str) -> str:
|
|
355
|
+
"""Dependency view: signatures, decorators, docstrings — no bodies, no data."""
|
|
356
|
+
tree = ast.parse(source)
|
|
357
|
+
parts: list[str] = []
|
|
358
|
+
for stmt in tree.body:
|
|
359
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)) and not stmt.name.startswith("_"):
|
|
360
|
+
parts.append(_skeleton_def(stmt))
|
|
361
|
+
elif isinstance(stmt, ast.ClassDef) and not stmt.name.startswith("_"):
|
|
362
|
+
parts.append(_skeleton_class(stmt))
|
|
363
|
+
return "\n".join(parts)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------ patch folding
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _trailing_main_guard(tree: ast.Module) -> ast.If | None:
|
|
370
|
+
"""The module's last top-level statement when it is `if __name__ == "__main__":`."""
|
|
371
|
+
last = tree.body[-1] if tree.body else None
|
|
372
|
+
if not isinstance(last, ast.If) or not isinstance(last.test, ast.Compare):
|
|
373
|
+
return None
|
|
374
|
+
sides = [last.test.left, *last.test.comparators]
|
|
375
|
+
names = {n.id for n in sides if isinstance(n, ast.Name)}
|
|
376
|
+
values = {c.value for c in sides if isinstance(c, ast.Constant)}
|
|
377
|
+
return last if names == {"__name__"} and values == {"__main__"} else None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def apply_patch(source: str, patch: list[dict]) -> str:
|
|
381
|
+
"""Fold AST-level ops into canonical source. Ops:
|
|
382
|
+
{"op":"append","code":…} · {"op":"replace_symbol","name":…,"code":…}
|
|
383
|
+
· {"op":"delete_symbol","name":…}. Each op splices only the target's own
|
|
384
|
+
lines (decorators and attached leading comments included) into the original
|
|
385
|
+
text, so comments, pragmas and formatting elsewhere survive verbatim. When
|
|
386
|
+
the module ends with an `if __name__ == "__main__":` guard, `append` inserts
|
|
387
|
+
just before it (comments attached to the guard stay attached), so a script
|
|
388
|
+
never calls code that is defined after its own entry point.
|
|
389
|
+
Raises ValueError on a bad op."""
|
|
390
|
+
for op in patch:
|
|
391
|
+
kind = op.get("op")
|
|
392
|
+
if kind == "append":
|
|
393
|
+
code = op["code"].rstrip("\n") + "\n"
|
|
394
|
+
guard = _trailing_main_guard(ast.parse(source))
|
|
395
|
+
if guard is None:
|
|
396
|
+
source = source.rstrip("\n") + "\n\n\n" + code
|
|
397
|
+
else:
|
|
398
|
+
lines = source.splitlines(keepends=True)
|
|
399
|
+
start, _end = _symbol_span(lines, guard)
|
|
400
|
+
head = "".join(lines[: start - 1]).rstrip("\n")
|
|
401
|
+
tail = "".join(lines[start - 1 :])
|
|
402
|
+
source = (head + "\n\n\n" if head else "") + code + "\n\n" + tail
|
|
403
|
+
continue
|
|
404
|
+
if kind not in ("replace_symbol", "delete_symbol"):
|
|
405
|
+
raise ValueError(f"unknown patch op {kind!r}")
|
|
406
|
+
tree = ast.parse(source)
|
|
407
|
+
lines = source.splitlines(keepends=True)
|
|
408
|
+
target = next(
|
|
409
|
+
(
|
|
410
|
+
s
|
|
411
|
+
for s in tree.body
|
|
412
|
+
if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
413
|
+
and s.name == op["name"]
|
|
414
|
+
),
|
|
415
|
+
None,
|
|
416
|
+
)
|
|
417
|
+
if target is None:
|
|
418
|
+
raise ValueError(f"patch targets unknown top-level symbol {op['name']!r}")
|
|
419
|
+
start, end = _symbol_span(lines, target)
|
|
420
|
+
head, tail = "".join(lines[: start - 1]), "".join(lines[end:])
|
|
421
|
+
if kind == "replace_symbol":
|
|
422
|
+
source = head + op["code"].rstrip("\n") + "\n" + tail
|
|
423
|
+
else:
|
|
424
|
+
head, tail = head.rstrip("\n"), tail.lstrip("\n")
|
|
425
|
+
if head and tail:
|
|
426
|
+
source = head + "\n\n\n" + tail
|
|
427
|
+
else:
|
|
428
|
+
source = head + "\n" if head else tail
|
|
429
|
+
return source
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def symbol_names(source: str) -> list[str]:
|
|
433
|
+
"""Top-level def/class names in file order (the auto-skeleton symbol index)."""
|
|
434
|
+
return [
|
|
435
|
+
s.name
|
|
436
|
+
for s in ast.parse(source).body
|
|
437
|
+
if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------ zero-config guard invariant mining
|
|
442
|
+
|
|
443
|
+
_TYPE_DEFAULTS = {"str": '"x"', "int": "1", "float": "1.0", "bool": "True",
|
|
444
|
+
"dict": "{}", "list": "[1]", "tuple": "(1,)", "set": "{1}", "bytes": 'b"x"'}
|
|
445
|
+
_FALSY = {"str": '""', "int": "0", "float": "0.0", "bool": "False",
|
|
446
|
+
"dict": "{}", "list": "[]", "tuple": "()", "set": "set()", "bytes": 'b""'}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _annotation_name(arg: ast.arg) -> str | None:
|
|
450
|
+
if arg.annotation is None:
|
|
451
|
+
return None
|
|
452
|
+
a = arg.annotation
|
|
453
|
+
if isinstance(a, ast.Name):
|
|
454
|
+
return a.id
|
|
455
|
+
if isinstance(a, ast.Subscript) and isinstance(a.value, ast.Name):
|
|
456
|
+
return a.value.id # list[str] -> list
|
|
457
|
+
if isinstance(a, ast.BinOp) and isinstance(a.left, ast.Name):
|
|
458
|
+
return a.left.id # str | None -> str
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def mock_value(arg: ast.arg) -> str:
|
|
463
|
+
"""Literal stand-in for a required parameter, chosen from its annotation."""
|
|
464
|
+
return _TYPE_DEFAULTS.get(_annotation_name(arg) or "", "None")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _violating_value(cond: ast.expr, params: dict[str, ast.arg]) -> tuple[str, str] | None:
|
|
468
|
+
"""Find (param, literal) such that `cond` is true when param = literal."""
|
|
469
|
+
if isinstance(cond, ast.BoolOp) and isinstance(cond.op, ast.Or) and cond.values:
|
|
470
|
+
return _violating_value(cond.values[0], params)
|
|
471
|
+
if isinstance(cond, ast.UnaryOp) and isinstance(cond.op, ast.Not) and isinstance(cond.operand, ast.Name):
|
|
472
|
+
name = cond.operand.id
|
|
473
|
+
if name in params:
|
|
474
|
+
return name, _FALSY.get(_annotation_name(params[name]) or "", "None")
|
|
475
|
+
if isinstance(cond, ast.Compare) and len(cond.ops) == 1 and len(cond.comparators) == 1:
|
|
476
|
+
left, op, right = cond.left, cond.ops[0], cond.comparators[0]
|
|
477
|
+
# len(param) == 0 / len(param) < 1
|
|
478
|
+
if (isinstance(left, ast.Call) and isinstance(left.func, ast.Name) and left.func.id == "len"
|
|
479
|
+
and left.args and isinstance(left.args[0], ast.Name) and left.args[0].id in params
|
|
480
|
+
and isinstance(right, ast.Constant) and isinstance(right.value, int)
|
|
481
|
+
and ((isinstance(op, ast.Eq) and right.value == 0) or (isinstance(op, ast.Lt) and right.value == 1))):
|
|
482
|
+
name = left.args[0].id
|
|
483
|
+
return name, _FALSY.get(_annotation_name(params[name]) or "list", "[]")
|
|
484
|
+
if isinstance(left, ast.Name) and left.id in params:
|
|
485
|
+
name = left.id
|
|
486
|
+
if isinstance(op, ast.Is) and isinstance(right, ast.Constant) and right.value is None:
|
|
487
|
+
return name, "None"
|
|
488
|
+
if isinstance(right, ast.Constant) and isinstance(right.value, (int, float)) and not isinstance(right.value, bool):
|
|
489
|
+
v = right.value
|
|
490
|
+
if isinstance(op, (ast.LtE, ast.GtE, ast.Eq)):
|
|
491
|
+
return name, repr(v)
|
|
492
|
+
if isinstance(op, ast.Lt):
|
|
493
|
+
return name, repr(v - 1)
|
|
494
|
+
if isinstance(op, ast.Gt):
|
|
495
|
+
return name, repr(v + 1)
|
|
496
|
+
if isinstance(right, ast.Constant) and isinstance(right.value, str) and isinstance(op, ast.Eq):
|
|
497
|
+
return name, repr(right.value)
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _exception_name(raise_stmt: ast.Raise) -> str | None:
|
|
502
|
+
exc = raise_stmt.exc
|
|
503
|
+
if isinstance(exc, ast.Call):
|
|
504
|
+
exc = exc.func
|
|
505
|
+
return exc.id if isinstance(exc, ast.Name) else None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def mine_guard_invariants(tree: ast.Module) -> list[str]:
|
|
509
|
+
"""For every top-level function (sync or async) whose body opens with guard
|
|
510
|
+
clauses (`if <cond on one parameter>: raise <Exc>`), synthesize a callable
|
|
511
|
+
invariant `call:fn(param=value, …) raises Exc` with arguments that trip
|
|
512
|
+
that guard. Stops at the first non-guard statement; skips anything it
|
|
513
|
+
cannot synthesize deterministically."""
|
|
514
|
+
out: list[str] = []
|
|
515
|
+
for fn in tree.body:
|
|
516
|
+
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
517
|
+
continue
|
|
518
|
+
a = fn.args
|
|
519
|
+
if a.posonlyargs or a.vararg or a.kwonlyargs or a.kwarg:
|
|
520
|
+
continue
|
|
521
|
+
params = {p.arg: p for p in a.args}
|
|
522
|
+
if not params:
|
|
523
|
+
continue
|
|
524
|
+
n_defaults = len(a.defaults)
|
|
525
|
+
required = [p.arg for p in a.args[: len(a.args) - n_defaults]]
|
|
526
|
+
body = fn.body
|
|
527
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant):
|
|
528
|
+
body = body[1:] # docstring
|
|
529
|
+
for stmt in body:
|
|
530
|
+
if not (isinstance(stmt, ast.If) and not stmt.orelse and len(stmt.body) == 1
|
|
531
|
+
and isinstance(stmt.body[0], ast.Raise)):
|
|
532
|
+
break
|
|
533
|
+
exc = _exception_name(stmt.body[0])
|
|
534
|
+
hit = _violating_value(stmt.test, params)
|
|
535
|
+
if exc is None or hit is None:
|
|
536
|
+
continue
|
|
537
|
+
name, value = hit
|
|
538
|
+
kwargs: list[str] = []
|
|
539
|
+
for p in a.args:
|
|
540
|
+
if p.arg == name:
|
|
541
|
+
kwargs.append(f"{p.arg}={value}")
|
|
542
|
+
elif p.arg in required:
|
|
543
|
+
kwargs.append(f"{p.arg}={mock_value(p)}")
|
|
544
|
+
inv = f"call:{fn.name}({', '.join(kwargs)}) raises {exc}"
|
|
545
|
+
if inv not in out:
|
|
546
|
+
out.append(inv)
|
|
547
|
+
return out
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ------------------------------------------------ package-aware import graph
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def module_name(path: str) -> str:
|
|
554
|
+
"""Dotted import name of a tracked file: `pkg/models.py` -> `pkg.models`,
|
|
555
|
+
`pkg/__init__.py` -> `pkg`; a root-level `__init__.py` stays `__init__`."""
|
|
556
|
+
if not path:
|
|
557
|
+
raise ValueError("empty module path")
|
|
558
|
+
parts = path.replace("\\", "/").removesuffix(".py").split("/")
|
|
559
|
+
if parts[-1] == "__init__" and len(parts) > 1:
|
|
560
|
+
parts = parts[:-1]
|
|
561
|
+
return ".".join(parts)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _package_of(path: str) -> str:
|
|
565
|
+
"""Dotted package a file lives in (`pkg/__init__.py` and `pkg/x.py` -> `pkg`)."""
|
|
566
|
+
parts = path.replace("\\", "/").removesuffix(".py").split("/")
|
|
567
|
+
return ".".join(parts[:-1])
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def module_map(paths: dict[str, str]) -> dict[str, str]:
|
|
571
|
+
"""Importable dotted names -> node_id.
|
|
572
|
+
|
|
573
|
+
Root-relative names always resolve (`backend.kernel`). Two kinds of alias
|
|
574
|
+
resolve too, but only while unambiguous across the project: the name with
|
|
575
|
+
its first directory stripped — the conventional source roots `src/`,
|
|
576
|
+
`backend/`, … that land on sys.path, so `backend/kernel/__init__.py` is
|
|
577
|
+
also `kernel` — and the bare stem (flat, sys.path-style projects). A
|
|
578
|
+
root-relative name always beats an alias of the same spelling, and an alias
|
|
579
|
+
whose top-level name belongs to the standard library is never registered:
|
|
580
|
+
`utils/json.py` must not capture `import json`."""
|
|
581
|
+
import sys
|
|
582
|
+
|
|
583
|
+
out = {module_name(p): nid for nid, p in paths.items()}
|
|
584
|
+
stdlib = getattr(sys, "stdlib_module_names", frozenset())
|
|
585
|
+
candidates: dict[str, set[str]] = {}
|
|
586
|
+
for nid, p in paths.items():
|
|
587
|
+
parts = p.replace("\\", "/").split("/")
|
|
588
|
+
names = {parts[-1].removesuffix(".py")}
|
|
589
|
+
if len(parts) > 1:
|
|
590
|
+
names.add(module_name("/".join(parts[1:])))
|
|
591
|
+
for name in names:
|
|
592
|
+
if name.split(".")[0] in stdlib:
|
|
593
|
+
continue
|
|
594
|
+
candidates.setdefault(name, set()).add(nid)
|
|
595
|
+
for name, nids in candidates.items():
|
|
596
|
+
if len(nids) == 1:
|
|
597
|
+
out.setdefault(name, next(iter(nids)))
|
|
598
|
+
return out
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _lookup(dotted: str, modules: dict[str, str]) -> tuple[str | None, bool]:
|
|
602
|
+
"""(node_id of the longest known prefix, whether the whole name matched)."""
|
|
603
|
+
parts = dotted.split(".")
|
|
604
|
+
for i in range(len(parts), 0, -1):
|
|
605
|
+
nid = modules.get(".".join(parts[:i]))
|
|
606
|
+
if nid is not None:
|
|
607
|
+
return nid, i == len(parts)
|
|
608
|
+
return None, False
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _import_records(
|
|
612
|
+
node_id: str, path: str, tree: ast.Module, modules: dict[str, str]
|
|
613
|
+
) -> list[tuple[str, str | None, str | None]]:
|
|
614
|
+
"""(dep node_id, name imported from it or None for the module object itself,
|
|
615
|
+
local binding) for every top-level import resolving to another project module.
|
|
616
|
+
Relative imports resolve against the importing file's package."""
|
|
617
|
+
package = _package_of(path)
|
|
618
|
+
out: list[tuple[str, str | None, str | None]] = []
|
|
619
|
+
|
|
620
|
+
def add(nid: str | None, name: str | None, local: str | None) -> None:
|
|
621
|
+
if nid is not None and nid != node_id:
|
|
622
|
+
out.append((nid, name, local))
|
|
623
|
+
|
|
624
|
+
for stmt in tree.body:
|
|
625
|
+
if isinstance(stmt, ast.Import):
|
|
626
|
+
for alias in stmt.names:
|
|
627
|
+
nid, _exact = _lookup(alias.name, modules)
|
|
628
|
+
add(nid, None, alias.asname or alias.name.split(".")[0])
|
|
629
|
+
elif isinstance(stmt, ast.ImportFrom):
|
|
630
|
+
if stmt.level:
|
|
631
|
+
base_parts = package.split(".") if package else []
|
|
632
|
+
if stmt.level - 1 > len(base_parts):
|
|
633
|
+
continue # climbs above the project root
|
|
634
|
+
base_parts = base_parts[: len(base_parts) - (stmt.level - 1)]
|
|
635
|
+
base = ".".join(base_parts + ([stmt.module] if stmt.module else []))
|
|
636
|
+
else:
|
|
637
|
+
base = stmt.module or ""
|
|
638
|
+
base_nid, exact = _lookup(base, modules) if base else (None, False)
|
|
639
|
+
for alias in stmt.names:
|
|
640
|
+
if alias.name == "*":
|
|
641
|
+
continue
|
|
642
|
+
local = alias.asname or alias.name
|
|
643
|
+
sub_nid, sub_exact = _lookup(f"{base}.{alias.name}" if base else alias.name, modules)
|
|
644
|
+
if sub_exact and sub_nid != base_nid:
|
|
645
|
+
add(sub_nid, None, local) # `from pkg import submodule`
|
|
646
|
+
elif base_nid is not None:
|
|
647
|
+
add(base_nid, alias.name if exact else None, local)
|
|
648
|
+
return out
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def resolve_imports(node_id: str, path: str, source: str, modules: dict[str, str]) -> dict[str, set[str]]:
|
|
652
|
+
"""Dependency node_id -> names this module imports from it (empty when it
|
|
653
|
+
imports the module object itself). Unparseable source resolves to nothing."""
|
|
654
|
+
try:
|
|
655
|
+
tree = ast.parse(source)
|
|
656
|
+
except SyntaxError:
|
|
657
|
+
return {}
|
|
658
|
+
out: dict[str, set[str]] = {}
|
|
659
|
+
for nid, name, _local in _import_records(node_id, path, tree, modules):
|
|
660
|
+
out.setdefault(nid, set())
|
|
661
|
+
if name is not None:
|
|
662
|
+
out[nid].add(name)
|
|
663
|
+
return out
|