orchestrata_ast.analyzer

Reference › orchestrata_ast › analyzer

This is the engine of the code-AST component: it walks a source tree with the standard ast module and builds the CodebaseAST described in models, using nothing outside the standard library. It was extracted from the serializer script. Its users are scripts.serialize_codebase_ast, the entry point of the deployed ast command in /opt/orchestrata-tools/bin, scripts.ast_live_sync, the gate fact_extractor, the implementation_scanner and ast_pipeline.

Main parts:

Relative paths are read under the anchor directory when one is given and never under the process working directory, while stored paths keep repository-relative form.

At a glance

Source src/orchestrata_ast/analyzer.py
Lines of code 1655
Classes 1
Functions 9
Imports (internal) orchestrata_ast.models, orchestrata_ast.serialize
Imported by orchestrata_ast, orchestrata_ultimate.gates.fact_extractor, orchestrata_ultimate.tools.ast_pipeline, orchestrata_ultimate.verification.implementation_scanner, scripts.ast_live_sync, scripts.serialize_codebase_ast
Tests that import it tests/gates/test_freshness_inference.py, tests/gates/test_gate_explain.py, tests/gates/test_gate_rules.py, tests/scripts/test_ast_branches.py, tests/scripts/test_ast_warm_cache.py, tests/scripts/test_gate_cli.py, tests/test_orchestrata_ast_analyzer.py, tests/test_orchestrata_ast_argv_literals.py, tests/test_orchestrata_ast_derive_subtree.py, tests/test_orchestrata_ast_env_access.py, tests/test_orchestrata_ast_scope.py, tests/test_orchestrata_tools_testgen_generator_coverage.py, tests/test_orchestrata_tools_testgen_ranking_coverage.py, tests/test_orchestrata_tools_testgen_units.py

Class diagram

Class diagram orchestrata_ast.analyzer

Dependency diagram

Dependency diagram orchestrata_ast.analyzer

API

Constants

