scripts.ast_live_sync

Reference › scripts › ast_live_sync

Live code-intelligence sync: when a source file is saved, the code graph is updated within seconds. It runs the AST analyzer from analyzer over the declared roots from roots, serializes the result with serialize and pushes the difference to the graph through a GraphSyncer. A long-running service or a human runs it. tests/scripts/test_ast_live_sync.py and tests/scripts/test_ast_live_sync_main.py import it.

Invocation: main accepts --uri (default taken from KG_ROUTING_URI, else MEMGRAPH_URI), --database (default orchkg), --project (default orchestrata-ultimate), --repo-root, --source-dir, --extra-root, --cache, --watch, --debounce-ms (1000), --once for a single pass, and --measure with --slo-seconds to time a sync against a budget. It exits 2 when the repo root or source directory is missing, and it refuses to run when no source roots are declared, so the graph is never narrowed to a single tree.

LiveCodeSync holds the cycle. sync_once analyses, compares with a cache file and writes the changes to the graph, returning a SyncOutcome. watch is an async loop that debounces file events, using is_watchable and filter_changes. assert_cache_root_matches and assert_destination_populated guard against syncing against the wrong root or an empty destination, and _preserve_runtime_calls keeps runtime call edges across passes.

At a glance

Source scripts/ast_live_sync.py
Lines of code 811
Classes 2
Functions 13
Imports (internal) orchestrata_ast.analyzer, orchestrata_ast.roots, orchestrata_ast.serialize, orchestrata_ultimate.knowledge_graph.sync, scripts.build_runtime_callgraph
Imported by none
Tests that import it tests/scripts/test_ast_live_sync.py, tests/scripts/test_ast_live_sync_main.py

Class diagram

Class diagram scripts.ast_live_sync

Dependency diagram

Dependency diagram scripts.ast_live_sync

API

Constants

