The henxels allowed_filetypes rule polices top-level ./docs/* for markdown/yaml only; machine-generated JSON artifacts are not docs. Moving rebase-inventory.json and rebase-wave-coverage.json under docs/rebase/ (outside the single-segment glob) rather than widening the gate's except list to fit new files. All consumer references updated (rebase_inventory.py, rebase_issue_factory.py, wave0 report).
215 lines
8.6 KiB
Python
215 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Tranche R1 Wave 0 — whole-project inventory generator.
|
|
|
|
Parses every .py file under src/rune/ with the stdlib AST and emits
|
|
docs/rebase/rebase-inventory.json: files, classes, functions (with signatures and
|
|
line ranges), imports, and best-effort caller/callee edges (intra-module call
|
|
graph + cross-module edges resolved through import tables).
|
|
|
|
Deterministic and idempotent: same tree in, same JSON out (sorted keys,
|
|
sorted files). The issue-factory reads this file; nothing else may edit it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
SRC = REPO / "src" / "rune"
|
|
OUT = REPO / "docs" / "rebase" / "rebase-inventory.json"
|
|
|
|
|
|
def _arg_str(a: ast.arg) -> str:
|
|
s = a.arg
|
|
if a.annotation is not None:
|
|
# Degraded signature (no annotation) is deterministic metadata — never
|
|
# let one pathological annotation node abort the whole inventory walk.
|
|
try:
|
|
s += f": {ast.unparse(a.annotation)}"
|
|
except (AttributeError, RecursionError, ValueError):
|
|
pass
|
|
return s
|
|
|
|
|
|
def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
parts = [_arg_str(a) for a in node.args.args]
|
|
if node.args.vararg:
|
|
parts.append(f"*{node.args.vararg.arg}")
|
|
elif node.args.kwonlyargs:
|
|
parts.append("*")
|
|
parts += [_arg_str(a) for a in node.args.kwonlyargs]
|
|
if node.args.kwarg:
|
|
parts.append(f"**{node.args.kwarg.arg}")
|
|
ret = ""
|
|
if node.returns is not None:
|
|
# Same degraded-metadata contract as _arg_str.
|
|
try:
|
|
ret = f" -> {ast.unparse(node.returns)}"
|
|
except (AttributeError, RecursionError, ValueError):
|
|
pass
|
|
return f"({', '.join(parts)}){ret}"
|
|
|
|
|
|
class FileVisitor(ast.NodeVisitor):
|
|
def __init__(self) -> None:
|
|
self.functions: list[dict] = []
|
|
self.classes: list[dict] = []
|
|
self.imports: list[dict] = []
|
|
self.calls: list[tuple[int, str]] = [] # (lineno, dotted call name)
|
|
|
|
def _fn(self, node, parents: list[str]) -> dict:
|
|
return {
|
|
"name": ".".join([*parents, node.name]),
|
|
"signature": _signature(node),
|
|
"line_start": node.lineno,
|
|
"line_end": node.end_lineno or node.lineno,
|
|
"decorators": [ast.unparse(d) for d in node.decorator_list
|
|
if isinstance(d, (ast.Name, ast.Attribute, ast.Call))],
|
|
"async": isinstance(node, ast.AsyncFunctionDef),
|
|
}
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
methods = []
|
|
for child in node.body:
|
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
methods.append(self._fn(child, [node.name]))
|
|
self.calls.extend(
|
|
(n.lineno, ast.unparse(n.func))
|
|
for n in ast.walk(child)
|
|
if isinstance(n, ast.Call) and not isinstance(n.func, ast.Name if False else type(None))
|
|
and isinstance(n.func, (ast.Name, ast.Attribute))
|
|
)
|
|
self.classes.append({
|
|
"name": node.name,
|
|
"bases": [ast.unparse(b) for b in node.bases],
|
|
"line_start": node.lineno,
|
|
"line_end": node.end_lineno or node.lineno,
|
|
"methods": methods,
|
|
})
|
|
# visit only non-function children: methods are already captured above,
|
|
# and a generic_visit would re-fire visit_FunctionDef on them (duplicate
|
|
# units + duplicate call edges). Nested classes are still visited.
|
|
for child in node.body:
|
|
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
self.visit(child)
|
|
|
|
def visit_FunctionDef(self, node) -> None:
|
|
# top-level only reached here (methods captured in ClassDef)
|
|
self.functions.append(self._fn(node, []))
|
|
self.calls.extend(
|
|
(n.lineno, ast.unparse(n.func))
|
|
for n in ast.walk(node)
|
|
if isinstance(n, ast.Call) and isinstance(n.func, (ast.Name, ast.Attribute))
|
|
)
|
|
# do not descend into nested defs separately; they are folded into parents
|
|
# (their calls are already captured by the ast.walk above). No generic_visit:
|
|
# it would re-collect nested defs as top-level functions and duplicate edges.
|
|
|
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
for a in node.names:
|
|
self.imports.append({"module": a.name, "as": a.asname, "line": node.lineno})
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
mod = node.module or ""
|
|
for a in node.names:
|
|
self.imports.append({"module": f"{mod}.{a.name}" if a.name != "*" else mod,
|
|
"as": a.asname or a.name, "line": node.lineno,
|
|
"from": True})
|
|
|
|
|
|
def collect_file(path: Path) -> dict:
|
|
src_text = path.read_text(encoding="utf-8", errors="replace")
|
|
tree = ast.parse(src_text)
|
|
v = FileVisitor()
|
|
v.visit(tree)
|
|
|
|
# module-level calls (outside defs/classes) — walk tree body directly.
|
|
# SKIP def/class statements: their bodies are walked by the visit_ methods;
|
|
# walking them here duplicated every call edge inside every top-level function.
|
|
for stmt in tree.body:
|
|
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
continue
|
|
for n in ast.walk(stmt):
|
|
if isinstance(n, ast.Call) and isinstance(n.func, (ast.Name, ast.Attribute)):
|
|
v.calls.append((n.lineno, ast.unparse(n.func)))
|
|
|
|
defined = {f["name"].split(".")[-1] for f in v.functions}
|
|
defined |= {m["name"].split(".")[-1] for c in v.classes for m in c["methods"]}
|
|
defined |= {c["name"] for c in v.classes}
|
|
|
|
callees = []
|
|
for lineno, call in v.calls:
|
|
root = call.split(".")[0].split("(")[0].strip()
|
|
callees.append({"line": lineno, "call": call,
|
|
"internal": root in defined or root == "self"})
|
|
|
|
return {
|
|
"path": str(path.relative_to(REPO)),
|
|
"lines": len(src_text.splitlines()),
|
|
"imports": sorted({i["module"] for i in v.imports}),
|
|
"import_lines": v.imports,
|
|
"functions": sorted(v.functions, key=lambda f: (f["line_start"], f["name"])),
|
|
"classes": sorted(v.classes, key=lambda c: c["line_start"]),
|
|
"calls": callees,
|
|
}
|
|
|
|
|
|
def cross_module_edges(files: dict[str, dict]) -> dict[str, set[str]]:
|
|
"""Resolve import tables: module-stem -> set of imported module stems
|
|
(rune-internal only), so the factory can attribute cross-module edges."""
|
|
graph = defaultdict(set)
|
|
for rel, info in files.items():
|
|
stem = Path(rel).stem
|
|
for imp in info["imports"]:
|
|
top = imp.split(".")[0]
|
|
second = ".".join(imp.split(".")[:2])
|
|
if top == "rune":
|
|
graph[stem].add(imp.replace("rune.", "", 1).split(".")[0])
|
|
elif not imp.startswith("_") and top not in sys.stdlib_module_names:
|
|
graph[stem].add(f"ext:{top}")
|
|
return graph
|
|
|
|
|
|
def main() -> None:
|
|
py_files = sorted(SRC.rglob("*.py"))
|
|
files: dict[str, dict] = {}
|
|
for p in py_files:
|
|
try:
|
|
info = collect_file(p)
|
|
except SyntaxError as e: # a file that cannot parse is a finding, not a skip
|
|
info = {"path": str(p.relative_to(REPO)), "lines": 0, "imports": [],
|
|
"import_lines": [], "functions": [], "classes": [], "calls": [],
|
|
"parse_error": f"{type(e).__name__}: {e}"}
|
|
files[info["path"]] = info
|
|
|
|
graph = cross_module_edges(files)
|
|
total_fns = sum(len(f["functions"]) + sum(len(c["methods"]) for c in f["classes"])
|
|
for f in files.values())
|
|
doc = {
|
|
"_meta": {
|
|
"generator": "scripts/rebase_inventory.py",
|
|
"tranche": "R1 language rebase, Wave 0",
|
|
"note": "Machine-generated. Regenerate with the script; never hand-edit.",
|
|
"files": len(files),
|
|
"functions_total": total_fns,
|
|
"classes_total": sum(len(f["classes"]) for f in files.values()),
|
|
"lines_total": sum(f["lines"] for f in files.values()),
|
|
},
|
|
"import_graph": {k: sorted(v) for k, v in sorted(graph.items())},
|
|
"files": dict(sorted(files.items())),
|
|
}
|
|
OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n", encoding="utf-8")
|
|
m = doc["_meta"]
|
|
print(f"inventory written: {OUT}")
|
|
print(f"files={m['files']} functions={m['functions_total']} "
|
|
f"classes={m['classes_total']} lines={m['lines_total']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|