Skip to content

Commit fdec2af

Browse files
committed
feat(core): tighten report semantics and polish MCP and HTML projections
1 parent 0cae34a commit fdec2af

29 files changed

Lines changed: 1186 additions & 238 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ Before cutting a release:
299299
- Don’t introduce nondeterministic ordering (dict iteration, set ordering, filesystem traversal without sort).
300300
- Don’t make the base `codeclone` install depend on optional MCP runtime packages.
301301
- Don’t let MCP mutate baselines, source files, or repo state.
302+
- Don’t let MCP re-synthesize design findings from raw metrics; read canonical `findings.groups.design` only.
302303

303304
---
304305

@@ -367,6 +368,8 @@ Use this map to route changes to the right owner module.
367368
`sys.exit` behavior here.
368369
- `codeclone/mcp_server.py` — optional MCP launcher/server wiring, transport config, and MCP tool/resource
369370
registration; keep dependency loading lazy so base installs/CI do not require MCP runtime packages.
371+
- `tests/test_mcp_service.py`, `tests/test_mcp_server.py` — MCP contract and integration tests; run these when
372+
touching any MCP surface.
370373
- `codeclone/html_report.py` — public HTML facade/re-export surface; preserve backward-compatible imports here; do not
371374
grow section/layout logic in this module.
372375
- `codeclone/_html_report/*` — actual HTML assembly, context shaping, tabs, sections, and overview/navigation behavior;

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ sync SPDX headers.
2222
split — all without changing canonical report schema until the later `2.2` report-threshold update below.
2323
- `cache.effective_freshness` marker and `get_production_triage` / `codeclone://latest/triage` for compact
2424
production-first overview.
25+
- `compare_runs` now reports `mixed` when new regressions and `health_delta` point in opposite directions.
26+
- `compare_runs` now reports `incomparable` and omits `health_delta` when run roots or effective analysis settings do not match.
27+
- MCP summary/triage/health surfaces now mark `health` as unavailable in `clones_only` runs instead of emitting zeroed placeholders.
2528
- Fix hotlist key resolution for `production_hotspots` and `test_fixture_hotspots`.
2629
- Bump cache schema to `2.3` (stale metric entries rebuilt, not reused).
2730

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ codeclone-mcp --transport streamable-http --port 8000
170170
20 tools + 10 resources — deterministic, baseline-aware, and read-only. Never mutates source files, baselines, or repo
171171
state.
172172
Payloads are optimised for LLM context: compact summaries by default, full detail on demand.
173+
Run comparison stays compact too: `compare_runs` reports `mixed` when finding deltas and run-to-run health move in
174+
opposite directions, and `incomparable` when roots or effective analysis settings differ.
175+
When metrics are skipped (`clones_only`), MCP marks `health` as unavailable instead of returning fake zeros.
173176