Name Type Value
IGNORE_FRAGMENTS tuple[str, ...] ('/.git/', '/.venv/', '/__pycache__/', '/.pytest_cache/', '/node_modules/', '/r…

Classes

SyncOutcome (dataclass)

Decorators: dataclass(frozen=True) · Line 152

What one pass actually changed, and how long the whole leg took.

Attribute Type Default
reparsed tuple[str, ...]
removed tuple[str, ...]
nodes int
relationships int
duration_s float
fallback bool
total_modules int 0
Method Signature Summary
changed property changed() -> bool

LiveCodeSync

Line 211

Keeps the graph's code-intelligence subgraph current with the tree.

Method Signature Summary
__init__ __init__(repo_root: Path, cache_path: Path, project: str, syncer: GraphSyncer, *, source_dir: str, extra_roots: Sequence[str] = ()) -> None
source_root property source_root() -> Path The directory module ids are relative to (the analyzer root).
extra_roots property extra_roots() -> tuple[Path, ...] The secondary trees this sync analyzes (declared, not assumed).
passes property passes() -> int Completed sync passes — the liveness signal for the unit.
assert_cache_root_matches assert_cache_root_matches() -> None Refuse to sync when the cache was built from a different root.
sync_once sync_once() -> SyncOutcome Re-parse what changed and push exactly those modules to the graph.
_write_cache _write_cache(ast_data: dict[str, Any]) -> None Rewrite the freshness map atomically.
watch async watch(watch_dirs: Sequence[str], *, debounce_ms: int = 1000, stop: asyncio.Event | None = None) -> None Run until stop is set, syncing after each debounced batch.

Functions

Function Signature Summary
default_roots default_roots(env: Mapping[str, str] | None = None) -> tuple[str | None, tuple[str, ...], tuple[str, ...]] (source_dir, extra_roots, watch_dirs) from the declared root set.
is_watchable is_watchable(path: str, watch_dirs: Sequence[str], repo_root: Path) -> bool Whether a changed path belongs to the code-intelligence graph.
filter_changes filter_changes(paths: Iterable[str], watch_dirs: Sequence[str], repo_root: Path) -> list[str] The watchable subset of one debounced batch, order-stable.
_as_ast_dict _as_ast_dict(result: Any) -> dict[str, Any] Serialized AST plus the file_meta freshness map the cache needs.
_preserve_runtime_calls _preserve_runtime_calls(ast_data: dict[str, Any], summary_path: Path = _RUNTIME_CALLGRAPH_SUMMARY) -> None Re-apply the runtime_calls field that serialize() drops.
_log_outcome _log_outcome(outcome: SyncOutcome) -> None
resolve_target_uri resolve_target_uri(env: Mapping[str, str], explicit: str | None) -> str Address of the graph to keep current, from .env at call time (RUL-003).
_build_parser _build_parser(env: Mapping[str, str] | None = None) -> argparse.ArgumentParser The CLI.
_make_syncer _make_syncer(uri: str, database: str) -> GraphSyncer A connected syncer aimed at the unauthenticated orchgraph cluster.
assert_destination_populated assert_destination_populated(syncer: GraphSyncer, project: str, total_modules: int) -> int Refuse to report success for a sync whose target did not receive the tree.
measure_slo measure_slo(sync: LiveCodeSync, budget_s: float) -> int Time one real save -> graph leg; return 0 only if it fits the budget.
_seed_cache_from_committed_artifact _seed_cache_from_committed_artifact(cache_path: Path) -> bool Populate an absent sidecar from the committed AST snapshot.
main main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int Run, measure, or single-pass the live code-intelligence sync.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/ast_live_sync.py

"""Live code-intelligence sync: file save -> graph updated in seconds.

Step 3.4 of ``plans/neo4j-to-orchgraph-migration-plan.md``. Wires three
components that already exist and are tested, rather than growing a fourth:

    watchfiles.awatch (debounced)
        -> orchestrata_ast.load_or_analyze  (warm: re-parses ONLY the files
           whose content hash changed, reusing every other module's cached
           dict verbatim)
        -> GraphSyncer.incremental_sync     (deletes + recreates just those
           modules and repairs their cross-edges, failing loudly on a module
           path the AST does not contain)

The AST cache doubles as the freshness map: ``load_or_analyze`` stat+hashes
every live file against the ``file_meta`` block of the previous serialize and
returns ``stats["reparsed"]`` -- exactly the module list ``incremental_sync``
wants. That is why this does not re-implement change detection: the watcher
says "something under src/ moved", and the hash map says which modules
actually differ. An editor that rewrites a file with identical content
produces a watch event and zero graph writes.

**SLO:** file save -> graph updated, end to end, < 5 s. ``--measure`` runs
that leg once against a real file and exits non-zero if it misses, which is
what ``benchmark_code_intel.py`` deliberately does not cover (that harness
measures query latency; this is pipeline latency).

Deployed as the ``orchestrata-ast-live-sync`` user systemd unit -- always on,
not a remembered command.

Usage::

    python scripts/ast_live_sync.py                      # run the service
    python scripts/ast_live_sync.py --once               # one pass, then exit
    python scripts/ast_live_sync.py --measure            # SLO check
"""

from __future__ import annotations

import argparse
import asyncio
import json
import logging
import os
import sys
import time
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any

_REPO_ROOT = Path(__file__).resolve().parent.parent
_REPO_SRC = _REPO_ROOT / "src"
if str(_REPO_SRC) not in sys.path:
    sys.path.insert(0, str(_REPO_SRC))
# Repo root on the path so the sibling ``scripts.*`` modules import whether
# this file is run as ``python scripts/ast_live_sync.py`` (sys.path[0] is
# ``scripts/``) or imported as ``scripts.ast_live_sync`` (test context).
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

from orchestrata_ast.analyzer import (  # noqa: E402
    compute_file_meta,
    load_or_analyze,
)
from orchestrata_ast.roots import (  # noqa: E402
    declared_roots,
    existing_roots,
    split_roots,
    undeclared_message,
)
from orchestrata_ast.serialize import serialize  # noqa: E402
from orchestrata_ultimate.knowledge_graph.sync import GraphSyncer  # noqa: E402
from scripts.build_runtime_callgraph import (  # noqa: E402
    augment_ast,
    collect_ast_nodes,
)

# The committed runtime call-graph summary (SPEC-023). ``serialize()`` knows
# nothing about the ``runtime_calls`` field -- only build_runtime_callgraph.py
# writes it -- so a fresh serialize DROPS it. This daemon re-serializes its
# own untracked freshness-map cache (``--cache``, default
# ``_DEFAULT_CACHE_PATH`` below -- never the committed ``plans/ast-ultimate
# .json`` artifact) on every source change, which without a preservation
# step silently strips the merged static+runtime call graph that
# scripts/derive_rtm.py, scripts/code_intel.py's merged queries, and
# ``trace-path`` all depend on. We re-apply ``runtime_calls`` from this
# committed summary after every serialize (the same edges reaugment uses),
# deterministically and idempotently.
_RUNTIME_CALLGRAPH_SUMMARY = _REPO_ROOT / "plans" / "runtime-callgraph.json"

# Untracked sidecar (see .gitignore) that doubles as the live-sync
# freshness map. plans/ast-ultimate.json stays CLI-only: it is the
# snapshot scripts/gates.py's ast-refresh step regenerates and the
# Prolog fact base reads, and this always-on service rewriting it kept
# it permanently dirty and churned it in unrelated commits (context.md
# backlog #284, DESIGN_WAIVER 2026-08-09).
_DEFAULT_CACHE_PATH = ".ast_daemon/ast-live-cache.json"

# The committed snapshot this service seeds ITS sidecar from on a cold
# start (sidecar absent, artifact present) -- never written back to.
_COMMITTED_AST_CACHE = _REPO_ROOT / "plans" / "ast-ultimate.json"

_logger = logging.getLogger("ast_live_sync")


# The analyzer roots the graph's module ids are RELATIVE to. They must match
# the roots `plans/ast-ultimate.json` was serialized from, because a module's
# canonical id is its dotted path from its root: parsing `src/` instead names
# the same file `orchestrata_ultimate.agents.communication` where the graph
# holds `agents.communication`, so an "incremental" sync inserts a second,
# parallel copy of the entire codebase rather than updating it. Measured
# 2026-07-26: one pass against `src/` took orchkg from 783 Modules to 1621 and
# had to be repaired with a full re-derive.
#
# NO LITERAL HERE (2026-10-09). This file used to pin
# `DEFAULT_SOURCE_DIR = "src/orchestrata_ultimate"` while the serializer read
# `$ORCHESTRATA_AST_ROOTS`, so the snapshot covered six roots and the graph
# one: 3,171 modules in the AST, 905 in the graph, every scripts/, claude/
# and tests/ module silently absent from code intelligence. Both tools now
# read the SAME declaration (env/ast-roots.env via orchestrata_ast.roots):
# the first root is primary, the rest are `extra_roots`, and the watch set is
# all of them. An undeclared set is a refusal, not a fallback.
def default_roots(
    env: Mapping[str, str] | None = None,
) -> tuple[str | None, tuple[str, ...], tuple[str, ...]]:
    """``(source_dir, extra_roots, watch_dirs)`` from the declared root set.

    ``(None, (), ())`` when nothing is declared -- the caller decides how loud
    to be; a service must refuse rather than narrow the graph.
    """
    primary, extra = split_roots(declared_roots(env))
    if primary is None:
        return None, (), ()
    return primary, extra, (primary, *extra)


# Paths that change constantly and carry no code intelligence. Without these
# the watcher wakes on its own output (the AST cache) and on every pytest run.
IGNORE_FRAGMENTS: tuple[str, ...] = (
    "/.git/",
    "/.venv/",
    "/__pycache__/",
    "/.pytest_cache/",
    "/node_modules/",
    "/reports/",
    "/reviews/",
    "/backups/",
)


@dataclass(frozen=True)
class SyncOutcome:
    """What one pass actually changed, and how long the whole leg took."""

    reparsed: tuple[str, ...]
    removed: tuple[str, ...]
    nodes: int
    relationships: int
    duration_s: float
    fallback: bool
    # How many modules the LOCAL AST holds for this project. Carried so the
    # destination check can compare "what the tree has" against "what the graph
    # has"; a pass that writes nothing is only correct if the graph is already
    # populated, and nothing else in this outcome can tell those apart.
    total_modules: int = 0

    @property
    def changed(self) -> bool:
        return bool(self.reparsed or self.removed)


def is_watchable(path: str, watch_dirs: Sequence[str], repo_root: Path) -> bool:
    """Whether a changed path belongs to the code-intelligence graph.

    Filtering here rather than inside the sync keeps a ``reports/*.json``
    write from costing a hash sweep of the whole tree. Watch dirs are matched
    as path PREFIXES, not first components, because the analyzer root is
    nested (``src/orchestrata_ultimate``) -- a first-component match would
    accept everything under ``src/``, which is the mis-scoping that inserted
    a duplicate codebase on 2026-07-26.
    """
    if not path.endswith(".py"):
        return False
    normalized = f"/{Path(path).as_posix().lstrip('/')}"
    if any(fragment in normalized for fragment in IGNORE_FRAGMENTS):
        return False
    try:
        relative = Path(path).resolve().relative_to(repo_root.resolve())
    except ValueError:
        return False
    return any(
        relative == Path(watched) or Path(watched) in relative.parents
        for watched in watch_dirs
    )


def filter_changes(
    paths: Iterable[str], watch_dirs: Sequence[str], repo_root: Path
) -> list[str]:
    """The watchable subset of one debounced batch, order-stable."""
    seen: set[str] = set()
    kept: list[str] = []
    for path in paths:
        if path in seen or not is_watchable(path, watch_dirs, repo_root):
            continue
        seen.add(path)
        kept.append(path)
    return kept


class LiveCodeSync:
    """Keeps the graph's code-intelligence subgraph current with the tree.

    Args:
        repo_root: Repository root; also the analyzer's source root.
        cache_path: The serialize output that doubles as the freshness map
            (an untracked sidecar by default -- ``_DEFAULT_CACHE_PATH``,
            never the committed ``plans/ast-ultimate.json`` artifact).
            Rewritten after every successful pass so the next one only
            re-parses what changed since.
        project: Project id the graph nodes are namespaced under.
        syncer: A CONNECTED ``GraphSyncer`` aimed at the target graph.
        source_dir: Subdirectory the analyzer parses (``src``).
    """

    def __init__(
        self,
        repo_root: Path,
        cache_path: Path,
        project: str,
        syncer: GraphSyncer,
        *,
        source_dir: str,
        extra_roots: Sequence[str] = (),
    ) -> None:
        self._repo_root = repo_root
        self._cache_path = cache_path
        self._project = project
        self._syncer = syncer
        self._source = repo_root / source_dir
        # Secondary trees, analyzed alongside the primary root and prefixed
        # with their own directory name (tests.x, scripts.x) -- the same
        # contract as the serializer's repeated --source.
        self._extra_roots: tuple[Path, ...] = tuple(repo_root / r for r in extra_roots)
        self._passes = 0

    @property
    def source_root(self) -> Path:
        """The directory module ids are relative to (the analyzer root)."""
        return self._source

    @property
    def extra_roots(self) -> tuple[Path, ...]:
        """The secondary trees this sync analyzes (declared, not assumed)."""
        return self._extra_roots

    @property
    def passes(self) -> int:
        """Completed sync passes — the liveness signal for the unit."""
        return self._passes

    def assert_cache_root_matches(self) -> None:
        """Refuse to sync when the cache was built from a different root.

        ``load_or_analyze`` treats a root mismatch as a cache miss and
        silently full-reparses -- correct for a one-shot CLI, catastrophic
        for a service: module ids are dotted paths RELATIVE to the root, so
        parsing a different root renames every module and
        ``incremental_sync`` inserts a parallel copy of the whole codebase
        instead of updating it. That is exactly what happened on 2026-07-26
        (783 Modules -> 1621). A loud refusal costs one restart; the silent
        fallback cost a full re-derive.
        """
        try:
            cached_root = json.loads(self._cache_path.read_text(encoding="utf-8")).get(
                "root_path"
            )
        except OSError, ValueError:
            return  # no usable cache: a full parse is the correct outcome
        if not cached_root:
            return
        if Path(str(cached_root)).resolve() != self._source.resolve():
            raise SystemExit(
                f"AST cache {self._cache_path} was built from "
                f"{cached_root!r}, but this sync parses "
                f"{str(self._source)!r}. Module ids are relative to the root, "
                "so syncing would insert a duplicate codebase rather than "
                "update it. Regenerate the cache from the same root, or pass "
                "--cache for the matching one."
            )
        # The set of trees is part of the cache's identity (load_or_analyze
        # checks it too, but falls back to a SILENT full reparse). A service
        # must refuse instead: a cache built for six roots warm-starting a
        # sync declared for one would drop five trees from the graph on the
        # next pass without a word.
        try:
            cached_extra = json.loads(self._cache_path.read_text(encoding="utf-8")).get(
                "extra_roots"
            )
        except OSError, ValueError:
            return
        if cached_extra is None:
            return
        want = [str(p.resolve()) for p in self._extra_roots]
        have = [str(Path(str(p)).resolve()) for p in cached_extra]
        if have != want:
            raise SystemExit(
                f"AST cache {self._cache_path} covers extra roots {have}, but "
                f"this sync declares {want}. The root set is part of the "
                "cache's identity; regenerate the cache for the declared set "
                "(env/ast-roots.env) or pass --cache for the matching one."
            )

    def sync_once(self) -> SyncOutcome:
        """Re-parse what changed and push exactly those modules to the graph.

        Returns a :class:`SyncOutcome` even when nothing changed (``nodes``
        and ``relationships`` zero), because "the watcher fired and the graph
        needed no write" is a real, reportable result -- not an error.
        """
        self.assert_cache_root_matches()
        started = time.perf_counter()
        result, stats = load_or_analyze(
            self._project,
            self._source,
            self._cache_path,
            extra_roots=self._extra_roots,
        )
        reparsed: list[str] = list(stats.get("reparsed", []))
        removed: list[str] = list(stats.get("removed", []))
        fallback = bool(stats.get("fallback", False))

        ast_data = _as_ast_dict(result)
        nodes = relationships = 0
        if removed:
            # A deleted file has no AST entry left, so incremental_sync
            # cannot express this at all (it raises on module paths the AST
            # does not contain). Without this the sync reported "1 removed"
            # while the module's whole subtree stayed in the graph forever —
            # observed live 2026-07-26, Modules stuck at 785 after a delete.
            self._syncer.delete_modules(self._project, removed)
        if reparsed:
            # incremental_sync deletes + recreates only these modules and
            # repairs their cross-edges; it raises on a module path the AST
            # does not contain rather than silently syncing nothing.
            counts = self._syncer.incremental_sync(ast_data, self._project, reparsed)
            nodes = int(counts.get("nodes_created", 0))
            relationships = int(counts.get("relationships_created", 0))

        if reparsed or removed:
            # Persist AFTER the graph write: if the write raises, the cache
            # still describes the graph's actual contents, so the next pass
            # retries the same modules instead of believing them synced.
            self._write_cache(ast_data)

        self._passes += 1
        return SyncOutcome(
            reparsed=tuple(reparsed),
            removed=tuple(removed),
            nodes=nodes,
            relationships=relationships,
            total_modules=len(ast_data.get("modules", ()) or ()),
            duration_s=time.perf_counter() - started,
            fallback=fallback,
        )

    def _write_cache(self, ast_data: dict[str, Any]) -> None:
        """Rewrite the freshness map atomically.

        A torn cache would send the next pass down ``load_or_analyze``'s
        full-fallback path -- correct, but a multi-minute reparse of the whole
        tree in a service meant to react in seconds.
        """
        tmp = self._cache_path.with_suffix(self._cache_path.suffix + ".tmp")
        tmp.parent.mkdir(parents=True, exist_ok=True)
        with open(tmp, "w", encoding="utf-8") as handle:
            json.dump(ast_data, handle, indent=2, default=str)
        tmp.replace(self._cache_path)

    async def watch(
        self,
        watch_dirs: Sequence[str],
        *,
        debounce_ms: int = 1000,
        stop: asyncio.Event | None = None,
    ) -> None:
        """Run until *stop* is set, syncing after each debounced batch."""
        from watchfiles import awatch

        targets = [str(self._repo_root / d) for d in watch_dirs]
        _logger.info("watching %s (debounce %d ms)", ", ".join(targets), debounce_ms)
        async for changes in awatch(*targets, debounce=debounce_ms, stop_event=stop):
            paths = filter_changes(
                (path for _, path in changes), watch_dirs, self._repo_root
            )
            if not paths:
                continue
            try:
                outcome = await asyncio.to_thread(self.sync_once)
            except Exception as exc:
                # A watcher that dies on one bad parse stops being live. Log
                # loudly and keep watching; the next save retries.
                _logger.error("sync pass failed: %s", exc)
                continue
            _log_outcome(outcome)


def _as_ast_dict(result: Any) -> dict[str, Any]:
    """Serialized AST plus the ``file_meta`` freshness map the cache needs."""
    data = serialize(result)
    if not isinstance(data, dict):  # defensive: serialize contract is a dict
        raise TypeError(f"serialize() returned {type(data).__name__}, expected dict")
    data["file_meta"] = compute_file_meta(result.modules)
    _preserve_runtime_calls(data)
    return data


def _preserve_runtime_calls(
    ast_data: dict[str, Any], summary_path: Path = _RUNTIME_CALLGRAPH_SUMMARY
) -> None:
    """Re-apply the ``runtime_calls`` field that ``serialize()`` drops.

    ``serialize()`` reconstructs the AST from source and has no notion of the
    SPEC-023 runtime call-graph, so it emits functions/methods WITHOUT a
    ``runtime_calls`` field. Persisting that as ``plans/ast-ultimate.json``
    strips the merged static+runtime graph that ``codebase_db.pl`` asserts
    ``runtime_call/2`` from -- breaking ``scripts/derive_rtm.py``,
    ``scripts/code_intel.py``'s merged reachability, and ``trace-path``.

    We deterministically restore it from the committed
    ``plans/runtime-callgraph.json`` summary (the same edge set
    ``build_runtime_callgraph.py reaugment`` uses), mutating ``ast_data`` in
    place. If the summary is absent (no runtime capture committed) we leave
    the AST as serialized rather than fail the live sync -- callers that need
    the field will hit the loud guard in derive_rtm.py, not a corrupt cache.
    """
    if not summary_path.exists():
        return
    with open(summary_path, encoding="utf-8") as fh:
        summary = json.load(fh)
    edges = [(pair[0], pair[1]) for pair in summary.get("edges", [])]
    _, func_index = collect_ast_nodes(ast_data)
    augment_ast(func_index, edges)


def _log_outcome(outcome: SyncOutcome) -> None:
    if not outcome.changed:
        _logger.info("no code change after hashing (%.2fs)", outcome.duration_s)
        return
    _logger.info(
        "synced %d module(s) (%d removed) -> %d nodes / %d rels in %.2fs%s",
        len(outcome.reparsed),
        len(outcome.removed),
        outcome.nodes,
        outcome.relationships,
        outcome.duration_s,
        " [full reparse fallback]" if outcome.fallback else "",
    )


def resolve_target_uri(env: Mapping[str, str], explicit: str | None) -> str:
    """Address of the graph to keep current, from .env at call time (RUL-003).

    Prefers ``KG_ROUTING_URI`` and falls back to ``MEMGRAPH_URI``, matching
    ``benchmark_code_intel.py``: the former is blank until the Phase 4.2
    cutover fills it, the latter is the orchgraph routing address today.
    """
    if explicit:
        return explicit
    for key in ("KG_ROUTING_URI", "MEMGRAPH_URI"):
        value = env.get(key, "").strip()
        if value:
            return value
    raise SystemExit(
        "neither KG_ROUTING_URI nor MEMGRAPH_URI is set, and no --uri was passed"
    )


def _build_parser(env: Mapping[str, str] | None = None) -> argparse.ArgumentParser:
    """The CLI. Root defaults come from the declared set (``env`` is the seam)."""
    source_dir, extra_roots, watch_dirs = default_roots(env)
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--uri", default=None, help="default: $KG_ROUTING_URI, else $MEMGRAPH_URI"
    )
    parser.add_argument("--database", default="orchkg")
    parser.add_argument("--project", default="orchestrata-ultimate")
    # Repo root and source dir were pinned to THIS checkout (`_REPO_ROOT` from
    # __file__, `src/orchestrata_ultimate`), which made a tool that is otherwise
    # fully project-generic — nodes are already namespaced by `--project` — usable
    # in exactly one repository. dr-hannah was absent from the AST graph for that
    # reason alone.
    parser.add_argument(
        "--repo-root",
        default=None,
        help="project root to analyze (default: this repository)",
    )
    parser.add_argument(
        "--source-dir",
        default=source_dir,
        help=(
            "primary analyzer root relative to --repo-root (default: the first "
            f"declared root, currently {source_dir!r})"
        ),
    )
    parser.add_argument(
        "--extra-root",
        action="append",
        default=None,
        help=(
            "secondary tree relative to --repo-root, repeatable; its modules are "
            "prefixed with the directory name (default: the declared roots after "
            f"the first, currently {' '.join(extra_roots) or '<none>'})"
        ),
    )
    parser.add_argument(
        "--cache",
        default=_DEFAULT_CACHE_PATH,
        help=(
            "serialize output used as the freshness map (default: an "
            "untracked sidecar, never the committed plans/ast-ultimate.json "
            "artifact)"
        ),
    )
    parser.add_argument(
        "--watch",
        nargs="+",
        default=None,
        help=(
            "directories to watch (default: every analyzed root, currently "
            f"{' '.join(watch_dirs) or '<none declared>'})"
        ),
    )
    parser.add_argument("--debounce-ms", type=int, default=1000)
    parser.add_argument(
        "--once", action="store_true", help="run one sync pass and exit"
    )
    parser.add_argument(
        "--measure",
        action="store_true",
        help="measure the file-save -> graph SLO once and exit non-zero on a miss",
    )
    parser.add_argument(
        "--slo-seconds",
        type=float,
        default=5.0,
        help="file-save -> graph budget (default: 5.0)",
    )
    return parser


