mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
446 lines
17 KiB
Python
446 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared import-graph core for the domain manifest tools (plan §7.1, CPL-1).
|
|
|
|
Single home of the machinery that both the report generator
|
|
(``scripts/v7next_domain_report.py``) and the gate checker
|
|
(``scripts/check_domains.py``) consume, so the two tools cannot drift apart —
|
|
the same discipline the checker itself enforces on runtime code (the
|
|
literal-copy ban).
|
|
|
|
Provides:
|
|
|
|
- manifest loading (``ouroboros/domains.toml``: human sections + generated
|
|
``[graph]``/``[duplicates]`` sections);
|
|
- the module-level import collector with the strict / lazy / guarded /
|
|
TYPE_CHECKING / dynamic classification (an import is STRICT only when it
|
|
executes unconditionally at import time);
|
|
- the domain quotient (cross-domain edges keyed by domain pair, with exact
|
|
module-edge witnesses) and Tarjan SCC over domain nodes;
|
|
- the literal-copy scan: normalized function-body source segments appearing
|
|
in more than one domain (the span-normalization approach follows
|
|
``scripts/v7next_transplant.py``: exact source segments, not name matching).
|
|
|
|
This is analysis tooling, not runtime code: nothing under ``ouroboros/``
|
|
imports it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import hashlib
|
|
import pathlib
|
|
import subprocess
|
|
import textwrap
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
|
|
try: # Python 3.11+
|
|
import tomllib
|
|
except ImportError: # pragma: no cover - the 3.10 venv ships tomli
|
|
import tomli as tomllib
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
MANIFEST_PATH = REPO_ROOT / "ouroboros" / "domains.toml"
|
|
DOMAIN_MAP_PATH = REPO_ROOT / "docs" / "DOMAIN_MAP.md"
|
|
|
|
STRICT, TYPE_ONLY, LAZY, DYNAMIC = "strict", "type_checking", "lazy", "dynamic"
|
|
# Executed at import time but failure-tolerant / entrypoint-only (F0 review F4):
|
|
# a `try: import x except ImportError/Exception` or an import under
|
|
# `if __name__ == "__main__"` must not stand as a strict cycle witness.
|
|
GUARDED = "guarded"
|
|
|
|
# A function body shorter than this many normalized source lines is too small
|
|
# to stand as literal-copy evidence (getters, one-line delegates).
|
|
DUPLICATE_MIN_LINES = 10
|
|
|
|
|
|
def module_name(path: str) -> str:
|
|
parts = path[:-3].split("/") # drop .py
|
|
if parts[-1] == "__init__":
|
|
parts = parts[:-1]
|
|
return ".".join(parts)
|
|
|
|
|
|
def is_type_checking_test(test: ast.expr) -> bool:
|
|
if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
|
|
return True
|
|
if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING":
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_main_guard_test(test: ast.expr) -> bool:
|
|
"""`if __name__ == "__main__":` — the body never runs on import."""
|
|
if not (isinstance(test, ast.Compare) and len(test.ops) == 1
|
|
and isinstance(test.ops[0], ast.Eq) and len(test.comparators) == 1):
|
|
return False
|
|
sides = (test.left, test.comparators[0])
|
|
has_name = any(isinstance(s, ast.Name) and s.id == "__name__" for s in sides)
|
|
has_main = any(isinstance(s, ast.Constant) and s.value == "__main__" for s in sides)
|
|
return has_name and has_main
|
|
|
|
|
|
_SWALLOWING = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"}
|
|
|
|
|
|
def try_swallows_import_failure(node: ast.Try) -> bool:
|
|
"""True when at least one handler catches import failure (or everything)
|
|
AND does not re-raise. A handler whose body contains a top-level ``raise``
|
|
may propagate the failure (``except ImportError: raise``), so it is not a
|
|
swallow — misclassifying it as guarded would hide a strict cycle witness
|
|
(F0 review round 2). A conditional re-raise nested in an ``if`` still
|
|
counts as re-raising here: erring toward STRICT is the safe direction."""
|
|
for h in node.handlers:
|
|
reraises = any(isinstance(s, ast.Raise) for s in ast.walk(h))
|
|
if reraises:
|
|
continue
|
|
if h.type is None: # bare except
|
|
return True
|
|
types = h.type.elts if isinstance(h.type, ast.Tuple) else [h.type]
|
|
for t in types:
|
|
if isinstance(t, ast.Name) and t.id in _SWALLOWING:
|
|
return True
|
|
if isinstance(t, ast.Attribute) and t.attr in _SWALLOWING:
|
|
return True
|
|
return False
|
|
|
|
|
|
class ImportCollector(ast.NodeVisitor):
|
|
"""Collect (kind, raw dotted target or ImportFrom base+names, lineno)."""
|
|
|
|
def __init__(self) -> None:
|
|
self.records: list[tuple[str, str, tuple[str, ...], int]] = []
|
|
# each record: (kind, base_or_module, aliases (() for plain import), lineno)
|
|
self._depth = 0 # function nesting depth
|
|
self._tc = 0 # TYPE_CHECKING nesting depth
|
|
self._guard = 0 # __main__-guard / failure-swallowing-try nesting depth
|
|
|
|
def _kind(self) -> str:
|
|
if self._tc:
|
|
return TYPE_ONLY
|
|
if self._depth:
|
|
return LAZY
|
|
if self._guard:
|
|
return GUARDED
|
|
return STRICT
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self._depth += 1
|
|
self.generic_visit(node)
|
|
self._depth -= 1
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self._depth += 1
|
|
self.generic_visit(node)
|
|
self._depth -= 1
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
self._depth += 1
|
|
self.generic_visit(node)
|
|
self._depth -= 1
|
|
|
|
def visit_If(self, node: ast.If) -> None:
|
|
tc = is_type_checking_test(node.test)
|
|
mg = is_main_guard_test(node.test)
|
|
if tc:
|
|
self._tc += 1
|
|
if mg:
|
|
self._guard += 1
|
|
for child in node.body:
|
|
self.visit(child)
|
|
if tc:
|
|
self._tc -= 1
|
|
if mg:
|
|
self._guard -= 1
|
|
for child in node.orelse:
|
|
self.visit(child)
|
|
|
|
def visit_Try(self, node: ast.Try) -> None:
|
|
swallows = try_swallows_import_failure(node)
|
|
if swallows:
|
|
self._guard += 1
|
|
for child in node.body:
|
|
self.visit(child)
|
|
if swallows:
|
|
self._guard -= 1
|
|
for part in (node.handlers, node.orelse, node.finalbody):
|
|
for child in part:
|
|
self.visit(child)
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
for alias in node.names:
|
|
self.records.append((self._kind(), alias.name, (), node.lineno))
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
names = tuple(a.name for a in node.names)
|
|
base = ("." * node.level) + (node.module or "")
|
|
self.records.append((self._kind(), base, names, node.lineno))
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
target = None
|
|
f = node.func
|
|
if isinstance(f, ast.Attribute) and f.attr == "import_module":
|
|
target = "?"
|
|
elif isinstance(f, ast.Name) and f.id == "__import__":
|
|
target = "?"
|
|
if target is not None:
|
|
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
|
target = node.args[0].value
|
|
else:
|
|
target = "<unresolved>"
|
|
self.records.append((DYNAMIC, target, (), node.lineno))
|
|
self.generic_visit(node)
|
|
|
|
|
|
def resolve_relative(base: str, importer: str, is_pkg: bool) -> str:
|
|
level = len(base) - len(base.lstrip("."))
|
|
tail = base[level:]
|
|
parts = importer.split(".")
|
|
if not is_pkg:
|
|
parts = parts[:-1]
|
|
if level > 1:
|
|
parts = parts[: len(parts) - (level - 1)]
|
|
prefix = ".".join(parts)
|
|
return f"{prefix}.{tail}" if tail else prefix
|
|
|
|
|
|
@dataclass
|
|
class Manifest:
|
|
"""Parsed ``ouroboros/domains.toml``."""
|
|
|
|
path: pathlib.Path
|
|
raw_bytes: bytes
|
|
meta: dict[str, str]
|
|
domains: dict[str, str]
|
|
modules: dict[str, str] # path -> domain id
|
|
proposed: set[str]
|
|
graph_allowed: list[str] = field(default_factory=list) # "D01->D02"
|
|
cycle_groups: list[list[str]] = field(default_factory=list)
|
|
lazy_only: list[str] = field(default_factory=list)
|
|
dynamic_pairs: list[str] = field(default_factory=list)
|
|
duplicates_allowed: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def sha256(self) -> str:
|
|
return hashlib.sha256(self.raw_bytes).hexdigest()
|
|
|
|
|
|
def load_manifest(path: pathlib.Path = MANIFEST_PATH) -> Manifest:
|
|
raw = path.read_bytes()
|
|
data = tomllib.loads(raw.decode("utf-8"))
|
|
graph = data.get("graph", {})
|
|
return Manifest(
|
|
path=path,
|
|
raw_bytes=raw,
|
|
meta=data.get("meta", {}),
|
|
domains=data["domains"],
|
|
modules=data["modules"],
|
|
proposed=set(data.get("classification", {}).get("proposed", [])),
|
|
graph_allowed=list(graph.get("allowed", [])),
|
|
cycle_groups=[list(g) for g in graph.get("cycle_groups", [])],
|
|
lazy_only=list(graph.get("lazy_only", [])),
|
|
dynamic_pairs=list(graph.get("dynamic_pairs", [])),
|
|
duplicates_allowed=list(data.get("duplicates", {}).get("allowed", [])),
|
|
)
|
|
|
|
|
|
def tracked_population(root: pathlib.Path = REPO_ROOT) -> set[str]:
|
|
out = subprocess.run(
|
|
["git", "ls-files", "ouroboros/**/*.py", "ouroboros/*.py",
|
|
"supervisor/*.py", "supervisor/**/*.py", "server.py", "launcher.py"],
|
|
cwd=root, capture_output=True, text=True, check=True,
|
|
).stdout.split()
|
|
return set(out)
|
|
|
|
|
|
@dataclass
|
|
class ImportGraph:
|
|
"""The classified module-level import graph over the manifest population."""
|
|
|
|
edges: dict[str, dict[tuple[str, str], list[int]]]
|
|
dynamic_unresolved: list[tuple[str, int]]
|
|
dom_of_path: dict[str, str]
|
|
|
|
def quotient(self, kind: str = STRICT) -> dict[tuple[str, str], list[tuple[str, str]]]:
|
|
"""Cross-domain edges of one kind, keyed by (from_domain, to_domain)."""
|
|
pairs: dict[tuple[str, str], list[tuple[str, str]]] = defaultdict(list)
|
|
for (src, dst) in sorted(self.edges[kind]):
|
|
d1, d2 = self.dom_of_path[src], self.dom_of_path[dst]
|
|
if d1 != d2:
|
|
pairs[(d1, d2)].append((src, dst))
|
|
return pairs
|
|
|
|
|
|
def build_import_graph(manifest: Manifest, root: pathlib.Path = REPO_ROOT,
|
|
population: set[str] | None = None) -> ImportGraph:
|
|
modules = manifest.modules
|
|
mod_by_name = {module_name(p): p for p in modules}
|
|
|
|
def resolve(dotted: str) -> str | None:
|
|
"""Longest population module matching the dotted name."""
|
|
parts = dotted.split(".")
|
|
for i in range(len(parts), 0, -1):
|
|
cand = ".".join(parts[:i])
|
|
if cand in mod_by_name:
|
|
return mod_by_name[cand]
|
|
return None
|
|
|
|
edges: dict[str, dict[tuple[str, str], list[int]]] = {
|
|
STRICT: defaultdict(list), TYPE_ONLY: defaultdict(list),
|
|
LAZY: defaultdict(list), DYNAMIC: defaultdict(list),
|
|
GUARDED: defaultdict(list),
|
|
}
|
|
dynamic_unresolved: list[tuple[str, int]] = []
|
|
pop = set(modules) if population is None else (set(modules) & population)
|
|
|
|
for path in sorted(pop):
|
|
src = root / path
|
|
tree = ast.parse(src.read_text(encoding="utf-8"), filename=path)
|
|
importer = module_name(path)
|
|
is_pkg = path.endswith("__init__.py")
|
|
col = ImportCollector()
|
|
col.visit(tree)
|
|
for kind, base, names, lineno in col.records:
|
|
if kind == DYNAMIC and base == "<unresolved>":
|
|
dynamic_unresolved.append((path, lineno))
|
|
continue
|
|
base_abs = resolve_relative(base, importer, is_pkg) if base.startswith(".") else base
|
|
targets = []
|
|
if names:
|
|
for name in names:
|
|
if name == "*":
|
|
targets.append(base_abs)
|
|
continue
|
|
sub = f"{base_abs}.{name}" if base_abs else name
|
|
targets.append(sub if resolve(sub) else base_abs)
|
|
else:
|
|
targets.append(base_abs)
|
|
for dotted in targets:
|
|
dst = resolve(dotted)
|
|
if dst is None or dst == path:
|
|
continue
|
|
edges[kind][(path, dst)].append(lineno)
|
|
|
|
return ImportGraph(edges=edges, dynamic_unresolved=dynamic_unresolved,
|
|
dom_of_path=dict(modules))
|
|
|
|
|
|
def strongly_connected(domains: list[str],
|
|
dom_edges: dict[tuple[str, str], list[tuple[str, str]]],
|
|
) -> list[list[str]]:
|
|
"""Tarjan SCC over domain nodes; returns every component (sorted members)."""
|
|
graph: dict[str, set[str]] = defaultdict(set)
|
|
for (d1, d2) in dom_edges:
|
|
graph[d1].add(d2)
|
|
index: dict[str, int] = {}
|
|
low: dict[str, int] = {}
|
|
on_stack: set[str] = set()
|
|
stack: list[str] = []
|
|
sccs: list[list[str]] = []
|
|
counter = [0]
|
|
|
|
def strongconnect(v: str) -> None:
|
|
index[v] = low[v] = counter[0]
|
|
counter[0] += 1
|
|
stack.append(v)
|
|
on_stack.add(v)
|
|
for w in sorted(graph.get(v, ())):
|
|
if w not in index:
|
|
strongconnect(w)
|
|
low[v] = min(low[v], low[w])
|
|
elif w in on_stack:
|
|
low[v] = min(low[v], index[w])
|
|
if low[v] == index[v]:
|
|
comp = []
|
|
while True:
|
|
w = stack.pop()
|
|
on_stack.discard(w)
|
|
comp.append(w)
|
|
if w == v:
|
|
break
|
|
sccs.append(sorted(comp))
|
|
|
|
for v in sorted(domains):
|
|
if v not in index:
|
|
strongconnect(v)
|
|
return sccs
|
|
|
|
|
|
def cycle_groups_of(domains: list[str],
|
|
dom_edges: dict[tuple[str, str], list[tuple[str, str]]],
|
|
) -> list[list[str]]:
|
|
"""Only the SCCs with more than one domain (the actual cycles), sorted."""
|
|
return sorted(c for c in strongly_connected(domains, dom_edges) if len(c) > 1)
|
|
|
|
|
|
def pair_key(d1: str, d2: str) -> str:
|
|
return f"{d1}->{d2}"
|
|
|
|
|
|
def _normalize_body(segment: str) -> str:
|
|
"""Normalize a function's source segment for literal-copy comparison:
|
|
dedent, strip trailing whitespace, drop blank lines. Version-stable
|
|
(pure text), unlike ``ast.dump`` whose field set moves between Pythons."""
|
|
lines = [ln.rstrip() for ln in textwrap.dedent(segment).splitlines()]
|
|
return "\n".join(ln for ln in lines if ln)
|
|
|
|
|
|
def duplicate_bodies(manifest: Manifest, root: pathlib.Path = REPO_ROOT,
|
|
min_lines: int = DUPLICATE_MIN_LINES) -> list[str]:
|
|
"""Literal-copy scan: normalized function bodies appearing in modules of
|
|
MORE THAN ONE domain. Returns sorted rows
|
|
``"<digest16> <path>::<qualname> <path>::<qualname> ..."`` — the format
|
|
pinned in the manifest ``[duplicates].allowed`` baseline."""
|
|
by_digest: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
|
|
for path in sorted(manifest.modules):
|
|
src_path = root / path
|
|
if not src_path.is_file():
|
|
continue
|
|
source = src_path.read_text(encoding="utf-8")
|
|
tree = ast.parse(source, filename=path)
|
|
|
|
def walk(node: ast.AST, prefix: str) -> None:
|
|
for child in ast.iter_child_nodes(node):
|
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
qual = f"{prefix}{child.name}"
|
|
# For ordinary source line endings, normalization cannot grow
|
|
# the raw span. Embedded splitlines-only separators are not counted.
|
|
span = (getattr(child, "end_lineno", None) or child.lineno) - child.lineno + 1
|
|
segment = ast.get_source_segment(source, child) if span >= min_lines else None
|
|
if segment is not None:
|
|
norm = _normalize_body(segment)
|
|
if norm.count("\n") + 1 >= min_lines:
|
|
digest = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
|
|
by_digest[digest].append((path, qual))
|
|
walk(child, f"{qual}.")
|
|
elif isinstance(child, ast.ClassDef):
|
|
walk(child, f"{prefix}{child.name}.")
|
|
|
|
walk(tree, "")
|
|
|
|
rows: list[str] = []
|
|
for digest, locs in by_digest.items():
|
|
domains = {manifest.modules[p] for p, _ in locs}
|
|
if len(domains) < 2:
|
|
continue
|
|
parts = " ".join(f"{p}::{q}" for p, q in sorted(set(locs)))
|
|
rows.append(f"{digest} {parts}")
|
|
return sorted(rows)
|
|
|
|
|
|
def computed_graph_sections(manifest: Manifest, graph: ImportGraph) -> dict[str, list]:
|
|
"""The generated-section values derived from the live tree."""
|
|
strict_pairs = graph.quotient(STRICT)
|
|
lazy_pairs = graph.quotient(LAZY)
|
|
dynamic_pairs = graph.quotient(DYNAMIC)
|
|
allowed = sorted(pair_key(d1, d2) for (d1, d2) in strict_pairs)
|
|
cycles = cycle_groups_of(sorted(manifest.domains), strict_pairs)
|
|
lazy_only = sorted(pair_key(d1, d2) for (d1, d2) in lazy_pairs
|
|
if (d1, d2) not in strict_pairs)
|
|
dyn = sorted(pair_key(d1, d2) for (d1, d2) in dynamic_pairs)
|
|
return {
|
|
"allowed": allowed,
|
|
"cycle_groups": cycles,
|
|
"lazy_only": lazy_only,
|
|
"dynamic_pairs": dyn,
|
|
}
|