Name Type Value
_ARGV_KEYWORDS tuple[str, ...] ('args', 'cmd', 'command', 'argv')
_EXEC_PROGRAM_FIRST frozenset[str] frozenset({'os.execv', 'os.execve', 'os.execvp', 'os.execvpe', 'os.execl', 'os.…
_EXEC_PROGRAM_SECOND frozenset[str] frozenset({'os.spawnv', 'os.spawnve', 'os.spawnvp', 'os.spawnvpe', 'os.spawnl',…
_EXEC_LAST_SEGMENTS frozenset[str] frozenset({'create_subprocess_exec'})
_SHELL_DOTTED frozenset[str] frozenset({'os.system', 'os.popen'})
_SHELL_LAST_SEGMENTS frozenset[str] frozenset({'getoutput', 'getstatusoutput', 'create_subprocess_shell', 'run', 'P…
_SHELL_BASES frozenset[str] frozenset({'subprocess'})
_SHELL_ASYNCIO_SEGMENTS frozenset[str] frozenset({'create_subprocess_shell'})
_DATACLASS_LIST_FIELDS dict[type, dict[str, type]] {FunctionInfo: {'parameters': ParamInfo}, ClassInfo: {'methods': FunctionInfo, …
SNAPSHOT_CAPTURES tuple[str, ...] ('env_accesses', 'argv_literals')

Classes

CodebaseAnalyzer

Line 188

Full AST analysis of a Python codebase.

Method Signature Summary
__init__ __init__(name: str, root: Path, monorepo: bool = False, extra_roots: tuple[Path, ...] = (), anchor: Path | None = None)
analyze analyze() -> CodebaseAST Analyze the full codebase.
_io _io(path: Path) -> Path The form of path to READ: anchored, never cwd-relative.
_display _display(path: Path) -> Path The form of path to STORE: relative to the anchor when under it.
_find_python_files _find_python_files() -> list[Path] Find all Python source files, across every declared root.
_walk staticmethod _walk(root: Path) -> list[Path] Every .py under root that is OUR SOURCE rather than output.
_owning_extra_root _owning_extra_root(file_path: Path) -> Path | None Which extra root holds this file, if any.
_file_to_module_path _file_to_module_path(file_path: Path) -> str Convert file path to Python module path.
_analyze_file _analyze_file(file_path: Path) -> ModuleInfo | None Analyze a single Python file.
_analyze_class _analyze_class(node: ast.ClassDef, module_path: str, file_path: str, text: str = '') -> ClassInfo Analyze a class definition.
_analyze_function _analyze_function(node: ast.FunctionDef | ast.AsyncFunctionDef, module_path: str, file_path: str, class_name: str | None = None, text: str = '') -> FunctionInfo Analyze a function or method definition (top-level function OR method — methods are FunctionInfo too, distinguished by class_name).
_extract_params _extract_params(args: ast.arguments) -> list[ParamInfo] Extract parameter information.
_get_decorators _get_decorators(node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef) -> list[str] Extract decorator names.
_extract_calls _extract_calls(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str] Extract function/method calls within a function body.
_is_type_checking_test staticmethod _is_type_checking_test(test: ast.expr) -> bool TYPE_CHECKING or typing.TYPE_CHECKING as an if test.
_collect_scoped_imports _collect_scoped_imports(tree: ast.Module) -> list[ImportInfo] Every import in the file, labelled with the scope it executes in.
_analyze_import _analyze_import(node: ast.Import | ast.ImportFrom) -> ImportInfo Analyze an import statement.
_analyze_env_accesses staticmethod _analyze_env_accesses(tree: ast.AST, file_path: str) -> list[EnvAccessInfo] Every access to the process environment in one parsed module.
_analyze_argv_literals staticmethod _analyze_argv_literals(tree: ast.AST, file_path: str) -> list[ArgvLiteralInfo] Every literal program name at a spawn-shaped site in one module.
_analyze_annotation _analyze_annotation(node: ast.AnnAssign) -> ClassVarInfo | None Analyze an annotated assignment.
_compute_derived _compute_derived() -> None Compute inheritance graph, dependency graph, and totals.

Functions

Function Signature Summary
_is_nested_distribution _is_nested_distribution(directory: Path) -> bool Does directory declare itself a distribution independent of ours?
_source_segment_hash _source_segment_hash(text: str, node: ast.AST) -> str sha256(ast.get_source_segment(text, node))[:16] — byte-exact hash of node's exact source span within text.
_residue_hash _residue_hash(text: str, top_level_spans: list[tuple[int, int]]) -> str sha256(text minus the given 1-based line spans)[:16] — ModuleInfo.residue_hash: everything in a file OUTSIDE its top-level class/function defs (imports, module vars, decorators of top-level…
_sha1_of_source _sha1_of_source(path: Path) -> str Content hash of a source file — the freshness authority (never mtime).
compute_file_meta compute_file_meta(modules: list[ModuleInfo], root: Path | None = None) -> dict[str, dict[str, Any]] module_path -> {file_path, sha1, size, mtime_ns} for modules.
_dataclass_from_dict _dataclass_from_dict(d: dict[str, Any]) -> Any Reconstruct a dataclass instance from its dataclasses.asdict() dict (the inverse of orchestrata_ast.serialize.serialize), recursively rebuilding nested dataclass list[...] fields.
module_info_from_dict module_info_from_dict(d: dict[str, Any]) -> ModuleInfo Reconstruct a :class:ModuleInfo (+ nested classes/functions/imports) from its serialized dict — the inverse of serialize(module).
derive_subtree derive_subtree(snapshot: dict[str, Any], *, tree: str, name: str, repo_root: Path) -> dict[str, Any] A single-tree snapshot DERIVED from a wider one — never re-parsed.
load_or_analyze load_or_analyze(name: str, source: Path, cache_path: Path, monorepo: bool = False, extra_roots: tuple[Path, ...] = (), root: Path | None = None) -> tuple[CodebaseAST, dict[str, Any]] Warm-start a :class:CodebaseAST from a previous serialize run.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ast/analyzer.py

"""Source-tree -> CodebaseAST analyzer — stdlib-only.

Extracted verbatim from scripts/serialize_codebase_ast.py (2026-07-10);
see orchestrata_ast.models for the interchange contract.
"""

from __future__ import annotations

import ast
import dataclasses
import hashlib
import json
import os
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Any, cast

from orchestrata_ast.models import (
    ArgvLiteralInfo,
    ClassInfo,
    ClassVarInfo,
    CodebaseAST,
    EnvAccessInfo,
    FunctionInfo,
    ImportInfo,
    ModuleInfo,
    ParamInfo,
)
from orchestrata_ast.serialize import serialize

#: Directory names that hold BUILD OUTPUT rather than source, at any depth.
#: A copy of a module under ``build/`` parses fine and produces a second module
#: with the same dotted path, so every consumer keyed on that path silently
#: takes whichever won the walk order.
EXCLUDED_SOURCE_DIRS = frozenset(
    {
        ".git",
        ".mypy_cache",
        ".pytest_cache",
        ".ruff_cache",
        ".venv",
        "__pycache__",
        "build",
        "dist",
        "node_modules",
        "site-packages",
        "venv",
    }
)

#: Files whose presence declares a directory a SEPARATE distribution.
#: ``scripts/_cxpt``, ``scripts/_ctok``, ``scripts/_codemod_fast`` and
#: ``scripts/_janus_patched`` are upstream projects vendored here to be
#: compiled. Their contents are somebody else's source — and somebody else's
#: test suite — not this codebase's. Detecting them by the build metadata they
#: carry, rather than by name, keeps the next vendored tree from silently
#: rejoining the snapshot.
DISTRIBUTION_MARKERS = frozenset({"pyproject.toml", "setup.py", "setup.cfg"})


def _is_nested_distribution(directory: Path) -> bool:
    """Does *directory* declare itself a distribution independent of ours?"""
    return any((directory / marker).is_file() for marker in DISTRIBUTION_MARKERS)


# ---------------------------------------------------------------------------
# Byte-exact capture (2026-08-02 user mandate: "the AST must capture
# byte-identical code -- all of the code, leave nothing behind"). Two
# helpers: a source-segment hash for class/function bodies, and a residue
# hash for everything else in a module (imports, module vars, decorators of
# top-level defs, module-level statements). Together every byte of a file
# lands in at least one hash. See orchestrata_ast.models docstring.
# ---------------------------------------------------------------------------


def _source_segment_hash(text: str, node: ast.AST) -> str:
    """``sha256(ast.get_source_segment(text, node))[:16]`` — byte-exact hash
    of *node*'s exact source span within *text*.

    Empty string when *text* is empty (nothing to segment — e.g. a
    synthetic/detached node analyzed with no source available) or when
    ``ast.get_source_segment`` itself returns ``None`` (node missing
    lineno/col_offset attributes).
    """
    if not text:
        return ""
    try:
        segment = ast.get_source_segment(text, node)
    except IndexError, AttributeError, ValueError:
        return ""
    if segment is None:
        return ""
    return hashlib.sha256(segment.encode("utf-8", "surrogateescape")).hexdigest()[:16]


def _residue_hash(text: str, top_level_spans: list[tuple[int, int]]) -> str:
    """``sha256(text minus the given 1-based line spans)[:16]`` —
    ``ModuleInfo.residue_hash``: everything in a file OUTSIDE its top-level
    class/function defs (imports, module vars, decorators of top-level defs
    [decorator lines precede ``node.lineno`` so they stay in residue],
    module-level statements).

    *top_level_spans* is ``(node.lineno, node.end_lineno)`` per top-level
    class/function; for each, lines ``[lineno - 1, end_lineno)`` (0-based,
    exclusive end) are dropped from ``text.splitlines(keepends=True)``
    before hashing the remainder.
    """
    lines = text.splitlines(keepends=True)
    drop: set[int] = {
        i
        for lineno, end_lineno in top_level_spans
        for i in range(lineno - 1, end_lineno)
    }
    residue = "".join(line for i, line in enumerate(lines) if i not in drop)
    return hashlib.sha256(residue.encode("utf-8", "surrogateescape")).hexdigest()[:16]


# ---------------------------------------------------------------------------
# Callee names at process-spawn sites (2026-10-11, the bare-toolchain gate —
# Mohamed: "fix bare python make it prolog and atoms"). These name ONLY the
# stdlib spawn APIs whose argument POSITIONS are fixed by the OS contract —
# where the program token sits. Which PROGRAMS count as bare toolchain names
# is NOT decided here or anywhere in Python; that is the Prolog rule's job
# (Q2b), over the facts ArgvLiteralInfo records. See models.py.
# ---------------------------------------------------------------------------

#: Keywords a command list conventionally travels in when a call takes no
#: positional arguments: ``subprocess.Popen(args=("gcc", "-c", "a.c"))``.
_ARGV_KEYWORDS: tuple[str, ...] = ("args", "cmd", "command", "argv")

#: ``os.execv``/``os.execve``/``os.execvp``/``os.execvpe`` and the ``execl``
#: family take the program as the FIRST positional argument.
_EXEC_PROGRAM_FIRST: frozenset[str] = frozenset(
    {
        "os.execv",
        "os.execve",
        "os.execvp",
        "os.execvpe",
        "os.execl",
        "os.execle",
        "os.execlp",
        "os.execlpe",
    }
)

#: ``os.spawnv``/``os.spawnl`` families take the mode FIRST; the program is
#: the SECOND positional argument.
_EXEC_PROGRAM_SECOND: frozenset[str] = frozenset(
    {
        "os.spawnv",
        "os.spawnve",
        "os.spawnvp",
        "os.spawnvpe",
        "os.spawnl",
        "os.spawnle",
        "os.spawnlp",
        "os.spawnlpe",
    }
)

#: Last segments that mark the exec form on ANY base — the asyncio API and
#: any wrapper that kept its name.
_EXEC_LAST_SEGMENTS: frozenset[str] = frozenset({"create_subprocess_exec"})

#: Callees whose first positional argument is a SHELL STRING, by full dotted
#: text...
_SHELL_DOTTED: frozenset[str] = frozenset({"os.system", "os.popen"})
#: ...or by last segment on a known base.
_SHELL_LAST_SEGMENTS: frozenset[str] = frozenset(
    {
        "getoutput",
        "getstatusoutput",
        "create_subprocess_shell",
        "run",
        "Popen",
        "call",
        "check_call",
        "check_output",
    }
)
#: The base those last segments are shell-string callees on. ``asyncio``
#: qualifies ONLY ``create_subprocess_shell``.
_SHELL_BASES: frozenset[str] = frozenset({"subprocess"})
_SHELL_ASYNCIO_SEGMENTS: frozenset[str] = frozenset({"create_subprocess_shell"})


class CodebaseAnalyzer:
    """Full AST analysis of a Python codebase."""

    def __init__(
        self,
        name: str,
        root: Path,
        monorepo: bool = False,
        extra_roots: tuple[Path, ...] = (),
        anchor: Path | None = None,
    ):
        self.name = name
        self.root = root
        self.monorepo = monorepo
        #: The checkout relative roots are read UNDER (ADR-0097) — never
        #: ``os.getcwd()``. Every path the analyzer STORES keeps the form it
        #: was given (repository-root-relative for a daemon publish, exactly
        #: like a CLI run from the repository root); only the reads are
        #: anchored, via :meth:`_io`. ``None`` keeps the CLI's cwd behaviour.
        self.anchor = anchor
        #: Additional trees to analyze alongside *root*. Mohamed, 2026-08-08:
        #: "the ast must see tests and scripts too." A snapshot of ``src``
        #: alone cannot answer a question about a test file, and the Prolog
        #: gate proved it: with only ``src`` serialized, ``file_class`` was
        #: 850/850 ``src`` and every rule was structurally blind to the 1,688
        #: test modules and 191 scripts — a PASS that meant "not looked at".
        self.extra_roots = tuple(extra_roots)
        #: file_path -> parsed AST, populated by ``_analyze_file`` as it
        #: parses each module. Reused by ``_augment_end_line_and_complexity``
        #: (scripts/serialize_codebase_ast.py) so the end_line/complexity
        #: enrichment pass doesn't re-read+re-parse a file this analyzer
        #: already parsed once.
        self.trees: dict[str, ast.Module] = {}
        #: How many Python files ``analyze()`` DISCOVERED, as distinct from
        #: how many it successfully parsed (``len(self.trees)``). A file
        #: that fails to parse is skipped with a stderr note and simply
        #: contributes nothing, so without this the two counts are
        #: indistinguishable to a caller and "found nothing in this file"
        #: reads identically to "could not read this file".
        self.discovered_python_files = 0
        self.result = CodebaseAST(
            name=name,
            root_path=str(root),
            extra_roots=[str(extra) for extra in self.extra_roots],
            captures=list(SNAPSHOT_CAPTURES),
        )

    def analyze(self) -> CodebaseAST:
        """Analyze the full codebase."""
        py_files = self._find_python_files()
        self.discovered_python_files = len(py_files)
        print(f"Found {len(py_files)} Python files in {self.name}")

        for py_file in sorted(py_files):
            module = self._analyze_file(py_file)
            if module:
                self.result.modules.append(module)

        self._compute_derived()
        return self.result

    def _io(self, path: Path) -> Path:
        """The form of *path* to READ: anchored, never cwd-relative."""
        if self.anchor is not None and not path.is_absolute():
            return self.anchor / path
        return path

    def _display(self, path: Path) -> Path:
        """The form of *path* to STORE: relative to the anchor when under it."""
        if self.anchor is None:
            return path
        try:
            return path.relative_to(self.anchor)
        except ValueError:
            return path

    def _find_python_files(self) -> list[Path]:
        """Find all Python source files, across every declared root.

        Walks the anchored (readable) roots but returns the DISPLAY form of
        every file, so each stored ``file_path`` keeps the shape a CLI run
        from the repository root would produce.
        """
        if self.monorepo:
            # Scan packages/orchestrata-*/src/orchestrata/**/*.py
            files: list[Path] = []
            for pkg_dir in sorted(self._io(self.root).iterdir()):
                if pkg_dir.is_dir() and pkg_dir.name.startswith("orchestrata-"):
                    src = pkg_dir / "src"
                    if src.exists():
                        files.extend(self._display(p) for p in self._walk(src))
            return files
        files = [self._display(p) for p in self._walk(self._io(self.root))]
        for extra in self.extra_roots:
            files.extend(self._display(p) for p in self._walk(self._io(extra)))
        return files

    @staticmethod
    def _walk(root: Path) -> list[Path]:
        """Every ``.py`` under *root* that is OUR SOURCE rather than output.

        ``src/orchestrata_ultimate`` needed no exclusions — it holds nothing
        but source. ``scripts`` does, in two distinct ways.

        The vendored native extensions each carry a ``build/`` tree holding
        COPIES of their own modules, so a naive walk serializes the same module
        twice under two paths and hands every consumer keyed on a dotted path a
        silent collision.

        And the vendored trees are not this codebase at all: they are upstream
        distributions parked here to be compiled, carrying upstream's package
        AND upstream's test suite. Serializing them told the coverage report
        that ``janus.query.next`` was an untested method of ours and put
        ``scripts._janus_patched.tests.dog`` in our test inventory.
        """
        found: list[Path] = []
        for dirpath, dirnames, filenames in os.walk(root):
            here = Path(dirpath)
            # In-place, so os.walk never descends into what we drop.
            dirnames[:] = [
                name
                for name in dirnames
                if name not in EXCLUDED_SOURCE_DIRS
                and not _is_nested_distribution(here / name)
            ]
            found.extend(here / name for name in filenames if name.endswith(".py"))
        # os.walk order is filesystem order; the snapshot must not depend on it.
        return sorted(found)

    def _owning_extra_root(self, file_path: Path) -> Path | None:
        """Which extra root holds this file, if any."""
        for extra in self.extra_roots:
            try:
                file_path.relative_to(extra)
            except ValueError:
                continue
            return extra
        return None

    def _file_to_module_path(self, file_path: Path) -> str:
        """Convert file path to Python module path."""
        if self.monorepo:
            # packages/orchestrata-agents/src/orchestrata/agents/base.py
            # → orchestrata.agents.base
            parts = file_path.parts
            try:
                src_idx = list(parts).index("src")
                module_parts = parts[src_idx + 1 :]
            except ValueError:
                module_parts = file_path.relative_to(self.root).parts
        elif (extra := self._owning_extra_root(file_path)) is not None:
            # tests/gates/test_gate_rules.py (extra root=tests)
            # → tests.gates.test_gate_rules
            #
            # The tree's OWN directory name leads, deliberately. The primary
            # root's paths drop their package prefix (``agents.base_agent``),
            # and reusing that scheme for a second tree would collide the
            # moment two trees share a subdirectory name — ``core``, ``agents``
            # and ``gateway`` all exist under both ``src/orchestrata_ultimate``
            # and ``tests``. Prefixing keeps the primary tree's 850 ids
            # byte-identical while making the new ones unambiguous.
            module_parts = (extra.name, *file_path.relative_to(extra).parts)
        else:
            # src/orchestrata_ultimate/agents/base_agent.py (root=src/orchestrata_ultimate)
            # → agents.base_agent  (root-relative, no package prefix)
            try:
                rel = file_path.relative_to(self.root)
                module_parts = rel.parts
            except ValueError:
                parts = file_path.parts
                try:
                    src_idx = list(parts).index("src")
                    module_parts = parts[src_idx + 1 :]
                except ValueError:
                    module_parts = file_path.parts

        # Convert to dotted path, remove .py
        path_str = ".".join(module_parts)
        if path_str.endswith(".py"):
            path_str = path_str[:-3]
        if path_str.endswith(".__init__"):
            path_str = path_str[:-9]
        return path_str

    def _analyze_file(self, file_path: Path) -> ModuleInfo | None:
        """Analyze a single Python file."""
        try:
            content = self._io(file_path).read_text(encoding="utf-8")
            tree = ast.parse(content, filename=str(file_path))
        except (SyntaxError, UnicodeDecodeError) as e:
            print(f"  Skip {file_path}: {e}")
            return None
        self.trees[str(file_path)] = tree

        module_path = self._file_to_module_path(file_path)
        module = ModuleInfo(
            path=module_path,
            file_path=str(file_path),
            line_count=len(content.splitlines()),
            docstring=ast.get_docstring(tree),
        )

        top_level_spans: list[tuple[int, int]] = []
        for node in ast.iter_child_nodes(tree):
            if isinstance(node, ast.ClassDef):
                cls = self._analyze_class(node, module_path, str(file_path), content)
                module.classes.append(cls)
                top_level_spans.append((node.lineno, node.end_lineno or node.lineno))
            elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                func = self._analyze_function(
                    node, module_path, str(file_path), text=content
                )
                module.functions.append(func)
                top_level_spans.append((node.lineno, node.end_lineno or node.lineno))
            elif isinstance(node, ast.AnnAssign):
                var = self._analyze_annotation(node)
                if var:
                    module.module_vars.append(var)
            elif isinstance(node, ast.Assign):
                # Check for __all__
                for target in node.targets:
                    if isinstance(target, ast.Name) and target.id == "__all__":
                        if isinstance(node.value, (ast.List, ast.Tuple)):
                            for elt in node.value.elts:
                                if isinstance(elt, ast.Constant) and isinstance(
                                    elt.value, str
                                ):
                                    module.all_exports.append(elt.value)

        # Whole-tree, not the top-level loop above: an env read inside a
        # function body is exactly as much a RUL-003 subject as one at module
        # scope, and the loop above only walks direct children of the Module.
        module.env_accesses = self._analyze_env_accesses(tree, str(file_path))

        # Same reasoning, different subject: a spawn inside a function body is
        # exactly as much a bare-toolchain subject as one at module scope, and
        # `calls` carries the callee with NO arguments — the program literal
        # is only recoverable from the tree. See ArgvLiteralInfo.
        module.argv_literals = self._analyze_argv_literals(tree, str(file_path))

        # Same reasoning, and the same bug the line above already fixed once:
        # an import inside a function body is still an import. Collected by a
        # scope-tracking walk rather than the top-level loop, which sees only
        # direct children of the Module. See _collect_scoped_imports.
        module.imports = self._collect_scoped_imports(tree)

        module.residue_hash = _residue_hash(content, top_level_spans)
        return module

    def _analyze_class(
        self, node: ast.ClassDef, module_path: str, file_path: str, text: str = ""
    ) -> ClassInfo:
        """Analyze a class definition.

        *text* is the full source of the file *node* was parsed from, used
        to compute a byte-exact ``body_hash`` over the class's source
        segment (``ast.get_source_segment``) — covers class vars + methods,
        every byte of the class. Defaults to ``""`` (body_hash left empty)
        for callers analyzing a synthetic/detached node with no source text.
        """
        qualified = f"{module_path}.{node.name}"
        bases = []
        for base in node.bases:
            try:
                bases.append(ast.unparse(base))
            except Exception:
                if isinstance(base, ast.Name):
                    bases.append(base.id)

        decorators = self._get_decorators(node)

        cls = ClassInfo(
            name=node.name,
            qualified_name=qualified,
            module_path=module_path,
            file_path=file_path,
            line=node.lineno,
            bases=bases,
            decorators=decorators,
            docstring=ast.get_docstring(node),
            is_abstract=any("ABC" in b or "Abstract" in b for b in bases),
            is_dataclass=any("dataclass" in d for d in decorators),
            is_pydantic=any("BaseModel" in b or "BaseSettings" in b for b in bases),
            is_protocol=any("Protocol" in b for b in bases),
            is_enum=any("Enum" in b or "IntEnum" in b for b in bases),
            end_line=node.end_lineno if node.end_lineno is not None else 0,
            body_hash=_source_segment_hash(text, node),
        )

        for item in node.body:
            if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                method = self._analyze_function(
                    item, module_path, file_path, class_name=node.name, text=text
                )
                cls.methods.append(method)
            elif isinstance(item, ast.AnnAssign):
                var = self._analyze_annotation(item)
                if var:
                    cls.class_vars.append(var)
            elif isinstance(item, ast.ClassDef):
                cls.inner_classes.append(item.name)

        return cls

    def _analyze_function(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
        module_path: str,
        file_path: str,
        class_name: str | None = None,
        text: str = "",
    ) -> FunctionInfo:
        """Analyze a function or method definition (top-level function OR
        method — methods are FunctionInfo too, distinguished by class_name).

        *text* is the full source of the file *node* was parsed from, used
        to compute a byte-exact ``body_hash`` over the def..end span
        (``ast.get_source_segment``). Defaults to ``""`` (body_hash left
        empty) for callers analyzing a synthetic/detached node.
        """
        if class_name:
            qualified = f"{module_path}.{class_name}.{node.name}"
        else:
            qualified = f"{module_path}.{node.name}"

        decorators = self._get_decorators(node)

        func = FunctionInfo(
            name=node.name,
            qualified_name=qualified,
            module_path=module_path,
            file_path=file_path,
            line=node.lineno,
            is_async=isinstance(node, ast.AsyncFunctionDef),
            is_method=class_name is not None,
            is_classmethod=any("classmethod" in d for d in decorators),
            is_staticmethod=any("staticmethod" in d for d in decorators),
            is_property=any("property" in d for d in decorators),
            is_abstract=any("abstractmethod" in d for d in decorators),
            decorators=decorators,
            parameters=self._extract_params(node.args),
            return_type=ast.unparse(node.returns) if node.returns else None,
            docstring=ast.get_docstring(node),
            class_name=class_name,
            calls=self._extract_calls(node),
            end_line=node.end_lineno if node.end_lineno is not None else 0,
            body_hash=_source_segment_hash(text, node),
        )

        return func

    def _extract_params(self, args: ast.arguments) -> list[ParamInfo]:
        """Extract parameter information."""
        params = []
        num_defaults = len(args.defaults)
        num_args = len(args.args)

        for i, arg in enumerate(args.args):
            if arg.arg in ("self", "cls"):
                continue
            default_idx = i - (num_args - num_defaults)
            default = None
            if default_idx >= 0:
                try:
                    default = ast.unparse(args.defaults[default_idx])
                except Exception:
                    default = "..."

            params.append(
                ParamInfo(
                    name=arg.arg,
                    type_annotation=(
                        ast.unparse(arg.annotation) if arg.annotation else None
                    ),
                    default=default,
                    kind="positional",
                )
            )

        if args.vararg:
            params.append(
                ParamInfo(
                    name=f"*{args.vararg.arg}",
                    type_annotation=(
                        ast.unparse(args.vararg.annotation)
                        if args.vararg.annotation
                        else None
                    ),
                    kind="varargs",
                )
            )

        for i, arg in enumerate(args.kwonlyargs):
            default = None
            kw_default = args.kw_defaults[i] if i < len(args.kw_defaults) else None
            if kw_default is not None:
                try:
                    default = ast.unparse(kw_default)
                except Exception:
                    default = "..."
            params.append(
                ParamInfo(
                    name=arg.arg,
                    type_annotation=(
                        ast.unparse(arg.annotation) if arg.annotation else None
                    ),
                    default=default,
                    kind="keyword_only",
                )
            )

        if args.kwarg:
            params.append(
                ParamInfo(
                    name=f"**{args.kwarg.arg}",
                    type_annotation=(
                        ast.unparse(args.kwarg.annotation)
                        if args.kwarg.annotation
                        else None
                    ),
                    kind="kwargs",
                )
            )

        return params

    def _get_decorators(
        self, node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
    ) -> list[str]:
        """Extract decorator names."""
        decorators = []
        for dec in node.decorator_list:
            try:
                decorators.append(ast.unparse(dec))
            except Exception:
                if isinstance(dec, ast.Name):
                    decorators.append(dec.id)
                elif isinstance(dec, ast.Attribute):
                    decorators.append(dec.attr)
        return decorators

    def _extract_calls(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
        """Extract function/method calls within a function body."""
        calls = []
        for child in ast.walk(node):
            if isinstance(child, ast.Call):
                try:
                    calls.append(ast.unparse(child.func))
                except Exception:
                    pass
        # Deduplicate, keep order
        seen = set()
        unique = []
        for c in calls:
            if c not in seen:
                seen.add(c)
                unique.append(c)
        return unique

    @staticmethod
    def _is_type_checking_test(test: ast.expr) -> bool:
        """``TYPE_CHECKING`` or ``typing.TYPE_CHECKING`` as an ``if`` test.

        Both spellings, unlike ``architecture_checker._is_inside_type_checking``
        which only matches the bare ``ast.Name`` form.
        """
        if isinstance(test, ast.Name):
            return test.id == "TYPE_CHECKING"
        if isinstance(test, ast.Attribute):
            return test.attr == "TYPE_CHECKING"
        return False

    def _collect_scoped_imports(self, tree: ast.Module) -> list[ImportInfo]:
        """Every import in the file, labelled with the scope it executes in.

        ``_analyze_file``'s top-level loop iterates ``ast.iter_child_nodes`` on
        the Module, so it only ever saw imports that were *direct* children of
        it. Everything nested was silently dropped: function-local (lazy)
        imports, class-body imports, ``if TYPE_CHECKING:`` imports, and the
        ``try: ... except ImportError:`` optional-dependency shape.

        That gap had a measured consequence. ``from_name/4`` — the Prolog fact
        that maps a local name back to the module it came from, and therefore
        the thing that resolves a call to its target — is built from this list.
        With lazy imports missing, ``code_intel.py callers
        core.redis_factory.create_sync_redis`` answered **0 callers** while real
        callers existed, because every one of them imports it inside a function.

        Scope is recorded, not flattened. ``_compute_derived`` still builds
        ``dependency_graph`` from module-scope imports alone: a lazy import is
        real runtime coupling but is NOT an import-time dependency, and feeding
        it to the dependency graph would resurrect precisely the cycles that
        lazy imports are written to break.
        """
        found: list[ImportInfo] = []

        def qualify(owner: str | None, name: str) -> str:
            return f"{owner}.{name}" if owner else name

        def emit(
            node: ast.Import | ast.ImportFrom,
            owner: str | None,
            scope: str,
            conditional: bool,
        ) -> None:
            imp = self._analyze_import(node)
            imp.scope = scope
            imp.owner = owner
            imp.conditional = conditional
            found.append(imp)

        def visit_body(
            stmts: list[ast.stmt], owner: str | None, scope: str, conditional: bool
        ) -> None:
            for stmt in stmts:
                visit(stmt, owner, scope, conditional)

        def visit(
            stmt: ast.stmt, owner: str | None, scope: str, conditional: bool
        ) -> None:
            if isinstance(stmt, (ast.Import, ast.ImportFrom)):
                emit(stmt, owner, scope, conditional)
            elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
                # Anything under a def runs only when called, however the def
                # itself was reached.
                visit_body(
                    stmt.body, qualify(owner, stmt.name), "function", conditional
                )
            elif isinstance(stmt, ast.ClassDef):
                # A class body runs at definition time — which is import time
                # at module scope, but stays lazy inside a function.
                inner = "function" if scope == "function" else "class"
                visit_body(stmt.body, qualify(owner, stmt.name), inner, conditional)
            elif isinstance(stmt, ast.If):
                # `if TYPE_CHECKING:` never executes; its `else:` branch does.
                # A def-scoped TYPE_CHECKING block is still never executed.
                taken = (
                    "type_checking" if self._is_type_checking_test(stmt.test) else scope
                )
                visit_body(stmt.body, owner, taken, True)
                visit_body(stmt.orelse, owner, scope, True)
            elif isinstance(stmt, (ast.Try, ast.TryStar)):
                visit_body(stmt.body, owner, scope, True)
                for handler in stmt.handlers:
                    visit_body(handler.body, owner, scope, True)
                visit_body(stmt.orelse, owner, scope, True)
                visit_body(stmt.finalbody, owner, scope, True)
            elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While)):
                visit_body(stmt.body, owner, scope, True)
                visit_body(stmt.orelse, owner, scope, True)
            elif isinstance(stmt, (ast.With, ast.AsyncWith)):
                visit_body(stmt.body, owner, scope, True)
            elif isinstance(stmt, ast.Match):
                for case in stmt.cases:
                    visit_body(case.body, owner, scope, True)

        visit_body(tree.body, None, "module", False)
        return found

    def _analyze_import(self, node: ast.Import | ast.ImportFrom) -> ImportInfo:
        """Analyze an import statement."""
        if isinstance(node, ast.Import):
            names = [alias.name for alias in node.names]
            # ImportInfo has a single alias slot, so asname is only representable
            # for single-name imports (`import a as b, c as d` stays None).
            asname = node.names[0].asname if len(node.names) == 1 else None
            return ImportInfo(
                module=names[0] if names else "",
                names=names,
                is_from=False,
                alias=asname,
                line=node.lineno,
            )
        else:
            names = [alias.name for alias in node.names]
            return ImportInfo(
                module=node.module or "",
                names=names,
                is_from=True,
                is_relative=node.level > 0,
                level=node.level,
                line=node.lineno,
            )

    @staticmethod
    def _analyze_env_accesses(tree: ast.AST, file_path: str) -> list[EnvAccessInfo]:
        """Every access to the process environment in one parsed module.

        Walks the WHOLE tree, not just module level: RUL-003 applies to a
        function-local `os.environ["X"]` exactly as much as a top-level one.

        Only the literal key is recorded. `os.environ.get(name)` where `name`
        is a variable yields key="" — reported as unknown rather than guessed,
        because a rule that invents a key name is worse than one that abstains.
        """
        accesses: list[EnvAccessInfo] = []

        # Which BARE names in this module are bound to os.environ. Only
        # `from os import environ` (or an `as` alias of it) creates one.
        #
        # Matching the bare name `environ` unconditionally was a false-positive
        # generator, and it fired on the exact pattern RUL-003 mandates: a test
        # that builds `environ: dict[str, str] = {}` and passes it through the
        # code's `env=` injection seam got eight findings telling it to add
        # defaults to a local dict. Measured 2026-08-08 across tests/, the hour
        # the snapshot first covered them. A binding is resolvable from this
        # module's own imports, so there is no reason to guess from the name.
        environ_names: set[str] = set()
        for imported in ast.walk(tree):
            if isinstance(imported, ast.ImportFrom) and imported.module == "os":
                environ_names.update(
                    alias.asname or alias.name
                    for alias in imported.names
                    if alias.name == "environ"
                )

        def _literal(node: ast.AST | None) -> str:
            return (
                node.value
                if isinstance(node, ast.Constant) and isinstance(node.value, str)
                else ""
            )

        def _is_environ(node: ast.AST) -> bool:
            # `os.environ` by attribute chain, or a bare name this module
            # actually imported FROM os — never a bare name that merely reads
            # like one.
            if isinstance(node, ast.Attribute):
                return node.attr == "environ"
            return isinstance(node, ast.Name) and node.id in environ_names

        for node in ast.walk(tree):
            # os.environ["X"] — a Subscript, which never appears in `calls`.
            if isinstance(node, ast.Subscript) and _is_environ(node.value):
                # Load vs Store is recorded because they are DIFFERENT policies
                # with different remedies, and one Form value conflated them.
                # `x = os.environ["K"]` risks KeyError -> use .get(); but
                # `os.environ["K"] = v` MUTATES the process environment, and
                # the gate told the reader to ".get()" an assignment — advice
                # that, if followed, silently drops the write. Measured
                # 2026-08-08 on a two-line corpus.
                accesses.append(
                    EnvAccessInfo(
                        file_path=file_path,
                        line=node.lineno,
                        key=_literal(node.slice),
                        form=(
                            "subscript"
                            if isinstance(node.ctx, ast.Load)
                            else "subscript_store"
                        ),
                    )
                )
                continue
            if not isinstance(node, ast.Call):
                continue
            func = node.func
            if isinstance(func, ast.Attribute) and _is_environ(func.value):
                form = "get" if func.attr == "get" else "other"
                accesses.append(
                    EnvAccessInfo(
                        file_path=file_path,
                        line=node.lineno,
                        key=_literal(node.args[0]) if node.args else "",
                        form=form,
                        has_default=len(node.args) > 1 or bool(node.keywords),
                    )
                )
            elif isinstance(func, ast.Attribute) and func.attr == "getenv":
                accesses.append(
                    EnvAccessInfo(
                        file_path=file_path,
                        line=node.lineno,
                        key=_literal(node.args[0]) if node.args else "",
                        form="getenv",
                        has_default=len(node.args) > 1 or bool(node.keywords),
                    )
                )
        return accesses

    @staticmethod
    def _analyze_argv_literals(tree: ast.AST, file_path: str) -> list[ArgvLiteralInfo]:
        """Every literal program name at a spawn-shaped site in one module.

        Walks the WHOLE tree, like ``_analyze_env_accesses``: a spawn inside a
        function body is exactly as much a bare-toolchain subject as one at
        module scope, and ``calls`` carries the callee with NO arguments, so
        the program token is recoverable only from the tree.

        LITERALS ONLY. A head that is not a ``str`` ``Constant`` is never
        recorded: ``sys.executable``, a variable, ``[*PY, ...]``, an ``argv``
        parameter are skipped, not guessed — a fact that invents a program
        name is worse than one that abstains.

        THIS FUNCTION CLASSIFIES NOTHING. Every literal program token is
        recorded verbatim with its callee — absolute paths, non-toolchain
        heads, and non-spawning callees included (``sorted(["python", "b"])``
        IS a record): a project wrapper such as ``_run([...])`` is a spawn
        that no fixed callee list can name, so which callees spawn processes
        and which programs are bare toolchain names is decided by the Prolog
        rule (Q2b) over these facts. Over-capture is the design; there is
        deliberately no callee allowlist or denylist for the ``argv`` form.

        The forms (see ``ArgvLiteralInfo`` for the full table):

        ``argv``   a literal-headed List/Tuple (element 0 a str Constant) or a
                   BinOp Add whose LEFT is one, among a call's positional
                   arguments — ANY position, not only the first:
                   ``os.execvp("gcc", ["gcc", "a.c"])`` carries its argv list
                   at position 1 — or, when the call takes no positional
                   arguments, its ``args``/``cmd``/``command``/``argv``
                   keyword; or a same-scope assignment of such a list to a
                   Name that a call in the same scope passes as its command
                   argument.
        ``exec``   last segment ``create_subprocess_exec``, or a dotted
                   ``os.execv*``/``os.execl*`` (program = first positional
                   str Constant), or ``os.spawnv*``/``os.spawnl*`` (program =
                   SECOND positional — the first is the mode).
        ``shell``  dotted ``os.system``/``os.popen``, or a
                   ``_SHELL_LAST_SEGMENTS`` callee on a ``subprocess`` base
                   (``asyncio`` qualifies only ``create_subprocess_shell``),
                   whose first positional is a str Constant or a JoinedStr
                   whose leading literal chunk contains whitespace; the
                   program is the string's first whitespace-separated token.

        KNOWN LIMITATION. A shell string prefixed by ``env`` (``"env -u X
        python y"``) records ``env``: the first token IS the program the
        shell execs. Looking past ``env`` is a later refinement (Q2b can do
        it in Prolog), not this layer's job.
        """

        def _callee_text(node: ast.expr) -> str:
            """Dotted callee text: ``Name`` gives its id, ``Attribute`` gives
            ``<render(value)>.<attr>``, any other node in the chain renders
            as ``?`` — ``factory().run`` gives ``?.run``."""
            if isinstance(node, ast.Name):
                return node.id
            if isinstance(node, ast.Attribute):
                return f"{_callee_text(node.value)}.{node.attr}"
            return "?"

        def _literal_head(node: ast.expr) -> tuple[ast.expr, str] | None:
            """``(seq, program)`` when *node* is a literal-headed command
            list: a List/Tuple whose element 0 is a str Constant, or a BinOp
            Add whose LEFT is such a List/Tuple (``["python"] + extra`` —
            the right operand is runtime data, the head is a fact). A Starred
            or computed element 0 yields None."""
            seq = (
                node.left
                if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add)
                else node
            )
            if isinstance(seq, (ast.List, ast.Tuple)) and seq.elts:
                first = seq.elts[0]
                if isinstance(first, ast.Constant) and isinstance(first.value, str):
                    return seq, first.value
            return None

        def _command_arg(call: ast.Call) -> ast.expr | None:
            """The slot a command travels in: the first positional argument,
            or — when the call has no positional arguments — the first of the
            conventional command keywords."""
            if call.args:
                return call.args[0]
            for keyword in call.keywords:
                if keyword.arg in _ARGV_KEYWORDS:
                    return keyword.value
            return None

        records: list[ArgvLiteralInfo] = []

        def _add(line: int, site: str, form: str, program: str) -> None:
            records.append(
                ArgvLiteralInfo(
                    file_path=file_path,
                    line=line,
                    site=site,
                    form=form,
                    program=program,
                )
            )

        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            site = _callee_text(node.func)
            base, _, last = site.rpartition(".")

            # argv (direct): every literal-headed command list among the
            # positional arguments — see the docstring for why not only the
            # first. With no positional arguments, the conventional command
            # keywords are the slot.
            slots: list[ast.expr] = list(node.args) or [
                keyword.value
                for keyword in node.keywords
                if keyword.arg in _ARGV_KEYWORDS
            ]
            for slot in slots:
                head = _literal_head(slot)
                if head is not None:
                    seq, program = head
                    _add(seq.lineno, site, "argv", program)

            # exec: the OS API fixes the position of the program token.
            if last in _EXEC_LAST_SEGMENTS or site in _EXEC_PROGRAM_FIRST:
                if node.args:
                    first = node.args[0]
                    if isinstance(first, ast.Constant) and isinstance(first.value, str):
                        _add(first.lineno, site, "exec", first.value)
            elif site in _EXEC_PROGRAM_SECOND and len(node.args) >= 2:
                second = node.args[1]
                if isinstance(second, ast.Constant) and isinstance(second.value, str):
                    _add(second.lineno, site, "exec", second.value)

            # shell: the program is the first whitespace-separated token of
            # the command string.
            if node.args and (
                site in _SHELL_DOTTED
                or (
                    last in _SHELL_LAST_SEGMENTS
                    and (
                        base in _SHELL_BASES
                        or (base == "asyncio" and last in _SHELL_ASYNCIO_SEGMENTS)
                    )
                )
            ):
                arg = node.args[0]
                text = ""
                if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
                    text = arg.value
                elif isinstance(arg, ast.JoinedStr) and arg.values:
                    chunk = arg.values[0]
                    if (
                        isinstance(chunk, ast.Constant)
                        and isinstance(chunk.value, str)
                        and any(character.isspace() for character in chunk.value)
                    ):
                        # Only the LEADING literal chunk of an f-string is
                        # knowable from the snapshot; an f-string that starts
                        # with an interpolation has no literal token and is
                        # skipped, not guessed.
                        text = chunk.value
                tokens = text.strip().split()
                if tokens:
                    _add(arg.lineno, site, "shell", tokens[0])

        # argv (named): a literal command list assigned to a Name and passed
        # to a call IN THE SAME SCOPE — the module body or one function body.
        # A nested function or class body is a scope of its own and is NOT
        # searched from the outer one: an assignment in `f` consumed by a call
        # in `f`'s nested `g` is not a same-scope fact, and resolving names
        # across the boundary is exactly the guessing this capture abstains
        # from.
        def _walk_scope(scope: ast.AST) -> Iterator[ast.AST]:
            """Every node in *scope*, stopping at nested scope boundaries.

            ``Lambda`` prunes like the statement scopes: its body is an
            expression evaluated in its own context, visited as part of its
            own walk, never as part of the enclosing scope's."""
            stack = list(ast.iter_child_nodes(scope))
            while stack:
                current = stack.pop()
                if isinstance(
                    current,
                    (
                        ast.FunctionDef,
                        ast.AsyncFunctionDef,
                        ast.ClassDef,
                        ast.Lambda,
                    ),
                ):
                    continue
                yield current
                stack.extend(ast.iter_child_nodes(current))

        scopes: list[ast.AST] = [tree]
        scopes.extend(
            found
            for found in ast.walk(tree)
            if isinstance(found, (ast.FunctionDef, ast.AsyncFunctionDef))
        )
        for scope in scopes:
            assigned: dict[str, list[tuple[ast.expr, str]]] = {}
            consumers: dict[str, list[ast.Call]] = {}
            for node in _walk_scope(scope):
                if (
                    isinstance(node, ast.Assign)
                    and len(node.targets) == 1
                    and isinstance(node.targets[0], ast.Name)
                ):
                    head = _literal_head(node.value)
                    if head is not None:
                        assigned.setdefault(node.targets[0].id, []).append(head)
                elif (
                    isinstance(node, ast.AnnAssign)
                    and isinstance(node.target, ast.Name)
                    and node.value is not None
                ):
                    head = _literal_head(node.value)
                    if head is not None:
                        assigned.setdefault(node.target.id, []).append(head)
                elif isinstance(node, ast.Call):
                    command = _command_arg(node)
                    if isinstance(command, ast.Name):
                        consumers.setdefault(command.id, []).append(node)
            for assigned_name, heads in assigned.items():
                calls = consumers.get(assigned_name)
                if not calls:
                    # A literal list no call consumes is not a spawn: a REPL
                    # language table (LANGS = ("python", "bash")) is data.
                    continue
                first_call = min(calls, key=lambda call: (call.lineno, call.col_offset))
                consumer_site = _callee_text(first_call.func)
                for seq, program in heads:
                    _add(seq.lineno, consumer_site, "argv", program)

        # Determinism: the snapshot is committed; unstable ordering creates
        # churn. Sorted and de-duplicated on the 4-tuple that distinguishes
        # two records (file_path is constant within one module).
        seen: set[tuple[int, str, str, str]] = set()
        ordered: list[ArgvLiteralInfo] = []
        for record in sorted(
            records,
            key=lambda record: (record.line, record.site, record.form, record.program),
        ):
            key = (record.line, record.site, record.form, record.program)
            if key in seen:
                continue
            seen.add(key)
            ordered.append(record)
        return ordered

    def _analyze_annotation(self, node: ast.AnnAssign) -> ClassVarInfo | None:
        """Analyze an annotated assignment."""
        if not isinstance(node.target, ast.Name):
            return None
        name = node.target.id
        type_ann = ast.unparse(node.annotation) if node.annotation else None
        default = None
        if node.value:
            try:
                default = ast.unparse(node.value)
            except Exception:
                default = "..."

        is_cv = False
        if type_ann and "ClassVar" in type_ann:
            is_cv = True

        return ClassVarInfo(
            name=name,
            type_annotation=type_ann,
            default=default,
            is_class_var=is_cv,
        )

    def _compute_derived(self) -> None:
        """Compute inheritance graph, dependency graph, and totals."""
        # Inheritance graph: class_qualified_name → [base_qualified_names]
        # We resolve base names to qualified names where possible
        class_by_name: dict[str, str] = {}  # short name → qualified name
        for mod in self.result.modules:
            for cls in mod.classes:
                class_by_name[cls.name] = cls.qualified_name

        for mod in self.result.modules:
            for cls in mod.classes:
                resolved_bases = []
                for base in cls.bases:
                    # Try to resolve short name to qualified
                    short = base.split(".")[-1]
                    if short in class_by_name:
                        resolved_bases.append(class_by_name[short])
                    else:
                        resolved_bases.append(base)
                self.result.inheritance_graph[cls.qualified_name] = resolved_bases

        # Propagate is_pydantic through inheritance chains
        # First, build a map of qualified_name -> ClassInfo
        class_by_qualified: dict[str, ClassInfo] = {}
        for mod in self.result.modules:
            for cls in mod.classes:
                class_by_qualified[cls.qualified_name] = cls

        def _has_pydantic_ancestor(cls_info: ClassInfo, visited: set[str]) -> bool:
            if cls_info.is_pydantic:
                return True
            for base in cls_info.bases:
                short = base.split(".")[-1]
                # Try to find the base class by short name in the same module
                base_qualified = None
                for qn, c in class_by_qualified.items():
                    if c.name == short:
                        base_qualified = qn
                        break
                if base_qualified and base_qualified not in visited:
                    visited.add(base_qualified)
                    if _has_pydantic_ancestor(
                        class_by_qualified[base_qualified], visited
                    ):
                        return True
            return False

        for mod in self.result.modules:
            for cls in mod.classes:
                if not cls.is_pydantic:
                    if _has_pydantic_ancestor(cls, set()):
                        cls.is_pydantic = True

        # Dependency graph: module_path → [imported_module_paths]
        # Import targets are absolute import strings (e.g.
        # "orchestrata_ultimate.agents.constants") while module paths are
        # root-relative (e.g. "agents.constants"). Strip the root-package
        # prefix from project-internal targets so both ends of an edge live
        # in the same namespace; external imports pass through unchanged.
        #
        # SCOPE FILTER (see _collect_scoped_imports): this graph means "what
        # does importing this module drag in", and `find-cycles` reads it. A
        # lazy function-local import is the standard way to BREAK an import
        # cycle, so counting one as a dependency would report cycles that do
        # not exist at import time. Unguarded module-scope imports only — the
        # exact set this loop saw before nested imports were captured.
        module_paths = {m.path for m in self.result.modules}
        root_pkg_prefix = self.root.name + "."

        def _normalize_dep(target: str, importer: str = "") -> str:
            if target not in module_paths and target.startswith(root_pkg_prefix):
                stripped = target[len(root_pkg_prefix) :]
                if stripped in module_paths:
                    return stripped
            # The mirror case: a module run as a SCRIPT (claude/hooks/*.py,
            # scripts/*.py) puts its own directory on sys.path and imports its
            # siblings BARE, so the recorded target is SHORT by the importer's
            # package prefix -- "error_gate.checks_lint" where the module path
            # is "claude.hooks.error_gate.checks_lint". Walk the importer's
            # package ancestors most-specific first and take the first that
            # names a real module. Without this, every intra-subtree edge in
            # such a tree is silently dropped and the graph reports the whole
            # subtree as orphaned (measured 2026-08-23: 240 claude.hooks
            # targets, 0 resolving to a known module, 0 IMPORTS edges).
            #
            # The walk must NOT fire for a name that already resolves as an
            # absolute import, or a project module SHADOWING a stdlib name
            # captures it: `import logging` inside core.base.service became an
            # edge to core.logging, and `import logging` inside core.logging
            # became a self-edge. Measured 2026-08-23 before this guard: 32
            # fabricated edges in plans/ast-ultimate.json, every one of them
            # stdlib `logging` or `threading`.
            if (
                target not in module_paths
                and importer
                and target.split(".")[0] not in sys.stdlib_module_names
            ):
                parts = importer.split(".")
                for i in range(len(parts) - 1, 0, -1):
                    candidate = f"{'.'.join(parts[:i])}.{target}"
                    if candidate in module_paths and candidate != importer:
                        return candidate
            return target

        for mod in self.result.modules:
            deps = set()
            for imp in mod.imports:
                if imp.scope != "module" or imp.conditional:
                    continue
                if imp.module:
                    deps.add(_normalize_dep(imp.module, mod.path))
            self.result.dependency_graph[mod.path] = sorted(deps)

        # Totals
        for mod in self.result.modules:
            self.result.total_classes += len(mod.classes)
            self.result.total_functions += len(mod.functions)
            for cls in mod.classes:
                self.result.total_methods += len(cls.methods)
            self.result.total_loc += mod.line_count


# ---------------------------------------------------------------------------
# Warm-cache read path (kills the cold-reparse design flaw — see
# docs/ast-toolkit.md "Warm-cache read path"). Two pieces:
#
#   compute_file_meta() — the additive freshness map embedded into a
#   serialized CodebaseAST at `serialize` time (top-level `file_meta` key:
#   module_path -> {file_path, sha1, size, mtime_ns}). Content-hash (sha1)
#   is the authority, never mtime — the JSON is a committed artifact and
#   mtimes don't survive a fresh clone; mtime_ns is carried only as a local
#   fast-path hint for future tooling, never trusted on its own here.
#
#   load_or_analyze() — used by `serialize --incremental` to warm-start a
#   CodebaseAST from a previous run's cache: unchanged files reuse their
#   cached ModuleInfo verbatim (zero re-parse), changed/new files are
#   re-parsed via the analyzer's own per-file methods, deleted files drop
#   out, and derived data (inheritance/dependency graphs, totals) is
#   recomputed over the patched module list. Falls back to a full analyze
#   on any mismatch (different name/root, corrupt/old-format cache).
# ---------------------------------------------------------------------------


def _sha1_of_source(path: Path) -> str:
    """Content hash of a source file — the freshness authority (never mtime).

    RAW BYTES, deliberately. Hashing ``read_text()`` instead hashes the
    UNIVERSAL-NEWLINE translation of the file: a CRLF file decodes to LF and
    produces a hash no byte-level reader can reproduce. The gate's freshness
    layer hashes bytes, so the two disagreed on every CRLF file in the tree —
    invisible while the snapshot covered only ``src`` (all LF), and immediately
    visible when it widened to ``scripts``: the five vendored
    ``scripts/_janus_patched/xsb_tests/*.py`` files reported ``content_changed``
    against a snapshot built seconds earlier, and the gate went INVALID for a
    tree that had not moved.
    """
    return hashlib.sha1(path.read_bytes()).hexdigest()


def compute_file_meta(
    modules: list[ModuleInfo], root: Path | None = None
) -> dict[str, dict[str, Any]]:
    """``module_path -> {file_path, sha1, size, mtime_ns}`` for *modules*.

    Additive companion to a serialized :class:`CodebaseAST`, embedded as the
    top-level ``file_meta`` key at serialize time. Files that can no longer
    be read (moved/deleted between analysis and this call) are silently
    skipped — best-effort, matching ``_augment_end_line_and_complexity``'s
    own skip-on-unreadable behaviour in serialize_codebase_ast.py.

    *root* anchors the stat/hash reads the same way :func:`load_or_analyze`'s
    does (ADR-0097): a relative ``file_path`` is read under *root* — never
    ``os.getcwd()`` — while the string STORED in the map stays the module's
    own ``file_path`` verbatim, so the artifact keeps its repository-root
    conventions no matter where the publishing process runs.
    """
    meta: dict[str, dict[str, Any]] = {}
    for mod in modules:
        path = Path(mod.file_path)
        if root is not None and not path.is_absolute():
            path = root / path
        try:
            sha1 = _sha1_of_source(path)
            stat = path.stat()
        except OSError:
            continue
        meta[mod.path] = {
            "file_path": mod.file_path,
            "sha1": sha1,
            "size": stat.st_size,
            "mtime_ns": stat.st_mtime_ns,
        }
    return meta


# field_name -> nested dataclass type, for the handful of dataclass fields
# that hold ``list[<dataclass>]`` rather than primitives — reconstructing a
# dict back into one of these needs each item rebuilt too.
_DATACLASS_LIST_FIELDS: dict[type, dict[str, type]] = {
    FunctionInfo: {"parameters": ParamInfo},
    ClassInfo: {"methods": FunctionInfo, "class_vars": ClassVarInfo},
    ModuleInfo: {
        "classes": ClassInfo,
        "functions": FunctionInfo,
        "imports": ImportInfo,
        "module_vars": ClassVarInfo,
        "env_accesses": EnvAccessInfo,
        "argv_literals": ArgvLiteralInfo,
    },
}

#: The per-module CAPTURE fields this analyzer emits — the fields whose facts
#: a whole-tree walk recovers because ``calls``/``imports`` cannot express
#: them (see ``EnvAccessInfo``, ``ArgvLiteralInfo``). Adding a capture field
#: means adding its name HERE: the list is part of the warm-start cache
#: identity (``CodebaseAST.captures``, checked by ``load_or_analyze``), so a
#: snapshot written before the field existed forces ONE full reparse instead
#: of reusing module dicts that lack the key — a reused dict deserializes the
#: missing field as ``[]``, indistinguishable from "captured, found none",
#: and the snapshot reads half-captured while looking complete (2026-10-11).
SNAPSHOT_CAPTURES: tuple[str, ...] = ("env_accesses", "argv_literals")


def _dataclass_from_dict(cls: type, d: dict[str, Any]) -> Any:
    """Reconstruct a dataclass instance from its ``dataclasses.asdict()``
    dict (the inverse of ``orchestrata_ast.serialize.serialize``),
    recursively rebuilding nested dataclass ``list[...]`` fields.

    Unknown keys are dropped rather than raising ``TypeError``: a cached
    module dict may carry additive JSON-only enrichment (``end_line`` /
    ``complexity``, injected post-serialize by
    ``_augment_end_line_and_complexity`` in serialize_codebase_ast.py) that
    is not part of the dataclass model itself.
    """
    known = {f.name for f in dataclasses.fields(cls)}
    kwargs: dict[str, Any] = {k: v for k, v in d.items() if k in known}
    for field_name, item_cls in _DATACLASS_LIST_FIELDS.get(cls, {}).items():
        items = kwargs.get(field_name)
        if items is not None:
            kwargs[field_name] = [
                _dataclass_from_dict(item_cls, item) for item in items
            ]
    return cls(**kwargs)


def module_info_from_dict(d: dict[str, Any]) -> ModuleInfo:
    """Reconstruct a :class:`ModuleInfo` (+ nested classes/functions/imports)
    from its serialized dict — the inverse of ``serialize(module)``."""
    return cast(ModuleInfo, _dataclass_from_dict(ModuleInfo, d))


def derive_subtree(
    snapshot: dict[str, Any], *, tree: str, name: str, repo_root: Path
) -> dict[str, Any]:
    """A single-tree snapshot DERIVED from a wider one — never re-parsed.

    ``plans/ast-tests.json`` used to be a second ``serialize --source tests``
    run. Two snapshots of the same files, produced by different invocations at
    different times, is one truth too many: they drift, and the drift is
    invisible because each looks complete on its own. Measured 2026-08-09, the
    checked-in tests snapshot predated four serializer fields the wide one
    already carried — ``body_hash``, ``residue_hash``, ``env_accesses`` and
    per-import line numbers — so the RTM's evidence tooling and the
    affected-test selector were reading a format the rest of the repo had
    moved past.

    Everything here is a RENAME plus a re-derivation of graphs the analyzer
    owns. Nothing is re-parsed, so the two artifacts cannot disagree about a
    file: they are the same parse, projected twice.

    The dotted ids drop the tree's own prefix (``tests.gates.x`` ->
    ``gates.x``), which is what a ``--source tests`` run produced and what this
    artifact's consumers are keyed on. The derived graphs are then rebuilt by
    :meth:`CodebaseAnalyzer._compute_derived` over the NARROWED module set,
    rather than by filtering the wide graphs — that is the difference between
    reproducing the semantics and re-implementing them. It matters concretely:
    a dependency on ``orchestrata_ultimate.agents.swarm.coordinator`` stays
    fully qualified here (that module is not in this snapshot) while the wide
    snapshot normalises it to ``agents.swarm.coordinator`` (where it is).
    """
    prefix = f"{tree}."
    root = Path(repo_root).resolve()

    def _rename(dotted: str) -> str:
        return dotted[len(prefix) :] if dotted.startswith(prefix) else dotted

    def _relative(file_path: str) -> str:
        path = Path(file_path)
        if not path.is_absolute():
            return path.as_posix()
        try:
            return path.relative_to(root).as_posix()
        except ValueError:
            return path.as_posix()

    def _retarget(node: Any) -> None:
        """Rewrite every dotted/absolute field this projection owns, in place."""
        if isinstance(node, dict):
            for key, value in node.items():
                if key in ("module_path", "qualified_name") and isinstance(value, str):
                    node[key] = _rename(value)
                elif key == "file_path" and isinstance(value, str):
                    node[key] = _relative(value)
                else:
                    _retarget(value)
        elif isinstance(node, list):
            for item in node:
                _retarget(item)

    modules: list[dict[str, Any]] = []
    for module in snapshot.get("modules", []):
        path = str(module.get("path", ""))
        if path != tree and not path.startswith(prefix):
            continue
        projected = json.loads(json.dumps(module))
        _retarget(projected)
        # `tests/__init__.py` is `tests` in the wide snapshot (the trailing
        # `.__init__` is stripped there) and `__init__` in a --source tests run.
        projected["path"] = _rename(path) if path != tree else "__init__"
        modules.append(projected)

    analyzer = CodebaseAnalyzer(name, root / tree)
    analyzer.result = CodebaseAST(
        name=name,
        root_path=tree,
        modules=[module_info_from_dict(module) for module in modules],
    )
    analyzer._compute_derived()

    derived = cast("dict[str, Any]", serialize(analyzer.result))
    # The projection reports what the SOURCE snapshot captured, never more:
    # this CodebaseAST was built by hand (its captures default to []), while
    # the module dicts below are the source's own. Claiming a capture the
    # source lacks would make its absent per-module keys read as "captured,
    # found none" — the exact lie load_or_analyze's identity check exists to
    # prevent.
    derived["captures"] = list(snapshot.get("captures", []))
    # _compute_derived works on the MODEL, which drops fields the model does
    # not declare; the projected dicts are the authority on module content.
    derived["modules"] = modules
    # compute_file_meta OPENS each file_path, and the projected paths are
    # repo-relative — so hand it absolute ones and put the relative form back.
    # Resolving them against the process CWD instead is the same class of bug
    # as the original artifact's: correct only when the producer happens to run
    # from the repo root. Caught by the unit test, whose repo_root is a
    # tmp_path and whose file_meta came back empty.
    absolute = [
        dataclasses.replace(module, file_path=str(root / module.file_path))
        for module in analyzer.result.modules
    ]
    meta = compute_file_meta(absolute)
    for entry in meta.values():
        entry["file_path"] = Path(entry["file_path"]).relative_to(root).as_posix()
    derived["file_meta"] = meta
    return derived


def load_or_analyze(
    name: str,
    source: Path,
    cache_path: Path,
    monorepo: bool = False,
    extra_roots: tuple[Path, ...] = (),
    root: Path | None = None,
) -> tuple[CodebaseAST, dict[str, Any]]:
    """Warm-start a :class:`CodebaseAST` from a previous ``serialize`` run.

    Reads *cache_path* — a serialized :class:`CodebaseAST` JSON carrying the
    additive ``file_meta`` freshness map (see :func:`compute_file_meta`). If
    its recorded ``name``/``root_path`` match this call AND it has a
    ``file_meta`` map, every live file under *source* is stat+hashed (content
    sha1 — cheap, tens of ms for hundreds of files) against that map:
    unchanged files reuse their cached :class:`ModuleInfo` verbatim (zero
    re-parse), new/changed files are re-parsed via the analyzer's own
    per-file methods, and modules for files that no longer exist are
    dropped. Derived data (``inheritance_graph``/``dependency_graph``/
    totals) is recomputed over the patched module list — cheap dict work
    over the in-memory model, no reparsing involved.

    On ANY mismatch — different name/root, missing/corrupt JSON, no
    ``file_meta`` key (an old-format cache) — this silently falls back to a
    full :meth:`CodebaseAnalyzer.analyze`: correctness over speed, never a
    stale or wrong answer from an inapplicable cache.

    Returns ``(result, stats)``. ``stats`` carries ``fallback`` (bool),
    ``reused``/``reparsed`` (module-path lists), ``removed`` (module paths
    dropped because their file is gone), and ``cached_modules``
    (``module_path -> raw cached dict`` for the reused modules — callers
    like ``serialize --incremental`` that want to reuse a module's on-disk
    enrichment (``end_line``/``complexity``) verbatim instead of just the
    dataclass model can look it up there instead of re-deriving it).

    *root* anchors the analysis to a checkout other than the process cwd
    (ADR-0097): every relative input is resolved against it — never
    ``os.getcwd()`` — so a resident daemon can publish a repository's
    snapshot without chdir-ing the whole process, and every path the result
    stores (``root_path``, ``extra_roots``, each module's ``file_path``) is
    relativised back against it, keeping the artifact byte-identical to a
    CLI run from the repository root. ``None`` keeps the cwd behaviour.
    """

    def _anchored(path: Path) -> Path:
        if root is not None and not path.is_absolute():
            return root / path
        return path

    def _display(path: Path) -> Path:
        if root is None:
            return path
        try:
            return path.relative_to(root)
        except ValueError:
            return path

    src_io = _anchored(Path(source))
    stored_src = _display(src_io)
    stored_extras = tuple(_display(_anchored(Path(extra))) for extra in extra_roots)
    analyzer = CodebaseAnalyzer(
        name, stored_src, monorepo=monorepo, extra_roots=stored_extras, anchor=root
    )
    stats: dict[str, Any] = {
        "fallback": False,
        "reused": [],
        "reparsed": [],
        "removed": [],
        "cached_modules": {},
    }

    def _full_fallback() -> tuple[CodebaseAST, dict[str, Any]]:
        result = analyzer.analyze()
        stats["fallback"] = True
        stats["reparsed"] = [m.path for m in result.modules]
        stats["trees"] = analyzer.trees
        return result, stats

    try:
        cached = json.loads(Path(cache_path).read_text(encoding="utf-8"))
    except OSError, ValueError:
        return _full_fallback()
    if not isinstance(cached, dict):
        return _full_fallback()

    try:
        cached_root = _anchored(Path(str(cached.get("root_path", ""))))
        same_root = cached_root.resolve() == src_io.resolve()
    except OSError:
        same_root = False
    if cached.get("name") != name or not same_root:
        return _full_fallback()
    # The set of trees is part of the cache's identity, not a detail of how it
    # was built. Without this check, widening the snapshot to ``tests`` and
    # ``scripts`` would warm-start from the src-only cache, keep its
    # ``file_meta``, and report every new module as unchanged — a narrower
    # snapshot wearing the wider one's name.
    if list(cached.get("extra_roots", [])) != [str(extra) for extra in stored_extras]:
        return _full_fallback()
    # The capture set is part of the cache's identity for the same reason: a
    # snapshot written BEFORE a capture field existed holds module dicts
    # WITHOUT the key, and a reused dict deserializes the field as [] —
    # "captured, found none", a lie wearing a complete snapshot's name. One
    # full reparse instead (2026-10-11, SNAPSHOT_CAPTURES).
    if list(cached.get("captures", [])) != list(SNAPSHOT_CAPTURES):
        return _full_fallback()

    file_meta = cached.get("file_meta")
    if not isinstance(file_meta, dict):
        return _full_fallback()
    cached_modules_raw = cached.get("modules")
    if not isinstance(cached_modules_raw, list):
        return _full_fallback()
    cached_by_path: dict[str, dict[str, Any]] = {
        m["path"]: m for m in cached_modules_raw if isinstance(m, dict) and "path" in m
    }

    py_files = sorted(analyzer._find_python_files())
    live_modules: list[ModuleInfo] = []
    live_paths: set[str] = set()
    for py_file in py_files:
        module_path = analyzer._file_to_module_path(py_file)
        live_paths.add(module_path)
        try:
            sha1 = _sha1_of_source(_anchored(py_file))
        except OSError:
            continue
        prior = file_meta.get(module_path)
        prior_dict = cached_by_path.get(module_path)
        if (
            isinstance(prior, dict)
            and prior.get("sha1") == sha1
            and prior_dict is not None
        ):
            live_modules.append(module_info_from_dict(prior_dict))
            stats["reused"].append(module_path)
            stats["cached_modules"][module_path] = prior_dict
        else:
            module = analyzer._analyze_file(py_file)
            if module:
                live_modules.append(module)
            stats["reparsed"].append(module_path)

    stats["removed"] = sorted(set(file_meta) - live_paths)

    analyzer.result = CodebaseAST(
        name=name,
        root_path=str(stored_src),
        modules=live_modules,
        extra_roots=[str(extra) for extra in stored_extras],
        captures=list(SNAPSHOT_CAPTURES),
    )
    analyzer._compute_derived()
    stats["trees"] = analyzer.trees
    return analyzer.result, stats