def _make_syncer(uri: str, database: str) -> GraphSyncer:
    """A connected syncer aimed at the unauthenticated orchgraph cluster."""
    syncer = GraphSyncer(uri=uri, database=database, no_auth=True, dialect="memgraph")
    syncer.connect()
    return syncer


# A pass may legitimately write nothing: the tree is unchanged AND the graph
# already holds it. The failure this guard exists for is the OTHER shape --
# unchanged tree, EMPTY graph -- which is indistinguishable in the outcome.
# Below this fraction of the local module count, the destination is treated as
# unpopulated rather than merely stale.
_DESTINATION_MIN_COVERAGE = 0.5


def assert_destination_populated(
    syncer: GraphSyncer, project: str, total_modules: int
) -> int:
    """Refuse to report success for a sync whose target did not receive the tree.

    Freshness here is decided by the AST CACHE, never by the destination. A warm
    cache reports "0 modules changed" and writes nothing -- and does so just as
    happily when the target graph holds ZERO modules for the project. That
    combination printed ``RESULT: PASS`` having populated nothing (measured
    2026-08-23 against a graph with 0 Module nodes for ``orchestrata-ultimate``;
    the run reported ``modules reparsed: 1, nodes/rels written: 3/2503`` and
    exited 0).

    The cache can only say what changed LOCALLY. Only the graph can say what it
    actually holds, so success has to be asserted against the graph.

    Returns 0 when the destination is populated, 1 when it is not. Never raises
    on a merely-empty result: the caller turns this into an exit code, and a
    swallowed exception here would restore the exact false green it removes.
    """
    rows = syncer._connected_repo.execute_read(
        "MATCH (m:Module {project: $project}) RETURN count(m) AS cnt",
        {"project": project},
    )
    actual = int(rows[0]["cnt"]) if rows else 0
    floor = int(total_modules * _DESTINATION_MIN_COVERAGE)

    if actual == 0:
        print(
            f"\nRESULT: FAIL — the target graph holds 0 Module nodes for project "
            f"{project!r} while the local AST holds {total_modules}. The AST cache "
            f"reported the tree as unchanged, so this pass wrote nothing into an "
            f"EMPTY destination. Populate it with a full sync:\n"
            f"  scripts/sync_ast_to_graph.py full "
            f"--ast plans/ast-ultimate.json --project {project}",
            file=sys.stderr,
        )
        return 1

    if total_modules and actual < floor:
        print(
            f"\nRESULT: FAIL — the target graph holds {actual} Module nodes for "
            f"project {project!r} but the local AST holds {total_modules} "
            f"({actual / total_modules:.0%} coverage, floor {_DESTINATION_MIN_COVERAGE:.0%}). "
            f"An incremental pass cannot close a gap this large because the cache "
            f"believes those modules are already synced. Run a full sync.",
            file=sys.stderr,
        )
        return 1

    print(
        f"  destination        : {actual} modules in graph (local AST {total_modules})"
    )
    return 0