174177
Docs:
175178
[MCP usage guide](https://orenlab.github.io/codeclone/mcp/)

codeclone/_html_css.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,14 @@
170170
background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);
171171
overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
172172
.main-tabs::-webkit-scrollbar{display:none}
173-
.main-tab{position:relative;flex:1;text-align:center;padding:var(--sp-2) var(--sp-3);
174-
background:none;border:none;cursor:pointer;font-size:.85rem;font-weight:500;
175-
color:var(--text-muted);white-space:nowrap;border-radius:var(--radius-md);
176-
transition:all var(--dur-fast) var(--ease)}
173+
.main-tab{position:relative;flex:1;display:inline-flex;align-items:center;justify-content:center;
174+
gap:var(--sp-1);text-align:center;padding:var(--sp-2) var(--sp-3);background:none;
175+
border:none;cursor:pointer;font-size:.85rem;font-weight:500;color:var(--text-muted);
176+
white-space:nowrap;border-radius:var(--radius-md);transition:all var(--dur-fast) var(--ease)}
177177
.main-tab:hover{color:var(--text-primary);background:var(--bg-raised)}
178178
.main-tab[aria-selected="true"]{color:var(--accent-primary);background:var(--accent-muted)}
179+
.main-tab-icon{flex-shrink:0;opacity:.72}
180+
.main-tab-label{display:inline-flex;align-items:center}
179181
.tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;
180182
height:18px;padding:0 5px;font-size:.7rem;font-weight:700;border-radius:9px;
181183
background:var(--bg-overlay);color:var(--text-muted);margin-left:var(--sp-1)}
@@ -656,6 +658,23 @@
656658
.breakdown-bar-track{height:6px;border-radius:3px;background:var(--bg-raised);overflow:hidden}
657659
.breakdown-bar-fill{display:block;height:100%;border-radius:3px;
658660
background:var(--accent-primary);transition:width .6s var(--ease)}
661+
/* Directory hotspot entries */
662+
.dir-hotspot-list{display:flex;flex-direction:column;gap:0}
663+
.dir-hotspot-entry{padding:var(--sp-2) 0;border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent)}
664+
.dir-hotspot-entry:last-child{border-bottom:none;padding-bottom:0}
665+
.dir-hotspot-entry:first-child{padding-top:0}
666+
.dir-hotspot-path{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:4px;min-width:0}
667+
.dir-hotspot-path code{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.3}
668+
.dir-hotspot-bar-row{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:3px}
669+
.dir-hotspot-bar-track{flex:1;height:4px;border-radius:2px;background:var(--bg-raised);
670+
overflow:hidden;display:flex}
671+
.dir-hotspot-bar-prev{height:100%;background:var(--text-muted);opacity:.18}
672+
.dir-hotspot-bar-cur{height:100%;background:var(--accent-primary);opacity:.7}
673+
.dir-hotspot-pct{font-size:.7rem;font-weight:600;font-variant-numeric:tabular-nums;
674+
color:var(--text-muted);min-width:3.2em;text-align:right}
675+
.dir-hotspot-meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)}
676+
.dir-hotspot-meta span{font-variant-numeric:tabular-nums}
677+
.dir-hotspot-meta-sep{opacity:.3}
659678
/* Health radar chart */
660679
.health-radar{display:flex;justify-content:center;padding:var(--sp-3) 0}
661680
.health-radar svg{width:100%;max-width:520px;height:auto;overflow:visible}
@@ -781,10 +800,10 @@
781800
.suggestion-sev-inline{font-size:.72rem;font-weight:600;padding:1px var(--sp-1);
782801
border-radius:var(--radius-sm)}
783802
.suggestion-title{font-weight:600;font-size:.85rem;color:var(--text-primary);flex:1;min-width:0}
784-
.suggestion-meta{display:flex;align-items:center;gap:var(--sp-1);flex-shrink:0;flex-wrap:wrap}
785-
.suggestion-meta-badge{font-size:.68rem;font-family:var(--font-mono);font-weight:500;
786-
padding:1px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-overlay);
787-
color:var(--text-muted);white-space:nowrap}
803+
.suggestion-meta{display:flex;align-items:center;gap:var(--sp-2);flex-shrink:0;flex-wrap:wrap}
804+
.suggestion-meta-badge{font-size:.68rem;font-weight:600;padding:2px var(--sp-2);
805+
border-radius:999px;background:var(--bg-overlay);color:var(--text-muted);
806+
white-space:nowrap;line-height:1.2;font-variant-numeric:tabular-nums}
788807
.suggestion-effort--easy{color:var(--success);background:var(--success-muted, rgba(34,197,94,.1))}
789808
.suggestion-effort--moderate{color:var(--warning);background:var(--warning-muted)}
790809
.suggestion-effort--hard{color:var(--error);background:var(--error-muted)}
@@ -1092,6 +1111,7 @@
10921111
linear-gradient(to left,rgba(0,0,0,.12),transparent) right center / 10px 100% no-repeat scroll,
10931112
var(--bg-surface)}
10941113
.main-tab{flex:none;padding:var(--sp-1) var(--sp-2);font-size:.78rem}
1114+
.main-tab-icon{width:13px;height:13px}
10951115
}
10961116
@media(max-width:480px){
10971117
.overview-kpi-grid{grid-template-columns:1fr}

codeclone/_html_report/_assemble.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from ..structural_findings import normalize_structural_findings
2222
from ..templates import FONT_CSS_URL, REPORT_TEMPLATE
2323
from ._context import _meta_pick, build_context
24-
from ._icons import BRAND_LOGO, ICONS
24+
from ._icons import BRAND_LOGO, ICONS, section_icon_html
2525
from ._sections._clones import render_clones_panel
2626
from ._sections._coupling import render_quality_panel
2727
from ._sections._dead_code import render_dead_code_panel
@@ -119,6 +119,15 @@ def _tab_badge(count: int) -> str:
119119
return f'<span class="tab-count">{count}</span>'
120120

121121
# -- Main tab navigation --
122+
tab_icon_keys: dict[str, str] = {
123+
"overview": "overview",
124+
"clones": "clones",
125+
"quality": "quality",
126+
"dependencies": "dependencies",
127+
"dead-code": "dead-code",
128+
"suggestions": "suggestions",
129+
"structural-findings": "structural-findings",
130+
}
122131
tab_defs = [
123132
("overview", "Overview", overview_html, ""),
124133
("clones", "Clones", clones_html, _tab_badge(ctx.clone_groups_total)),
@@ -151,10 +160,15 @@ def _tab_badge(count: int) -> str:
151160
extra = tab_extra_attrs.get(tab_id, "")
152161
if extra:
153162
extra = " " + extra
163+
tab_icon = section_icon_html(
164+
tab_icon_keys.get(tab_id, ""),
165+
class_name="main-tab-icon",
166+
size=14,
167+
)
154168
tab_buttons.append(
155169
f'<button class="main-tab" role="tab" data-tab="{tab_id}" '
156170
f'aria-selected="{selected}" aria-controls="panel-{tab_id}"{extra}>'
157-
f"{tab_label}{badge}</button>"
171+
f'{tab_icon}<span class="main-tab-label">{tab_label}</span>{badge}</button>'
158172
)
159173
active = " active" if idx == 0 else ""
160174
tab_panels.append(

codeclone/_html_report/_components.py

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .. import _coerce
1515
from .._html_badges import _source_kind_badge_html
1616
from .._html_escape import _escape_attr, _escape_html
17+
from ._icons import section_icon_html
1718

1819
_as_int = _coerce.as_int
1920
_as_mapping = _coerce.as_mapping
@@ -52,52 +53,24 @@ def overview_cluster_header(title: str, subtitle: str | None = None) -> str:
5253
)
5354

5455

55-
_ICON_ALERT = (
56-
'<svg class="summary-icon summary-icon--risk" width="16" height="16" '
57-
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
58-
'stroke-linecap="round" stroke-linejoin="round">'
59-
'<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86'
60-
'a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/>'
61-
'<line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
62-
)
63-
64-
_ICON_PIE = (
65-
'<svg class="summary-icon summary-icon--info" width="16" height="16" '
66-
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
67-
'stroke-linecap="round" stroke-linejoin="round">'
68-
'<path d="M21.21 15.89A10 10 0 118 2.83"/>'
69-
'<path d="M22 12A10 10 0 0012 2v10z"/></svg>'
70-
)
71-
72-
_ICON_RADAR = (
73-
'<svg class="summary-icon summary-icon--info" width="16" height="16" '
74-
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
75-
'stroke-linecap="round" stroke-linejoin="round">'
76-
'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/>'
77-
'<circle cx="12" cy="12" r="2"/>'
78-
'<line x1="12" y1="2" x2="12" y2="6"/>'
79-
'<line x1="12" y1="18" x2="12" y2="22"/></svg>'
80-
)
81-
82-
_ICON_BAR = (
83-
'<svg class="summary-icon summary-icon--info" width="16" height="16" '
84-
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
85-
'stroke-linecap="round" stroke-linejoin="round">'
86-
'<rect x="3" y="3" width="18" height="4" rx="1"/>'
87-
'<rect x="3" y="10" width="13" height="4" rx="1"/>'
88-
'<rect x="3" y="17" width="8" height="4" rx="1"/></svg>'
89-
)
90-
91-
_SUMMARY_ICONS: dict[str, str] = {
92-
"top risks": _ICON_ALERT,
93-
"source breakdown": _ICON_PIE,
94-
"health profile": _ICON_RADAR,
95-
"issue breakdown": _ICON_BAR,
56+
_SUMMARY_ICON_KEYS: dict[str, tuple[str, str]] = {
57+
"top risks": ("top-risks", "summary-icon summary-icon--risk"),
58+
"issue breakdown": ("issue-breakdown", "summary-icon summary-icon--info"),
59+
"source breakdown": ("source-breakdown", "summary-icon summary-icon--info"),
60+
"all findings": ("all-findings", "summary-icon summary-icon--info"),
61+
"clone groups": ("clone-groups", "summary-icon summary-icon--info"),
62+
"low cohesion": ("low-cohesion", "summary-icon summary-icon--info"),
63+
"health profile": ("health-profile", "summary-icon summary-icon--info"),
9664
}
9765

9866

9967
def overview_summary_item_html(*, label: str, body_html: str) -> str:
100-
icon = _SUMMARY_ICONS.get(label.lower(), "")
68+
icon_key, icon_class = _SUMMARY_ICON_KEYS.get(label.lower(), ("", ""))
69+
icon = (
70+
section_icon_html(icon_key, class_name=icon_class)
71+
if icon_key and icon_class
72+
else ""
73+
)
10174
return (
10275
'<article class="overview-summary-item">'
10376
'<div class="overview-summary-label">'

codeclone/_html_report/_icons.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ def _svg(size: int, sw: str, body: str) -> str:
1717
)
1818

1919

20+
def _svg_with_class(size: int, sw: str, body: str, *, class_name: str = "") -> str:
21+
class_attr = f' class="{class_name}"' if class_name else ""
22+
return (
23+
f'<svg{class_attr} width="{size}" height="{size}" viewBox="0 0 24 24" '
24+
f'fill="none" stroke="currentColor" stroke-width="{sw}" '
25+
f'stroke-linecap="round" stroke-linejoin="round">{body}</svg>'
26+
)
27+
28+
2029
BRAND_LOGO = (
2130
'<svg class="brand-logo" width="32" height="32" viewBox="0 0 32 32" fill="none">'
2231
'<rect x="9" y="3" width="18" height="23" rx="3.5" '
@@ -82,3 +91,99 @@ def _svg(size: int, sw: str, body: str) -> str:
8291
'<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
8392
),
8493
}
94+
95+
_SECTION_ICON_BODIES: dict[str, tuple[str, str]] = {
96+
"overview": (
97+
"2",
98+
'<rect x="3" y="4" width="8" height="7" rx="1.5"/>'
99+
'<rect x="13" y="4" width="8" height="7" rx="1.5"/>'
100+
'<rect x="3" y="13" width="8" height="7" rx="1.5"/>'
101+
'<rect x="13" y="13" width="8" height="7" rx="1.5"/>',
102+
),
103+
"clones": (
104+
"2",
105+
'<rect x="9" y="9" width="10" height="10" rx="2"/>'
106+
'<rect x="5" y="5" width="10" height="10" rx="2"/>',
107+
),
108+
"quality": (
109+
"2",
110+
'<path d="M4 19h16"/><rect x="5" y="11" width="3" height="6" rx="1"/>'
111+
'<rect x="10.5" y="7" width="3" height="10" rx="1"/>'
112+
'<rect x="16" y="4" width="3" height="13" rx="1"/>',
113+
),
114+
"dependencies": (
115+
"2",
116+
'<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/>'
117+
'<circle cx="12" cy="18" r="2"/><path d="M8 7.5l2.5 6.5"/>'
118+
'<path d="M16 7.5l-2.5 6.5"/>',
119+
),
120+
"dead-code": (
121+
"2",
122+
'<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>'
123+
'<path d="M14 3v6h6"/><path d="M9 14l6 6"/><path d="M15 14l-6 6"/>',
124+
),
125+
"suggestions": (
126+
"2",
127+
'<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8Z"/>'
128+
'<path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8Z"/>',
129+
),
130+
"structural-findings": (
131+
"2",
132+
'<circle cx="6" cy="6" r="2"/><circle cx="18" cy="18" r="2"/>'
133+
'<circle cx="18" cy="6" r="2"/><path d="M8 6h8"/>'
134+
'<path d="M6 8v8a2 2 0 0 0 2 2h8"/>',
135+
),
136+
"top-risks": (
137+
"2",
138+
'<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86'
139+
'a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/>'
140+
'<line x1="12" y1="17" x2="12.01" y2="17"/>',
141+
),
142+
"issue-breakdown": (
143+
"2",
144+
'<rect x="3" y="3" width="18" height="4" rx="1"/>'
145+
'<rect x="3" y="10" width="13" height="4" rx="1"/>'
146+
'<rect x="3" y="17" width="8" height="4" rx="1"/>',
147+
),
148+
"source-breakdown": (
149+
"2",
150+
'<path d="M21.21 15.89A10 10 0 118 2.83"/>'
151+
'<path d="M22 12A10 10 0 0012 2v10z"/>',
152+
),
153+
"health-profile": (
154+
"2",
155+
'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/>'
156+
'<circle cx="12" cy="12" r="2"/><line x1="12" y1="2" x2="12" y2="6"/>'
157+
'<line x1="12" y1="18" x2="12" y2="22"/>',
158+
),
159+
"all-findings": (
160+
"2",
161+
'<circle cx="5" cy="6" r="1.5"/><circle cx="5" cy="12" r="1.5"/>'
162+
'<circle cx="5" cy="18" r="1.5"/><path d="M10 6h10"/><path d="M10 12h10"/>'
163+
'<path d="M10 18h10"/>',
164+
),
165+
"clone-groups": (
166+
"2",
167+
'<rect x="9" y="9" width="10" height="10" rx="2"/>'
168+
'<rect x="5" y="5" width="10" height="10" rx="2"/>',
169+
),
170+
"low-cohesion": (
171+
"2",
172+
'<rect x="4" y="5" width="6" height="14" rx="1.5"/>'
173+
'<rect x="14" y="5" width="6" height="14" rx="1.5"/>'
174+
'<path d="M10 12h4"/>',
175+
),
176+
}
177+
178+
179+
def section_icon_html(
180+
key: str,
181+
*,
182+
class_name: str = "",
183+
size: int = 16,
184+
) -> str:
185+
spec = _SECTION_ICON_BODIES.get(key.strip().lower())
186+
if spec is None:
187+
return ""
188+
stroke_width, body = spec
189+
return _svg_with_class(size, stroke_width, body, class_name=class_name)

0 commit comments

Comments
 (0)