ouroboros/scripts/regenerate_inventories.py
Ouroboros 25c249f855 v7next F5 CPL-2: gen/verify inventories (frozen contracts, data layout, facades)
Three generated inventories with staleness = red, following the ratchet/
domain-manifest gen/verify convention (generator in scripts/, deterministic
output, verify test in tests/):

- docs/v7next/FROZEN_CONTRACTS_INVENTORY.md - machine extraction of
  ARCHITECTURE 11.1 (20 rows): every referenced owner/anchor path resolved
  against the tree; the ouroboros/contracts/ package-coverage gap is listed
  and PINNED in the test (task_constraint.py, skill_payload_policy.py are
  the two known 11.1 gaps; a new frozen-package module without a 11.1 row
  turns red even after regeneration).
- docs/v7next/DATA_LAYOUT_INVENTORY.md - the ARCHITECTURE Data-layout tree
  (111 entries; the closest carrier this tree has to the reference
  PERSISTENCE_OWNERS checklist) probed entry-by-entry: repo entries must be
  tracked paths, data-plane entries must appear as literals in the runtime
  sources; zero UNRESOLVED entries pinned.
- docs/v7next/FACADE_INVENTORY.md - AST-derived noqa:F401 re-export facade
  inventory over the domain-manifest population (49 facades, 2175 marked
  bindings, 121 cross-domain facade->leaf pairs), leaf domains from
  ouroboros/domains.toml.

scripts/regenerate_inventories.py --check is the CI-invocable verify;
tests/test_generated_inventories.py pins byte-identity, the resolution
invariants, and synthetic red-branch coverage for the parsers/markers.
2026-09-01 10:37:37 +00:00

441 lines
17 KiB
Python

