Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
Expand Down
190 changes: 157 additions & 33 deletions src/arcade_agent/ci/arch_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,51 @@
from arcade_agent.tools.parse import parse
from arcade_agent.tools.recover import recover

# Metrics where higher is better (for direction indicators).
_HIGHER_IS_BETTER = {
"BalancedArchitectureScore",
"PrincipleAlignmentScore",
"RCI",
"TurboMQ",
"BasicMQ",
"IntraConnectivity",
"DependencyHealth",
"ComponentBalance",
"HubBalance",
"BoundaryClarity",
"DependencyDistribution",
"SmellDiscipline",
}

# Metrics where lower is better.
_LOWER_IS_BETTER = {
"InterConnectivity",
"TwoWayPairRatio",
}

# Maximum entity movements to list individually.
_MAX_ENTITY_MOVEMENTS = 15

# Smell → actionable recommendation mapping.
_SMELL_RECOMMENDATIONS = {
"concern_overload": (
"Split the overloaded component into smaller, focused units. "
"Consider extracting sub-packages or introducing an interface layer."
),
"hub_like_dependency": (
"Reduce fan-in/fan-out by introducing abstractions or splitting "
"the hub into domain-specific modules."
),
"cyclic_dependency": (
"Break the cycle by extracting shared abstractions or inverting "
"dependencies via interfaces/events."
),
"unstable_interface": (
"Stabilize the interface by reducing outgoing dependencies or "
"applying the Stable Abstractions Principle."
),
}


