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/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