From bbf24a98e56325d269f1770c7cca8565d6d086e1 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Tue, 30 Jun 2026 04:32:00 +0000 Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/" not in html assert "
    View diff" in html assert "
    Focused graph evidence") == 1 + assert html.count("

    Focused impact summary

    ") == 1 + assert "Focused graph evidence" in html assert "Graph artifact" in html assert "Inspector metadata" in html @@ -169,7 +222,8 @@ def test_write_command_report_does_not_invent_node_rows_from_counts(tmp_path: Pa ) html = report.read_text(encoding="utf-8") - assert html.count("No node evidence recorded.") == 2 + assert "Why these nodes?" not in html + assert "Focused impact summary" not in html assert '"dep_nodes": 4' in html assert '4' not in html From 7fb0de91105d1f5573d5f15137e1bcccc4e594de Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Fri, 3 Jul 2026 05:14:55 +0000 Subject: [PATCH 17/56] rpg_edit: Stop producing standalone focused HTML: remove/disable _focu --- CoderMind/scripts/common/run_report.py | 276 +++++---- CoderMind/scripts/rpg_edit/review.py | 597 ++++++++++++++++---- CoderMind/tests/test_rpg_edit_run_report.py | 106 ++-- CoderMind/tests/test_run_report.py | 126 +++-- 4 files changed, 815 insertions(+), 290 deletions(-) diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index d35923e..7ac682e 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -51,8 +51,9 @@ def write_command_report( 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") - focused_impact = data.get("focused_impact") or evidence_data.get("focused_impact") + focused_view = data.get("focused_view") or evidence_data.get("focused_view") + if not focused_view: + focused_view = data.get("focused_impact") or evidence_data.get("focused_impact") user_decisions = data.get("user_decisions") or evidence_data.get("user_decisions") page_title = title or f"CoderMind {command} Explain View" @@ -67,8 +68,7 @@ def write_command_report( 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), - focused_impact=_normalize_focused_impact(focused_impact), + focused_view=_normalize_focused_view(focused_view), artifacts=_normalize_artifacts(data.get("artifacts")), verification=_normalize_verification(data.get("verification")), user_decisions=_normalize_user_decisions(user_decisions), @@ -198,22 +198,34 @@ def _normalize_code_deltas(value: Any) -> list[dict[str, Any]]: 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_focused_impact(value: Any) -> dict[str, Any]: +def _normalize_focused_view(value: Any) -> dict[str, Any]: if value in (None, "", [], {}): return {} if not isinstance(value, Mapping): - return {"detail": value, "groups": []} + return {"detail": value} normalized = dict(value) normalized["summary"] = dict(value.get("summary") or {}) if isinstance(value.get("summary"), Mapping) else {} - normalized["groups"] = [dict(group) if isinstance(group, Mapping) else {"detail": group} for group in _as_sequence(value.get("groups"))] + normalized["primary_rpg_nodes"] = [ + dict(node) if isinstance(node, Mapping) else {"node_id": node} + for node in _as_sequence(value.get("primary_rpg_nodes")) + ] + normalized["primary_code_nodes"] = [ + dict(node) if isinstance(node, Mapping) else {"node_id": node} + for node in _as_sequence(value.get("primary_code_nodes")) + ] + normalized["mappings"] = [ + dict(mapping) if isinstance(mapping, Mapping) else {"detail": mapping} + for mapping in _as_sequence(value.get("mappings")) + ] + normalized["edges"] = [ + dict(edge) if isinstance(edge, Mapping) else {"detail": edge} + for edge in _as_sequence(value.get("edges")) + ] + normalized["hidden_counts"] = dict(value.get("hidden_counts") or {}) if isinstance(value.get("hidden_counts"), Mapping) else {} + normalized["warnings"] = [ + dict(warning) if isinstance(warning, Mapping) else {"message": warning} + for warning in _as_sequence(value.get("warnings")) + ] normalized["unmatched_code_deltas"] = [ dict(delta) if isinstance(delta, Mapping) else {"file": delta} for delta in _as_sequence(value.get("unmatched_code_deltas")) @@ -293,8 +305,7 @@ def _render_page( rpg_nodes: list[dict[str, Any]], dep_nodes: list[dict[str, Any]], code_deltas: list[dict[str, Any]], - focused_graph: dict[str, Any], - focused_impact: dict[str, Any], + focused_view: dict[str, Any], artifacts: list[dict[str, Any]], verification: list[dict[str, Any]], user_decisions: list[dict[str, Any]], @@ -342,6 +353,9 @@ def _render_page( .empty {{ color:var(--muted); font-style:italic; }} pre {{ white-space:pre-wrap; overflow:auto; background:#0f172a; color:#e5e7eb; border-radius:10px; padding:14px; }} details summary {{ cursor:pointer; color:var(--accent); font-weight:600; }} +.focus-summary {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px; }} +.focus-summary .badge {{ background:#eef4ff; color:#174ea6; }} +.warning-list {{ margin:0 0 12px; padding-left:18px; }} @@ -354,7 +368,7 @@ def _render_page( {_render_timeline(stages)} {_render_safety_boundary(user_decisions)} {_render_why_these_nodes(retrievals, rpg_nodes, dep_nodes)} -{_render_focused_impact(focused_impact, focused_graph)} +{_render_focused_impact(focused_view)} {_render_code_deltas(code_deltas)} {_render_verification(verification)} {_render_artifacts(artifacts)} @@ -564,94 +578,164 @@ def _render_node_rows(title: str, nodes: list[dict[str, Any]], *, dep_graph: boo return f"

    {_h(title)}

    {body}" -def _focused_graph_metadata(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_focused_impact(focused_impact: dict[str, Any], focused_graph: dict[str, Any]) -> str: - if not focused_impact and not focused_graph: - return "" - summary = focused_impact.get("summary") if isinstance(focused_impact.get("summary"), Mapping) else {} - groups = focused_impact.get("groups") or [] - blocks = [] - if summary: - rows = "".join(f"{_h(key)}{_h(value)}" for key, value in summary.items()) - blocks.append("" + rows + "
    ") - if groups: - for group in groups: - blocks.append(_render_focused_impact_group(group)) - elif focused_impact: - blocks.append("

    No focused impact groups recorded.

    ") - blocks.append(_focused_graph_metadata(focused_graph)) - return f"

    Focused impact summary

    {''.join(blocks)}
    " +def _focused_graph_metadata(focused_view: dict[str, Any]) -> str: + data = json.dumps(focused_view, indent=2, ensure_ascii=False, default=_json_default) + return f"
    Inspector JSON
    {_h(data)}
    " -def _render_focused_impact_group(group: Mapping[str, Any]) -> str: - title = group.get("name") or group.get("node_id") or "feature" - node_id = group.get("node_id", "") - missing_states = group.get("missing_states") if isinstance(group.get("missing_states"), Mapping) else {} - hidden_counts = group.get("hidden_counts") if isinstance(group.get("hidden_counts"), Mapping) else {} - relations = [item for item in _as_sequence(group.get("code_relations")) if isinstance(item, Mapping)] - affected_files = _as_sequence(group.get("affected_files")) - changed_files = _as_sequence(group.get("changed_files")) - state_rows = [ - ("Feature", f"{_h(node_id)}", True), - ("Reason", _h(group.get("reason", "")), True), - ("Status", _h(group.get("status", "")), True), - ("Missing states", _h(missing_states or "none"), True), - ("Hidden counts", _h(hidden_counts or "none"), True), - ("Affected files", _h(", ".join(str(item) for item in affected_files)), True), - ("Changed files", _h(", ".join(str(item) for item in changed_files)), True), +def _focused_node_cell(node_id: Any, node: Mapping[str, Any] | None) -> str: + if node_id in (None, "") and not node: + return "missing" + node = node or {} + parts = [] + if node_id not in (None, ""): + parts.append(f"{_h(node_id)}") + if node.get("name"): + parts.append(f"
    {_h(node.get('name'))}
    ") + if node.get("path"): + parts.append(f"
    {_h(node.get('path'))}
    ") + return "".join(parts) or "missing" + + +def _render_focused_impact(focused_view: dict[str, Any]) -> str: + if not focused_view: + return "" + summary = focused_view.get("summary") if isinstance(focused_view.get("summary"), Mapping) else {} + rpg_nodes = [node for node in _as_sequence(focused_view.get("primary_rpg_nodes")) if isinstance(node, Mapping)] + code_nodes = [node for node in _as_sequence(focused_view.get("primary_code_nodes")) if isinstance(node, Mapping)] + mappings = [mapping for mapping in _as_sequence(focused_view.get("mappings")) if isinstance(mapping, Mapping)] + edges = [edge for edge in _as_sequence(focused_view.get("edges")) if isinstance(edge, Mapping)] + hidden_counts = focused_view.get("hidden_counts") if isinstance(focused_view.get("hidden_counts"), Mapping) else {} + warnings = [warning for warning in _as_sequence(focused_view.get("warnings")) if isinstance(warning, Mapping)] + rpg_by_id = {str(node.get("node_id")): node for node in rpg_nodes if node.get("node_id") not in (None, "")} + code_by_id = { + str(node.get("node_id") or node.get("dep_node_id")): node + for node in code_nodes + if (node.get("node_id") or node.get("dep_node_id")) not in (None, "") + } + summary_items = [ + ("Primary RPG nodes", summary.get("primary_rpg_nodes", len(rpg_nodes))), + ("Primary code nodes", summary.get("primary_code_nodes", len(code_nodes))), + ("Mapped relations", summary.get("mapped_code_relations", len([row for row in mappings if row.get("code_node_id")]))), + ("Missing mappings", summary.get("missing_mappings", len([row for row in mappings if row.get("status") == "missing"]))), + ("Edges shown", summary.get("edges", len(edges))), + ("Warnings", summary.get("warnings", len(warnings))), ] - rows = [] - for label, value, is_html in state_rows: - if value in (None, ""): - continue - rows.append(f"{_h(label)}{value if is_html else _h(value)}") - relation_rows = [] - for relation in relations: - dep_id = relation.get("dep_node_id") or relation.get("node_id") - relation_rows.append( + summary_html = "
    " + "".join( + f"{_h(label)}: {_h(value)}" + for label, value in summary_items + ) + "
    " + + code_rows = [] + for node in code_nodes: + node_id = node.get("node_id") or node.get("dep_node_id") + code_rows.append( + "" + f"{_focused_node_cell(node_id, node)}" + f"{_h(node.get('type') or node.get('kind') or '')}" + f"{_h(node.get('status', ''))}" + f"{_h(node.get('source', ''))}" + "" + ) + code_html = "

    No primary code nodes recorded.

    " + if code_rows: + code_html = "" + "".join(code_rows) + "
    Code nodeTypeStatusSource
    " + + mapping_rows = [] + for mapping in mappings: + rpg_id = mapping.get("rpg_node_id") or mapping.get("node_id") + code_id = mapping.get("code_node_id") or mapping.get("dep_node_id") + rpg_node = rpg_by_id.get(str(rpg_id)) if rpg_id not in (None, "") else None + code_node = code_by_id.get(str(code_id)) if code_id not in (None, "") else None + changed_files = ", ".join(str(item) for item in _as_sequence(mapping.get("changed_files"))) + mapping_rows.append( "" - f"{_h(dep_id)}" - f"{_h(relation.get('path', ''))}" - f"{_h(relation.get('relation') or relation.get('source') or '')}" - f"{_h(relation.get('status', ''))}" + f"{_focused_node_cell(rpg_id, rpg_node)}" + f"{_focused_node_cell(code_id, code_node)}" + f"{_h(mapping.get('status', ''))}" + f"{_h(mapping.get('path') or (code_node or {}).get('path') or '')}" + f"{_h(mapping.get('source', ''))}" + f"{_h(mapping.get('reason', ''))}" + f"{_h(changed_files)}" "" ) - relations_html = "

    No mapped code relations recorded.

    " - if relation_rows: - relations_html = "" + "".join(relation_rows) + "
    Code nodePathRelationStatus
    " + mappings_html = "

    No semantic-code mappings recorded.

    " + if mapping_rows: + mappings_html = ( + "" + "" + + "".join(mapping_rows) + + "
    Semantic nodeCode nodeStatusPathSourceReasonChanged files
    " + ) + + hidden_relations = hidden_counts.get("relations") if isinstance(hidden_counts.get("relations"), Mapping) else {} + relation_hidden_keys = {"caller": "callers", "callee": "callees", "import": "imports", "inheritance": "inheritance"} + edges_by_relation: dict[str, list[Mapping[str, Any]]] = {} + for edge in edges: + relation = str(edge.get("relation") or "dependency") + edges_by_relation.setdefault(relation, []).append(edge) + relation_names = set(edges_by_relation) + for relation, hidden_key in relation_hidden_keys.items(): + if hidden_counts.get(hidden_key): + relation_names.add(relation) + relation_blocks = [] + for relation in sorted(relation_names): + hidden = hidden_relations.get(relation, 0) if isinstance(hidden_relations, Mapping) else 0 + hidden += hidden_counts.get(relation_hidden_keys.get(relation, ""), 0) or 0 + relation_blocks.append(_render_focused_impact_group({"relation": relation, "edges": edges_by_relation.get(relation, []), "hidden": hidden})) + neighborhood_html = "".join(relation_blocks) or "

    No one-hop neighborhood edges recorded.

    " + + warning_html = "

    No focused warnings recorded.

    " + if warnings: + warning_items = [] + for warning in warnings: + warning_type = warning.get("type", "warning") + context = {key: value for key, value in warning.items() if key not in {"type", "message"}} + context_html = f" {_h(context)}" if context else "" + warning_items.append(f"
  • {_h(warning_type)} {_h(warning.get('message', ''))}{context_html}
  • ") + warning_html = '
      ' + "".join(warning_items) + "
    " + + hidden_html = "

    No hidden focused context recorded.

    " + if hidden_counts: + hidden_rows = "".join(f"{_h(key)}{_h(value)}" for key, value in hidden_counts.items()) + hidden_html = "" + hidden_rows + "
    " + return ( - "
    " - f"
    {_h(title)}{_h(node_id)}
    " - "" + "".join(rows) + "
    " - f"
    Mapped code relations{relations_html}
    " - "
    " + "

    Focused impact view

    " + f"{summary_html}" + f"

    Primary code nodes

    {code_html}" + f"

    Semantic-code mappings

    {mappings_html}" + f"

    Capped neighborhood

    {neighborhood_html}" + f"

    Warnings

    {warning_html}" + f"

    Hidden context

    {hidden_html}" + f"{_focused_graph_metadata(focused_view)}" + "
    " ) +def _render_focused_impact_group(group: Mapping[str, Any]) -> str: + relation = group.get("relation") or "dependency" + edges = [edge for edge in _as_sequence(group.get("edges")) if isinstance(edge, Mapping)] + hidden = group.get("hidden") or 0 + edge_rows = [] + for edge in edges: + edge_rows.append( + "" + f"{_h(edge.get('source_node_id', ''))}" + f"{_h(edge.get('target_node_id', ''))}" + f"{_h(edge.get('direction', ''))}" + f"{_h(edge.get('path', ''))}" + f"{_h(edge.get('source', ''))}" + f"{_h(edge.get('reason', ''))}" + "" + ) + rows_html = "

    No visible rows for this relation.

    " + if edge_rows: + rows_html = "" + "".join(edge_rows) + "
    SourceTargetDirectionPathSourceReason
    " + hidden_html = f"

    Hidden { _h(hidden) } additional {_h(relation)} neighbors.

    " if hidden else "" + return f"
    {_h(relation)} neighborhood ({_h(len(edges))} shown){hidden_html}{rows_html}
    " + + def _render_artifacts(artifacts: list[dict[str, Any]]) -> str: if not artifacts: body = "

    No artifact links recorded.

    " diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index d66ce52..6368a3d 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -283,47 +283,17 @@ def _code_delta_rows(artifacts: Dict[str, Any]) -> List[Dict[str, Any]]: 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", + return { + "status": "embedded", "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: @@ -367,6 +337,176 @@ def _code_delta_file(delta: Dict[str, Any]) -> str: return str(delta.get("file") or delta.get("path") or "") +_FOCUSED_RPG_NODE_CAP = 20 +_FOCUSED_CODE_NODE_CAP = 50 +_FOCUSED_EDGE_CAP = 80 +_FOCUSED_WARNING_TYPES = {"missing_mapping", "missing_reason", "too_many_neighbors", "stale_graph"} + + +def _set_if_present(row: Dict[str, Any], key: str, value: Any) -> None: + if value not in (None, ""): + row[key] = value + + +def _focus_reason(candidate: Dict[str, Any], impact: Dict[str, Any]) -> str: + for source in (candidate, impact): + for key in ("reason", "hit_reason", "rationale", "explanation"): + value = source.get(key) if isinstance(source, dict) else None + if value not in (None, ""): + return str(value) + return "" + + +def _rpg_node_entry(node_id: str, raw: Dict[str, Any]) -> Dict[str, Any]: + meta = raw.get("meta") if isinstance(raw.get("meta"), dict) else {} + row: Dict[str, Any] = {"node_id": node_id} + _set_if_present(row, "name", raw.get("name")) + _set_if_present(row, "node_type", raw.get("node_type") or raw.get("type") or raw.get("type_name")) + _set_if_present(row, "path", raw.get("path") or raw.get("file") or meta.get("path")) + _set_if_present(row, "feature_path", raw.get("feature_path") or meta.get("feature_path")) + return row + + +def _dep_node_entry(dep_id: str, raw: Dict[str, Any]) -> Dict[str, Any]: + row: Dict[str, Any] = {"node_id": dep_id, "dep_node_id": dep_id} + for key in ( + "name", + "type", + "kind", + "module", + "path", + "file", + "signature", + "line_start", + "line_end", + "start_line", + "end_line", + "lineno", + "line", + ): + _set_if_present(row, key, raw.get(key)) + if not row.get("path"): + _set_if_present(row, "path", row.get("module") or row.get("file") or _dep_node_path(dep_id)) + return row + + +def _coerce_dep_edge(edge: Any) -> Optional[Dict[str, Any]]: + if isinstance(edge, dict): + source = edge.get("source") or edge.get("from") or edge.get("caller") or edge.get("src") + target = edge.get("target") or edge.get("to") or edge.get("callee") or edge.get("dst") + relation = edge.get("relation") or edge.get("type") or edge.get("edge_type") or edge.get("kind") or "dep_graph" + if source in (None, "") or target in (None, ""): + return None + row = {"source_node_id": str(source), "target_node_id": str(target), "relation": str(relation), "source": "dep_graph"} + _set_if_present(row, "reason", edge.get("reason")) + return row + if isinstance(edge, (list, tuple)) and len(edge) >= 2: + relation = edge[2] if len(edge) >= 3 else "dep_graph" + return {"source_node_id": str(edge[0]), "target_node_id": str(edge[1]), "relation": str(relation), "source": "dep_graph"} + return None + + +def _current_rpg_context() -> Tuple[ + Dict[str, Dict[str, Any]], + Dict[str, Dict[str, Any]], + List[Dict[str, Any]], + Dict[str, List[str]], + Dict[str, List[str]], + List[Dict[str, Any]], +]: + rpg_data = _load_json_artifact(REPO_RPG_FILE) + if not isinstance(rpg_data, dict) or rpg_data.get("_error"): + return {}, {}, [], {}, {}, [{"type": "stale_graph", "message": f"RPG file not available: {REPO_RPG_FILE}"}] + + rpg_nodes: Dict[str, Dict[str, Any]] = {} + + def visit_rpg_node(raw: Any) -> None: + if not isinstance(raw, dict): + return + node_id = raw.get("id") or raw.get("node_id") + if node_id not in (None, ""): + rpg_nodes[str(node_id)] = _rpg_node_entry(str(node_id), raw) + children = raw.get("children") or [] + if isinstance(children, dict): + children = list(children.values()) + if isinstance(children, (list, tuple)): + for child in children: + visit_rpg_node(child) + + visit_rpg_node(rpg_data.get("root")) + raw_rpg_nodes = rpg_data.get("nodes") + if isinstance(raw_rpg_nodes, dict): + for node_id, raw in raw_rpg_nodes.items(): + raw_dict = raw if isinstance(raw, dict) else {} + rpg_nodes[str(node_id)] = _rpg_node_entry(str(node_id), raw_dict) + elif isinstance(raw_rpg_nodes, (list, tuple)): + for raw in raw_rpg_nodes: + if isinstance(raw, dict): + node_id = raw.get("id") or raw.get("node_id") + if node_id not in (None, ""): + rpg_nodes[str(node_id)] = _rpg_node_entry(str(node_id), raw) + + dep_graph = rpg_data.get("dep_graph") if isinstance(rpg_data.get("dep_graph"), dict) else {} + dep_nodes: Dict[str, Dict[str, Any]] = {} + dep_to_rpg: Dict[str, List[str]] = {} + rpg_to_dep: Dict[str, List[str]] = {} + raw_dep_nodes = dep_graph.get("nodes") + if isinstance(raw_dep_nodes, dict): + dep_items = raw_dep_nodes.items() + elif isinstance(raw_dep_nodes, (list, tuple)): + dep_items = [] + for raw in raw_dep_nodes: + if isinstance(raw, dict): + dep_id = raw.get("id") or raw.get("node_id") or raw.get("dep_node_id") + if dep_id not in (None, ""): + dep_items.append((dep_id, raw)) + else: + dep_items = [] + for dep_id, raw in dep_items: + dep_id_text = str(dep_id) + raw_dict = raw if isinstance(raw, dict) else {} + dep_nodes[dep_id_text] = _dep_node_entry(dep_id_text, raw_dict) + linked = _ordered_unique([str(item) for item in _listify(raw_dict.get("rpg_nodes") or raw_dict.get("features") or raw_dict.get("source_features"))]) + if linked: + dep_to_rpg[dep_id_text] = linked + for rpg_id in linked: + rpg_to_dep.setdefault(rpg_id, []).append(dep_id_text) + + raw_dep_to_rpg = rpg_data.get("_dep_to_rpg_map") if isinstance(rpg_data.get("_dep_to_rpg_map"), dict) else {} + for dep_id, rpg_ids in raw_dep_to_rpg.items(): + dep_id_text = str(dep_id) + linked = _ordered_unique([str(item) for item in _listify(rpg_ids)]) + if linked: + dep_to_rpg[dep_id_text] = _ordered_unique((dep_to_rpg.get(dep_id_text) or []) + linked) + for rpg_id in linked: + rpg_to_dep.setdefault(rpg_id, []).append(dep_id_text) + + raw_rpg_to_dep = rpg_data.get("_rpg_to_dep_map") if isinstance(rpg_data.get("_rpg_to_dep_map"), dict) else {} + for rpg_id, dep_ids in raw_rpg_to_dep.items(): + rpg_id_text = str(rpg_id) + linked = _ordered_unique([str(item) for item in _listify(dep_ids)]) + if linked: + rpg_to_dep[rpg_id_text] = _ordered_unique((rpg_to_dep.get(rpg_id_text) or []) + linked) + for dep_id in linked: + dep_to_rpg.setdefault(dep_id, []).append(rpg_id_text) + + for rpg_id, dep_ids in list(rpg_to_dep.items()): + rpg_to_dep[rpg_id] = _ordered_unique(dep_ids) + for dep_id, rpg_ids in list(dep_to_rpg.items()): + dep_to_rpg[dep_id] = _ordered_unique(rpg_ids) + + dep_edges = [] + for raw_edge in dep_graph.get("edges") or []: + edge = _coerce_dep_edge(raw_edge) + if edge: + dep_edges.append(edge) + + warnings: List[Dict[str, Any]] = [] + if not rpg_nodes and not dep_nodes: + warnings.append({"type": "stale_graph", "message": "Current RPG contains no indexed feature or dependency nodes"}) + return rpg_nodes, dep_nodes, dep_edges, rpg_to_dep, dep_to_rpg, warnings + + def _feature_evidence_groups( artifacts: Dict[str, Any], candidates: List[Dict[str, Any]], @@ -379,9 +519,11 @@ def _feature_evidence_groups( code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else {} apply_result = artifacts.get("apply_result") if isinstance(artifacts.get("apply_result"), dict) else {} impact_results = _impact_results(artifacts) - locate_ids = {row.get("node_id") for row in locate.get("results") or [] if isinstance(row, dict)} + current_rpg_nodes, current_dep_nodes, current_dep_edges, current_rpg_to_dep, _, graph_warnings = _current_rpg_context() + current_graph_available = bool(current_rpg_nodes or current_dep_nodes) + locate_ids = {str(row.get("node_id")) for row in locate.get("results") or [] if isinstance(row, dict) and row.get("node_id")} applied_by_id = { - row.get("node_id"): row + str(row.get("node_id")): row for row in apply_result.get("applied_features") or [] if isinstance(row, dict) and row.get("node_id") } @@ -390,104 +532,327 @@ def _feature_evidence_groups( + [str(path) for path in _listify(code_result.get("files_modified"))] + [str(change.get("file_path")) for change in plan.get("code_changes") or [] if isinstance(change, dict) and change.get("file_path")] ) - groups: List[Dict[str, Any]] = [] + warnings: List[Dict[str, Any]] = [] + warning_keys: set[str] = set() + + def add_warning(warning_type: str, message: str, **context: Any) -> None: + if warning_type not in _FOCUSED_WARNING_TYPES: + return + row: Dict[str, Any] = {"type": warning_type, "message": message} + for key, value in context.items(): + _set_if_present(row, key, value) + key = json.dumps(row, sort_keys=True, default=str) + if key not in warning_keys: + warning_keys.add(key) + warnings.append(row) + + for warning in graph_warnings: + if isinstance(warning, dict): + add_warning(str(warning.get("type") or "stale_graph"), str(warning.get("message") or "Current RPG graph may be stale")) + + if apply_result and apply_result.get("dep_graph_refreshed") is False: + add_warning("stale_graph", "Apply result says the dependency graph was not refreshed", apply_status=_apply_status(apply_result)) + + primary_rpg_nodes_all: List[Dict[str, Any]] = [] + mapping_rows_all: List[Dict[str, Any]] = [] + code_node_by_id: Dict[str, Dict[str, Any]] = {} + all_edges: List[Dict[str, Any]] = [] + edge_keys: set[Tuple[str, str, str, str]] = set() + impact_hidden_counts: Dict[str, int] = {} + + def count_value(value: Any, fallback: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return fallback + + def add_code_node(dep_id: Any, *, source_feature: Any = None, source: str = "mapping", fallback_path: Any = None) -> None: + if dep_id in (None, ""): + return + dep_id_text = str(dep_id) + if dep_id_text in code_node_by_id: + row = code_node_by_id[dep_id_text] + row["source"] = "+".join(_ordered_unique(_listify(row.get("source")) + [source])) + if source_feature not in (None, ""): + row["source_features"] = _ordered_unique(_listify(row.get("source_features")) + [str(source_feature)]) + return + current = current_dep_nodes.get(dep_id_text, {}) + row = dict(current) if current else {"node_id": dep_id_text, "dep_node_id": dep_id_text} + row.setdefault("node_id", dep_id_text) + row.setdefault("dep_node_id", dep_id_text) + _set_if_present(row, "path", fallback_path) + row["status"] = "mapped" + row["source"] = source + if source_feature not in (None, ""): + row["source_feature"] = str(source_feature) + row["source_features"] = [str(source_feature)] + if row.get("path") in changed_files: + row["changed"] = True + if current_graph_available and dep_id_text not in current_dep_nodes: + row["status"] = "stale_graph" + add_warning("stale_graph", "Mapped code node is absent from the current dependency graph", dep_node_id=dep_id_text) + code_node_by_id[dep_id_text] = row + + def add_edge(edge: Dict[str, Any]) -> None: + source_node = edge.get("source_node_id") + target_node = edge.get("target_node_id") + relation = edge.get("relation") or "dependency" + source = edge.get("source") or "impact" + if source_node in (None, "") or target_node in (None, ""): + return + source_text = str(source_node) + target_text = str(target_node) + relation_text = str(relation) + source_text_label = str(source) + key = (source_text, target_text, relation_text, source_text_label) + if key in edge_keys: + return + edge_keys.add(key) + row: Dict[str, Any] = { + "source_node_id": source_text, + "target_node_id": target_text, + "relation": relation_text, + "source": source_text_label, + } + for key_name in ("rpg_node_id", "neighbor_node_id", "direction", "name", "path", "reason", "status"): + _set_if_present(row, key_name, edge.get(key_name)) + all_edges.append(row) + + def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: + if isinstance(item, dict): + node_id = item.get("dep_node_id") or item.get("node_id") or item.get("id") + if node_id in (None, ""): + return None + row: Dict[str, Any] = {"node_id": str(node_id)} + for key_name in ("name", "path", "reason", "status", "type"): + _set_if_present(row, key_name, item.get(key_name)) + return row + if item in (None, ""): + return None + return {"node_id": str(item)} + + neighbor_specs = [ + ("callers", "caller", "upstream", "total_callers"), + ("callees", "callee", "downstream", "total_callees"), + ("imports", "import", "downstream", "total_imports"), + ("inheritance", "inheritance", "downstream", "total_inheritance"), + ] + for candidate in candidates: - node_id = candidate.get("node_id") + raw_node_id = candidate.get("node_id") + if raw_node_id in (None, ""): + continue + node_id = str(raw_node_id) impact = impact_results.get(node_id) if isinstance(impact_results.get(node_id), dict) else {} + current_node = current_rpg_nodes.get(node_id, {}) relations = _mapped_code_relations(candidate, impact) - relation_paths = _ordered_unique([row.get("path") for row in relations]) + relation_by_dep = {str(row.get("dep_node_id") or row.get("node_id")): row for row in relations if row.get("dep_node_id") or row.get("node_id")} + mapped_dep_ids = _ordered_unique(list(relation_by_dep) + (current_rpg_to_dep.get(node_id) or [])) + relation_paths = _ordered_unique( + [row.get("path") for row in relations] + + [current_dep_nodes.get(dep_id, {}).get("path") for dep_id in mapped_dep_ids] + ) affected_files = _ordered_unique(_listify(impact.get("affected_files")) + relation_paths) relevant_files = set(affected_files or relation_paths or changed_files) relevant_deltas = [delta for delta in code_deltas if _code_delta_file(delta) in relevant_files] - locate_state = candidate.get("locate_state") or ("selected" if node_id in locate_ids or not locate else "missing") - if not impact: - impact_state = "missing" - elif impact.get("error"): - impact_state = "error" - elif relations: - impact_state = "mapped" - else: - impact_state = "missing_mapping" - missing_states: Dict[str, str] = {} - if locate_state != "selected": - missing_states["locate"] = str(locate_state) - if impact_state != "mapped": - missing_states["impact"] = impact_state - if not relations: - missing_states["mapping"] = "missing_dep_graph_mapping" + changed_for_node = _ordered_unique([_code_delta_file(delta) for delta in relevant_deltas]) + focus_reason = _focus_reason(candidate, impact) + if not focus_reason: + add_warning("missing_reason", "Focused view has no explicit selection reason", node_id=node_id) + if not mapped_dep_ids: + add_warning("missing_mapping", "Selected RPG node has no mapped code node", node_id=node_id) + if current_graph_available and node_id not in current_rpg_nodes: + add_warning("stale_graph", "Selected RPG node is absent from the current RPG graph", node_id=node_id) + impact_summary = impact.get("impact_summary") if isinstance(impact.get("impact_summary"), dict) else {} - caller_count = impact_summary.get("total_callers", len(impact.get("callers") or [])) - callee_count = impact_summary.get("total_callees", len(impact.get("callees") or [])) - inheritance_count = impact_summary.get("total_inheritance", len(impact.get("inheritance") or [])) - affected_file_count = impact_summary.get("affected_file_count", len(affected_files)) + node_hidden_counts: Dict[str, int] = {} + for impact_key, relation, direction, total_key in neighbor_specs: + items = _listify(impact.get(impact_key)) + total = count_value(impact_summary.get(total_key), len(items)) + visible = 0 + for item in items: + neighbor = impact_neighbor(item) + if not neighbor: + continue + visible += 1 + if relation == "caller": + source_node = neighbor["node_id"] + target_node = node_id + else: + source_node = node_id + target_node = neighbor["node_id"] + edge_row = { + "source_node_id": source_node, + "target_node_id": target_node, + "relation": relation, + "source": "impact", + "rpg_node_id": node_id, + "neighbor_node_id": neighbor["node_id"], + "direction": direction, + } + for key_name in ("name", "path", "reason", "status"): + _set_if_present(edge_row, key_name, neighbor.get(key_name)) + add_edge(edge_row) + hidden = max(0, total - visible) + if hidden: + node_hidden_counts[impact_key] = hidden + impact_hidden_counts[impact_key] = impact_hidden_counts.get(impact_key, 0) + hidden + apply_row = applied_by_id.get(node_id, {}) - groups.append({ + rpg_row: Dict[str, Any] = { "node_id": node_id, - "name": candidate.get("name") or impact.get("name") or node_id, - "node_type": candidate.get("node_type") or candidate.get("type_name") or candidate.get("type"), - "path": candidate.get("path") or candidate.get("meta_path"), - "feature_path": candidate.get("feature_path"), - "score": candidate.get("score"), - "status": "mapped" if relations else "missing_mapping", - "reason": _retrieval_hit_reason(candidate, impact), - "missing_states": missing_states, - "code_relations": relations, - "affected_files": affected_files, - "changed_files": [_code_delta_file(delta) for delta in relevant_deltas], - "callers": impact.get("callers") or [], - "callees": impact.get("callees") or [], - "imports": impact.get("imports") or [], - "inheritance": impact.get("inheritance") or [], - "hidden_counts": { - "code_relations": len(relations), - "affected_files": affected_file_count, - "callers": caller_count, - "callees": callee_count, - "imports": len(impact.get("imports") or []), - "inheritance": inheritance_count, - }, - "apply": { - "status": _apply_status(apply_result), - "action": apply_row.get("action") or apply_row.get("change"), - "dep_graph_refreshed": apply_result.get("dep_graph_refreshed"), - }, - "review": { - "status": result.get("type", "review"), - "success": result.get("success", result.get("type") == "skipped"), - "iterations": len(result.get("iterations") or []), - "suggestions": len(result.get("suggestions") or []), - }, - }) - matched_files = {file_path for group in groups for file_path in group.get("changed_files") or []} + "status": "mapped" if mapped_dep_ids else "missing", + "mapping_status": "mapped" if mapped_dep_ids else "missing", + } + _set_if_present(rpg_row, "name", candidate.get("name") or impact.get("name") or current_node.get("name")) + _set_if_present(rpg_row, "node_type", candidate.get("node_type") or candidate.get("type_name") or candidate.get("type") or current_node.get("node_type")) + _set_if_present(rpg_row, "path", candidate.get("path") or candidate.get("meta_path") or current_node.get("path")) + _set_if_present(rpg_row, "feature_path", candidate.get("feature_path") or current_node.get("feature_path")) + _set_if_present(rpg_row, "score", candidate.get("score")) + _set_if_present(rpg_row, "reason", focus_reason) + if node_id not in locate_ids and locate: + rpg_row["locate_status"] = candidate.get("locate_state") or "missing" + if affected_files: + rpg_row["affected_files"] = affected_files + if changed_for_node: + rpg_row["changed_files"] = changed_for_node + if node_hidden_counts: + rpg_row["hidden_counts"] = node_hidden_counts + _set_if_present(rpg_row, "apply_action", apply_row.get("action") or apply_row.get("change")) + primary_rpg_nodes_all.append(rpg_row) + + if mapped_dep_ids: + for dep_id in mapped_dep_ids: + relation = relation_by_dep.get(dep_id, {}) + current_dep = current_dep_nodes.get(dep_id, {}) + path = current_dep.get("path") or relation.get("path") + source_parts = _ordered_unique(_listify(relation.get("source")) + (["current_rpg"] if dep_id in (current_rpg_to_dep.get(node_id) or []) else [])) + mapping_status = "mapped" + if current_graph_available and dep_id not in current_dep_nodes: + mapping_status = "stale_graph" + add_warning("stale_graph", "Mapped code node is absent from the current dependency graph", node_id=node_id, dep_node_id=dep_id) + mapping_row: Dict[str, Any] = { + "rpg_node_id": node_id, + "code_node_id": dep_id, + "dep_node_id": dep_id, + "status": mapping_status, + "source": "+".join(source_parts) or "selected_feature", + } + _set_if_present(mapping_row, "path", path) + _set_if_present(mapping_row, "reason", focus_reason) + if changed_for_node: + mapping_row["changed_files"] = changed_for_node + mapping_rows_all.append(mapping_row) + add_code_node(dep_id, source_feature=node_id, source=mapping_row["source"], fallback_path=path) + else: + mapping_row = {"rpg_node_id": node_id, "status": "missing"} + _set_if_present(mapping_row, "reason", focus_reason) + if changed_for_node: + mapping_row["changed_files"] = changed_for_node + mapping_rows_all.append(mapping_row) + + for dep_id, dep_node in current_dep_nodes.items(): + if dep_node.get("path") in changed_files: + add_code_node(dep_id, source="changed_file", fallback_path=dep_node.get("path")) + + selected_code_ids = set(code_node_by_id) + for edge in current_dep_edges: + if edge.get("source_node_id") in selected_code_ids or edge.get("target_node_id") in selected_code_ids: + add_edge(edge) + + primary_rpg_nodes = primary_rpg_nodes_all[:_FOCUSED_RPG_NODE_CAP] + primary_code_nodes_all = list(code_node_by_id.values()) + primary_code_nodes = primary_code_nodes_all[:_FOCUSED_CODE_NODE_CAP] + visible_rpg_ids = {row.get("node_id") for row in primary_rpg_nodes} + visible_code_ids = {row.get("node_id") for row in primary_code_nodes} + mappings = [ + row for row in mapping_rows_all + if row.get("rpg_node_id") in visible_rpg_ids and (not row.get("code_node_id") or row.get("code_node_id") in visible_code_ids) + ] + edges = all_edges[:_FOCUSED_EDGE_CAP] + hidden_counts: Dict[str, Any] = { + "primary_rpg_nodes": max(0, len(primary_rpg_nodes_all) - len(primary_rpg_nodes)), + "primary_code_nodes": max(0, len(primary_code_nodes_all) - len(primary_code_nodes)), + "edges": max(0, len(all_edges) - len(edges)), + } + for key, count in impact_hidden_counts.items(): + hidden_counts[key] = hidden_counts.get(key, 0) + count + relation_totals: Dict[str, int] = {} + relation_visible: Dict[str, int] = {} + for edge in all_edges: + relation = str(edge.get("relation") or "dependency") + relation_totals[relation] = relation_totals.get(relation, 0) + 1 + for edge in edges: + relation = str(edge.get("relation") or "dependency") + relation_visible[relation] = relation_visible.get(relation, 0) + 1 + hidden_relations = { + relation: total - relation_visible.get(relation, 0) + for relation, total in relation_totals.items() + if total > relation_visible.get(relation, 0) + } + if hidden_relations: + hidden_counts["relations"] = hidden_relations + capped_hidden = { + key: value for key, value in hidden_counts.items() + if key in {"primary_rpg_nodes", "primary_code_nodes", "edges"} and value + } + if capped_hidden: + add_warning("too_many_neighbors", "Focused view omitted rows because caps were reached", hidden_counts=capped_hidden) + + matched_files = {file_path for node in primary_rpg_nodes_all for file_path in node.get("changed_files") or []} unmatched_code_deltas = [delta for delta in code_deltas if _code_delta_file(delta) not in matched_files] + mapped_code_relations = sum(1 for row in mapping_rows_all if row.get("code_node_id")) + missing_mappings = sum(1 for row in mapping_rows_all if row.get("status") == "missing") summary = { - "selected_feature_groups": len(groups), - "mapped_code_relations": sum(len(group.get("code_relations") or []) for group in groups), - "missing_mappings": sum(1 for group in groups if not group.get("code_relations")), + "selected_feature_groups": len(primary_rpg_nodes_all), + "primary_rpg_nodes": len(primary_rpg_nodes), + "primary_code_nodes": len(primary_code_nodes), + "mapped_code_relations": mapped_code_relations, + "missing_mappings": missing_mappings, + "edges": len(edges), + "warnings": len(warnings), "changed_files": len(changed_files), "review_status": result.get("type", "review"), "apply_status": _apply_status(apply_result), "verification_status": _test_status(result, code_result, apply_result), } - payload: Dict[str, Any] = { + return { "summary": summary, - "groups": groups, + "primary_rpg_nodes": primary_rpg_nodes, + "primary_code_nodes": primary_code_nodes, + "mappings": mappings, + "edges": edges, + "hidden_counts": hidden_counts, + "warnings": warnings, + "changed_files": changed_files, "unmatched_code_deltas": unmatched_code_deltas, + "caps": { + "primary_rpg_nodes": _FOCUSED_RPG_NODE_CAP, + "primary_code_nodes": _FOCUSED_CODE_NODE_CAP, + "edges": _FOCUSED_EDGE_CAP, + }, + "apply": { + "status": _apply_status(apply_result), + "dep_graph_refreshed": apply_result.get("dep_graph_refreshed"), + }, + "review": { + "status": result.get("type", "review"), + "success": result.get("success", result.get("type") == "skipped"), + "iterations": len(result.get("iterations") or []), + "suggestions": len(result.get("suggestions") or []), + }, } - if focused_graph: - payload["graph"] = focused_graph - return payload def _review_summary_cards( result: Dict[str, Any], artifacts: Dict[str, Any], - focused_impact: Optional[Dict[str, Any]] = None, + focused_view: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: plan = artifacts.get("plan") if isinstance(artifacts.get("plan"), dict) else {} code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else {} apply_result = artifacts.get("apply_result") if isinstance(artifacts.get("apply_result"), dict) else {} - summary = focused_impact.get("summary") if isinstance(focused_impact, dict) else {} + summary = focused_view.get("summary") if isinstance(focused_view, dict) else {} result_value = "passed" if result.get("success", result.get("type") == "skipped") else "failed" changed_files = summary.get("changed_files", len(code_result.get("files_modified") or [])) return [ @@ -590,17 +955,14 @@ def _user_decision(result: Dict[str, Any], artifacts: Dict[str, Any]) -> UserDec class _ReportPayload: - def __init__(self, run: CommandRun, focused_graph: Dict[str, Any], focused_impact: Dict[str, Any]): + def __init__(self, run: CommandRun, focused_view: Dict[str, Any]): self.run = run - self.focused_graph = focused_graph - self.focused_impact = focused_impact + self.focused_view = focused_view def to_dict(self) -> Dict[str, Any]: data = self.run.to_dict() - if self.focused_graph: - data["focused_graph"] = self.focused_graph - if self.focused_impact: - data["focused_impact"] = self.focused_impact + if self.focused_view: + data["focused_view"] = self.focused_view return data @@ -609,20 +971,15 @@ 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) code_deltas = _code_delta_rows(artifacts) - focused_graph = _focused_graph_artifact(candidates, artifacts) - focused_impact = _feature_evidence_groups(artifacts, candidates, code_deltas, result, focused_graph) + focused_view = _feature_evidence_groups(artifacts, candidates, code_deltas, result) 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, "focused_impact": focused_impact} - if focused_graph: - evidence["focused_graph"] = focused_graph + evidence = {"artifacts": artifacts, "review_result": result, "focused_view": focused_view} try: report_run = CommandRun( command="rpg_edit", title="CoderMind rpg_edit Explain View", status=str(result.get("type", "review")), - summary=_review_summary_cards(result, artifacts, focused_impact), + summary=_review_summary_cards(result, artifacts, focused_view), steps=[ StepEvent(name=row.get("name", "stage"), status=row.get("status"), reason=row.get("reason", "")) for row in _review_timeline(result, artifacts) @@ -671,7 +1028,7 @@ def _publish_review_report(result: Dict[str, Any], plan_path: Path, impact_path: user_decisions=[_user_decision(result, artifacts)], evidence=evidence, ) - report_path = write_command_report(_ReportPayload(report_run, focused_graph, focused_impact)) + report_path = write_command_report(_ReportPayload(report_run, focused_view)) result["report_path"] = str(report_path) except Exception as exc: result["report_error"] = str(exc) diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index 943c1e1..a5f0008 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -93,10 +93,17 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) validate_path.write_text(json.dumps({"type": "ready"}), 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"]}]}), + json.dumps({ + "type": "candidates", + "query": "a.py", + "results": [ + {"node_id": "n1", "name": "Node", "score": 1.0, "dep_nodes": ["a.py:f"]}, + {"node_id": "n2", "name": "Missing Node", "score": 0.5}, + ], + }), encoding="utf-8", ) - plan_path.write_text(json.dumps({"affected_nodes": ["n1"], "code_changes": [{"file_path": "a.py"}]}), encoding="utf-8") + plan_path.write_text(json.dumps({"affected_nodes": ["n1", "n2"], "code_changes": [{"file_path": "a.py"}]}), encoding="utf-8") impact_path.write_text( json.dumps({ "type": "impact", @@ -106,6 +113,11 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) "dep_nodes": ["a.py:f"], "affected_files": ["a.py"], "impact_summary": {"total_callers": 1, "affected_file_count": 1}, + }, + "n2": { + "name": "Missing Node", + "affected_files": [], + "impact_summary": {"total_callers": 0, "affected_file_count": 0}, } }, }), @@ -136,7 +148,15 @@ def test_review_publish_report_returns_report_path(tmp_path: Path, monkeypatch) rpg_path.write_text( json.dumps({ "repo_name": "test", - "root": {"id": "n1", "name": "Node", "node_type": "feature", "meta": {"path": "a.py"}, "children": []}, + "root": { + "id": "n1", + "name": "Node", + "node_type": "feature", + "meta": {"path": "a.py"}, + "children": [ + {"id": "n2", "name": "Missing Node", "node_type": "feature", "meta": {"path": "b.py"}, "children": []} + ], + }, "edges": [], "dep_graph": { "nodes": {"a.py:f": {"name": "f", "type": "function", "module": "a.py", "rpg_nodes": ["n1"]}}, @@ -198,31 +218,26 @@ def fake_write_command_report(run): 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 data["focused_impact"]["summary"]["selected_feature_groups"] == 1 - assert data["focused_impact"]["summary"]["mapped_code_relations"] == 1 - assert data["focused_impact"]["summary"]["missing_mappings"] == 0 - focused_group = data["focused_impact"]["groups"][0] - assert focused_group["node_id"] == "n1" - assert focused_group["status"] == "mapped" - assert focused_group["code_relations"] == [ - { - "node_id": "a.py:f", - "dep_node_id": "a.py:f", - "source_feature": "n1", - "path": "a.py", - "relation": "feature_to_dep", - "source": "locate+impact", - "status": "mapped", - } - ] - assert focused_group["affected_files"] == ["a.py"] - assert focused_group["changed_files"] == ["a.py"] - assert focused_group["hidden_counts"]["callers"] == 1 - assert any(item["label"] == "focused_graph" for item in data["artifacts"]) + assert "focused_graph" not in data + assert "focused_impact" not in data + assert not any(item["label"] == "focused_graph" for item in data["artifacts"]) + assert not list(tmp_path.glob("rpg_edit_focused_graph_*.html")) + focused = data["focused_view"] + assert focused["summary"]["selected_feature_groups"] == 2 + assert focused["summary"]["mapped_code_relations"] == 1 + assert focused["summary"]["missing_mappings"] == 1 + assert focused["primary_rpg_nodes"][0]["node_id"] == "n1" + assert focused["primary_rpg_nodes"][0]["mapping_status"] == "mapped" + assert focused["primary_rpg_nodes"][0]["changed_files"] == ["a.py"] + assert focused["primary_rpg_nodes"][0]["hidden_counts"]["callers"] == 1 + assert focused["primary_code_nodes"][0]["node_id"] == "a.py:f" + assert focused["primary_code_nodes"][0]["path"] == "a.py" + mapping_statuses = {row["status"] for row in focused["mappings"]} + assert {"mapped", "missing"}.issubset(mapping_statuses) + mapped = next(row for row in focused["mappings"] if row.get("code_node_id") == "a.py:f") + assert mapped["rpg_node_id"] == "n1" + assert mapped["changed_files"] == ["a.py"] + assert any(row["type"] == "missing_mapping" and row["node_id"] == "n2" for row in focused["warnings"]) return report_path monkeypatch.setattr(review, "write_command_report", fake_write_command_report) @@ -245,6 +260,7 @@ def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: apply_path = tmp_path / "apply.json" review_path = tmp_path / "review.json" report_path = tmp_path / "report.html" + rpg_path = tmp_path / "rpg.json" dep_id = "scripts/common/run_report.py:_render_artifacts" validate_path.write_text(json.dumps({"type": "ready"}), encoding="utf-8") @@ -267,6 +283,19 @@ def test_review_report_reconstructs_affected_node_evidence_from_impact(tmp_path: }), encoding="utf-8", ) + rpg_path.write_text( + json.dumps({ + "repo_name": "test", + "root": {"id": "planned", "name": "Planned Node", "node_type": "feature", "meta": {"path": "scripts/common/run_report.py"}, "children": []}, + "edges": [], + "dep_graph": { + "nodes": {dep_id: {"name": "_render_artifacts", "type": "function", "module": "scripts/common/run_report.py", "rpg_nodes": ["planned"]}}, + "edges": [], + }, + "_dep_to_rpg_map": {dep_id: ["planned"]}, + }), + encoding="utf-8", + ) monkeypatch.setattr(review, "RPG_EDIT_VALIDATE_FILE", validate_path) monkeypatch.setattr(review, "RPG_EDIT_LOCATE_FILE", locate_path) @@ -274,7 +303,7 @@ 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_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, "REPO_RPG_FILE", rpg_path) monkeypatch.setattr( review, "read_head", @@ -307,17 +336,20 @@ def fake_write_command_report(run): assert "1 mapped code relations" 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) - focused = data["focused_impact"] + assert "focused_graph" not in data + assert not any(item["label"] == "focused_graph" for item in data["artifacts"]) + focused = data["focused_view"] assert focused["summary"]["selected_feature_groups"] == 1 assert focused["summary"]["mapped_code_relations"] == 1 assert focused["summary"]["missing_mappings"] == 0 - group = focused["groups"][0] - assert group["node_id"] == "planned" - assert group["missing_states"] == {"locate": "missing"} - assert group["code_relations"][0]["dep_node_id"] == dep_id - assert group["code_relations"][0]["source"] == "impact" - assert group["changed_files"] == ["scripts/common/run_report.py"] - assert group["apply"]["status"] == "dep_refreshed" + primary = focused["primary_rpg_nodes"][0] + assert primary["node_id"] == "planned" + assert primary["locate_status"] == "missing" + assert primary["changed_files"] == ["scripts/common/run_report.py"] + mapping = focused["mappings"][0] + assert mapping["code_node_id"] == dep_id + assert "impact" in mapping["source"] + assert focused["apply"]["status"] == "dep_refreshed" 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 fb06d8d..c2fe4e3 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -62,7 +62,7 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path "Stage timeline", "Safety boundary", "Why these nodes?", - "Focused impact summary", + "Focused impact view", "What changed?", "Verification status", "Artifact links", @@ -95,9 +95,7 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path assert "backup/" for i in range(40) ) @@ -116,43 +114,76 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_graph(t "code_deltas": [ CodeDeltaEvent(file="a.py", change_type="modify", diff=long_diff).to_dict() ], - "focused_graph": { - "path": graph, - "status": "available", - "selected_rpg_nodes": ["n1"], - "selected_dep_nodes": ["a.py:f"], - "rpg_node_count": 2, - "dep_node_count": 1, - }, - "focused_impact": { + "focused_view": { "summary": { - "selected_feature_groups": 2, + "primary_rpg_nodes": 2, + "primary_code_nodes": 1, "mapped_code_relations": 1, "missing_mappings": 1, + "edges": 2, + "warnings": 4, }, - "groups": [ + "primary_rpg_nodes": [ { "node_id": "n1" not in html + assert "Node " not in html + assert "a.py" not in html assert "Node " not in html assert "a.py" + inspector_payload = {"focused_graph": graph_payload, "nodes_view": nodes_view} + inspector = json.dumps(inspector_payload, indent=2, ensure_ascii=False, default=_json_default) + controls = ( + '
    ' + '' + '' + '' + '' + '' + '
    ' + ) + legend = ( + '
    ' + 'Semantic' + 'Code' + 'Mapping' + 'Context edge' + '
    ' + ) + return ( + '

    Focused graph

    ' + f"{summary_html}{controls}{legend}{warnings_html}{hidden_html}" + f"" + '
    ' + '' + f'
    Static focused graph fallback is available when D3 cannot run.{d3_missing_note}
    ' + '
    ' + f"{_focused_graph_evidence(nodes_view, file_anchors)}" + f"
    Inspector JSON
    {_h(inspector)}
    " + f"{scripts}" + '
    ' + ) + + +def _legacy_render_focused_nodes_map(focused_view: dict[str, Any], file_anchors: Mapping[str, str]) -> str: nodes_view = focused_view.get("nodes_view") if isinstance(focused_view.get("nodes_view"), Mapping) else {} if not nodes_view: return "" @@ -1046,7 +1372,7 @@ def _render_legacy_chain_rows( return "".join(blocks) -def _render_semantic_code_impact_chain( +def _legacy_render_semantic_code_impact_chain( retrievals: list[dict[str, Any]], rpg_nodes: list[dict[str, Any]], dep_nodes: list[dict[str, Any]], @@ -1149,7 +1475,7 @@ def _compact_payload(value: Any, *, depth: int = 0) -> Any: compacted: dict[str, Any] = {} for key, item in value.items(): key_text = str(key) - if key_text in {"code_deltas", "focused_view", "focused_impact", "focused_graph", "evidence"}: + if key_text in {"code_deltas", "focused_view", "nodes_view", "focused_impact", "focused_graph", "evidence"}: continue if key_text == "diff": compacted["has_diff"] = bool(item) diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 2da1859..e6fa182 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -432,6 +432,196 @@ def _warning_link_fields(warning: Dict[str, Any], rpg_nodes: Dict[str, Dict[str, return row +def _hierarchy_segments(value: Any) -> List[str]: + if isinstance(value, (list, tuple)): + return [str(item) for item in value if item not in (None, "")] + if value in (None, ""): + return [] + text = str(value) + separator = " / " if " / " in text else "/" + return [part.strip() for part in text.split(separator) if part.strip()] + + +def _hierarchy_child(parent: Dict[str, Any], child_id: str, name: str, kind: str) -> Dict[str, Any]: + children = parent.setdefault("children", []) + for child in children: + if isinstance(child, dict) and child.get("id") == child_id: + return child + child = {"id": child_id, "name": name, "kind": kind, "children": []} + children.append(child) + return child + + +def _append_hierarchy_leaf(parent: Dict[str, Any], leaf: Dict[str, Any]) -> None: + children = parent.setdefault("children", []) + leaf_id = leaf.get("id") + if any(isinstance(child, dict) and child.get("id") == leaf_id for child in children): + return + children.append(leaf) + + +def _add_hierarchy_path(root: Dict[str, Any], parts: List[str], leaf: Dict[str, Any], group_kind: str) -> None: + parent = root + trail: List[str] = [] + for part in parts: + trail.append(part) + parent = _hierarchy_child(parent, _node_link_id(group_kind, "/".join(trail)), part, group_kind) + _append_hierarchy_leaf(parent, leaf) + + +def _semantic_hierarchy_parts(node: Dict[str, Any]) -> List[str]: + for key in ("breadcrumb", "breadcrumb_path", "feature_path", "path"): + parts = _hierarchy_segments(node.get(key)) + if parts: + return parts[:-1] if len(parts) > 1 else [] + return [] + + +def _code_hierarchy_parts(node: Dict[str, Any]) -> List[str]: + for key in ("path", "module", "file"): + parts = _hierarchy_segments(node.get(key)) + if parts: + return parts[:-1] + return [] + + +def _focused_graph_hierarchy( + semantic_nodes: List[Dict[str, Any]], + code_nodes: List[Dict[str, Any]], + mappings: List[Dict[str, Any]], + edges: List[Dict[str, Any]], + hidden_counts: Dict[str, Any], + warnings: List[Dict[str, Any]], +) -> Dict[str, Any]: + root: Dict[str, Any] = { + "id": "focused-graph-root", + "name": "Focused graph", + "kind": "root", + "meta": {"hidden_counts": hidden_counts, "warnings": len(warnings), "edges": len(edges)}, + "children": [], + } + semantic_root = _hierarchy_child(root, "focused-semantic-root", "Semantic scope", "semantic_group") + code_root = _hierarchy_child(root, "focused-code-root", "Code scope", "code_group") + mapping_root = _hierarchy_child(root, "focused-mapping-root", "Mappings", "mapping_group") + context_root = _hierarchy_child(root, "focused-context-root", "One-hop context", "context_group") + + known_link_ids: set[str] = set() + for node in semantic_nodes: + link_id = str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) + known_link_ids.add(link_id) + leaf = { + "id": link_id, + "node_id": node.get("node_id"), + "name": node.get("name") or node.get("symbol") or node.get("node_id") or "semantic node", + "kind": "semantic", + "state": node.get("state"), + "mapping_status": node.get("mapping_status"), + } + _add_hierarchy_path(semantic_root, _semantic_hierarchy_parts(node), leaf, "semantic-path") + + for node in code_nodes: + code_id = node.get("node_id") or node.get("dep_node_id") + link_id = str(node.get("link_id") or _node_link_id("code", code_id)) + known_link_ids.add(link_id) + leaf = { + "id": link_id, + "node_id": code_id, + "name": node.get("symbol") or node.get("name") or code_id or "code node", + "kind": "code", + "state": node.get("state"), + "path": node.get("path"), + } + _add_hierarchy_path(code_root, _code_hierarchy_parts(node), leaf, "code-path") + + for mapping in mappings: + link_id = str(mapping.get("link_id") or _node_link_id("map", f"{mapping.get('rpg_node_id')}-{mapping.get('code_node_id') or 'missing'}")) + known_link_ids.add(link_id) + target = mapping.get("code_node_id") or mapping.get("dep_node_id") or "missing mapping" + _append_hierarchy_leaf(mapping_root, { + "id": link_id, + "name": f"{mapping.get('rpg_node_id')} → {target}", + "kind": "mapping", + "state": mapping.get("state") or mapping.get("status"), + "rpg_node_id": mapping.get("rpg_node_id"), + "code_node_id": mapping.get("code_node_id") or mapping.get("dep_node_id"), + }) + + context_link_ids: set[str] = set() + for edge in edges: + for endpoint_key, link_key in (("source_node_id", "source_link_id"), ("target_node_id", "target_link_id")): + link_id = edge.get(link_key) + node_id = edge.get(endpoint_key) + if link_id in (None, "") or str(link_id) in known_link_ids or str(link_id) in context_link_ids: + continue + context_link_ids.add(str(link_id)) + _append_hierarchy_leaf(context_root, { + "id": str(link_id), + "node_id": node_id, + "name": node_id or "context node", + "kind": "context", + }) + return root + + +def _focused_graph_default_focus( + semantic_nodes: List[Dict[str, Any]], + code_nodes: List[Dict[str, Any]], + mappings: List[Dict[str, Any]], + edges: List[Dict[str, Any]], + warnings: List[Dict[str, Any]], +) -> Dict[str, Any]: + semantic_links = [str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) for node in semantic_nodes] + code_links = [str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) for node in code_nodes] + changed_code_links = [ + str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) + for node in code_nodes + if node.get("changed") or node.get("changed_files") or node.get("diff_anchor") + ] + warning_links = [] + for warning in warnings: + warning_links.extend(_listify(warning.get("node_link_id")) + _listify(warning.get("code_link_id"))) + node_link_ids = _ordered_unique(semantic_links + changed_code_links + warning_links) + if not node_link_ids: + node_link_ids = _ordered_unique(semantic_links + code_links) + return { + "node_link_ids": node_link_ids, + "semantic_node_ids": _ordered_unique([node.get("node_id") for node in semantic_nodes]), + "code_node_ids": _ordered_unique([node.get("node_id") or node.get("dep_node_id") for node in code_nodes]), + "mapping_link_ids": _ordered_unique([mapping.get("link_id") for mapping in mappings]), + "edge_link_ids": _ordered_unique([edge.get("link_id") for edge in edges]), + "edge_depth": 1, + "show_edges": True, + } + + +def _focused_graph_metadata( + semantic_nodes: List[Dict[str, Any]], + code_nodes: List[Dict[str, Any]], + mappings: List[Dict[str, Any]], + edges: List[Dict[str, Any]], + hidden_counts: Dict[str, Any], + warnings: List[Dict[str, Any]], + caps: Optional[Dict[str, Any]], + graph_context: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + hierarchy = _focused_graph_hierarchy(semantic_nodes, code_nodes, mappings, edges, hidden_counts, warnings) + default_focus = _focused_graph_default_focus(semantic_nodes, code_nodes, mappings, edges, warnings) + focused_graph: Dict[str, Any] = { + "schema": "cmind.focused_graph.v1", + "hierarchy": hierarchy, + "default_focus": default_focus, + } + if caps: + focused_graph["caps"] = caps + if graph_context: + focused_graph["graph_context"] = graph_context + if hidden_counts: + focused_graph["hidden_counts"] = hidden_counts + if warnings: + focused_graph["warning_count"] = len(warnings) + return focused_graph + + def _build_nodes_view( primary_rpg_nodes: List[Dict[str, Any]], primary_code_nodes: List[Dict[str, Any]], @@ -441,6 +631,9 @@ def _build_nodes_view( warnings: List[Dict[str, Any]], changed_files: List[str], diff_anchors: Dict[str, str], + *, + caps: Optional[Dict[str, Any]] = None, + graph_context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: rpg_by_id = {str(row.get("node_id")): row for row in primary_rpg_nodes if row.get("node_id") not in (None, "")} code_by_id = {str(row.get("node_id") or row.get("dep_node_id")): row for row in primary_code_nodes if (row.get("node_id") or row.get("dep_node_id")) not in (None, "")} @@ -543,12 +736,14 @@ def _build_nodes_view( for edge in edges: source_id = edge.get("source_node_id") target_id = edge.get("target_node_id") + relation = edge.get("relation") or "dependency" row: Dict[str, Any] = { + "link_id": _node_link_id("edge", f"{source_id}-{relation}-{target_id}"), "source_node_id": source_id, "target_node_id": target_id, "source_link_id": _edge_endpoint_link_id(source_id, rpg_by_id, code_by_id), "target_link_id": _edge_endpoint_link_id(target_id, rpg_by_id, code_by_id), - "relation": edge.get("relation") or "dependency", + "relation": relation, "state": edge.get("status") or "visible", } for key in ("direction", "source", "path", "reason", "rpg_node_id", "neighbor_node_id", "name"): @@ -556,6 +751,16 @@ def _build_nodes_view( edge_rows.append(row) warning_rows = [_warning_link_fields(warning, rpg_by_id, code_by_id) for warning in warnings if isinstance(warning, dict)] + focused_graph = _focused_graph_metadata( + semantic_nodes, + code_nodes, + mapping_rows, + edge_rows, + hidden_counts, + warning_rows, + caps or {}, + graph_context or {}, + ) return { "summary": { "semantic_nodes": len(semantic_nodes), @@ -572,6 +777,11 @@ def _build_nodes_view( "hidden_counts": hidden_counts, "warnings": warning_rows, "changed_files": _changed_file_refs(changed_files, diff_anchors), + "hierarchy": focused_graph["hierarchy"], + "default_focus": focused_graph["default_focus"], + "focused_graph": focused_graph, + "caps": caps or {}, + "graph_context": graph_context or {}, } @@ -1097,7 +1307,29 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: mapped_code_relations = sum(1 for row in mapping_rows_all if row.get("code_node_id")) missing_mappings = sum(1 for row in mapping_rows_all if row.get("status") == "missing") diff_anchors = _diff_anchor_map(code_deltas) - nodes_view = _build_nodes_view(primary_rpg_nodes, primary_code_nodes, mappings, edges, hidden_counts, warnings, changed_files, diff_anchors) + focused_caps = { + "primary_rpg_nodes": _FOCUSED_RPG_NODE_CAP, + "primary_code_nodes": _FOCUSED_CODE_NODE_CAP, + "edges": _FOCUSED_EDGE_CAP, + } + graph_context = { + "current_graph_available": current_graph_available, + "current_rpg_nodes": len(current_rpg_nodes), + "current_dep_nodes": len(current_dep_nodes), + "current_dep_edges": len(current_dep_edges), + } + nodes_view = _build_nodes_view( + primary_rpg_nodes, + primary_code_nodes, + mappings, + edges, + hidden_counts, + warnings, + changed_files, + diff_anchors, + caps=focused_caps, + graph_context=graph_context, + ) summary = { "selected_feature_groups": len(primary_rpg_nodes_all), "primary_rpg_nodes": len(primary_rpg_nodes), @@ -1126,11 +1358,7 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: "warnings": warnings, "changed_files": changed_files, "unmatched_code_deltas": unmatched_code_deltas, - "caps": { - "primary_rpg_nodes": _FOCUSED_RPG_NODE_CAP, - "primary_code_nodes": _FOCUSED_CODE_NODE_CAP, - "edges": _FOCUSED_EDGE_CAP, - }, + "caps": focused_caps, "apply": { "status": _apply_status(apply_result), "dep_graph_refreshed": apply_result.get("dep_graph_refreshed"), diff --git a/CoderMind/tests/test_rpg_edit_run_report.py b/CoderMind/tests/test_rpg_edit_run_report.py index 82018d9..020587b 100644 --- a/CoderMind/tests/test_rpg_edit_run_report.py +++ b/CoderMind/tests/test_rpg_edit_run_report.py @@ -279,7 +279,10 @@ def fake_write_command_report(run): assert "review_result" not in evidence assert "focused_view" not in evidence assert "nodes_view" not in evidence_text + assert "default_focus" not in evidence_text + assert "hierarchy" not in evidence_text assert "focused_graph" not in evidence + assert "focused_graph" not in evidence_text assert "focused_impact" not in evidence assert "+new " not in evidence_text evidence_paths = {item["label"]: item["path"] for item in evidence["artifact_paths"]} @@ -346,6 +349,18 @@ def fake_write_command_report(run): assert bridge_by_rpg["n2"]["state"] == "missing_mapping" assert nodes_view["hidden_counts"]["callers"] == 1 assert any(row["type"] == "missing_mapping" and row["node_id"] == "n2" for row in nodes_view["warnings"]) + assert nodes_view["hierarchy"]["id"] == "focused-graph-root" + assert nodes_view["default_focus"]["node_link_ids"] + assert "rpg-n1" in nodes_view["default_focus"]["node_link_ids"] + assert nodes_view["default_focus"]["edge_depth"] == 1 + assert nodes_view["default_focus"]["show_edges"] is True + assert nodes_view["focused_graph"]["schema"] == "cmind.focused_graph.v1" + assert nodes_view["focused_graph"]["hierarchy"]["id"] == "focused-graph-root" + assert nodes_view["focused_graph"]["default_focus"] == nodes_view["default_focus"] + assert nodes_view["caps"] == {"primary_rpg_nodes": 20, "primary_code_nodes": 50, "edges": 80} + assert nodes_view["graph_context"]["current_graph_available"] is True + assert nodes_view["graph_context"]["current_rpg_nodes"] == 2 + assert nodes_view["graph_context"]["current_dep_nodes"] == 1 assert "+new " not in json.dumps(nodes_view, ensure_ascii=False) return report_path @@ -477,7 +492,10 @@ def fake_write_command_report(run): assert "review_result" not in evidence assert "focused_view" not in evidence assert "nodes_view" not in evidence_text + assert "default_focus" not in evidence_text + assert "hierarchy" not in evidence_text assert "focused_graph" not in evidence + assert "focused_graph" not in evidence_text assert "focused_impact" not in evidence assert "failing test" not in evidence_text evidence_paths = {item["label"]: item["path"] for item in evidence["artifact_paths"]} @@ -530,6 +548,15 @@ def fake_write_command_report(run): assert bridge["status"] == "mapped" assert bridge["changed_files"] == [{"path": "scripts/common/run_report.py", "diff_anchor": "diff-scripts_common_run_report.py"}] assert any(row["type"] == "missing_reason" and row["node_id"] == "planned" for row in nodes_view["warnings"]) + assert nodes_view["hierarchy"]["id"] == "focused-graph-root" + assert nodes_view["default_focus"]["node_link_ids"] == ["rpg-planned", "code-scripts-common-run_report.py-_render_artifacts"] + assert nodes_view["default_focus"]["edge_depth"] == 1 + assert nodes_view["focused_graph"]["schema"] == "cmind.focused_graph.v1" + assert nodes_view["focused_graph"]["default_focus"] == nodes_view["default_focus"] + assert nodes_view["caps"] == {"primary_rpg_nodes": 20, "primary_code_nodes": 50, "edges": 80} + assert nodes_view["graph_context"]["current_graph_available"] is True + assert nodes_view["graph_context"]["current_rpg_nodes"] == 1 + assert nodes_view["graph_context"]["current_dep_nodes"] == 1 assert focused["apply"]["status"] == "dep_refreshed" return report_path diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index d5e4da4..820034d 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -24,8 +24,10 @@ def test_run_report_exposes_current_impact_renderers_only() -> None: - assert hasattr(run_report, "_render_semantic_code_impact_chain") - assert hasattr(run_report, "_render_focused_nodes_map") + assert hasattr(run_report, "_render_focused_graph") + assert hasattr(run_report, "_inline_d3") + assert not hasattr(run_report, "_render_semantic_code_impact_chain") + assert not hasattr(run_report, "_render_focused_nodes_map") assert not hasattr(run_report, "_render_why_these_nodes") assert not hasattr(run_report, "_render_node_rows") assert not hasattr(run_report, "_render_focused_impact") @@ -71,8 +73,7 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path "Summary", "Stage timeline", "Safety boundary", - "Focused nodes map", - "semantic-code impact chain", + "Focused graph", "What changed?", "Verification status", "Artifact links", @@ -229,6 +230,25 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm {"type": "stale_graph", "message": "Stale ", "dep_node_id": "old.py:f"}, ], "changed_files": [{"path": "a.py", "diff_anchor": "diff-a.py"}], + "hierarchy": { + "id": "focused-graph-root", + "name": "Focused graph", + "kind": "root", + "children": [ + {"id": "rpg-n1-script", "name": "Node ", "kind": "semantic"}, + {"id": "code-a.py-f-script", "name": "func ", "kind": "code"}, + ], + }, + "default_focus": { + "node_link_ids": ["rpg-n1-script", "code-a.py-f-script"], + "edge_depth": 1, + "show_edges": True, + }, + "focused_graph": { + "schema": "cmind.focused_graph.v1", + "hierarchy": {"id": "focused-graph-root"}, + "default_focus": {"node_link_ids": ["rpg-n1-script"]}, + }, }, "primary_rpg_nodes": [ { @@ -298,17 +318,37 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm ) html = report.read_text(encoding="utf-8") - assert "Focused nodes map" in html - assert "semantic-code impact chain" in html - assert html.count("

    Focused nodes map

    ") == 1 - assert html.count("

    semantic-code impact chain

    ") == 1 + assert "Focused graph" in html + assert "Focused nodes map" not in html + assert "semantic-code impact chain" not in html + assert html.count("

    Focused graph

    ") == 1 assert "Why these nodes?" not in html assert "Focused impact view" not in html assert "What changed?" in html - assert "Feature group" in html - assert "Semantic → code evidence" in html + assert "Focused graph evidence" in html assert "focus-map" in html assert "One-hop context" in html + assert "data-focused-graph-json" in html + assert "focused-graph-svg" in html + assert "https://d3js.org v7.9.0" in html + assert " +RPG: {repo_name_html} + @@ -845,12 +852,42 @@ def _json_for_script(value: Any) -> str: ) +def _mapped_code_label(row: Mapping[str, Any]) -> str: + path = row.get("mapped_code_path") + symbol = row.get("mapped_code_symbol") + first = next((item for item in _as_sequence(row.get("mapped_code")) if isinstance(item, Mapping)), None) + if first: + path = path or first.get("path") + symbol = symbol or first.get("symbol") or first.get("name") + if not path: + paths = _as_sequence(row.get("mapped_code_paths")) + path = paths[0] if paths else None + if not symbol: + symbols = _as_sequence(row.get("mapped_code_symbols")) + symbol = symbols[0] if symbols else None + detail = " · ".join(str(item) for item in (path, symbol) if item not in (None, "")) + if not detail: + return "" + count = row.get("mapped_code_count") + try: + count_int = int(count) + except (TypeError, ValueError): + count_int = 0 + suffix = f" +{count_int - 1}" if count_int > 1 else "" + return f"{detail}{suffix}" + + def _graph_label(row: Mapping[str, Any], fallback: Any) -> str: - for key in ("name", "symbol", "label", "path", "node_id", "dep_node_id", "id"): + base = "" + for key in ("feature_name", "name", "symbol", "label", "path", "node_id", "dep_node_id", "id"): value = row.get(key) if value not in (None, ""): - return str(value) - return str(fallback or "node") + base = str(value) + break + if not base: + base = str(fallback or "node") + mapped_code = _mapped_code_label(row) + return f"{base} — {mapped_code}" if mapped_code else base def _graph_diff_ref(row: Mapping[str, Any], file_anchors: Mapping[str, str]) -> dict[str, str]: @@ -893,7 +930,23 @@ def _append_graph_node(nodes: list[dict[str, Any]], seen: set[str], row: Mapping "node_id": row.get("node_id") or row.get("dep_node_id"), "state": row.get("state") or row.get("status") or row.get("mapping_status"), } - for key in ("path", "type", "node_type", "source", "mapping_status", "locate_status", "breadcrumb_path"): + for key in ( + "path", + "feature_path", + "feature_name", + "type", + "node_type", + "source", + "mapping_status", + "locate_status", + "breadcrumb_path", + "mapped_code", + "mapped_code_path", + "mapped_code_paths", + "mapped_code_symbol", + "mapped_code_symbols", + "mapped_code_count", + ): if row.get(key) not in (None, ""): payload[key] = row.get(key) diff_ref = _graph_diff_ref(row, file_anchors) @@ -928,18 +981,33 @@ def visit(row: Any) -> str: if not isinstance(row, Mapping): return "" row_id = node_id(row) - children = [child for child in _as_sequence(row.get("children")) if isinstance(child, Mapping)] - if row_id and children and row_id not in seen_nodes: + if row_id and row_id not in seen_nodes: seen_nodes.add(row_id) - nodes.append({ + payload = { "id": row_id, - "kind": "hierarchy", - "label": str(row.get("name") or row.get("node_id") or row_id), + "kind": row.get("kind") or "feature", + "label": _graph_label(row, row.get("node_id") or row_id), "node_id": row.get("node_id") or row_id, "state": row.get("state") or row.get("kind") or "hierarchy", "type": row.get("kind") or "hierarchy", - }) - for child in children: + } + for key in ( + "feature_name", + "feature_path", + "path", + "node_type", + "mapping_status", + "mapped_code", + "mapped_code_path", + "mapped_code_paths", + "mapped_code_symbol", + "mapped_code_symbols", + "mapped_code_count", + ): + if row.get(key) not in (None, ""): + payload[key] = row.get(key) + nodes.append(payload) + for child in [child for child in _as_sequence(row.get("children")) if isinstance(child, Mapping)]: child_id = visit(child) if row_id in seen_nodes and child_id in seen_nodes: link_id = f"hierarchy-{_slug(row_id)}-{_slug(child_id)}" @@ -960,63 +1028,151 @@ def _focused_graph_payload(focused_view: Mapping[str, Any], file_anchors: Mappin context_edges = [edge for edge in _as_sequence(nodes_view.get("edges")) if isinstance(edge, Mapping)] hidden_counts = nodes_view.get("hidden_counts") if isinstance(nodes_view.get("hidden_counts"), Mapping) else focused_view.get("hidden_counts", {}) warnings = [warning for warning in _as_sequence(nodes_view.get("warnings")) if isinstance(warning, Mapping)] + focused_graph = nodes_view.get("focused_graph") if isinstance(nodes_view.get("focused_graph"), Mapping) else {} + hierarchy = nodes_view.get("hierarchy") or focused_graph.get("hierarchy") or {} + default_focus = nodes_view.get("default_focus") or focused_graph.get("default_focus") or {} + + if not isinstance(hierarchy, Mapping) or not hierarchy or (semantic_nodes and not _as_sequence(hierarchy.get("children"))): + hierarchy = { + "id": "focused-graph-root", + "name": "Focused graph", + "kind": "root", + "children": [ + { + "id": _graph_node_id(node, "feature"), + "node_id": node.get("node_id"), + "name": node.get("name") or node.get("symbol") or node.get("node_id") or "feature", + "kind": "feature", + "feature_name": node.get("name") or node.get("symbol") or node.get("node_id") or "feature", + "feature_path": node.get("feature_path") or node.get("breadcrumb_path") or node.get("path"), + "mapped_code": node.get("mapped_code"), + "mapped_code_node_ids": node.get("mapped_code_node_ids"), + "mapped_code_link_ids": node.get("mapped_code_link_ids"), + "mapped_code_path": node.get("mapped_code_path"), + "mapped_code_paths": node.get("mapped_code_paths"), + "mapped_code_symbol": node.get("mapped_code_symbol"), + "mapped_code_symbols": node.get("mapped_code_symbols"), + "mapped_code_count": node.get("mapped_code_count"), + } + for node in semantic_nodes + ], + } + nodes: list[dict[str, Any]] = [] + links: list[dict[str, Any]] = [] seen_nodes: set[str] = set() + _append_hierarchy_nodes(nodes, links, seen_nodes, hierarchy) + rpg_links: dict[str, str] = {} code_links: dict[str, str] = {} + node_aliases: dict[str, list[str]] = {} + + def add_alias(alias: Any, target: Any) -> None: + if alias in (None, "") or target in (None, ""): + return + alias_text = str(alias) + target_text = str(target) + values = node_aliases.setdefault(alias_text, []) + if target_text not in values: + values.append(target_text) + + def visit_hierarchy(row: Any) -> None: + if not isinstance(row, Mapping): + return + row_id = str(row.get("id") or row.get("link_id") or row.get("node_id") or "") + node_id = row.get("node_id") + if node_id not in (None, "") and row_id: + rpg_links.setdefault(str(node_id), row_id) + for code_id in _as_sequence(row.get("mapped_code_node_ids")): + add_alias(_graph_node_id({"node_id": code_id}, "code"), row_id) + add_alias(code_id, row_id) + for code_link in _as_sequence(row.get("mapped_code_link_ids")): + add_alias(code_link, row_id) + for code_ref in _as_sequence(row.get("mapped_code")): + if isinstance(code_ref, Mapping): + add_alias(code_ref.get("link_id"), row_id) + add_alias(code_ref.get("node_id") or code_ref.get("dep_node_id"), row_id) + for child in _as_sequence(row.get("children")): + visit_hierarchy(child) + + visit_hierarchy(hierarchy) for node in semantic_nodes: - link_id = _append_graph_node(nodes, seen_nodes, node, "semantic", file_anchors) + link_id = str(node.get("link_id") or _graph_node_id(node, "feature")) node_id = node.get("node_id") if node_id not in (None, ""): - rpg_links[str(node_id)] = link_id + rpg_links.setdefault(str(node_id), link_id) for node in code_nodes: - link_id = _append_graph_node(nodes, seen_nodes, node, "code", file_anchors) + link_id = str(node.get("link_id") or _graph_node_id(node, "code")) node_id = node.get("node_id") or node.get("dep_node_id") if node_id not in (None, ""): code_links[str(node_id)] = link_id + for rpg_link in _as_sequence(node.get("mapped_rpg_link_ids")): + add_alias(link_id, rpg_link) + add_alias(node_id, rpg_link) - links: list[dict[str, Any]] = [] for mapping in mappings: - mapping_id = _append_graph_node(nodes, seen_nodes, mapping, "mapping", file_anchors) - rpg_id = mapping.get("rpg_node_id") or mapping.get("node_id") - code_id = mapping.get("code_node_id") or mapping.get("dep_node_id") - source = mapping.get("source_link_id") or rpg_links.get(str(rpg_id or "")) - target = mapping.get("target_link_id") or code_links.get(str(code_id or "")) - if source: - links.append({"id": f"{mapping_id}-semantic", "source": source, "target": mapping_id, "kind": "mapping", "relation": mapping.get("status") or "mapped"}) - if target: - links.append({"id": f"{mapping_id}-code", "source": mapping_id, "target": target, "kind": "mapping", "relation": mapping.get("source") or "code"}) - - for edge in context_edges: - source = edge.get("source_link_id") - target = edge.get("target_link_id") - if not source: - source = _append_context_node(nodes, seen_nodes, None, edge.get("source_node_id")) - elif str(source) not in seen_nodes: - _append_context_node(nodes, seen_nodes, source, edge.get("source_node_id")) - if not target: - target = _append_context_node(nodes, seen_nodes, None, edge.get("target_node_id")) - elif str(target) not in seen_nodes: - _append_context_node(nodes, seen_nodes, target, edge.get("target_node_id")) - links.append({ - "id": edge.get("link_id") or f"edge-{len(links) + 1}", - "source": str(source), - "target": str(target), - "kind": "context", + source = mapping.get("source_link_id") or rpg_links.get(str(mapping.get("rpg_node_id") or mapping.get("node_id") or "")) + target = mapping.get("target_link_id") or code_links.get(str(mapping.get("code_node_id") or mapping.get("dep_node_id") or "")) + add_alias(target, source) + add_alias(mapping.get("code_node_id") or mapping.get("dep_node_id"), source) + + def edge_kind(edge: Mapping[str, Any]) -> str: + source_parts = " ".join( + str(edge.get(key) or "") + for key in ("source_graph", "edge_source", "relation_source", "source") + ).lower() + relation = str(edge.get("relation") or "").lower() + if "rpg" in source_parts or relation in {"semantic", "depends_on", "related", "relates_to"}: + return "semantic" + return "dependency" + + def candidates(edge: Mapping[str, Any], side: str) -> list[str]: + values: list[Any] = [] + values.extend(_as_sequence(edge.get(f"{side}_candidates"))) + values.extend(_as_sequence(edge.get(f"{side}_rpg_link_ids"))) + values.append(edge.get(f"{side}_link_id")) + node_id = edge.get(f"{side}_node_id") + values.append(rpg_links.get(str(node_id or ""))) + values.append(code_links.get(str(node_id or ""))) + values.append(node_id) + expanded: list[str] = [] + for value in values: + if value in (None, ""): + continue + text = str(value) + expanded.append(text) + expanded.extend(node_aliases.get(text, [])) + return _ordered_texts(expanded) + + relation_edges: list[dict[str, Any]] = [] + for index, edge in enumerate(context_edges, start=1): + source_candidates = candidates(edge, "source") + target_candidates = candidates(edge, "target") + relation_edges.append({ + "id": str(edge.get("link_id") or f"edge-{index}"), + "source": source_candidates[0] if source_candidates else "", + "target": target_candidates[0] if target_candidates else "", + "source_candidates": source_candidates, + "target_candidates": target_candidates, + "kind": edge_kind(edge), "relation": edge.get("relation") or "dependency", + "source_meta": edge.get("source"), + "source_graph": edge.get("source_graph"), + "edge_source": edge.get("edge_source"), + "relation_source": edge.get("relation_source"), "direction": edge.get("direction"), + "reason": edge.get("reason"), + "path": edge.get("path"), }) - focused_graph = nodes_view.get("focused_graph") if isinstance(nodes_view.get("focused_graph"), Mapping) else {} - hierarchy = nodes_view.get("hierarchy") or focused_graph.get("hierarchy") or {} - default_focus = nodes_view.get("default_focus") or focused_graph.get("default_focus") or {} - _append_hierarchy_nodes(nodes, links, seen_nodes, hierarchy) + links.extend(relation_edges) return { "schema": "cmind.focused_graph.render.v1", "summary": summary, "nodes": nodes, "links": links, + "relation_edges": relation_edges, "semantic_nodes": semantic_nodes, "code_nodes": code_nodes, "mappings": mappings, @@ -1025,6 +1181,7 @@ def _focused_graph_payload(focused_view: Mapping[str, Any], file_anchors: Mappin "warnings": warnings, "hierarchy": hierarchy, "default_focus": default_focus, + "node_aliases": node_aliases, } @@ -1040,279 +1197,270 @@ def _focused_graph_runtime() -> str: if (fallback) fallback.hidden = true; const data = JSON.parse(dataEl.textContent || '{}'); const width = Number(svg.getAttribute('width')) || 960; - const height = Number(svg.getAttribute('height')) || 480; + const height = Number(svg.getAttribute('height')) || 520; const defaultFocus = data.default_focus || {}; const defaultShowEdges = defaultFocus.show_edges !== false; const text = value => value === undefined || value === null ? '' : String(value); const list = value => Array.isArray(value) ? value : []; - let hierarchyDepth = Math.max(0, Number(defaultFocus.hierarchy_depth ?? defaultFocus.edge_depth ?? 1) || 0); - const defaultHierarchyDepth = hierarchyDepth; + const root = d3.hierarchy(data.hierarchy || {id:'focused-graph-root', name:'Focused graph', children:[]}, d => d.children); + const rootHierarchyId = text(root.data.id || 'focused-graph-root'); + const focusedNodeIds = new Set(list(defaultFocus.focused_tree_node_ids || defaultFocus.focused_node_ids || defaultFocus.node_link_ids).map(text).filter(Boolean)); + const focusedCodeLinkIds = new Set(list(defaultFocus.focused_code_link_ids).map(text).filter(Boolean)); + const expandedNodeIds = new Set(list(defaultFocus.default_expanded_node_ids || defaultFocus.expanded_node_ids).map(text).filter(Boolean)); + const focusedPathNodeIds = new Set(list(defaultFocus.focused_path_node_ids).map(text).filter(Boolean)); + const defaultExpandedIds = new Set([rootHierarchyId, ...expandedNodeIds]); let showEdges = defaultShowEdges; let query = ''; let selectedId = null; - let visibleNodeIds = new Set(); - const collapsedHierarchyIds = new Set(); - const nodes = list(data.nodes).map((node, index) => ({ - ...node, - id: text(node.id || node.node_id || `node-${index + 1}`), - kind: text(node.kind || 'context'), - label: text(node.label || node.name || node.node_id || node.id || `node-${index + 1}`), - })); - const nodeById = new Map(nodes.map(node => [node.id, node])); - const links = list(data.links).map((link, index) => { - const source = text(link.source && link.source.id ? link.source.id : link.source); - const target = text(link.target && link.target.id ? link.target.id : link.target); - return {...link, id: text(link.id || `link-${index + 1}`), source, target}; - }).filter(link => nodeById.has(link.source) && nodeById.has(link.target)); - const hierarchyNodeById = new Map(); - const hierarchyParentById = new Map(); - const hierarchyChildrenById = new Map(); - const hierarchyDepthById = new Map(); - const hierarchyAncestorsById = new Map(); - const rootHierarchyId = text(data.hierarchy && data.hierarchy.id); - const adjacency = new Map(nodes.map(node => [node.id, new Set()])); - const linksByNode = new Map(nodes.map(node => [node.id, new Set()])); - - function fill(kind) { - return kind === 'semantic' ? '#2563eb' : kind === 'code' ? '#16a34a' : kind === 'mapping' ? '#f59e0b' : kind === 'hierarchy' ? '#8b5cf6' : '#64748b'; - } - function linkKey(link) { - return text(link.id || `${link.source}-${link.target}`); - } - function collectHierarchy(row, parentId, depth, ancestors) { - if (!row || typeof row !== 'object') return; - const id = text(row.id || row.link_id || row.node_id); - if (!id) return; - const children = list(row.children).map(child => text(child && (child.id || child.link_id || child.node_id))).filter(Boolean); - hierarchyNodeById.set(id, row); - hierarchyChildrenById.set(id, children); - hierarchyDepthById.set(id, depth); - hierarchyAncestorsById.set(id, ancestors); - if (parentId) hierarchyParentById.set(id, parentId); - list(row.children).forEach(child => collectHierarchy(child, id, depth + 1, ancestors.concat(id))); - } - collectHierarchy(data.hierarchy, '', 0, []); - let maxHierarchyDepth = Math.max(1, defaultHierarchyDepth); - hierarchyDepthById.forEach(depth => { maxHierarchyDepth = Math.max(maxHierarchyDepth, depth); }); - const maxDepth = Math.max(maxHierarchyDepth, Math.min(Math.max(nodes.length - 1, 1), 8)); - - links.forEach(link => { - adjacency.get(link.source)?.add(link.target); - adjacency.get(link.target)?.add(link.source); - linksByNode.get(link.source)?.add(linkKey(link)); - linksByNode.get(link.target)?.add(linkKey(link)); + const allNodeById = {}; + let nodeById = {}; + let currentNodes = []; + let currentRelationEdges = []; + + root.x0 = height / 2; + root.y0 = 0; + root.descendants().forEach(d => { + d.id = text(d.data.id || d.data.link_id || d.data.node_id || d.data.name); + if (!d.id) d.id = `node-${Math.random().toString(36).slice(2)}`; + d._allChildren = d.children || null; + allNodeById[d.id] = d; }); - const defaultFocusIds = new Set(list(defaultFocus.node_link_ids).map(text).filter(id => nodeById.has(id))); - const semanticFocusIds = new Set(list(defaultFocus.semantic_node_ids).map(text)); - const codeFocusIds = new Set(list(defaultFocus.code_node_ids).map(text)); - const mappingFocusIds = new Set(list(defaultFocus.mapping_link_ids).map(text)); - nodes.forEach(node => { - if (semanticFocusIds.has(text(node.node_id)) || codeFocusIds.has(text(node.node_id)) || mappingFocusIds.has(node.id)) { - defaultFocusIds.add(node.id); - } - }); + function walkAll(d, visit) { + visit(d); + list(d._allChildren).forEach(child => walkAll(child, visit)); + } - function layout(rows) { - const groups = {hierarchy: [], semantic: [], mapping: [], code: [], context: []}; - rows.forEach(node => (groups[node.kind] || groups.context).push(node)); - const x = {hierarchy: 90, semantic: 240, mapping: 455, code: 690, context: 850}; - const marginY = 36; - const availableY = Math.max(120, height - marginY * 2); - Object.entries(groups).forEach(([kind, groupRows]) => { - const gap = groupRows.length > 1 ? Math.min(78, availableY / (groupRows.length - 1)) : 0; - const start = height / 2 - ((groupRows.length - 1) * gap) / 2; - groupRows.forEach((node, index) => { node.x = x[kind] || width / 2; node.y = start + index * gap; }); + function initializeDefaultState() { + walkAll(root, d => { + if (!d._allChildren) return; + const keepOpen = d.depth === 0 || defaultExpandedIds.has(d.id) || focusedPathNodeIds.has(d.id); + if (keepOpen) { + d.children = d._allChildren; + d._children = null; + } else { + d._children = d._allChildren; + d.children = null; + } }); } + initializeDefaultState(); - function expandFromFocus(seedIds, depthLimit) { - const visible = new Set([...seedIds].filter(id => nodeById.has(id))); - let frontier = new Set(visible); - for (let depth = 0; depth < depthLimit && frontier.size; depth += 1) { - const next = new Set(); - frontier.forEach(id => (adjacency.get(id) || new Set()).forEach(neighbor => { - if (!visible.has(neighbor)) { - visible.add(neighbor); - next.add(neighbor); - } - })); - frontier = next; + const relationEdges = list(data.relation_edges || data.links).filter(edge => text(edge.relation) !== 'contains'); + const treemap = d3.tree().nodeSize([28, 250]); + const svgSelection = d3.select(svg).attr('viewBox', `0 0 ${width} ${height}`); + svgSelection.selectAll('*').remove(); + const graphLayer = svgSelection.append('g').attr('class', 'focused-graph-layer').attr('transform', 'translate(80,40)'); + const relationLayer = graphLayer.append('g').attr('class', 'focused-graph-relation-links'); + const treeLinkLayer = graphLayer.append('g').attr('class', 'focused-graph-tree-links'); + const nodeLayer = graphLayer.append('g').attr('class', 'focused-graph-nodes'); + const zoom = d3.zoom().scaleExtent([0.25, 3.5]).on('zoom', event => graphLayer.attr('transform', event.transform)); + svgSelection.call(zoom).on('dblclick.zoom', null).on('click', event => { + if (event.target === svg) { + selectedId = null; + update(root); } - return visible; - } + }); - function nodeMatches(node) { - if (!query) return true; - return `${node.label} ${node.node_id || ''} ${node.path || ''} ${node.kind || ''}`.toLowerCase().includes(query); + function mappedCodeLabel(d) { + const path = d.data.mapped_code_path || list(d.data.mapped_code_paths)[0] || ''; + const symbol = d.data.mapped_code_symbol || list(d.data.mapped_code_symbols)[0] || ''; + return [path, symbol].filter(Boolean).join(' · '); } - function isCollapsed(nodeId) { - return list(hierarchyAncestorsById.get(nodeId)).some(id => collapsedHierarchyIds.has(id)); + function nodeLabel(d) { + const base = text(d.data.label || d.data.feature_name || d.data.name || d.data.node_id || d.id); + const mapped = mappedCodeLabel(d); + return mapped && !base.includes(mapped) ? `${base} — ${mapped}` : base; } - function nearestHierarchyToggle(nodeId) { - return nodeId !== rootHierarchyId && (hierarchyChildrenById.get(nodeId) || []).length ? nodeId : ''; + function nodeSearchText(d) { + return `${nodeLabel(d)} ${d.data.node_id || ''} ${d.data.feature_path || ''} ${d.data.path || ''} ${d.data.mapped_code_path || ''} ${list(d.data.mapped_code_paths).join(' ')} ${d.data.mapped_code_symbol || ''} ${list(d.data.mapped_code_symbols).join(' ')}`.toLowerCase(); } - function computeVisibleNodeIds() { - const seeds = defaultFocusIds.size ? defaultFocusIds : new Set(nodes.map(node => node.id)); - const visible = expandFromFocus(seeds, hierarchyDepth); - if (hierarchyNodeById.size) { - nodes.forEach(node => { - const depth = hierarchyDepthById.get(node.id); - if (depth !== undefined && depth <= hierarchyDepth) visible.add(node.id); - }); - } - if (query) nodes.forEach(node => { if (nodeMatches(node)) visible.add(node.id); }); - nodes.forEach(node => { - if (!nodeMatches(node) || isCollapsed(node.id)) visible.delete(node.id); - }); - if (!visible.size && !query) nodes.forEach(node => { if (!isCollapsed(node.id)) visible.add(node.id); }); - return visible; + function nodeMatches(d) { + return query && nodeSearchText(d).includes(query); } - function isLinkVisible(link) { - return showEdges && visibleNodeIds.has(link.source) && visibleNodeIds.has(link.target); + function diagonal(s, d) { + return `M${s.y},${s.x} C${(s.y + d.y) / 2},${s.x} ${(s.y + d.y) / 2},${d.x} ${d.y},${d.x}`; } - layout(nodes); - const svgSelection = d3.select(svg).attr('viewBox', `0 0 ${width} ${height}`); - svgSelection.selectAll('*').remove(); - const graphLayer = svgSelection.append('g').attr('class', 'focused-graph-layer'); - const linkLayer = graphLayer.append('g').attr('class', 'focused-graph-links'); - const nodeLayer = graphLayer.append('g').attr('class', 'focused-graph-nodes'); - const zoom = d3.zoom() - .scaleExtent([0.35, 3.5]) - .on('zoom', event => graphLayer.attr('transform', event.transform)); - svgSelection.call(zoom).on('dblclick.zoom', null).on('click', event => { - if (event.target === svg) { - selectedId = null; - renderVisibility(); + function openAncestors(d) { + let p = d.parent; + while (p) { + if (p._children) { p.children = p._children; p._children = null; } + p = p.parent; } - }); - - const linkSelection = linkLayer.selectAll('line') - .data(links, link => linkKey(link)) - .join('line') - .attr('class', 'focused-graph-link') - .attr('data-link-id', link => linkKey(link)) - .attr('stroke', link => link.kind === 'mapping' ? '#f59e0b' : '#94a3b8') - .attr('stroke-width', link => link.kind === 'mapping' ? 2 : 1.4) - .attr('opacity', .78); - - const drag = d3.drag() - .container(() => graphLayer.node()) - .on('start', function(event) { - event.sourceEvent?.stopPropagation(); - d3.select(this).classed('dragging', true); - }) - .on('drag', function(event, node) { - node.x = event.x; - node.y = event.y; - updatePositions(); - }) - .on('end', function() { - d3.select(this).classed('dragging', false); - }); - - const nodeSelection = nodeLayer.selectAll('g') - .data(nodes, node => node.id) - .join(enter => { - const group = enter.append('g') - .attr('class', 'focused-graph-node') - .attr('data-node-id', node => node.id) - .attr('tabindex', 0) - .attr('role', 'button'); - group.append('circle') - .attr('r', node => node.kind === 'mapping' ? 6 : 8) - .attr('fill', node => fill(node.kind)) - .attr('stroke', '#fff') - .attr('stroke-width', 2); - group.append('text') - .attr('x', 13) - .attr('y', 4) - .attr('font-size', 12) - .attr('fill', '#1f2937') - .text(node => text(node.label || node.id).slice(0, 42)); - group.append('title').text(node => `${node.kind}: ${node.label || node.id}`); - return group; - }) - .call(drag) - .on('click', (event, node) => { - event.stopPropagation(); - selectedId = selectedId === node.id ? null : node.id; - renderVisibility(); - }) - .on('dblclick', (event, node) => { - event.stopPropagation(); - const toggleId = nearestHierarchyToggle(node.id); - if (!toggleId) return; - if (collapsedHierarchyIds.has(toggleId)) collapsedHierarchyIds.delete(toggleId); - else collapsedHierarchyIds.add(toggleId); - if (selectedId && isCollapsed(selectedId)) selectedId = null; - renderVisibility(); - }); + } - function updatePositions() { - linkSelection - .attr('x1', link => nodeById.get(link.source)?.x || 0) - .attr('y1', link => nodeById.get(link.source)?.y || 0) - .attr('x2', link => nodeById.get(link.target)?.x || 0) - .attr('y2', link => nodeById.get(link.target)?.y || 0); - nodeSelection.attr('transform', node => `translate(${node.x},${node.y})`); + function applySearchOpen() { + if (!query) return; + walkAll(root, d => { if (nodeMatches(d)) openAncestors(d); }); } - function renderVisibility() { - visibleNodeIds = computeVisibleNodeIds(); - if (selectedId && !visibleNodeIds.has(selectedId)) selectedId = null; - const activeNodes = new Set(); - const activeLinks = new Set(); - if (selectedId) { - activeNodes.add(selectedId); - (adjacency.get(selectedId) || new Set()).forEach(id => activeNodes.add(id)); - (linksByNode.get(selectedId) || new Set()).forEach(id => activeLinks.add(id)); - } - nodeSelection - .classed('hidden', node => !visibleNodeIds.has(node.id)) - .classed('selected', node => node.id === selectedId) - .classed('active', node => Boolean(selectedId && activeNodes.has(node.id))) - .classed('dimmed', node => Boolean(selectedId && visibleNodeIds.has(node.id) && !activeNodes.has(node.id))); - linkSelection - .classed('hidden', link => !isLinkVisible(link)) - .classed('active', link => Boolean(selectedId && activeLinks.has(linkKey(link)))) - .classed('dimmed', link => Boolean(selectedId && isLinkVisible(link) && !activeLinks.has(linkKey(link)))); + function expandAll() { + walkAll(root, d => { if (d._children) { d.children = d._children; d._children = null; } }); + update(root); } function expandDepth() { - hierarchyDepth = Math.min(maxDepth, hierarchyDepth + 1); - renderVisibility(); + let minDepth = Infinity; + walkAll(root, d => { if (d._children && d.depth < minDepth) minDepth = d.depth; }); + if (minDepth === Infinity) return; + walkAll(root, d => { if (d._children && d.depth === minDepth) { d.children = d._children; d._children = null; } }); + update(root); } function collapseDepth() { - hierarchyDepth = Math.max(0, hierarchyDepth - 1); - renderVisibility(); + let maxDepth = -1; + walkAll(root, d => { if (d.children && d.children.length && d.depth > maxDepth) maxDepth = d.depth; }); + if (maxDepth <= 0) return; + walkAll(root, d => { if (d.children && d.children.length && d.depth === maxDepth) { d._children = d.children; d.children = null; } }); + update(root); } - section.querySelector('[data-action="reset"]')?.addEventListener('click', () => { - hierarchyDepth = defaultHierarchyDepth; - showEdges = defaultShowEdges; + function resetDefault() { query = ''; selectedId = null; - collapsedHierarchyIds.clear(); + showEdges = defaultShowEdges; + initializeDefaultState(); const search = section.querySelector('[data-action="search"]'); if (search) search.value = ''; const edges = section.querySelector('[data-action="edges"]'); if (edges) edges.checked = showEdges; - svgSelection.transition().duration(150).call(zoom.transform, d3.zoomIdentity); - renderVisibility(); - }); + svgSelection.transition().duration(150).call(zoom.transform, d3.zoomIdentity.translate(80, 40)); + update(root); + } + + function cssToken(value) { + const token = text(value).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); + return token || 'unknown'; + } + + function edgeClass(edge) { + const kind = text(edge.kind || edge.source_graph).toLowerCase().includes('rpg') || text(edge.kind).toLowerCase() === 'semantic' ? 'edge-semantic' : 'edge-dependency'; + const relation = `relation-${cssToken(edge.relation || 'dependency')}`; + const source = `source-${cssToken(edge.source_graph || edge.edge_source || edge.source_meta || kind)}`; + return `${kind} ${relation} ${source}`; + } + + function visibleEndpoint(edge, side) { + for (const candidate of list(edge[`${side}_candidates`]).map(text)) { + let node = allNodeById[candidate] || nodeById[candidate]; + while (node) { + if (nodeById[node.id]) return nodeById[node.id]; + node = node.parent; + } + } + let node = allNodeById[text(edge[side])] || nodeById[text(edge[side])]; + while (node) { + if (nodeById[node.id]) return nodeById[node.id]; + node = node.parent; + } + return null; + } + + function relationPath(source, target) { + const sx = source.y + 8, sy = source.x; + const dx = target.y - 8, dy = target.x; + const midX = Math.max(sx, dx) + 56 + Math.abs(sy - dy) * 0.18; + return `M${sx},${sy} Q${midX},${(sy + dy) / 2} ${dx},${dy}`; + } + + function drawRelationEdges() { + currentRelationEdges = []; + if (showEdges) { + relationEdges.forEach(edge => { + const source = visibleEndpoint(edge, 'source'); + const target = visibleEndpoint(edge, 'target'); + if (!source || !target || source === target) return; + currentRelationEdges.push({...edge, _source: source, _target: target, _key: `${edge.id || edge.relation}-${source.id}-${target.id}`}); + }); + } + const rel = relationLayer.selectAll('path.focused-graph-link').data(currentRelationEdges, edge => edge._key); + const relEnter = rel.enter().append('path') + .attr('class', edge => `focused-graph-link ${edgeClass(edge)}`) + .attr('data-link-id', edge => text(edge.id || edge._key)) + .attr('d', edge => relationPath(edge._source, edge._target)); + relEnter.append('title'); + const relUpdate = relEnter.merge(rel) + .attr('class', edge => `focused-graph-link ${edgeClass(edge)}${selectedId && (edge._source.id === selectedId || edge._target.id === selectedId) ? ' active' : ''}${selectedId && edge._source.id !== selectedId && edge._target.id !== selectedId ? ' dimmed' : ''}`) + .attr('d', edge => relationPath(edge._source, edge._target)); + relUpdate.select('title').text(edge => `${edge.relation || 'dependency'}\n${edge.source_graph || edge.edge_source || edge.source_meta || edge.kind || ''}\n${edge.path || edge.reason || ''}`); + rel.exit().remove(); + } + + function update(source) { + applySearchOpen(); + const layout = treemap(root); + currentNodes = layout.descendants(); + currentNodes.forEach(d => { d.y = d.depth * 250; }); + const minX = d3.min(currentNodes, d => d.x) || 0; + if (minX < 20) currentNodes.forEach(d => { d.x += 20 - minX; }); + nodeById = {}; + currentNodes.forEach(d => { nodeById[d.id] = d; }); + + const node = nodeLayer.selectAll('g.focused-graph-node').data(currentNodes, d => d.id); + const nodeEnter = node.enter().append('g') + .attr('class', 'focused-graph-node') + .attr('data-node-id', d => d.id) + .attr('tabindex', 0) + .attr('role', 'button') + .attr('transform', `translate(${source.y0 || 0},${source.x0 || 0})`) + .on('click', (event, d) => { + event.stopPropagation(); + selectedId = selectedId === d.id ? null : d.id; + update(d); + }) + .on('dblclick', (event, d) => { + event.stopPropagation(); + if (d.children) { d._children = d.children; d.children = null; } + else if (d._children) { d.children = d._children; d._children = null; } + update(d); + }); + nodeEnter.append('circle').attr('r', d => d._children ? 6 : (d.children ? 5 : 4)); + nodeEnter.append('text') + .attr('dy', 4) + .attr('x', d => d.children || d._children ? -10 : 10) + .attr('text-anchor', d => d.children || d._children ? 'end' : 'start') + .attr('font-size', 12) + .attr('fill', '#1f2937') + .text(d => nodeLabel(d).length > 46 ? nodeLabel(d).slice(0, 44) + '…' : nodeLabel(d)); + nodeEnter.append('title').text(d => `${nodeLabel(d)}\n${d.data.feature_path || ''}\n${d.data.mapped_code_path || ''} ${d.data.mapped_code_symbol || ''}`); + + const nodeUpdate = nodeEnter.merge(node); + nodeUpdate.transition().duration(250).attr('transform', d => `translate(${d.y},${d.x})`); + nodeUpdate + .attr('class', d => `focused-graph-node${d.id === selectedId ? ' selected' : ''}${focusedNodeIds.has(d.id) || focusedCodeLinkIds.has(d.id) ? ' focused' : ''}${nodeMatches(d) ? ' search-match' : ''}${selectedId && d.id !== selectedId && !currentRelationEdges.some(edge => edge._source.id === d.id || edge._target.id === d.id) ? ' dimmed' : ''}`); + nodeUpdate.select('circle').attr('r', d => d._children ? 6 : (d.children ? 5 : 4)); + nodeUpdate.select('text') + .attr('x', d => d.children || d._children ? -10 : 10) + .attr('text-anchor', d => d.children || d._children ? 'end' : 'start'); + node.exit().transition().duration(180).attr('transform', `translate(${source.y || 0},${source.x || 0})`).remove(); + + const treeLinks = layout.links(); + const link = treeLinkLayer.selectAll('path.focused-graph-tree-link').data(treeLinks, d => d.target.id); + link.enter().insert('path', 'g') + .attr('class', 'focused-graph-tree-link') + .attr('d', () => diagonal({x: source.x0 || 0, y: source.y0 || 0}, {x: source.x0 || 0, y: source.y0 || 0})) + .merge(link).transition().duration(250) + .attr('d', d => diagonal(d.source, d.target)); + link.exit().transition().duration(180) + .attr('d', () => diagonal({x: source.x || 0, y: source.y || 0}, {x: source.x || 0, y: source.y || 0})) + .remove(); + + drawRelationEdges(); + currentNodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; }); + } + + section.querySelector('[data-action="reset"]')?.addEventListener('click', resetDefault); + section.querySelector('[data-action="expand-all"]')?.addEventListener('click', expandAll); section.querySelector('[data-action="depth-plus"]')?.addEventListener('click', expandDepth); section.querySelector('[data-action="depth-minus"]')?.addEventListener('click', collapseDepth); - section.querySelector('[data-action="edges"]')?.addEventListener('change', event => { showEdges = event.target.checked; renderVisibility(); }); - section.querySelector('[data-action="search"]')?.addEventListener('input', event => { query = text(event.target.value).toLowerCase(); renderVisibility(); }); - updatePositions(); - renderVisibility(); + section.querySelector('[data-action="edges"]')?.addEventListener('change', event => { showEdges = event.target.checked; update(root); }); + section.querySelector('[data-action="search"]')?.addEventListener('input', event => { query = text(event.target.value).toLowerCase(); update(root); }); + update(root); })(); """ @@ -1345,30 +1493,30 @@ def _render_focused_graph(focused_view: dict[str, Any], file_anchors: Mapping[st inspector = json.dumps(inspector_payload, indent=2, ensure_ascii=False, default=_json_default) controls = ( '
    ' - '' - '' - '' + '' + '' + '' + '' '' '' '
    ' ) legend = ( '
    ' - 'Semantic' - 'Code' - 'Mapping' - 'Hierarchy' - 'Context edge' + 'Feature tree node' + 'RPG semantic edge' + 'dep_graph dependency edge' '
    ' ) return ( '

    Focused graph

    ' - f"{summary_html}{controls}{legend}{warnings_html}{hidden_html}" + f"{summary_html}{controls}{legend}" f"" '
    ' - '' + '' f'
    Static focused graph fallback is available when D3 cannot run.{d3_missing_note}
    ' '
    ' + f"{warnings_html}{hidden_html}" f"
    Inspector JSON
    {_h(inspector)}
    " f"{scripts}" '
    ' diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 49c8687..75f80d1 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -501,69 +501,143 @@ def _focused_graph_hierarchy( "id": "focused-graph-root", "name": "Focused graph", "kind": "root", + "feature_name": "Focused graph", + "feature_path": "Focused graph", "meta": {"hidden_counts": hidden_counts, "warnings": len(warnings), "edges": len(edges)}, "children": [], } - semantic_root = _hierarchy_child(root, "focused-semantic-root", "Semantic scope", "semantic_group") - code_root = _hierarchy_child(root, "focused-code-root", "Code scope", "code_group") - mapping_root = _hierarchy_child(root, "focused-mapping-root", "Mappings", "mapping_group") - context_root = _hierarchy_child(root, "focused-context-root", "One-hop context", "context_group") + code_by_id = { + str(node.get("node_id") or node.get("dep_node_id")): node + for node in code_nodes + if (node.get("node_id") or node.get("dep_node_id")) not in (None, "") + } + mapped_ids_by_rpg: Dict[str, List[str]] = {} + for mapping in mappings: + rpg_id = mapping.get("rpg_node_id") or mapping.get("node_id") + code_id = mapping.get("code_node_id") or mapping.get("dep_node_id") + if rpg_id not in (None, "") and code_id not in (None, ""): + mapped_ids_by_rpg.setdefault(str(rpg_id), []).append(str(code_id)) + + def code_refs_for(node: Dict[str, Any]) -> List[Dict[str, Any]]: + rpg_id = str(node.get("node_id") or "") + refs: List[Dict[str, Any]] = [] + seen: set[str] = set() + + def append_ref(ref: Dict[str, Any]) -> None: + key = str(ref.get("node_id") or ref.get("link_id") or ref.get("path") or ref.get("symbol") or "") + if not key or key in seen: + return + seen.add(key) + refs.append(ref) + + for item in _listify(node.get("mapped_code")): + if not isinstance(item, dict): + continue + code_id = item.get("node_id") or item.get("dep_node_id") + code_id_text = str(code_id or "") + code = code_by_id.get(code_id_text, {}) + path = item.get("path") or code.get("path") or item.get("file") or code.get("file") or code.get("module") or _dep_node_path(code_id_text) + symbol = item.get("symbol") or item.get("name") or code.get("symbol") or code.get("name") or _symbol_from_dep(code_id_text, code) + row: Dict[str, Any] = { + "node_id": code_id_text, + "link_id": item.get("link_id") or code.get("link_id") or _node_link_id("code", code_id_text), + "path": path, + "symbol": symbol, + } + for key in ("type", "kind", "line_range", "state", "source"): + _set_if_present(row, key, item.get(key) or code.get(key)) + append_ref(row) + + code_ids = _ordered_unique(_listify(node.get("mapped_code_node_ids")) + mapped_ids_by_rpg.get(rpg_id, [])) + for code_id in code_ids: + code = code_by_id.get(code_id, {}) + path = code.get("path") or code.get("file") or code.get("module") or _dep_node_path(code_id) + symbol = code.get("symbol") or code.get("name") or _symbol_from_dep(code_id, code) + row = { + "node_id": code_id, + "link_id": code.get("link_id") or _node_link_id("code", code_id), + "path": path, + "symbol": symbol, + } + for key in ("type", "kind", "line_range", "state", "source"): + _set_if_present(row, key, code.get(key)) + append_ref(row) + return refs + + def merge_code_metadata(row: Dict[str, Any], refs: List[Dict[str, Any]]) -> None: + if not refs: + return + existing = [item for item in _listify(row.get("mapped_code")) if isinstance(item, dict)] + merged: List[Dict[str, Any]] = [] + seen: set[str] = set() + for ref in existing + refs: + key = str(ref.get("node_id") or ref.get("link_id") or ref.get("path") or ref.get("symbol") or "") + if not key or key in seen: + continue + seen.add(key) + merged.append(ref) + row["mapped_code"] = merged + row["mapped_code_node_ids"] = _ordered_unique([ref.get("node_id") for ref in merged]) + row["mapped_code_link_ids"] = _ordered_unique([ref.get("link_id") for ref in merged]) + row["mapped_code_paths"] = _ordered_unique([ref.get("path") for ref in merged]) + row["mapped_code_symbols"] = _ordered_unique([ref.get("symbol") for ref in merged]) + if row["mapped_code_paths"]: + row["mapped_code_path"] = row["mapped_code_paths"][0] + if row["mapped_code_symbols"]: + row["mapped_code_symbol"] = row["mapped_code_symbols"][0] + row["mapped_code_count"] = len(merged) + + def feature_path_text(node: Dict[str, Any], group_parts: List[str], feature_name: str) -> str: + for key in ("feature_path", "breadcrumb_path", "breadcrumb", "path"): + value = node.get(key) + parts = _hierarchy_segments(value) + if parts: + return " / ".join(parts) + return " / ".join(group_parts + ([feature_name] if feature_name else [])) - known_link_ids: set[str] = set() for node in semantic_nodes: - link_id = str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) - known_link_ids.add(link_id) - leaf = { + node_id = str(node.get("node_id") or "") + link_id = str(node.get("link_id") or _node_link_id("rpg", node_id)) + feature_name = str(node.get("name") or node.get("symbol") or node_id or "feature") + group_parts = _semantic_hierarchy_parts(node) + code_refs = code_refs_for(node) + parent = root + trail: List[str] = [] + for part in group_parts: + trail.append(part) + group = _hierarchy_child(parent, _node_link_id("feature-path", "/".join(trail)), part, "feature_group") + group["feature_name"] = part + group["feature_path"] = " / ".join(trail) + merge_code_metadata(group, code_refs) + parent = group + leaf: Dict[str, Any] = { "id": link_id, - "node_id": node.get("node_id"), - "name": node.get("name") or node.get("symbol") or node.get("node_id") or "semantic node", - "kind": "semantic", + "node_id": node_id, + "name": feature_name, + "feature_name": feature_name, + "feature_path": feature_path_text(node, group_parts, feature_name), + "kind": "feature", "state": node.get("state"), "mapping_status": node.get("mapping_status"), } - _add_hierarchy_path(semantic_root, _semantic_hierarchy_parts(node), leaf, "semantic-path") - - for node in code_nodes: - code_id = node.get("node_id") or node.get("dep_node_id") - link_id = str(node.get("link_id") or _node_link_id("code", code_id)) - known_link_ids.add(link_id) - leaf = { - "id": link_id, - "node_id": code_id, - "name": node.get("symbol") or node.get("name") or code_id or "code node", - "kind": "code", - "state": node.get("state"), - "path": node.get("path"), - } - _add_hierarchy_path(code_root, _code_hierarchy_parts(node), leaf, "code-path") - - for mapping in mappings: - link_id = str(mapping.get("link_id") or _node_link_id("map", f"{mapping.get('rpg_node_id')}-{mapping.get('code_node_id') or 'missing'}")) - known_link_ids.add(link_id) - target = mapping.get("code_node_id") or mapping.get("dep_node_id") or "missing mapping" - _append_hierarchy_leaf(mapping_root, { - "id": link_id, - "name": f"{mapping.get('rpg_node_id')} → {target}", - "kind": "mapping", - "state": mapping.get("state") or mapping.get("status"), - "rpg_node_id": mapping.get("rpg_node_id"), - "code_node_id": mapping.get("code_node_id") or mapping.get("dep_node_id"), - }) - - context_link_ids: set[str] = set() - for edge in edges: - for endpoint_key, link_key in (("source_node_id", "source_link_id"), ("target_node_id", "target_link_id")): - link_id = edge.get(link_key) - node_id = edge.get(endpoint_key) - if link_id in (None, "") or str(link_id) in known_link_ids or str(link_id) in context_link_ids: - continue - context_link_ids.add(str(link_id)) - _append_hierarchy_leaf(context_root, { - "id": str(link_id), - "node_id": node_id, - "name": node_id or "context node", - "kind": "context", - }) + for key in ( + "type", + "node_type", + "path", + "breadcrumb", + "breadcrumb_path", + "locate_status", + "score", + "reason", + "apply_action", + "changed_files", + "hidden_counts", + "warning_types", + "source", + ): + _set_if_present(leaf, key, node.get(key)) + merge_code_metadata(leaf, code_refs) + _append_hierarchy_leaf(parent, leaf) return root @@ -576,19 +650,93 @@ def _focused_graph_default_focus( ) -> Dict[str, Any]: semantic_links = [str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) for node in semantic_nodes] code_links = [str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) for node in code_nodes] - changed_code_links = [ - str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) - for node in code_nodes - if node.get("changed") or node.get("changed_files") or node.get("diff_anchor") - ] + semantic_link_set = set(semantic_links) + code_to_feature_links: Dict[str, List[str]] = {} + + def remember_code_feature(code_link: Any, feature_link: Any) -> None: + if code_link in (None, "") or feature_link in (None, ""): + return + code_text = str(code_link) + feature_text = str(feature_link) + values = code_to_feature_links.setdefault(code_text, []) + if feature_text not in values: + values.append(feature_text) + + rpg_link_by_node_id = { + str(node.get("node_id")): str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) + for node in semantic_nodes + if node.get("node_id") not in (None, "") + } + for mapping in mappings: + source = mapping.get("source_link_id") or rpg_link_by_node_id.get(str(mapping.get("rpg_node_id") or mapping.get("node_id") or "")) + target = mapping.get("target_link_id") or _node_link_id("code", mapping.get("code_node_id") or mapping.get("dep_node_id")) + remember_code_feature(target, source) + remember_code_feature(mapping.get("code_node_id") or mapping.get("dep_node_id"), source) + for node in code_nodes: + code_link = str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) + for rpg_link in _listify(node.get("mapped_rpg_link_ids")): + remember_code_feature(code_link, rpg_link) + remember_code_feature(node.get("node_id") or node.get("dep_node_id"), rpg_link) + + changed_feature_links: List[Any] = [] + changed_code_links: List[str] = [] + for node in code_nodes: + link_id = str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) + if node.get("changed") or node.get("changed_files") or node.get("diff_anchor"): + changed_code_links.append(link_id) + changed_feature_links.extend(_listify(node.get("mapped_rpg_link_ids")) + code_to_feature_links.get(link_id, [])) warning_links = [] for warning in warnings: warning_links.extend(_listify(warning.get("node_link_id")) + _listify(warning.get("code_link_id"))) - node_link_ids = _ordered_unique(semantic_links + changed_code_links + warning_links) + node_link_ids = _ordered_unique(semantic_links + changed_feature_links + changed_code_links + warning_links) if not node_link_ids: node_link_ids = _ordered_unique(semantic_links + code_links) + + expanded_node_ids: List[Any] = ["focused-graph-root"] + focused_path_node_ids: List[Any] = [] + focused_tree_node_ids: List[Any] = [] + semantic_path_ids_by_link: Dict[str, List[str]] = {} + for node in semantic_nodes: + link_id = str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) + trail: List[str] = [] + path_ids: List[str] = ["focused-graph-root"] + for part in _semantic_hierarchy_parts(node): + trail.append(part) + path_ids.append(_node_link_id("feature-path", "/".join(trail))) + path_ids.append(link_id) + semantic_path_ids_by_link[link_id] = path_ids + + def focus_tree_link(link_id: Any) -> None: + link_text = str(link_id or "") + if not link_text: + return + feature_links = [link_text] if link_text in semantic_link_set else code_to_feature_links.get(link_text, []) + if not feature_links: + feature_links = [link_text] if link_text.startswith("feature-path-") or link_text == "focused-graph-root" else [] + for feature_link in feature_links: + path_ids = semantic_path_ids_by_link.get(feature_link, [feature_link]) + expanded_node_ids.extend(path_ids[:-1]) + focused_path_node_ids.extend(path_ids) + focused_tree_node_ids.append(path_ids[-1]) + + for link_id in node_link_ids: + focus_tree_link(link_id) + if not focused_tree_node_ids: + for link_id in semantic_links: + focus_tree_link(link_id) + + expanded_node_ids = _ordered_unique(expanded_node_ids) + focused_path_node_ids = _ordered_unique(focused_path_node_ids) + focused_tree_node_ids = _ordered_unique(focused_tree_node_ids) + focused_code_link_ids = _ordered_unique(changed_code_links + [link_id for link_id in node_link_ids if link_id in code_links]) return { "node_link_ids": node_link_ids, + "focused_node_ids": node_link_ids, + "focused_tree_node_ids": focused_tree_node_ids, + "focused_code_link_ids": focused_code_link_ids, + "expanded_node_ids": expanded_node_ids, + "default_expanded_node_ids": expanded_node_ids, + "focused_path_node_ids": focused_path_node_ids, "semantic_node_ids": _ordered_unique([node.get("node_id") for node in semantic_nodes]), "code_node_ids": _ordered_unique([node.get("node_id") or node.get("dep_node_id") for node in code_nodes]), "mapping_link_ids": _ordered_unique([mapping.get("link_id") for mapping in mappings]), @@ -677,8 +825,30 @@ def _build_nodes_view( row["hidden_counts"] = node.get("hidden_counts") mapped_code_ids = _ordered_unique(code_ids_by_rpg.get(node_id, [])) if mapped_code_ids: + mapped_code_refs: List[Dict[str, Any]] = [] + for code_id in mapped_code_ids: + code = code_by_id.get(code_id, {}) + path = code.get("path") or code.get("file") or code.get("module") or _dep_node_path(code_id) + symbol = code.get("symbol") or code.get("name") or _symbol_from_dep(code_id, code) + ref: Dict[str, Any] = { + "node_id": code_id, + "link_id": _edge_endpoint_link_id(code_id, rpg_by_id, code_by_id), + "path": path, + "symbol": symbol, + } + for key in ("type", "kind", "line_range", "state", "source"): + _set_if_present(ref, key, code.get(key)) + mapped_code_refs.append(ref) + row["mapped_code"] = mapped_code_refs row["mapped_code_node_ids"] = mapped_code_ids - row["mapped_code_link_ids"] = [_edge_endpoint_link_id(code_id, rpg_by_id, code_by_id) for code_id in mapped_code_ids] + row["mapped_code_link_ids"] = [ref["link_id"] for ref in mapped_code_refs] + row["mapped_code_paths"] = _ordered_unique([ref.get("path") for ref in mapped_code_refs]) + row["mapped_code_symbols"] = _ordered_unique([ref.get("symbol") for ref in mapped_code_refs]) + if row["mapped_code_paths"]: + row["mapped_code_path"] = row["mapped_code_paths"][0] + if row["mapped_code_symbols"]: + row["mapped_code_symbol"] = row["mapped_code_symbols"][0] + row["mapped_code_count"] = len(mapped_code_refs) if warnings_by_rpg.get(node_id): row["warning_types"] = _ordered_unique([warning.get("type") for warning in warnings_by_rpg[node_id]]) semantic_nodes.append(row) @@ -750,7 +920,7 @@ def _build_nodes_view( "relation": relation, "state": edge.get("status") or "visible", } - for key in ("direction", "source", "path", "reason", "rpg_node_id", "neighbor_node_id", "name"): + for key in ("direction", "source", "source_graph", "edge_source", "relation_source", "path", "reason", "rpg_node_id", "neighbor_node_id", "name"): _set_if_present(row, key, edge.get(key)) edge_rows.append(row) @@ -881,19 +1051,75 @@ def _dep_node_entry(dep_id: str, raw: Dict[str, Any]) -> Dict[str, Any]: return row +_CONTAINMENT_RELATIONS = {"contains", "CONTAINS", "composes", "COMPOSES"} + + +def _edge_relation(edge: Dict[str, Any], default: str) -> str: + attrs = edge.get("attrs") if isinstance(edge.get("attrs"), dict) else {} + relation = ( + edge.get("relation") + or edge.get("type") + or edge.get("edge_type") + or edge.get("kind") + or attrs.get("relation") + or attrs.get("type") + or attrs.get("edge_type") + or attrs.get("kind") + or default + ) + return str(relation) + + +def _coerce_rpg_edge(edge: Any) -> Optional[Dict[str, Any]]: + if isinstance(edge, dict): + source = edge.get("source_node_id") or edge.get("source") or edge.get("from") or edge.get("src") + target = edge.get("target_node_id") or edge.get("target") or edge.get("to") or edge.get("dst") + relation = _edge_relation(edge, "semantic") + if relation in _CONTAINMENT_RELATIONS or source in (None, "") or target in (None, ""): + return None + row = { + "source_node_id": str(source), + "target_node_id": str(target), + "relation": relation, + "source": "rpg_semantic", + "source_graph": "rpg", + "relation_source": "rpg_semantic", + "edge_source": "rpg", + } + for key in ("direction", "name", "path", "reason", "status"): + _set_if_present(row, key, edge.get(key)) + return row + if isinstance(edge, (list, tuple)) and len(edge) >= 2: + relation = str(edge[2]) if len(edge) >= 3 else "semantic" + if relation in _CONTAINMENT_RELATIONS: + return None + return {"source_node_id": str(edge[0]), "target_node_id": str(edge[1]), "relation": relation, "source": "rpg_semantic", "source_graph": "rpg", "relation_source": "rpg_semantic", "edge_source": "rpg"} + return None + + def _coerce_dep_edge(edge: Any) -> Optional[Dict[str, Any]]: if isinstance(edge, dict): - source = edge.get("source") or edge.get("from") or edge.get("caller") or edge.get("src") - target = edge.get("target") or edge.get("to") or edge.get("callee") or edge.get("dst") - relation = edge.get("relation") or edge.get("type") or edge.get("edge_type") or edge.get("kind") or "dep_graph" - if source in (None, "") or target in (None, ""): + source = edge.get("source_node_id") or edge.get("source") or edge.get("from") or edge.get("caller") or edge.get("src") + target = edge.get("target_node_id") or edge.get("target") or edge.get("to") or edge.get("callee") or edge.get("dst") + relation = _edge_relation(edge, "dep_graph") + if relation in _CONTAINMENT_RELATIONS or source in (None, "") or target in (None, ""): return None - row = {"source_node_id": str(source), "target_node_id": str(target), "relation": str(relation), "source": "dep_graph"} + row = { + "source_node_id": str(source), + "target_node_id": str(target), + "relation": relation, + "source": "dep_graph", + "source_graph": "dep_graph", + "relation_source": "dep_graph", + "edge_source": "dep_graph", + } _set_if_present(row, "reason", edge.get("reason")) return row if isinstance(edge, (list, tuple)) and len(edge) >= 2: - relation = edge[2] if len(edge) >= 3 else "dep_graph" - return {"source_node_id": str(edge[0]), "target_node_id": str(edge[1]), "relation": str(relation), "source": "dep_graph"} + relation = str(edge[2]) if len(edge) >= 3 else "dep_graph" + if relation in _CONTAINMENT_RELATIONS: + return None + return {"source_node_id": str(edge[0]), "target_node_id": str(edge[1]), "relation": relation, "source": "dep_graph", "source_graph": "dep_graph", "relation_source": "dep_graph", "edge_source": "dep_graph"} return None @@ -901,13 +1127,14 @@ def _current_rpg_context() -> Tuple[ Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]], List[Dict[str, Any]], + List[Dict[str, Any]], Dict[str, List[str]], Dict[str, List[str]], List[Dict[str, Any]], ]: rpg_data = _load_json_artifact(REPO_RPG_FILE) if not isinstance(rpg_data, dict) or rpg_data.get("_error"): - return {}, {}, [], {}, {}, [{"type": "stale_graph", "message": f"RPG file not available: {REPO_RPG_FILE}"}] + return {}, {}, [], [], {}, {}, [{"type": "stale_graph", "message": f"RPG file not available: {REPO_RPG_FILE}"}] rpg_nodes: Dict[str, Dict[str, Any]] = {} @@ -994,8 +1221,21 @@ def visit_rpg_node(raw: Any, breadcrumb: List[str]) -> None: for dep_id, rpg_ids in list(dep_to_rpg.items()): dep_to_rpg[dep_id] = _ordered_unique(rpg_ids) + def edge_values(value: Any) -> List[Any]: + if isinstance(value, dict): + return list(value.values()) + if isinstance(value, (list, tuple, set)): + return list(value) + return [] + + rpg_edges = [] + for raw_edge in edge_values(rpg_data.get("edges")) + edge_values(rpg_data.get("semantic_edges")) + edge_values(rpg_data.get("feature_edges")): + edge = _coerce_rpg_edge(raw_edge) + if edge: + rpg_edges.append(edge) + dep_edges = [] - for raw_edge in dep_graph.get("edges") or []: + for raw_edge in edge_values(dep_graph.get("edges")) + edge_values(dep_graph.get("syntax_edges")): edge = _coerce_dep_edge(raw_edge) if edge: dep_edges.append(edge) @@ -1003,7 +1243,7 @@ def visit_rpg_node(raw: Any, breadcrumb: List[str]) -> None: warnings: List[Dict[str, Any]] = [] if not rpg_nodes and not dep_nodes: warnings.append({"type": "stale_graph", "message": "Current RPG contains no indexed feature or dependency nodes"}) - return rpg_nodes, dep_nodes, dep_edges, rpg_to_dep, dep_to_rpg, warnings + return rpg_nodes, dep_nodes, rpg_edges, dep_edges, rpg_to_dep, dep_to_rpg, warnings def _feature_evidence_groups( @@ -1017,7 +1257,7 @@ def _feature_evidence_groups( code_result = artifacts.get("code_result") if isinstance(artifacts.get("code_result"), dict) else {} apply_result = artifacts.get("apply_result") if isinstance(artifacts.get("apply_result"), dict) else {} impact_results = _impact_results(artifacts) - current_rpg_nodes, current_dep_nodes, current_dep_edges, current_rpg_to_dep, _, graph_warnings = _current_rpg_context() + current_rpg_nodes, current_dep_nodes, current_rpg_edges, current_dep_edges, current_rpg_to_dep, _, graph_warnings = _current_rpg_context() current_graph_available = bool(current_rpg_nodes or current_dep_nodes) locate_ids = {str(row.get("node_id")) for row in locate.get("results") or [] if isinstance(row, dict) and row.get("node_id")} applied_by_id = { @@ -1110,11 +1350,15 @@ def add_edge(edge: Dict[str, Any]) -> None: if key in edge_keys: return edge_keys.add(key) + source_graph = edge.get("source_graph") or ("rpg" if source_text_label == "rpg_semantic" else "dep_graph" if source_text_label == "dep_graph" else source_text_label) row: Dict[str, Any] = { "source_node_id": source_text, "target_node_id": target_text, "relation": relation_text, "source": source_text_label, + "source_graph": source_graph, + "relation_source": edge.get("relation_source") or source_text_label, + "edge_source": edge.get("edge_source") or source_graph, } for key_name in ("rpg_node_id", "neighbor_node_id", "direction", "name", "path", "reason", "status"): _set_if_present(row, key_name, edge.get(key_name)) @@ -1262,6 +1506,11 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: if dep_node.get("path") in changed_files: add_code_node(dep_id, source="changed_file", fallback_path=dep_node.get("path")) + selected_rpg_ids = {row.get("node_id") for row in primary_rpg_nodes_all} + for edge in current_rpg_edges: + if edge.get("source_node_id") in selected_rpg_ids or edge.get("target_node_id") in selected_rpg_ids: + add_edge(edge) + selected_code_ids = set(code_node_by_id) for edge in current_dep_edges: if edge.get("source_node_id") in selected_code_ids or edge.get("target_node_id") in selected_code_ids: @@ -1320,6 +1569,7 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: "current_graph_available": current_graph_available, "current_rpg_nodes": len(current_rpg_nodes), "current_dep_nodes": len(current_dep_nodes), + "current_rpg_edges": len(current_rpg_edges), "current_dep_edges": len(current_dep_edges), } nodes_view = _build_nodes_view( From 1fc9983d0af99d960f0fc04cbb865c172aede149 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 8 Jul 2026 04:13:45 +0000 Subject: [PATCH 37/56] Update rpg_edit command. --- CoderMind/templates/commands/rpg_edit.md | 27 ++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/CoderMind/templates/commands/rpg_edit.md b/CoderMind/templates/commands/rpg_edit.md index 7fbc05b..4aa19e5 100644 --- a/CoderMind/templates/commands/rpg_edit.md +++ b/CoderMind/templates/commands/rpg_edit.md @@ -243,7 +243,7 @@ replies above before proceeding. Treat any other free-form reply as All work in this step happens on a fresh `rpg-edit/` branch 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 +branch the user was on when the command started — only after Step 5d tests pass, so a failed run leaves `` clean and the branch preserved for inspection. @@ -330,16 +330,20 @@ git add -A && git commit --amend --no-edit cmind script smoke_test.py --json ``` -1. **Impact review** — run targeted tests and verify affected functionality: +1. **Impact review + final Explain View** — run targeted tests, verify affected functionality, and publish the final `rpg_edit` report: ```bash cmind script rpg_edit/review.py --report-scope final --json ``` -The outer `rpg_edit` flow owns the sole user-visible final report; +Use `--report-scope final` exactly once for the outer `rpg_edit` flow. +It publishes the sole user-visible `CoderMind rpg_edit Explain View`; intermediate or nested review runs should use `--report-scope internal` or `--no-report` so their artifacts can be linked without publishing a -second final report. +second final report. Record `report_path` from stdout for Step 6; if it +is absent, use `published_to`. If `report_error` is present, keep +reviewing the JSON result but mention the report generation failure in +Step 6. The review script reads the plan and impact JSON from their default home-dir locations and automatically: @@ -348,6 +352,19 @@ home-dir locations and automatically: - Runs pytest on matching test files - Dispatches a sub-agent to verify affected callers (if impact is large enough) +The final Explain View is the canonical audit surface for `rpg_edit` and +the reference shape for other CoderMind command reports. When polishing +reports for other commands, preserve the shared report surfaces from this +stage — artifact links, summary cards, workflow timeline, verification, +retrievals, and code/graph deltas — and only replace or extend the +command-specific evidence sections. For `rpg_edit`, it links the +`validate`, `locate`, `plan`, `impact`, `code_result`, `apply_result`, +and `review_result` artifacts (plus any `internal_report_*` artifacts) +and renders a **Focused graph** from `focused_view` showing selected RPG +feature groups, mapped dep_graph/code nodes, missing mappings, warnings, +hidden context, and the default focus. Do NOT create or link a separate +`focused_graph` artifact — it is now embedded in the final Explain View. + Check the output `type` field: - `"skipped"`: impact is small, pytest passed or no tests — review not needed @@ -378,6 +395,7 @@ visible in `git log --graph`. The user is returned to `` - **Success path** (Step 5e completed): > Merged `rpg-edit/` into `` (commit ``). + > Report: `` > To revert later: > - Code: `git revert -m 1 ` > - Graphs: `cmind script rpg_edit/apply.py --rollback --json` @@ -401,6 +419,7 @@ visible in `git log --graph`. The user is returned to `` Report to the user: > Tests failed. Branch `rpg-edit/` preserved for inspection. + > Report: `` (if generated) > `` is clean. Choose one of: > - Inspect: `git diff rpg-edit/` > - Discard code + graphs together: From 690c12434e2ea0326b7485c46b6e379f23a79202 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 8 Jul 2026 04:21:46 +0000 Subject: [PATCH 38/56] rpg_edit: Extend `_git_delta_files()` at lines 125-142 to return real --- CoderMind/scripts/update_graphs.py | 510 +++++++++++++++++++++++- CoderMind/tests/test_encode_commands.py | 193 ++++++++- 2 files changed, 673 insertions(+), 30 deletions(-) diff --git a/CoderMind/scripts/update_graphs.py b/CoderMind/scripts/update_graphs.py index 0f48fb2..f2e5a77 100644 --- a/CoderMind/scripts/update_graphs.py +++ b/CoderMind/scripts/update_graphs.py @@ -26,7 +26,7 @@ import sys import time from pathlib import Path -from typing import Optional +from typing import Any, Optional SCRIPTS_DIR = Path(__file__).resolve().parent if str(SCRIPTS_DIR) not in sys.path: @@ -34,7 +34,15 @@ 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_events import ( # noqa: E402 + ArtifactEvent, + CodeDeltaEvent, + CommandRun, + DepGraphDeltaEvent, + RPGDeltaEvent, + StepEvent, + VerificationEvent, +) from common.run_report import write_command_report # noqa: E402 @@ -122,12 +130,54 @@ def _format_diff_summary(summary: dict) -> str: return ", ".join(parts) +def _git_change_type(status: object) -> str: + code = str(status or "").upper() + if code.startswith("A"): + return "added" + if code.startswith("D"): + return "deleted" + if code.startswith("R"): + return "renamed" + if code.startswith("C"): + return "copied" + if code.startswith("T"): + return "typechanged" + if code.startswith("U"): + return "unmerged" + return "modified" if code.startswith("M") else (code.lower() or "changed") + + +def _git_diff_text(prev_ref: str, workspace_root: str, paths: list[str]) -> str: + import subprocess + + if not paths: + return "" + try: + return subprocess.check_output( + ["git", "diff", "--relative", f"{prev_ref}..HEAD", "--", *paths], + cwd=workspace_root, + stderr=subprocess.DEVNULL, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return "" + + def _git_delta_files(prev_ref: str, workspace_root: str) -> list[dict[str, str]]: import subprocess try: output = subprocess.check_output( - ["git", "diff", "--relative", "--name-status", f"{prev_ref}..HEAD", "--", "."], + [ + "git", + "diff", + "--relative", + "--name-status", + "--find-renames", + f"{prev_ref}..HEAD", + "--", + ".", + ], cwd=workspace_root, stderr=subprocess.DEVNULL, text=True, @@ -137,17 +187,367 @@ def _git_delta_files(prev_ref: str, workspace_root: str) -> list[dict[str, str]] 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]}) + if len(parts) < 2: + continue + status = parts[0] + path = parts[-1] + before = parts[1] if status.upper().startswith(("R", "C")) and len(parts) >= 3 else path + diff_paths = [before, path] if before != path else [path] + row = { + "status": status, + "change_type": _git_change_type(status), + "path": path, + "file": path, + "diff": _git_diff_text(prev_ref, workspace_root, diff_paths), + } + if before != path: + row["before"] = before + row["after"] = path + elif row["change_type"] == "added": + row["after"] = path + elif row["change_type"] == "deleted": + row["before"] = path + files.append(row) return files +def _listify(value: Any) -> list[Any]: + if value in (None, ""): + return [] + if isinstance(value, dict): + return list(value.keys()) + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + +def _ordered_unique_text(values: list[Any]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for value in values: + if value in (None, ""): + continue + text = str(value) + if text in seen: + continue + seen.add(text) + ordered.append(text) + return ordered + + +def _code_delta_rows(result: dict) -> list[dict[str, Any]]: + rows = result.get("code_deltas") or result.get("git_delta") or [] + normalized: list[dict[str, Any]] = [] + for row in _listify(rows): + if isinstance(row, dict): + path = row.get("file") or row.get("path") or row.get("after") or row.get("before") + if path in (None, ""): + continue + item = dict(row) + item["file"] = path + item["path"] = path + item["change_type"] = item.get("change_type") or _git_change_type(item.get("status")) + item.setdefault("diff", "") + normalized.append(item) + elif row not in (None, ""): + normalized.append({"file": str(row), "path": str(row), "change_type": "changed", "diff": ""}) + return normalized + + +def _diff_files(result: dict) -> list[str]: + diff_files = result.get("diff_files") + files: list[Any] = [] + if isinstance(diff_files, dict): + for value in diff_files.values(): + if isinstance(value, dict): + files.extend(value.keys()) + else: + files.extend(_listify(value)) + return _ordered_unique_text(files) + + +def _changed_report_files(result: dict, code_deltas: list[dict[str, Any]]) -> list[str]: + files: list[Any] = _diff_files(result) + for row in code_deltas: + files.append(row.get("file") or row.get("path") or row.get("after") or row.get("before")) + return _ordered_unique_text(files) + + +def _load_report_rpg(result: dict) -> dict[str, Any]: + rpg_path = result.get("output_path") or result.get("rpg_path") + if not rpg_path: + return {} + try: + return safe_load_rpg(Path(str(rpg_path))) + except Exception: + return {} + + +def _flatten_rpg_tree(root: Any) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: + nodes: dict[str, dict[str, Any]] = {} + paths: dict[str, str] = {} + + def visit(node: Any, ancestors: list[str]) -> None: + if not isinstance(node, dict): + return + node_id = node.get("id") or node.get("node_id") + name = node.get("name") or node_id + next_ancestors = ancestors + ([str(name)] if name not in (None, "") else []) + if node_id not in (None, ""): + node_id_text = str(node_id) + nodes[node_id_text] = node + paths[node_id_text] = " / ".join(next_ancestors) + for child in node.get("children") or []: + visit(child, next_ancestors) + + visit(root, []) + return nodes, paths + + +def _dep_node_items(dep_nodes: Any) -> list[tuple[str, dict[str, Any]]]: + if isinstance(dep_nodes, dict): + return [(str(node_id), attrs if isinstance(attrs, dict) else {}) for node_id, attrs in dep_nodes.items()] + if isinstance(dep_nodes, list): + rows = [] + for index, attrs in enumerate(dep_nodes): + if not isinstance(attrs, dict): + continue + node_id = attrs.get("id") or attrs.get("node_id") or attrs.get("dep_node_id") or index + rows.append((str(node_id), attrs)) + return rows + return [] + + +def _dep_node_path(dep_id: str, attrs: dict[str, Any]) -> str: + for key in ("path", "file", "module", "code_path"): + value = attrs.get(key) + if value not in (None, ""): + text = str(value) + if key == "code_path" and os.path.isabs(text): + try: + return os.path.relpath(text, os.getcwd()) + except ValueError: + return text + return text + return dep_id.split(":", 1)[0] + + +def _dep_node_matches_file(dep_id: str, attrs: dict[str, Any], file_path: str) -> bool: + candidates = {dep_id, dep_id.split(":", 1)[0], _dep_node_path(dep_id, attrs)} + for key in ("path", "file", "module", "code_path"): + value = attrs.get(key) + if value not in (None, ""): + candidates.add(str(value)) + for candidate in candidates: + if candidate == file_path or candidate.endswith("/" + file_path): + return True + if candidate.startswith(file_path + ":"): + return True + return False + + +def _rpg_node_row(node_id: str, node: dict[str, Any], paths: dict[str, str], changed_files: list[str]) -> dict[str, Any]: + meta = node.get("meta") if isinstance(node.get("meta"), dict) else {} + return { + "node_id": node_id, + "name": node.get("name") or node_id, + "type": node.get("node_type") or node.get("type") or meta.get("type_name"), + "node_type": node.get("node_type") or node.get("type") or meta.get("type_name"), + "path": meta.get("path") or node.get("path"), + "feature_path": paths.get(node_id, ""), + "breadcrumb_path": paths.get(node_id, ""), + "change": "semantic", + "changed_files": changed_files, + "mapping_status": "mapped", + } + + +def _dep_node_row(dep_id: str, attrs: dict[str, Any], changed_files: list[str], mapped_rpg_ids: list[str]) -> dict[str, Any]: + row: dict[str, Any] = { + "node_id": dep_id, + "dep_node_id": dep_id, + "path": _dep_node_path(dep_id, attrs), + "symbol": attrs.get("name") or dep_id.rsplit(":", 1)[-1], + "type": attrs.get("type") or attrs.get("kind"), + "change": "code", + "changed_files": changed_files, + "source": "dep_graph", + } + if attrs.get("signature") not in (None, ""): + row["signature"] = attrs.get("signature") + start = attrs.get("start_line") or attrs.get("lineno") or attrs.get("line") + end = attrs.get("end_line") or start + if start not in (None, ""): + row["line_range"] = {"start": start, "end": end} + if mapped_rpg_ids: + row["mapped_rpg_node_ids"] = mapped_rpg_ids + return row + + +def _edge_rows(dep_edges: Any, code_ids: set[str]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + if not isinstance(dep_edges, list): + return rows + for edge in dep_edges: + if not isinstance(edge, dict): + continue + source = edge.get("src") or edge.get("source") or edge.get("from") + target = edge.get("dst") or edge.get("target") or edge.get("to") + if source not in code_ids and target not in code_ids: + continue + attrs = edge.get("attrs") if isinstance(edge.get("attrs"), dict) else {} + rows.append({ + "source_node_id": source, + "target_node_id": target, + "relation": edge.get("relation") or edge.get("type") or attrs.get("type") or "dependency", + "source_graph": "dep_graph", + "edge_source": "dep_graph", + "reason": "adjacent to changed code", + }) + return rows[:50] + + +def _build_update_focus(result: dict, code_deltas: list[dict[str, Any]], semantic_total: int) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]: + changed_files = _changed_report_files(result, code_deltas) + rpg_data = _load_report_rpg(result) + dep_graph = rpg_data.get("dep_graph") if isinstance(rpg_data.get("dep_graph"), dict) else {} + dep_to_rpg = rpg_data.get("_dep_to_rpg_map") if isinstance(rpg_data.get("_dep_to_rpg_map"), dict) else {} + rpg_nodes, rpg_paths = _flatten_rpg_tree(rpg_data.get("root")) + matched_dep_ids: set[str] = set() + code_nodes: list[dict[str, Any]] = [] + semantic_by_id: dict[str, dict[str, Any]] = {} + mappings: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + + if semantic_total == 0: + warnings.append({ + "type": "zero_semantic_delta", + "message": "RPG semantic delta 为 0", + "reason": "diff_summary contains no added, deleted, modified, or renamed semantic files", + }) + + dep_items = _dep_node_items(dep_graph.get("nodes") if isinstance(dep_graph, dict) else {}) + for changed_file in changed_files: + file_matched = False + for dep_id, attrs in dep_items: + if not _dep_node_matches_file(dep_id, attrs, changed_file): + continue + file_matched = True + if dep_id in matched_dep_ids: + continue + mapped_rpg_ids = _ordered_unique_text(_listify(attrs.get("rpg_nodes")) + _listify(dep_to_rpg.get(dep_id))) + code_nodes.append(_dep_node_row(dep_id, attrs, [changed_file], mapped_rpg_ids)) + matched_dep_ids.add(dep_id) + for rpg_id in mapped_rpg_ids: + node = rpg_nodes.get(rpg_id) + if not isinstance(node, dict): + continue + semantic_by_id.setdefault(rpg_id, _rpg_node_row(rpg_id, node, rpg_paths, [changed_file])) + mappings.append({ + "rpg_node_id": rpg_id, + "code_node_id": dep_id, + "dep_node_id": dep_id, + "status": "mapped", + "state": "mapped", + "source": "rpg_json", + "path": _dep_node_path(dep_id, attrs), + "changed_files": [changed_file], + }) + if not file_matched: + warnings.append({ + "type": "unmapped_changed_file", + "message": "changed file has no dep_graph node in updated RPG JSON", + "path": changed_file, + }) + + semantic_nodes = list(semantic_by_id.values()) + dep_edges = _edge_rows(dep_graph.get("edges") if isinstance(dep_graph, dict) else [], matched_dep_ids) + unmatched_files = set(changed_files) + for node in code_nodes: + unmatched_files.difference_update(str(path) for path in _listify(node.get("changed_files"))) + unmatched_code_deltas = [row for row in code_deltas if (row.get("file") or row.get("path")) in unmatched_files] + summary = { + "selected_feature_groups": len(semantic_nodes), + "primary_rpg_nodes": len(semantic_nodes), + "primary_code_nodes": len(code_nodes), + "mapped_code_relations": len(mappings), + "missing_mappings": len([node for node in code_nodes if not node.get("mapped_rpg_node_ids")]), + "edges": len(dep_edges), + "warnings": len(warnings), + "changed_files": len(changed_files), + "semantic_delta": semantic_total, + } + nodes_view = { + "summary": { + "semantic_nodes": len(semantic_nodes), + "code_nodes": len(code_nodes), + "mappings": len(mappings), + "edges": len(dep_edges), + "warnings": len(warnings), + "changed_files": len(changed_files), + }, + "semantic_nodes": semantic_nodes, + "code_nodes": code_nodes, + "mappings": mappings, + "edges": dep_edges, + "warnings": warnings, + "changed_files": [{"path": file_path} for file_path in changed_files], + "hidden_counts": {}, + } + focused_view = { + "summary": summary, + "nodes_view": nodes_view, + "primary_rpg_nodes": semantic_nodes, + "primary_code_nodes": code_nodes, + "mappings": mappings, + "edges": dep_edges, + "hidden_counts": {}, + "warnings": warnings, + "changed_files": changed_files, + "unmatched_code_deltas": unmatched_code_deltas, + } + return focused_view, semantic_nodes, code_nodes + + +def _hook_context(result: dict) -> dict[str, Any]: + return { + "CMIND_HOOK": result.get("CMIND_HOOK", os.environ.get("CMIND_HOOK", "")), + "hook_calls_log": result.get("hook_calls_log") or str(HOOK_CALLS_LOG), + "update_rpg_log": result.get("update_rpg_log") or str(HOOK_CALLS_LOG.parent / "update_rpg.log"), + } + + +def _commit_range(result: dict) -> dict[str, Any]: + return { + "prev_ref": result.get("prev_ref"), + "previous_commit": result.get("previous_commit"), + "new_commit": result.get("new_commit"), + } + + +def _commit_range_reason(commit_range: dict[str, Any]) -> str: + parts = [f"{key}={value}" for key, value in commit_range.items() if value not in (None, "")] + return ", ".join(parts) if parts else "not recorded" + + +def _dep_delta_detail(result: dict) -> str: + parts = [] + if result.get("dep_nodes_delta") not in (None, ""): + parts.append(f"nodes_delta={result.get('dep_nodes_delta'):+d}" if isinstance(result.get("dep_nodes_delta"), int) else f"nodes_delta={result.get('dep_nodes_delta')}") + if result.get("dep_edges_delta") not in (None, ""): + parts.append(f"edges_delta={result.get('dep_edges_delta'):+d}" if isinstance(result.get("dep_edges_delta"), int) else f"edges_delta={result.get('dep_edges_delta')}") + if result.get("dep_to_rpg_map_size") not in (None, ""): + parts.append(f"dep_to_rpg_map_size={result.get('dep_to_rpg_map_size')}") + return ", ".join(parts) + + def _attach_update_report(result: dict) -> dict: try: semantic_summary = _diff_summary(result) semantic_total = sum(semantic_summary.values()) + code_deltas = _code_delta_rows(result) git_delta = result.get("git_delta") - git_total = _change_count(git_delta) if git_delta is not None else "" + git_total = _change_count(git_delta) if git_delta is not None else _change_count(code_deltas) node_count = result.get("node_count", result.get("rpg_nodes", "")) rpg_path = result.get("output_path") or result.get("rpg_path") dep_summary = "" @@ -165,13 +565,35 @@ def _attach_update_report(result: dict) -> dict: result.get("dep_edges_delta"), ) ) + semantic_detail = "RPG semantic delta 为 0" if semantic_total == 0 else _format_diff_summary(semantic_summary) + dep_detail = _dep_delta_detail(result) + focused_view, rpg_delta_rows, dep_delta_rows = _build_update_focus(result, code_deltas, semantic_total) + hook_context = _hook_context(result) + commit_range = _commit_range(result) viz_status = result.get("viz_error") or ( "ok" if result.get("viz_path") else "not recorded" ) - report_path = write_command_report(CommandRun( + status = result.get("status") or ("error" if result.get("error") else result.get("mode")) + evidence = dict(result) + evidence.update({ + "code_deltas": code_deltas, + "semantic_summary": semantic_summary, + "semantic_total": semantic_total, + "focused_view_summary": focused_view.get("summary", {}), + "commit_range": commit_range, + "commit_range_reason": _commit_range_reason(commit_range), + "hook_context": hook_context, + "artifact_paths": [ + {"label": "rpg_json", "path": rpg_path}, + {"label": "rpg_html", "path": result.get("viz_path")}, + {"label": "hook_calls_log", "path": hook_context["hook_calls_log"]}, + {"label": "update_rpg_log", "path": hook_context["update_rpg_log"]}, + ], + }) + report_run = CommandRun( command="update_rpg", title="CoderMind update_rpg Explain View", - status=result.get("status") or ("error" if result.get("error") else result.get("mode")), + status=status, summary=[ {"label": "mode", "value": result.get("mode", "")}, { @@ -179,15 +601,16 @@ def _attach_update_report(result: dict) -> dict: "value": result.get("reason") or result.get("error", ""), }, {"label": "git files", "value": git_total}, - {"label": "semantic files", "value": semantic_total}, + {"label": "semantic files", "value": semantic_total, "detail": semantic_detail}, { "label": "RPG nodes", "value": _format_count_delta( node_count, result.get("nodes_delta"), ), + "detail": f"edges={_format_count_delta(result.get('edge_count'), result.get('edges_delta'))}" if result.get("edge_count") not in (None, "") else "", }, - {"label": "dep graph", "value": dep_summary}, + {"label": "dep graph", "value": dep_summary, "detail": dep_detail}, { "label": "visualization", "value": result.get("viz_path") or result.get("viz_error", ""), @@ -205,12 +628,26 @@ def _attach_update_report(result: dict) -> dict: ), StepEvent( name="semantic delta", - status=result.get("mode", ""), - reason=_format_diff_summary(semantic_summary), + status="warning" if semantic_total == 0 else result.get("mode", ""), + reason=semantic_detail, + ), + StepEvent( + name="commit range", + status=status, + reason=_commit_range_reason(commit_range), + ), + StepEvent( + name="hook context", + status="recorded", + reason=( + f"CMIND_HOOK={hook_context['CMIND_HOOK'] or 'not set'}, " + f"hook_calls_log={hook_context['hook_calls_log']}, " + f"update_rpg_log={hook_context['update_rpg_log']}" + ), ), StepEvent( name="sync graph", - status=result.get("status") or ("error" if result.get("error") else result.get("mode", "")), + status=status, reason=result.get("reason", ""), ), StepEvent( @@ -228,13 +665,48 @@ def _attach_update_report(result: dict) -> dict: artifacts=[ ArtifactEvent(label="rpg_json", path=rpg_path), ArtifactEvent(label="rpg_html", path=result.get("viz_path")), + ArtifactEvent(label="hook_calls_log", path=hook_context["hook_calls_log"]), + ArtifactEvent(label="update_rpg_log", path=hook_context["update_rpg_log"]), + ], + rpg_deltas=[ + RPGDeltaEvent( + node_id=row.get("node_id"), + name=row.get("name"), + type=row.get("node_type") or row.get("type"), + path=row.get("path") or row.get("feature_path"), + change=row.get("change"), + ) + for row in rpg_delta_rows + ], + dep_graph_deltas=[ + DepGraphDeltaEvent( + dep_node_id=row.get("dep_node_id") or row.get("node_id"), + path=row.get("path"), + source_feature=", ".join(_ordered_unique_text(_listify(row.get("mapped_rpg_node_ids")))), + change=row.get("change"), + ) + for row in dep_delta_rows + ], + code_deltas=[ + CodeDeltaEvent( + file=row.get("file") or row.get("path"), + 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="update_rpg", status=result.get("status") or ("error" if result.get("error") else result.get("mode"))), - VerificationEvent(name="viz", status=viz_status), + VerificationEvent(name="update_rpg", status=status, detail=result.get("reason") or result.get("error")), + VerificationEvent(name="viz", status=viz_status, detail=result.get("viz_path") or result.get("viz_error")), + VerificationEvent(name="semantic_delta", status="warning" if semantic_total == 0 else "recorded", detail=semantic_detail), + VerificationEvent(name="commit_range", status="recorded", detail=_commit_range_reason(commit_range)), ], - evidence=result, - )) + evidence=evidence, + ).to_dict() + report_run["focused_view"] = focused_view + report_path = write_command_report(report_run) result["report_path"] = str(report_path) except Exception as exc: result["report_error"] = str(exc) @@ -687,6 +1159,10 @@ def cmd_update_rpg( result["mode"] = "update-rpg" result["prev_ref"] = prev_ref result["git_delta"] = git_delta + result["code_deltas"] = git_delta + result["CMIND_HOOK"] = os.environ.get("CMIND_HOOK", "") + result["hook_calls_log"] = str(HOOK_CALLS_LOG) + result["update_rpg_log"] = str(HOOK_CALLS_LOG.parent / "update_rpg.log") # 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 391cce0..44731be 100644 --- a/CoderMind/tests/test_encode_commands.py +++ b/CoderMind/tests/test_encode_commands.py @@ -400,48 +400,215 @@ def test_missing_cur_repo_dir(self, tmp_rpg_file): def test_attach_update_report_uses_update_rpg_result_fields(tmp_path, monkeypatch): import update_graphs + rpg_path = tmp_path / "rpg.json" + rpg_path.write_text(json.dumps({ + "repo_name": "test_repo", + "root": { + "id": "root", + "name": "test_repo", + "node_type": "root", + "meta": {"path": "."}, + "children": [ + { + "id": "feature_a", + "name": "Refresh graph visualization", + "node_type": "feature", + "meta": {"path": "scripts/a.py:f"}, + "children": [], + } + ], + }, + "_dep_to_rpg_map": { + "scripts/a.py": ["feature_a"], + "scripts/a.py:f": ["feature_a"], + }, + "dep_graph": { + "nodes": { + "scripts/a.py": { + "type": "file", + "name": "a.py", + "path": "scripts/a.py", + "rpg_nodes": ["feature_a"], + }, + "scripts/a.py:f": { + "type": "function", + "name": "f", + "path": "scripts/a.py", + "start_line": 10, + "end_line": 12, + "rpg_nodes": ["feature_a"], + }, + "tests/test_a.py": { + "type": "file", + "name": "test_a.py", + "path": "tests/test_a.py", + }, + }, + "edges": [ + { + "src": "scripts/a.py:f", + "dst": "tests/test_a.py", + "attrs": {"type": "imports"}, + } + ], + }, + }), encoding="utf-8") + viz_path = tmp_path / "rpg.html" + viz_path.write_text("", encoding="utf-8") captured = {} def fake_write_command_report(run): - captured.update(run.to_dict()) + captured.update(run.to_dict() if hasattr(run, "to_dict") else dict(run)) return tmp_path / "report.html" + monkeypatch.setenv("CMIND_HOOK", "PostCommit") 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", + "output_path": str(rpg_path), "node_count": 4504, + "edge_count": 15000, "nodes_delta": 2, + "edges_delta": 7, "dep_nodes": 2708, "dep_nodes_delta": 46, "dep_edges": 5498, "dep_edges_delta": 103, + "dep_to_rpg_map_size": 2, "diff_summary": { "added": 0, "deleted": 0, "modified": 3, "renamed": 0, }, + "diff_files": { + "added": [], + "deleted": [], + "modified": ["scripts/a.py", "tests/test_a.py"], + "renamed": [], + }, "git_delta": [ - {"status": "M", "path": "scripts/a.py"}, - {"status": "M", "path": "tests/test_a.py"}, + {"status": "M", "change_type": "modified", "path": "scripts/a.py", "diff": "diff --git a/scripts/a.py b/scripts/a.py\n+new"}, + {"status": "M", "change_type": "modified", "path": "tests/test_a.py", "diff": "diff --git a/tests/test_a.py b/tests/test_a.py\n+test"}, ], - "viz_path": "/tmp/rpg.html", + "prev_ref": "prev123", + "previous_commit": "old456", + "new_commit": "new789", + "viz_path": str(viz_path), }) - cards = {card["label"]: card["value"] for card in captured["summary"]} + cards = {card["label"]: card for card in captured["summary"]} artifacts = {artifact["label"]: artifact["path"] for artifact in captured["artifacts"]} + steps = {step["name"]: step for step in captured["steps"]} + evidence = captured["evidence"] + focused = captured["focused_view"] + nodes_view = focused["nodes_view"] + 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 artifacts["rpg_json"] == "/tmp/rpg.json" + assert cards["git files"]["value"] == 2 + assert cards["semantic files"]["value"] == 3 + assert cards["semantic files"]["detail"] == "3 semantic files, modified=3" + assert cards["RPG nodes"]["value"] == "4504 (delta: +2)" + assert cards["RPG nodes"]["detail"] == "edges=15000 (delta: +7)" + assert cards["dep graph"]["value"] == "nodes=2708 (delta: +46), edges=5498 (delta: +103)" + assert "dep_to_rpg_map_size=2" in cards["dep graph"]["detail"] + assert artifacts["rpg_json"] == str(rpg_path) + assert artifacts["rpg_html"] == str(viz_path) + assert artifacts["hook_calls_log"].endswith("hook_calls.jsonl") + assert artifacts["update_rpg_log"].endswith("update_rpg.log") assert captured["status"] == "success" - assert captured["steps"][0]["reason"] == "2 changed files" - assert captured["steps"][1]["reason"] == "3 semantic files, modified=3" + assert steps["git delta"]["reason"] == "2 changed files" + assert steps["semantic delta"]["reason"] == "3 semantic files, modified=3" + assert "prev_ref=prev123" in steps["commit range"]["reason"] + assert "previous_commit=old456" in steps["commit range"]["reason"] + assert "new_commit=new789" in steps["commit range"]["reason"] + assert "CMIND_HOOK=PostCommit" in steps["hook context"]["reason"] + assert captured["code_deltas"][0]["file"] == "scripts/a.py" + assert captured["code_deltas"][0]["change_type"] == "modified" + assert "+new" in captured["code_deltas"][0]["diff"] + assert captured["rpg_deltas"][0]["node_id"] == "feature_a" + assert captured["rpg_deltas"][0]["name"] == "Refresh graph visualization" + assert {row["path"] for row in captured["dep_graph_deltas"]} >= {"scripts/a.py", "tests/test_a.py"} + assert focused["summary"]["primary_rpg_nodes"] == 1 + assert focused["summary"]["primary_code_nodes"] == 3 + assert focused["summary"]["mapped_code_relations"] == 2 + assert focused["summary"]["missing_mappings"] == 1 + assert focused["warnings"] == [] + assert nodes_view["summary"]["semantic_nodes"] == 1 + assert nodes_view["summary"]["code_nodes"] == 3 + assert nodes_view["summary"]["mappings"] == 2 + assert nodes_view["semantic_nodes"][0]["node_id"] == "feature_a" + assert nodes_view["code_nodes"][1]["line_range"] == {"start": 10, "end": 12} + assert evidence["code_deltas"][1]["file"] == "tests/test_a.py" + assert evidence["semantic_summary"] == {"added": 0, "deleted": 0, "modified": 3, "renamed": 0} + assert evidence["commit_range"] == { + "prev_ref": "prev123", + "previous_commit": "old456", + "new_commit": "new789", + } + assert evidence["commit_range_reason"] == "prev_ref=prev123, previous_commit=old456, new_commit=new789" + assert evidence["hook_context"]["CMIND_HOOK"] == "PostCommit" + assert evidence["hook_context"]["hook_calls_log"].endswith("hook_calls.jsonl") + assert evidence["hook_context"]["update_rpg_log"].endswith("update_rpg.log") + + +def test_attach_update_report_warns_on_zero_semantic_delta_without_inventing_nodes(tmp_path, monkeypatch): + import update_graphs + + captured = {} + + def fake_write_command_report(run): + captured.update(run.to_dict() if hasattr(run, "to_dict") else dict(run)) + return tmp_path / "zero.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": str(tmp_path / "missing-rpg.json"), + "node_count": 4504, + "nodes_delta": 0, + "dep_nodes": 2708, + "dep_nodes_delta": 0, + "dep_edges": 5498, + "dep_edges_delta": 0, + "diff_summary": { + "added": 0, + "deleted": 0, + "modified": 0, + "renamed": 0, + }, + "git_delta": [ + {"status": "M", "change_type": "modified", "path": "docs/readme.md", "diff": "+doc"}, + ], + "prev_ref": "prev123", + "previous_commit": "old456", + "new_commit": "new789", + }) + + cards = {card["label"]: card for card in captured["summary"]} + steps = {step["name"]: step for step in captured["steps"]} + focused = captured["focused_view"] + nodes_view = focused["nodes_view"] + assert result["report_path"] == str(tmp_path / "zero.html") + assert cards["semantic files"]["value"] == 0 + assert cards["semantic files"]["detail"] == "RPG semantic delta 为 0" + assert steps["semantic delta"]["status"] == "warning" + assert steps["semantic delta"]["reason"] == "RPG semantic delta 为 0" + assert captured["verification"][2]["detail"] == "RPG semantic delta 为 0" + assert focused["summary"]["primary_rpg_nodes"] == 0 + assert focused["summary"]["primary_code_nodes"] == 0 + assert nodes_view["semantic_nodes"] == [] + assert nodes_view["code_nodes"] == [] + assert nodes_view["mappings"] == [] + assert captured.get("rpg_deltas", []) == [] + assert captured.get("dep_graph_deltas", []) == [] + assert any(warning["message"] == "RPG semantic delta 为 0" for warning in focused["warnings"]) + assert any(warning["type"] == "unmapped_changed_file" for warning in focused["warnings"]) + assert focused["unmatched_code_deltas"][0]["file"] == "docs/readme.md" # ============================================================================ From 657461bd92666b7363321275c5abc4860d4c5dd3 Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 8 Jul 2026 05:59:09 +0000 Subject: [PATCH 39/56] rpg_edit: At lines 1519-1567, remove the focused graph slicing by _FOC --- CoderMind/scripts/common/run_report.py | 172 +++++++++++-- CoderMind/scripts/rpg_edit/review.py | 323 +++++++++++++++++++------ CoderMind/tests/test_run_report.py | 109 +++++++-- 3 files changed, 487 insertions(+), 117 deletions(-) diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index 36bc287..18139ac 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -407,10 +407,16 @@ def _render_page( .focused-graph-fallback {{ margin:12px; padding:12px; border:1px dashed var(--line); border-radius:10px; background:#fff; color:var(--muted); }} .focused-graph-legend {{ display:flex; flex-wrap:wrap; gap:8px; margin:8px 0 12px; }} .legend-item {{ display:inline-flex; gap:6px; align-items:center; color:var(--muted); font-size:13px; }} -.legend-swatch {{ width:10px; height:10px; border-radius:999px; display:inline-block; border:1px solid var(--line); }} +.legend-swatch {{ width:10px; height:10px; border-radius:999px; display:inline-block; border:1px solid var(--line); flex:0 0 auto; }} +.legend-line {{ width:28px; height:0; border-radius:0; border:0; border-top:2px solid var(--line); background:transparent; }} .legend-node {{ background:#2563eb; }} -.legend-semantic-edge {{ background:#7c3aed; }} -.legend-dependency-edge {{ background:#ea580c; }} +.legend-tree-link {{ border-top-color:#cbd5e1; border-top-width:1.4px; }} +.legend-semantic-edge {{ border-top-color:#7c3aed; }} +.legend-dependency-edge, .legend-imports-edge {{ border-top-color:#ea580c; }} +.legend-invokes-edge, .legend-caller-edge, .legend-callee-edge {{ border-top-color:#16a34a; }} +.legend-inherits-edge {{ border-top-color:#9333ea; }} +.legend-references-edge {{ border-top-color:#2563eb; }} +.legend-dep-graph-edge {{ border-top-style:dashed; }} @media (max-width:720px) {{ main {{ padding:22px 12px 36px; }} .focus-map {{ grid-template-columns:1fr; }} table {{ min-width:560px; }} }} @@ -679,12 +685,9 @@ def _chain_edge_html(edges: Sequence[Mapping[str, Any]]) -> str: def _combined_hidden_counts(hidden_counts: Mapping[str, Any]) -> list[tuple[str, int]]: relation_keys = {"caller": "callers", "callee": "callees", "import": "imports", "inheritance": "inheritance"} - hidden_relations = hidden_counts.get("relations") if isinstance(hidden_counts.get("relations"), Mapping) else {} rows: list[tuple[str, int]] = [] for relation, count_key in relation_keys.items(): count = hidden_counts.get(count_key) or 0 - if isinstance(hidden_relations, Mapping): - count += hidden_relations.get(relation, 0) or 0 try: count_int = int(count) except (TypeError, ValueError): @@ -698,13 +701,6 @@ def _hidden_context_html(hidden_counts: Mapping[str, Any]) -> str: parts = [] for relation, count in _combined_hidden_counts(hidden_counts): parts.append(f"

    Hidden {_h(count)} additional {_h(relation)} neighbors.

    ") - cap_rows = [] - for key in ("primary_rpg_nodes", "primary_code_nodes", "edges"): - value = hidden_counts.get(key) - if value: - cap_rows.append(f"{_h(key)}{_h(value)}") - if cap_rows: - parts.append("" + "".join(cap_rows) + "
    ") return "".join(parts) @@ -969,6 +965,8 @@ def _append_hierarchy_nodes( links: list[dict[str, Any]], seen_nodes: set[str], hierarchy: Any, + file_anchors: Mapping[str, str], + node_metadata_by_id: Mapping[str, Mapping[str, Any]], ) -> None: if not isinstance(hierarchy, Mapping): return @@ -983,6 +981,7 @@ def visit(row: Any) -> str: row_id = node_id(row) if row_id and row_id not in seen_nodes: seen_nodes.add(row_id) + metadata = node_metadata_by_id.get(row_id) or node_metadata_by_id.get(str(row.get("node_id") or "")) or {} payload = { "id": row_id, "kind": row.get("kind") or "feature", @@ -1003,9 +1002,15 @@ def visit(row: Any) -> str: "mapped_code_symbol", "mapped_code_symbols", "mapped_code_count", + "changed_files", + "diff_anchor", ): - if row.get(key) not in (None, ""): - payload[key] = row.get(key) + value = row.get(key) if row.get(key) not in (None, "") else metadata.get(key) + if value not in (None, ""): + payload[key] = value + diff_ref = _graph_diff_ref(payload, file_anchors) + if diff_ref: + payload["diff"] = diff_ref nodes.append(payload) for child in [child for child in _as_sequence(row.get("children")) if isinstance(child, Mapping)]: child_id = visit(child) @@ -1021,7 +1026,8 @@ def visit(row: Any) -> str: def _focused_graph_payload(focused_view: Mapping[str, Any], file_anchors: Mapping[str, str]) -> dict[str, Any]: nodes_view = focused_view.get("nodes_view") if isinstance(focused_view.get("nodes_view"), Mapping) else {} - summary = nodes_view.get("summary") if isinstance(nodes_view.get("summary"), Mapping) else focused_view.get("summary", {}) + summary_source = nodes_view.get("summary") if isinstance(nodes_view.get("summary"), Mapping) else focused_view.get("summary", {}) + summary = dict(summary_source) if isinstance(summary_source, Mapping) else {} semantic_nodes = [node for node in _as_sequence(nodes_view.get("semantic_nodes")) if isinstance(node, Mapping)] code_nodes = [node for node in _as_sequence(nodes_view.get("code_nodes")) if isinstance(node, Mapping)] mappings = [mapping for mapping in _as_sequence(nodes_view.get("mappings")) if isinstance(mapping, Mapping)] @@ -1058,10 +1064,95 @@ def _focused_graph_payload(focused_view: Mapping[str, Any], file_anchors: Mappin ], } + semantic_link_by_node = { + str(node.get("node_id")): str(node.get("link_id") or _graph_node_id(node, "feature")) + for node in semantic_nodes + if node.get("node_id") not in (None, "") + } + code_link_by_node = { + str(node.get("node_id") or node.get("dep_node_id")): str(node.get("link_id") or _graph_node_id(node, "code")) + for node in code_nodes + if (node.get("node_id") or node.get("dep_node_id")) not in (None, "") + } + node_metadata_by_id: dict[str, Mapping[str, Any]] = {} + + def remember_node_metadata(alias: Any, row: Mapping[str, Any]) -> None: + if alias in (None, ""): + return + node_metadata_by_id.setdefault(str(alias), row) + + for node in semantic_nodes: + node_id = node.get("node_id") + remember_node_metadata(node.get("link_id") or _graph_node_id(node, "feature"), node) + remember_node_metadata(node_id, node) + for node in code_nodes: + node_id = node.get("node_id") or node.get("dep_node_id") + remember_node_metadata(node.get("link_id") or _graph_node_id(node, "code"), node) + remember_node_metadata(node_id, node) + + def endpoint_link_id(edge: Mapping[str, Any], side: str) -> str: + node_id = edge.get(f"{side}_node_id") + node_text = str(node_id or "") + return str( + edge.get(f"{side}_link_id") + or semantic_link_by_node.get(node_text) + or code_link_by_node.get(node_text) + or _graph_node_id({"node_id": node_text}, "context") + ) + + def collect_hierarchy_ids(row: Any, ids: set[str]) -> None: + if not isinstance(row, Mapping): + return + row_id = row.get("id") or row.get("link_id") or row.get("node_id") + if row_id not in (None, ""): + ids.add(str(row_id)) + for child in _as_sequence(row.get("children")): + collect_hierarchy_ids(child, ids) + + def endpoint_group() -> dict[str, Any]: + children = hierarchy.setdefault("children", []) if isinstance(hierarchy, dict) else [] + group_id = "focused-graph-relation-endpoints" + for child in children: + if isinstance(child, dict) and child.get("id") == group_id: + return child + group = {"id": group_id, "name": "Relation endpoints", "kind": "context_group", "children": []} + children.append(group) + return group + + hierarchy_ids: set[str] = set() + collect_hierarchy_ids(hierarchy, hierarchy_ids) + for edge in context_edges: + for side in ("source", "target"): + link_id = endpoint_link_id(edge, side) + if not link_id or link_id in hierarchy_ids: + continue + node_id = edge.get(f"{side}_node_id") + node_text = str(node_id or link_id) + is_code = node_text in code_link_by_node or link_id in set(code_link_by_node.values()) + leaf: dict[str, Any] = { + "id": link_id, + "node_id": node_text, + "name": edge.get("name") or edge.get("path") or node_text, + "kind": "code" if is_code else "context", + "state": "mapped" if is_code else "context", + "aliases": _ordered_texts([node_text, link_id]), + } + if is_code: + code = next((row for row in code_nodes if str(row.get("node_id") or row.get("dep_node_id") or "") == node_text), {}) + for key in ("path", "symbol", "type", "line_range", "source", "changed_files", "diff_anchor"): + if isinstance(code, Mapping) and code.get(key) not in (None, ""): + leaf[key] = code.get(key) + else: + for key in ("relation", "direction", "path", "reason", "source", "source_graph", "edge_source", "relation_source"): + if edge.get(key) not in (None, ""): + leaf[key] = edge.get(key) + endpoint_group().setdefault("children", []).append(leaf) + hierarchy_ids.add(link_id) + nodes: list[dict[str, Any]] = [] links: list[dict[str, Any]] = [] seen_nodes: set[str] = set() - _append_hierarchy_nodes(nodes, links, seen_nodes, hierarchy) + _append_hierarchy_nodes(nodes, links, seen_nodes, hierarchy, file_anchors, node_metadata_by_id) rpg_links: dict[str, str] = {} code_links: dict[str, str] = {} @@ -1081,8 +1172,15 @@ def visit_hierarchy(row: Any) -> None: return row_id = str(row.get("id") or row.get("link_id") or row.get("node_id") or "") node_id = row.get("node_id") + kind = str(row.get("kind") or "") if node_id not in (None, "") and row_id: - rpg_links.setdefault(str(node_id), row_id) + if kind in {"code", "context"}: + add_alias(node_id, row_id) + add_alias(row_id, row_id) + else: + rpg_links.setdefault(str(node_id), row_id) + for alias in _as_sequence(row.get("aliases")): + add_alias(alias, row_id) for code_id in _as_sequence(row.get("mapped_code_node_ids")): add_alias(_graph_node_id({"node_id": code_id}, "code"), row_id) add_alias(code_id, row_id) @@ -1166,6 +1264,14 @@ def candidates(edge: Mapping[str, Any], side: str) -> list[str]: "path": edge.get("path"), }) + summary.update({ + "semantic_nodes": len(semantic_nodes), + "code_nodes": len(code_nodes), + "mappings": len(mappings), + "edges": len(context_edges), + "relation_edges": len(relation_edges), + "context_edges": len(context_edges), + }) links.extend(relation_edges) return { "schema": "cmind.focused_graph.render.v1", @@ -1193,6 +1299,7 @@ def _focused_graph_runtime() -> str: const dataEl = section.querySelector('[data-focused-graph-json]'); const svg = section.querySelector('[data-focused-graph-svg]'); const fallback = section.querySelector('[data-focused-graph-fallback]'); + const statusEl = section.querySelector('[data-focused-graph-status]'); if (!window.d3 || !dataEl || !svg) return; if (fallback) fallback.hidden = true; const data = JSON.parse(dataEl.textContent || '{}'); @@ -1368,6 +1475,13 @@ def _focused_graph_runtime() -> str: return `M${sx},${sy} Q${midX},${(sy + dy) / 2} ${dx},${dy}`; } + function updateStatus() { + if (!statusEl) return; + const visible = showEdges ? currentRelationEdges.length : 0; + const total = relationEdges.length; + statusEl.textContent = `Visible relation edges: ${visible}/${total}`; + } + function drawRelationEdges() { currentRelationEdges = []; if (showEdges) { @@ -1389,13 +1503,16 @@ def _focused_graph_runtime() -> str: .attr('d', edge => relationPath(edge._source, edge._target)); relUpdate.select('title').text(edge => `${edge.relation || 'dependency'}\n${edge.source_graph || edge.edge_source || edge.source_meta || edge.kind || ''}\n${edge.path || edge.reason || ''}`); rel.exit().remove(); + updateStatus(); } function update(source) { applySearchOpen(); const layout = treemap(root); currentNodes = layout.descendants(); - currentNodes.forEach(d => { d.y = d.depth * 250; }); + const maxDepth = d3.max(currentNodes, d => d.depth) || 1; + const depthGap = Math.max(140, Math.min(250, (width - 260) / maxDepth)); + currentNodes.forEach(d => { d.y = d.depth * depthGap; }); const minX = d3.min(currentNodes, d => d.x) || 0; if (minX < 20) currentNodes.forEach(d => { d.x += 20 - minX; }); nodeById = {}; @@ -1469,7 +1586,8 @@ def _render_focused_graph(focused_view: dict[str, Any], file_anchors: Mapping[st nodes_view = focused_view.get("nodes_view") if isinstance(focused_view.get("nodes_view"), Mapping) else {} if not focused_view and not nodes_view: return "" - summary = nodes_view.get("summary") if isinstance(nodes_view.get("summary"), Mapping) else focused_view.get("summary", {}) + graph_payload = _focused_graph_payload(focused_view, file_anchors) + summary = graph_payload.get("summary") if isinstance(graph_payload.get("summary"), Mapping) else {} summary_html = _summary_badges(summary, [ ("Semantic nodes", "semantic_nodes", len(_as_sequence(nodes_view.get("semantic_nodes")))), ("Code nodes", "code_nodes", len(_as_sequence(nodes_view.get("code_nodes")))), @@ -1481,7 +1599,7 @@ def _render_focused_graph(focused_view: dict[str, Any], file_anchors: Mapping[st hidden_html = _hidden_context_html(hidden_counts if isinstance(hidden_counts, Mapping) else {}) warnings = [warning for warning in _as_sequence(nodes_view.get("warnings") or focused_view.get("warnings")) if isinstance(warning, Mapping)] warnings_html = f"

    Warnings

    {_chain_warning_html(warnings)}" if warnings else "" - graph_payload = _focused_graph_payload(focused_view, file_anchors) + relation_edge_total = len(_as_sequence(graph_payload.get("relation_edges") or graph_payload.get("edges") or graph_payload.get("links"))) graph_json = _json_for_script(graph_payload) d3_js = _inline_d3() fallback_hidden = " hidden" if d3_js else "" @@ -1499,13 +1617,21 @@ def _render_focused_graph(focused_view: dict[str, Any], file_anchors: Mapping[st '' '' '' + f'Visible relation edges: 0/{relation_edge_total}' '' ) legend = ( '
    ' 'Feature tree node' - 'RPG semantic edge' - 'dep_graph dependency edge' + 'Tree link' + 'RPG semantic edge' + 'dep_graph dependency edge' + 'invokes' + 'imports' + 'inherits' + 'references' + 'caller' + 'callee' '
    ' ) return ( diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 75f80d1..5160a33 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -511,6 +511,15 @@ def _focused_graph_hierarchy( for node in code_nodes if (node.get("node_id") or node.get("dep_node_id")) not in (None, "") } + semantic_link_by_id = { + str(node.get("node_id")): str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) + for node in semantic_nodes + if node.get("node_id") not in (None, "") + } + code_link_by_id = { + code_id: str(node.get("link_id") or _node_link_id("code", code_id)) + for code_id, node in code_by_id.items() + } mapped_ids_by_rpg: Dict[str, List[str]] = {} for mapping in mappings: rpg_id = mapping.get("rpg_node_id") or mapping.get("node_id") @@ -595,6 +604,91 @@ def feature_path_text(node: Dict[str, Any], group_parts: List[str], feature_name return " / ".join(parts) return " / ".join(group_parts + ([feature_name] if feature_name else [])) + attached_endpoint_ids: set[str] = set() + + def make_code_leaf(ref: Dict[str, Any]) -> Dict[str, Any]: + code_id = str(ref.get("node_id") or ref.get("dep_node_id") or "") + code = code_by_id.get(code_id, {}) + path = ref.get("path") or code.get("path") or code.get("file") or code.get("module") or _dep_node_path(code_id) + symbol = ref.get("symbol") or ref.get("name") or code.get("symbol") or code.get("name") or _symbol_from_dep(code_id, code) + leaf: Dict[str, Any] = { + "id": str(ref.get("link_id") or code.get("link_id") or _node_link_id("code", code_id)), + "node_id": code_id, + "dep_node_id": code_id, + "name": symbol or path or code_id, + "symbol": symbol, + "path": path, + "kind": "code", + "state": ref.get("state") or code.get("state") or _node_state(code, "mapped"), + "aliases": _ordered_unique([code_id, ref.get("link_id"), code.get("link_id")]), + } + for key in ("type", "line_range", "source", "changed", "changed_files", "diff_anchor"): + _set_if_present(leaf, key, ref.get(key) or code.get(key)) + return leaf + + def endpoint_leaf(edge: Dict[str, Any], side: str) -> Optional[Dict[str, Any]]: + node_id = edge.get(f"{side}_node_id") + node_text = str(node_id or "") + link_id = edge.get(f"{side}_link_id") or semantic_link_by_id.get(node_text) or code_link_by_id.get(node_text) + if link_id in (None, "") and node_text: + link_id = _node_link_id("context", node_text) + link_text = str(link_id or "") + if not link_text or link_text in set(semantic_link_by_id.values()): + return None + if node_text in code_by_id: + return make_code_leaf({"node_id": node_text, "link_id": link_text}) + leaf: Dict[str, Any] = { + "id": link_text, + "node_id": node_text or link_text, + "name": edge.get("name") or edge.get("path") or node_text or "context", + "kind": "context", + "state": edge.get("state") or "context", + "relation": edge.get("relation"), + "direction": edge.get("direction"), + "source": edge.get("source") or edge.get("source_graph") or edge.get("edge_source"), + "aliases": _ordered_unique([node_text, link_text]), + } + for key in ("path", "reason", "source_graph", "edge_source", "relation_source"): + _set_if_present(leaf, key, edge.get(key)) + return leaf + + def append_endpoint(parent: Dict[str, Any], leaf: Dict[str, Any]) -> None: + leaf_id = str(leaf.get("id") or "") + if not leaf_id or leaf_id in attached_endpoint_ids: + return + attached_endpoint_ids.add(leaf_id) + _append_hierarchy_leaf(parent, leaf) + + def relation_endpoint_refs(node_id: str, link_id: str, code_refs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + feature_tokens = set(_ordered_unique([node_id, link_id])) + for ref in code_refs: + feature_tokens.update(_ordered_unique([ref.get("node_id"), ref.get("link_id")])) + refs: List[Dict[str, Any]] = [] + seen: set[str] = set() + for edge in edges: + edge_tokens = _ordered_unique([ + edge.get("rpg_node_id"), + edge.get("source_node_id"), + edge.get("target_node_id"), + edge.get("source_link_id"), + edge.get("target_link_id"), + ]) + if not feature_tokens.intersection(edge_tokens): + continue + for side in ("source", "target"): + leaf = endpoint_leaf(edge, side) + if not leaf: + continue + leaf_id = str(leaf.get("id") or "") + if not leaf_id or leaf_id in feature_tokens or leaf_id in seen: + continue + seen.add(leaf_id) + refs.append(leaf) + return refs + + def endpoint_group(parent: Dict[str, Any], owner_id: str, name: str, kind: str) -> Dict[str, Any]: + return _hierarchy_child(parent, _node_link_id(kind, owner_id), name, kind) + for node in semantic_nodes: node_id = str(node.get("node_id") or "") link_id = str(node.get("link_id") or _node_link_id("rpg", node_id)) @@ -637,7 +731,46 @@ def feature_path_text(node: Dict[str, Any], group_parts: List[str], feature_name ): _set_if_present(leaf, key, node.get(key)) merge_code_metadata(leaf, code_refs) + code_group: Optional[Dict[str, Any]] = None + for ref in code_refs: + if code_group is None: + code_group = endpoint_group(leaf, link_id, "Mapped code", "code_group") + append_endpoint(code_group, make_code_leaf(ref)) + context_group: Optional[Dict[str, Any]] = None + for ref in relation_endpoint_refs(node_id, link_id, code_refs): + group_kind = "code_group" if ref.get("kind") == "code" else "context_group" + group_name = "Mapped code" if group_kind == "code_group" else "Relation endpoints" + if group_kind == "code_group": + if code_group is None: + code_group = endpoint_group(leaf, link_id, group_name, group_kind) + append_endpoint(code_group, ref) + else: + if context_group is None: + context_group = endpoint_group(leaf, link_id, group_name, group_kind) + append_endpoint(context_group, ref) _append_hierarchy_leaf(parent, leaf) + + root_code_group: Optional[Dict[str, Any]] = None + for code in code_nodes: + code_id = str(code.get("node_id") or code.get("dep_node_id") or "") + if not code_id: + continue + leaf = make_code_leaf({"node_id": code_id, "link_id": code.get("link_id")}) + if str(leaf.get("id") or "") in attached_endpoint_ids: + continue + if root_code_group is None: + root_code_group = endpoint_group(root, "unassigned", "Additional code context", "code_group") + append_endpoint(root_code_group, leaf) + + root_context_group: Optional[Dict[str, Any]] = None + for edge in edges: + for side in ("source", "target"): + leaf = endpoint_leaf(edge, side) + if not leaf or str(leaf.get("id") or "") in attached_endpoint_ids: + continue + if root_context_group is None: + root_context_group = endpoint_group(root, "unassigned", "Additional relation endpoints", "context_group") + append_endpoint(root_context_group, leaf) return root @@ -652,6 +785,7 @@ def _focused_graph_default_focus( code_links = [str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) for node in code_nodes] semantic_link_set = set(semantic_links) code_to_feature_links: Dict[str, List[str]] = {} + hierarchy_paths_by_link: Dict[str, List[str]] = {} def remember_code_feature(code_link: Any, feature_link: Any) -> None: if code_link in (None, "") or feature_link in (None, ""): @@ -662,21 +796,113 @@ def remember_code_feature(code_link: Any, feature_link: Any) -> None: if feature_text not in values: values.append(feature_text) + def remember_path(alias: Any, path_ids: List[str]) -> None: + if alias in (None, "") or not path_ids: + return + alias_text = str(alias) + if alias_text not in hierarchy_paths_by_link: + hierarchy_paths_by_link[alias_text] = path_ids + rpg_link_by_node_id = { str(node.get("node_id")): str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) for node in semantic_nodes if node.get("node_id") not in (None, "") } + semantic_path_ids_by_link: Dict[str, List[str]] = {} + for node in semantic_nodes: + link_id = str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) + trail: List[str] = [] + path_ids: List[str] = ["focused-graph-root"] + for part in _semantic_hierarchy_parts(node): + trail.append(part) + path_ids.append(_node_link_id("feature-path", "/".join(trail))) + path_ids.append(link_id) + semantic_path_ids_by_link[link_id] = path_ids + remember_path(link_id, path_ids) + remember_path(node.get("node_id"), path_ids) + + code_link_by_node_id = { + str(node.get("node_id") or node.get("dep_node_id")): str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) + for node in code_nodes + if (node.get("node_id") or node.get("dep_node_id")) not in (None, "") + } + for mapping in mappings: source = mapping.get("source_link_id") or rpg_link_by_node_id.get(str(mapping.get("rpg_node_id") or mapping.get("node_id") or "")) - target = mapping.get("target_link_id") or _node_link_id("code", mapping.get("code_node_id") or mapping.get("dep_node_id")) + target_node_id = mapping.get("code_node_id") or mapping.get("dep_node_id") + target = mapping.get("target_link_id") or code_link_by_node_id.get(str(target_node_id or "")) or _node_link_id("code", target_node_id) remember_code_feature(target, source) - remember_code_feature(mapping.get("code_node_id") or mapping.get("dep_node_id"), source) + remember_code_feature(target_node_id, source) for node in code_nodes: - code_link = str(node.get("link_id") or _node_link_id("code", node.get("node_id") or node.get("dep_node_id"))) + node_id = node.get("node_id") or node.get("dep_node_id") + code_link = str(node.get("link_id") or _node_link_id("code", node_id)) for rpg_link in _listify(node.get("mapped_rpg_link_ids")): remember_code_feature(code_link, rpg_link) - remember_code_feature(node.get("node_id") or node.get("dep_node_id"), rpg_link) + remember_code_feature(node_id, rpg_link) + + for code_id, code_link in code_link_by_node_id.items(): + feature_links = code_to_feature_links.get(code_link) or code_to_feature_links.get(code_id) or [] + if feature_links: + for feature_link in feature_links: + base_path = semantic_path_ids_by_link.get(str(feature_link)) + if not base_path: + continue + code_path = base_path + [_node_link_id("code_group", feature_link), code_link] + remember_path(code_link, code_path) + remember_path(code_id, code_path) + else: + code_path = ["focused-graph-root", _node_link_id("code_group", "unassigned"), code_link] + remember_path(code_link, code_path) + remember_path(code_id, code_path) + + endpoint_link_ids: List[Any] = [] + context_link_ids: List[Any] = [] + + def endpoint_link(edge: Dict[str, Any], side: str) -> str: + node_id = edge.get(f"{side}_node_id") + node_text = str(node_id or "") + return str( + edge.get(f"{side}_link_id") + or rpg_link_by_node_id.get(node_text) + or code_link_by_node_id.get(node_text) + or _node_link_id("context", node_text) + ) + + def owner_feature_links(edge: Dict[str, Any]) -> List[str]: + owners: List[Any] = [] + owners.append(rpg_link_by_node_id.get(str(edge.get("rpg_node_id") or ""))) + for side in ("source", "target"): + node_id = edge.get(f"{side}_node_id") + link_id = endpoint_link(edge, side) + if str(node_id or "") in rpg_link_by_node_id: + owners.append(rpg_link_by_node_id[str(node_id)]) + owners.extend(code_to_feature_links.get(str(node_id or ""), [])) + owners.extend(code_to_feature_links.get(link_id, [])) + return _ordered_unique(owners) + + for edge in edges: + owners = owner_feature_links(edge) + for side in ("source", "target"): + link_id = endpoint_link(edge, side) + node_id = edge.get(f"{side}_node_id") + endpoint_link_ids.append(link_id) + endpoint_link_ids.append(node_id) + if link_id in semantic_link_set: + continue + if link_id in code_links or str(node_id or "") in code_link_by_node_id: + continue + context_link_ids.append(link_id) + context_path_base = None + for feature_link in owners: + base_path = semantic_path_ids_by_link.get(str(feature_link)) + if base_path: + context_path_base = base_path + [_node_link_id("context_group", feature_link)] + break + if context_path_base is None: + context_path_base = ["focused-graph-root", _node_link_id("context_group", "unassigned")] + context_path = context_path_base + [link_id] + remember_path(link_id, context_path) + remember_path(node_id, context_path) changed_feature_links: List[Any] = [] changed_code_links: List[str] = [] @@ -695,29 +921,25 @@ def remember_code_feature(code_link: Any, feature_link: Any) -> None: expanded_node_ids: List[Any] = ["focused-graph-root"] focused_path_node_ids: List[Any] = [] focused_tree_node_ids: List[Any] = [] - semantic_path_ids_by_link: Dict[str, List[str]] = {} - for node in semantic_nodes: - link_id = str(node.get("link_id") or _node_link_id("rpg", node.get("node_id"))) - trail: List[str] = [] - path_ids: List[str] = ["focused-graph-root"] - for part in _semantic_hierarchy_parts(node): - trail.append(part) - path_ids.append(_node_link_id("feature-path", "/".join(trail))) - path_ids.append(link_id) - semantic_path_ids_by_link[link_id] = path_ids def focus_tree_link(link_id: Any) -> None: link_text = str(link_id or "") if not link_text: return + path_ids = hierarchy_paths_by_link.get(link_text) + if path_ids: + expanded_node_ids.extend(path_ids[:-1]) + focused_path_node_ids.extend(path_ids) + focused_tree_node_ids.append(path_ids[-1]) + return feature_links = [link_text] if link_text in semantic_link_set else code_to_feature_links.get(link_text, []) if not feature_links: feature_links = [link_text] if link_text.startswith("feature-path-") or link_text == "focused-graph-root" else [] for feature_link in feature_links: - path_ids = semantic_path_ids_by_link.get(feature_link, [feature_link]) - expanded_node_ids.extend(path_ids[:-1]) - focused_path_node_ids.extend(path_ids) - focused_tree_node_ids.append(path_ids[-1]) + feature_path = semantic_path_ids_by_link.get(feature_link, [feature_link]) + expanded_node_ids.extend(feature_path[:-1]) + focused_path_node_ids.extend(feature_path) + focused_tree_node_ids.append(feature_path[-1]) for link_id in node_link_ids: focus_tree_link(link_id) @@ -741,6 +963,8 @@ def focus_tree_link(link_id: Any) -> None: "code_node_ids": _ordered_unique([node.get("node_id") or node.get("dep_node_id") for node in code_nodes]), "mapping_link_ids": _ordered_unique([mapping.get("link_id") for mapping in mappings]), "edge_link_ids": _ordered_unique([edge.get("link_id") for edge in edges]), + "relation_endpoint_link_ids": _ordered_unique(endpoint_link_ids), + "context_node_ids": _ordered_unique(context_link_ids), "edge_depth": 1, "show_edges": True, } @@ -753,7 +977,6 @@ def _focused_graph_metadata( edges: List[Dict[str, Any]], hidden_counts: Dict[str, Any], warnings: List[Dict[str, Any]], - caps: Optional[Dict[str, Any]], graph_context: Optional[Dict[str, Any]], ) -> Dict[str, Any]: hierarchy = _focused_graph_hierarchy(semantic_nodes, code_nodes, mappings, edges, hidden_counts, warnings) @@ -763,8 +986,6 @@ def _focused_graph_metadata( "hierarchy": hierarchy, "default_focus": default_focus, } - if caps: - focused_graph["caps"] = caps if graph_context: focused_graph["graph_context"] = graph_context if hidden_counts: @@ -784,7 +1005,6 @@ def _build_nodes_view( changed_files: List[str], diff_anchors: Dict[str, str], *, - caps: Optional[Dict[str, Any]] = None, graph_context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: rpg_by_id = {str(row.get("node_id")): row for row in primary_rpg_nodes if row.get("node_id") not in (None, "")} @@ -932,7 +1152,6 @@ def _build_nodes_view( edge_rows, hidden_counts, warning_rows, - caps or {}, graph_context or {}, ) return { @@ -954,15 +1173,13 @@ def _build_nodes_view( "hierarchy": focused_graph["hierarchy"], "default_focus": focused_graph["default_focus"], "focused_graph": focused_graph, - "caps": caps or {}, + "caps": dict(_LEGACY_FOCUSED_VIEW_CAPS), "graph_context": graph_context or {}, } -_FOCUSED_RPG_NODE_CAP = 20 -_FOCUSED_CODE_NODE_CAP = 50 -_FOCUSED_EDGE_CAP = 80 -_FOCUSED_WARNING_TYPES = {"missing_mapping", "missing_reason", "too_many_neighbors", "stale_graph"} +_LEGACY_FOCUSED_VIEW_CAPS = {"primary_rpg_nodes": 20, "primary_code_nodes": 50, "edges": 80} +_FOCUSED_WARNING_TYPES = {"missing_mapping", "missing_reason", "stale_graph"} def _set_if_present(row: Dict[str, Any], key: str, value: Any) -> None: @@ -1516,55 +1733,21 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: if edge.get("source_node_id") in selected_code_ids or edge.get("target_node_id") in selected_code_ids: add_edge(edge) - primary_rpg_nodes = primary_rpg_nodes_all[:_FOCUSED_RPG_NODE_CAP] + primary_rpg_nodes = primary_rpg_nodes_all primary_code_nodes_all = list(code_node_by_id.values()) - primary_code_nodes = primary_code_nodes_all[:_FOCUSED_CODE_NODE_CAP] - visible_rpg_ids = {row.get("node_id") for row in primary_rpg_nodes} - visible_code_ids = {row.get("node_id") for row in primary_code_nodes} - mappings = [ - row for row in mapping_rows_all - if row.get("rpg_node_id") in visible_rpg_ids and (not row.get("code_node_id") or row.get("code_node_id") in visible_code_ids) - ] - edges = all_edges[:_FOCUSED_EDGE_CAP] - hidden_counts: Dict[str, Any] = { - "primary_rpg_nodes": max(0, len(primary_rpg_nodes_all) - len(primary_rpg_nodes)), - "primary_code_nodes": max(0, len(primary_code_nodes_all) - len(primary_code_nodes)), - "edges": max(0, len(all_edges) - len(edges)), - } + primary_code_nodes = primary_code_nodes_all + mappings = mapping_rows_all + edges = all_edges + hidden_counts: Dict[str, Any] = {} for key, count in impact_hidden_counts.items(): - hidden_counts[key] = hidden_counts.get(key, 0) + count - relation_totals: Dict[str, int] = {} - relation_visible: Dict[str, int] = {} - for edge in all_edges: - relation = str(edge.get("relation") or "dependency") - relation_totals[relation] = relation_totals.get(relation, 0) + 1 - for edge in edges: - relation = str(edge.get("relation") or "dependency") - relation_visible[relation] = relation_visible.get(relation, 0) + 1 - hidden_relations = { - relation: total - relation_visible.get(relation, 0) - for relation, total in relation_totals.items() - if total > relation_visible.get(relation, 0) - } - if hidden_relations: - hidden_counts["relations"] = hidden_relations - capped_hidden = { - key: value for key, value in hidden_counts.items() - if key in {"primary_rpg_nodes", "primary_code_nodes", "edges"} and value - } - if capped_hidden: - add_warning("too_many_neighbors", "Focused view omitted rows because caps were reached", hidden_counts=capped_hidden) + if count: + hidden_counts[key] = hidden_counts.get(key, 0) + count matched_files = {file_path for node in primary_rpg_nodes_all for file_path in node.get("changed_files") or []} unmatched_code_deltas = [delta for delta in code_deltas if _code_delta_file(delta) not in matched_files] mapped_code_relations = sum(1 for row in mapping_rows_all if row.get("code_node_id")) missing_mappings = sum(1 for row in mapping_rows_all if row.get("status") == "missing") diff_anchors = _diff_anchor_map(code_deltas) - focused_caps = { - "primary_rpg_nodes": _FOCUSED_RPG_NODE_CAP, - "primary_code_nodes": _FOCUSED_CODE_NODE_CAP, - "edges": _FOCUSED_EDGE_CAP, - } graph_context = { "current_graph_available": current_graph_available, "current_rpg_nodes": len(current_rpg_nodes), @@ -1581,7 +1764,6 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: warnings, changed_files, diff_anchors, - caps=focused_caps, graph_context=graph_context, ) summary = { @@ -1612,7 +1794,6 @@ def impact_neighbor(item: Any) -> Optional[Dict[str, Any]]: "warnings": warnings, "changed_files": changed_files, "unmatched_code_deltas": unmatched_code_deltas, - "caps": focused_caps, "apply": { "status": _apply_status(apply_result), "dep_graph_refreshed": apply_result.get("dep_graph_refreshed"), diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index 2f12d8e..741422a 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -1,5 +1,7 @@ from __future__ import annotations +import html as html_lib +import json import sys from pathlib import Path @@ -139,8 +141,8 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm "semantic_nodes": 2, "code_nodes": 1, "mappings": 2, - "edges": 2, - "warnings": 4, + "edges": 3, + "warnings": 3, }, "semantic_nodes": [ { @@ -221,12 +223,23 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm "source": "impact", "reason": "reaches ", }, + { + "source_node_id": "a.py:f" not in html assert "Node " not in html @@ -406,6 +448,26 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm assert "maps because " not in html assert "
    View diff" in html assert "
    ", 1)[1].split("", 1)[0] + graph_payload = json.loads(html_lib.unescape(graph_json)) + assert len(graph_payload["edges"]) == 3 + assert len(graph_payload["relation_edges"]) == 3 + assert graph_payload["summary"]["edges"] == 3 + assert graph_payload["summary"]["relation_edges"] == 3 + assert graph_payload["summary"]["context_edges"] == 3 + relation_links = [link for link in graph_payload["links"] if link.get("relation") != "contains"] + assert len(relation_links) == 3 + assert any(node["id"] == "context-callee-script" for node in graph_payload["nodes"]) + assert any(node["id"] == "context-imported-script" for node in graph_payload["nodes"]) + assert any("context-callee-script" in edge["target_candidates"] for edge in graph_payload["relation_edges"]) + assert any(edge["relation"] == "imports" and "context-imported-script" in edge["target_candidates"] for edge in graph_payload["relation_edges"]) + assert any("rpg-n1-script" in edge["source_candidates"] for edge in graph_payload["relation_edges"]) + assert any("code-a.py-f-script" in edge["source_candidates"] for edge in graph_payload["relation_edges"]) + default_focus = graph_payload["default_focus"] + assert "focused-graph-root" in default_focus["default_expanded_node_ids"] + assert "rpg-n1-script" in default_focus["focused_path_node_ids"] + assert "context-callee-script" not in default_focus["default_expanded_node_ids"] + assert graph_payload["hidden_counts"] == {"callers": 3} inspector_json = html.split("Inspector JSON
    ", 1)[1].split("
    ", 1)[0] assert "focused_graph" in inspector_json assert "nodes_view" in inspector_json @@ -414,6 +476,7 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm assert "default_focus" in inspector_json assert "primary_rpg_nodes" not in inspector_json assert "primary_code_nodes" not in inspector_json + assert "caps" not in inspector_json assert long_diff not in inspector_json evidence_json = html.split("Evidence JSON
    ", 1)[1].split("
    ", 1)[0] assert "code_deltas" not in evidence_json From e49ed9cf417eeb646bdc29f25b5a003c1e91ee7b Mon Sep 17 00:00:00 2001 From: Qingtao Li Date: Wed, 8 Jul 2026 09:53:21 +0000 Subject: [PATCH 40/56] rpg_edit: In _append_hierarchy_nodes() at lines 963-1024, preserve add --- CoderMind/scripts/common/run_report.py | 257 ++++++++++++++++++++++--- CoderMind/tests/test_run_report.py | 53 ++++- 2 files changed, 272 insertions(+), 38 deletions(-) diff --git a/CoderMind/scripts/common/run_report.py b/CoderMind/scripts/common/run_report.py index 18139ac..29b496c 100644 --- a/CoderMind/scripts/common/run_report.py +++ b/CoderMind/scripts/common/run_report.py @@ -380,34 +380,48 @@ def _render_page( .focus-links {{ display:flex; flex-wrap:wrap; gap:6px; }} .focus-link {{ border:1px solid var(--line); border-radius:999px; padding:2px 8px; background:#fff; font-size:12px; }} .focused-graph-section {{ overflow-x:hidden; }} -.focused-graph-toolbar {{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0 12px; }} -.focused-graph-toolbar button, .focused-graph-toolbar input {{ border:1px solid var(--line); border-radius:8px; background:#fff; color:var(--text); padding:6px 10px; font:inherit; }} -.focused-graph-toolbar label {{ display:inline-flex; gap:6px; align-items:center; color:var(--muted); }} -.focused-graph-stage {{ border:1px solid var(--line); border-radius:12px; background:#fbfdff; min-height:520px; position:relative; overflow:hidden; }} +.focused-graph-toolbar {{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0 12px; padding:10px; border:1px solid #334155; border-radius:12px; background:#0f172a; color:#e5e7eb; }} +.focused-graph-toolbar button, .focused-graph-toolbar input {{ border:1px solid #475569; border-radius:8px; background:#1e293b; color:#e5e7eb; padding:6px 10px; font:inherit; }} +.focused-graph-toolbar input::placeholder {{ color:#94a3b8; }} +.focused-graph-toolbar label {{ display:inline-flex; gap:6px; align-items:center; color:#cbd5e1; }} +.focused-graph-stage {{ border:1px solid #334155; border-radius:12px; background:#0f172a; min-height:520px; position:relative; overflow:hidden; }} .focused-graph-svg {{ display:block; width:100%; height:520px; cursor:grab; touch-action:none; }} .focused-graph-svg:active {{ cursor:grabbing; }} -.focused-graph-tree-link {{ fill:none; stroke:#cbd5e1; stroke-width:1.4; }} -.focused-graph-link {{ fill:none; opacity:.78; transition:opacity .15s ease, stroke-width .15s ease; }} +.focused-graph-detail {{ position:absolute; top:14px; right:14px; z-index:2; width:min(320px,calc(100% - 28px)); max-height:calc(100% - 28px); overflow:auto; border:1px solid #334155; border-radius:12px; background:rgba(15,23,42,.94); color:#e5e7eb; padding:12px; box-shadow:0 18px 40px rgba(2,6,23,.35); }} +.focused-graph-detail h3 {{ margin:0 0 8px; font-size:15px; color:#f8fafc; overflow-wrap:anywhere; }} +.focused-graph-detail dl {{ margin:0; display:grid; gap:8px; }} +.focused-graph-detail-row {{ display:grid; gap:3px; }} +.focused-graph-detail dt {{ color:#94a3b8; font-size:11px; text-transform:uppercase; letter-spacing:.04em; }} +.focused-graph-detail dd {{ margin:0; font-size:13px; overflow-wrap:anywhere; }} +.focused-graph-detail a {{ color:#93c5fd; }} +.focused-graph-detail code {{ color:#bfdbfe; }} +.focused-graph-detail .empty {{ color:#94a3b8; }} +.focused-graph-detail-badges {{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:10px; }} +.focused-graph-detail-badges .badge {{ background:#1e293b; color:#cbd5e1; }} +.focused-graph-detail-list {{ margin:0; padding-left:18px; }} +.focused-graph-detail-list span {{ display:block; color:#94a3b8; font-size:12px; }} +.focused-graph-tree-link {{ fill:none; stroke:#64748b; stroke-width:1.4; }} +.focused-graph-link {{ fill:none; opacity:.86; transition:opacity .15s ease, stroke-width .15s ease; }} .focused-graph-link.active {{ opacity:1; stroke-width:2.8; }} .focused-graph-link.dimmed {{ opacity:.14; }} .focused-graph-link.hidden {{ display:none; }} -.focused-graph-link.edge-semantic {{ stroke:#7c3aed; }} -.focused-graph-link.edge-dependency {{ stroke:#ea580c; }} -.focused-graph-link.relation-invokes, .focused-graph-link.relation-caller, .focused-graph-link.relation-callee {{ stroke:#16a34a; }} -.focused-graph-link.relation-imports, .focused-graph-link.relation-import {{ stroke:#ea580c; }} -.focused-graph-link.relation-inherits, .focused-graph-link.relation-inheritance {{ stroke:#9333ea; }} -.focused-graph-link.relation-references, .focused-graph-link.relation-reference {{ stroke:#2563eb; }} +.focused-graph-link.edge-semantic {{ stroke:#a78bfa; }} +.focused-graph-link.edge-dependency {{ stroke:#fb923c; }} +.focused-graph-link.relation-invokes, .focused-graph-link.relation-caller, .focused-graph-link.relation-callee {{ stroke:#4ade80; }} +.focused-graph-link.relation-imports, .focused-graph-link.relation-import {{ stroke:#fb923c; }} +.focused-graph-link.relation-inherits, .focused-graph-link.relation-inheritance {{ stroke:#c084fc; }} +.focused-graph-link.relation-references, .focused-graph-link.relation-reference {{ stroke:#60a5fa; }} .focused-graph-link.source-dep-graph {{ stroke-dasharray:5 3; }} .focused-graph-node {{ cursor:pointer; transition:opacity .15s ease; }} -.focused-graph-node circle {{ fill:#2563eb; stroke:#fff; stroke-width:2; transition:stroke .15s ease, stroke-width .15s ease; }} -.focused-graph-node.selected circle, .focused-graph-node.active circle, .focused-graph-node.focused circle {{ stroke:#0f172a; stroke-width:3; }} +.focused-graph-node circle {{ fill:#3b82f6; stroke:#e2e8f0; stroke-width:2; transition:stroke .15s ease, stroke-width .15s ease; }} +.focused-graph-node.selected circle, .focused-graph-node.active circle, .focused-graph-node.focused circle {{ stroke:#f8fafc; stroke-width:3; }} .focused-graph-node.search-match circle {{ stroke:#f59e0b; stroke-width:3; }} .focused-graph-node.dimmed {{ opacity:.18; }} .focused-graph-node.hidden {{ display:none; }} -.focused-graph-fallback {{ margin:12px; padding:12px; border:1px dashed var(--line); border-radius:10px; background:#fff; color:var(--muted); }} -.focused-graph-legend {{ display:flex; flex-wrap:wrap; gap:8px; margin:8px 0 12px; }} -.legend-item {{ display:inline-flex; gap:6px; align-items:center; color:var(--muted); font-size:13px; }} -.legend-swatch {{ width:10px; height:10px; border-radius:999px; display:inline-block; border:1px solid var(--line); flex:0 0 auto; }} +.focused-graph-fallback {{ margin:12px; padding:12px; border:1px dashed #475569; border-radius:10px; background:#1e293b; color:#cbd5e1; }} +.focused-graph-legend {{ display:flex; flex-wrap:wrap; gap:8px; margin:8px 0 12px; padding:10px; border:1px solid #334155; border-radius:12px; background:#0f172a; }} +.legend-item {{ display:inline-flex; gap:6px; align-items:center; color:#cbd5e1; font-size:13px; }} +.legend-swatch {{ width:10px; height:10px; border-radius:999px; display:inline-block; border:1px solid #475569; flex:0 0 auto; }} .legend-line {{ width:28px; height:0; border-radius:0; border:0; border-top:2px solid var(--line); background:transparent; }} .legend-node {{ background:#2563eb; }} .legend-tree-link {{ border-top-color:#cbd5e1; border-top-width:1.4px; }} @@ -991,19 +1005,52 @@ def visit(row: Any) -> str: "type": row.get("kind") or "hierarchy", } for key in ( + "name", "feature_name", "feature_path", "path", + "module", + "file", + "symbol", + "dep_node_id", + "type", "node_type", + "signature", "mapping_status", + "locate_status", + "breadcrumb", + "breadcrumb_path", + "score", + "source", + "source_graph", + "edge_source", + "relation_source", + "source_feature", + "source_features", + "relation", + "direction", + "reason", + "line_range", + "apply_action", "mapped_code", + "mapped_code_node_ids", + "mapped_code_link_ids", "mapped_code_path", "mapped_code_paths", "mapped_code_symbol", "mapped_code_symbols", "mapped_code_count", + "mapped_rpg_node_ids", + "mapped_rpg_link_ids", + "changed", "changed_files", + "affected_files", "diff_anchor", + "diff", + "hidden_counts", + "warning_types", + "rpg_node_id", + "neighbor_node_id", ): value = row.get(key) if row.get(key) not in (None, "") else metadata.get(key) if value not in (None, ""): @@ -1300,6 +1347,7 @@ def _focused_graph_runtime() -> str: const svg = section.querySelector('[data-focused-graph-svg]'); const fallback = section.querySelector('[data-focused-graph-fallback]'); const statusEl = section.querySelector('[data-focused-graph-status]'); + const detailEl = section.querySelector('[data-focused-graph-detail]'); if (!window.d3 || !dataEl || !svg) return; if (fallback) fallback.hidden = true; const data = JSON.parse(dataEl.textContent || '{}'); @@ -1309,6 +1357,14 @@ def _focused_graph_runtime() -> str: const defaultShowEdges = defaultFocus.show_edges !== false; const text = value => value === undefined || value === null ? '' : String(value); const list = value => Array.isArray(value) ? value : []; + const nodePayloadById = {}; + list(data.nodes).forEach(node => { + if (!node || typeof node !== 'object') return; + const id = text(node.id); + const nodeId = text(node.node_id || node.dep_node_id); + if (id) nodePayloadById[id] = node; + if (nodeId && !nodePayloadById[nodeId]) nodePayloadById[nodeId] = node; + }); const root = d3.hierarchy(data.hierarchy || {id:'focused-graph-root', name:'Focused graph', children:[]}, d => d.children); const rootHierarchyId = text(root.data.id || 'focused-graph-root'); const focusedNodeIds = new Set(list(defaultFocus.focused_tree_node_ids || defaultFocus.focused_node_ids || defaultFocus.node_link_ids).map(text).filter(Boolean)); @@ -1365,30 +1421,168 @@ def _focused_graph_runtime() -> str: svgSelection.call(zoom).on('dblclick.zoom', null).on('click', event => { if (event.target === svg) { selectedId = null; + renderFocusedGraphDetail(null); update(root); } }); - function mappedCodeLabel(d) { - const path = d.data.mapped_code_path || list(d.data.mapped_code_paths)[0] || ''; - const symbol = d.data.mapped_code_symbol || list(d.data.mapped_code_symbols)[0] || ''; - return [path, symbol].filter(Boolean).join(' · '); + function nodeDetailData(d) { + if (!d) return {}; + const data = d.data || {}; + const payload = nodePayloadById[text(d.id)] || nodePayloadById[text(data.node_id || data.dep_node_id)] || {}; + return {...payload, ...data}; } function nodeLabel(d) { - const base = text(d.data.label || d.data.feature_name || d.data.name || d.data.node_id || d.id); - const mapped = mappedCodeLabel(d); - return mapped && !base.includes(mapped) ? `${base} — ${mapped}` : base; + const detail = nodeDetailData(d); + return text(detail.feature_name || detail.name || detail.node_id || detail.dep_node_id || detail.id || d.id || 'node'); + } + + function searchText(value) { + if (Array.isArray(value)) return value.map(searchText).join(' '); + if (value && typeof value === 'object') return Object.values(value).map(searchText).join(' '); + return text(value); } function nodeSearchText(d) { - return `${nodeLabel(d)} ${d.data.node_id || ''} ${d.data.feature_path || ''} ${d.data.path || ''} ${d.data.mapped_code_path || ''} ${list(d.data.mapped_code_paths).join(' ')} ${d.data.mapped_code_symbol || ''} ${list(d.data.mapped_code_symbols).join(' ')}`.toLowerCase(); + return `${nodeLabel(d)} ${searchText(nodeDetailData(d))}`.toLowerCase(); } function nodeMatches(d) { return query && nodeSearchText(d).includes(query); } + function isPresent(value) { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + return true; + } + + function escapeHtml(value) { + return text(value).replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"', "'":'''}[char])); + } + + function uniqueTexts(values) { + const seen = new Set(); + const result = []; + values.forEach(value => { + const item = text(value).trim(); + if (!item || seen.has(item)) return; + seen.add(item); + result.push(item); + }); + return result; + } + + function detailItems(value) { + if (!isPresent(value)) return []; + return Array.isArray(value) ? value : [value]; + } + + function lineRangeText(value) { + if (!isPresent(value)) return ''; + if (Array.isArray(value)) return value.map(text).filter(Boolean).join('-'); + if (value && typeof value === 'object') { + const start = value.start ?? value.start_line ?? value.line_start; + const end = value.end ?? value.end_line ?? value.line_end; + if (isPresent(start) && isPresent(end)) return `${start}-${end}`; + return searchText(value).trim(); + } + return text(value); + } + + function simpleValueHtml(value) { + if (!isPresent(value)) return ''; + if (Array.isArray(value)) return detailListHtml(value, simpleValueHtml); + if (value && typeof value === 'object') return `${escapeHtml(searchText(value).trim() || JSON.stringify(value))}`; + return escapeHtml(value); + } + + function detailListHtml(value, render) { + const rows = detailItems(value).map(render).filter(Boolean); + return rows.length ? `
      ${rows.map(row => `
    • ${row}
    • `).join('')}
    ` : ''; + } + + function changedFilesHtml(value) { + return detailListHtml(value, item => { + if (item && typeof item === 'object') { + const label = text(item.path || item.file || item.diff_anchor || item.href); + const href = text(item.href || (item.diff_anchor ? `#${item.diff_anchor}` : '')); + if (!label) return ''; + return href ? `${escapeHtml(label)}` : escapeHtml(label); + } + return escapeHtml(item); + }); + } + + function diffHtml(value, detail) { + if (value && typeof value === 'object') { + const label = text(value.path || value.href || 'View diff'); + const href = text(value.href || (value.diff_anchor ? `#${value.diff_anchor}` : '')); + return href ? `${escapeHtml(label)}` : escapeHtml(label); + } + if (detail.diff_anchor) return `${escapeHtml(detail.path || detail.diff_anchor)}`; + return ''; + } + + function mappedCodeHtml(value, detail) { + let refs = detailItems(value); + if (!refs.length) { + const paths = list(detail.mapped_code_paths); + const symbols = list(detail.mapped_code_symbols); + if (paths.length || symbols.length || detail.mapped_code_path || detail.mapped_code_symbol) { + refs = [{path: detail.mapped_code_path || paths[0], symbol: detail.mapped_code_symbol || symbols[0]}]; + } + } + return detailListHtml(refs, item => { + if (item && typeof item === 'object') { + const label = uniqueTexts([item.path, item.symbol, item.node_id || item.link_id]).join(' · '); + const meta = uniqueTexts([item.type || item.kind, lineRangeText(item.line_range), item.source]).join(' · '); + if (!label && !meta) return ''; + return `${escapeHtml(label || meta)}${label && meta ? `${escapeHtml(meta)}` : ''}`; + } + return escapeHtml(item); + }); + } + + function addValueRow(rows, label, value) { + if (!isPresent(value)) return; + rows.push(`
    ${escapeHtml(label)}
    ${simpleValueHtml(value)}
    `); + } + + function addHtmlRow(rows, label, value) { + if (!value) return; + rows.push(`
    ${escapeHtml(label)}
    ${value}
    `); + } + + function renderFocusedGraphDetail(d) { + if (!detailEl) return; + if (!d) { + detailEl.innerHTML = '

    Node details

    Select a node to inspect metadata.

    '; + return; + } + const detail = nodeDetailData(d); + const rows = []; + const typeText = uniqueTexts([detail.node_type || detail.type || detail.kind, detail.state, detail.mapping_status, detail.locate_status]).join(' · '); + const relationText = uniqueTexts([detail.relation, detail.direction]).join(' · '); + const sourceText = uniqueTexts([detail.source, detail.source_graph, detail.edge_source, detail.relation_source]).join(' · '); + addValueRow(rows, 'Node id', detail.node_id || detail.dep_node_id || detail.id); + addValueRow(rows, 'Feature path', detail.feature_path || detail.breadcrumb_path || detail.breadcrumb); + addValueRow(rows, 'Path', detail.path || detail.module || detail.file); + addValueRow(rows, 'Symbol', detail.symbol); + addValueRow(rows, 'Type', typeText); + addValueRow(rows, 'Relation', relationText); + addValueRow(rows, 'Source', sourceText); + addValueRow(rows, 'Reason', detail.reason); + addValueRow(rows, 'Lines', lineRangeText(detail.line_range)); + addHtmlRow(rows, 'Mapped code', mappedCodeHtml(detail.mapped_code, detail)); + addHtmlRow(rows, 'Changed files', changedFilesHtml(detail.changed_files || detail.affected_files)); + addHtmlRow(rows, 'Diff', diffHtml(detail.diff, detail)); + const chips = uniqueTexts([detail.kind, detail.state, detail.mapping_status, detail.source]).map(item => `${escapeHtml(item)}`).join(''); + const body = rows.length ? `
    ${rows.join('')}
    ` : '

    No additional metadata for this node.

    '; + detailEl.innerHTML = `

    ${escapeHtml(nodeLabel(d))}

    ${chips ? `
    ${chips}
    ` : ''}${body}`; + } + function diagonal(s, d) { return `M${s.y},${s.x} C${(s.y + d.y) / 2},${s.x} ${(s.y + d.y) / 2},${d.x} ${d.y},${d.x}`; } @@ -1436,6 +1630,7 @@ def _focused_graph_runtime() -> str: if (search) search.value = ''; const edges = section.querySelector('[data-action="edges"]'); if (edges) edges.checked = showEdges; + renderFocusedGraphDetail(null); svgSelection.transition().duration(150).call(zoom.transform, d3.zoomIdentity.translate(80, 40)); update(root); } @@ -1528,6 +1723,7 @@ def _focused_graph_runtime() -> str: .on('click', (event, d) => { event.stopPropagation(); selectedId = selectedId === d.id ? null : d.id; + renderFocusedGraphDetail(selectedId ? d : null); update(d); }) .on('dblclick', (event, d) => { @@ -1542,9 +1738,12 @@ def _focused_graph_runtime() -> str: .attr('x', d => d.children || d._children ? -10 : 10) .attr('text-anchor', d => d.children || d._children ? 'end' : 'start') .attr('font-size', 12) - .attr('fill', '#1f2937') + .attr('fill', '#e5e7eb') .text(d => nodeLabel(d).length > 46 ? nodeLabel(d).slice(0, 44) + '…' : nodeLabel(d)); - nodeEnter.append('title').text(d => `${nodeLabel(d)}\n${d.data.feature_path || ''}\n${d.data.mapped_code_path || ''} ${d.data.mapped_code_symbol || ''}`); + nodeEnter.append('title').text(d => { + const detail = nodeDetailData(d); + return `${nodeLabel(d)}\n${detail.feature_path || ''}\n${detail.path || ''} ${detail.symbol || ''}`; + }); const nodeUpdate = nodeEnter.merge(node); nodeUpdate.transition().duration(250).attr('transform', d => `translate(${d.y},${d.x})`); @@ -1568,6 +1767,7 @@ def _focused_graph_runtime() -> str: .remove(); drawRelationEdges(); + renderFocusedGraphDetail(selectedId ? (nodeById[selectedId] || allNodeById[selectedId]) : null); currentNodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; }); } @@ -1640,6 +1840,7 @@ def _render_focused_graph(focused_view: dict[str, Any], file_anchors: Mapping[st f"" '
    ' '' + '' f'
    Static focused graph fallback is available when D3 cannot run.{d3_missing_note}
    ' '
    ' f"{warnings_html}{hidden_html}" diff --git a/CoderMind/tests/test_run_report.py b/CoderMind/tests/test_run_report.py index 741422a..6c8fb53 100644 --- a/CoderMind/tests/test_run_report.py +++ b/CoderMind/tests/test_run_report.py @@ -25,7 +25,7 @@ from common.run_report import write_command_report -def test_run_report_exposes_current_impact_renderers_only() -> None: +def test_common_run_report_run_report_exposes_current_impact_renderers_only() -> None: assert hasattr(run_report, "_render_focused_graph") assert hasattr(run_report, "_inline_d3") assert not hasattr(run_report, "_render_semantic_code_impact_chain") @@ -36,7 +36,7 @@ def test_run_report_exposes_current_impact_renderers_only() -> None: assert not hasattr(run_report, "_render_focused_impact_group") -def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path) -> None: +def test_common_run_report_write_command_report_escapes_content_and_writes_sections(tmp_path: Path) -> None: report = write_command_report( CommandRun( command="rpg/edit ", @@ -108,7 +108,7 @@ def test_write_command_report_escapes_content_and_writes_sections(tmp_path: Path assert "backup/" for i in range(40) ) @@ -380,6 +380,10 @@ def test_write_command_report_renders_retrievals_code_deltas_and_focused_view(tm assert "data-action=\"edges\"" in html assert "data-action=\"search\"" in html assert "data-focused-graph-status" in html + assert "data-focused-graph-detail" in html + assert '