def build_report(
current,
Expand All @@ -29,6 +74,7 @@ def build_report(
smells,
drift=None,
baseline=None,
baseline_metrics=None,
) -> str:
"""Build a markdown drift report.

Expand All @@ -39,6 +85,7 @@ def build_report(
smells: List of SmellInstance detected.
drift: Optional compare() result dict (if baseline exists).
baseline: Optional baseline Architecture.
baseline_metrics: Optional dict of baseline metric name→value.

Returns:
Markdown string.
Expand All @@ -55,14 +102,13 @@ def build_report(
"",
]

# Drift table (only when baseline exists)
# ── Drift table (only when baseline exists) ──────────────────────────
if drift and baseline:
similarity = drift["overall_similarity"]
summary = drift["summary"]
baseline_components = summary["arch_a_components"]

# Build metric lookup for current and baseline delta
metric_map = {m.name: m.value for m in metrics}
bl_metrics = baseline_metrics or {}

lines.append("### Drift from Baseline")
lines.append("")
Expand All @@ -72,9 +118,7 @@ def build_report(
f"| Components | {baseline_components} | {num_components} | "
f"{_delta(num_components - baseline_components)} |"
)
lines.append(
f"| Similarity | — | {similarity:.2f} | — |"
)
lines.append(f"| Similarity | — | {similarity:.2f} | — |")

preferred_metrics = (
"BalancedArchitectureScore",
Expand All @@ -86,47 +130,58 @@ def build_report(
for name in preferred_metrics:
val = metric_map.get(name)
if val is not None:
lines.append(f"| {name} | — | {val:.2f} | — |")
bl_val = bl_metrics.get(name)
lines.append(_metric_row(name, bl_val, val))
displayed_metrics.add(name)
for metric in metrics:
if metric.name in displayed_metrics:
continue
lines.append(f"| {metric.name} | — | {metric.value:.2f} | — |")
bl_val = bl_metrics.get(metric.name)
lines.append(_metric_row(metric.name, bl_val, metric.value))

lines.append("")

# Changes summary
# ── Changes summary ──────────────────────────────────────────────
lines.append("### Changes")
lines.append("")
if summary["components_added"]:
added_names = [
m["target"]
for m in drift["matches"]
if not m["source"]
m["target"] for m in drift["matches"] if not m["source"]
]
lines.append(
f"- {summary['components_added']} component(s) added: "
f"`{'`, `'.join(added_names)}`"
)
if summary["components_removed"]:
removed_names = [
m["source"]
for m in drift["matches"]
if not m["target"]
m["source"] for m in drift["matches"] if not m["target"]
]
lines.append(
f"- {summary['components_removed']} component(s) removed: "
f"`{'`, `'.join(removed_names)}`"
)

# Count entity movements
entities_moved = sum(
len(m.get("entities_added", [])) + len(m.get("entities_removed", []))
for m in drift["matches"]
if m["source"] and m["target"]
)
if entities_moved:
lines.append(f"- {entities_moved} entity movement(s) between components")
# Entity movements with details
movements: list[tuple[str, str, str]] = []
for m in drift["matches"]:
if not (m["source"] and m["target"]):
continue
for ent in m.get("entities_added", []):
movements.append((ent, "→", m["target"]))
for ent in m.get("entities_removed", []):
movements.append((ent, "←", m["source"]))

if movements:
lines.append(
f"- {len(movements)} entity movement(s) between components"
)
shown = movements[:_MAX_ENTITY_MOVEMENTS]
for ent, arrow, comp in shown:
lines.append(f" - `{ent}` {arrow} **{comp}**")
if len(movements) > _MAX_ENTITY_MOVEMENTS:
lines.append(
f" - … and {len(movements) - _MAX_ENTITY_MOVEMENTS} more"
)

if summary["possible_merges"]:
lines.append(
Expand All @@ -140,29 +195,73 @@ def build_report(
if not any([
summary["components_added"],
summary["components_removed"],
entities_moved,
movements,
summary["possible_merges"],
summary["possible_splits"],
]):
lines.append("- No structural changes detected")

lines.append("")

# Smells section
# ── Component breakdown ──────────────────────────────────────────────
lines.append("### Components")
lines.append("")
lines.append("| Component | Entities | Responsibility |")
lines.append("|-----------|----------|----------------|")
for comp in sorted(current.components, key=lambda c: -len(c.entities)):
resp = (comp.responsibility or "")[:60]
lines.append(f"| {comp.name} | {len(comp.entities)} | {resp} |")
lines.append("")

# ── Mermaid dependency diagram ───────────────────────────────────────
lines.append("### Architecture Diagram")
lines.append("")
lines.append("```mermaid")
lines.append("graph LR")
# Build inter-component edges from the dependency graph
entity_to_comp: dict[str, str] = {}
for comp in current.components:
for ent in comp.entities:
entity_to_comp[ent] = comp.name
comp_edges: set[tuple[str, str]] = set()
for edge in graph.edges:
src_comp = entity_to_comp.get(edge.source)
tgt_comp = entity_to_comp.get(edge.target)
if src_comp and tgt_comp and src_comp != tgt_comp:
comp_edges.add((src_comp, tgt_comp))
for comp in current.components:
safe = comp.name.replace(" ", "_")
lines.append(f" {safe}[\"{comp.name}\"]")
for src, tgt in sorted(comp_edges):
safe_src = src.replace(" ", "_")
safe_tgt = tgt.replace(" ", "_")
lines.append(f" {safe_src} --> {safe_tgt}")
lines.append("```")
lines.append("")

# ── Smells section with recommendations ──────────────────────────────
if smells:
lines.append(f"### Smells ({len(smells)})")
lines.append("")
for smell in smells:
affected = ", ".join(smell.affected_components) if smell.affected_components else ""
lines.append(f"- {_display_value(smell.smell_type)}: {affected}")
affected = (
", ".join(smell.affected_components)
if smell.affected_components
else ""
)
smell_key = _display_value(smell.smell_type).lower().replace(" ", "_")
lines.append(f"- **{_display_value(smell.smell_type)}**: {affected}")
rec = _SMELL_RECOMMENDATIONS.get(smell_key)
if rec:
lines.append(f" - 💡 {rec}")
lines.append("")
else:
lines.append("### Smells")
lines.append("")
lines.append("No architectural smells detected.")
lines.append("No architectural smells detected.")
lines.append("")

# Metrics table (when no baseline — still useful)
# ── Metrics table (when no baseline — still useful) ──────────────────
if not drift:
lines.append("### Metrics")
lines.append("")
Expand All @@ -182,6 +281,27 @@ def build_report(
return "\n".join(lines)


def _metric_row(name: str, baseline_val: float | None, current_val: float) -> str:
"""Format a single metric row with direction indicator."""
if baseline_val is None:
return f"| {name} | — | {current_val:.2f} | — |"
delta = current_val - baseline_val
icon = _direction_icon(name, delta)
return f"| {name} | {baseline_val:.2f} | {current_val:.2f} | {icon} {_delta(delta)} |"


def _direction_icon(name: str, delta: float) -> str:
"""Return 🟢/🔴/⚪ based on whether the metric moved in a good direction."""
if abs(delta) < 0.005:
return "⚪"
if name in _HIGHER_IS_BETTER:
return "🟢" if delta > 0 else "🔴"
if name in _LOWER_IS_BETTER:
return "🟢" if delta < 0 else "🔴"
# Unknown metric — neutral
return "⚪"


def _delta(val: int | float) -> str:
"""Format a delta value with sign."""
if isinstance(val, float):
Expand Down Expand Up @@ -232,8 +352,9 @@ def main(argv: list[str] | None = None) -> None:
# 3. Load baseline and compare (if it exists)
drift = None
baseline = None
baseline_metrics: dict[str, float] = {}
if baseline_path.exists():
baseline = load_architecture(baseline_path)
baseline, baseline_metrics = load_architecture(baseline_path)
drift = compare(baseline, current)

# 4. Metrics and smells
Expand All @@ -247,13 +368,16 @@ def main(argv: list[str] | None = None) -> None:
)
metrics = metrics + derived_metrics

# 5. Update baseline if requested
# 5. Update baseline if requested (persist metrics for future deltas)
if args.update_baseline:
save_architecture(current, baseline_path)
metric_map = {m.name: m.value for m in metrics}
save_architecture(current, baseline_path, metrics=metric_map)
print(f"Baseline updated: {baseline_path}", file=sys.stderr)

# 6. Print report
report = build_report(current, graph, metrics, smells, drift, baseline)
report = build_report(
current, graph, metrics, smells, drift, baseline, baseline_metrics
)
print(report)


Expand Down
19 changes: 14 additions & 5 deletions src/arcade_agent/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@
from arcade_agent.parsers.graph import DependencyGraph, Edge, Entity


def save_architecture(arch: Architecture, path: Path) -> None:
def save_architecture(
arch: Architecture, path: Path, metrics: dict[str, float] | None = None
) -> None:
"""Write an Architecture to a JSON file.

Creates parent directories if they don't exist.

Args:
arch: The architecture to serialize.
path: Destination file path.
metrics: Optional metric name→value map to persist alongside the
architecture (used for baseline drift deltas).
"""
data = {
data: dict[str, Any] = {
"algorithm": arch.algorithm,
"rationale": arch.rationale,
"metadata": arch.metadata,
Expand All @@ -31,18 +35,21 @@ def save_architecture(arch: Architecture, path: Path) -> None:
for c in arch.components
],
}
if metrics:
data["metrics"] = metrics
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n")


def load_architecture(path: Path) -> Architecture:
def load_architecture(path: Path) -> tuple[Architecture, dict[str, float]]:
"""Read an Architecture from a JSON file.

Args:
path: Path to the baseline JSON file.

Returns:
The deserialized Architecture.
Tuple of (Architecture, baseline_metrics). The metrics dict is empty
when the baseline was created before metric persistence was added.

Raises:
FileNotFoundError: If the file does not exist.
Expand All @@ -56,12 +63,14 @@ def load_architecture(path: Path) -> Architecture:
)
for c in data.get("components", [])
]
return Architecture(
arch = Architecture(
components=components,
rationale=data.get("rationale", ""),
algorithm=data.get("algorithm", ""),
metadata=data.get("metadata", {}),
)
baseline_metrics: dict[str, float] = data.get("metrics", {})
return arch, baseline_metrics


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading