scripts.codemod
The command-line front end of the SPEC-110 codemod engine, which lives in codemod. It runs transformation passes over the codebase and answers dependency-graph questions. A human runs it. tests/scripts/test_codemod_cli.py and tests/scripts/test_codemod_cli_daemon.py import it, and it uses @traces from traces.
Invocation: main has four subcommands. load-bench measures how long loading the workspace takes (_cmd_load_bench). run applies one or more named transformation passes with their arguments (_cmd_run, _pass_spec, _resolve_pass) and reports a commit report (_finish_run). report shows a previous report (_cmd_report). analyze runs analysis passes or dependency queries over a module, such as dependencies, cycles, topological order and centrality (_cmd_analyze_passes, _LibraryQueries, the _print_* helpers), and can compare against a base revision (_changes_since). Common options include the repository path, --include roots, --no-daemon and --auto-start. A parse error returns 2.
Two execution modes exist. When a resident daemon serves the repository (REQ-110-140), _connect_or_warn connects to its socket with a CodemodClient and the work runs there (_run_via_daemon). Otherwise the script parses the code in its own process (_run_in_process, library mode), says so with _library_mode_warning, and --no-daemon selects that mode quietly. --auto-start first starts the daemon through _auto_start_daemon, and a failure to start it returns 1.
At a glance
| Source | scripts/codemod.py |
| Lines of code | 1183 |
| Classes | 3 |
| Functions | 37 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.refactor.codemod, orchestrata_ultimate.refactor.codemod._cig, orchestrata_ultimate.refactor.codemod._types, orchestrata_ultimate.refactor.codemod.analysis, orchestrata_ultimate.refactor.codemod.analysis.protocol, orchestrata_ultimate.refactor.codemod.client, orchestrata_ultimate.refactor.codemod.daemon, orchestrata_ultimate.refactor.codemod.passes, orchestrata_ultimate.refactor.codemod.report, orchestrata_ultimate.refactor.codemod.review_engine, orchestrata_ultimate.refactor.codemod.workspace, orchestrata_ultimate.refactor.ctok |
| Imported by | none |
| Tests that import it | tests/scripts/test_codemod_cli.py, tests/scripts/test_codemod_cli_daemon.py |
Class diagram
Dependency diagram
API
Classes
_UnknownModule
Bases: Exception · Line 954
--module names no node of the code intelligence graph.
_LibraryQueries
Line 958
The graph queries of analyze, answered from a workspace parsed here.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(ws: CodemodWorkspace, args: argparse.Namespace) -> None |
|
_node |
_node(module: str) -> int |
|
name_of |
name_of(ids: list[int]) -> NameOf |
|
dependencies |
dependencies(module: str) -> list[int] |
|
dependents |
dependents(module: str) -> list[int] |
|
cycles |
cycles() -> list[list[int]] |
|
topological_order |
topological_order() -> list[int] |
|
centrality |
centrality() -> list[tuple[int, float]] |
|
impact_score |
impact_score(module: str) -> float |
|
routed |
routed(module: str) -> object |
_DaemonQueries
Line 999
The same queries, answered by the daemon serving the repo.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(client: CodemodClient, args: argparse.Namespace) -> None |
|
_query staticmethod |
_query(module: str, call: Callable[[str], Any]) -> Any |
|
name_of |
name_of(ids: list[int]) -> NameOf |
|
dependencies |
dependencies(module: str) -> list[int] |
|
dependents |
dependents(module: str) -> list[int] |
|
cycles |
cycles() -> list[list[int]] |
|
topological_order |
topological_order() -> list[int] |
|
centrality |
centrality() -> list[tuple[int, float]] |
|
impact_score |
impact_score(module: str) -> float |
|
routed |
routed(module: str) -> object |
Functions
| Function | Signature | Summary |
|---|---|---|
_build_parser |
_build_parser() -> argparse.ArgumentParser |
|
_auto_start_daemon |
_auto_start_daemon(repo_root: Path, *, tools_prefix: Path | None = None, unit_name: str | None = None, wait_seconds: float | None = None) -> str | None |
Ensure the deployed codemod daemon serves repo_root (--auto-start). |
_daemon_socket_path |
_daemon_socket_path(repo_root: Path) -> Path |
Where the daemon serving repo_root listens. |
_connect_daemon |
_connect_daemon(socket_path: Path) -> CodemodClient | None |
A client connected to the daemon at socket_path, or None. |
_library_mode_warning |
_library_mode_warning(repo_root: Path, socket_path: Path) -> str |
The one stderr line printed when no daemon serves repo_root. |
_roots_text |
_roots_text(roots: tuple[str, ...]) -> str |
|
_folded_roots |
_folded_roots(repo_root: Path, include: list[str]) -> tuple[str, ...] |
include as the sorted repo-relative roots a workspace load stores. |
_connect_or_warn traces |
_connect_or_warn(repo_root: Path, include: list[str] | None = None) -> CodemodClient | None |
Auto-connect to the daemon serving repo_root; say so when none does. |
_resolve_include |
_resolve_include(include: list[str] | None) -> tuple[str, ...] |
|
_load_workspace |
_load_workspace(args: argparse.Namespace) -> CodemodWorkspace |
Parse the repo into a workspace in this process (library mode). |
_pass_spec |
_pass_spec(name: str, args: dict[str, str]) -> tuple[str, dict[str, Any]] |
The pass CLASS name (as passes exports it) and its constructor kwargs. |
_resolve_pass |
_resolve_pass(name: str, args: dict[str, str]) -> TransformPass |
|
_parse_args |
_parse_args(raw_args: list[str] | None = None) -> dict[str, str] |
|
_cmd_load_bench |
_cmd_load_bench(args: argparse.Namespace) -> int |
|
_cmd_run traces |
_cmd_run(args: argparse.Namespace, client: CodemodClient | None = None) -> int |
run: in this process, or inside the daemon client is connected to. |
_run_in_process |
_run_in_process(args: argparse.Namespace, pass_args: dict[str, str]) -> int |
Library mode: parse the repo, run, commit, all in this process. |
_run_via_daemon |
_run_via_daemon(args: argparse.Namespace, client: CodemodClient, pass_args: dict[str, str]) -> int |
Daemon mode: the passes stack into one run the daemon holds by reference. |
_fail_run |
_fail_run(message: str) -> int |
The one Error: line a failed commit prints, in either mode. |
_finish_run |
_finish_run(args: argparse.Namespace, pass_names: list[str], files_mutated: int, report: CommitReport) -> int |
Print the run summary, write the report file, return the exit code. |
_cmd_report |
_cmd_report(args: argparse.Namespace) -> int |
|
_resolve_cig_node |
_resolve_cig_node(cig: CodeIntelligenceGraph, module: str) -> int | None |
|
_changes_since |
_changes_since(ws: CodemodWorkspace, module: str, base: str) -> ChangeSet |
A one-entry ChangeSet: module at git ref base vs the loaded tree. |
_run_analysis |
_run_analysis(name: str, ws: CodemodWorkspace, module: str | None, args: argparse.Namespace) -> object |
Run one analysis pass over the loaded workspace (never writes it). |
_module_paths |
_module_paths(ws: CodemodWorkspace) -> list[str] |
|
_run_all_passes |
_run_all_passes(ws: CodemodWorkspace, module: str | None, args: argparse.Namespace) -> dict[str, object] |
The seven analyzers, keyed by analyzer name. |
_summarize |
_summarize(item: object) -> object |
Counts in place of the id/path collections of a BlastRadius. |
_encode_all |
_encode_all(results: dict[str, object]) -> dict[str, object] |
|
_print_analysis_text |
_print_analysis_text(name: str, result: object, cig: CodeIntelligenceGraph) -> None |
Human-readable summary of one analysis result. |
_cmd_analyze_passes |
_cmd_analyze_passes(args: argparse.Namespace, ws: CodemodWorkspace, module: str | None) -> int |
The analysis-pass queries (REQ-110-031): print text or --json. |
_print_routed |
_print_routed(args: argparse.Namespace, result: object) -> None |
Render a blast_radius / coupling_score / abstraction_level result. |
_print_node_list |
_print_node_list(title: str, ids: list[int], name_of: NameOf) -> None |
|
_print_cycles |
_print_cycles(cycles: list[list[int]], name_of: NameOf) -> None |
|
_print_topological_order |
_print_topological_order(order: list[int], name_of: NameOf) -> None |
|
_print_centrality |
_print_centrality(results: list[tuple[int, float]], name_of: NameOf, top_k: int, exact: bool) -> None |
|
_render_graph_query |
_render_graph_query(args: argparse.Namespace, queries: _LibraryQueries | _DaemonQueries) -> None |
Print one non-pass analyze query; both modes share every line. |
_cmd_analyze traces |
_cmd_analyze(args: argparse.Namespace, client: CodemodClient | None = None) -> int |
analyze: graph queries from the daemon client is connected to, or from a workspace parsed in this process (library mode). |
main |
main(argv: list[str] | None = None) -> int |
Source
Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/codemod.py
#!/usr/bin/env python3
"""SPEC-110 codemod engine CLI — load-bench, run, report, analyze subcommands."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from orchestrata_ultimate.core.base.traces import traces
if TYPE_CHECKING:
# Static-only: the real imports stay lazy inside the functions below
# (avoids loading the codemod engine for CLI paths that never touch
# it), but mypy needs the names in scope to check the signatures.
from orchestrata_ultimate.refactor.codemod._cig import CodeIntelligenceGraph
from orchestrata_ultimate.refactor.codemod.client import CodemodClient
from orchestrata_ultimate.refactor.codemod.passes import TransformPass
from orchestrata_ultimate.refactor.codemod.report import ChangeSet, CommitReport
from orchestrata_ultimate.refactor.codemod.workspace import CodemodWorkspace
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="codemod",
description="In-memory code intelligence platform",
)
parser.add_argument(
"--repo",
type=Path,
default=Path(os.environ.get("CODEMOD_ROOT", ".")),
help="Repository root path (default: $CODEMOD_ROOT or .)",
)
parser.add_argument(
"--include",
action="append",
default=None,
help="Directories to parse (repeatable; default: src, scripts, tests)",
)
parser.add_argument(
"--parallel",
action="store_true",
default=False,
help="Use parallel parsing",
)
parser.add_argument(
"--auto-start",
action="store_true",
help="Auto-connect to daemon or start it if not running",
)
parser.add_argument(
"--no-daemon",
action="store_true",
help="Force library mode (parse on every invocation)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser(
"load-bench",
help="Measure workspace load time, RSS delta, file count",
)
run_parser = subparsers.add_parser(
"run",
help="Execute named transform passes",
)
run_parser.add_argument(
"--pass",
dest="passes",
action="append",
required=True,
help="Transform pass name (repeatable)",
)
run_parser.add_argument(
"--arg",
action="append",
default=[],
help="Pass argument as key=value (repeatable)",
)
run_parser.add_argument(
"--apply",
action="store_true",
default=False,
help="Write changes to disk (default: dry-run)",
)
run_parser.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Produce diff and report without writing (default)",
)
run_parser.add_argument(
"--observe",
action="store_true",
default=False,
help=(
"Attach the five REQ-110-026 observers (tracing, metrics, debug "
"panel, error detector, graph writer) for this run and commit; "
"the debug panel writes NDJSON traces under "
"pipeline_output/traces/ relative to the working directory"
),
)
run_parser.add_argument(
"--baseline",
choices=("delta", "absolute"),
default="delta",
help=(
"Review-engine baseline (ADR-0073): 'delta' (default) aborts the "
"commit only on error findings the change introduced; 'absolute' "
"aborts on any error finding in a mutated module"
),
)
run_parser.add_argument(
"--output",
type=Path,
default=None,
help="Report output path (default: reports/codemod-<id>.json)",
)
report_parser = subparsers.add_parser(
"report",
help="Pretty-print a CommitReport JSON file",
)
report_parser.add_argument(
"path",
type=Path,
help="Path to CommitReport JSON file",
)
analyze_parser = subparsers.add_parser(
"analyze",
help="Execute CIG queries",
)
analyze_parser.add_argument(
"--query",
required=True,
choices=[
"dependencies",
"dependents",
"blast_radius",
"cycles",
"topological_order",
"coupling_score",
"abstraction_level",
"centrality",
"impact_score",
"breaking_changes",
"complexity",
"test_coverage",
"dead_code",
"all-passes",
],
help="CIG query type",
)
analyze_parser.add_argument(
"--module",
type=str,
default=None,
help="Module path or dotted name for query",
)
analyze_parser.add_argument(
"--hops",
type=int,
default=3,
help="BFS depth for blast_radius (default: 3)",
)
analyze_parser.add_argument(
"--top-k",
type=int,
default=20,
help="Number of results for centrality (default: 20)",
)
analyze_parser.add_argument(
"--exact",
action="store_true",
help="Exact Brandes centrality over all sources (default: "
"pivot-sampled approximation)",
)
analyze_parser.add_argument(
"--base",
type=str,
default=None,
help="Git ref the working tree is diffed against for breaking_changes "
"(used with --module)",
)
analyze_parser.add_argument(
"--json",
action="store_true",
help="Emit the result of an analysis pass (blast_radius, coupling_score, "
"abstraction_level, breaking_changes, complexity, test_coverage, "
"dead_code, all-passes) as JSON",
)
return parser
def _auto_start_daemon(
repo_root: Path,
*,
tools_prefix: Path | None = None,
unit_name: str | None = None,
wait_seconds: float | None = None,
) -> str | None:
"""Ensure the deployed codemod daemon serves *repo_root* (``--auto-start``).
ADR-0067: the daemon only ever runs deployed code through its systemd
unit. Unless given explicitly, the prefix and unit come from the
identity-shaped seams ``CODEMOD_TOOLS_PREFIX`` / ``CODEMOD_DAEMON_UNIT``
(default ``/opt/orchestrata-tools`` / ``orchestrata-codemod-daemon``).
The daemon loads its workspace before it binds the socket, so a started
unit is not yet a serving daemon: this waits for a running daemon's socket
file, polling every 0.2 s up to *wait_seconds* (default
``CODEMOD_AUTO_START_WAIT``, 300: REQ-110-133's 120-s parse plus margin).
A unit that is not running after its first second, or has failed, ends the
wait at once.
Returns:
``None`` when the daemon is serving afterwards, else the
``RuntimeError`` text verbatim (e.g. the not-deployed message), the
failed-before-serving text, or the not-serving text after the wait.
"""
from orchestrata_ultimate.refactor.codemod.daemon import (
DEFAULT_TOOLS_PREFIX,
DEFAULT_UNIT_NAME,
CodemodDaemon,
)
daemon = CodemodDaemon(
tools_prefix=tools_prefix
or Path(os.environ.get("CODEMOD_TOOLS_PREFIX", str(DEFAULT_TOOLS_PREFIX))),
unit_name=unit_name or os.environ.get("CODEMOD_DAEMON_UNIT", DEFAULT_UNIT_NAME),
)
daemon._repo_root = repo_root
try:
if not daemon.status()["running"]:
daemon.start(repo_root)
except RuntimeError as e:
return str(e)
wait = (
wait_seconds
if wait_seconds is not None
else float(os.environ.get("CODEMOD_AUTO_START_WAIT", "300"))
)
started = time.monotonic()
deadline = started + wait
while True:
status = daemon.status()
running = bool(status.get("running"))
if running and status.get("socket_exists"):
return None
# A unit that has not yet reported a main pid is given its first
# second; after that a unit that is not running (exited, or waiting
# to restart) or has failed will never serve, and neither will a
# socket file left behind by a killed daemon.
state = status.get("active_state")
if state == "failed" or (not running and time.monotonic() - started >= 1.0):
return f"daemon {status.get('unit')} failed before serving ({state})"
if time.monotonic() >= deadline:
return (
f"daemon {status.get('unit')} started but not serving after {wait:g}s"
)
time.sleep(0.2)
def _daemon_socket_path(repo_root: Path) -> Path:
"""Where the daemon serving *repo_root* listens."""
from orchestrata_ultimate.refactor.codemod.daemon import DAEMON_DIR, SOCKET_NAME
return repo_root / DAEMON_DIR / SOCKET_NAME
def _connect_daemon(socket_path: Path) -> CodemodClient | None:
"""A client connected to the daemon at *socket_path*, or None.
None covers every way there is nobody to talk to: no socket file, a
socket file with no listener, a path that is not a socket.
"""
from orchestrata_ultimate.refactor.codemod.client import CodemodClient
try:
return CodemodClient().connect(socket_path)
except ConnectionError:
return None
def _library_mode_warning(repo_root: Path, socket_path: Path) -> str:
"""The one stderr line printed when no daemon serves *repo_root*."""
from orchestrata_ultimate.refactor.codemod.daemon import (
DEFAULT_UNIT_NAME,
CodemodDaemon,
)
daemon = CodemodDaemon(
unit_name=os.environ.get("CODEMOD_DAEMON_UNIT", DEFAULT_UNIT_NAME)
)
try:
instance = daemon._unit_instance(repo_root)
except RuntimeError:
instance = f"{daemon.unit_name}@<escaped-repo-path>.service"
return (
f"codemod: no daemon serving {repo_root} (socket {socket_path}); "
"library mode, parsing on every invocation. "
f"Start it with --auto-start or systemctl --user start {instance}; "
"--no-daemon selects library mode without this warning."
)
def _roots_text(roots: tuple[str, ...]) -> str:
return ", ".join(roots) or "the whole repo"
def _folded_roots(repo_root: Path, include: list[str]) -> tuple[str, ...]:
"""*include* as the sorted repo-relative roots a workspace load stores.
Folds the way ``CodemodWorkspace.load`` does (an include naming the repo
root lifts the restriction, which a workspace stores as no roots). An
include the load would refuse is kept as spelled: it matches no daemon.
"""
from orchestrata_ultimate.refactor.codemod.workspace import _include_root
try:
folded = {_include_root(repo_root, subdir) for subdir in include}
except ValueError:
return tuple(sorted(set(include)))
return () if None in folded else tuple(sorted(r for r in folded if r))
@traces(spec="110", story="US-110-013", reqs=["REQ-110-140"])
def _connect_or_warn(
repo_root: Path, include: list[str] | None = None
) -> CodemodClient | None:
"""Auto-connect to the daemon serving *repo_root*; say so when none does.
Library mode answers (with one stderr line) when no daemon is listening,
and when one is but loaded other roots than the ``--include`` asked for:
a daemon never answers for roots it did not parse. Without ``--include``
the daemon's roots are the roots (CLI and daemon default alike).
"""
socket_path = _daemon_socket_path(repo_root)
client = _connect_daemon(socket_path)
# A server between its bind and its listen refuses connections on a socket
# file that exists: retry before taking it for a stale one.
for _ in range(3):
if client is not None or not socket_path.exists():
break
time.sleep(0.05)
client = _connect_daemon(socket_path)
if client is None:
print(_library_mode_warning(repo_root, socket_path), file=sys.stderr)
return None
if not include:
return client
# A daemon older than the ``include`` status field parsed the defaults.
served = tuple(sorted(set(client.status().get("include", _resolve_include(None)))))
asked = _folded_roots(repo_root, include)
if served != asked:
client.close()
print(
f"codemod: daemon serves {_roots_text(served)} but --include asks "
f"for {_roots_text(asked)}; library mode for this run.",
file=sys.stderr,
)
return None
return client
def _resolve_include(include: list[str] | None) -> tuple[str, ...]:
if include:
return tuple(include)
return ("src", "scripts", "tests")
def _load_workspace(args: argparse.Namespace) -> CodemodWorkspace:
"""Parse the repo into a workspace in this process (library mode)."""
from orchestrata_ultimate.refactor.codemod.workspace import CodemodWorkspace
return CodemodWorkspace.load(
args.repo.resolve(),
include=_resolve_include(args.include),
parallel=args.parallel,
)
_PASS_CLASSES = {
"replace-string": "ReplaceStringLiteralPass",
"add-import": "AddImportPass",
"remove-unused-import": "RemoveUnusedImportPass",
"docstring-sweep": "DocstringSweepPass",
"rename-symbol": "RenameSymbolPass",
}
def _pass_spec(name: str, args: dict[str, str]) -> tuple[str, dict[str, Any]]:
"""The pass CLASS name (as ``passes`` exports it) and its constructor kwargs.
The kwargs are JSON-serializable, so the same spec builds the pass in
this process (``_resolve_pass``) or inside the daemon (``run_pass``).
"""
cls_name = _PASS_CLASSES.get(name)
if cls_name is None:
print(
f"Error: unknown pass '{name}'. "
f"Available: {', '.join(sorted(_PASS_CLASSES))}",
file=sys.stderr,
)
sys.exit(2)
if name == "replace-string":
return cls_name, {
"old": args.get("old", ""),
"new": args.get("new", ""),
"regex": args.get("regex", "false").lower() == "true",
"keep_patterns": [p for p in args.get("keep", "").split(",") if p],
}
if name == "add-import":
return cls_name, {
"module": args.get("module", ""),
"name": args.get("name"),
"alias": args.get("alias"),
}
if name == "docstring-sweep":
replacements = {}
for k, v in args.items():
if k.startswith("old_"):
suffix = k[4:]
new_key = f"new_{suffix}"
if new_key in args:
replacements[v] = args[new_key]
if not replacements and "old" in args and "new" in args:
replacements[args["old"]] = args["new"]
return cls_name, {"replacements": replacements}
if name == "rename-symbol":
print(
"Error: rename-symbol requires a WorkspaceEditPlan (use Python API)",
file=sys.stderr,
)
sys.exit(2)
return cls_name, {}
def _resolve_pass(name: str, args: dict[str, str]) -> TransformPass:
from orchestrata_ultimate.refactor.codemod import passes
cls_name, kwargs = _pass_spec(name, args)
pass_cls: type[TransformPass] = getattr(passes, cls_name)
return pass_cls(**kwargs)
def _parse_args(raw_args: list[str] | None = None) -> dict[str, str]:
result: dict[str, str] = {}
if not raw_args:
return result
for arg in raw_args:
if "=" in arg:
k, v = arg.split("=", 1)
result[k] = v
return result
def _cmd_load_bench(args: argparse.Namespace) -> int:
from orchestrata_ultimate.refactor.codemod.workspace import CodemodWorkspace
root = args.repo.resolve()
include = _resolve_include(args.include)
t0 = time.monotonic()
CodemodWorkspace.load(root, include=include, parallel=False)
serial_time = time.monotonic() - t0
t0 = time.monotonic()
ws_parallel = CodemodWorkspace.load(root, include=include, parallel=True)
parallel_time = time.monotonic() - t0
stats = ws_parallel.stats()
cores = os.cpu_count() or 1
print(f"Load time (serial): {serial_time:.1f}s")
print(f"Load time (parallel): {parallel_time:.1f}s ({cores} cores)")
print(f"Files loaded: {stats.file_count}")
print(f"Total bytes: {stats.total_bytes:,}")
print(f"RSS delta: {stats.rss_delta_bytes / (1024**3):.2f} GiB")
print(f"Unparsable: {stats.unparsable_count}")
return 0
@traces(spec="110", story="US-110-013", reqs=["REQ-110-140"])
def _cmd_run(args: argparse.Namespace, client: CodemodClient | None = None) -> int:
"""``run``: in this process, or inside the daemon ``client`` is connected to."""
from orchestrata_ultimate.refactor.codemod.client import CodemodRpcError
pass_args = _parse_args(args.arg)
if client is None:
return _run_in_process(args, pass_args)
try:
return _run_via_daemon(args, client, pass_args)
except CodemodRpcError as e:
return _fail_run(e.rpc_message)
def _run_in_process(args: argparse.Namespace, pass_args: dict[str, str]) -> int:
"""Library mode: parse the repo, run, commit, all in this process."""
from orchestrata_ultimate.refactor.codemod.review_engine import (
InMemoryReviewEngine,
)
from orchestrata_ultimate.refactor.codemod.workspace import default_observers
ws = _load_workspace(args)
pass_instances = [_resolve_pass(pass_name, pass_args) for pass_name in args.passes]
# --observe: the five REQ-110-026 observers watch this run and commit
# (the workspace default stays "nobody", REQ-110-027).
observers = default_observers() if args.observe else None
try:
changes = ws.run(pass_instances, observers=observers)
try:
report = ws.commit(
changes,
dry_run=not args.apply,
observers=observers,
review_engine=InMemoryReviewEngine(baseline=args.baseline),
)
except Exception as exc:
return _fail_run(f"{type(exc).__name__}: {exc}")
finally:
# The debug panel holds open NDJSON handles; release them.
for observer in observers or ():
close = getattr(observer, "close", None)
if callable(close):
close()
return _finish_run(
args,
[p.name for p in pass_instances],
changes.file_count,
report,
)
def _run_via_daemon(
args: argparse.Namespace, client: CodemodClient, pass_args: dict[str, str]
) -> int:
"""Daemon mode: the passes stack into one run the daemon holds by reference.
The daemon restores its forest after a dry run, so this leaves the
served workspace as it found it.
"""
specs = [_pass_spec(pass_name, pass_args) for pass_name in args.passes]
run_id: str | None = None
names: list[str] = []
file_count = 0
for cls_name, kwargs in specs:
reply = client.run_pass(cls_name, kwargs, run_id=run_id, observe=args.observe)
run_id = reply["run_id"]
file_count = reply["file_count"]
names.append(reply["pass_name"])
assert run_id is not None # argparse requires at least one --pass
report = client.commit(
run_id,
dry_run=not args.apply,
baseline=args.baseline,
observe=args.observe,
)
return _finish_run(args, names, file_count, report)
def _fail_run(message: str) -> int:
"""The one ``Error:`` line a failed commit prints, in either mode."""
print(f"Error: {message}", file=sys.stderr)
return 1
def _finish_run(
args: argparse.Namespace,
pass_names: list[str],
files_mutated: int,
report: CommitReport,
) -> int:
"""Print the run summary, write the report file, return the exit code.
Both modes end here, so the two cannot drift.
"""
root = args.repo.resolve()
dry_run = not args.apply
correlation_id = (
f"codemod-{time.strftime('%Y-%m-%dT%H-%M-%S')}-{uuid.uuid4().hex[:8]}"
)
if report.diffstat:
print(report.diffstat)
error_count = len([f for f in report.findings if f.severity == "error"])
warn_count = len([f for f in report.findings if f.severity == "warning"])
print(f"Passes run: {', '.join(pass_names)}")
if dry_run:
print(f"Files mutated: {files_mutated}")
else:
print(f"Files written: {len(report.files_written)}")
print(f"Findings: {error_count} errors, {warn_count} warnings")
print(f"Duration: {report.duration_seconds:.1f}s")
if args.output:
report_path = args.output
else:
report_dir = root / "reports"
report_dir.mkdir(exist_ok=True)
report_path = report_dir / f"{correlation_id}.json"
report_path.parent.mkdir(parents=True, exist_ok=True)
payload = json.loads(report.to_json())
payload["baseline"] = args.baseline
report_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"Commit report: {report_path}")
# The exit code follows ``success`` in a dry run too: a review that
# aborts the commit is a failure whether or not --apply was given.
if not report.success or (error_count > 0 and not dry_run):
return 1
return 0
def _cmd_report(args: argparse.Namespace) -> int:
if not args.path.exists():
print(f"Error: file not found: {args.path}", file=sys.stderr)
return 2
data = json.loads(args.path.read_text(encoding="utf-8"))
passes = data.get("passes_run", [])
files = data.get("files_written", [])
findings = data.get("findings", [])
duration = data.get("duration_seconds", 0.0)
dry_run = data.get("dry_run", True)
correlation_id = data.get("correlation_id", "")
success = data.get("success", True)
diffstat = data.get("diffstat", "")
print(f"Passes run: {', '.join(passes) if passes else '(none)'}")
print(f"Files written: {len(files)}")
print(f"Duration: {duration:.1f}s")
print(f"Dry run: {dry_run}")
print(f"Success: {success}")
print(f"Correlation ID: {correlation_id}")
if diffstat:
print(f"\nDiff:\n{diffstat}")
if findings:
error_count = len([f for f in findings if f.get("severity") == "error"])
warn_count = len([f for f in findings if f.get("severity") == "warning"])
print(
f"\nFindings ({len(findings)}): {error_count} errors, {warn_count} warnings"
)
for f in findings:
line = f.get("line", "?")
print(
f" [{f.get('severity', '?')}] {f.get('rule', '?')} "
f"at {f.get('path', '?')}:{line} — {f.get('message', '?')}"
)
else:
print("\nNo findings.")
return 0
def _resolve_cig_node(cig: CodeIntelligenceGraph, module: str) -> int | None:
from orchestrata_ultimate.refactor.codemod.analysis.protocol import (
resolve_node_id,
)
return resolve_node_id(cig, module)
_ROUTED_QUERIES = ("blast_radius", "coupling_score", "abstraction_level")
_PASS_QUERIES = ("breaking_changes", "complexity", "test_coverage", "dead_code")
def _changes_since(ws: CodemodWorkspace, module: str, base: str) -> ChangeSet:
"""A one-entry ChangeSet: ``module`` at git ref ``base`` vs the loaded tree.
Raises:
ValueError: the module is not in the forest, or ``git show`` /
parsing of the base version fails.
"""
from orchestrata_ultimate.refactor.codemod.report import ChangeEntry, ChangeSet
from orchestrata_ultimate.refactor.ctok import LosslessModule
node_id = _resolve_cig_node(ws.cig, module)
path = ws.cig.nodes[node_id].path if node_id is not None else module
mutated = ws.forest.get(path)
if mutated is None:
raise ValueError(f"module '{module}' not found in the workspace")
shown = subprocess.run(
# ``./`` makes git resolve the path from the -C directory, not the
# repository top level (``--repo`` may be a subdirectory).
["git", "-C", str(ws.root), "show", f"{base}:./{path}"],
capture_output=True,
check=False,
)
if shown.returncode != 0:
detail = shown.stderr.decode("utf-8", "replace").strip()
raise ValueError(f"cannot read {path} at {base}: {detail}")
try:
original = LosslessModule.parse(shown.stdout)
except SyntaxError as exc:
raise ValueError(f"{path} at {base} does not parse: {exc}") from exc
entry = ChangeEntry(
path=path,
original_tree=original,
mutated_tree=mutated,
pass_name="working-tree-diff",
)
return ChangeSet(entries=(entry,))
def _run_analysis(
name: str, ws: CodemodWorkspace, module: str | None, args: argparse.Namespace
) -> object:
"""Run one analysis pass over the loaded workspace (never writes it)."""
from orchestrata_ultimate.refactor.codemod import analysis
from orchestrata_ultimate.refactor.codemod.report import ChangeSet
cig = ws.cig
if name == "blast_radius":
return analysis.BlastRadiusAnalyzer(cig).analyze(module, hops=args.hops)
if name == "coupling_score":
return analysis.CouplingAnalyzer(cig).analyze(module)
if name == "abstraction_level":
return analysis.AbstractionAnalyzer(cig).analyze(module)
if name == "complexity":
return analysis.ComplexityAnalyzer(cig, ws.forest).analyze(module)
if name == "test_coverage":
return analysis.TestCoverageMapper(cig).analyze(module)
if name == "dead_code":
return analysis.DeadCodeDetector(cig).analyze(module)
# breaking_changes: needs a ChangeSet; without --base there is no diff.
changes = (
_changes_since(ws, module, args.base) if module and args.base else ChangeSet()
)
return analysis.BreakingChangeDetector(cig).analyze(module, changes=changes)
def _module_paths(ws: CodemodWorkspace) -> list[str]:
return sorted(
node.path for node in ws.cig.nodes.values() if node.node_type == "Module"
)
def _run_all_passes(
ws: CodemodWorkspace, module: str | None, args: argparse.Namespace
) -> dict[str, object]:
"""The seven analyzers, keyed by analyzer name.
The module-scoped ones (blast_radius, coupling_score, abstraction_level)
run for ``module`` when given, else for every module (a mapping keyed by
path).
"""
results: dict[str, object] = {}
for name in (*_ROUTED_QUERIES, *_PASS_QUERIES):
if name in _ROUTED_QUERIES and module is None:
results[name] = {
path: _run_analysis(name, ws, path, args) for path in _module_paths(ws)
}
else:
results[name] = _run_analysis(name, ws, module, args)
return results
def _summarize(item: object) -> object:
"""Counts in place of the id/path collections of a BlastRadius."""
from orchestrata_ultimate.refactor.codemod._types import BlastRadius
if isinstance(item, BlastRadius):
return {
"affected_node_count": len(item.affected_nodes),
"affected_file_count": len(item.affected_files),
"impact_score": item.impact_score,
}
return item
def _encode_all(results: dict[str, object]) -> dict[str, object]:
from orchestrata_ultimate.refactor.codemod.analysis import AnalysisResultEncoder
encoded: dict[str, object] = {}
for name, result in results.items():
if isinstance(result, dict):
# The per-module mapping carries counts, never node-id lists: a
# whole-repo blast_radius id list was 147 MB on this repo.
encoded[name] = {
"type": "mapping",
"data": {
key: AnalysisResultEncoder.encode(_summarize(item))["data"]
for key, item in result.items()
},
}
else:
encoded[name] = AnalysisResultEncoder.encode(result)
return encoded
def _print_analysis_text(name: str, result: object, cig: CodeIntelligenceGraph) -> None:
"""Human-readable summary of one analysis result."""
from orchestrata_ultimate.refactor.codemod._types import (
ComplexityReport,
DeadCodeReport,
TestCoverageMap,
)
print(f"== {name} ==")
if isinstance(result, dict):
print(f" {len(result)} modules analyzed (use --json for the full mapping)")
elif isinstance(result, ComplexityReport):
print(f" Files: {result.file_count}")
print(f" Functions: {len(result.functions)}")
print(f" Max cyclomatic: {result.max_cyclomatic}")
print(f" Mean cyclomatic: {result.mean_cyclomatic:.2f}")
elif isinstance(result, TestCoverageMap):
print(f" Covered: {len(result.covered)}")
print(f" Uncovered: {len(result.uncovered)}")
print(f" Heuristic only: {len(result.heuristic_only)}")
print(f" Coverage ratio: {result.coverage_ratio:.2f}")
elif isinstance(result, DeadCodeReport):
print(f" Granularity: {result.granularity}")
print(f" Entry roots: {len(result.roots)}")
for nid in sorted(result.unreachable_modules):
node = cig.nodes.get(nid)
print(f" unreachable: {node.path if node else nid}")
elif isinstance(result, list):
print(f" Breaking changes: {len(result)}")
for item in result:
print(
f" {item.consumer_path}:{item.consumer_line} "
f"<- {item.change_path} ({item.incompatibility})"
)
else:
print(f" {result}")
def _cmd_analyze_passes(
args: argparse.Namespace, ws: CodemodWorkspace, module: str | None
) -> int:
"""The analysis-pass queries (REQ-110-031): print text or ``--json``."""
if args.query == "breaking_changes" and not (module and args.base):
print("Error: breaking_changes needs both --module and --base", file=sys.stderr)
return 2
try:
if args.query == "all-passes":
results = _run_all_passes(ws, module, args)
else:
results = {args.query: _run_analysis(args.query, ws, module, args)}
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if args.json:
encoded = _encode_all(results)
print(
json.dumps(encoded if args.query == "all-passes" else encoded[args.query])
)
return 0
for name, result in results.items():
_print_analysis_text(name, result, ws.cig)
return 0
def _print_routed(args: argparse.Namespace, result: object) -> None:
"""Render a blast_radius / coupling_score / abstraction_level result.
Shared by library and daemon mode: both hand it the same dataclasses.
"""
from orchestrata_ultimate.refactor.codemod._types import (
AbstractionLevel,
BlastRadius,
CouplingScore,
)
module = args.module
if args.json:
from orchestrata_ultimate.refactor.codemod.analysis import (
AnalysisResultEncoder,
)
print(json.dumps(AnalysisResultEncoder.encode(result)))
return
if isinstance(result, BlastRadius):
print(f"Blast radius of {module} ({args.hops} hops):")
print(f" Affected nodes: {len(result.affected_nodes)}")
print(f" Affected files: {len(result.affected_files)}")
print(f" Impact score: {result.impact_score:.2f}")
elif isinstance(result, CouplingScore):
print(f"Coupling score for {module}:")
print(f" Efferent: {result.efferent}")
print(f" Afferent: {result.afferent}")
print(f" Instability: {result.instability:.2f}")
elif isinstance(result, AbstractionLevel):
print(f"Abstraction level for {module}:")
print(f" Abstract: {result.abstract_count}")
print(f" Concrete: {result.concrete_count}")
print(f" Ratio: {result.ratio:.2f}")
NameOf = Callable[[int], "str | None"]
def _print_node_list(title: str, ids: list[int], name_of: NameOf) -> None:
print(title)
for node_id in ids:
name = name_of(node_id)
if name is not None:
print(f" - {name}")
def _print_cycles(cycles: list[list[int]], name_of: NameOf) -> None:
if not cycles:
print("No import cycles found.")
return
print(f"Import cycles found: {len(cycles)}")
for cycle in cycles:
names = [n for n in (name_of(nid) for nid in cycle) if n is not None]
print(f" {' -> '.join(names)}")
def _print_topological_order(order: list[int], name_of: NameOf) -> None:
print(f"Topological order ({len(order)} modules):")
for nid in order[:20]:
name = name_of(nid)
if name is not None:
print(f" - {name}")
if len(order) > 20:
print(f" ... and {len(order) - 20} more")
def _print_centrality(
results: list[tuple[int, float]], name_of: NameOf, top_k: int, exact: bool
) -> None:
mode = "exact" if exact else "approximate"
print(f"Betweenness centrality ({mode}, top {top_k}):")
for nid, centrality_score in results:
name = name_of(nid)
print(
f" {name if name is not None else f'node:{nid}'}: {centrality_score:.4f}"
)
class _UnknownModule(Exception):
"""``--module`` names no node of the code intelligence graph."""
class _LibraryQueries:
"""The graph queries of ``analyze``, answered from a workspace parsed here."""
def __init__(self, ws: CodemodWorkspace, args: argparse.Namespace) -> None:
self._ws = ws
self._cig = ws.cig
self._args = args
def _node(self, module: str) -> int:
node_id = _resolve_cig_node(self._cig, module)
if node_id is None:
raise _UnknownModule(module)
return node_id
def name_of(self, ids: list[int]) -> NameOf:
nodes = self._cig.nodes
return lambda nid: nodes[nid].qualified_name if nid in nodes else None
def dependencies(self, module: str) -> list[int]:
return self._cig.dependencies(self._node(module))
def dependents(self, module: str) -> list[int]:
return self._cig.dependents(self._node(module))
def cycles(self) -> list[list[int]]:
return self._cig.cycles()
def topological_order(self) -> list[int]:
return self._cig.topological_order()
def centrality(self) -> list[tuple[int, float]]:
return self._cig.centrality(top_k=self._args.top_k, exact=self._args.exact)
def impact_score(self, module: str) -> float:
return self._cig.pagerank_score(self._node(module))
def routed(self, module: str) -> object:
self._node(module)
return _run_analysis(self._args.query, self._ws, module, self._args)
class _DaemonQueries:
"""The same queries, answered by the daemon serving the repo."""
def __init__(self, client: CodemodClient, args: argparse.Namespace) -> None:
self._client = client
self._args = args
@staticmethod
def _query(module: str, call: Callable[[str], Any]) -> Any:
from orchestrata_ultimate.refactor.codemod.client import CodemodRpcError
try:
return call(module)
except CodemodRpcError as exc:
# -32602 on a query is the server's "Unknown node": the module
# key resolved to nothing, the refusal library mode raises itself.
if exc.code == -32602:
raise _UnknownModule(module) from exc
raise
def name_of(self, ids: list[int]) -> NameOf:
names = {
record["id"]: record["qualified_name"]
for record in self._client.resolve_nodes(ids)
}
return names.get
def dependencies(self, module: str) -> list[int]:
return self._query(module, self._client.query_dependencies)
def dependents(self, module: str) -> list[int]:
return self._query(module, self._client.query_dependents)
def cycles(self) -> list[list[int]]:
return self._client.query_cycles()
def topological_order(self) -> list[int]:
return self._client.query_topological_order()
def centrality(self) -> list[tuple[int, float]]:
return self._client.query_centrality(self._args.top_k, self._args.exact)
def impact_score(self, module: str) -> float:
return self._query(module, self._client.query_impact_score)
def routed(self, module: str) -> object:
from orchestrata_ultimate.refactor.codemod._types import (
AbstractionLevel,
BlastRadius,
CouplingScore,
)
query = self._args.query
if query == "blast_radius":
record = self._query(
module,
lambda m: self._client.query_blast_radius(m, self._args.hops),
)
return BlastRadius(
affected_nodes=frozenset(record["affected_nodes"]),
affected_files=frozenset(record["affected_files"]),
impact_score=record["impact_score"],
)
if query == "coupling_score":
return CouplingScore(
**self._query(module, self._client.query_coupling_score)
)
return AbstractionLevel(
**self._query(module, self._client.query_abstraction_level)
)
def _render_graph_query(
args: argparse.Namespace, queries: _LibraryQueries | _DaemonQueries
) -> None:
"""Print one non-pass ``analyze`` query; both modes share every line."""
query = args.query
module = args.module
if query in _ROUTED_QUERIES:
_print_routed(args, queries.routed(module))
elif query in ("dependencies", "dependents"):
ids = getattr(queries, query)(module)
label = "Dependencies" if query == "dependencies" else "Dependents"
_print_node_list(f"{label} of {module}:", ids, queries.name_of(ids))
elif query == "cycles":
cycles = queries.cycles()
_print_cycles(cycles, queries.name_of([nid for c in cycles for nid in c]))
elif query == "topological_order":
order = queries.topological_order()
_print_topological_order(order, queries.name_of(order[:20]))
elif query == "centrality":
results = queries.centrality()
_print_centrality(
results,
queries.name_of([nid for nid, _ in results]),
args.top_k,
args.exact,
)
elif query == "impact_score":
impact = queries.impact_score(module)
print(f"Impact score for {module}: {impact:.4f}")
_MODULE_QUERIES = (
"dependencies",
"dependents",
"blast_radius",
"coupling_score",
"abstraction_level",
"impact_score",
)
@traces(spec="110", story="US-110-013", reqs=["REQ-110-140"])
def _cmd_analyze(args: argparse.Namespace, client: CodemodClient | None = None) -> int:
"""``analyze``: graph queries from the daemon ``client`` is connected to, or
from a workspace parsed in this process (library mode)."""
query = args.query
module = args.module
if query in (*_PASS_QUERIES, "all-passes"):
# The analysis passes walk the parsed forest; the daemon exposes no
# method for them (S4 backlog), so they run here either way.
if client is not None:
print(
f"codemod: '{query}' has no daemon method; parsed locally",
file=sys.stderr,
)
return _cmd_analyze_passes(args, _load_workspace(args), module)
if query in _MODULE_QUERIES and not module:
print(f"Error: --module required for {query}", file=sys.stderr)
return 2
queries: _LibraryQueries | _DaemonQueries
if client is not None:
queries = _DaemonQueries(client, args)
else:
queries = _LibraryQueries(_load_workspace(args), args)
try:
_render_graph_query(args, queries)
except _UnknownModule:
print(f"Error: module '{module}' not found in CIG", file=sys.stderr)
return 1
return 0
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
try:
args = parser.parse_args(argv)
except SystemExit as e:
return 2 if e.code != 0 else 0
if args.auto_start and not args.no_daemon:
failure = _auto_start_daemon(args.repo.resolve())
if failure is not None:
print(failure, file=sys.stderr)
return 1
if args.command == "load-bench":
return _cmd_load_bench(args)
if args.command == "report":
return _cmd_report(args)
if args.command not in ("run", "analyze"):
return 2
# run / analyze: use the daemon serving the repo when one is running
# (REQ-110-140); otherwise parse in this process and say so. --no-daemon
# selects library mode quietly.
client = (
None if args.no_daemon else _connect_or_warn(args.repo.resolve(), args.include)
)
try:
if args.command == "run":
return _cmd_run(args, client)
return _cmd_analyze(args, client)
finally:
if client is not None:
client.close()
if __name__ == "__main__":
sys.exit(main())