#!/usr/bin/env python3
"""Regenerate the three CPL-2 gen/verify inventories (plan §7.2).
Each inventory is a generated document whose staleness turns CI red
(``tests/test_generated_inventories.py`` pins byte-identity against a fresh
in-memory regeneration, plus the resolution invariants below):
1. ``docs/v7next/FROZEN_CONTRACTS_INVENTORY.md`` — machine extraction of the
ARCHITECTURE §11.1 frozen-contracts table: per row the contract label, the
owner files, and the anchoring suites, every referenced repo path resolved
against the tree (a row whose owner or anchor file disappeared = red), plus
the ``ouroboros/contracts/`` package coverage (a contracts module never
referenced by §11.1 is listed as a gap — growth of that list = red).
2. ``docs/v7next/DATA_LAYOUT_INVENTORY.md`` — machine extraction of the
ARCHITECTURE "Data layout (`~/Ouroboros/`)" tree (the closest thing this
tree has to the reference's PERSISTENCE_OWNERS carrier): every entry is
probed against reality — repo entries must exist as tracked paths, data-
plane entries must appear as a literal in the runtime sources that
construct them (a renamed/removed durable file whose tree row survived =
red).
3. ``docs/v7next/FACADE_INVENTORY.md`` — AST-derived facade inventory: every
runtime module whose top-level ``from <population module> import ...``
statements carry the ``noqa: F401`` re-export marker (the codebase's
declared "this binding exists for compatibility" convention, per the
reference FACADE_CONSUMERS method), with its leaves, name counts and
domain from ``ouroboros/domains.toml``.
Convention follows the ratchet/domain-manifest pairs: generator in scripts/,
deterministic output (no timestamps, no HEAD SHAs), verify test in tests/.
``--check`` exits 1 when any on-disk inventory differs from regeneration.
"""
from __future__ import annotations
import argparse
import ast
import pathlib
import re
import subprocess
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
from scripts.domain_graph import ( # noqa: E402
load_manifest,
module_name,
tracked_population,
)
ARCHITECTURE = REPO_ROOT / "docs" / "ARCHITECTURE.md"
OUT_DIR = REPO_ROOT / "docs" / "v7next"
FROZEN_OUT = OUT_DIR / "FROZEN_CONTRACTS_INVENTORY.md"
LAYOUT_OUT = OUT_DIR / "DATA_LAYOUT_INVENTORY.md"
FACADE_OUT = OUT_DIR / "FACADE_INVENTORY.md"
_PATH_SPAN = re.compile(
r"^(?:ouroboros|supervisor|web|tests|scripts|docs|prompts|skills)/[\w./-]+\.\w+")
_CODE_SPAN = re.compile(r"`([^`]+)`")
def _tracked_all() -> set[str]:
out = subprocess.run(["git", "ls-files"], cwd=REPO_ROOT,
capture_output=True, text=True, check=True).stdout
return set(out.split())
def _split_row(line: str) -> list[str]:
"""Split one markdown table row on unescaped pipes (adoption-validator
convention)."""
body = line.strip().strip("|")
cells, cur, escaped = [], [], False
for ch in body:
if escaped:
cur.append(ch)
escaped = False
elif ch == "\\":
cur.append(ch)
escaped = True
elif ch == "|":
cells.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
cells.append("".join(cur).strip())
return cells
# ---------------------------------------------------------------------------
# 1. Frozen contracts (ARCHITECTURE §11.1)
# ---------------------------------------------------------------------------
def frozen_section_text(arch_text: str) -> str:
m = re.search(r"^### 11\.1 What is frozen$(.*?)^### 11\.2 ",
arch_text, re.MULTILINE | re.DOTALL)
if not m:
raise ValueError("ARCHITECTURE.md has no `### 11.1 What is frozen` section")
return m.group(1)
def _row_paths(cell: str) -> list[str]:
paths = []
for span in _CODE_SPAN.findall(cell):
span = span.replace("\\|", "|")
base = span.split("::", 1)[0]
if _PATH_SPAN.match(base):
paths.append(span)
return paths
def _row_label(cell: str) -> str:
m = _CODE_SPAN.search(cell)
if m:
return m.group(1)
return cell.split(" — ", 1)[0].split(".", 1)[0].strip()[:80]
def parse_frozen_rows(section: str) -> list[dict]:
rows = []
lines = section.splitlines()
in_table = False
for line in lines:
if line.startswith("|"):
cells = _split_row(line)
if [c.lower() for c in cells[:3]] == ["contract", "file", "anchored by"]:
in_table = True
continue
if in_table and set(line.replace("|", "").strip()) <= {"-", " "}:
continue
if in_table and len(cells) >= 3:
rows.append({
"label": _row_label(cells[0]),
"owners": _row_paths(cells[1]),
"anchors": _row_paths(cells[2]),
})
else:
in_table = False
return rows
def build_frozen_inventory() -> tuple[str, list[str]]:
"""Returns (rendered document, hard findings)."""
arch_text = ARCHITECTURE.read_text(encoding="utf-8")
section = frozen_section_text(arch_text)
rows = parse_frozen_rows(section)
tracked = _tracked_all()
findings: list[str] = []
# The prose paragraph above the table declares the browser-envelope ABI.
prose = section.split("| Contract |", 1)[0]
prose_paths = sorted({p for p in _row_paths(prose)})
def resolve(span: str) -> str:
base = span.split("::", 1)[0]
if base in tracked:
return "ok"
findings.append(f"frozen: §11.1 references missing file: {span}")
return "MISSING"
contracts_pkg = sorted(p for p in tracked_population(REPO_ROOT)
if p.startswith("ouroboros/contracts/")
and not p.endswith("__init__.py"))
referenced = set()
for p in contracts_pkg:
if p in section:
referenced.add(p)
uncovered = [p for p in contracts_pkg if p not in referenced]
L: list[str] = []
L.append("# Frozen-contracts inventory (generated)")
L.append("")
L.append("Machine extraction of `docs/ARCHITECTURE.md` §11.1 (the frozen-ABI"
" SSOT), regenerated by `python scripts/regenerate_inventories.py`."
" Do not edit — edit §11.1 and regenerate;"
" `tests/test_generated_inventories.py` pins byte-identity and the"
" resolution invariants (a §11.1 row whose owner or anchor file"
" disappeared from the tree = red).")
L.append("")
L.append(f"- table rows: **{len(rows)}**")
L.append(f"- browser-envelope prose owners: {', '.join(f'`{p}`' for p in prose_paths)}")
L.append("")
L.append("| # | contract | owner files | anchored by |")
L.append("|---:|---|---|---|")
for i, row in enumerate(rows, 1):
owners = "<br>".join(f"`{p}` ({resolve(p)})" for p in row["owners"]) or "—"
anchors = "<br>".join(f"`{p}` ({resolve(p)})" for p in row["anchors"]) or "—"
label = row["label"].replace("|", "\\|")
L.append(f"| {i} | `{label}` | {owners} | {anchors} |")
for p in prose_paths:
resolve(p)
L.append("")
L.append("## Package coverage")
L.append("")
L.append(f"`ouroboros/contracts/` modules (excluding `__init__.py`): {len(contracts_pkg)};"
f" referenced by §11.1: {len(referenced)}.")
if uncovered:
L.append("")
L.append("Contracts-package modules NOT referenced by §11.1 (a frozen-package"
" module without a frozen-table row — every new one must either get"
" a §11.1 row or be a deliberate, here-visible gap):")
L.append("")
for p in uncovered:
L.append(f"- `{p}`")
else:
L.append("")
L.append("Every contracts-package module is referenced by §11.1.")
L.append("")
return "\n".join(L), findings
# ---------------------------------------------------------------------------
# 2. Data layout (ARCHITECTURE "Data layout" tree)
# ---------------------------------------------------------------------------
_TREE_ENTRY = re.compile(r"[├└]──\s+(.+?)(?:\s+←.*)?$")
def layout_block(arch_text: str) -> str:
m = re.search(r"^### Data layout \(`~/Ouroboros/`\)$.*?```(.*?)```",
arch_text, re.MULTILINE | re.DOTALL)
if not m:
raise ValueError("ARCHITECTURE.md has no Data layout fenced tree")
return m.group(1)
def parse_layout_entries(block: str) -> list[str]:
entries = []
for line in block.splitlines():
m = _TREE_ENTRY.search(line)
if m:
entries.append(m.group(1).strip())
return entries
def _probe_token(entry: str) -> str | None:
segments = [s for s in entry.strip("/").split("/") if s]
literal = [s for s in segments if "<" not in s and ">" not in s]
return literal[-1] if literal else None
def build_layout_inventory() -> tuple[str, list[str]]:
arch_text = ARCHITECTURE.read_text(encoding="utf-8")
entries = parse_layout_entries(layout_block(arch_text))
tracked = _tracked_all()
tracked_dirs: set[str] = set()
for p in tracked:
parts = p.split("/")
for i in range(1, len(parts)):
tracked_dirs.add("/".join(parts[:i]))
runtime_blob = "\n".join(
(REPO_ROOT / p).read_text(encoding="utf-8", errors="replace")
for p in sorted(tracked_population(REPO_ROOT)))
findings: list[str] = []
rows: list[tuple[str, str, str]] = []
for entry in entries:
probe = _probe_token(entry)
if probe is None:
rows.append((entry, "—", "placeholder"))
continue
clean = probe.strip("/")
if any(f == clean or f.endswith("/" + clean) for f in tracked) :
rows.append((entry, clean, "repo-path"))
elif clean in tracked_dirs or any(d.endswith("/" + clean) for d in tracked_dirs):
rows.append((entry, clean, "repo-dir"))
elif clean in runtime_blob:
rows.append((entry, clean, "code-ref"))
else:
rows.append((entry, clean, "UNRESOLVED"))
findings.append(
f"layout: tree entry `{entry}` (probe `{clean}`) resolves neither as a"
" tracked repo path nor as a literal in the runtime sources")
L: list[str] = []
L.append("# Data-layout inventory (generated)")
L.append("")
L.append("Machine extraction of the `docs/ARCHITECTURE.md` \"Data layout"
" (`~/Ouroboros/`)\" tree — the durable-file orientation carrier"
" (this tree's counterpart of the reference PERSISTENCE_OWNERS"
" derivation checklist) — regenerated by"
" `python scripts/regenerate_inventories.py`. Do not edit."
" Every entry is probed against reality: repo entries must exist as"
" tracked paths; data-plane entries must appear as a literal in the"
" runtime sources that construct them. A durable file renamed or"
" removed in code while its tree row survives = red"
" (`tests/test_generated_inventories.py`).")
L.append("")
n_kinds = {}
for _, _, kind in rows:
n_kinds[kind] = n_kinds.get(kind, 0) + 1
L.append(f"- entries: **{len(rows)}** ({', '.join(f'{k}: {v}' for k, v in sorted(n_kinds.items()))})")
L.append("")
L.append("| entry | probe | resolution |")
L.append("|---|---|---|")
for entry, probe, kind in rows:
esc = entry.replace("|", "\\|")
L.append(f"| `{esc}` | `{probe}` | {kind} |")
L.append("")
return "\n".join(L), findings
# ---------------------------------------------------------------------------
# 3. Facade inventory (AST, noqa: F401 re-export marker)
# ---------------------------------------------------------------------------
_NOQA_F401 = re.compile(r"#\s*noqa(?::[^#]*\bF401\b|\s*$|:\s*$)")
def _statement_has_noqa_f401(source_lines: list[str], node: ast.ImportFrom) -> bool:
end = getattr(node, "end_lineno", node.lineno) or node.lineno
for lineno in range(node.lineno, end + 1):
if lineno - 1 < len(source_lines) and _NOQA_F401.search(source_lines[lineno - 1]):
return True
return False
def build_facade_inventory() -> tuple[str, list[str]]:
manifest = load_manifest()
mod_by_name = {module_name(p): p for p in manifest.modules}
def resolve(dotted: str) -> str | None:
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
findings: list[str] = []
facades: list[dict] = []
for path in sorted(manifest.modules):
f = REPO_ROOT / path
if not f.is_file():
continue
source = f.read_text(encoding="utf-8")
lines = source.splitlines()
tree = ast.parse(source, filename=path)
leaves: dict[str, int] = {}
n_names = 0
for node in tree.body:
if not isinstance(node, ast.ImportFrom):
continue
if not _statement_has_noqa_f401(lines, node):
continue
base = ("." * node.level) + (node.module or "")
importer = module_name(path)
if base.startswith("."):
from scripts.domain_graph import resolve_relative
base = resolve_relative(base, importer, path.endswith("__init__.py"))
for alias in node.names:
if alias.name == "*":
leaf = resolve(base)
else:
leaf = resolve(f"{base}.{alias.name}") or resolve(base)
if leaf is None or leaf == path:
continue
leaves[leaf] = leaves.get(leaf, 0) + 1
n_names += 1
if leaves:
facades.append({"path": path, "leaves": leaves, "names": n_names})
L: list[str] = []
L.append("# Facade inventory (generated)")
L.append("")
L.append("AST-derived inventory of compatibility facades, regenerated by"
" `python scripts/regenerate_inventories.py`. Do not edit."
" A facade row is any runtime module whose top-level"
" `from <population module> import ...` statements carry the"
" `noqa: F401` re-export marker — the codebase's declared"
" \"this binding exists for its binding, not for this module's own"
" use\" convention (reference FACADE_CONSUMERS method). Leaf"
" domains come from `ouroboros/domains.toml`; a leaf outside the"
" facade's domain is marked ✗ (that edge also appears in the"
" manifest's pinned direction matrix)."
" `tests/test_generated_inventories.py` pins byte-identity, so any"
" re-export surface change must regenerate this file.")
L.append("")
n_cross = sum(1 for row in facades for leaf in row["leaves"]
if manifest.modules[leaf] != manifest.modules[row["path"]])
L.append(f"- facade modules: **{len(facades)}**;"
f" marked re-export bindings: **{sum(r['names'] for r in facades)}**;"
f" cross-domain facade→leaf pairs: **{n_cross}**")
L.append("")
L.append("| facade | domain | bindings | leaves |")
L.append("|---|---|---:|---|")
for row in facades:
dom = manifest.modules[row["path"]]
leaf_cells = []
for leaf in sorted(row["leaves"]):
ldom = manifest.modules[leaf]
mark = "" if ldom == dom else f" ✗{ldom}"
leaf_cells.append(f"`{leaf}` ({row['leaves'][leaf]}{mark})")
L.append(f"| `{row['path']}` | {dom} | {row['names']} | {'<br>'.join(leaf_cells)} |")
L.append("")
return "\n".join(L), findings
# ---------------------------------------------------------------------------
BUILDERS = {
FROZEN_OUT: build_frozen_inventory,
LAYOUT_OUT: build_layout_inventory,
FACADE_OUT: build_facade_inventory,
}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--check", action="store_true",
help="verify the on-disk inventories match regeneration (no writes)")
args = ap.parse_args(argv)
stale: list[str] = []
all_findings: list[str] = []
for out_path, builder in BUILDERS.items():
try:
rendered, findings = builder()
except (OSError, ValueError) as exc:
print(f"structural failure building {out_path.name}: {exc}", file=sys.stderr)
return 2
rendered += "\n" if not rendered.endswith("\n") else ""
all_findings.extend(findings)
rel = out_path.relative_to(REPO_ROOT)
if args.check:
on_disk = out_path.read_text(encoding="utf-8") if out_path.is_file() else None
if on_disk != rendered:
stale.append(str(rel))
else:
out_path.write_text(rendered, encoding="utf-8")
print(f"wrote {rel}")
for f in all_findings:
print(f"RED: {f}", file=sys.stderr)
if args.check and stale:
for rel in stale:
print(f"RED: stale inventory (regeneration changes it): {rel}", file=sys.stderr)
return 1 if (all_findings or stale) else 0
if __name__ == "__main__":
sys.exit(main())