def measure_slo(sync: LiveCodeSync, budget_s: float) -> int:
    """Time one real save -> graph leg; return 0 only if it fits the budget.

    Writes a NEW module into the source root, measures the save -> graph
    leg, then deletes it and syncs the removal. A new file is used rather
    than an edit to an existing one because:

    * ``touch()`` is not enough. Freshness is decided by CONTENT hash, so
      an mtime-only change reparses nothing and writes nothing -- the first
      version of this function reported PASS at 0.552 s having produced 0
      nodes, timing a hash sweep and calling it a pipeline measurement.
    * Editing a real module would have to be reverted, and a crash between
      the edit and the revert would leave the tree modified.

    The probe file is uuid-named so a crashed run leaves something obviously
    disposable rather than a plausible-looking module.
    """
    sync.sync_once()  # settle: make the cache describe the tree exactly

    import uuid

    probe = sync.source_root / f"_slo_probe_{uuid.uuid4().hex[:10]}.py"
    probe.write_text(
        '"""Transient probe written by ast_live_sync --measure."""\n\n\n'
        "def _slo_probe() -> None:\n    return None\n",
        encoding="utf-8",
    )
    try:
        started = time.perf_counter()
        outcome = sync.sync_once()
        elapsed = time.perf_counter() - started
    finally:
        probe.unlink(missing_ok=True)
        sync.sync_once()  # propagate the removal to the graph and the cache

    if not outcome.reparsed:
        # The probe was written but nothing reparsed: the pipeline is not
        # detecting new files at all. Reporting the elapsed time as a PASS
        # here is precisely the false green this guard exists to stop.
        print(
            "\nRESULT: FAIL — the probe module was written but no module was "
            "reparsed; the change-detection leg is broken"
        )
        return 1

    print(f"  file-save -> graph : {elapsed:.3f} s  budget={budget_s:.3f} s")
    print(f"  modules reparsed   : {len(outcome.reparsed)}")
    print(f"  nodes/rels written : {outcome.nodes} / {outcome.relationships}")
    if elapsed >= budget_s:
        print(
            f"\nRESULT: FAIL — {elapsed:.3f} s >= {budget_s:.3f} s budget",
        )
        return 1
    # Timing a probe module through the pipeline proves the LEG works; it says
    # nothing about whether the destination holds the rest of the tree. Both
    # must hold before this prints PASS.
    if assert_destination_populated(sync._syncer, sync._project, outcome.total_modules):
        return 1
    print("\nRESULT: PASS")
    return 0


def _seed_cache_from_committed_artifact(cache_path: Path) -> bool:
    """Populate an absent sidecar from the committed AST snapshot.

    ``load_or_analyze`` treats a missing freshness map as a full cold
    reparse of the whole tree -- correct, but multi-minute on this
    codebase (~122K LOC), which a service meant to react to a single
    file save within 5s cannot pay on every fresh checkout / deploy.
    ``plans/ast-ultimate.json`` already carries an equivalent snapshot
    (including the ``file_meta`` hash map ``load_or_analyze`` diffs
    against), so a byte-for-byte copy into the sidecar location makes
    the very next pass incremental instead of full. A no-op when the
    sidecar already exists -- including an explicit
    ``--cache plans/ast-ultimate.json`` override, since then *cache_path*
    IS the committed artifact and therefore already exists -- or the
    committed artifact itself is absent.

    Returns True when a seed copy was written.
    """
    if cache_path.exists() or not _COMMITTED_AST_CACHE.exists():
        return False
    cache_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = cache_path.with_suffix(cache_path.suffix + ".seed.tmp")
    with open(_COMMITTED_AST_CACHE, "rb") as src, open(tmp, "wb") as dst:
        dst.write(src.read())
    tmp.replace(cache_path)
    return True


def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int:
    """Run, measure, or single-pass the live code-intelligence sync."""
    environ = env if env is not None else os.environ
    args = _build_parser(environ).parse_args(argv)
    logging.basicConfig(
        level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
    )

    uri = resolve_target_uri(environ, args.uri)
    repo_root = (
        Path(args.repo_root).expanduser().resolve() if args.repo_root else _REPO_ROOT
    )
    if not repo_root.is_dir():
        _logger.error("repo root does not exist: %s", repo_root)
        return 2
    if args.source_dir is None:
        # Refuse, never narrow: an undeclared set defaulting to one tree is
        # how the graph lost five of six roots (see default_roots).
        _logger.error("%s Or pass --source-dir explicitly.", undeclared_message())
        return 2
    if not (repo_root / args.source_dir).is_dir():
        _logger.error(
            "source dir %s does not exist under %s — pass --source-dir",
            args.source_dir,
            repo_root,
        )
        return 2
    # An explicit --extra-root list wins; otherwise the declared roots after
    # the first. Declared roots absent from THIS checkout (vizint-ui, infra on
    # a trimmed deploy) are reported and skipped -- a service that refused to
    # start over an optional tree would be down more often than it is wrong.
    if args.extra_root is not None:
        extra_roots = list(args.extra_root)
        _, absent = existing_roots(extra_roots, repo_root)
        if absent:
            _logger.error("extra root(s) do not exist under %s: %s", repo_root, absent)
            return 2
    else:
        _, declared_extra, _ = default_roots(environ)
        extra_roots, absent = existing_roots(list(declared_extra), repo_root)
        if absent:
            _logger.info("declared roots absent here, skipped: %s", " ".join(absent))
    cache_path = Path(args.cache)
    if not cache_path.is_absolute():
        cache_path = repo_root / cache_path
    # Only for THIS repository: the committed snapshot is orchestrata's own AST, and
    # its cached root would not match another project's source root. LiveCodeSync
    # raises on that mismatch rather than corrupting the graph, but seeding a foreign
    # project's sidecar with it is still wrong, so do not do it at all.
    if repo_root == _REPO_ROOT and _seed_cache_from_committed_artifact(cache_path):
        _logger.info(
            "seeded %s from the committed AST snapshot (cold start)", cache_path
        )

    syncer = _make_syncer(uri, args.database)
    try:
        sync = LiveCodeSync(
            repo_root,
            cache_path,
            args.project,
            syncer,
            source_dir=args.source_dir,
            extra_roots=extra_roots,
        )
        _logger.info("target %s db=%s project=%s", uri, args.database, args.project)
        _logger.info(
            "roots: %s%s",
            args.source_dir,
            "".join(f" {r}" for r in extra_roots),
        )

        if args.measure:
            rc = measure_slo(sync, args.slo_seconds)
            return rc
        if args.once:
            outcome = sync.sync_once()
            _log_outcome(outcome)
            # A single pass is NOT a success just because it raised no error.
            # Verify the destination actually holds the tree — the cache cannot
            # see the graph, and "nothing to do" over an empty graph is the
            # false green this exit path used to print by returning 0 blindly.
            return assert_destination_populated(
                syncer, args.project, outcome.total_modules
            )
        # An unspecified --watch means "watch what we analyze": the primary
        # root plus every extra root actually present. A save under scripts/
        # or tests/ now produces a module, so waking for it is no longer a
        # wasted hash sweep -- it is the point.
        watch_dirs = (
            args.watch if args.watch is not None else [args.source_dir, *extra_roots]
        )
        asyncio.run(sync.watch(watch_dirs, debounce_ms=args.debounce_ms))
        return 0
    finally:
        syncer.close()


if __name__ == "__main__":
    sys.exit(main())