From bbf24a98e56325d269f1770c7cca8565d6d086e1 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Tue, 30 Jun 2026 04:32:00 +0000 Subject: [PATCH 01/15] rpg_edit: Add a shared CoderMind Explain View renderer with `write_com --- CoderMind/scripts/check_code_gen.py | 50 +- CoderMind/scripts/common/paths.py | 2 + CoderMind/scripts/common/run_report.py | 478 ++++++++++++++++++++ CoderMind/scripts/plan.py | 114 ++++- CoderMind/scripts/rpg_edit/code.py | 10 + CoderMind/scripts/rpg_edit/locate.py | 11 +- CoderMind/scripts/rpg_edit/review.py | 174 ++++++- CoderMind/scripts/rpg_edit/validate.py | 14 +- CoderMind/scripts/rpg_encoder/run_encode.py | 60 ++- CoderMind/scripts/run_batch.py | 49 ++ CoderMind/scripts/smoke_test.py | 8 +- CoderMind/scripts/update_graphs.py | 65 ++- CoderMind/tests/test_rpg_edit_run_report.py | 86 ++++ CoderMind/tests/test_run_report.py | 83 ++++ 14 files changed, 1177 insertions(+), 27 deletions(-) create mode 100644 CoderMind/scripts/common/run_report.py create mode 100644 CoderMind/tests/test_rpg_edit_run_report.py create mode 100644 CoderMind/tests/test_run_report.py diff --git a/CoderMind/scripts/check_code_gen.py b/CoderMind/scripts/check_code_gen.py index f2165ec..387f439 100644 --- a/CoderMind/scripts/check_code_gen.py +++ b/CoderMind/scripts/check_code_gen.py @@ -13,9 +13,14 @@ import json import argparse +import sys from pathlib import Path from typing import Dict, Any, List, Tuple +SCRIPTS_DIR = Path(__file__).resolve().parent +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + # Import centralized paths and state loader from common.paths import ( TASKS_FILE, @@ -24,6 +29,7 @@ cmd_for, REPO_DIR, ) +from common.run_report import write_command_report from common.execution_state import load_code_gen_state from common.execution_state import load_code_gen_state as _load_state, save_code_gen_state as _save_state from common.execution_state import complete_batch as _complete_batch @@ -427,6 +433,43 @@ def determine_state( return result +def _write_code_gen_report(result: Dict[str, Any]) -> str | None: + try: + stats = result.get("stats") or {} + report_path = write_command_report( + "code_gen", + title="CoderMind code_gen Progress View", + status=result.get("type"), + summary_cards=[ + {"label": "state", "value": result.get("type", "")}, + {"label": "total", "value": stats.get("total_tasks", 0)}, + {"label": "completed", "value": stats.get("completed", 0)}, + {"label": "failed", "value": stats.get("failed", 0)}, + {"label": "remaining", "value": stats.get("remaining", 0)}, + {"label": "current batch", "value": (result.get("current_batch") or {}).get("batch_id", "")}, + {"label": "next batch", "value": result.get("next_batch", "")}, + ], + stages=[ + {"name": "determine_state", "status": result.get("type"), "reason": result.get("message", "")}, + {"name": "current_batch", "status": (result.get("current_batch") or {}).get("phase", "none"), "reason": (result.get("current_batch") or {}).get("file_path", "")}, + {"name": "next_action", "status": "available" if result.get("next_action") else "missing", "reason": result.get("next_action", "")}, + ], + artifacts={"tasks": TASKS_FILE, "code_gen_state": STATE_FILE}, + verification=[{"name": "state", "status": result.get("type"), "detail": result.get("message", "")}], + evidence={ + "type": result.get("type"), + "stats": stats, + "current_batch": result.get("current_batch"), + "next_batch": result.get("next_batch"), + "next_action": result.get("next_action"), + }, + ) + return str(report_path) + except Exception as exc: + result["report_error"] = str(exc) + return None + + def print_status(result: Dict[str, Any], json_output: bool = False) -> None: """Print the status in human-readable or JSON format.""" if json_output: @@ -474,7 +517,9 @@ def print_status(result: Dict[str, Any], json_output: bool = False) -> None: # Next batch info if result.get("next_batch"): print(f"\n Next Batch: {result['next_batch']}") - + if result.get("report_path"): + print(f"\n Report: {result['report_path']}") + # Guidance print("\n " + "─" * 60) @@ -516,6 +561,9 @@ def main(): args = parser.parse_args() result = determine_state(args.tasks, args.state) + report_path = _write_code_gen_report(result) + if report_path: + result["report_path"] = report_path print_status(result, json_output=args.json) # Return exit code based on state diff --git a/CoderMind/scripts/common/paths.py b/CoderMind/scripts/common/paths.py index bebd0d0..093e651 100644 --- a/CoderMind/scripts/common/paths.py +++ b/CoderMind/scripts/common/paths.py @@ -301,6 +301,8 @@ def cmd_for(script_relpath: str) -> str: RPG_EDIT_PLAN_FILE = DATA_DIR / "rpg_edit_plan.json" RPG_EDIT_IMPACT_FILE = DATA_DIR / "rpg_edit_impact.json" +RPG_EDIT_VALIDATE_FILE = DATA_DIR / "rpg_edit_validate.json" +RPG_EDIT_LOCATE_FILE = DATA_DIR / "rpg_edit_locate.json" RPG_EDIT_CODE_RESULT_FILE = DATA_DIR / "rpg_edit_code_result.json" RPG_EDIT_REVIEW_RESULT_FILE = DATA_DIR / "rpg_edit_review_result.json" diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py new file mode 100644 index 0000000..f100477 --- /dev/null +++ b/CoderMind/scripts/common/run_report.py @@ -0,0 +1,478 @@ +"""Shared HTML renderer for CoderMind command run reports.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Any, Mapping, Sequence +from urllib.parse import quote + +from common.paths import REPORTS_DIR + +_MAX_SUMMARY_CARDS = 7 + + +def write_command_report( + command: str | None = None, + payload: Any = None, + *, + summary_cards: Any = None, + summary: Any = None, + stages: Any = None, + timeline: Any = None, + rpg_nodes: Any = None, + dep_nodes: Any = None, + artifacts: Any = None, + artifact_links: Any = None, + verification: Any = None, + evidence: Any = None, + evidence_json: Any = None, + status: str | None = None, + title: str | None = None, + report_dir: str | Path | None = None, + timestamp: str | datetime | None = None, + **extra: Any, +) -> Path: + """Write a sanitized Explain View HTML report and return its path.""" + if isinstance(payload, Mapping): + extra = {**dict(payload), **extra} + elif payload is not None: + extra.setdefault("payload", payload) + if command is None: + command = str(extra.pop("command", extra.pop("command_name", "command"))) + summary_cards = summary_cards if summary_cards is not None else extra.pop("summary_cards", None) + summary = summary if summary is not None else extra.pop("summary", None) + stages = stages if stages is not None else extra.pop("stages", None) + timeline = timeline if timeline is not None else extra.pop("timeline", None) + rpg_nodes = rpg_nodes if rpg_nodes is not None else extra.pop("rpg_nodes", None) + dep_nodes = dep_nodes if dep_nodes is not None else extra.pop("dep_nodes", None) + artifacts = artifacts if artifacts is not None else extra.pop("artifacts", None) + artifact_links = artifact_links if artifact_links is not None else extra.pop("artifact_links", None) + verification = verification if verification is not None else extra.pop("verification", None) + evidence = evidence if evidence is not None else extra.pop("evidence", None) + evidence_json = evidence_json if evidence_json is not None else extra.pop("evidence_json", None) + status = status if status is not None else extra.pop("status", None) + title = title if title is not None else extra.pop("title", None) + report_dir = report_dir if report_dir is not None else extra.pop("report_dir", None) + timestamp = timestamp if timestamp is not None else extra.pop("timestamp", None) + summary_cards = summary_cards if summary_cards is not None else summary + stages = stages if stages is not None else timeline + artifacts = artifacts if artifacts is not None else artifact_links + evidence = evidence if evidence is not None else evidence_json + + if rpg_nodes is None: + rpg_nodes = extra.pop("rpg_node_evidence", None) or extra.pop("rpg_evidence", None) + if dep_nodes is None: + dep_nodes = extra.pop("dep_node_evidence", None) or extra.pop("dep_evidence", None) + if isinstance(evidence, Mapping): + if rpg_nodes is None: + rpg_nodes = _evidence_nodes(evidence.get("rpg_nodes")) or _evidence_nodes(evidence.get("rpg_node_evidence")) + if dep_nodes is None: + dep_nodes = _evidence_nodes(evidence.get("dep_nodes")) or _evidence_nodes(evidence.get("dep_node_evidence")) + + generated_at = _display_timestamp(timestamp) + filename_ts = _filename_timestamp(timestamp) + safe_command = _slug(command) + target_dir = Path(report_dir) if report_dir is not None else REPORTS_DIR + target_dir.mkdir(parents=True, exist_ok=True) + report_path = _unique_report_path(target_dir / f"cmind_run_{safe_command}_{filename_ts}.html") + + aggregate_evidence = { + "command": command, + "status": status, + "summary_cards": summary_cards, + "stages": stages, + "rpg_nodes": rpg_nodes, + "dep_nodes": dep_nodes, + "artifacts": artifacts, + "verification": verification, + "evidence": evidence, + } + for key, value in extra.items(): + aggregate_evidence[key] = value + + page_title = title or f"CoderMind {command} Explain View" + html = _render_page( + title=page_title, + command=command, + generated_at=generated_at, + status=status, + summary_cards=_normalize_cards(summary_cards), + stages=_normalize_stages(stages), + rpg_nodes=_normalize_nodes(rpg_nodes), + dep_nodes=_normalize_nodes(dep_nodes), + artifacts=_normalize_artifacts(artifacts), + verification=_normalize_verification(verification), + evidence=aggregate_evidence, + ) + report_path.write_text(html, encoding="utf-8") + return report_path + + +def _slug(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._-") + return slug or "command" + + +def _unique_report_path(path: Path) -> Path: + if not path.exists(): + return path + stem = path.stem + suffix = path.suffix + for index in range(2, 1000): + candidate = path.with_name(f"{stem}_{index}{suffix}") + if not candidate.exists(): + return candidate + return path.with_name(f"{stem}_{datetime.now(timezone.utc).strftime('%f')}{suffix}") + + +def _display_timestamp(value: str | datetime | None) -> str: + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat(timespec="seconds") + if value is not None: + return str(value) + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _filename_timestamp(value: str | datetime | None) -> str: + if isinstance(value, datetime): + raw = value.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + elif value is not None: + raw = str(value) + else: + raw = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return _slug(raw) + + +def _as_sequence(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, (str, bytes, Path)): + return [value] + if isinstance(value, Mapping): + return list(value.items()) + if isinstance(value, Sequence): + return list(value) + return [value] + + +def _normalize_cards(value: Any) -> list[dict[str, Any]]: + cards: list[dict[str, Any]] = [] + if isinstance(value, Mapping): + iterable = value.items() + else: + iterable = _as_sequence(value) + for item in iterable: + if isinstance(item, tuple) and len(item) == 2: + label, card_value = item + cards.append({"label": label, "value": card_value}) + elif isinstance(item, Mapping): + label = item.get("label") or item.get("title") or item.get("name") or item.get("key") or "Summary" + card_value = item.get("value", item.get("count", item.get("text", ""))) + detail = item.get("detail") or item.get("description") + cards.append({"label": label, "value": card_value, "detail": detail}) + else: + cards.append({"label": "Summary", "value": item}) + return cards[:_MAX_SUMMARY_CARDS] + + +def _normalize_stages(value: Any) -> list[dict[str, Any]]: + stages: list[dict[str, Any]] = [] + for item in _as_sequence(value): + if isinstance(item, tuple) and len(item) == 2: + name, state = item + stages.append({"name": name, "status": state}) + elif isinstance(item, Mapping): + name = item.get("name") or item.get("stage") or item.get("id") or "stage" + status = item.get("status") or item.get("state") or item.get("type") or item.get("action") or "recorded" + reason = item.get("reason") or item.get("message") or item.get("description") or "" + duration = item.get("duration") or item.get("elapsed") or item.get("elapsed_seconds") + stages.append({"name": name, "status": status, "reason": reason, "duration": duration}) + else: + stages.append({"name": item, "status": "recorded"}) + return stages + + +def _evidence_nodes(value: Any) -> Any: + if isinstance(value, Mapping): + return value + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, Path)): + return value + return None + + +def _normalize_nodes(value: Any) -> list[dict[str, Any]]: + nodes: list[dict[str, Any]] = [] + for item in _as_sequence(value): + if isinstance(item, tuple) and len(item) == 2: + node_id, node_value = item + if isinstance(node_value, Mapping): + entry = {"node_id": node_id, **dict(node_value)} + else: + entry = {"node_id": node_id, "value": node_value} + elif isinstance(item, Mapping): + entry = dict(item) + else: + entry = {"node_id": item} + nodes.append(entry) + return nodes + + +def _normalize_artifacts(value: Any) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + if isinstance(value, Mapping): + iterable = value.items() + else: + iterable = _as_sequence(value) + for item in iterable: + if isinstance(item, tuple) and len(item) == 2: + label, path = item + artifacts.append({"label": label, "path": path}) + elif isinstance(item, Mapping): + path = item.get("path") or item.get("href") or item.get("url") or item.get("file") + label = item.get("label") or item.get("name") or item.get("title") or path or "artifact" + artifacts.append({"label": label, "path": path, "status": item.get("status")}) + else: + artifacts.append({"label": Path(str(item)).name or "artifact", "path": item}) + return artifacts + + +def _normalize_verification(value: Any) -> list[dict[str, Any]]: + checks: list[dict[str, Any]] = [] + if isinstance(value, Mapping) and not any(isinstance(v, Mapping) for v in value.values()): + for key, check_value in value.items(): + checks.append({"name": key, "status": check_value}) + return checks + if isinstance(value, Mapping): + iterable = value.items() + else: + iterable = _as_sequence(value) + for item in iterable: + if isinstance(item, tuple) and len(item) == 2: + name, check_value = item + if isinstance(check_value, Mapping): + checks.append({"name": name, **dict(check_value)}) + else: + checks.append({"name": name, "status": check_value}) + elif isinstance(item, Mapping): + name = item.get("name") or item.get("check") or item.get("label") or "verification" + checks.append({"name": name, **dict(item)}) + else: + checks.append({"name": "verification", "status": item}) + return checks + + +def _render_page( + *, + title: str, + command: str, + generated_at: str, + status: str | None, + summary_cards: list[dict[str, Any]], + stages: list[dict[str, Any]], + rpg_nodes: list[dict[str, Any]], + dep_nodes: list[dict[str, Any]], + artifacts: list[dict[str, Any]], + verification: list[dict[str, Any]], + evidence: Mapping[str, Any], +) -> str: + status_html = f"{_h(status)}" if status else "" + return f""" + + + + +{_h(title)} + + + +
+
+

{_h(title)}

+
Command: {_h(command)}Generated: {_h(generated_at)}{status_html}
+
+{_render_summary_cards(summary_cards)} +{_render_timeline(stages)} +{_render_verification(verification)} +{_render_node_table("Focused RPG node evidence", rpg_nodes)} +{_render_node_table("Focused dependency node evidence", dep_nodes)} +{_render_artifacts(artifacts)} +{_render_evidence(evidence)} +
+ + +""" + + +def _render_summary_cards(cards: list[dict[str, Any]]) -> str: + if not cards: + body = "

No summary cards recorded.

" + else: + rendered_cards = [] + for card in cards: + detail = card.get("detail") + detail_html = "" + if detail is not None: + detail_html = f"
{_h(detail)}
" + value = card.get("value", "") + long_value = len(str(value)) > 48 + card_class = "card card-wide" if long_value else "card" + value_class = "card-value card-value-long" if long_value else "card-value" + rendered_cards.append( + f"
{_h(card.get('label', 'Summary'))}
" + f"
{_h(value)}
" + f"{detail_html}
" + ) + body = '
' + "".join(rendered_cards) + "
" + return f"

Summary

{body}
" + + +def _render_timeline(stages: list[dict[str, Any]]) -> str: + if not stages: + body = "

No stages recorded.

" + else: + items = [] + for stage in stages: + duration = stage.get("duration") + duration_text = f"{_h(duration)}s" if duration not in (None, "") else "" + items.append( + "
  • " + f"
    {_h(stage.get('name', 'stage'))}" + f"{_h(stage.get('status', 'recorded'))}{duration_text}
    " + f"
    {_h(stage.get('reason', ''))}
    " + "
  • " + ) + body = '
      ' + "".join(items) + "
    " + return f"

    Stage timeline

    {body}
    " + + +def _render_verification(checks: list[dict[str, Any]]) -> str: + if not checks: + body = "

    No verification status recorded.

    " + else: + rows = [] + for check in checks: + rows.append( + "" + f"{_h(check.get('name') or check.get('check') or 'verification')}" + f"{_h(check.get('status', check.get('success', '')))}" + f"{_h(check.get('detail') or check.get('message') or check.get('reason') or '')}" + "" + ) + body = "" + "".join(rows) + "
    CheckStatusDetail
    " + return f"

    Verification status

    {body}
    " + + +def _render_node_table(title: str, nodes: list[dict[str, Any]]) -> str: + if not nodes: + body = "

    No node evidence recorded.

    " + else: + rows = [] + for node in nodes: + node_id = node.get("node_id") or node.get("id") or node.get("dep_node") or "" + node_type = node.get("type_name") or node.get("node_type") or node.get("type") or "" + path = node.get("meta_path") or node.get("path") or node.get("file_path") or node.get("feature_path") or "" + score = node.get("score") or node.get("weight") or node.get("status") or "" + rows.append( + "" + f"{_h(node_id)}" + f"{_h(node.get('name', ''))}" + f"{_h(node_type)}" + f"{_h(path)}" + f"{_h(score)}" + "" + ) + body = "" + "".join(rows) + "
    IDNameTypePathScore/status
    " + return f"

    {_h(title)}

    {body}
    " + + +def _render_artifacts(artifacts: list[dict[str, Any]]) -> str: + if not artifacts: + body = "

    No artifact links recorded.

    " + else: + rows = [] + for artifact in artifacts: + path = artifact.get("path") + href = _artifact_href(path) + rows.append( + "" + f"{_h(artifact.get('label', 'artifact'))}" + f"{_h(path or '')}" + f"{_h(artifact.get('status', ''))}" + "" + ) + body = "" + "".join(rows) + "
    ArtifactPathStatus
    " + return f"

    Artifact links

    {body}
    " + + +def _render_evidence(evidence: Mapping[str, Any]) -> str: + data = json.dumps(evidence, indent=2, ensure_ascii=False, default=_json_default) + return f"
    Evidence JSON
    {_h(data)}
    " + + +def _artifact_href(path: Any) -> str: + if path is None: + return "#" + try: + return Path(str(path)).expanduser().resolve().as_uri() + except Exception: + return "file://" + quote(str(path), safe="/._-~") + + +def _json_default(value: Any) -> Any: + if isinstance(value, Path): + return str(value) + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "__dict__"): + return value.__dict__ + return str(value) + + +def _h(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list, tuple)): + value = json.dumps(value, ensure_ascii=False, default=_json_default) + return escape(str(value), quote=False) + + +def _h_attr(value: Any) -> str: + if value is None: + return "" + return escape(str(value), quote=True) diff --git a/CoderMind/scripts/plan.py b/CoderMind/scripts/plan.py index 90c2cd5..6a3a421 100644 --- a/CoderMind/scripts/plan.py +++ b/CoderMind/scripts/plan.py @@ -58,7 +58,19 @@ # Sub-scripts live in the same directory as this file (bundled under # cmind_cli/core_pack/scripts/ in the installed wheel). _SCRIPTS_DIR = Path(__file__).resolve().parent - +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from common.paths import ( + BASE_CLASSES_FILE, + DATA_FLOW_FILE, + DATA_FLOW_VIZ_FILE, + INTERFACES_FILE, + SKELETON_FILE, + SKELETON_SUMMARY_FILE, + TASKS_FILE, +) +from common.run_report import write_command_report # --------------------------------------------------------------------------- # Stage table — single source of truth for the pipeline. @@ -278,10 +290,7 @@ def decide(states: list[StageState], force: bool) -> None: state.reason = "up-to-date" else: state.will_run = True - if state.type == "warning": - state.reason = "warning: cross-stage contract violation; rebuild stage and downstream" - else: - state.reason = f"type={state.type}" + state.reason = f"type={state.type}" cascade = True @@ -292,6 +301,88 @@ def decide(states: list[StageState], force: bool) -> None: _GLYPH = {"update": "✓", "init": "·", "warning": "!", "error": "✗"} +def _stage_rows(states: list[StageState]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for state in states: + rows.append({ + "name": state.stage.name, + "status": "run" if state.will_run else "skip", + "type": state.type, + "done": state.done, + "reason": state.reason or state.message, + "message": state.message, + }) + return rows + + +def _plan_artifacts() -> list[dict[str, Any]]: + paths = { + "skeleton": SKELETON_FILE, + "skeleton_summary": SKELETON_SUMMARY_FILE, + "data_flow": DATA_FLOW_FILE, + "data_flow_viz": DATA_FLOW_VIZ_FILE, + "base_classes": BASE_CLASSES_FILE, + "interfaces": INTERFACES_FILE, + "tasks": TASKS_FILE, + } + return [ + {"label": label, "path": str(path), "status": "available" if path.exists() else "missing"} + for label, path in paths.items() + ] + + +def _write_plan_report( + states: list[StageState], + *, + mode: str, + elapsed: float | None = None, + post_steps: list[dict[str, Any]] | None = None, +) -> str | None: + try: + done = sum(1 for s in states if s.done) + runnable = sum(1 for s in states if s.will_run) + cards = [ + {"label": "mode", "value": mode}, + {"label": "stages", "value": len(states)}, + {"label": "done", "value": done}, + {"label": "to run", "value": runnable}, + {"label": "skipped", "value": len(states) - runnable}, + ] + if elapsed is not None: + cards.append({"label": "elapsed", "value": f"{elapsed:.1f}s"}) + if post_steps is not None: + cards.append({"label": "post steps", "value": len(post_steps)}) + timeline = _stage_rows(states) + for post in post_steps or []: + timeline.append({ + "name": post.get("name", "post-step"), + "status": "ok" if post.get("returncode") == 0 else "warning", + "reason": f"exit {post.get('returncode')}", + }) + report_path = write_command_report( + "plan", + title="CoderMind plan Explain View", + status=mode, + summary_cards=cards, + stages=timeline, + artifacts=_plan_artifacts(), + verification=[ + {"name": s.stage.name, "status": s.type, "detail": s.message or s.reason} + for s in states + ], + evidence={ + "mode": mode, + "elapsed": elapsed, + "stages": _stage_rows(states), + "post_steps": post_steps or [], + "artifacts": _plan_artifacts(), + }, + ) + return str(report_path) + except Exception: + return None + + def _format_table(states: list[StageState]) -> str: rows = ["Stage Type Done Action"] rows.append("-" * 50) @@ -322,6 +413,7 @@ def _emit_check_only_json(states: list[StageState]) -> None: done = sum(1 for s in states if s.done) total = len(states) next_pending = next((s.stage.name for s in states if not s.done), None) + report_path = _write_plan_report(states, mode="check-only") payload = { "total": total, "done": done, @@ -332,10 +424,14 @@ def _emit_check_only_json(states: list[StageState]) -> None: "type": s.type, "message": s.message, "done": s.done, + "will_run": s.will_run, + "reason": s.reason, } for s in states ], } + if report_path: + payload["report_path"] = report_path print(json.dumps(payload, indent=2)) @@ -436,6 +532,9 @@ def main(argv: Optional[list[str]] = None) -> int: _emit_check_only_json(states) else: _print_probe_summary(states) + report_path = _write_plan_report(states, mode="check-only") + if report_path: + print(f"Report: {report_path}") return 0 # --- Step: prerequisite check ----------------------------------------- @@ -521,15 +620,20 @@ def main(argv: Optional[list[str]] = None) -> int: # --- Step: post-pipeline helpers -------------------------------------- print() print("Running post-pipeline helpers ...") + post_results: list[dict[str, Any]] = [] for post in POST_STEPS: print(f"▶ {post}") rc = _run_stage(invoker, post, []) + post_results.append({"name": post, "returncode": rc}) if rc != 0: print(f" warning: {post} exited with {rc} (continuing)") total_elapsed = time.monotonic() - started + report_path = _write_plan_report(states, mode="complete", elapsed=total_elapsed, post_steps=post_results) print() print(f"Plan complete in {total_elapsed:.1f}s.") + if report_path: + print(f"Report: {report_path}") print("Next: `/cmind.code_gen` to generate source code.") print("Graph: see the 'Writing visualization to:' line above for the generated HTML path.") return 0 diff --git a/CoderMind/scripts/rpg_edit/code.py b/CoderMind/scripts/rpg_edit/code.py index c070be7..4c7e2f2 100644 --- a/CoderMind/scripts/rpg_edit/code.py +++ b/CoderMind/scripts/rpg_edit/code.py @@ -43,6 +43,7 @@ RPG_FILE, REPO_DIR, RPG_EDIT_PLAN_FILE, + RPG_EDIT_CODE_RESULT_FILE, DATA_DIR, WORKSPACE_ROOT, cmd_for, @@ -52,6 +53,14 @@ logger = logging.getLogger(__name__) +def _write_code_result(result: Dict[str, Any]) -> None: + RPG_EDIT_CODE_RESULT_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_CODE_RESULT_FILE.write_text( + json.dumps(result, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + # --------------------------------------------------------------------------- # Prompt templates # --------------------------------------------------------------------------- @@ -684,6 +693,7 @@ def main() -> int: max_iterations=args.max_iterations, timeout=args.timeout, ) + _write_code_result(result) if args.json: print(json.dumps(result, indent=2, ensure_ascii=False)) diff --git a/CoderMind/scripts/rpg_edit/locate.py b/CoderMind/scripts/rpg_edit/locate.py index 082b819..43492c6 100644 --- a/CoderMind/scripts/rpg_edit/locate.py +++ b/CoderMind/scripts/rpg_edit/locate.py @@ -20,7 +20,15 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE # noqa: E402 +from common.paths import REPO_RPG_FILE, RPG_EDIT_LOCATE_FILE # noqa: E402 + + +def _write_locate_result(result: dict) -> None: + RPG_EDIT_LOCATE_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_LOCATE_FILE.write_text( + json.dumps(result, indent=2, ensure_ascii=False), + encoding="utf-8", + ) def _build_tree_summary(svc, max_depth: int = 3, max_lines: int = 150) -> List[str]: @@ -198,6 +206,7 @@ def main(): # when search results are poor (e.g. editing features that don't exist yet). tree_lines = _build_tree_summary(svc) output["tree_summary"] = tree_lines + _write_locate_result(output) if args.json: print(json.dumps(output, indent=2, ensure_ascii=False)) diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 85b8386..45af3ce 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -34,10 +34,161 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_DIR, cmd_for, RPG_EDIT_PLAN_FILE, RPG_EDIT_IMPACT_FILE # noqa: E402 +from common.paths import ( # noqa: E402 + REPO_DIR, + cmd_for, + RPG_EDIT_PLAN_FILE, + RPG_EDIT_IMPACT_FILE, + RPG_EDIT_VALIDATE_FILE, + RPG_EDIT_LOCATE_FILE, + RPG_EDIT_CODE_RESULT_FILE, + RPG_EDIT_REVIEW_RESULT_FILE, +) +from common.run_report import write_command_report # noqa: E402 logger = logging.getLogger(__name__) + +def _write_review_result(result: Dict[str, Any]) -> None: + RPG_EDIT_REVIEW_RESULT_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_REVIEW_RESULT_FILE.write_text( + json.dumps(result, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def _load_json_artifact(path: Optional[Path]) -> Any: + if path is None or not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + return {"_error": str(exc), "_path": str(path)} + + +def _load_review_artifacts(plan_path: Path, impact_path: Optional[Path]) -> Dict[str, Any]: + return { + "validate": _load_json_artifact(RPG_EDIT_VALIDATE_FILE), + "locate": _load_json_artifact(RPG_EDIT_LOCATE_FILE), + "plan": _load_json_artifact(plan_path), + "impact": _load_json_artifact(impact_path), + "code_result": _load_json_artifact(RPG_EDIT_CODE_RESULT_FILE), + } + + +def _artifact_links(plan_path: Path, impact_path: Optional[Path]) -> List[Dict[str, Any]]: + paths = { + "validate": RPG_EDIT_VALIDATE_FILE, + "locate": RPG_EDIT_LOCATE_FILE, + "plan": plan_path, + "impact": impact_path, + "code_result": RPG_EDIT_CODE_RESULT_FILE, + "review_result": RPG_EDIT_REVIEW_RESULT_FILE, + } + links: List[Dict[str, Any]] = [] + for label, path in paths.items(): + if path is None: + continue + status = "available" if path.exists() or label == "review_result" else "missing" + links.append({"label": label, "path": str(path), "status": status}) + return links + + +def _selected_candidate_rows(artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: + locate = artifacts.get("locate") if isinstance(artifacts.get("locate"), dict) else {} + plan = artifacts.get("plan") if isinstance(artifacts.get("plan"), dict) else {} + affected = set(plan.get("affected_nodes") or []) + candidates = locate.get("results") or [] + if affected: + candidates = [c for c in candidates if c.get("node_id") in affected] + return [c for c in candidates if isinstance(c, dict)] + + +def _dep_node_rows(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for candidate in candidates: + for dep_id in candidate.get("dep_nodes") or []: + rows.append({ + "node_id": dep_id, + "source_feature": candidate.get("node_id"), + "path": candidate.get("meta_path"), + }) + return rows + + +def _review_summary_cards(result: Dict[str, Any], artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: + plan = artifacts.get("plan") if isinstance(artifacts.get("plan"), dict) else {} + locate = artifacts.get("locate") if isinstance(artifacts.get("locate"), dict) else {} + code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else {} + return [ + {"label": "review", "value": result.get("type", "review")}, + {"label": "success", "value": result.get("success", result.get("type") == "skipped")}, + {"label": "iterations", "value": len(result.get("iterations") or [])}, + {"label": "files", "value": len(code_result.get("files_modified") or [])}, + {"label": "candidates", "value": len(locate.get("results") or [])}, + {"label": "affected nodes", "value": len(plan.get("affected_nodes") or [])}, + {"label": "suggestions", "value": len(result.get("suggestions") or [])}, + ] + + +def _review_timeline(result: Dict[str, Any], artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: + validate = artifacts.get("validate") if isinstance(artifacts.get("validate"), dict) else None + locate = artifacts.get("locate") if isinstance(artifacts.get("locate"), dict) else None + plan = artifacts.get("plan") if isinstance(artifacts.get("plan"), dict) else None + impact = artifacts.get("impact") if isinstance(artifacts.get("impact"), dict) else None + code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else None + return [ + {"name": "validate", "status": validate.get("type") if validate else "missing", "reason": validate.get("message", "") if validate else "artifact not found"}, + {"name": "locate", "status": locate.get("type") if locate else "missing", "reason": f"{len(locate.get('results') or [])} candidates" if locate else "artifact not found"}, + {"name": "plan", "status": "available" if plan else "missing", "reason": f"{len(plan.get('code_changes') or [])} code changes" if plan else "artifact not found"}, + {"name": "impact", "status": impact.get("type", "available") if impact else "missing", "reason": f"{len((impact.get('results') or {}))} impact result sets" if impact else "artifact not found"}, + {"name": "code", "status": code_result.get("last_status") if code_result else "missing", "reason": code_result.get("last_error") or f"success={code_result.get('success')}" if code_result else "artifact not found"}, + {"name": "review", "status": result.get("type"), "reason": result.get("reason") or f"success={result.get('success')}"}, + ] + + +def _review_verification(result: Dict[str, Any], artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: + checks: List[Dict[str, Any]] = [] + validate = artifacts.get("validate") if isinstance(artifacts.get("validate"), dict) else None + code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else None + if validate: + checks.append({"name": "validate", "status": validate.get("type"), "detail": validate.get("message", "")}) + if code_result: + checks.append({"name": "code", "status": code_result.get("success"), "detail": code_result.get("last_error") or code_result.get("last_status")}) + checks.append({"name": "review", "status": result.get("success", result.get("type") == "skipped"), "detail": result.get("reason") or result.get("type")}) + for iteration in result.get("iterations") or []: + checks.append({ + "name": f"review iteration {iteration.get('iteration')}", + "status": iteration.get("post_pytest_passed"), + "detail": iteration.get("agent_detail", ""), + }) + return checks + + +def _publish_review_report(result: Dict[str, Any], plan_path: Path, impact_path: Optional[Path]) -> Dict[str, Any]: + _write_review_result(result) + artifacts = _load_review_artifacts(plan_path, impact_path) + candidates = _selected_candidate_rows(artifacts) + try: + report_path = write_command_report( + "rpg_edit", + title="CoderMind rpg_edit Explain View", + status=str(result.get("type", "review")), + summary_cards=_review_summary_cards(result, artifacts), + stages=_review_timeline(result, artifacts), + rpg_nodes=candidates, + dep_nodes=_dep_node_rows(candidates), + artifacts=_artifact_links(plan_path, impact_path), + verification=_review_verification(result, artifacts), + evidence={"artifacts": artifacts, "review_result": result}, + ) + result["report_path"] = str(report_path) + except Exception as exc: + result["report_error"] = str(exc) + _write_review_result(result) + return result + + # --------------------------------------------------------------------------- # Review prompt template # --------------------------------------------------------------------------- @@ -546,7 +697,7 @@ def impact_review( all_suggestions.append(s) if all_suggestions: results["suggestions"] = all_suggestions - return results + return _publish_review_report(results, plan_path, impact_path) # --------------------------------------------------------------------------- @@ -583,6 +734,7 @@ def main(): if not args.plan.exists(): result = {"type": "error", "message": f"Plan not found: {args.plan}"} + result = _publish_review_report(result, args.plan, args.impact) print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 @@ -599,12 +751,14 @@ def main(): if total_callers == 0 and affected_files <= 1: result = { "type": "skipped", + "success": True, "reason": f"Impact too small for sub-agent review " f"(callers={total_callers}, files={affected_files}). " f"Agent self-review is sufficient.", } + result = _publish_review_report(result, args.plan, args.impact) print(json.dumps(result, indent=2) if args.json else - f"Skipped: {result['reason']}") + f"Skipped: {result['reason']}\nReport: {result.get('report_path', '')}") return 0 result = impact_review( @@ -615,10 +769,16 @@ def main(): timeout=args.timeout, ) - print(json.dumps(result, indent=2) if args.json else - f"Review {'PASSED' if result['success'] else 'FAILED'} " - f"({len(result['iterations'])} iterations, " - f"{result['total_duration']:.1f}s)") + if args.json: + print(json.dumps(result, indent=2)) + else: + print( + f"Review {'PASSED' if result['success'] else 'FAILED'} " + f"({len(result['iterations'])} iterations, " + f"{result['total_duration']:.1f}s)" + ) + if result.get("report_path"): + print(f"Report: {result['report_path']}") return 0 if result["success"] else 1 diff --git a/CoderMind/scripts/rpg_edit/validate.py b/CoderMind/scripts/rpg_edit/validate.py index e52f7b0..d098fee 100644 --- a/CoderMind/scripts/rpg_edit/validate.py +++ b/CoderMind/scripts/rpg_edit/validate.py @@ -12,7 +12,15 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE # noqa: E402 +from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, RPG_EDIT_VALIDATE_FILE # noqa: E402 + + +def _write_validate_result(result: dict) -> None: + RPG_EDIT_VALIDATE_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_VALIDATE_FILE.write_text( + json.dumps(result, indent=2, ensure_ascii=False), + encoding="utf-8", + ) def main(): @@ -31,6 +39,7 @@ def main(): if not args.rpg.exists(): result = {"type": "error", "error_code": "rpg_not_found", "message": f"RPG file not found: {args.rpg}"} + _write_validate_result(result) print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 @@ -40,6 +49,7 @@ def main(): except Exception as e: result = {"type": "error", "error_code": "rpg_load_failed", "message": f"Failed to load RPG: {e}"} + _write_validate_result(result) print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 @@ -52,6 +62,7 @@ def main(): "Run /cmind.encode to (re)build it; the embedded " "dep_graph rides inside rpg.json." )} + _write_validate_result(result) print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 @@ -64,6 +75,7 @@ def main(): "dep_to_rpg": len(svc.rpg._dep_to_rpg_map), "feature_to_dep": len(svc.rpg._feature_to_dep_map), } + _write_validate_result(result) print(json.dumps(result, indent=2) if args.json else f"Ready: {result['nodes']} nodes, dep_graph={'yes' if has_dep_graph else 'no'}") return 0 diff --git a/CoderMind/scripts/rpg_encoder/run_encode.py b/CoderMind/scripts/rpg_encoder/run_encode.py index d9ed2a0..f159dfb 100644 --- a/CoderMind/scripts/rpg_encoder/run_encode.py +++ b/CoderMind/scripts/rpg_encoder/run_encode.py @@ -27,9 +27,52 @@ from common.paths import RPG_FILE, RPG_HTML_FILE, WORKSPACE_ROOT, ensure_cmind_dir # noqa: E402 from common.rpg_io import atomic_write_rpg # noqa: E402 +from common.run_report import write_command_report # noqa: E402 from common.trajectory import Trajectory # noqa: E402 +def _attach_encode_report(result: dict) -> dict: + try: + dep_summary = [] + if result.get("dep_nodes") is not None: + dep_summary.append(f"nodes={result.get('dep_nodes')}") + if result.get("dep_edges") is not None: + dep_summary.append(f"edges={result.get('dep_edges')}") + if result.get("dep_to_rpg_map_size") is not None: + dep_summary.append(f"mapped={result.get('dep_to_rpg_map_size')}") + report_path = write_command_report( + "encode", + title="CoderMind encode Explain View", + status=result.get("status"), + summary_cards=[ + {"label": "repo", "value": result.get("repo_name", "unknown")}, + {"label": "RPG nodes", "value": result.get("node_count", 0)}, + {"label": "RPG edges", "value": result.get("edge_count", 0)}, + {"label": "dep graph", "value": ", ".join(dep_summary) or "not recorded"}, + {"label": "output", "value": result.get("output_path", "")}, + {"label": "visualization", "value": result.get("viz_path", result.get("viz_error", ""))}, + {"label": "trajectory", "value": result.get("trajectory", "")}, + ], + stages=[ + {"name": "parse_rpg", "status": "recorded", "reason": f"nodes={result.get('node_count', 0)}, edges={result.get('edge_count', 0)}"}, + {"name": "dep_graph", "status": "recorded" if dep_summary else "not recorded", "reason": ", ".join(dep_summary)}, + {"name": "save_rpg", "status": "recorded" if result.get("output_path") else "not recorded", "reason": result.get("output_path", "")}, + {"name": "visualize", "status": "recorded" if result.get("viz_path") else "not recorded", "reason": result.get("viz_path") or result.get("viz_error", "")}, + ], + artifacts={ + "rpg_json": result.get("output_path"), + "rpg_html": result.get("viz_path"), + "trajectory": result.get("trajectory"), + }, + verification={"encode": result.get("status")}, + evidence=result, + ) + result["report_path"] = str(report_path) + except Exception as exc: + result["report_error"] = str(exc) + return result + + def run_encode( repo_dir: str | None = None, repo_name: str | None = None, @@ -54,7 +97,12 @@ def run_encode( repo_dir = os.path.abspath(repo_dir) if not os.path.isdir(repo_dir): - return {"status": "error", "error": f"Repository directory not found: {repo_dir}"} + return _attach_encode_report({ + "status": "error", + "error": f"Repository directory not found: {repo_dir}", + "repo_name": repo_name or os.path.basename(repo_dir) or "unknown", + "output_path": output or str(RPG_FILE), + }) if repo_name is None: repo_name = os.path.basename(repo_dir) or "unknown" @@ -188,12 +236,18 @@ def run_encode( traj.complete(stats) stats["trajectory"] = str(traj.trajectory_file) - return {"status": "success", **stats} + return _attach_encode_report({"status": "success", **stats}) except Exception as exc: logger.exception("Encoding failed: %s", exc) traj.fail(str(exc)) - return {"status": "error", "error": str(exc), "trajectory": str(traj.trajectory_file)} + return _attach_encode_report({ + "status": "error", + "error": str(exc), + "repo_name": repo_name, + "output_path": output, + "trajectory": str(traj.trajectory_file), + }) def main(): diff --git a/CoderMind/scripts/run_batch.py b/CoderMind/scripts/run_batch.py index d2ed385..d31555c 100644 --- a/CoderMind/scripts/run_batch.py +++ b/CoderMind/scripts/run_batch.py @@ -65,6 +65,7 @@ cmd_for, REPO_DIR, ) +from common.run_report import write_command_report from code_gen.context_collector import build_dependency_context from code_gen.prompts import ( build_test_prompt_from_batch, @@ -923,8 +924,53 @@ def run_batch( # CLI # ============================================================================ +def _write_batch_report(result: Dict[str, Any]) -> Optional[str]: + if result.get("type") not in {"batch_complete", "batch_failed", "final_test", "complete"}: + return None + try: + stats = result.get("stats") or {} + report_path = write_command_report( + "code_gen", + title="CoderMind code_gen Batch View", + status=result.get("type"), + summary_cards=[ + {"label": "result", "value": result.get("type", "")}, + {"label": "success", "value": result.get("success", "")}, + {"label": "batch", "value": result.get("batch_id", "")}, + {"label": "attempts", "value": result.get("attempts_used", "")}, + {"label": "duration", "value": f"{result.get('total_duration', 0):.1f}s" if "total_duration" in result else ""}, + {"label": "completed", "value": stats.get("completed", "")}, + {"label": "failed", "value": stats.get("failed", result.get("failed", ""))}, + ], + stages=[ + {"name": "batch", "status": result.get("type"), "reason": result.get("failure_reason") or result.get("message", "")}, + {"name": "verification", "status": result.get("success"), "reason": f"passed={result.get('passed', '')} failed={result.get('failed', '')} errors={result.get('errors', '')}"}, + {"name": "next_action", "status": "available" if result.get("next_action") else "missing", "reason": result.get("next_action", "")}, + ], + artifacts={ + "feature_spec": FEATURE_SPEC_FILE, + "tasks": TASKS_FILE, + "code_gen_state": STATE_FILE, + "rpg_json": REPO_RPG_FILE, + }, + verification=[ + {"name": "result", "status": result.get("success", result.get("type"))}, + {"name": "pytest", "status": result.get("passed", ""), "detail": f"failed={result.get('failed', '')}, errors={result.get('errors', '')}"}, + ], + evidence={"result": result}, + ) + return str(report_path) + except Exception as exc: + result["report_error"] = str(exc) + return None + + def print_result(result: Dict[str, Any], json_output: bool = False) -> None: """Print result to stdout and log it.""" + report_path = _write_batch_report(result) + if report_path: + result["report_path"] = report_path + # Always log the result as JSON for the file log logger.info("Batch result: %s", json.dumps(result, indent=2)) @@ -962,6 +1008,9 @@ def print_result(result: Dict[str, Any], json_output: bool = False) -> None: if "next_action" in result: print(f"\n -> {result['next_action']}") + if result.get("report_path"): + print(f"\n Report: {result['report_path']}") + def main() -> int: # Convert SIGTERM → SystemExit so "except BaseException" in Popen calls diff --git a/CoderMind/scripts/smoke_test.py b/CoderMind/scripts/smoke_test.py index 71e9c4d..2f91e05 100644 --- a/CoderMind/scripts/smoke_test.py +++ b/CoderMind/scripts/smoke_test.py @@ -160,7 +160,13 @@ def _find_source_files(repo_path: Path) -> List[Path]: def _run_in_repo(repo_path: Path, cmd: List[str], timeout: int = 30) -> subprocess.CompletedProcess: """Run a command in the repo directory with the dev venv.""" env = os.environ.copy() - env["PYTHONPATH"] = str(repo_path) + python_path = [str(repo_path)] + scripts_dir = repo_path / "scripts" + if scripts_dir.is_dir(): + python_path.append(str(scripts_dir)) + if env.get("PYTHONPATH"): + python_path.append(env["PYTHONPATH"]) + env["PYTHONPATH"] = os.pathsep.join(python_path) # Suppress interactive prompts env["PYTHONDONTWRITEBYTECODE"] = "1" return subprocess.run( diff --git a/CoderMind/scripts/update_graphs.py b/CoderMind/scripts/update_graphs.py index b9964b3..4795423 100644 --- a/CoderMind/scripts/update_graphs.py +++ b/CoderMind/scripts/update_graphs.py @@ -34,6 +34,7 @@ from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, HOOK_CALLS_LOG # noqa: E402 from common.rpg_io import atomic_write_rpg, safe_load_rpg # noqa: E402 +from common.run_report import write_command_report # noqa: E402 # Shared message used by every subcommand that requires an existing @@ -79,6 +80,49 @@ def _log_hook_call(hook_type: str, result: dict) -> None: pass +def _change_count(value: object) -> int: + if isinstance(value, int): + return value + if isinstance(value, (list, tuple, set, dict)): + return len(value) + return 0 + + +def _attach_update_report(result: dict) -> dict: + try: + changed_total = sum( + _change_count(result.get(key)) + for key in ("modified", "added", "deleted", "renamed") + ) + viz_status = result.get("viz_error") or ("ok" if result.get("viz_path") else "not recorded") + report_path = write_command_report( + "update_rpg", + title="CoderMind update_rpg Explain View", + status=result.get("mode") or result.get("status"), + summary_cards=[ + {"label": "mode", "value": result.get("mode", "")}, + {"label": "reason", "value": result.get("reason") or result.get("error", "")}, + {"label": "changed files", "value": changed_total}, + {"label": "RPG nodes", "value": result.get("rpg_nodes", "")}, + {"label": "dep nodes", "value": result.get("dep_nodes", "")}, + {"label": "dep edges", "value": result.get("dep_edges", "")}, + {"label": "visualization", "value": result.get("viz_path") or result.get("viz_error", "")}, + ], + stages=[ + {"name": "git delta", "status": result.get("mode", ""), "reason": f"{changed_total} changed files"}, + {"name": "sync graph", "status": result.get("status", result.get("mode", "")), "reason": result.get("reason", "")}, + {"name": "visualize", "status": "ok" if result.get("viz_path") else "error" if result.get("viz_error") else "skipped", "reason": result.get("viz_path") or result.get("viz_error", "")}, + ], + artifacts={"rpg_json": result.get("rpg_path"), "rpg_html": result.get("viz_path")}, + verification={"update_rpg": result.get("status", result.get("mode")), "viz": viz_status}, + evidence=result, + ) + result["report_path"] = str(report_path) + except Exception as exc: + result["report_error"] = str(exc) + return result + + def _refresh_rpg_html(rpg_path: Path) -> dict: """Regenerate ``rpg.html`` next to ``rpg.json`` after a hook update. @@ -364,12 +408,12 @@ def cmd_sync( # instead so the hook log shows exactly what's wrong and how to fix # it. if not rpg_path.is_file(): - return { + return _attach_update_report({ "mode": "sync", "error": _RPG_MISSING_MSG.format(rpg_path=rpg_path), "rpg_path": str(rpg_path), "duration": round(time.time() - t0, 3), - } + }) svc = RPGService.load(str(rpg_path)) @@ -427,6 +471,7 @@ def cmd_sync( "viz_error": viz_result.get("viz_error"), "duration": round(time.time() - t0, 3), } + _attach_update_report(sync_out) _log_hook_call("sync", sync_out) return sync_out @@ -458,11 +503,11 @@ def cmd_update_rpg( t0 = time.time() if not rpg_path.is_file(): - return { + return _attach_update_report({ "mode": "update-rpg", "error": _RPG_MISSING_MSG.format(rpg_path=rpg_path), "rpg_path": str(rpg_path), - } + }) # Check git has enough history try: @@ -472,10 +517,11 @@ def cmd_update_rpg( stderr=subprocess.DEVNULL, ).decode().strip() except (subprocess.CalledProcessError, FileNotFoundError): - return { + return _attach_update_report({ "mode": "update-rpg", "error": "Need at least 2 commits for incremental update (no HEAD~1)", - } + "rpg_path": str(rpg_path), + }) # Prune orphaned worktrees from previous runs that were killed. subprocess.call( @@ -495,10 +541,12 @@ def cmd_update_rpg( text=True, ) if wt_proc.returncode != 0: - return { + return _attach_update_report({ "mode": "update-rpg", "error": f"git worktree add failed for {prev_ref}: {wt_proc.stderr.strip()}", - } + "rpg_path": str(rpg_path), + "prev_ref": prev_ref, + }) from rpg_encoder.run_update_rpg import run_update_rpg @@ -524,6 +572,7 @@ def cmd_update_rpg( result["viz_error"] = viz_result["viz_error"] result["duration"] = round(time.time() - t0, 3) + _attach_update_report(result) _log_hook_call("update-rpg", result) return result diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py new file mode 100644 index 0000000..bdaab5a --- /dev/null +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_SCRIPTS = _REPO / "scripts" +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + + +def _load_script(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def test_validate_and_locate_results_are_persisted(tmp_path: Path, monkeypatch) -> None: + validate = _load_script("rpg_edit_validate_test", _SCRIPTS / "rpg_edit" / "validate.py") + locate = _load_script("rpg_edit_locate_test", _SCRIPTS / "rpg_edit" / "locate.py") + + validate_path = tmp_path / "rpg_edit_validate.json" + locate_path = tmp_path / "rpg_edit_locate.json" + monkeypatch.setattr(validate, "RPG_EDIT_VALIDATE_FILE", validate_path) + monkeypatch.setattr(locate, "RPG_EDIT_LOCATE_FILE", locate_path) + + validate._write_validate_result({"type": "ready", "nodes": 2}) + locate._write_locate_result({"type": "candidates", "results": [{"node_id": "n1"}]}) + + assert json.loads(validate_path.read_text(encoding="utf-8"))["type"] == "ready" + assert json.loads(locate_path.read_text(encoding="utf-8"))["results"][0]["node_id"] == "n1" + + +def test_code_result_is_persisted(tmp_path: Path, monkeypatch) -> None: + code = _load_script("rpg_edit_code_test", _SCRIPTS / "rpg_edit" / "code.py") + + result_path = tmp_path / "rpg_edit_code_result.json" + monkeypatch.setattr(code, "RPG_EDIT_CODE_RESULT_FILE", result_path) + + code._write_code_result({"success": True, "commit_sha": "abc123"}) + + data = json.loads(result_path.read_text(encoding="utf-8")) + assert data == {"success": True, "commit_sha": "abc123"} + + +def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) -> None: + review = _load_script("rpg_edit_review_test", _SCRIPTS / "rpg_edit" / "review.py") + + validate_path = tmp_path / "validate.json" + locate_path = tmp_path / "locate.json" + plan_path = tmp_path / "plan.json" + impact_path = tmp_path / "impact.json" + code_path = tmp_path / "code.json" + review_path = tmp_path / "review.json" + report_path = tmp_path / "report.html" + + validate_path.write_text(json.dumps({"type": "ready"}), encoding="utf-8") + locate_path.write_text(json.dumps({"type": "candidates", "results": [{"node_id": "n1", "name": "Node", "score": 1.0, "dep_nodes": ["a.py:f"]}]}), encoding="utf-8") + plan_path.write_text(json.dumps({"affected_nodes": ["n1"], "code_changes": [{"file_path": "a.py"}]}), encoding="utf-8") + impact_path.write_text(json.dumps({"type": "impact", "results": {"n1": {}}}), encoding="utf-8") + code_path.write_text(json.dumps({"success": True, "files_modified": ["a.py"], "last_status": "complete"}), encoding="utf-8") + + monkeypatch.setattr(review, "RPG_EDIT_VALIDATE_FILE", validate_path) + monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) + monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) + monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) + + def fake_write_command_report(*args, **kwargs): + review_artifact = next( + item for item in kwargs["artifacts"] if item["label"] == "review_result" + ) + assert review_artifact["status"] == "available" + return report_path + + monkeypatch.setattr(review, "write_command_report", fake_write_command_report) + + result = review._publish_review_report({"type": "skipped", "success": True}, plan_path, impact_path) + + assert result["report_path"] == str(report_path) + persisted = json.loads(review_path.read_text(encoding="utf-8")) + assert persisted["report_path"] == str(report_path) diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py new file mode 100644 index 0000000..7c89013 --- /dev/null +++ b/CoderMind/tests/test_run_report.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_SCRIPTS = _REPO / "scripts" +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +from common.run_report import write_command_report + + +def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path) -> None: + report = write_command_report( + "rpg/edit ", + title="Title ", + status="ok ", + summary_cards=[ + {"label": "node", "value": ""}, + {"label": "count", "value": 3}, + ], + stages=[{"name": "locate ", "status": "done", "reason": "score > 1"}], + rpg_nodes=[{"node_id": "feature"}, + report_dir=tmp_path, + timestamp="2026-06-30T12:34:56Z", + ) + + assert report.parent == tmp_path + assert report.name.startswith("cmind_run_rpg_edit_script_alert_1_script_") + html = report.read_text(encoding="utf-8") + assert "Summary" in html + assert "Stage timeline" in html + assert "Artifact links" in html + assert "Evidence JSON" in html + assert "<script>evil()</script>" in html + assert "" not in html + + +def test_write_command_report_limits_summary_cards(tmp_path: Path) -> None: + report = write_command_report( + "encode", + summary_cards=[{"label": f"card-{i}", "value": i} for i in range(9)], + report_dir=tmp_path, + timestamp="fixed", + ) + + html = report.read_text(encoding="utf-8") + assert html.count('class="card-label"') == 7 + visible_cards = html.split("
    ", 1)[0] + assert "card-0" in visible_cards + assert "card-6" in visible_cards + assert "card-7" not in visible_cards + assert "card-8" not in visible_cards + + +def test_write_command_report_preserves_same_timestamp_runs(tmp_path: Path) -> None: + first = write_command_report("update_rpg", report_dir=tmp_path, timestamp="fixed") + second = write_command_report("update_rpg", report_dir=tmp_path, timestamp="fixed") + + assert first != second + assert first.name == "cmind_run_update_rpg_fixed.html" + assert second.name == "cmind_run_update_rpg_fixed_2.html" + assert first.exists() + assert second.exists() + + +def test_write_command_report_does_not_invent_node_rows_from_counts(tmp_path: Path) -> None: + report = write_command_report( + "encode", + evidence={"dep_nodes": 4, "rpg_nodes": 6}, + report_dir=tmp_path, + timestamp="fixed", + ) + + html = report.read_text(encoding="utf-8") + assert html.count("No node evidence recorded.") == 2 + assert '"dep_nodes": 4' in html + assert '4' not in html From 416764d2315b9c089c01b676c8198b002ed8c857 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Tue, 30 Jun 2026 10:54:10 +0000 Subject: [PATCH 02/15] fix(cmind): handle RPG updates from git subdirectory workspaces Align update-rpg worktree comparisons and git diff outputs to the cmind workspace root so subdirectory workspaces do not duplicate RPG nodes. Co-Authored-By: Claude Opus 4.7 --- CoderMind/scripts/common/git_utils.py | 36 +++++++- .../scripts/rpg_encoder/run_update_rpg.py | 3 +- CoderMind/scripts/update_graphs.py | 6 +- CoderMind/tests/test_encode_commands.py | 51 +++++++++++ .../tests/test_encoder_workspace_layout.py | 91 +++++++++++++++++++ CoderMind/tests/test_sync_from_commit_diff.py | 43 +++++++++ 6 files changed, 223 insertions(+), 7 deletions(-) diff --git a/CoderMind/scripts/common/git_utils.py b/CoderMind/scripts/common/git_utils.py index 3c72866..ebe40e3 100644 --- a/CoderMind/scripts/common/git_utils.py +++ b/CoderMind/scripts/common/git_utils.py @@ -565,6 +565,34 @@ def read_head(repo_dir: str | Path) -> Optional[dict]: } +def git_workspace_prefix(workspace_dir: str | Path) -> str: + """Return the path from the git root to ``workspace_dir``. + + Returns ``""`` when ``workspace_dir`` is the git root or when git metadata + cannot be read. This keeps callers safe outside git repositories. + """ + if not workspace_dir: + return "" + workspace_path = Path(workspace_dir) + if not workspace_path.is_dir(): + return "" + + git_root = _run_git_readonly( + ["rev-parse", "--show-toplevel"], + workspace_path, + ) + if not git_root: + return "" + + try: + rel = workspace_path.resolve().relative_to(Path(git_root).resolve()) + except ValueError: + return "" + + rel_str = rel.as_posix() + return "" if rel_str == "." else rel_str + + # --------------------------------------------------------------------------- # Diff helpers — produce ``(modified, renames)`` from various git scopes. # --------------------------------------------------------------------------- @@ -669,14 +697,14 @@ def staged_changes( if not repo_path.is_dir(): return [], {} raw = _run_git_readonly( - ["diff", "--cached", "--name-status", "-M", "HEAD"], + ["diff", "--cached", "--relative", "--name-status", "-M", "HEAD"], repo_path, ) if raw is None: # ``HEAD`` may not exist yet (unborn branch); try without it so # the very first commit's staged files still get picked up. raw = _run_git_readonly( - ["diff", "--cached", "--name-status", "-M"], + ["diff", "--cached", "--relative", "--name-status", "-M"], repo_path, ) return _parse_name_status(raw) @@ -706,7 +734,7 @@ def working_tree_changes( return [], {} raw = _run_git_readonly( - ["diff", "--name-status", "-M", "HEAD"], + ["diff", "--relative", "--name-status", "-M", "HEAD"], repo_path, ) modified, renames = _parse_name_status(raw) @@ -749,7 +777,7 @@ def changed_files_between( if not repo_path.is_dir(): return [], {} raw = _run_git_readonly( - ["diff", "--name-status", "-M", f"{old_ref}..{new_ref}"], + ["diff", "--relative", "--name-status", "-M", f"{old_ref}..{new_ref}"], repo_path, ) return _parse_name_status(raw) diff --git a/CoderMind/scripts/rpg_encoder/run_update_rpg.py b/CoderMind/scripts/rpg_encoder/run_update_rpg.py index c368ef4..bd2a9f6 100644 --- a/CoderMind/scripts/rpg_encoder/run_update_rpg.py +++ b/CoderMind/scripts/rpg_encoder/run_update_rpg.py @@ -228,8 +228,7 @@ def run_update_rpg( # to a full rebuild (rebase / diverged path). meta_git_advanced = False try: - ws_root = WORKSPACE_ROOT - current = read_head(ws_root) + current = read_head(cur_repo_dir) if current: updated_rpg.set_git_meta( head_commit=current["head_commit"], diff --git a/CoderMind/scripts/update_graphs.py b/CoderMind/scripts/update_graphs.py index 4795423..e017e4a 100644 --- a/CoderMind/scripts/update_graphs.py +++ b/CoderMind/scripts/update_graphs.py @@ -548,11 +548,15 @@ def cmd_update_rpg( "prev_ref": prev_ref, }) + from common.git_utils import git_workspace_prefix from rpg_encoder.run_update_rpg import run_update_rpg + git_prefix = git_workspace_prefix(workspace_root) + last_repo_dir = os.path.join(worktree_dir, git_prefix) if git_prefix else worktree_dir + result = run_update_rpg( rpg_file=str(rpg_path), - last_repo_dir=worktree_dir, + last_repo_dir=last_repo_dir, cur_repo_dir=workspace_root, dep_graph_path=str(dep_graph_path), ) diff --git a/CoderMind/tests/test_encode_commands.py b/CoderMind/tests/test_encode_commands.py index 1e8b5db..2cb7c44 100644 --- a/CoderMind/tests/test_encode_commands.py +++ b/CoderMind/tests/test_encode_commands.py @@ -273,6 +273,57 @@ def test_missing_rpg_file(self, tmp_repo): assert result["status"] == "error" assert "not found" in result["error"] + def test_meta_git_reads_explicit_cur_repo_dir(self, tmp_path): + from rpg_encoder.run_update_rpg import run_update_rpg + + last_repo = tmp_path / "last" + cur_repo = tmp_path / "current" + last_repo.mkdir() + cur_repo.mkdir() + rpg_file = tmp_path / "rpg.json" + rpg_file.write_text(json.dumps({ + "repo_name": "test_repo", + "repo_info": "", + "root": { + "id": "test_repo_L0", + "name": "test_repo", + "node_type": "repo", + "level": 0, + "meta": {"type_name": "directory", "path": "."}, + "children": [], + }, + "edges": [], + })) + + calls = [] + + def fake_read_head(repo_dir): + calls.append(Path(repo_dir)) + return { + "head_commit": "a" * 40, + "head_short": "aaaaaaa", + "head_branch": "main", + "head_timestamp": "2026-06-30T00:00:00+00:00", + } + + with patch( + "rpg_encoder.rpg_evolution.RPGEvolution.process_diff", + side_effect=lambda **kwargs: kwargs["last_rpg"], + ), patch( + "rpg.service.RPGService.enrich_from_code", + return_value={}, + ), patch("common.git_utils.read_head", side_effect=fake_read_head): + result = run_update_rpg( + rpg_file=str(rpg_file), + last_repo_dir=str(last_repo), + cur_repo_dir=str(cur_repo), + ) + + assert result["status"] == "success" + assert result["meta_git_advanced"] is True + assert result["new_commit"] == "a" * 40 + assert calls == [cur_repo.resolve()] + def test_missing_last_repo_dir(self, tmp_rpg_file): """Should return error when last repo dir doesn't exist.""" from rpg_encoder.run_update_rpg import run_update_rpg diff --git a/CoderMind/tests/test_encoder_workspace_layout.py b/CoderMind/tests/test_encoder_workspace_layout.py index 71a3ea7..7184cfe 100644 --- a/CoderMind/tests/test_encoder_workspace_layout.py +++ b/CoderMind/tests/test_encoder_workspace_layout.py @@ -18,6 +18,7 @@ import importlib import os +import subprocess import sys from pathlib import Path @@ -31,6 +32,10 @@ # Fixtures — reload ``common.paths`` against an arbitrary workspace # --------------------------------------------------------------------------- +def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + def _reload_paths_against(workspace: Path): """Import / reload ``common.paths`` with ``workspace`` as cwd. @@ -77,6 +82,30 @@ def workspace_with_repo_subdir(tmp_path, monkeypatch): return ws +@pytest.fixture +def workspace_inside_git_repo(tmp_path, monkeypatch): + git_root = tmp_path / "gitroot" + workspace = git_root / "subproject" + src = workspace / "src" + src.mkdir(parents=True) + (workspace / ".cmind").mkdir() + (src / "module.py").write_text("def value():\n return 1\n") + + _git(git_root, "init", "-q", "-b", "main") + _git(git_root, "config", "user.email", "test@example.com") + _git(git_root, "config", "user.name", "Test User") + _git(git_root, "add", ".") + _git(git_root, "commit", "-q", "-m", "initial") + + (src / "module.py").write_text("def value():\n return 2\n") + _git(git_root, "add", ".") + _git(git_root, "commit", "-q", "-m", "update module") + + monkeypatch.chdir(workspace) + monkeypatch.delenv("CMIND_WORKSPACE", raising=False) + return git_root, workspace + + # --------------------------------------------------------------------------- # Encoder entry point defaults # --------------------------------------------------------------------------- @@ -229,3 +258,65 @@ def test_update_graphs_auto_detect_ignores_present_repo_subdir( assert result == str(workspace_with_repo_subdir) # Critically NOT the repo/ subdir assert result != str(workspace_with_repo_subdir / "repo") + + +def test_git_workspace_prefix_handles_workspace_inside_git_repo( + workspace_inside_git_repo, +): + git_root, workspace = workspace_inside_git_repo + _reload_paths_against(workspace) + from common.git_utils import git_workspace_prefix + + assert git_workspace_prefix(workspace) == "subproject" + assert git_workspace_prefix(git_root) == "" + + +def test_cmd_update_rpg_passes_subdir_adjusted_last_repo_dir( + workspace_inside_git_repo, + monkeypatch, +): + _, workspace = workspace_inside_git_repo + _reload_paths_against(workspace) + + import update_graphs + importlib.reload(update_graphs) + import rpg_encoder.run_update_rpg as run_update_mod + + data_dir = workspace / ".cmind" / "data" + data_dir.mkdir(parents=True) + rpg_path = data_dir / "rpg.json" + dep_graph_path = data_dir / "dep_graph.json" + rpg_path.write_text("{}") + + captured = {} + + def fake_run_update_rpg(*, rpg_file, last_repo_dir, cur_repo_dir, dep_graph_path): + last_repo = Path(last_repo_dir) + captured["rpg_file"] = rpg_file + captured["last_repo_dir"] = last_repo.as_posix() + captured["cur_repo_dir"] = cur_repo_dir + captured["dep_graph_path"] = dep_graph_path + captured["old_file_at_workspace_root"] = ( + last_repo / "src" / "module.py" + ).is_file() + captured["nested_subproject_exists"] = ( + last_repo / "subproject" + ).exists() + return {"status": "success", "repo_name": "subproject"} + + monkeypatch.setattr(run_update_mod, "run_update_rpg", fake_run_update_rpg) + monkeypatch.setattr(update_graphs, "_refresh_rpg_html", lambda _rpg_path: {}) + monkeypatch.setattr(update_graphs, "_attach_update_report", lambda result: result) + monkeypatch.setattr(update_graphs, "_log_hook_call", lambda *_args, **_kwargs: None) + + result = update_graphs.cmd_update_rpg( + rpg_path=rpg_path, + dep_graph_path=dep_graph_path, + workspace_root=str(workspace), + ) + + assert result["status"] == "success" + assert captured["last_repo_dir"].endswith("/subproject") + assert captured["cur_repo_dir"] == str(workspace) + assert captured["old_file_at_workspace_root"] is True + assert captured["nested_subproject_exists"] is False diff --git a/CoderMind/tests/test_sync_from_commit_diff.py b/CoderMind/tests/test_sync_from_commit_diff.py index 023d18c..fcef438 100644 --- a/CoderMind/tests/test_sync_from_commit_diff.py +++ b/CoderMind/tests/test_sync_from_commit_diff.py @@ -129,6 +129,49 @@ def _node_edge_snapshot(g: DependencyGraph) -> Tuple[Dict[str, dict], Set[Tuple[ return nodes, edges +def test_git_diff_helpers_return_workspace_relative_paths_in_subdir(tmp_path): + from common.git_utils import ( + changed_files_between, + staged_changes, + working_tree_changes, + ) + + git_root = tmp_path / "gitroot" + workspace = git_root / "subproject" + src = workspace / "src" + src.mkdir(parents=True) + (src / "a.py").write_text("def a():\n return 1\n") + + _sh(git_root, "init", "-q", "-b", "main") + _sh(git_root, "config", "user.email", "test@example.com") + _sh(git_root, "config", "user.name", "Test") + _sh(git_root, "add", ".") + _sh(git_root, "commit", "-q", "-m", "initial") + first = _head_sha(git_root) + + (src / "a.py").write_text("def a():\n return 2\n") + _sh(git_root, "add", ".") + _sh(git_root, "commit", "-q", "-m", "update a") + second = _head_sha(git_root) + + changed, renames = changed_files_between(workspace, first, second) + assert changed == ["src/a.py"] + assert renames == {} + + (src / "a.py").write_text("def a():\n return 3\n") + _sh(git_root, "add", "subproject/src/a.py") + changed, renames = staged_changes(workspace) + assert changed == ["src/a.py"] + assert renames == {} + + (src / "new.py").write_text("def new():\n return 1\n") + changed, renames = working_tree_changes(workspace) + assert "src/a.py" in changed + assert "src/new.py" in changed + assert all(not path.startswith("subproject/") for path in changed) + assert renames == {} + + # --------------------------------------------------------------------------- # Decision tree # --------------------------------------------------------------------------- From 2b443e9b86e7daf69e3b486de9f0ff6203a6f662 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 04:34:17 +0000 Subject: [PATCH 03/15] fix(cmind): correct update_rpg report delta reporting Report update_rpg results using the actual output fields, include raw git delta counts, and propagate semantic diff summaries so Explain View no longer shows zero/empty values for changed files and graph node counts. --- .../scripts/rpg_encoder/rpg_evolution.py | 32 +++- .../scripts/rpg_encoder/run_update_rpg.py | 6 + CoderMind/scripts/update_graphs.py | 155 ++++++++++++++++-- CoderMind/tests/test_encode_commands.py | 97 +++++++++++ CoderMind/tests/test_rpg_evolution.py | 9 +- 5 files changed, 277 insertions(+), 22 deletions(-) diff --git a/CoderMind/scripts/rpg_encoder/rpg_evolution.py b/CoderMind/scripts/rpg_encoder/rpg_evolution.py index 5b9507e..18c483b 100644 --- a/CoderMind/scripts/rpg_encoder/rpg_evolution.py +++ b/CoderMind/scripts/rpg_encoder/rpg_evolution.py @@ -758,6 +758,18 @@ def process_diff( save_path=dep_graph_save_path, ) + last_rpg._last_diff_summary = { + "added": 0, + "deleted": 0, + "modified": 0, + "renamed": 0, + } + last_rpg._last_diff_files = { + "added": [], + "deleted": [], + "modified": [], + "renamed": [], + } total_time = time.time() - global_start logger.info( "\nNo changes detected for [%s]. RPG remains unchanged.\n" @@ -795,6 +807,20 @@ def process_diff( save_path=dep_graph_save_path, ) + diff_summary = { + "added": len(add_files), + "deleted": len(deleted_files), + "modified": len(modified_result), + "renamed": 0, + } + ctx["last_rpg"]._last_diff_summary = diff_summary + ctx["last_rpg"]._last_diff_files = { + "added": add_files, + "deleted": deleted_files, + "modified": list(modified_result.keys()), + "renamed": [], + } + # Save results result = { "repo_name": repo_name, @@ -804,11 +830,7 @@ def process_diff( "structure": ctx["last_rpg"].to_dict(), "feature_tree": ctx["last_rpg"].get_functionality_graph(), }, - "diff_summary": { - "added": len(add_files), - "deleted": len(deleted_files), - "modified": len(modified_result), - }, + "diff_summary": diff_summary, } if save_path: diff --git a/CoderMind/scripts/rpg_encoder/run_update_rpg.py b/CoderMind/scripts/rpg_encoder/run_update_rpg.py index bd2a9f6..d62f7e0 100644 --- a/CoderMind/scripts/rpg_encoder/run_update_rpg.py +++ b/CoderMind/scripts/rpg_encoder/run_update_rpg.py @@ -253,6 +253,8 @@ def run_update_rpg( post_nodes = len(updated_rpg.nodes) post_edges = _serialized_feature_edges(result_data) post_dep_stats = _serialized_dep_stats(result_data) + diff_summary = getattr(updated_rpg, "_last_diff_summary", None) + diff_files = getattr(updated_rpg, "_last_diff_files", None) stats = { "repo_name": repo_name, @@ -274,6 +276,10 @@ def run_update_rpg( "previous_commit": pre_commit, "new_commit": (updated_rpg.git_meta or {}).get("head_commit"), } + if isinstance(diff_summary, dict): + stats["diff_summary"] = diff_summary + if isinstance(diff_files, dict): + stats["diff_files"] = diff_files try: stats["functional_areas"] = len(updated_rpg.get_functional_areas()) except Exception: diff --git a/CoderMind/scripts/update_graphs.py b/CoderMind/scripts/update_graphs.py index e017e4a..605e798 100644 --- a/CoderMind/scripts/update_graphs.py +++ b/CoderMind/scripts/update_graphs.py @@ -88,33 +88,150 @@ def _change_count(value: object) -> int: return 0 +def _diff_summary(result: dict) -> dict: + summary = result.get("diff_summary") + if isinstance(summary, dict): + return { + "added": _change_count(summary.get("added")), + "deleted": _change_count(summary.get("deleted")), + "modified": _change_count(summary.get("modified")), + "renamed": _change_count(summary.get("renamed")), + } + return { + key: _change_count(result.get(key)) + for key in ("added", "deleted", "modified", "renamed") + } + + +def _format_count_delta(value: object, delta: object) -> object: + if value in (None, ""): + return "" + if isinstance(delta, int): + return f"{value} (delta: {delta:+d})" + return value + + +def _format_diff_summary(summary: dict) -> str: + total = sum(summary.values()) + parts = [f"{total} semantic files"] + for key in ("added", "deleted", "modified", "renamed"): + count = summary.get(key, 0) + if count: + parts.append(f"{key}={count}") + return ", ".join(parts) + + +def _git_delta_files(prev_ref: str, workspace_root: str) -> list[dict[str, str]]: + import subprocess + + try: + output = subprocess.check_output( + ["git", "diff", "--name-status", f"{prev_ref}..HEAD", "--", "."], + cwd=workspace_root, + stderr=subprocess.DEVNULL, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + files: list[dict[str, str]] = [] + for line in output.splitlines(): + parts = line.split("\t") + if len(parts) >= 2: + files.append({"status": parts[0], "path": parts[-1]}) + return files + + def _attach_update_report(result: dict) -> dict: try: - changed_total = sum( - _change_count(result.get(key)) - for key in ("modified", "added", "deleted", "renamed") + semantic_summary = _diff_summary(result) + semantic_total = sum(semantic_summary.values()) + git_delta = result.get("git_delta") + git_total = _change_count(git_delta) if git_delta is not None else "" + node_count = result.get("node_count", result.get("rpg_nodes", "")) + rpg_path = result.get("output_path") or result.get("rpg_path") + dep_summary = "" + if result.get("dep_nodes") not in (None, ""): + dep_summary = "nodes={}".format( + _format_count_delta( + result.get("dep_nodes"), + result.get("dep_nodes_delta"), + ) + ) + if result.get("dep_edges") not in (None, ""): + dep_summary += ", edges={}".format( + _format_count_delta( + result.get("dep_edges"), + result.get("dep_edges_delta"), + ) + ) + viz_status = result.get("viz_error") or ( + "ok" if result.get("viz_path") else "not recorded" ) - viz_status = result.get("viz_error") or ("ok" if result.get("viz_path") else "not recorded") report_path = write_command_report( "update_rpg", title="CoderMind update_rpg Explain View", status=result.get("mode") or result.get("status"), summary_cards=[ {"label": "mode", "value": result.get("mode", "")}, - {"label": "reason", "value": result.get("reason") or result.get("error", "")}, - {"label": "changed files", "value": changed_total}, - {"label": "RPG nodes", "value": result.get("rpg_nodes", "")}, - {"label": "dep nodes", "value": result.get("dep_nodes", "")}, - {"label": "dep edges", "value": result.get("dep_edges", "")}, - {"label": "visualization", "value": result.get("viz_path") or result.get("viz_error", "")}, + { + "label": "reason", + "value": result.get("reason") or result.get("error", ""), + }, + {"label": "git files", "value": git_total}, + {"label": "semantic files", "value": semantic_total}, + { + "label": "RPG nodes", + "value": _format_count_delta( + node_count, + result.get("nodes_delta"), + ), + }, + {"label": "dep graph", "value": dep_summary}, + { + "label": "visualization", + "value": result.get("viz_path") or result.get("viz_error", ""), + }, ], stages=[ - {"name": "git delta", "status": result.get("mode", ""), "reason": f"{changed_total} changed files"}, - {"name": "sync graph", "status": result.get("status", result.get("mode", "")), "reason": result.get("reason", "")}, - {"name": "visualize", "status": "ok" if result.get("viz_path") else "error" if result.get("viz_error") else "skipped", "reason": result.get("viz_path") or result.get("viz_error", "")}, + { + "name": "git delta", + "status": result.get("mode", ""), + "reason": ( + f"{git_total} changed files" + if git_total != "" + else "not recorded" + ), + }, + { + "name": "semantic delta", + "status": result.get("mode", ""), + "reason": _format_diff_summary(semantic_summary), + }, + { + "name": "sync graph", + "status": result.get("status", result.get("mode", "")), + "reason": result.get("reason", ""), + }, + { + "name": "visualize", + "status": ( + "ok" + if result.get("viz_path") + else "error" + if result.get("viz_error") + else "skipped" + ), + "reason": result.get("viz_path") or result.get("viz_error", ""), + }, ], - artifacts={"rpg_json": result.get("rpg_path"), "rpg_html": result.get("viz_path")}, - verification={"update_rpg": result.get("status", result.get("mode")), "viz": viz_status}, + artifacts={ + "rpg_json": rpg_path, + "rpg_html": result.get("viz_path"), + }, + verification={ + "update_rpg": result.get("status", result.get("mode")), + "viz": viz_status, + }, evidence=result, ) result["report_path"] = str(report_path) @@ -552,7 +669,12 @@ def cmd_update_rpg( from rpg_encoder.run_update_rpg import run_update_rpg git_prefix = git_workspace_prefix(workspace_root) - last_repo_dir = os.path.join(worktree_dir, git_prefix) if git_prefix else worktree_dir + last_repo_dir = ( + os.path.join(worktree_dir, git_prefix) + if git_prefix + else worktree_dir + ) + git_delta = _git_delta_files(prev_ref, workspace_root) result = run_update_rpg( rpg_file=str(rpg_path), @@ -563,6 +685,7 @@ def cmd_update_rpg( result["mode"] = "update-rpg" result["prev_ref"] = prev_ref + result["git_delta"] = git_delta # Refresh ``rpg.html`` whenever the JSON was actually rewritten. # ``run_update_rpg`` returns ``status="success"`` on a normal diff --git a/CoderMind/tests/test_encode_commands.py b/CoderMind/tests/test_encode_commands.py index 2cb7c44..4014e39 100644 --- a/CoderMind/tests/test_encode_commands.py +++ b/CoderMind/tests/test_encode_commands.py @@ -324,6 +324,52 @@ def fake_read_head(repo_dir): assert result["new_commit"] == "a" * 40 assert calls == [cur_repo.resolve()] + def test_diff_summary_is_returned(self, tmp_rpg_file, tmp_path): + from rpg_encoder.run_update_rpg import run_update_rpg + + last_repo = tmp_path / "last" + cur_repo = tmp_path / "current" + last_repo.mkdir() + cur_repo.mkdir() + + def fake_process_diff(**kwargs): + rpg = kwargs["last_rpg"] + rpg._last_diff_summary = { + "added": 1, + "deleted": 0, + "modified": 2, + "renamed": 0, + } + rpg._last_diff_files = { + "added": ["a.py"], + "deleted": [], + "modified": ["b.py", "c.py"], + "renamed": [], + } + return rpg + + with patch( + "rpg_encoder.rpg_evolution.RPGEvolution.process_diff", + side_effect=fake_process_diff, + ), patch( + "rpg.service.RPGService.enrich_from_code", + return_value={}, + ), patch("common.git_utils.read_head", return_value=None): + result = run_update_rpg( + rpg_file=tmp_rpg_file, + last_repo_dir=str(last_repo), + cur_repo_dir=str(cur_repo), + ) + + assert result["status"] == "success" + assert result["diff_summary"] == { + "added": 1, + "deleted": 0, + "modified": 2, + "renamed": 0, + } + assert result["diff_files"]["modified"] == ["b.py", "c.py"] + def test_missing_last_repo_dir(self, tmp_rpg_file): """Should return error when last repo dir doesn't exist.""" from rpg_encoder.run_update_rpg import run_update_rpg @@ -346,6 +392,57 @@ def test_missing_cur_repo_dir(self, tmp_rpg_file): assert "not found" in result["error"] +# ============================================================================ +# Test: update_graphs report wiring +# ============================================================================ + + +def test_attach_update_report_uses_update_rpg_result_fields(tmp_path, monkeypatch): + import update_graphs + + captured = {} + + def fake_write_command_report(command, **kwargs): + captured["command"] = command + captured.update(kwargs) + return tmp_path / "report.html" + + monkeypatch.setattr(update_graphs, "write_command_report", fake_write_command_report) + + result = update_graphs._attach_update_report({ + "mode": "update-rpg", + "status": "success", + "output_path": "/tmp/rpg.json", + "node_count": 4504, + "nodes_delta": 2, + "dep_nodes": 2708, + "dep_nodes_delta": 46, + "dep_edges": 5498, + "dep_edges_delta": 103, + "diff_summary": { + "added": 0, + "deleted": 0, + "modified": 3, + "renamed": 0, + }, + "git_delta": [ + {"status": "M", "path": "scripts/a.py"}, + {"status": "M", "path": "tests/test_a.py"}, + ], + "viz_path": "/tmp/rpg.html", + }) + + cards = {card["label"]: card["value"] for card in captured["summary_cards"]} + assert result["report_path"] == str(tmp_path / "report.html") + assert cards["git files"] == 2 + assert cards["semantic files"] == 3 + assert cards["RPG nodes"] == "4504 (delta: +2)" + assert cards["dep graph"] == "nodes=2708 (delta: +46), edges=5498 (delta: +103)" + assert captured["artifacts"]["rpg_json"] == "/tmp/rpg.json" + assert captured["stages"][0]["reason"] == "2 changed files" + assert captured["stages"][1]["reason"] == "3 semantic files, modified=3" + + # ============================================================================ # Test: Template validation # ============================================================================ diff --git a/CoderMind/tests/test_rpg_evolution.py b/CoderMind/tests/test_rpg_evolution.py index 4f72b4b..e9bb59c 100644 --- a/CoderMind/tests/test_rpg_evolution.py +++ b/CoderMind/tests/test_rpg_evolution.py @@ -745,7 +745,7 @@ def test_save_path_creates_file(self, simple_rpg): return_value=diff_result, ): - RPGEvolution.process_diff( + updated_rpg = RPGEvolution.process_diff( repo_name="test", repo_info="Test repo", save_path=save_path, @@ -756,6 +756,13 @@ def test_save_path_creates_file(self, simple_rpg): update_dep_graph=False, ) + assert updated_rpg._last_diff_summary == { + "added": 0, + "deleted": 1, + "modified": 0, + "renamed": 0, + } + assert updated_rpg._last_diff_files["deleted"] == ["src/module_a.py"] assert os.path.isfile(save_path) with open(save_path, "r") as f: data = json.load(f) From f6b79a61592ce89fc46fbb80960d003e74d332f0 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 04:54:46 +0000 Subject: [PATCH 04/15] Update .gitignore. --- CoderMind/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CoderMind/.gitignore b/CoderMind/.gitignore index 835fb40..298698b 100644 --- a/CoderMind/.gitignore +++ b/CoderMind/.gitignore @@ -239,3 +239,5 @@ plans/ .mcp.json .github/agents/ .github/prompts/ + +.claude/commands/ From 9c2db7d8ef7257a4a0ec2a93dffbed7b49b76a37 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 05:04:41 +0000 Subject: [PATCH 05/15] rpg_edit: At _normalize_artifacts() lines 224-240 and _render_artifact --- CoderMind/scripts/common/run_report.py | 30 +++++++++++++--- CoderMind/scripts/rpg_edit/review.py | 39 ++++++++++++++++---- CoderMind/tests/test_rpg_edit_run_report.py | 40 +++++++++++++++++++++ CoderMind/tests/test_run_report.py | 25 +++++++++++++ 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index f100477..9c03284 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -230,16 +230,36 @@ def _normalize_artifacts(value: Any) -> list[dict[str, Any]]: for item in iterable: if isinstance(item, tuple) and len(item) == 2: label, path = item - artifacts.append({"label": label, "path": path}) + artifacts.append({"label": label, "path": path, "status": _artifact_status(path)}) elif isinstance(item, Mapping): path = item.get("path") or item.get("href") or item.get("url") or item.get("file") - label = item.get("label") or item.get("name") or item.get("title") or path or "artifact" - artifacts.append({"label": label, "path": path, "status": item.get("status")}) + label = item.get("label") or item.get("name") or item.get("title") + if path is None: + path_items = [ + (key, item_value) + for key, item_value in item.items() + if key not in {"label", "name", "title", "status"} + ] + if len(path_items) == 1: + label, path = path_items[0] + label = label or path or "artifact" + artifacts.append({"label": label, "path": path, "status": _artifact_status(path, item.get("status"))}) else: - artifacts.append({"label": Path(str(item)).name or "artifact", "path": item}) + artifacts.append({"label": Path(str(item)).name or "artifact", "path": item, "status": _artifact_status(item)}) return artifacts +def _artifact_status(path: Any, status: Any = None) -> Any: + if status not in (None, ""): + return status + if path in (None, ""): + return "missing" + try: + return "available" if Path(str(path)).expanduser().exists() else "missing" + except (OSError, ValueError): + return "missing" + + def _normalize_verification(value: Any) -> list[dict[str, Any]]: checks: list[dict[str, Any]] = [] if isinstance(value, Mapping) and not any(isinstance(v, Mapping) for v in value.values()): @@ -433,7 +453,7 @@ def _render_artifacts(artifacts: list[dict[str, Any]]) -> str: "" f"{_h(artifact.get('label', 'artifact'))}" f"{_h(path or '')}" - f"{_h(artifact.get('status', ''))}" + f"{_h(_artifact_status(path, artifact.get('status')))}" "" ) body = "" + "".join(rows) + "
    ArtifactPathStatus
    " diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 45af3ce..9b4efc9 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -94,14 +94,41 @@ def _artifact_links(plan_path: Path, impact_path: Optional[Path]) -> List[Dict[s return links +def _impact_results(artifacts: Dict[str, Any]) -> Dict[str, Any]: + impact = artifacts.get("impact") if isinstance(artifacts.get("impact"), dict) else {} + results = impact.get("results") if isinstance(impact.get("results"), dict) else {} + return results + + def _selected_candidate_rows(artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: locate = artifacts.get("locate") if isinstance(artifacts.get("locate"), dict) else {} plan = artifacts.get("plan") if isinstance(artifacts.get("plan"), dict) else {} - affected = set(plan.get("affected_nodes") or []) - candidates = locate.get("results") or [] - if affected: - candidates = [c for c in candidates if c.get("node_id") in affected] - return [c for c in candidates if isinstance(c, dict)] + affected = [node_id for node_id in plan.get("affected_nodes") or [] if node_id] + candidates = [c for c in locate.get("results") or [] if isinstance(c, dict)] + if not affected: + return candidates + + impact_results = _impact_results(artifacts) + candidates_by_id = {c.get("node_id"): c for c in candidates if c.get("node_id") in affected} + rows: List[Dict[str, Any]] = [] + for node_id in affected: + impact = impact_results.get(node_id) if isinstance(impact_results.get(node_id), dict) else {} + candidate = dict(candidates_by_id.get(node_id) or {"node_id": node_id}) + if impact: + candidate.setdefault("name", impact.get("name", "")) + if not candidate.get("dep_nodes"): + candidate["dep_nodes"] = impact.get("dep_nodes") or [] + if not candidate.get("status") and (impact.get("error") or impact.get("message")): + candidate["status"] = impact.get("error") or impact.get("message") + rows.append(candidate) + return rows + + +def _dep_node_path(dep_id: Any) -> str: + if dep_id in (None, ""): + return "" + dep_id_text = str(dep_id) + return dep_id_text.split(":", 1)[0] if ":" in dep_id_text else dep_id_text def _dep_node_rows(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -111,7 +138,7 @@ def _dep_node_rows(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: rows.append({ "node_id": dep_id, "source_feature": candidate.get("node_id"), - "path": candidate.get("meta_path"), + "path": _dep_node_path(dep_id) or candidate.get("meta_path"), }) return rows diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index bdaab5a..257cd10 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -84,3 +84,43 @@ def fake_write_command_report(*args, **kwargs): assert result["report_path"] == str(report_path) persisted = json.loads(review_path.read_text(encoding="utf-8")) assert persisted["report_path"] == str(report_path) + + +def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: Path, monkeypatch) -> None: + review = _load_script("rpg_edit_review_impact_test", _SCRIPTS / "rpg_edit" / "review.py") + + validate_path = tmp_path / "validate.json" + locate_path = tmp_path / "locate.json" + plan_path = tmp_path / "plan.json" + impact_path = tmp_path / "impact.json" + code_path = tmp_path / "code.json" + review_path = tmp_path / "review.json" + report_path = tmp_path / "report.html" + dep_id = "scripts/common/run_report.py:_render_artifacts" + + validate_path.write_text(json.dumps({"type": "ready"}), encoding="utf-8") + locate_path.write_text(json.dumps({"type": "candidates", "results": [{"node_id": "other", "name": "Other"}]}), encoding="utf-8") + plan_path.write_text(json.dumps({"affected_nodes": ["planned"], "code_changes": [{"file_path": "scripts/common/run_report.py"}]}), encoding="utf-8") + impact_path.write_text( + json.dumps({"type": "impact", "results": {"planned": {"name": "Planned Node", "dep_nodes": [dep_id], "affected_files": ["scripts/common/run_report.py"]}}}), + encoding="utf-8", + ) + code_path.write_text(json.dumps({"success": True, "files_modified": ["scripts/common/run_report.py"], "last_status": "complete"}), encoding="utf-8") + + monkeypatch.setattr(review, "RPG_EDIT_VALIDATE_FILE", validate_path) + monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) + monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) + monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) + + def fake_write_command_report(*args, **kwargs): + assert kwargs["rpg_nodes"] == [{"node_id": "planned", "name": "Planned Node", "dep_nodes": [dep_id]}] + assert kwargs["dep_nodes"] == [ + {"node_id": dep_id, "source_feature": "planned", "path": "scripts/common/run_report.py"} + ] + return report_path + + monkeypatch.setattr(review, "write_command_report", fake_write_command_report) + + result = review._publish_review_report({"type": "skipped", "success": True}, plan_path, impact_path) + + assert result["report_path"] == str(report_path) diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index 7c89013..6fef570 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -81,3 +81,28 @@ def test_write_command_report_does_not_invent_node_rows_from_counts(tmp_path: Pa assert html.count("No node evidence recorded.") == 2 assert '"dep_nodes": 4' in html assert '4' not in html + + +def test_write_command_report_infers_artifact_status_and_preserves_verification_detail(tmp_path: Path) -> None: + available = tmp_path / "available.json" + available.write_text("{}", encoding="utf-8") + missing = tmp_path / "missing.json" + + report = write_command_report( + "encode", + artifacts=[("rpg_json", available), {"missing_json": missing}], + verification=[ + {"name": "message", "status": "ok", "message": "from message"}, + {"name": "reason", "status": "warn", "reason": "from reason"}, + ], + report_dir=tmp_path, + timestamp="fixed", + ) + + html = report.read_text(encoding="utf-8") + assert "rpg_json" in html + assert "missing_json" in html + assert html.count("available") == 1 + assert html.count("missing") == 1 + assert "from message" in html + assert "from reason" in html From fd5a29c82d8c8c158d1d76b400a07a39350b0b45 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 05:19:52 +0000 Subject: [PATCH 06/15] fix(cmind): clear smoke test baseline failures Use package-aware imports for llm_usage_count_coarse and make intentional no-op/unsupported backend methods explicit so smoke stub detection no longer flags valid code. --- CoderMind/scripts/common/session_manager.py | 2 +- CoderMind/scripts/common/utils.py | 3 --- .../scripts/decoder_lang/python_backend.py | 6 ++++++ .../utils/claude/llm_usage_count_coarse.py | 20 ++++++++++++++----- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CoderMind/scripts/common/session_manager.py b/CoderMind/scripts/common/session_manager.py index df8fd5a..daa0a23 100644 --- a/CoderMind/scripts/common/session_manager.py +++ b/CoderMind/scripts/common/session_manager.py @@ -225,7 +225,7 @@ class NullSessionManager(SessionManager): """ def before(self, ctx: TraceContext, prompt: str) -> None: - pass + return None def after(self, purpose: str) -> Optional[Path]: return None diff --git a/CoderMind/scripts/common/utils.py b/CoderMind/scripts/common/utils.py index 1dd1874..a4f3809 100644 --- a/CoderMind/scripts/common/utils.py +++ b/CoderMind/scripts/common/utils.py @@ -857,9 +857,6 @@ def get_skeleton( class _CompressTransformer(cst.CSTTransformer): """Replace function bodies with ``...`` while preserving structure.""" - def __init__(self): - pass - def _is_import_stmt(self, stmt: cst.CSTNode) -> bool: if not m.matches(stmt, m.SimpleStatementLine()): return False diff --git a/CoderMind/scripts/decoder_lang/python_backend.py b/CoderMind/scripts/decoder_lang/python_backend.py index 06384dd..b329e42 100644 --- a/CoderMind/scripts/decoder_lang/python_backend.py +++ b/CoderMind/scripts/decoder_lang/python_backend.py @@ -14,6 +14,7 @@ import ast import keyword import logging +from abc import abstractmethod from pathlib import Path from typing import Any @@ -422,6 +423,7 @@ def _source_for_node(source: str, node: ast.AST) -> str: # 3. Build / test environment — not wired into the decoder yet # ------------------------------------------------------------------ + @abstractmethod def detect_env(self, repo_root: Path) -> EnvHandle | None: """Return an existing Python test environment when supported.""" raise NotImplementedError( @@ -430,6 +432,7 @@ def detect_env(self, repo_root: Path) -> EnvHandle | None: "for now.", ) + @abstractmethod def ensure_env(self, repo_root: Path) -> EnvHandle: """Always available on a host that's already running Python (the decoder itself), so this never raises @@ -438,6 +441,7 @@ def ensure_env(self, repo_root: Path) -> EnvHandle: "PythonBackend.ensure_env is not wired into the decoder.", ) + @abstractmethod def test_command( self, env: EnvHandle, @@ -450,6 +454,7 @@ def test_command( "build_batch_pytest_cmd for now.", ) + @abstractmethod def install_deps_command( self, env: EnvHandle, @@ -464,6 +469,7 @@ def install_deps_command( # 4. Test-output parsing — not wired into the decoder yet # ------------------------------------------------------------------ + @abstractmethod def parse_test_output(self, raw: str, exit_code: int) -> TestRunResult: """Parse native Python test output when backend-driven tests run.""" raise NotImplementedError( diff --git a/CoderMind/utils/claude/llm_usage_count_coarse.py b/CoderMind/utils/claude/llm_usage_count_coarse.py index 49fe89a..ec02829 100644 --- a/CoderMind/utils/claude/llm_usage_count_coarse.py +++ b/CoderMind/utils/claude/llm_usage_count_coarse.py @@ -22,11 +22,21 @@ from typing import Dict, List, Optional # Import from the detailed counter -from llm_usage_count import ( - parse_file, - FileSummary, - _match_pricing, -) +if __package__: + from .llm_usage_count import ( + parse_file, + FileSummary, + _match_pricing, + ) +else: + module_dir = str(Path(__file__).resolve().parent) + if module_dir not in sys.path: + sys.path.insert(0, module_dir) + from llm_usage_count import ( + parse_file, + FileSummary, + _match_pricing, + ) # ── Stage definitions ───────────────────────────────────────────────────────── From da7bc2b413533b886e539afe686b67773bd0ca23 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 06:44:28 +0000 Subject: [PATCH 07/15] rpg_edit: merge to base branch instead of main branch. --- CoderMind/templates/commands/rpg_edit.md | 42 +++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/CoderMind/templates/commands/rpg_edit.md b/CoderMind/templates/commands/rpg_edit.md index bb8550f..f467237 100644 --- a/CoderMind/templates/commands/rpg_edit.md +++ b/CoderMind/templates/commands/rpg_edit.md @@ -241,9 +241,16 @@ replies above before proceeding. Treat any other free-form reply as ### Step 5: Apply Changes (RPG-First, on a dedicated branch) All work in this step happens on a fresh `rpg-edit/` branch -in the project repo (workspace root), never directly on `main`. The -branch is merged into `main` only after Step 5e tests pass, so a -failed run leaves `main` clean and the branch preserved for inspection. +in the project repo (workspace root), never directly on the user's +working branch. The branch is merged back into `` — the +branch the user was on when the command started — only after Step 5e +tests pass, so a failed run leaves `` clean and the branch +preserved for inspection. + +The user may be developing on any branch (a feature branch, not +necessarily `main`). Never assume `main`: capture the base branch in +Step 5a and restore it on both the success and failure paths so the +user's git environment is left exactly where they started. `` should be derived from the plan filename or the first affected node id (e.g. last 8 chars of `feature_changes[0].node_id`). @@ -259,9 +266,19 @@ test -z "$(git status --porcelain)" || { echo "Error: working tree has uncommitted changes. Commit or stash first."; exit 1; } +# Capture the branch the user is currently on so we can merge back into +# it (and restore it on failure). Do NOT assume `main` — the user may +# be developing on a feature branch. +BASE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +echo "base branch: $BASE_BRANCH" + git checkout -b rpg-edit/ ``` +Remember `` = the captured `$BASE_BRANCH` value — every +later step that references `` means this branch, not +`main`. + If the precondition fails, surface the error to the user and stop — do **not** silently `git stash`, as that would hide their work. @@ -339,22 +356,23 @@ If any test step fails: fix the code on the branch, re-run dep-refresh (Step 5c command), `git commit --amend --no-edit` to fold the fix into the same branch commit, then re-test. -**Step 5e — Merge into `main` (only after Step 5d is green):** +**Step 5e — Merge into `` (only after Step 5d is green):** ```bash -git checkout main +git checkout "$BASE_BRANCH" git merge --no-ff rpg-edit/ -m "rpg_edit: merge " git branch -d rpg-edit/ ``` `--no-ff` preserves the merge commit so the rpg_edit boundary is -visible in `git log --graph`. +visible in `git log --graph`. The user is returned to `` +— the same branch they started on. ### Step 6: Report Results - **Success path** (Step 5e completed): - > Merged `rpg-edit/` into `main` (commit ``). + > Merged `rpg-edit/` into `` (commit ``). > To revert later: > - Code: `git revert -m 1 ` > - Graphs: `cmind script rpg_edit/apply.py --rollback --json` @@ -369,17 +387,17 @@ visible in `git log --graph`. - **Failure path** (Step 5d failed, Step 5e skipped): - Restore `main` and preserve the branch for the user to inspect: + Restore `` and preserve the branch for the user to inspect: ```bash - git checkout main + git checkout "$BASE_BRANCH" ``` Report to the user: > Tests failed. Branch `rpg-edit/` preserved for inspection. - > `main` is clean. Choose one of: - > - Inspect: `git diff main rpg-edit/` + > `` is clean. Choose one of: + > - Inspect: `git diff rpg-edit/` > - Discard code + graphs together: > `cmind script rpg_edit/apply.py --rollback --rollback-branch rpg-edit/ --json` > - Discard code only: `git branch -D rpg-edit/` @@ -390,6 +408,6 @@ visible in `git log --graph`. 1. **RPG is the anchor** — all modifications start from RPG feature graph nodes, not from files. 2. **Three-way sync** — code, RPG, and dep_graph must stay consistent after every edit. 3. **User confirmation** — always confirm the plan before applying changes. Never auto-apply. -4. **Branch isolation** — `main` is touched only after tests pass. Failed runs leave the work on a `rpg-edit/` branch for inspection. +4. **Branch isolation** — `` (the branch the user started on) is touched only after tests pass, and the user is always returned to it. Never assume `main`. Failed runs leave the work on a `rpg-edit/` branch for inspection. 5. **Coordinated rollback** — `--rollback --rollback-branch ` reverts RPG, dep_graph, and the dedicated branch in one step. 6. **Independent command** — does not depend on or invoke any other `/cmind.*` command. From 2acefc3e5233e478d7458c8a5b8e9ffee3a413f2 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 06:44:53 +0000 Subject: [PATCH 08/15] fix: GitRunner init parameter main_branch. --- CoderMind/scripts/common/git_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CoderMind/scripts/common/git_utils.py b/CoderMind/scripts/common/git_utils.py index ebe40e3..c86abd1 100644 --- a/CoderMind/scripts/common/git_utils.py +++ b/CoderMind/scripts/common/git_utils.py @@ -85,12 +85,12 @@ class GitRunner: def __init__( self, repo_path: str, - main_branch: str = "main", + main_branch: str = MAIN_BRANCH, logger: Optional[logging.Logger] = None ): self.repo_path = Path(repo_path) self.logger = logger or logging.getLogger(__name__) - self.main_branch = self.MAIN_BRANCH + self.main_branch = main_branch # Ensure repo exists and is a git repo self._ensure_git_repository() @@ -149,7 +149,7 @@ def _ensure_git_repository(self) -> None: if not git_dir.exists(): self.logger.info("Initializing git repository...") self.repo_path.mkdir(parents=True, exist_ok=True) - self.run_git(["init", "-b", self.MAIN_BRANCH]) + self.run_git(["init", "-b", self.main_branch]) # Configure safe directory self.run_git([ From 294727f0866d360833177fb6535e4fd70fdcf9cd Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 08:53:05 +0000 Subject: [PATCH 09/15] fix: handle list-valued RPG meta paths in graph search Normalize list-valued feature paths before searching so search_rpg with feature/all scope no longer crashes when RPG nodes map to multiple paths. Add regression coverage for list-valued meta.path entries. --- CoderMind/scripts/rpg/graph_query.py | 28 ++++++++++++++++--------- CoderMind/tests/test_encode_commands.py | 16 +++++++++++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/CoderMind/scripts/rpg/graph_query.py b/CoderMind/scripts/rpg/graph_query.py index 734d07f..cc119a6 100644 --- a/CoderMind/scripts/rpg/graph_query.py +++ b/CoderMind/scripts/rpg/graph_query.py @@ -135,18 +135,26 @@ def from_files(cls, rpg_path: str, dep_graph_path: str = "") -> "GraphQueryEngin # Helpers # ------------------------------------------------------------------ - def _normalize_path(self, meta_path: str) -> str: - """Strip redundant code_dir prefix from RPG meta.path. - - For legacy data where ``_dep_graph_code_dir`` was ``"repo"``, - this converts e.g. ``repo/routes/auth.py`` to ``routes/auth.py`` - so it aligns with dep_graph node IDs. In the unified - workspace==repo layout the prefix is empty and this is a no-op. - """ + def _normalize_path(self, meta_path: Any) -> Any: + """Strip redundant code_dir prefix from RPG meta.path.""" + if isinstance(meta_path, list): + return [self._normalize_path(path) for path in meta_path] + if not isinstance(meta_path, str): + return meta_path if self._code_dir_prefix and meta_path.startswith(self._code_dir_prefix): return meta_path[len(self._code_dir_prefix):] return meta_path + def _path_search_text(self, meta_path: Any) -> str: + normalized_path = self._normalize_path(meta_path) + if isinstance(normalized_path, list): + return " ".join( + self._path_search_text(path) for path in normalized_path + ) + if isinstance(normalized_path, str): + return normalized_path.split(":", 1)[0].lower() + return str(normalized_path).lower() if normalized_path else "" + def _get_feature_path(self, node_id: str) -> str: """Build ancestor chain for an RPG node (excluding repo root).""" parts: list[str] = [] @@ -306,8 +314,8 @@ def _search_rpg_tree(self, query: str, top_k: int) -> List[Dict[str, Any]]: elif query in name.lower(): score = 75 else: - file_part = meta_path.split(":")[0] if ":" in meta_path else meta_path - if query in file_part.lower(): + path_text = self._path_search_text(meta_path) + if path_text and query in path_text: score = 60 elif query in nid.lower(): score = 55 diff --git a/CoderMind/tests/test_encode_commands.py b/CoderMind/tests/test_encode_commands.py index 4014e39..e8aa194 100644 --- a/CoderMind/tests/test_encode_commands.py +++ b/CoderMind/tests/test_encode_commands.py @@ -533,7 +533,10 @@ def _make_rpg_with_dep_graph(tmp_path): "name": "Core Logic", "node_type": "functional_area", "level": 1, - "meta": {"type_name": "directory", "path": "."}, + "meta": { + "type_name": "directory", + "path": ["src/core.py", "src/extra.py"], + }, "children": [ { "id": "feat_1", @@ -605,6 +608,17 @@ def test_graph_query_engine_search_feature(self, tmp_rpg_with_dep_graph): assert len(results) >= 1 assert any(r["id"] == "area_1" for r in results) + def test_graph_query_engine_search_list_path(self, tmp_rpg_with_dep_graph): + from rpg.graph_query import GraphQueryEngine + engine = GraphQueryEngine.from_rpg_file(tmp_rpg_with_dep_graph) + feature_results = engine.search("src/core.py", scope="feature") + all_results = engine.search("src/extra.py", scope="all") + assert any( + r["id"] == "area_1" and r["path"] == ["src/core.py", "src/extra.py"] + for r in feature_results + ) + assert any(r["id"] == "area_1" for r in all_results) + def test_graph_query_engine_explore(self, tmp_rpg_with_dep_graph): """explore() should traverse edges from a node.""" from rpg.graph_query import GraphQueryEngine From 5e6930bb00512f2fb62006930ba3294894933a2e Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 09:04:21 +0000 Subject: [PATCH 10/15] rpg_edit: Add the structured event contract currently missing from the --- CoderMind/scripts/check_code_gen.py | 24 +- CoderMind/scripts/common/run_events.py | 234 +++++++++++++++++++ CoderMind/scripts/common/run_report.py | 241 +++++++------------- CoderMind/scripts/plan.py | 39 ++-- CoderMind/scripts/rpg_edit/review.py | 52 ++++- CoderMind/scripts/rpg_encoder/run_encode.py | 31 +-- CoderMind/scripts/run_batch.py | 33 +-- CoderMind/scripts/update_graphs.py | 67 +++--- CoderMind/tests/test_encode_commands.py | 14 +- CoderMind/tests/test_rpg_edit_run_report.py | 14 +- CoderMind/tests/test_run_report.py | 127 ++++++++--- 11 files changed, 574 insertions(+), 302 deletions(-) create mode 100644 CoderMind/scripts/common/run_events.py diff --git a/CoderMind/scripts/check_code_gen.py b/CoderMind/scripts/check_code_gen.py index 387f439..5a0da18 100644 --- a/CoderMind/scripts/check_code_gen.py +++ b/CoderMind/scripts/check_code_gen.py @@ -29,6 +29,7 @@ cmd_for, REPO_DIR, ) +from common.run_events import ArtifactEvent, CommandRun, StepEvent, VerificationEvent from common.run_report import write_command_report from common.execution_state import load_code_gen_state from common.execution_state import load_code_gen_state as _load_state, save_code_gen_state as _save_state @@ -436,11 +437,11 @@ def determine_state( def _write_code_gen_report(result: Dict[str, Any]) -> str | None: try: stats = result.get("stats") or {} - report_path = write_command_report( - "code_gen", + report_path = write_command_report(CommandRun( + command="code_gen", title="CoderMind code_gen Progress View", status=result.get("type"), - summary_cards=[ + summary=[ {"label": "state", "value": result.get("type", "")}, {"label": "total", "value": stats.get("total_tasks", 0)}, {"label": "completed", "value": stats.get("completed", 0)}, @@ -449,13 +450,16 @@ def _write_code_gen_report(result: Dict[str, Any]) -> str | None: {"label": "current batch", "value": (result.get("current_batch") or {}).get("batch_id", "")}, {"label": "next batch", "value": result.get("next_batch", "")}, ], - stages=[ - {"name": "determine_state", "status": result.get("type"), "reason": result.get("message", "")}, - {"name": "current_batch", "status": (result.get("current_batch") or {}).get("phase", "none"), "reason": (result.get("current_batch") or {}).get("file_path", "")}, - {"name": "next_action", "status": "available" if result.get("next_action") else "missing", "reason": result.get("next_action", "")}, + steps=[ + StepEvent(name="determine_state", status=result.get("type"), reason=result.get("message", "")), + StepEvent(name="current_batch", status=(result.get("current_batch") or {}).get("phase", "none"), reason=(result.get("current_batch") or {}).get("file_path", "")), + StepEvent(name="next_action", status="available" if result.get("next_action") else "missing", reason=result.get("next_action", "")), ], - artifacts={"tasks": TASKS_FILE, "code_gen_state": STATE_FILE}, - verification=[{"name": "state", "status": result.get("type"), "detail": result.get("message", "")}], + artifacts=[ + ArtifactEvent(label="tasks", path=TASKS_FILE), + ArtifactEvent(label="code_gen_state", path=STATE_FILE), + ], + verification=[VerificationEvent(name="state", status=result.get("type"), detail=result.get("message", ""))], evidence={ "type": result.get("type"), "stats": stats, @@ -463,7 +467,7 @@ def _write_code_gen_report(result: Dict[str, Any]) -> str | None: "next_batch": result.get("next_batch"), "next_action": result.get("next_action"), }, - ) + )) return str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/scripts/common/run_events.py b/CoderMind/scripts/common/run_events.py new file mode 100644 index 0000000..ca61bc0 --- /dev/null +++ b/CoderMind/scripts/common/run_events.py @@ -0,0 +1,234 @@ +"""Structured event contract for command run reports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence + + +def _to_plain(value: Any) -> Any: + if hasattr(value, "to_dict"): + return value.to_dict() + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + return {str(key): _to_plain(item_value) for key, item_value in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_to_plain(item) for item in value] + return value + + +def _compact(values: Mapping[str, Any]) -> dict[str, Any]: + compacted: dict[str, Any] = {} + for key, value in values.items(): + plain = _to_plain(value) + if plain is None or plain == "" or plain == [] or plain == {}: + continue + compacted[key] = plain + return compacted + + +def _as_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_to_plain(item) for item in value] + return [_to_plain(value)] + + +def _artifact_status(path: Any, status: Any = None) -> Any: + if status not in (None, ""): + return status + if path in (None, ""): + return "missing" + try: + return "available" if Path(str(path)).expanduser().exists() else "missing" + except (OSError, ValueError): + return "missing" + + +@dataclass +class StepEvent: + name: Any = "step" + status: Any = "recorded" + reason: Any = None + duration: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "name": self.name, + "status": self.status, + "reason": self.reason, + "duration": self.duration, + }) + + +@dataclass +class RetrievalEvent: + query: Any = None + tool: Any = None + hits: Any = None + reason: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "query": self.query, + "tool": self.tool, + "hits": self.hits, + "reason": self.reason, + }) + + +@dataclass +class RPGDeltaEvent: + node_id: Any = None + name: Any = None + type: Any = None + path: Any = None + change: Any = None + score: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "node_id": self.node_id, + "name": self.name, + "type": self.type, + "path": self.path, + "change": self.change, + "score": self.score, + }) + + +@dataclass +class DepGraphDeltaEvent: + dep_node_id: Any = None + path: Any = None + source_feature: Any = None + change: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "dep_node_id": self.dep_node_id, + "path": self.path, + "source_feature": self.source_feature, + "change": self.change, + }) + + +@dataclass +class CodeDeltaEvent: + file: Any = None + change_type: Any = None + before: Any = None + after: Any = None + diff: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "file": self.file, + "change_type": self.change_type, + "before": self.before, + "after": self.after, + "diff": self.diff, + }) + + +@dataclass +class VerificationEvent: + name: Any = "verification" + status: Any = None + detail: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "name": self.name, + "status": self.status, + "detail": self.detail, + }) + + +@dataclass +class UserDecisionEvent: + decision: Any = None + branch: Any = None + before_state: Any = None + rollback_path: Any = None + confirmed: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "decision": self.decision, + "branch": self.branch, + "before_state": self.before_state, + "rollback_path": self.rollback_path, + "confirmed": self.confirmed, + }) + + +@dataclass +class ArtifactEvent: + label: Any = "artifact" + path: Any = None + status: Any = None + + def to_dict(self) -> dict[str, Any]: + return _compact({ + "label": self.label, + "path": self.path, + "status": _artifact_status(self.path, self.status), + }) + + +@dataclass +class CommandRun: + command: str = "command" + status: Any = None + title: Any = None + timestamp: Any = None + summary: Any = None + steps: Any = None + retrievals: Any = None + rpg_deltas: Any = None + dep_graph_deltas: Any = None + code_deltas: Any = None + verification: Any = None + user_decisions: Any = None + artifacts: Any = None + evidence: Any = None + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "command": self.command, + "status": _to_plain(self.status), + "title": _to_plain(self.title), + "timestamp": _to_plain(self.timestamp), + "summary": _as_list(self.summary), + "steps": _as_list(self.steps), + "retrievals": _as_list(self.retrievals), + "rpg_deltas": _as_list(self.rpg_deltas), + "dep_graph_deltas": _as_list(self.dep_graph_deltas), + "code_deltas": _as_list(self.code_deltas), + "verification": _as_list(self.verification), + "user_decisions": _as_list(self.user_decisions), + "artifacts": _as_list(self.artifacts), + "evidence": _to_plain(self.evidence) if self.evidence is not None else {}, + } + return _compact(data) + + +__all__ = [ + "CommandRun", + "StepEvent", + "RetrievalEvent", + "RPGDeltaEvent", + "DepGraphDeltaEvent", + "CodeDeltaEvent", + "VerificationEvent", + "UserDecisionEvent", + "ArtifactEvent", + "_compact", + "_to_plain", + "_as_list", + "_artifact_status", +] diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index 9c03284..db73a71 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -11,67 +11,34 @@ from urllib.parse import quote from common.paths import REPORTS_DIR +from common.run_events import _to_plain _MAX_SUMMARY_CARDS = 7 def write_command_report( - command: str | None = None, - payload: Any = None, + run: Any, *, - summary_cards: Any = None, - summary: Any = None, - stages: Any = None, - timeline: Any = None, - rpg_nodes: Any = None, - dep_nodes: Any = None, - artifacts: Any = None, - artifact_links: Any = None, - verification: Any = None, - evidence: Any = None, - evidence_json: Any = None, - status: str | None = None, - title: str | None = None, report_dir: str | Path | None = None, timestamp: str | datetime | None = None, - **extra: Any, ) -> Path: """Write a sanitized Explain View HTML report and return its path.""" - if isinstance(payload, Mapping): - extra = {**dict(payload), **extra} - elif payload is not None: - extra.setdefault("payload", payload) - if command is None: - command = str(extra.pop("command", extra.pop("command_name", "command"))) - summary_cards = summary_cards if summary_cards is not None else extra.pop("summary_cards", None) - summary = summary if summary is not None else extra.pop("summary", None) - stages = stages if stages is not None else extra.pop("stages", None) - timeline = timeline if timeline is not None else extra.pop("timeline", None) - rpg_nodes = rpg_nodes if rpg_nodes is not None else extra.pop("rpg_nodes", None) - dep_nodes = dep_nodes if dep_nodes is not None else extra.pop("dep_nodes", None) - artifacts = artifacts if artifacts is not None else extra.pop("artifacts", None) - artifact_links = artifact_links if artifact_links is not None else extra.pop("artifact_links", None) - verification = verification if verification is not None else extra.pop("verification", None) - evidence = evidence if evidence is not None else extra.pop("evidence", None) - evidence_json = evidence_json if evidence_json is not None else extra.pop("evidence_json", None) - status = status if status is not None else extra.pop("status", None) - title = title if title is not None else extra.pop("title", None) - report_dir = report_dir if report_dir is not None else extra.pop("report_dir", None) - timestamp = timestamp if timestamp is not None else extra.pop("timestamp", None) - summary_cards = summary_cards if summary_cards is not None else summary - stages = stages if stages is not None else timeline - artifacts = artifacts if artifacts is not None else artifact_links - evidence = evidence if evidence is not None else evidence_json - - if rpg_nodes is None: - rpg_nodes = extra.pop("rpg_node_evidence", None) or extra.pop("rpg_evidence", None) - if dep_nodes is None: - dep_nodes = extra.pop("dep_node_evidence", None) or extra.pop("dep_evidence", None) - if isinstance(evidence, Mapping): - if rpg_nodes is None: - rpg_nodes = _evidence_nodes(evidence.get("rpg_nodes")) or _evidence_nodes(evidence.get("rpg_node_evidence")) - if dep_nodes is None: - dep_nodes = _evidence_nodes(evidence.get("dep_nodes")) or _evidence_nodes(evidence.get("dep_node_evidence")) + if hasattr(run, "to_dict"): + data = run.to_dict() + elif isinstance(run, Mapping): + data = dict(run) + else: + raise TypeError("write_command_report() expects a CommandRun or mapping") + + data = _to_plain(data) + if not isinstance(data, Mapping): + raise TypeError("CommandRun.to_dict() must return a mapping") + + command = str(data.get("command") or "command") + status = data.get("status") + title = data.get("title") + timestamp = timestamp if timestamp is not None else data.get("timestamp") + report_dir = report_dir if report_dir is not None else data.get("report_dir") generated_at = _display_timestamp(timestamp) filename_ts = _filename_timestamp(timestamp) @@ -80,33 +47,19 @@ def write_command_report( target_dir.mkdir(parents=True, exist_ok=True) report_path = _unique_report_path(target_dir / f"cmind_run_{safe_command}_{filename_ts}.html") - aggregate_evidence = { - "command": command, - "status": status, - "summary_cards": summary_cards, - "stages": stages, - "rpg_nodes": rpg_nodes, - "dep_nodes": dep_nodes, - "artifacts": artifacts, - "verification": verification, - "evidence": evidence, - } - for key, value in extra.items(): - aggregate_evidence[key] = value - page_title = title or f"CoderMind {command} Explain View" html = _render_page( title=page_title, command=command, generated_at=generated_at, status=status, - summary_cards=_normalize_cards(summary_cards), - stages=_normalize_stages(stages), - rpg_nodes=_normalize_nodes(rpg_nodes), - dep_nodes=_normalize_nodes(dep_nodes), - artifacts=_normalize_artifacts(artifacts), - verification=_normalize_verification(verification), - evidence=aggregate_evidence, + summary_cards=_normalize_cards(data.get("summary")), + stages=_normalize_stages(data.get("steps")), + rpg_nodes=_normalize_nodes(data.get("rpg_deltas"), dep_graph=False), + dep_nodes=_normalize_nodes(data.get("dep_graph_deltas"), dep_graph=True), + artifacts=_normalize_artifacts(data.get("artifacts")), + verification=_normalize_verification(data.get("verification")), + evidence=dict(data), ) report_path.write_text(html, encoding="utf-8") return report_path @@ -153,7 +106,7 @@ def _as_sequence(value: Any) -> list[Any]: if isinstance(value, (str, bytes, Path)): return [value] if isinstance(value, Mapping): - return list(value.items()) + return [value] if isinstance(value, Sequence): return list(value) return [value] @@ -161,19 +114,13 @@ def _as_sequence(value: Any) -> list[Any]: def _normalize_cards(value: Any) -> list[dict[str, Any]]: cards: list[dict[str, Any]] = [] - if isinstance(value, Mapping): - iterable = value.items() - else: - iterable = _as_sequence(value) - for item in iterable: - if isinstance(item, tuple) and len(item) == 2: - label, card_value = item - cards.append({"label": label, "value": card_value}) - elif isinstance(item, Mapping): - label = item.get("label") or item.get("title") or item.get("name") or item.get("key") or "Summary" - card_value = item.get("value", item.get("count", item.get("text", ""))) - detail = item.get("detail") or item.get("description") - cards.append({"label": label, "value": card_value, "detail": detail}) + for item in _as_sequence(value): + if isinstance(item, Mapping): + cards.append({ + "label": item.get("label") or "Summary", + "value": item.get("value", ""), + "detail": item.get("detail"), + }) else: cards.append({"label": "Summary", "value": item}) return cards[:_MAX_SUMMARY_CARDS] @@ -182,68 +129,41 @@ def _normalize_cards(value: Any) -> list[dict[str, Any]]: def _normalize_stages(value: Any) -> list[dict[str, Any]]: stages: list[dict[str, Any]] = [] for item in _as_sequence(value): - if isinstance(item, tuple) and len(item) == 2: - name, state = item - stages.append({"name": name, "status": state}) - elif isinstance(item, Mapping): - name = item.get("name") or item.get("stage") or item.get("id") or "stage" - status = item.get("status") or item.get("state") or item.get("type") or item.get("action") or "recorded" - reason = item.get("reason") or item.get("message") or item.get("description") or "" - duration = item.get("duration") or item.get("elapsed") or item.get("elapsed_seconds") - stages.append({"name": name, "status": status, "reason": reason, "duration": duration}) + if isinstance(item, Mapping): + stages.append({ + "name": item.get("name") or "stage", + "status": item.get("status", "recorded"), + "reason": item.get("reason", ""), + "duration": item.get("duration"), + }) else: stages.append({"name": item, "status": "recorded"}) return stages -def _evidence_nodes(value: Any) -> Any: - if isinstance(value, Mapping): - return value - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, Path)): - return value - return None - - -def _normalize_nodes(value: Any) -> list[dict[str, Any]]: +def _normalize_nodes(value: Any, *, dep_graph: bool = False) -> list[dict[str, Any]]: nodes: list[dict[str, Any]] = [] + id_key = "dep_node_id" if dep_graph else "node_id" for item in _as_sequence(value): - if isinstance(item, tuple) and len(item) == 2: - node_id, node_value = item - if isinstance(node_value, Mapping): - entry = {"node_id": node_id, **dict(node_value)} - else: - entry = {"node_id": node_id, "value": node_value} - elif isinstance(item, Mapping): + if isinstance(item, Mapping): entry = dict(item) else: - entry = {"node_id": item} + entry = {id_key: item} nodes.append(entry) return nodes def _normalize_artifacts(value: Any) -> list[dict[str, Any]]: artifacts: list[dict[str, Any]] = [] - if isinstance(value, Mapping): - iterable = value.items() - else: - iterable = _as_sequence(value) - for item in iterable: - if isinstance(item, tuple) and len(item) == 2: - label, path = item - artifacts.append({"label": label, "path": path, "status": _artifact_status(path)}) - elif isinstance(item, Mapping): - path = item.get("path") or item.get("href") or item.get("url") or item.get("file") - label = item.get("label") or item.get("name") or item.get("title") - if path is None: - path_items = [ - (key, item_value) - for key, item_value in item.items() - if key not in {"label", "name", "title", "status"} - ] - if len(path_items) == 1: - label, path = path_items[0] - label = label or path or "artifact" - artifacts.append({"label": label, "path": path, "status": _artifact_status(path, item.get("status"))}) + for item in _as_sequence(value): + if isinstance(item, Mapping): + path = item.get("path") + artifacts.append({ + "label": item.get("label") or path or "artifact", + "path": path, + "status": _artifact_status(path, item.get("status")), + "detail": item.get("detail"), + }) else: artifacts.append({"label": Path(str(item)).name or "artifact", "path": item, "status": _artifact_status(item)}) return artifacts @@ -262,24 +182,13 @@ def _artifact_status(path: Any, status: Any = None) -> Any: def _normalize_verification(value: Any) -> list[dict[str, Any]]: checks: list[dict[str, Any]] = [] - if isinstance(value, Mapping) and not any(isinstance(v, Mapping) for v in value.values()): - for key, check_value in value.items(): - checks.append({"name": key, "status": check_value}) - return checks - if isinstance(value, Mapping): - iterable = value.items() - else: - iterable = _as_sequence(value) - for item in iterable: - if isinstance(item, tuple) and len(item) == 2: - name, check_value = item - if isinstance(check_value, Mapping): - checks.append({"name": name, **dict(check_value)}) - else: - checks.append({"name": name, "status": check_value}) - elif isinstance(item, Mapping): - name = item.get("name") or item.get("check") or item.get("label") or "verification" - checks.append({"name": name, **dict(item)}) + for item in _as_sequence(value): + if isinstance(item, Mapping): + checks.append({ + "name": item.get("name") or "verification", + "status": item.get("status", ""), + "detail": item.get("detail"), + }) else: checks.append({"name": "verification", "status": item}) return checks @@ -409,9 +318,9 @@ def _render_verification(checks: list[dict[str, Any]]) -> str: for check in checks: rows.append( "" - f"{_h(check.get('name') or check.get('check') or 'verification')}" - f"{_h(check.get('status', check.get('success', '')))}" - f"{_h(check.get('detail') or check.get('message') or check.get('reason') or '')}" + f"{_h(check.get('name') or 'verification')}" + f"{_h(check.get('status', ''))}" + f"{_h(check.get('detail', ''))}" "" ) body = "" + "".join(rows) + "
    CheckStatusDetail
    " @@ -421,20 +330,28 @@ def _render_verification(checks: list[dict[str, Any]]) -> str: def _render_node_table(title: str, nodes: list[dict[str, Any]]) -> str: if not nodes: body = "

    No node evidence recorded.

    " + elif any("dep_node_id" in node for node in nodes): + rows = [] + for node in nodes: + rows.append( + "" + f"{_h(node.get('dep_node_id', ''))}" + f"{_h(node.get('path', ''))}" + f"{_h(node.get('source_feature', ''))}" + f"{_h(node.get('change', ''))}" + "" + ) + body = "" + "".join(rows) + "
    IDPathSource featureChange
    " else: rows = [] for node in nodes: - node_id = node.get("node_id") or node.get("id") or node.get("dep_node") or "" - node_type = node.get("type_name") or node.get("node_type") or node.get("type") or "" - path = node.get("meta_path") or node.get("path") or node.get("file_path") or node.get("feature_path") or "" - score = node.get("score") or node.get("weight") or node.get("status") or "" rows.append( "" - f"{_h(node_id)}" + f"{_h(node.get('node_id', ''))}" f"{_h(node.get('name', ''))}" - f"{_h(node_type)}" - f"{_h(path)}" - f"{_h(score)}" + f"{_h(node.get('type', ''))}" + f"{_h(node.get('path', ''))}" + f"{_h(node.get('score', ''))}" "" ) body = "" + "".join(rows) + "
    IDNameTypePathScore/status
    " diff --git a/CoderMind/scripts/plan.py b/CoderMind/scripts/plan.py index 6a3a421..9ec9efc 100644 --- a/CoderMind/scripts/plan.py +++ b/CoderMind/scripts/plan.py @@ -70,6 +70,7 @@ SKELETON_SUMMARY_FILE, TASKS_FILE, ) +from common.run_events import ArtifactEvent, CommandRun, StepEvent, VerificationEvent from common.run_report import write_command_report # --------------------------------------------------------------------------- @@ -352,32 +353,40 @@ def _write_plan_report( cards.append({"label": "elapsed", "value": f"{elapsed:.1f}s"}) if post_steps is not None: cards.append({"label": "post steps", "value": len(post_steps)}) - timeline = _stage_rows(states) + stage_rows = _stage_rows(states) + artifact_rows = _plan_artifacts() + steps = [ + StepEvent(name=row["name"], status=row["status"], reason=row.get("reason", "")) + for row in stage_rows + ] for post in post_steps or []: - timeline.append({ - "name": post.get("name", "post-step"), - "status": "ok" if post.get("returncode") == 0 else "warning", - "reason": f"exit {post.get('returncode')}", - }) - report_path = write_command_report( - "plan", + steps.append(StepEvent( + name=post.get("name", "post-step"), + status="ok" if post.get("returncode") == 0 else "warning", + reason=f"exit {post.get('returncode')}", + )) + report_path = write_command_report(CommandRun( + command="plan", title="CoderMind plan Explain View", status=mode, - summary_cards=cards, - stages=timeline, - artifacts=_plan_artifacts(), + summary=cards, + steps=steps, + artifacts=[ + ArtifactEvent(label=row["label"], path=row["path"], status=row.get("status")) + for row in artifact_rows + ], verification=[ - {"name": s.stage.name, "status": s.type, "detail": s.message or s.reason} + VerificationEvent(name=s.stage.name, status=s.type, detail=s.message or s.reason) for s in states ], evidence={ "mode": mode, "elapsed": elapsed, - "stages": _stage_rows(states), + "stages": stage_rows, "post_steps": post_steps or [], - "artifacts": _plan_artifacts(), + "artifacts": artifact_rows, }, - ) + )) return str(report_path) except Exception: return None diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 9b4efc9..cadb764 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -44,6 +44,14 @@ RPG_EDIT_CODE_RESULT_FILE, RPG_EDIT_REVIEW_RESULT_FILE, ) +from common.run_events import ( # noqa: E402 + ArtifactEvent, + CommandRun, + DepGraphDeltaEvent, + RPGDeltaEvent, + StepEvent, + VerificationEvent, +) from common.run_report import write_command_report # noqa: E402 logger = logging.getLogger(__name__) @@ -197,18 +205,44 @@ def _publish_review_report(result: Dict[str, Any], plan_path: Path, impact_path: artifacts = _load_review_artifacts(plan_path, impact_path) candidates = _selected_candidate_rows(artifacts) try: - report_path = write_command_report( - "rpg_edit", + report_path = write_command_report(CommandRun( + command="rpg_edit", title="CoderMind rpg_edit Explain View", status=str(result.get("type", "review")), - summary_cards=_review_summary_cards(result, artifacts), - stages=_review_timeline(result, artifacts), - rpg_nodes=candidates, - dep_nodes=_dep_node_rows(candidates), - artifacts=_artifact_links(plan_path, impact_path), - verification=_review_verification(result, artifacts), + summary=_review_summary_cards(result, artifacts), + steps=[ + StepEvent(name=row.get("name", "stage"), status=row.get("status"), reason=row.get("reason", "")) + for row in _review_timeline(result, artifacts) + ], + rpg_deltas=[ + RPGDeltaEvent( + node_id=row.get("node_id"), + name=row.get("name"), + type=row.get("type"), + path=row.get("path") or row.get("meta_path"), + score=row.get("score"), + ) + for row in candidates + ], + dep_graph_deltas=[ + DepGraphDeltaEvent( + dep_node_id=row.get("node_id"), + path=row.get("path"), + source_feature=row.get("source_feature"), + change=row.get("change"), + ) + for row in _dep_node_rows(candidates) + ], + artifacts=[ + ArtifactEvent(label=row["label"], path=row["path"], status=row.get("status")) + for row in _artifact_links(plan_path, impact_path) + ], + verification=[ + VerificationEvent(name=row.get("name", "verification"), status=row.get("status"), detail=row.get("detail")) + for row in _review_verification(result, artifacts) + ], evidence={"artifacts": artifacts, "review_result": result}, - ) + )) result["report_path"] = str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/scripts/rpg_encoder/run_encode.py b/CoderMind/scripts/rpg_encoder/run_encode.py index f159dfb..6c299a1 100644 --- a/CoderMind/scripts/rpg_encoder/run_encode.py +++ b/CoderMind/scripts/rpg_encoder/run_encode.py @@ -27,6 +27,7 @@ from common.paths import RPG_FILE, RPG_HTML_FILE, WORKSPACE_ROOT, ensure_cmind_dir # noqa: E402 from common.rpg_io import atomic_write_rpg # noqa: E402 +from common.run_events import ArtifactEvent, CommandRun, StepEvent, VerificationEvent # noqa: E402 from common.run_report import write_command_report # noqa: E402 from common.trajectory import Trajectory # noqa: E402 @@ -40,11 +41,11 @@ def _attach_encode_report(result: dict) -> dict: dep_summary.append(f"edges={result.get('dep_edges')}") if result.get("dep_to_rpg_map_size") is not None: dep_summary.append(f"mapped={result.get('dep_to_rpg_map_size')}") - report_path = write_command_report( - "encode", + report_path = write_command_report(CommandRun( + command="encode", title="CoderMind encode Explain View", status=result.get("status"), - summary_cards=[ + summary=[ {"label": "repo", "value": result.get("repo_name", "unknown")}, {"label": "RPG nodes", "value": result.get("node_count", 0)}, {"label": "RPG edges", "value": result.get("edge_count", 0)}, @@ -53,20 +54,20 @@ def _attach_encode_report(result: dict) -> dict: {"label": "visualization", "value": result.get("viz_path", result.get("viz_error", ""))}, {"label": "trajectory", "value": result.get("trajectory", "")}, ], - stages=[ - {"name": "parse_rpg", "status": "recorded", "reason": f"nodes={result.get('node_count', 0)}, edges={result.get('edge_count', 0)}"}, - {"name": "dep_graph", "status": "recorded" if dep_summary else "not recorded", "reason": ", ".join(dep_summary)}, - {"name": "save_rpg", "status": "recorded" if result.get("output_path") else "not recorded", "reason": result.get("output_path", "")}, - {"name": "visualize", "status": "recorded" if result.get("viz_path") else "not recorded", "reason": result.get("viz_path") or result.get("viz_error", "")}, + steps=[ + StepEvent(name="parse_rpg", status="recorded", reason=f"nodes={result.get('node_count', 0)}, edges={result.get('edge_count', 0)}"), + StepEvent(name="dep_graph", status="recorded" if dep_summary else "not recorded", reason=", ".join(dep_summary)), + StepEvent(name="save_rpg", status="recorded" if result.get("output_path") else "not recorded", reason=result.get("output_path", "")), + StepEvent(name="visualize", status="recorded" if result.get("viz_path") else "not recorded", reason=result.get("viz_path") or result.get("viz_error", "")), ], - artifacts={ - "rpg_json": result.get("output_path"), - "rpg_html": result.get("viz_path"), - "trajectory": result.get("trajectory"), - }, - verification={"encode": result.get("status")}, + artifacts=[ + ArtifactEvent(label="rpg_json", path=result.get("output_path")), + ArtifactEvent(label="rpg_html", path=result.get("viz_path")), + ArtifactEvent(label="trajectory", path=result.get("trajectory")), + ], + verification=[VerificationEvent(name="encode", status=result.get("status"))], evidence=result, - ) + )) result["report_path"] = str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/scripts/run_batch.py b/CoderMind/scripts/run_batch.py index d31555c..02ce272 100644 --- a/CoderMind/scripts/run_batch.py +++ b/CoderMind/scripts/run_batch.py @@ -65,6 +65,7 @@ cmd_for, REPO_DIR, ) +from common.run_events import ArtifactEvent, CommandRun, StepEvent, VerificationEvent from common.run_report import write_command_report from code_gen.context_collector import build_dependency_context from code_gen.prompts import ( @@ -929,11 +930,11 @@ def _write_batch_report(result: Dict[str, Any]) -> Optional[str]: return None try: stats = result.get("stats") or {} - report_path = write_command_report( - "code_gen", + report_path = write_command_report(CommandRun( + command="code_gen", title="CoderMind code_gen Batch View", status=result.get("type"), - summary_cards=[ + summary=[ {"label": "result", "value": result.get("type", "")}, {"label": "success", "value": result.get("success", "")}, {"label": "batch", "value": result.get("batch_id", "")}, @@ -942,23 +943,23 @@ def _write_batch_report(result: Dict[str, Any]) -> Optional[str]: {"label": "completed", "value": stats.get("completed", "")}, {"label": "failed", "value": stats.get("failed", result.get("failed", ""))}, ], - stages=[ - {"name": "batch", "status": result.get("type"), "reason": result.get("failure_reason") or result.get("message", "")}, - {"name": "verification", "status": result.get("success"), "reason": f"passed={result.get('passed', '')} failed={result.get('failed', '')} errors={result.get('errors', '')}"}, - {"name": "next_action", "status": "available" if result.get("next_action") else "missing", "reason": result.get("next_action", "")}, + steps=[ + StepEvent(name="batch", status=result.get("type"), reason=result.get("failure_reason") or result.get("message", "")), + StepEvent(name="verification", status=result.get("success"), reason=f"passed={result.get('passed', '')} failed={result.get('failed', '')} errors={result.get('errors', '')}"), + StepEvent(name="next_action", status="available" if result.get("next_action") else "missing", reason=result.get("next_action", "")), + ], + artifacts=[ + ArtifactEvent(label="feature_spec", path=FEATURE_SPEC_FILE), + ArtifactEvent(label="tasks", path=TASKS_FILE), + ArtifactEvent(label="code_gen_state", path=STATE_FILE), + ArtifactEvent(label="rpg_json", path=REPO_RPG_FILE), ], - artifacts={ - "feature_spec": FEATURE_SPEC_FILE, - "tasks": TASKS_FILE, - "code_gen_state": STATE_FILE, - "rpg_json": REPO_RPG_FILE, - }, verification=[ - {"name": "result", "status": result.get("success", result.get("type"))}, - {"name": "pytest", "status": result.get("passed", ""), "detail": f"failed={result.get('failed', '')}, errors={result.get('errors', '')}"}, + VerificationEvent(name="result", status=result.get("success", result.get("type"))), + VerificationEvent(name="pytest", status=result.get("passed", ""), detail=f"failed={result.get('failed', '')}, errors={result.get('errors', '')}"), ], evidence={"result": result}, - ) + )) return str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/scripts/update_graphs.py b/CoderMind/scripts/update_graphs.py index 605e798..d4b2042 100644 --- a/CoderMind/scripts/update_graphs.py +++ b/CoderMind/scripts/update_graphs.py @@ -34,6 +34,7 @@ from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, HOOK_CALLS_LOG # noqa: E402 from common.rpg_io import atomic_write_rpg, safe_load_rpg # noqa: E402 +from common.run_events import ArtifactEvent, CommandRun, StepEvent, VerificationEvent # noqa: E402 from common.run_report import write_command_report # noqa: E402 @@ -167,11 +168,11 @@ def _attach_update_report(result: dict) -> dict: viz_status = result.get("viz_error") or ( "ok" if result.get("viz_path") else "not recorded" ) - report_path = write_command_report( - "update_rpg", + report_path = write_command_report(CommandRun( + command="update_rpg", title="CoderMind update_rpg Explain View", status=result.get("mode") or result.get("status"), - summary_cards=[ + summary=[ {"label": "mode", "value": result.get("mode", "")}, { "label": "reason", @@ -192,48 +193,48 @@ def _attach_update_report(result: dict) -> dict: "value": result.get("viz_path") or result.get("viz_error", ""), }, ], - stages=[ - { - "name": "git delta", - "status": result.get("mode", ""), - "reason": ( + steps=[ + StepEvent( + name="git delta", + status=result.get("mode", ""), + reason=( f"{git_total} changed files" if git_total != "" else "not recorded" ), - }, - { - "name": "semantic delta", - "status": result.get("mode", ""), - "reason": _format_diff_summary(semantic_summary), - }, - { - "name": "sync graph", - "status": result.get("status", result.get("mode", "")), - "reason": result.get("reason", ""), - }, - { - "name": "visualize", - "status": ( + ), + StepEvent( + name="semantic delta", + status=result.get("mode", ""), + reason=_format_diff_summary(semantic_summary), + ), + StepEvent( + name="sync graph", + status=result.get("status", result.get("mode", "")), + reason=result.get("reason", ""), + ), + StepEvent( + name="visualize", + status=( "ok" if result.get("viz_path") else "error" if result.get("viz_error") else "skipped" ), - "reason": result.get("viz_path") or result.get("viz_error", ""), - }, + reason=result.get("viz_path") or result.get("viz_error", ""), + ), + ], + artifacts=[ + ArtifactEvent(label="rpg_json", path=rpg_path), + ArtifactEvent(label="rpg_html", path=result.get("viz_path")), + ], + verification=[ + VerificationEvent(name="update_rpg", status=result.get("status", result.get("mode"))), + VerificationEvent(name="viz", status=viz_status), ], - artifacts={ - "rpg_json": rpg_path, - "rpg_html": result.get("viz_path"), - }, - verification={ - "update_rpg": result.get("status", result.get("mode")), - "viz": viz_status, - }, evidence=result, - ) + )) result["report_path"] = str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/tests/test_encode_commands.py b/CoderMind/tests/test_encode_commands.py index e8aa194..3af2f6d 100644 --- a/CoderMind/tests/test_encode_commands.py +++ b/CoderMind/tests/test_encode_commands.py @@ -402,9 +402,8 @@ def test_attach_update_report_uses_update_rpg_result_fields(tmp_path, monkeypatc captured = {} - def fake_write_command_report(command, **kwargs): - captured["command"] = command - captured.update(kwargs) + def fake_write_command_report(run): + captured.update(run.to_dict()) return tmp_path / "report.html" monkeypatch.setattr(update_graphs, "write_command_report", fake_write_command_report) @@ -432,15 +431,16 @@ def fake_write_command_report(command, **kwargs): "viz_path": "/tmp/rpg.html", }) - cards = {card["label"]: card["value"] for card in captured["summary_cards"]} + cards = {card["label"]: card["value"] for card in captured["summary"]} + artifacts = {artifact["label"]: artifact["path"] for artifact in captured["artifacts"]} assert result["report_path"] == str(tmp_path / "report.html") assert cards["git files"] == 2 assert cards["semantic files"] == 3 assert cards["RPG nodes"] == "4504 (delta: +2)" assert cards["dep graph"] == "nodes=2708 (delta: +46), edges=5498 (delta: +103)" - assert captured["artifacts"]["rpg_json"] == "/tmp/rpg.json" - assert captured["stages"][0]["reason"] == "2 changed files" - assert captured["stages"][1]["reason"] == "3 semantic files, modified=3" + assert artifacts["rpg_json"] == "/tmp/rpg.json" + assert captured["steps"][0]["reason"] == "2 changed files" + assert captured["steps"][1]["reason"] == "3 semantic files, modified=3" # ============================================================================ diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index 257cd10..469f9f2 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -70,9 +70,10 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) - def fake_write_command_report(*args, **kwargs): + def fake_write_command_report(run): + data = run.to_dict() review_artifact = next( - item for item in kwargs["artifacts"] if item["label"] == "review_result" + item for item in data["artifacts"] if item["label"] == "review_result" ) assert review_artifact["status"] == "available" return report_path @@ -112,10 +113,11 @@ def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) - def fake_write_command_report(*args, **kwargs): - assert kwargs["rpg_nodes"] == [{"node_id": "planned", "name": "Planned Node", "dep_nodes": [dep_id]}] - assert kwargs["dep_nodes"] == [ - {"node_id": dep_id, "source_feature": "planned", "path": "scripts/common/run_report.py"} + def fake_write_command_report(run): + data = run.to_dict() + assert data["rpg_deltas"] == [{"node_id": "planned", "name": "Planned Node"}] + assert data["dep_graph_deltas"] == [ + {"dep_node_id": dep_id, "path": "scripts/common/run_report.py", "source_feature": "planned"} ] return report_path diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index 6fef570..ecf90ad 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -8,26 +8,39 @@ if str(_SCRIPTS) not in sys.path: sys.path.insert(0, str(_SCRIPTS)) +from common.run_events import ( + ArtifactEvent, + CodeDeltaEvent, + CommandRun, + DepGraphDeltaEvent, + RPGDeltaEvent, + RetrievalEvent, + StepEvent, + UserDecisionEvent, + VerificationEvent, +) from common.run_report import write_command_report def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path) -> None: report = write_command_report( - "rpg/edit ", - title="Title ", - status="ok ", - summary_cards=[ - {"label": "node", "value": ""}, - {"label": "count", "value": 3}, - ], - stages=[{"name": "locate ", "status": "done", "reason": "score > 1"}], - rpg_nodes=[{"node_id": "feature"}, + CommandRun( + command="rpg/edit ", + title="Title ", + status="ok ", + summary=[ + {"label": "node", "value": ""}, + {"label": "count", "value": 3}, + ], + steps=[StepEvent(name="locate ", status="done", reason="score > 1")], + rpg_deltas=[RPGDeltaEvent(node_id="feature"}, + timestamp="2026-06-30T12:34:56Z", + ), report_dir=tmp_path, - timestamp="2026-06-30T12:34:56Z", ) assert report.parent == tmp_path @@ -43,10 +56,12 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path def test_write_command_report_limits_summary_cards(tmp_path: Path) -> None: report = write_command_report( - "encode", - summary_cards=[{"label": f"card-{i}", "value": i} for i in range(9)], + CommandRun( + command="encode", + summary=[{"label": f"card-{i}", "value": i} for i in range(9)], + timestamp="fixed", + ), report_dir=tmp_path, - timestamp="fixed", ) html = report.read_text(encoding="utf-8") @@ -59,8 +74,8 @@ def test_write_command_report_limits_summary_cards(tmp_path: Path) -> None: def test_write_command_report_preserves_same_timestamp_runs(tmp_path: Path) -> None: - first = write_command_report("update_rpg", report_dir=tmp_path, timestamp="fixed") - second = write_command_report("update_rpg", report_dir=tmp_path, timestamp="fixed") + first = write_command_report(CommandRun("update_rpg", timestamp="fixed"), report_dir=tmp_path) + second = write_command_report(CommandRun("update_rpg", timestamp="fixed"), report_dir=tmp_path) assert first != second assert first.name == "cmind_run_update_rpg_fixed.html" @@ -71,10 +86,12 @@ def test_write_command_report_preserves_same_timestamp_runs(tmp_path: Path) -> N def test_write_command_report_does_not_invent_node_rows_from_counts(tmp_path: Path) -> None: report = write_command_report( - "encode", - evidence={"dep_nodes": 4, "rpg_nodes": 6}, + CommandRun( + command="encode", + evidence={"dep_nodes": 4, "rpg_nodes": 6}, + timestamp="fixed", + ), report_dir=tmp_path, - timestamp="fixed", ) html = report.read_text(encoding="utf-8") @@ -89,14 +106,19 @@ def test_write_command_report_infers_artifact_status_and_preserves_verification_ missing = tmp_path / "missing.json" report = write_command_report( - "encode", - artifacts=[("rpg_json", available), {"missing_json": missing}], - verification=[ - {"name": "message", "status": "ok", "message": "from message"}, - {"name": "reason", "status": "warn", "reason": "from reason"}, - ], + CommandRun( + command="encode", + artifacts=[ + ArtifactEvent(label="rpg_json", path=available), + ArtifactEvent(label="missing_json", path=missing), + ], + verification=[ + VerificationEvent(name="message", status="ok", detail="from message"), + VerificationEvent(name="reason", status="warn", detail="from reason"), + ], + timestamp="fixed", + ), report_dir=tmp_path, - timestamp="fixed", ) html = report.read_text(encoding="utf-8") @@ -106,3 +128,50 @@ def test_write_command_report_infers_artifact_status_and_preserves_verification_ assert html.count("missing") == 1 assert "from message" in html assert "from reason" in html + + +def test_all_event_types_serialize_with_optional_fields(tmp_path: Path) -> None: + available = tmp_path / "artifact.txt" + available.write_text("ok", encoding="utf-8") + + events = [ + StepEvent(), + RetrievalEvent(query="grep", tool="grep", hits=[{"path": "a.py"}], reason="matched"), + RPGDeltaEvent(node_id="feature", name="Feature", type="function", path="a.py", change="modified", score=1.0), + DepGraphDeltaEvent(dep_node_id="a.py:f", path="a.py", source_feature="feature", change="modified"), + CodeDeltaEvent(file="a.py", change_type="modify", before="old", after="new", diff="@@"), + VerificationEvent(), + UserDecisionEvent(decision="apply", branch="rpg-edit/x", before_state={"clean": True}, rollback_path="backup", confirmed=True), + ArtifactEvent(label="artifact", path=available), + ] + + for event in events: + assert isinstance(event.to_dict(), dict) + + run = CommandRun( + command="events", + retrievals=[events[1]], + rpg_deltas=[events[2]], + dep_graph_deltas=[events[3]], + code_deltas=[events[4]], + user_decisions=[events[6]], + artifacts=[events[7]], + ).to_dict() + + assert run["retrievals"][0]["tool"] == "grep" + assert run["code_deltas"][0]["file"] == "a.py" + assert run["user_decisions"][0]["confirmed"] is True + assert run["artifacts"][0]["status"] == "available" + + +def test_write_command_report_accepts_command_run_mapping(tmp_path: Path) -> None: + run = CommandRun( + command="mapping", + summary=[{"label": "safe", "value": ""}], + timestamp="fixed", + ).to_dict() + + report = write_command_report(run, report_dir=tmp_path) + + html = report.read_text(encoding="utf-8") + assert "<ok>" in html From 46423f9c898dabf40bb52c590855f20213048963 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 10:13:01 +0000 Subject: [PATCH 11/15] rpg_edit: Extend _publish_review_report() at scripts/rpg_edit/review.p --- CoderMind/scripts/common/git_utils.py | 163 +++++++++++++---- CoderMind/scripts/common/run_report.py | 142 ++++++++++++++- CoderMind/scripts/rpg_edit/review.py | 182 ++++++++++++++++++- CoderMind/scripts/rpg_visualize.py | 184 ++++++++++++++++++++ CoderMind/tests/test_rpg_edit_run_report.py | 64 ++++++- CoderMind/tests/test_run_report.py | 49 ++++++ 6 files changed, 743 insertions(+), 41 deletions(-) diff --git a/CoderMind/scripts/common/git_utils.py b/CoderMind/scripts/common/git_utils.py index c86abd1..b08e45c 100644 --- a/CoderMind/scripts/common/git_utils.py +++ b/CoderMind/scripts/common/git_utils.py @@ -391,11 +391,11 @@ def get_diff( to_commit: str = "HEAD" ) -> str: """Get diff between commits. - + Args: from_commit: Start commit (default: parent of to_commit) to_commit: End commit (default: HEAD) - + Returns: Diff content as string """ @@ -403,9 +403,24 @@ def get_diff( result = self.run_git(["diff", from_commit, to_commit]) else: result = self.run_git(["diff", f"{to_commit}^", to_commit]) - + return result.stdout if result.success else "" - + + def get_file_diffs( + self, + from_commit: Optional[str] = None, + to_commit: str = "HEAD", + files: Optional[List[str]] = None, + ) -> List[Dict[str, str]]: + """Get per-file diff rows between commits.""" + return file_diffs_between( + self.repo_path, + from_commit, + to_commit, + files=files, + py_only=False, + ) + def get_changed_files( self, from_commit: Optional[str] = None, @@ -617,6 +632,52 @@ def git_workspace_prefix(workspace_dir: str | Path) -> str: _GIT_STATUS_COPY_PREFIX = "C" +def _parse_name_status_rows( + raw: Optional[str], + *, + py_only: bool = True, +) -> List[Dict[str, str]]: + rows: List[Dict[str, str]] = [] + if not raw: + return rows + + def _keep(p: str) -> bool: + return (not py_only) or p.endswith(".py") + + for line in raw.splitlines(): + parts = line.split("\t") + if len(parts) < 2: + continue + status = parts[0] + if status.startswith(_GIT_STATUS_RENAME_PREFIX) or status.startswith( + _GIT_STATUS_COPY_PREFIX + ): + if len(parts) < 3: + continue + old_path, new_path = parts[1], parts[2] + if _keep(new_path) or _keep(old_path): + rows.append({ + "file": new_path, + "change_type": "rename" if status.startswith(_GIT_STATUS_RENAME_PREFIX) else "copy", + "old_file": old_path, + "status": status, + }) + continue + path = parts[1] + if not _keep(path): + continue + if status == _GIT_STATUS_ADDED: + change_type = "add" + elif status == _GIT_STATUS_DELETED: + change_type = "delete" + elif status == _GIT_STATUS_MODIFIED: + change_type = "modify" + else: + continue + rows.append({"file": path, "change_type": change_type, "status": status}) + return rows + + def _parse_name_status( raw: Optional[str], *, @@ -644,40 +705,76 @@ def _parse_name_status( if not raw: return modified, renames - def _keep(p: str) -> bool: - return (not py_only) or p.endswith(".py") - - for line in raw.splitlines(): - parts = line.split("\t") - if len(parts) < 2: - continue - status = parts[0] - if status.startswith(_GIT_STATUS_RENAME_PREFIX) or status.startswith( - _GIT_STATUS_COPY_PREFIX - ): - if len(parts) < 3: + for row in _parse_name_status_rows(raw, py_only=py_only): + path = row.get("file", "") + if row.get("change_type") in ("rename", "copy"): + old_path = row.get("old_file", "") + if old_path and path: + renames[old_path] = path + if py_only and not path.endswith(".py"): continue - old_path, new_path = parts[1], parts[2] - if _keep(new_path) or _keep(old_path): - renames[old_path] = new_path - # update_files() treats the OLD path as a deletion (via - # ``renames``) and the NEW path as something it must - # reparse — so we surface the new path through the - # modified list as well. - if _keep(new_path): - modified.append(new_path) - continue - path = parts[1] - if not _keep(path): - continue - if status in (_GIT_STATUS_ADDED, _GIT_STATUS_DELETED, _GIT_STATUS_MODIFIED): + if path: modified.append(path) - # Type / unmerged / other status letters → ignore (caller will - # fall back to full sync via the safety threshold if there are - # many of them). return modified, renames +def _diff_range_args( + from_commit: Optional[str] = None, + to_commit: str = "HEAD", +) -> List[str]: + if from_commit: + return [from_commit, to_commit] + return [f"{to_commit}^", to_commit] + + +def file_diffs_between( + repo_dir: str | Path, + from_commit: Optional[str] = None, + to_commit: str = "HEAD", + *, + files: Optional[List[str]] = None, + py_only: bool = False, +) -> List[Dict[str, str]]: + """Return per-file diff rows without mutating git state.""" + if not repo_dir: + return [] + repo_path = Path(repo_dir) + if not repo_path.is_dir(): + return [] + + range_args = _diff_range_args(from_commit, to_commit) + raw_status = _run_git_readonly( + ["diff", "--relative", "--name-status", "-M", *range_args], + repo_path, + ) + rows = _parse_name_status_rows(raw_status, py_only=py_only) + if not rows: + return [] + + selected = {str(path) for path in files or [] if path} + diff_rows: List[Dict[str, str]] = [] + for row in rows: + file_path = row.get("file", "") + old_file = row.get("old_file", "") + if selected and file_path not in selected and old_file not in selected: + continue + pathspecs = [path for path in [old_file, file_path] if path] + raw_diff = _run_git_readonly( + ["diff", *range_args, "--", *pathspecs], + repo_path, + timeout=10.0, + ) + diff_row = { + "file": file_path, + "change_type": row.get("change_type", "modify"), + "diff": raw_diff or "", + } + if old_file: + diff_row["old_file"] = old_file + diff_rows.append(diff_row) + return diff_rows + + def staged_changes( repo_dir: str | Path, ) -> Tuple[List[str], Dict[str, str]]: diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index db73a71..9fa3ba7 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -47,6 +47,12 @@ def write_command_report( target_dir.mkdir(parents=True, exist_ok=True) report_path = _unique_report_path(target_dir / f"cmind_run_{safe_command}_{filename_ts}.html") + evidence = dict(data) + evidence_data = evidence.get("evidence") if isinstance(evidence.get("evidence"), Mapping) else {} + retrievals = data.get("retrievals") or evidence_data.get("retrievals") + code_deltas = data.get("code_deltas") or evidence_data.get("code_deltas") + focused_graph = data.get("focused_graph") or evidence_data.get("focused_graph") + page_title = title or f"CoderMind {command} Explain View" html = _render_page( title=page_title, @@ -55,11 +61,14 @@ def write_command_report( status=status, summary_cards=_normalize_cards(data.get("summary")), stages=_normalize_stages(data.get("steps")), + retrievals=_normalize_retrievals(retrievals), rpg_nodes=_normalize_nodes(data.get("rpg_deltas"), dep_graph=False), dep_nodes=_normalize_nodes(data.get("dep_graph_deltas"), dep_graph=True), + code_deltas=_normalize_code_deltas(code_deltas), + focused_graph=_normalize_focused_graph(focused_graph), artifacts=_normalize_artifacts(data.get("artifacts")), verification=_normalize_verification(data.get("verification")), - evidence=dict(data), + evidence=evidence, ) report_path.write_text(html, encoding="utf-8") return report_path @@ -141,6 +150,22 @@ def _normalize_stages(value: Any) -> list[dict[str, Any]]: return stages +def _normalize_retrievals(value: Any) -> list[dict[str, Any]]: + retrievals: list[dict[str, Any]] = [] + for item in _as_sequence(value): + if isinstance(item, Mapping): + hits = item.get("hits") + retrievals.append({ + "query": item.get("query", ""), + "tool": item.get("tool", ""), + "reason": item.get("reason", ""), + "hits": _as_sequence(hits), + }) + else: + retrievals.append({"query": item, "hits": []}) + return retrievals + + def _normalize_nodes(value: Any, *, dep_graph: bool = False) -> list[dict[str, Any]]: nodes: list[dict[str, Any]] = [] id_key = "dep_node_id" if dep_graph else "node_id" @@ -153,6 +178,30 @@ def _normalize_nodes(value: Any, *, dep_graph: bool = False) -> list[dict[str, A return nodes +def _normalize_code_deltas(value: Any) -> list[dict[str, Any]]: + deltas: list[dict[str, Any]] = [] + for item in _as_sequence(value): + if isinstance(item, Mapping): + deltas.append({ + "file": item.get("file") or item.get("path") or "", + "change_type": item.get("change_type") or item.get("status") or "", + "before": item.get("before"), + "after": item.get("after"), + "diff": item.get("diff", ""), + }) + else: + deltas.append({"file": item, "change_type": "recorded", "diff": ""}) + return deltas + + +def _normalize_focused_graph(value: Any) -> dict[str, Any]: + if value in (None, "", [], {}): + return {} + if isinstance(value, Mapping): + return dict(value) + return {"detail": value} + + def _normalize_artifacts(value: Any) -> list[dict[str, Any]]: artifacts: list[dict[str, Any]] = [] for item in _as_sequence(value): @@ -202,8 +251,11 @@ def _render_page( status: str | None, summary_cards: list[dict[str, Any]], stages: list[dict[str, Any]], + retrievals: list[dict[str, Any]], rpg_nodes: list[dict[str, Any]], dep_nodes: list[dict[str, Any]], + code_deltas: list[dict[str, Any]], + focused_graph: dict[str, Any], artifacts: list[dict[str, Any]], verification: list[dict[str, Any]], evidence: Mapping[str, Any], @@ -238,6 +290,9 @@ def _render_page( .stage-head {{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; }} .badge {{ font-size:12px; border-radius:999px; background:#eef2f7; padding:2px 8px; color:#334155; }} .reason {{ color:var(--muted); margin-top:4px; }} +.delta {{ border:1px solid var(--line); border-radius:12px; padding:12px; margin:10px 0; background:#fbfdff; }} +.delta-head {{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:8px; }} +.hit-list {{ margin:0; padding-left:18px; }} table {{ width:100%; border-collapse:collapse; font-size:14px; table-layout:fixed; }} th, td {{ border-top:1px solid var(--line); padding:8px 10px; text-align:left; vertical-align:top; overflow-wrap:anywhere; word-break:break-word; }} th {{ color:var(--muted); font-weight:600; background:#fbfdff; }} @@ -258,8 +313,11 @@ def _render_page( {_render_summary_cards(summary_cards)} {_render_timeline(stages)} {_render_verification(verification)} +{_render_retrievals(retrievals)} {_render_node_table("Focused RPG node evidence", rpg_nodes)} {_render_node_table("Focused dependency node evidence", dep_nodes)} +{_render_code_deltas(code_deltas)} +{_render_focused_graph(focused_graph)} {_render_artifacts(artifacts)} {_render_evidence(evidence)} @@ -327,6 +385,88 @@ def _render_verification(checks: list[dict[str, Any]]) -> str: return f"

    Verification status

    {body}
    " +def _render_retrievals(retrievals: list[dict[str, Any]]) -> str: + if not retrievals: + return "" + rows = [] + for retrieval in retrievals: + hits = retrieval.get("hits") or [] + hit_items = [] + for hit in hits: + if isinstance(hit, Mapping): + label = hit.get("node_id") or hit.get("dep_node_id") or hit.get("path") or hit.get("file") or hit.get("name") or "hit" + reason = hit.get("reason") or hit.get("score") or hit.get("status") or "" + hit_items.append(f"
  • {_h(label)} {_h(reason)}
  • ") + else: + hit_items.append(f"
  • {_h(hit)}
  • ") + hits_html = "No hits recorded." + if hit_items: + hits_html = '
      ' + "".join(hit_items) + "
    " + rows.append( + "" + f"{_h(retrieval.get('tool', ''))}" + f"{_h(retrieval.get('query', ''))}" + f"{_h(retrieval.get('reason', ''))}" + f"{hits_html}" + "" + ) + body = "" + "".join(rows) + "
    ToolQueryReasonHits
    " + return f"

    Retrieval evidence

    {body}
    " + + +def _render_code_deltas(deltas: list[dict[str, Any]]) -> str: + if not deltas: + return "" + blocks = [] + for delta in deltas: + diff = delta.get("diff", "") + diff_html = "

    No diff recorded.

    " + if diff: + diff_html = f"
    View diff
    {_h(diff)}
    " + before_after = "" + if delta.get("before") is not None or delta.get("after") is not None: + before_after = ( + "
    Before/after" + f"
    {_h({'before': delta.get('before'), 'after': delta.get('after')})}
    " + "
    " + ) + blocks.append( + "
    " + "
    " + f"{_h(delta.get('file', ''))}" + f"{_h(delta.get('change_type', ''))}" + "
    " + f"{diff_html}{before_after}" + "
    " + ) + return f"

    Code deltas

    {''.join(blocks)}
    " + + +def _render_focused_graph(focused_graph: dict[str, Any]) -> str: + if not focused_graph: + return "" + path = focused_graph.get("path") or focused_graph.get("artifact_path") or focused_graph.get("html_path") + href = _artifact_href(path) if path else "#" + rows = [ + ("Status", focused_graph.get("status", "recorded"), False), + ("Graph artifact", f"{_h(path or '')}" if path else "", True), + ("Selected RPG nodes", ", ".join(str(v) for v in focused_graph.get("selected_rpg_nodes") or focused_graph.get("rpg_node_ids") or []), False), + ("Selected dependency nodes", ", ".join(str(v) for v in focused_graph.get("selected_dep_nodes") or focused_graph.get("dep_node_ids") or []), False), + ("Included RPG nodes", focused_graph.get("rpg_node_count") or focused_graph.get("rpg_nodes") or "", False), + ("Included dependency nodes", focused_graph.get("dep_node_count") or focused_graph.get("dep_nodes") or "", False), + ] + table_rows = [] + for label, value, is_html in rows: + if value in (None, ""): + continue + rendered = str(value) if is_html else _h(value) + table_rows.append(f"{_h(label)}{rendered}") + table = "" + "".join(table_rows) + "
    " if table_rows else "

    No focused graph metadata recorded.

    " + metadata = json.dumps(focused_graph, indent=2, ensure_ascii=False, default=_json_default) + inspector = f"
    Inspector metadata
    {_h(metadata)}
    " + return f"

    Focused graph evidence

    {table}{inspector}
    " + + def _render_node_table(title: str, nodes: list[dict[str, Any]]) -> str: if not nodes: body = "

    No node evidence recorded.

    " diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index cadb764..460d7e4 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -36,6 +36,8 @@ from common.paths import ( # noqa: E402 REPO_DIR, + REPORTS_DIR, + REPO_RPG_FILE, cmd_for, RPG_EDIT_PLAN_FILE, RPG_EDIT_IMPACT_FILE, @@ -46,12 +48,15 @@ ) from common.run_events import ( # noqa: E402 ArtifactEvent, + CodeDeltaEvent, CommandRun, DepGraphDeltaEvent, RPGDeltaEvent, + RetrievalEvent, StepEvent, VerificationEvent, ) +from common.git_utils import file_diffs_between # noqa: E402 from common.run_report import write_command_report # noqa: E402 logger = logging.getLogger(__name__) @@ -132,6 +137,140 @@ def _selected_candidate_rows(artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: return rows +def _listify(value: Any) -> List[Any]: + if value in (None, ""): + return [] + if isinstance(value, (list, tuple, set)): + return [item for item in value if item not in (None, "")] + return [value] + + +def _retrieval_hit_reason(candidate: Dict[str, Any], impact: Dict[str, Any]) -> str: + parts: List[str] = [] + if candidate.get("score") not in (None, ""): + parts.append(f"locate score={candidate.get('score')}") + if candidate.get("feature_path"): + parts.append(f"feature path={candidate.get('feature_path')}") + dep_count = len(_listify(candidate.get("dep_nodes"))) + if dep_count: + parts.append(f"{dep_count} dep nodes") + if impact.get("error"): + parts.append(f"impact error={impact.get('error')}") + if impact.get("message"): + parts.append(str(impact.get("message"))) + summary = impact.get("impact_summary") if isinstance(impact.get("impact_summary"), dict) else {} + callers = summary.get("total_callers", len(impact.get("callers") or [])) + files = summary.get("affected_file_count", len(impact.get("affected_files") or [])) + if callers or files: + parts.append(f"impact callers={callers}, affected_files={files}") + return "; ".join(parts) or "selected by review plan" + + +def _retrieval_rows(artifacts: Dict[str, Any], candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + locate = artifacts.get("locate") if isinstance(artifacts.get("locate"), dict) else {} + impact_results = _impact_results(artifacts) + if locate or candidates: + hits: List[Dict[str, Any]] = [] + for candidate in candidates: + node_id = candidate.get("node_id") + impact = impact_results.get(node_id) if isinstance(impact_results.get(node_id), dict) else {} + hits.append({ + "node_id": node_id, + "name": candidate.get("name"), + "path": candidate.get("path") or candidate.get("meta_path"), + "score": candidate.get("score"), + "reason": _retrieval_hit_reason(candidate, impact), + }) + rows.append({ + "query": locate.get("query", ""), + "tool": str(RPG_EDIT_LOCATE_FILE), + "reason": f"{len(hits)} selected from {len(locate.get('results') or [])} locate candidates", + "hits": hits, + }) + if impact_results: + hits = [] + for node_id, impact in impact_results.items(): + impact = impact if isinstance(impact, dict) else {} + hits.append({ + "node_id": node_id, + "name": impact.get("name"), + "reason": _retrieval_hit_reason({"node_id": node_id}, impact), + }) + rows.append({ + "query": ", ".join(str(node_id) for node_id in impact_results), + "tool": str(RPG_EDIT_IMPACT_FILE), + "reason": f"{len(impact_results)} impact result sets", + "hits": hits, + }) + return rows + + +def _code_delta_rows(artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: + code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else {} + files = [str(path) for path in _listify(code_result.get("files_modified"))] + commit_sha = code_result.get("commit_sha") + rows: List[Dict[str, Any]] = [] + if commit_sha: + try: + rows = file_diffs_between( + REPO_DIR, + to_commit=str(commit_sha), + files=files or None, + py_only=False, + ) + except Exception as exc: + rows = [{"file": path, "change_type": "modify", "diff": "", "error": str(exc)} for path in files] + seen = {row.get("file") for row in rows} + for path in files: + if path not in seen: + rows.append({"file": path, "change_type": "modify", "diff": ""}) + return rows + + +def _focused_graph_output_path() -> Path: + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + return REPORTS_DIR / f"rpg_edit_focused_graph_{time.time_ns()}.html" + + +def _focused_graph_artifact(candidates: List[Dict[str, Any]], artifacts: Dict[str, Any]) -> Dict[str, Any]: + dep_rows = _dep_node_rows(candidates) + selected_rpg = sorted({str(row.get("node_id")) for row in candidates if row.get("node_id")}) + selected_dep = sorted({str(row.get("node_id")) for row in dep_rows if row.get("node_id")}) + if not selected_rpg and not selected_dep: + return {} + + metadata: Dict[str, Any] = { + "status": "recorded", + "selected_rpg_nodes": selected_rpg, + "selected_dep_nodes": selected_dep, + } + rpg_data = _load_json_artifact(REPO_RPG_FILE) + if not isinstance(rpg_data, dict): + metadata.update({"status": "missing", "reason": f"RPG file not available: {REPO_RPG_FILE}"}) + return metadata + + try: + from rpg_visualize import build_focused_graph_data, generate_html + + focused_data = build_focused_graph_data( + rpg_data, + rpg_node_ids=selected_rpg, + dep_node_ids=selected_dep, + ) + focused_meta = focused_data.get("_focused_graph") if isinstance(focused_data.get("_focused_graph"), dict) else {} + metadata.update(focused_meta) + if not (focused_meta.get("matched_rpg_nodes") or focused_meta.get("matched_dep_nodes")): + metadata.update({"status": "unavailable", "reason": "selected nodes not found in current RPG"}) + return metadata + graph_path = _focused_graph_output_path() + graph_path.write_text(generate_html(focused_data), encoding="utf-8") + metadata.update({"status": "available", "path": str(graph_path)}) + except Exception as exc: + metadata.update({"status": "error", "reason": str(exc)}) + return metadata + + def _dep_node_path(dep_id: Any) -> str: if dep_id in (None, ""): return "" @@ -200,12 +339,32 @@ def _review_verification(result: Dict[str, Any], artifacts: Dict[str, Any]) -> L return checks +class _ReportPayload: + def __init__(self, run: CommandRun, focused_graph: Dict[str, Any]): + self.run = run + self.focused_graph = focused_graph + + def to_dict(self) -> Dict[str, Any]: + data = self.run.to_dict() + if self.focused_graph: + data["focused_graph"] = self.focused_graph + return data + + def _publish_review_report(result: Dict[str, Any], plan_path: Path, impact_path: Optional[Path]) -> Dict[str, Any]: _write_review_result(result) artifacts = _load_review_artifacts(plan_path, impact_path) candidates = _selected_candidate_rows(artifacts) + code_deltas = _code_delta_rows(artifacts) + focused_graph = _focused_graph_artifact(candidates, artifacts) + artifact_rows = _artifact_links(plan_path, impact_path) + if focused_graph.get("path"): + artifact_rows.append({"label": "focused_graph", "path": focused_graph["path"], "status": focused_graph.get("status")}) + evidence = {"artifacts": artifacts, "review_result": result} + if focused_graph: + evidence["focused_graph"] = focused_graph try: - report_path = write_command_report(CommandRun( + report_run = CommandRun( command="rpg_edit", title="CoderMind rpg_edit Explain View", status=str(result.get("type", "review")), @@ -233,16 +392,31 @@ def _publish_review_report(result: Dict[str, Any], plan_path: Path, impact_path: ) for row in _dep_node_rows(candidates) ], + retrievals=[ + RetrievalEvent(query=row.get("query"), tool=row.get("tool"), hits=row.get("hits"), reason=row.get("reason")) + for row in _retrieval_rows(artifacts, candidates) + ], artifacts=[ ArtifactEvent(label=row["label"], path=row["path"], status=row.get("status")) - for row in _artifact_links(plan_path, impact_path) + for row in artifact_rows + ], + code_deltas=[ + CodeDeltaEvent( + file=row.get("file"), + change_type=row.get("change_type"), + before=row.get("before"), + after=row.get("after"), + diff=row.get("diff"), + ) + for row in code_deltas ], verification=[ VerificationEvent(name=row.get("name", "verification"), status=row.get("status"), detail=row.get("detail")) for row in _review_verification(result, artifacts) ], - evidence={"artifacts": artifacts, "review_result": result}, - )) + evidence=evidence, + ) + report_path = write_command_report(_ReportPayload(report_run, focused_graph)) result["report_path"] = str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/scripts/rpg_visualize.py b/CoderMind/scripts/rpg_visualize.py index 9f1b8de..e318073 100644 --- a/CoderMind/scripts/rpg_visualize.py +++ b/CoderMind/scripts/rpg_visualize.py @@ -227,6 +227,190 @@ def to_tree(nid): "children": [to_tree(r) for r in sorted(roots)]} +def _id_set(values) -> set: + if values is None: + return set() + if isinstance(values, (str, bytes)): + values = [values] + return {str(value) for value in values if value not in (None, "")} + + +def _collect_tree_ids(node: dict) -> set: + ids = set() + node_id = node.get("id") + if node_id not in (None, ""): + ids.add(str(node_id)) + for child in node.get("children", []) or []: + ids.update(_collect_tree_ids(child)) + return ids + + +def _filter_tree(node: dict, keep_ids: set, *, keep_root: bool = True) -> dict | None: + children = [ + child + for child in (_filter_tree(child, keep_ids, keep_root=False) for child in node.get("children", []) or []) + if child is not None + ] + node_id = str(node.get("id", "")) + if keep_root or node_id in keep_ids or children: + filtered = dict(node) + filtered["children"] = children + return filtered + return None + + +def _expand_rpg_focus(data: dict, rpg_ids: set, dep_ids: set, include_neighbors: bool) -> set: + focus = set(rpg_ids) + dep_to_rpg = data.get("_dep_to_rpg_map", {}) if isinstance(data.get("_dep_to_rpg_map"), dict) else {} + for dep_id, mapped_rpg_ids in dep_to_rpg.items(): + mapped = _id_set(mapped_rpg_ids) + if dep_id in dep_ids: + focus.update(mapped) + if focus.intersection(mapped): + dep_ids.add(str(dep_id)) + + if include_neighbors: + for edge in get_semantic_edges(data): + src = str(edge.get("src", "")) + dst = str(edge.get("dst", "")) + if src in focus or dst in focus: + focus.update([src, dst]) + return focus + + +def _dep_parent_map(dep_graph: dict) -> Dict[str, str]: + parent: Dict[str, str] = {} + for edge in dep_graph.get("edges", []) or []: + if edge.get("attrs", {}).get("type", "") in ("contains", "CONTAINS"): + parent[str(edge.get("dst", ""))] = str(edge.get("src", "")) + return {child: par for child, par in parent.items() if child and par} + + +def _expand_dep_focus(data: dict, rpg_ids: set, dep_ids: set, include_neighbors: bool) -> set: + dep_graph = data.get("dep_graph", {}) if isinstance(data.get("dep_graph"), dict) else {} + raw_nodes = dep_graph.get("nodes", {}) if isinstance(dep_graph.get("nodes"), dict) else {} + focus = set(dep_ids) + dep_to_rpg = data.get("_dep_to_rpg_map", {}) if isinstance(data.get("_dep_to_rpg_map"), dict) else {} + for dep_id, mapped_rpg_ids in dep_to_rpg.items(): + if rpg_ids.intersection(_id_set(mapped_rpg_ids)): + focus.add(str(dep_id)) + for dep_id, attrs in raw_nodes.items(): + if not isinstance(attrs, dict): + continue + mapped = _id_set(attrs.get("rpg_nodes")) + if mapped.intersection(rpg_ids): + focus.add(str(dep_id)) + if str(dep_id) in focus: + rpg_ids.update(mapped) + + if include_neighbors: + for edge in dep_graph.get("edges", []) or []: + edge_type = edge.get("attrs", {}).get("type", "") + if edge_type in ("contains", "CONTAINS"): + continue + src = str(edge.get("src", "")) + dst = str(edge.get("dst", "")) + if src in focus or dst in focus: + focus.update([src, dst]) + + parent = _dep_parent_map(dep_graph) + for dep_id in list(focus): + cur = dep_id + while cur in parent: + cur = parent[cur] + focus.add(cur) + for dep_id in list(focus): + attrs = raw_nodes.get(dep_id) + if isinstance(attrs, dict): + rpg_ids.update(_id_set(attrs.get("rpg_nodes"))) + rpg_ids.update(_id_set(dep_to_rpg.get(dep_id))) + return focus + + +def build_focused_graph_data( + data: dict, + *, + rpg_node_ids: List[str] | None = None, + dep_node_ids: List[str] | None = None, + include_neighbors: bool = True, +) -> dict: + selected_rpg = _id_set(rpg_node_ids) + selected_dep = _id_set(dep_node_ids) + if not selected_rpg and not selected_dep: + return dict(data) + + rpg_focus = _expand_rpg_focus(data, set(selected_rpg), set(selected_dep), include_neighbors) + dep_focus = _expand_dep_focus(data, rpg_focus, set(selected_dep), include_neighbors) + rpg_focus = _expand_rpg_focus(data, rpg_focus, dep_focus, include_neighbors=False) + + tree = normalize_to_tree(data) + filtered_tree = _filter_tree(tree, rpg_focus) or {"id": "__root__", "name": "Focused graph", "children": []} + tree_ids = _collect_tree_ids(filtered_tree) + matched_rpg = sorted(selected_rpg.intersection(tree_ids)) + + semantic_edges = [ + edge for edge in get_semantic_edges(data) + if str(edge.get("src", "")) in tree_ids and str(edge.get("dst", "")) in tree_ids + ] + + dep_graph = data.get("dep_graph", {}) if isinstance(data.get("dep_graph"), dict) else {} + raw_nodes = dep_graph.get("nodes", {}) if isinstance(dep_graph.get("nodes"), dict) else {} + raw_edges = dep_graph.get("edges", []) if isinstance(dep_graph.get("edges"), list) else [] + filtered_dep_nodes = {dep_id: attrs for dep_id, attrs in raw_nodes.items() if str(dep_id) in dep_focus} + filtered_dep_edges = [ + edge for edge in raw_edges + if str(edge.get("src", "")) in dep_focus and str(edge.get("dst", "")) in dep_focus + ] + dep_ids = {str(dep_id) for dep_id in filtered_dep_nodes} + matched_dep = sorted(selected_dep.intersection(dep_ids)) + + dep_to_rpg = data.get("_dep_to_rpg_map", {}) if isinstance(data.get("_dep_to_rpg_map"), dict) else {} + filtered_map = {} + for dep_id, mapped_rpg_ids in dep_to_rpg.items(): + if str(dep_id) not in dep_ids: + continue + mapped = [str(rpg_id) for rpg_id in mapped_rpg_ids if str(rpg_id) in tree_ids] + if mapped: + filtered_map[str(dep_id)] = mapped + + focused = dict(data) + if isinstance(data.get("nodes"), list): + focused["nodes"] = [ + dict(node) for node in data["nodes"] + if isinstance(node, dict) and str(node.get("id", "")) in tree_ids + ] + focused["root"] = filtered_tree + focused["edges"] = semantic_edges + focused["dep_graph"] = {**dep_graph, "nodes": filtered_dep_nodes, "edges": filtered_dep_edges} + focused["_dep_to_rpg_map"] = filtered_map + focused["_focused_graph"] = { + "selected_rpg_nodes": sorted(selected_rpg), + "selected_dep_nodes": sorted(selected_dep), + "matched_rpg_nodes": matched_rpg, + "matched_dep_nodes": matched_dep, + "rpg_node_count": len(tree_ids), + "dep_node_count": len(dep_ids), + "semantic_edge_count": len(semantic_edges), + "dep_edge_count": len(filtered_dep_edges), + } + return focused + + +def generate_focused_html( + data: dict, + *, + rpg_node_ids: List[str] | None = None, + dep_node_ids: List[str] | None = None, + include_neighbors: bool = True, +) -> str: + return generate_html(build_focused_graph_data( + data, + rpg_node_ids=rpg_node_ids, + dep_node_ids=dep_node_ids, + include_neighbors=include_neighbors, + )) + + def generate_html(data: dict) -> str: tree = normalize_to_tree(data) semantic_edges = get_semantic_edges(data) diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index 469f9f2..3ac5dde 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -58,17 +58,58 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) code_path = tmp_path / "code.json" review_path = tmp_path / "review.json" report_path = tmp_path / "report.html" + rpg_path = tmp_path / "rpg.json" validate_path.write_text(json.dumps({"type": "ready"}), encoding="utf-8") - locate_path.write_text(json.dumps({"type": "candidates", "results": [{"node_id": "n1", "name": "Node", "score": 1.0, "dep_nodes": ["a.py:f"]}]}), encoding="utf-8") + locate_path.write_text( + json.dumps({"type": "candidates", "query": "a.py", "results": [{"node_id": "n1", "name": "Node", "score": 1.0, "dep_nodes": ["a.py:f"]}]}), + encoding="utf-8", + ) plan_path.write_text(json.dumps({"affected_nodes": ["n1"], "code_changes": [{"file_path": "a.py"}]}), encoding="utf-8") - impact_path.write_text(json.dumps({"type": "impact", "results": {"n1": {}}}), encoding="utf-8") - code_path.write_text(json.dumps({"success": True, "files_modified": ["a.py"], "last_status": "complete"}), encoding="utf-8") + impact_path.write_text( + json.dumps({ + "type": "impact", + "results": { + "n1": { + "name": "Node", + "dep_nodes": ["a.py:f"], + "affected_files": ["a.py"], + "impact_summary": {"total_callers": 1, "affected_file_count": 1}, + } + }, + }), + encoding="utf-8", + ) + code_path.write_text(json.dumps({"success": True, "commit_sha": "abc123", "files_modified": ["a.py"], "last_status": "complete"}), encoding="utf-8") + rpg_path.write_text( + json.dumps({ + "repo_name": "test", + "root": {"id": "n1", "name": "Node", "node_type": "feature", "meta": {"path": "a.py"}, "children": []}, + "edges": [], + "dep_graph": { + "nodes": {"a.py:f": {"name": "f", "type": "function", "module": "a.py", "rpg_nodes": ["n1"]}}, + "edges": [], + }, + "_dep_to_rpg_map": {"a.py:f": ["n1"]}, + }), + encoding="utf-8", + ) monkeypatch.setattr(review, "RPG_EDIT_VALIDATE_FILE", validate_path) monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) + monkeypatch.setattr(review, "RPG_EDIT_IMPACT_FILE", impact_path) monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) + monkeypatch.setattr(review, "REPO_RPG_FILE", rpg_path) + monkeypatch.setattr(review, "REPORTS_DIR", tmp_path) + + def fake_file_diffs_between(repo_dir, from_commit=None, to_commit="HEAD", *, files=None, py_only=False): + assert to_commit == "abc123" + assert files == ["a.py"] + assert py_only is False + return [{"file": "a.py", "change_type": "modify", "diff": "+new "}] + + monkeypatch.setattr(review, "file_diffs_between", fake_file_diffs_between) def fake_write_command_report(run): data = run.to_dict() @@ -76,6 +117,17 @@ def fake_write_command_report(run): item for item in data["artifacts"] if item["label"] == "review_result" ) assert review_artifact["status"] == "available" + assert data["retrievals"][0]["tool"] == str(locate_path) + assert data["retrievals"][0]["query"] == "a.py" + assert "locate score=1.0" in data["retrievals"][0]["hits"][0]["reason"] + assert "impact callers=1, affected_files=1" in data["retrievals"][0]["hits"][0]["reason"] + assert data["retrievals"][1]["tool"] == str(impact_path) + assert data["code_deltas"] == [{"file": "a.py", "change_type": "modify", "diff": "+new "}] + assert data["focused_graph"]["status"] == "available" + assert data["focused_graph"]["selected_rpg_nodes"] == ["n1"] + assert data["focused_graph"]["selected_dep_nodes"] == ["a.py:f"] + assert Path(data["focused_graph"]["path"]).exists() + assert any(item["label"] == "focused_graph" for item in data["artifacts"]) return report_path monkeypatch.setattr(review, "write_command_report", fake_write_command_report) @@ -110,8 +162,10 @@ def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: monkeypatch.setattr(review, "RPG_EDIT_VALIDATE_FILE", validate_path) monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) + monkeypatch.setattr(review, "RPG_EDIT_IMPACT_FILE", impact_path) monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) + monkeypatch.setattr(review, "_focused_graph_artifact", lambda candidates, artifacts: {}) def fake_write_command_report(run): data = run.to_dict() @@ -119,6 +173,10 @@ def fake_write_command_report(run): assert data["dep_graph_deltas"] == [ {"dep_node_id": dep_id, "path": "scripts/common/run_report.py", "source_feature": "planned"} ] + assert data["retrievals"][0]["hits"][0]["node_id"] == "planned" + assert "1 dep nodes" in data["retrievals"][0]["hits"][0]["reason"] + assert "impact callers=0, affected_files=1" in data["retrievals"][0]["hits"][0]["reason"] + assert data["retrievals"][1]["tool"] == str(impact_path) return report_path monkeypatch.setattr(review, "write_command_report", fake_write_command_report) diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index ecf90ad..7ffe7d5 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -54,6 +54,55 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path assert "" not in html +def test_write_command_report_renders_retrievals_code_deltas_and_focused_graph(tmp_path: Path) -> None: + graph = tmp_path / "focused.html" + graph.write_text("graph", encoding="utf-8") + long_diff = "diff --git a/a.py b/a.py\n" + "\n".join( + f"+line {i} " for i in range(40) + ) + + report = write_command_report( + { + "command": "rpg_edit", + "retrievals": [ + RetrievalEvent( + query="a.py", + tool="RPG_EDIT_LOCATE_FILE", + hits=[{"node_id": "n1" not in html + assert "
    View diff" in html + assert "
    Focused graph evidence") == 1 + assert "Graph artifact" in html + assert "Inspector metadata" in html + + def test_write_command_report_limits_summary_cards(tmp_path: Path) -> None: report = write_command_report( CommandRun( From 40a51ba5f24d1b454d20bceb31107968c559b46a Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 10:31:39 +0000 Subject: [PATCH 12/15] fix(dep_graph): missing property. --- CoderMind/scripts/rpg/dep_graph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/CoderMind/scripts/rpg/dep_graph.py b/CoderMind/scripts/rpg/dep_graph.py index 093228f..5397895 100644 --- a/CoderMind/scripts/rpg/dep_graph.py +++ b/CoderMind/scripts/rpg/dep_graph.py @@ -214,6 +214,7 @@ def __init__(self, repo_dir: str): _DERIVED_FIELDS = frozenset({ "imports_from", "calls", "called_by", "inherits", "inherited_by", + "rpg_nodes", }) # ------------------------------------------------------------------ From ccf9e62562b9a3f9978554f2e5c1fa3210aa8771 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 10:49:17 +0000 Subject: [PATCH 13/15] fix(rpg_edit): clear recovered code errors Clear stale sub-agent errors once a later iteration completes so successful rpg_edit reports do not surface recovered timeouts. Co-Authored-By: Claude Opus 4.7 --- CoderMind/scripts/rpg_edit/code.py | 1 + CoderMind/tests/test_rpg_edit_run_report.py | 30 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/CoderMind/scripts/rpg_edit/code.py b/CoderMind/scripts/rpg_edit/code.py index 4c7e2f2..6d0172e 100644 --- a/CoderMind/scripts/rpg_edit/code.py +++ b/CoderMind/scripts/rpg_edit/code.py @@ -593,6 +593,7 @@ def apply_code_changes( fp = c.get("file_path") if fp and fp not in done_files: done_files.append(fp) + last_error = None iter_info["detail"] = None break elif status == "partial": diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index 3ac5dde..037c9cc 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -3,6 +3,7 @@ import importlib.util import json import sys +import types from pathlib import Path _REPO = Path(__file__).resolve().parents[1] @@ -48,6 +49,35 @@ def test_code_result_is_persisted(tmp_path: Path, monkeypatch) -> None: assert data == {"success": True, "commit_sha": "abc123"} +def test_code_result_clears_recovered_subagent_error(tmp_path: Path, monkeypatch) -> None: + code = _load_script("rpg_edit_code_retry_test", _SCRIPTS / "rpg_edit" / "code.py") + + plan_path = tmp_path / "plan.json" + plan_path.write_text( + json.dumps({"code_changes": [{"file_path": "a.py", "description": "update report"}]}), + encoding="utf-8", + ) + calls = iter([ + (None, "Sub-agent failed after 900.1s: timeout"), + ("CODE_STATUS: COMPLETE", None), + ]) + fake_run_batch = types.ModuleType("run_batch") + fake_run_batch.dispatch_sub_agent = lambda *args, **kwargs: next(calls) + monkeypatch.setitem(sys.modules, "run_batch", fake_run_batch) + monkeypatch.setattr(code, "_format_rpg_target_nodes", lambda plan, rpg_path: "nodes") + monkeypatch.setattr(code, "_format_impact_context", lambda plan: "impact") + monkeypatch.setattr(code, "_commit_changes", lambda repo_path, summary, status: "abc123") + + result = code.apply_code_changes(plan_path, tmp_path / "rpg.json", tmp_path, max_iterations=2, timeout=1) + + assert result["success"] is True + assert result["last_status"] == "complete" + assert result["last_error"] is None + assert result["commit_sha"] == "abc123" + assert result["iterations"][0]["parsed_status"] == "llm_error" + assert result["iterations"][1]["parsed_status"] == "complete" + + def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) -> None: review = _load_script("rpg_edit_review_test", _SCRIPTS / "rpg_edit" / "review.py") From 915a04b2175c5feb93df0a073e7889a3e1e5b97a Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 1 Jul 2026 11:08:07 +0000 Subject: [PATCH 14/15] rpg_edit: Add RPG_EDIT_APPLY_RESULT_FILE beside RPG_EDIT_PLAN_FILE/RPG --- CoderMind/scripts/common/paths.py | 1 + CoderMind/scripts/common/run_events.py | 4 + CoderMind/scripts/common/run_report.py | 56 +++++++++ CoderMind/scripts/rpg_edit/apply.py | 133 +++++++++++++++++++- CoderMind/scripts/rpg_edit/review.py | 61 ++++++++- CoderMind/tests/test_rpg_edit_run_report.py | 78 ++++++++++++ CoderMind/tests/test_run_report.py | 37 +++++- 7 files changed, 366 insertions(+), 4 deletions(-) diff --git a/CoderMind/scripts/common/paths.py b/CoderMind/scripts/common/paths.py index 093e651..15c62b0 100644 --- a/CoderMind/scripts/common/paths.py +++ b/CoderMind/scripts/common/paths.py @@ -304,6 +304,7 @@ def cmd_for(script_relpath: str) -> str: RPG_EDIT_VALIDATE_FILE = DATA_DIR / "rpg_edit_validate.json" RPG_EDIT_LOCATE_FILE = DATA_DIR / "rpg_edit_locate.json" RPG_EDIT_CODE_RESULT_FILE = DATA_DIR / "rpg_edit_code_result.json" +RPG_EDIT_APPLY_RESULT_FILE = DATA_DIR / "rpg_edit_apply_result.json" RPG_EDIT_REVIEW_RESULT_FILE = DATA_DIR / "rpg_edit_review_result.json" diff --git a/CoderMind/scripts/common/run_events.py b/CoderMind/scripts/common/run_events.py index ca61bc0..99ccf11 100644 --- a/CoderMind/scripts/common/run_events.py +++ b/CoderMind/scripts/common/run_events.py @@ -155,6 +155,8 @@ class UserDecisionEvent: before_state: Any = None rollback_path: Any = None confirmed: Any = None + apply_status: Any = None + test_status: Any = None def to_dict(self) -> dict[str, Any]: return _compact({ @@ -163,6 +165,8 @@ def to_dict(self) -> dict[str, Any]: "before_state": self.before_state, "rollback_path": self.rollback_path, "confirmed": self.confirmed, + "apply_status": self.apply_status, + "test_status": self.test_status, }) diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index 9fa3ba7..d0bae9d 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -52,6 +52,7 @@ def write_command_report( retrievals = data.get("retrievals") or evidence_data.get("retrievals") code_deltas = data.get("code_deltas") or evidence_data.get("code_deltas") focused_graph = data.get("focused_graph") or evidence_data.get("focused_graph") + user_decisions = data.get("user_decisions") or evidence_data.get("user_decisions") page_title = title or f"CoderMind {command} Explain View" html = _render_page( @@ -68,6 +69,7 @@ def write_command_report( focused_graph=_normalize_focused_graph(focused_graph), artifacts=_normalize_artifacts(data.get("artifacts")), verification=_normalize_verification(data.get("verification")), + user_decisions=_normalize_user_decisions(user_decisions), evidence=evidence, ) report_path.write_text(html, encoding="utf-8") @@ -243,6 +245,25 @@ def _normalize_verification(value: Any) -> list[dict[str, Any]]: return checks +def _normalize_user_decisions(value: Any) -> list[dict[str, Any]]: + decisions: list[dict[str, Any]] = [] + for item in _as_sequence(value): + if isinstance(item, Mapping): + entry = dict(item) + else: + entry = {"decision": item} + decisions.append({ + "decision": entry.get("decision", ""), + "before_state": entry.get("before_state"), + "confirmed": entry.get("confirmed"), + "branch": entry.get("branch", ""), + "apply_status": entry.get("apply_status", ""), + "test_status": entry.get("test_status", ""), + "rollback_path": entry.get("rollback_path", ""), + }) + return decisions + + def _render_page( *, title: str, @@ -258,6 +279,7 @@ def _render_page( focused_graph: dict[str, Any], artifacts: list[dict[str, Any]], verification: list[dict[str, Any]], + user_decisions: list[dict[str, Any]], evidence: Mapping[str, Any], ) -> str: status_html = f"{_h(status)}" if status else "" @@ -312,6 +334,7 @@ def _render_page( {_render_summary_cards(summary_cards)} {_render_timeline(stages)} +{_render_safety_boundary(user_decisions)} {_render_verification(verification)} {_render_retrievals(retrievals)} {_render_node_table("Focused RPG node evidence", rpg_nodes)} @@ -385,6 +408,39 @@ def _render_verification(checks: list[dict[str, Any]]) -> str: return f"

    Verification status

    {body}
    " +def _render_safety_boundary(decisions: list[dict[str, Any]]) -> str: + if not decisions: + return "" + rows = [] + for decision in decisions: + confirmed = decision.get("confirmed") + if confirmed is True: + confirmation = "confirmed" + elif confirmed is False: + confirmation = "not confirmed" + else: + confirmation = "" + rows.append( + "" + f"{_h(decision.get('decision', ''))}" + f"{_h(decision.get('before_state'))}" + f"{_h(confirmation)}" + f"{_h(decision.get('branch', ''))}" + f"{_h(decision.get('apply_status', ''))}" + f"{_h(decision.get('test_status', ''))}" + f"{_h(decision.get('rollback_path', ''))}" + "" + ) + body = ( + "" + "" + "" + + "".join(rows) + + "
    DecisionBefore stateConfirmationBranchApply statusTest statusRollback path
    " + ) + return f"

    Safety boundary

    {body}
    " + + def _render_retrievals(retrievals: list[dict[str, Any]]) -> str: if not retrievals: return "" diff --git a/CoderMind/scripts/rpg_edit/apply.py b/CoderMind/scripts/rpg_edit/apply.py index 2421576..8f60827 100644 --- a/CoderMind/scripts/rpg_edit/apply.py +++ b/CoderMind/scripts/rpg_edit/apply.py @@ -8,6 +8,7 @@ import argparse import json +import shlex import shutil import subprocess import sys @@ -21,7 +22,15 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, REPO_DIR, RPG_EDIT_PLAN_FILE # noqa: E402 +from common.paths import ( # noqa: E402 + REPO_RPG_FILE, + DEP_GRAPH_FILE, + REPO_DIR, + RPG_EDIT_PLAN_FILE, + RPG_EDIT_APPLY_RESULT_FILE, + cmd_for, +) +from common.git_utils import read_head # noqa: E402 def _backup(rpg_path: Path, dep_graph_path: Path, ts: str) -> Dict[str, str]: @@ -50,6 +59,57 @@ def _rollback(backups: Dict[str, str], rpg_path: Path, dep_graph_path: Path) -> shutil.copy2(backups["dep_graph"], dep_graph_path) +def _rollback_command(timestamp: str | None, before_state: dict | None) -> str | None: + if not timestamp: + return None + command = f"{cmd_for('rpg_edit/apply.py')} --rollback {shlex.quote(str(timestamp))}" + branch = before_state.get("head_branch") if isinstance(before_state, dict) else None + if branch: + command += f" --rollback-branch {shlex.quote(str(branch))}" + return command + + +def _rollback_path(backups: Dict[str, str]) -> str | None: + return backups.get("rpg") or backups.get("dep_graph") + + +def _persist_apply_result(result: Dict[str, Any]) -> None: + RPG_EDIT_APPLY_RESULT_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_APPLY_RESULT_FILE.write_text( + json.dumps(result, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def _record_apply_result( + result: Dict[str, Any], + *, + backup_timestamp: str | None = None, + backups: Dict[str, str] | None = None, + applied_features: list | None = None, + dep_graph_refreshed: bool | None = None, + before_state: dict | None = None, + confirmed: bool | None = None, +) -> Dict[str, Any]: + backups = backups or {} + if backup_timestamp is not None: + result.setdefault("backup_timestamp", backup_timestamp) + result.setdefault("rollback_command", _rollback_command(backup_timestamp, before_state)) + result.setdefault("backups", backups) + result.setdefault("applied_features", applied_features or []) + if dep_graph_refreshed is not None: + result.setdefault("dep_graph_refreshed", dep_graph_refreshed) + rollback_path = _rollback_path(backups) + if rollback_path: + result.setdefault("rollback_path", rollback_path) + if before_state is not None: + result.setdefault("before_state", before_state) + if confirmed is not None: + result.setdefault("confirmed", confirmed) + _persist_apply_result(result) + return result + + def apply_feature_changes(svc, changes: list) -> list: """Apply feature_changes to the RPG in memory. @@ -128,6 +188,8 @@ def main(): parser.add_argument("--backup-ts", type=str, default=None, help="Reuse existing backup timestamp (skip new backup)") parser.add_argument("--skip-tests", action="store_true") + parser.add_argument("--confirmed", action="store_const", const=True, default=None, + help="Record that the apply boundary was explicitly confirmed") parser.add_argument("--rollback", type=str, default=None, help="Rollback to a previous timestamp backup") parser.add_argument("--rollback-branch", type=str, default=None, @@ -145,6 +207,8 @@ def main(): from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") + before_state = read_head(REPO_DIR) + # Handle rollback if args.rollback: rpg_backup = args.rpg.with_suffix(f".before-edit-{args.rollback}.json") @@ -180,10 +244,23 @@ def main(): "message": f"git invocation failed: {exc}", } + rollback_backups = {} + if rpg_backup.exists(): + rollback_backups["rpg"] = str(rpg_backup) + if dg_backup.exists(): + rollback_backups["dep_graph"] = str(dg_backup) result = {"type": "rollback", "restored": restored, "timestamp": args.rollback} if branch_result: result["branch"] = branch_result + _record_apply_result( + result, + backup_timestamp=args.rollback, + backups=rollback_backups, + dep_graph_refreshed=False, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result, indent=2) if args.json else f"Rolled back: {restored}" + (f"; branch={branch_result}" if branch_result else "")) @@ -192,6 +269,12 @@ def main(): # Load plan if not args.plan.exists(): result = {"type": "error", "message": f"Plan not found: {args.plan}"} + _record_apply_result( + result, + dep_graph_refreshed=False, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 @@ -214,6 +297,7 @@ def main(): # --- Phase: rpg-only or all → apply feature_changes --- applied_features = [] + dep_graph_refreshed = False if args.phase in ("rpg-only", "all"): feature_changes = plan.get("feature_changes", []) applied_features = apply_feature_changes(svc, feature_changes) if feature_changes else [] @@ -226,12 +310,20 @@ def main(): "backup_timestamp": ts, "backups": backups, } + _record_apply_result( + result, + backup_timestamp=ts, + backups=backups, + applied_features=applied_features, + dep_graph_refreshed=dep_graph_refreshed, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result, indent=2) if args.json else f"RPG updated ({len(applied_features)} features). Backup: {ts}") return 0 # --- Phase: dep-refresh or all → refresh dep_graph --- - dep_graph_refreshed = False if args.phase in ("dep-refresh", "all"): # Workspace root is the project repo root. Explicit ``--repo`` # still wins for tests / brownfield setups where the code lives @@ -252,6 +344,15 @@ def main(): "message": f"dep_graph refresh failed: {exc}", "backup_timestamp": ts, } + _record_apply_result( + result, + backup_timestamp=ts, + backups=backups, + applied_features=applied_features, + dep_graph_refreshed=dep_graph_refreshed, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result, indent=2) if args.json else f"Error: {result['message']}") return 1 @@ -264,6 +365,15 @@ def main(): "dep_graph_refreshed": dep_graph_refreshed, "backup_timestamp": ts, } + _record_apply_result( + result, + backup_timestamp=ts, + backups=backups, + applied_features=applied_features, + dep_graph_refreshed=dep_graph_refreshed, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result, indent=2) if args.json else f"dep_graph refreshed: {dep_graph_refreshed}. Backup: {ts}") return 0 @@ -293,9 +403,19 @@ def main(): "type": "test_failed", "applied_features": applied_features, "test_output": test_result["output"], + "test_result": test_result, "rolled_back": True, "backup_timestamp": ts, } + _record_apply_result( + result, + backup_timestamp=ts, + backups=backups, + applied_features=applied_features, + dep_graph_refreshed=dep_graph_refreshed, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result, indent=2) if args.json else f"Tests failed. Rolled back to {ts}.") return 1 @@ -309,6 +429,15 @@ def main(): "backup_timestamp": ts, "backups": backups, } + _record_apply_result( + result, + backup_timestamp=ts, + backups=backups, + applied_features=applied_features, + dep_graph_refreshed=dep_graph_refreshed, + before_state=before_state, + confirmed=args.confirmed, + ) print(json.dumps(result, indent=2) if args.json else "EditPlan applied successfully.") return 0 diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 460d7e4..ab8bc0e 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -44,6 +44,7 @@ RPG_EDIT_VALIDATE_FILE, RPG_EDIT_LOCATE_FILE, RPG_EDIT_CODE_RESULT_FILE, + RPG_EDIT_APPLY_RESULT_FILE, RPG_EDIT_REVIEW_RESULT_FILE, ) from common.run_events import ( # noqa: E402 @@ -54,9 +55,10 @@ RPGDeltaEvent, RetrievalEvent, StepEvent, + UserDecisionEvent, VerificationEvent, ) -from common.git_utils import file_diffs_between # noqa: E402 +from common.git_utils import file_diffs_between, read_head # noqa: E402 from common.run_report import write_command_report # noqa: E402 logger = logging.getLogger(__name__) @@ -86,6 +88,7 @@ def _load_review_artifacts(plan_path: Path, impact_path: Optional[Path]) -> Dict "plan": _load_json_artifact(plan_path), "impact": _load_json_artifact(impact_path), "code_result": _load_json_artifact(RPG_EDIT_CODE_RESULT_FILE), + "apply_result": _load_json_artifact(RPG_EDIT_APPLY_RESULT_FILE), } @@ -96,6 +99,7 @@ def _artifact_links(plan_path: Path, impact_path: Optional[Path]) -> List[Dict[s "plan": plan_path, "impact": impact_path, "code_result": RPG_EDIT_CODE_RESULT_FILE, + "apply_result": RPG_EDIT_APPLY_RESULT_FILE, "review_result": RPG_EDIT_REVIEW_RESULT_FILE, } links: List[Dict[str, Any]] = [] @@ -339,6 +343,60 @@ def _review_verification(result: Dict[str, Any], artifacts: Dict[str, Any]) -> L return checks +def _status_from_bool(value: Any) -> Optional[str]: + if value is True: + return "passed" + if value is False: + return "failed" + return None + + +def _apply_status(apply_result: Dict[str, Any]) -> Any: + return apply_result.get("type") or apply_result.get("status") or "missing" + + +def _test_status(result: Dict[str, Any], code_result: Dict[str, Any], apply_result: Dict[str, Any]) -> Any: + test_result = apply_result.get("test_result") if isinstance(apply_result.get("test_result"), dict) else {} + status = _status_from_bool(test_result.get("passed")) + if status: + return status + for iteration in reversed(result.get("iterations") or []): + if isinstance(iteration, dict): + status = _status_from_bool(iteration.get("post_pytest_passed")) + if status: + return status + if code_result.get("last_status"): + return code_result.get("last_status") + return _status_from_bool(result.get("success")) + + +def _rollback_path(apply_result: Dict[str, Any]) -> Any: + if apply_result.get("rollback_path"): + return apply_result.get("rollback_path") + backups = apply_result.get("backups") if isinstance(apply_result.get("backups"), dict) else {} + return backups.get("rpg") or backups.get("dep_graph") or apply_result.get("rollback_command") + + +def _user_decision(result: Dict[str, Any], artifacts: Dict[str, Any]) -> UserDecisionEvent: + apply_result = artifacts.get("apply_result") if isinstance(artifacts.get("apply_result"), dict) else {} + code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else {} + current_head = read_head(REPO_DIR) + before_state = apply_result.get("before_state") if apply_result.get("before_state") else current_head + branch = before_state.get("head_branch") if isinstance(before_state, dict) else None + if not branch and isinstance(current_head, dict): + branch = current_head.get("head_branch") + confirmed = apply_result.get("confirmed") if "confirmed" in apply_result else None + return UserDecisionEvent( + decision="apply", + branch=branch, + before_state=before_state, + rollback_path=_rollback_path(apply_result), + confirmed=confirmed, + apply_status=_apply_status(apply_result), + test_status=_test_status(result, code_result, apply_result), + ) + + class _ReportPayload: def __init__(self, run: CommandRun, focused_graph: Dict[str, Any]): self.run = run @@ -414,6 +472,7 @@ def _publish_review_report(result: Dict[str, Any], plan_path: Path, impact_path: VerificationEvent(name=row.get("name", "verification"), status=row.get("status"), detail=row.get("detail")) for row in _review_verification(result, artifacts) ], + user_decisions=[_user_decision(result, artifacts)], evidence=evidence, ) report_path = write_command_report(_ReportPayload(report_run, focused_graph)) diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index 037c9cc..b0c91c0 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -86,6 +86,7 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) plan_path = tmp_path / "plan.json" impact_path = tmp_path / "impact.json" code_path = tmp_path / "code.json" + apply_path = tmp_path / "apply.json" review_path = tmp_path / "review.json" report_path = tmp_path / "report.html" rpg_path = tmp_path / "rpg.json" @@ -111,6 +112,27 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) encoding="utf-8", ) code_path.write_text(json.dumps({"success": True, "commit_sha": "abc123", "files_modified": ["a.py"], "last_status": "complete"}), encoding="utf-8") + backup_path = tmp_path / "rpg.before-edit-123.json" + apply_path.write_text( + json.dumps({ + "type": "success", + "backup_timestamp": "123", + "backups": {"rpg": str(backup_path)}, + "applied_features": [{"node_id": "n1", "action": "modified"}], + "dep_graph_refreshed": True, + "rollback_path": str(backup_path), + "rollback_command": "cmind script rpg_edit/apply.py --rollback 123", + "before_state": { + "head_commit": "before123", + "head_short": "before1", + "head_branch": "rpg-edit/test", + "head_timestamp": "2026-06-30T12:00:00+00:00", + }, + "confirmed": True, + "test_result": {"passed": True, "output": ""}, + }), + encoding="utf-8", + ) rpg_path.write_text( json.dumps({ "repo_name": "test", @@ -129,6 +151,7 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) monkeypatch.setattr(review, "RPG_EDIT_IMPACT_FILE", impact_path) monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) + monkeypatch.setattr(review, "RPG_EDIT_APPLY_RESULT_FILE", apply_path) monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) monkeypatch.setattr(review, "REPO_RPG_FILE", rpg_path) monkeypatch.setattr(review, "REPORTS_DIR", tmp_path) @@ -140,13 +163,35 @@ def fake_file_diffs_between(repo_dir, from_commit=None, to_commit="HEAD", *, fil return [{"file": "a.py", "change_type": "modify", "diff": "+new "}] monkeypatch.setattr(review, "file_diffs_between", fake_file_diffs_between) + monkeypatch.setattr( + review, + "read_head", + lambda repo_dir: { + "head_commit": "current123", + "head_short": "current", + "head_branch": "current-branch", + "head_timestamp": "2026-06-30T12:30:00+00:00", + }, + ) def fake_write_command_report(run): data = run.to_dict() review_artifact = next( item for item in data["artifacts"] if item["label"] == "review_result" ) + apply_artifact = next( + item for item in data["artifacts"] if item["label"] == "apply_result" + ) assert review_artifact["status"] == "available" + assert apply_artifact["status"] == "available" + decision = data["user_decisions"][0] + assert decision["decision"] == "apply" + assert decision["branch"] == "rpg-edit/test" + assert decision["before_state"]["head_commit"] == "before123" + assert decision["rollback_path"] == str(backup_path) + assert decision["confirmed"] is True + assert decision["apply_status"] == "success" + assert decision["test_status"] == "passed" assert data["retrievals"][0]["tool"] == str(locate_path) assert data["retrievals"][0]["query"] == "a.py" assert "locate score=1.0" in data["retrievals"][0]["hits"][0]["reason"] @@ -177,6 +222,7 @@ def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: plan_path = tmp_path / "plan.json" impact_path = tmp_path / "impact.json" code_path = tmp_path / "code.json" + apply_path = tmp_path / "apply.json" review_path = tmp_path / "review.json" report_path = tmp_path / "report.html" dep_id = "scripts/common/run_report.py:_render_artifacts" @@ -189,16 +235,48 @@ def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: encoding="utf-8", ) code_path.write_text(json.dumps({"success": True, "files_modified": ["scripts/common/run_report.py"], "last_status": "complete"}), encoding="utf-8") + apply_path.write_text( + json.dumps({ + "type": "dep_refreshed", + "backup_timestamp": "456", + "backups": {"dep_graph": str(tmp_path / "dep_graph.before-edit-456.json")}, + "applied_features": [], + "dep_graph_refreshed": True, + "rollback_command": "cmind script rpg_edit/apply.py --rollback 456", + "test_result": {"passed": False, "output": "failing test"}, + }), + encoding="utf-8", + ) monkeypatch.setattr(review, "RPG_EDIT_VALIDATE_FILE", validate_path) monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) monkeypatch.setattr(review, "RPG_EDIT_IMPACT_FILE", impact_path) monkeypatch.setattr(review, "RPG_EDIT_CODE_RESULT_FILE", code_path) + monkeypatch.setattr(review, "RPG_EDIT_APPLY_RESULT_FILE", apply_path) monkeypatch.setattr(review, "RPG_EDIT_REVIEW_RESULT_FILE", review_path) monkeypatch.setattr(review, "_focused_graph_artifact", lambda candidates, artifacts: {}) + monkeypatch.setattr( + review, + "read_head", + lambda repo_dir: { + "head_commit": "current456", + "head_short": "current", + "head_branch": "fallback-branch", + "head_timestamp": "2026-06-30T13:00:00+00:00", + }, + ) def fake_write_command_report(run): data = run.to_dict() + decision = data["user_decisions"][0] + assert decision["decision"] == "apply" + assert decision["branch"] == "fallback-branch" + assert decision["before_state"]["head_commit"] == "current456" + assert "confirmed" not in decision + assert decision["apply_status"] == "dep_refreshed" + assert decision["test_status"] == "failed" + assert decision["rollback_path"].endswith("dep_graph.before-edit-456.json") + assert any(item["label"] == "apply_result" for item in data["artifacts"]) assert data["rpg_deltas"] == [{"node_id": "planned", "name": "Planned Node"}] assert data["dep_graph_deltas"] == [ {"dep_node_id": dep_id, "path": "scripts/common/run_report.py", "source_feature": "planned"} diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index 7ffe7d5..f27ccd0 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -37,6 +37,17 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path dep_graph_deltas=[DepGraphDeltaEvent(dep_node_id="a.py:f", path="a.py")], artifacts=[ArtifactEvent(label="plan", path=tmp_path / "plan.json")], verification=[VerificationEvent(name="pytest", status="passed")], + user_decisions=[ + UserDecisionEvent( + decision="apply ", + branch="rpg-edit/", + before_state={"head_branch": "
    ", "head_commit": "abc"}, timestamp="2026-06-30T12:34:56Z", ), @@ -48,10 +59,24 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path html = report.read_text(encoding="utf-8") assert "Summary" in html assert "Stage timeline" in html + assert "Safety boundary" in html assert "Artifact links" in html assert "Evidence JSON" in html + assert "Before state" in html + assert "Confirmation" in html + assert "Branch" in html + assert "Apply status" in html + assert "Test status" in html + assert "Rollback path" in html + assert "apply <unsafe>" in html + assert "rpg-edit/<branch>" in html + assert "abc<script>" in html + assert "backup/<script>" in html + assert "success <ok>" in html + assert "passed <ok>" in html assert "<script>evil()</script>" in html assert "" not in html + assert "backup/