scripts.extract_reference_edges
Reference › scripts › extract_reference_edges
Extracts non-import reference edges from Python source into a JSON map: references that an import graph does not show, such as a script naming another file's path or launching another script. affected_tests regenerates this index while selecting tests and reads it through its ReferenceIndex. tests/scripts/test_extract_reference_edges.py and tests/scripts/test_extract_reference_edges_coverage.py import it, and it writes through atomic_json.
Invocation: main requires --source (one or more repo-relative source roots) and --output (the JSON file). It prints the edge count to standard error and returns 0. The output holds a version number and the list of edges.
extract_edges walks the source roots, optionally in parallel, and _extract_file parses one file with a _RefEdgeVisitor. The visitor first collects constants that hold path fragments (collect_path_consts), then records edges from calls, from argument vectors passed to subprocess calls (_argv_paths), from path-building expressions such as a division of paths (_div_target, _path_segments) and from string constants that look like repository paths (_is_repo_path, visit_Constant). A map of basenames under the Claude configuration directory lets bare names resolve (_claude_basename_map). The output is written atomically because a reader may have the file mapped while it is being regenerated.
At a glance
| Source | scripts/extract_reference_edges.py |
| Lines of code | 508 |
| Classes | 1 |
| Functions | 11 |
| Imports (internal) | orchestrata_ultimate.core.atomic_json |
| Imported by | none |
| Tests that import it | tests/scripts/test_extract_reference_edges.py, tests/scripts/test_extract_reference_edges_coverage.py |
Class diagram
Dependency diagram
API
Classes
_RefEdgeVisitor
Bases: ast.NodeVisitor · Line 156
Harvest reference edges from one module's call nodes.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(rel_path: str, *, claude_basenames: dict[str, list[str]] | None = None) -> None |
|
collect_path_consts |
collect_path_consts(tree: ast.Module) -> None |
First pass: resolve module-level NAME = <rooted path expr> assignments to repo-relative path strings (joined segments). |
_record |
_record(kind: str, target: str) -> None |
|
_div_target |
_div_target(expr: ast.expr) -> str | None |
Resolve a CONST / <something> expression. |
_argv_paths |
_argv_paths(node: ast.expr) -> list[str] |
Repo-relative paths named by an argv list — string literals AND resolved module-level path constants (str(HOOK_PATH)). |
visit_Call |
visit_Call(node: ast.Call) -> None |
|
visit_Constant |
visit_Constant(node: ast.Constant) -> None |
Basename-literal rule: a TEST file UNDER tests/claude/ that mentions the exact basename of a claude/**/*.py|.sh file, as a bare string literal, references it — no matter how the path is… |
Functions
| Function | Signature | Summary |
|---|---|---|
_dotted |
_dotted(func: ast.expr) -> str |
Best-effort dotted name of a call target (subprocess.run etc.). |
_last |
_last(name: str) -> str |
|
_is_repo_path |
_is_repo_path(s: str) -> bool |
A literal that names a repo artifact, not a flag/URL/abs path. |
_normalize |
_normalize(s: str) -> str |
|
_path_segments |
_path_segments(expr: ast.expr, consts: dict[str, str]) -> list[str] | None |
Resolve a BASE / "a" / "b.sh" or BASE.joinpath(...) chain to its string segments. |
_path_segments_str |
_path_segments_str(joined: str) -> list[str] |
|
_claude_basename_map |
_claude_basename_map(repo_root: Path) -> dict[str, list[str]] |
{basename: [claude/**/*.py|sh repo-relative paths, ...]}. |
_extract_file |
_extract_file(args: tuple[Path, str], *, claude_basenames: dict[str, list[str]] | None = None) -> list[dict[str, str]] |
Edges from ONE file — the thread-pool unit of work. |
_is_project_source |
_is_project_source(path: Path) -> bool |
True when path is our own source, not a vendored/generated tree. |
extract_edges |
extract_edges(source_roots: list[Path], *, repo_root: Path, parallel: bool = True) -> list[dict[str, str]] |
Walk every .py file under source_roots and return sorted edges. |
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Extract NON-IMPORT reference edges from Python source into a JSON map.
The affected-test resolver (``scripts/affected_tests.py``) walks the IMPORT
closure — which is exact for Python imports and blind everywhere else. The
three edge classes that actually burned full-suite runs (2026-08-11: a
2-file deploy/hook change escalated to 32k tests) are statically visible
but are not imports:
- **invoke** — ``subprocess.run(["bash", ".../hook.sh"])`` and friends:
a script path sitting in an argv literal. This is how hook tests bind to
``.claude/hooks/*.sh`` and how CLIs invoke sibling scripts.
- **bdd** — ``scenario("x.feature", ...)`` / ``scenarios(...)``: pytest-bdd
binds a feature file by string, not by import.
- **consult** — Prolog rule files loaded via ``consult("...pl")``-style
calls at runtime.
This extractor walks call nodes (stdlib ``ast`` — no import graph needed,
each file parses independently) and harvests repo-relative string literals
in those positions. What it cannot see stays unseen BY DESIGN: a
runtime-computed path (f-string, variable, os.path.join) yields no edge,
and the resolver treats an unreferenced non-documentation file as UNKNOWN
(full-suite escalation) — fail-closed, never a silent skip.
Output (deterministic — sorted, no timestamps):
{"version": 1,
"edges": [{"from": "tests/scripts/test_x.py",
"kind": "invoke"|"bdd"|"consult",
"target": ".claude/hooks/x.sh"}, ...]}
CLI:
python scripts/extract_reference_edges.py \
--source src scripts tests --output plans/reference-edges.json
"""
from __future__ import annotations
import argparse
import ast
import functools
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
#: Argv head names that mark a subprocess call's list literal as a shell
#: invocation of a script.
SHELL_NAMES = frozenset({"bash", "sh", "zsh", "dash"})
#: subprocess entry points whose first positional arg is an argv list.
SUBPROCESS_FUNCS = frozenset({"run", "Popen", "check_output", "check_call", "call"})
#: pytest-bdd binding functions whose string args name feature files.
BDD_FUNCS = frozenset({"scenario", "scenarios"})
#: Content-read entry points whose first arg is a file being asserted on.
READ_FUNCS = frozenset({"read_text", "read_bytes", "open"})
#: Suffixes that make an argv-literal string a repo-artifact reference.
PATH_SUFFIXES = (".sh", ".py", ".pl", ".feature", ".json")
def _dotted(func: ast.expr) -> str:
"""Best-effort dotted name of a call target (``subprocess.run`` etc.)."""
parts: list[str] = []
node = func
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
def _last(name: str) -> str:
return name.rsplit(".", 1)[-1] if name else ""
def _is_repo_path(s: str) -> bool:
"""A literal that names a repo artifact, not a flag/URL/abs path."""
if not s.endswith(PATH_SUFFIXES):
return False
if (
s.startswith(("/", "http://", "https://"))
or "${" in s
or "/" not in s.lstrip("./")
):
return False
return True
def _normalize(s: str) -> str:
return s[2:] if s.startswith("./") else s
#: Variable names accepted as repo-root anchors for module-level path
#: constants. Uppercase-only by convention: fixture parameters like
#: ``repo_root``/``tmp_path`` (lowercase) point at TEMP trees whose
#: ``src/...`` segments would otherwise mint edges to real repo files they
#: merely shadow — a false edge is over-selection (safe) but a false edge
#: that suppresses escalation is not, so the anchor set is deliberately
#: narrow (2026-08-11 design review).
ROOT_NAMES = frozenset(
{"REPO_ROOT", "ROOT", "PROJECT_ROOT", "PACKAGE_ROOT", "_REPO_ROOT", "_ROOT"}
)
def _path_segments(expr: ast.expr, consts: dict[str, str]) -> list[str] | None:
"""Resolve a ``BASE / "a" / "b.sh"`` or ``BASE.joinpath(...)`` chain to
its string segments. Returns None for anything not statically rooted in
a ROOT_NAMES variable (directly, or via another module-level constant).
"""
if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Div):
left = _path_segments(expr.left, consts)
if left is None:
return None
if isinstance(expr.right, ast.Constant) and isinstance(expr.right.value, str):
return left + [expr.right.value]
return None
if isinstance(expr, ast.Call):
if isinstance(expr.func, ast.Attribute) and expr.func.attr == "joinpath":
base = _path_segments(expr.func.value, consts)
if base is None:
return None
segs = [
a.value
for a in expr.args
if isinstance(a, ast.Constant) and isinstance(a.value, str)
]
if len(segs) != len(expr.args):
return None
return base + segs
# str(<path expr>) — unwrap.
if (
isinstance(expr.func, ast.Name)
and expr.func.id == "str"
and len(expr.args) == 1
):
return _path_segments(expr.args[0], consts)
return None
if isinstance(expr, ast.Name):
if expr.id in ROOT_NAMES:
return []
if expr.id in consts:
resolved = _path_segments_str(consts[expr.id])
return resolved
return None
return None
def _path_segments_str(joined: str) -> list[str]:
return joined.split("/") if joined else []
class _RefEdgeVisitor(ast.NodeVisitor):
"""Harvest reference edges from one module's call nodes."""
def __init__(
self, rel_path: str, *, claude_basenames: dict[str, list[str]] | None = None
) -> None:
self.rel_path = rel_path
self.edges: list[dict[str, str]] = []
# Module-level path constants (``HOOK_PATH = REPO_ROOT / ...``),
# filled by ``collect_path_consts`` before the call walk.
self.path_consts: dict[str, str] = {}
# basename -> claude/**/*.py|sh repo-relative paths, for the
# basename-literal rule below (empty for non-test files / repos with
# no claude/ tree — see visit_Constant).
self.claude_basenames = claude_basenames or {}
def collect_path_consts(self, tree: ast.Module) -> None:
"""First pass: resolve module-level ``NAME = <rooted path expr>``
assignments to repo-relative path strings (joined segments).
Directory constants (no file suffix) are kept too — ``HOOKS_DIR /
hook`` patterns resolve against them."""
for node in tree.body:
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
if node.value is None:
continue
segs = _path_segments(node.value, self.path_consts)
if segs is None or not segs:
continue
joined = "/".join(segs)
for target in targets:
if isinstance(target, ast.Name) and target.id.isupper():
self.path_consts[target.id] = joined
def _record(self, kind: str, target: str) -> None:
edge = {"from": self.rel_path, "kind": kind, "target": target}
if edge not in self.edges:
self.edges.append(edge)
def _div_target(self, expr: ast.expr) -> str | None:
"""Resolve a ``CONST / <something>`` expression. A fully-static
right side yields a file target; an UNRESOLVABLE right side (loop
variable, computed name) yields the DIRECTORY as a prefix target —
the reference is to the whole directory's contents (the
``for hook in HOOKS: (HOOKS_DIR / hook).read_text()`` pattern),
which the resolver matches by prefix."""
if not isinstance(expr, ast.BinOp) or not isinstance(expr.op, ast.Div):
return None
left = _path_segments(expr.left, self.path_consts)
if left is None or not left:
return None
if isinstance(expr.right, ast.Constant) and isinstance(expr.right.value, str):
joined = "/".join(left + [expr.right.value])
return joined if joined.endswith(PATH_SUFFIXES) else None
return "/".join(left) + "/"
def _argv_paths(self, node: ast.expr) -> list[str]:
"""Repo-relative paths named by an argv list — string literals AND
resolved module-level path constants (``str(HOOK_PATH)``)."""
if not isinstance(node, (ast.List, ast.Tuple)):
return []
paths: list[str] = []
for elt in node.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
if _is_repo_path(elt.value):
paths.append(_normalize(elt.value))
elif isinstance(elt, ast.Name) and elt.id in self.path_consts:
paths.append(self.path_consts[elt.id])
elif (
isinstance(elt, ast.Call)
and isinstance(elt.func, ast.Name)
and elt.func.id == "str"
and len(elt.args) == 1
and isinstance(elt.args[0], ast.Name)
and elt.args[0].id in self.path_consts
):
paths.append(self.path_consts[elt.args[0].id])
return paths
def visit_Call(self, node: ast.Call) -> None:
name = _dotted(node.func)
last = _last(name)
if last in SUBPROCESS_FUNCS and node.args:
for arg in self._argv_paths(node.args[0]):
self._record("invoke", arg)
if last in BDD_FUNCS:
for arg in node.args:
if (
isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
and arg.value.endswith(".feature")
):
self._record("bdd", _normalize(arg.value))
elif isinstance(arg, ast.Name) and arg.id in self.path_consts:
self._record("bdd", self.path_consts[arg.id])
if "consult" in last.lower():
for arg in node.args:
if (
isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
and _is_repo_path(arg.value)
):
self._record("consult", _normalize(arg.value))
elif isinstance(arg, ast.Name) and arg.id in self.path_consts:
self._record("consult", self.path_consts[arg.id])
# Content references — a test that READS a hook/script asserts its
# content just as surely as one that executes it (2026-08-11:
# test_session_start_hooks covers inject-codebase-map.sh entirely
# through read_text assertions on path constants).
if last in READ_FUNCS and node.args:
arg0 = node.args[0]
if isinstance(arg0, ast.Name) and arg0.id in self.path_consts:
self._record("read", self.path_consts[arg0.id])
elif (
isinstance(arg0, ast.Constant)
and isinstance(arg0.value, str)
and _is_repo_path(arg0.value)
):
self._record("read", _normalize(arg0.value))
elif (div := self._div_target(arg0)) is not None:
self._record("read", div)
# (HOOKS_DIR / name).read_text() — the file object itself is the
# attribute value, so the path expression sits under func, not args.
if last in READ_FUNCS and isinstance(node.func, ast.Attribute):
if (div := self._div_target(node.func.value)) is not None:
self._record("read", div)
elif (
isinstance(node.func.value, ast.Name)
and node.func.value.id in self.path_consts
):
self._record("read", self.path_consts[node.func.value.id])
self.generic_visit(node)
def visit_Constant(self, node: ast.Constant) -> None:
"""Basename-literal rule: a TEST file UNDER ``tests/claude/`` that
mentions the exact basename of a ``claude/**/*.py|.sh`` file, as a
bare string literal, references it — no matter how the path is
actually assembled at runtime.
This exists for shapes the call-node walk above cannot see: measured
2026-08-14, ``tests/claude/test_intercept_guards.py`` binds three
hooks via ``_HOOKS = Path(__file__).resolve().parents[2] / "claude"
/ "hooks"`` (an anchor the directory-constant machinery does not
recognize -- ``ROOT_NAMES`` only understands module-level constants
rooted directly in a name like ``REPO_ROOT``, not
``Path(__file__).resolve().parents[N]``) and then calls
``str(_HOOKS / hook)`` where ``hook`` is a runtime parameter -- the
literal basenames (``"intercept_write.py"``, ...) appear only as
plain string arguments to a local test helper, never as an argv
element or a path-constant reference.
Restricted to ``tests/claude/`` referrers specifically (not every
``tests/`` module) -- a basename like ``"utils.py"`` or
``"__init__.py"`` is common enough that a repo-wide ``tests/``
gate mints edges from modules that merely mention the string about
an UNRELATED tree. Measured 2026-08-14:
``claude/hooks/error_gate/utils.py`` bound to
``test_deepseek_coder_driver.py``, ``test_code_intelligence.py`` and
others purely because they contain the literal ``"utils.py"`` about
a *different* utils.py, and with 75 claude/ files sharing the
``__init__.py`` basename, every one of those bound to all 75 test
modules that happen to say ``"__init__.py"`` anywhere -- coincidental
bindings so dense they SUPPRESSED the ``tests/claude/`` directory
fallback in ``affected_tests.py`` (a wrong-but-non-empty referrer set
short-circuited the fallback that would otherwise have fired). Real
bindings from OUTSIDE ``tests/claude/`` -- e.g. shell-hook tests
under ``tests/scripts/`` -- are covered instead by the
``claude/hooks/*.sh`` manifest rule (``plans/affected-tests-
manifest.json``), and ``affected_tests.py``'s ``tests/claude/``
directory-convention fallback is now unconditionally additive for
every ``claude/`` file, so narrowing this gate cannot silently drop
coverage the way the false bindings it replaces used to threaten.
"""
if (
self.claude_basenames
and isinstance(node.value, str)
and self.rel_path.startswith("tests/claude/")
):
for target in self.claude_basenames.get(node.value, ()):
self._record("basename", target)
self.generic_visit(node)
def _claude_basename_map(repo_root: Path) -> dict[str, list[str]]:
"""``{basename: [claude/**/*.py|sh repo-relative paths, ...]}``.
Powers ``visit_Constant`` above. Scoped to the ``claude/`` tree only --
the one directory whose Python/shell source lives outside
src/scripts/tests and is invisible to both the import closure and the
directory-constant reference machinery (2026-08-14 defect: a 3-file
change to ``claude/hooks/intercept_*.py`` escalated to the full suite
despite real coverage in ``tests/claude/test_intercept_guards.py``).
Sorted ``rglob`` keeps output deterministic; a basename that names more
than one file (``__init__.py`` recurs under several ``claude/`` package
dirs) maps to every one of them -- ambiguous is safe here, since an
extra referrer is over-selection, never a missed one.
"""
claude_dir = repo_root / "claude"
mapping: dict[str, list[str]] = {}
if not claude_dir.is_dir():
return mapping
for suffix in ("*.py", "*.sh"):
for p in sorted(claude_dir.rglob(suffix)):
rel = p.relative_to(repo_root).as_posix()
mapping.setdefault(p.name, []).append(rel)
return mapping
def _extract_file(
args: tuple[Path, str], *, claude_basenames: dict[str, list[str]] | None = None
) -> list[dict[str, str]]:
"""Edges from ONE file — the thread-pool unit of work. Each file parses
independently (no import graph needed), so this scales linearly on the
free-threaded build; output is order-independent, sorted by the caller.
"""
py, rel = args
try:
tree = ast.parse(py.read_bytes())
except SyntaxError, OSError:
return []
visitor = _RefEdgeVisitor(rel, claude_basenames=claude_basenames)
try:
visitor.collect_path_consts(tree)
visitor.visit(tree)
except RecursionError:
# One pathological file must never take down the whole extraction:
# that is exactly how a single vendored module silently disabled
# --affected selection for every run (2026-08-17). Dropping one file's
# edges costs a little selection precision; raising here costs the
# feature entirely, and fails the suite with it.
print(
f"extract-reference-edges: skipped {rel} (AST nesting too deep)",
file=sys.stderr,
)
return []
return visitor.edges
# Directory components that are NEVER project source. A vendored dependency
# tree walked as if it were ours is both wrong and unbounded: `.claude/` can
# hold entire agent worktrees, each with its own `.venv`. Measured 2026-08-17 —
# four stale worktrees turned a ~2K-file walk into 76,768 files, and sympy's
# generated `resolvent_lookup.py` nests 569 AST levels. `ast.NodeVisitor` costs
# ~2 Python frames per level (visit -> generic_visit -> visit), so 569 levels
# overflows the default 1000-frame recursion limit and the RecursionError took
# down `--affected` selection AND `tests/scripts/test_affected_manifest_rules.py`
# for every run. Excluding vendored trees fixes the cause; raising the recursion
# limit would not, and is actively unsafe here because `_extract_file` runs in a
# ThreadPoolExecutor on the free-threaded build, where a deeper limit without a
# matching `threading.stack_size` trades a clean RecursionError for a SIGSEGV.
_NON_SOURCE_DIRS = frozenset(
{
".venv",
"venv",
"site-packages",
"node_modules",
".git",
"__pycache__",
".mypy_cache",
".pytest_cache",
# Agent worktrees are CHECKOUTS OF THIS REPO AT OTHER COMMITS, parked
# under `.claude/worktrees/agent-*`. The 2026-08-17 fix above excluded
# the `.venv` inside them, which cured the 76,768-file walk and the
# sympy recursion crash, but left their src/, tests/ and scripts/ being
# indexed as if they were ours.
#
# That is a correctness problem, not a size one. Those trees are pinned
# at OLD commits (measured 2026-08-19: five worktrees at c24da189,
# 60e7affe x3 and d0fee172), so a test module renamed or deleted since
# still exists in them — and an edge naming a path that is no longer in
# the working tree is a phantom, which escalates the whole suite. The
# same mechanism can also make a change look COVERED by a test only a
# stale worktree still has. It already distorted the evidence: the
# affected-set oracle reported "resolved via 5 reference edge(s)" for
# plans/ast-tests.json where all five cited were worktree copies of one
# file, so a single real referrer read as five.
"worktrees",
}
)
def _is_project_source(path: Path) -> bool:
"""True when *path* is our own source, not a vendored/generated tree."""
return _NON_SOURCE_DIRS.isdisjoint(path.parts)
def extract_edges(
source_roots: list[Path], *, repo_root: Path, parallel: bool = True
) -> list[dict[str, str]]:
"""Walk every .py file under ``source_roots`` and return sorted edges."""
files: list[tuple[Path, str]] = []
for root in source_roots:
root = root if root.is_absolute() else repo_root / root
if not root.exists():
continue
for py in sorted(root.rglob("*.py")):
if not _is_project_source(py):
continue
files.append((py, py.relative_to(repo_root).as_posix()))
worker = functools.partial(
_extract_file, claude_basenames=_claude_basename_map(repo_root)
)
if parallel and len(files) > 1:
with ThreadPoolExecutor(max_workers=os.cpu_count() or 4) as executor:
per_file = list(executor.map(worker, files))
else:
per_file = [worker(f) for f in files]
edges = [edge for file_edges in per_file for edge in file_edges]
edges.sort(key=lambda e: (e["from"], e["kind"], e["target"]))
return edges
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__.splitlines()[0] if __doc__ else None
)
parser.add_argument(
"--source",
nargs="+",
required=True,
help="source roots to walk (repo-relative)",
)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args(argv)
repo_root = Path(__file__).resolve().parent.parent
edges = extract_edges([Path(s) for s in args.source], repo_root=repo_root)
# Atomic: affected_tests.py regenerates this mid-run while the tree is
# being staged; a truncate-in-place write keeps the inode and SIGBUSes a
# reader that already mmap'd it. Byte-identical output (indent=1 plus the
# trailing newline the previous writer appended).
from orchestrata_ultimate.core.atomic_json import dump_json_atomic
dump_json_atomic(
args.output,
{"version": 1, "edges": edges},
indent=1,
trailing_newline=True,
)
print(f"[ref-edges] {len(edges)} edges -> {args.output}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())