'
- f"{_bar(span.duration_ms, op_duration, color=color)}"
+ f"{_bar(span.duration_ms, op_duration, color=color, offset_ms=offset_ms)}"
f'
'
f''
@@ -550,7 +735,11 @@ def _span_row(span: SpanView, op_duration: float) -> str:
def _op_block(op: OperationView, group_max: float) -> str:
op_duration = op.duration_ms or 1.0
- spans = "".join(_span_row(span, op_duration) for span in op.spans)
+ op_start = _epoch_ms(op.started_at_utc)
+ spans = "".join(
+ _span_row(span, op_duration, offset_ms=_span_offset_ms(op_start, span))
+ for span in op.spans
+ )
spans_block = f'
' if spans else ""
kids = "".join(_op_block(child, group_max) for child in op.children)
kids_block = f'
' if kids else ""
@@ -578,11 +767,13 @@ def _chain(trace: TraceView) -> str:
return _section(
"Correlated event chains",
f'
',
- subtitle="What triggered what, across processes — finish → spawned worker.",
+ subtitle="What triggered what, across processes — finish → spawned worker. "
+ "Span bars cascade by start offset within their operation (the staircase is "
+ "the real order); width is duration.",
)
-def _semantic_row(span: SpanCostView) -> str:
+def _semantic_row(span: SpanCostView, *, lead: bool) -> str:
costly = span.no_op and span.duration_ms >= _NOOP_COSTLY_MS
if costly:
verdict = '
'
@@ -593,8 +784,9 @@ def _semantic_row(span: SpanCostView) -> str:
reason = (
_esc(span.reason_kind) if span.reason_kind else '
'
)
+ cls = " ".join(name for name, on in (("flag", costly), ("lead", lead)) if on)
return (
- f'
'
)
def _mcp(tools: tuple[McpToolAggregate, ...]) -> str:
if not tools:
return ""
- rows = "".join(_mcp_row(tool) for tool in tools)
+ lead = max(
+ range(len(tools)),
+ key=lambda i: tools[i].p95_response_bytes or 0,
+ default=-1,
+ )
+ rows = "".join(_mcp_row(tool, lead=(i == lead)) for i, tool in enumerate(tools))
headers = (
("Tool", False),
("Calls", True),
@@ -651,13 +856,13 @@ def _mcp(tools: tuple[McpToolAggregate, ...]) -> str:
("p95", True),
("↑ req p95", True),
("↓ resp p95", True),
- ("resp tok p95", True),
+ ("resp ctx p95", True),
)
return _section(
"MCP tool matrix",
_table(headers, rows),
subtitle="Per-tool latency and payload — spot tools that flood request "
- "or response bytes.",
+ "or response context units.",
)
@@ -666,13 +871,12 @@ def _wf_bar(row: WaterfallRow, total_ms: float) -> str:
left = round(min(row.offset_ms / span * 100, 99.0), 2)
width = max(0.6, round(row.duration_ms / span * 100, 2))
kind = "op" if row.kind == "operation" else "span"
- surf = f"surf-{row.surface}" if row.surface in _KNOWN_SURFACES else ""
tick = '
'
f'
'
f"{tick}{_esc(row.label)} "
- f'
'
f'
{_ms(row.duration_ms)} '
)
@@ -700,13 +904,13 @@ def _waterfall(trace: TraceView) -> str:
)
-def _agent_row(row: AgentTokenRow, total_response: int) -> str:
+def _agent_row(row: AgentTokenRow, total_response: int, *, lead: bool) -> str:
share = round(row.response_tokens / total_response * 100) if total_response else 0
return (
- f'
{_esc(row.name)} '
+ f'{_esc(row.name)} '
f'{row.calls} '
- f'{_tokens(row.request_tokens)} '
- f'{_tokens(row.response_tokens)} '
+ f'{_context_units(row.request_tokens)} '
+ f'{_context_units(row.response_tokens)} '
f'{share}% '
)
@@ -717,32 +921,40 @@ def _agent(agg: AggregatesView) -> str:
return ""
cards = (
'
'
- + _stat(_tokens(view.response_tokens), "context pressure (tok)", "accent")
- + _stat(_tokens(view.request_tokens), "sent (tok)")
+ + _stat(_context_units(view.response_tokens), "context pressure", "accent")
+ + _stat(_context_units(view.request_tokens), "sent context")
+ _stat(str(view.mcp_calls), "mcp calls")
+ _stat(str(len(view.consumers)), "tools")
+ "
"
)
- rows = "".join(_agent_row(row, view.response_tokens) for row in view.consumers)
+ lead = max(
+ range(len(view.consumers)),
+ key=lambda i: view.consumers[i].response_tokens,
+ default=-1,
+ )
+ rows = "".join(
+ _agent_row(row, view.response_tokens, lead=(i == lead))
+ for i, row in enumerate(view.consumers)
+ )
headers = (
("Tool", False),
("Calls", True),
- ("↑ tok", True),
- ("↓ tok", True),
+ ("↑ ctx", True),
+ ("↓ ctx", True),
("Context %", True),
)
return _section(
"Agent context",
cards + _table(headers, rows),
- subtitle="Tokens MCP tools push back into the agent's context — the real "
- "per-call cost for an LLM. The top row is your biggest context consumer.",
+ subtitle="Estimated context units MCP tools push back into the agent's "
+ "context. The top row is your biggest context consumer.",
)
-def _db_row(row: DbCostRow) -> str:
+def _db_row(row: DbCostRow, *, lead: bool) -> str:
per_call = round(row.total_queries / row.span_count) if row.span_count else 0
return (
- f'
{_esc(row.span_name)} '
+ f'{_esc(row.span_name)} '
f'{row.span_count} '
f'{row.total_queries} '
f'{row.total_writes} '
@@ -754,7 +966,12 @@ def _db_row(row: DbCostRow) -> str:
def _db_cost(agg: AggregatesView) -> str:
if not agg.db_costs:
return ""
- rows = "".join(_db_row(row) for row in agg.db_costs)
+ lead = max(
+ range(len(agg.db_costs)),
+ key=lambda i: agg.db_costs[i].total_queries,
+ default=-1,
+ )
+ rows = "".join(_db_row(row, lead=(i == lead)) for i, row in enumerate(agg.db_costs))
headers = (
("Span", False),
("Spans", True),
@@ -771,12 +988,12 @@ def _db_cost(agg: AggregatesView) -> str:
)
-def _db_fingerprint_row(row: DbFingerprintRow) -> str:
+def _db_fingerprint_row(row: DbFingerprintRow, *, lead: bool) -> str:
table = _esc(row.table_hint) if row.table_hint else "—"
shape = _esc(row.summary) if row.summary else "—"
raw = _esc(row.fingerprint)
return (
- f'{_esc(row.span_name)} '
+ f'{_esc(row.span_name)} '
f"{table} "
f'{_esc(row.kind.upper())} '
f'{row.count} '
@@ -788,7 +1005,15 @@ def _db_fingerprint_row(row: DbFingerprintRow) -> str:
def _db_fingerprints(agg: AggregatesView) -> str:
if not agg.db_fingerprints:
return ""
- rows = "".join(_db_fingerprint_row(row) for row in agg.db_fingerprints)
+ lead = max(
+ range(len(agg.db_fingerprints)),
+ key=lambda i: agg.db_fingerprints[i].count,
+ default=-1,
+ )
+ rows = "".join(
+ _db_fingerprint_row(row, lead=(i == lead))
+ for i, row in enumerate(agg.db_fingerprints)
+ )
headers = (
("Span", False),
("Table", False),
@@ -804,9 +1029,9 @@ def _db_fingerprints(agg: AggregatesView) -> str:
)
-def _pipeline_row(group: PipelineGroup) -> str:
+def _pipeline_row(group: PipelineGroup, *, lead: bool) -> str:
return (
- f'{_esc(group.name)} '
+ f'{_esc(group.name)} '
f'{group.op_count} '
f'{_ms(group.duration_ms)} '
f'{_ms(group.cpu_ms)} '
@@ -816,7 +1041,14 @@ def _pipeline_row(group: PipelineGroup) -> str:
def _pipeline_section(agg: AggregatesView) -> str:
if not agg.pipeline:
return ""
- rows = "".join(_pipeline_row(group) for group in agg.pipeline)
+ lead = max(
+ range(len(agg.pipeline)),
+ key=lambda i: agg.pipeline[i].duration_ms,
+ default=-1,
+ )
+ rows = "".join(
+ _pipeline_row(group, lead=(i == lead)) for i, group in enumerate(agg.pipeline)
+ )
headers = (("Subsystem", False), ("Ops", True), ("Wall", True), ("CPU", True))
return _section(
"Pipeline",
@@ -825,25 +1057,194 @@ def _pipeline_section(agg: AggregatesView) -> str:
)
+def _analysis_phase_row(row: AnalysisPhaseRow, max_permille: int, *, lead: bool) -> str:
+ label = _ANALYSIS_PHASE_LABELS.get(row.phase, row.phase)
+ sig = '
peak ' if lead else ""
+ return (
+ f'
'
+ f'{_esc(label)} '
+ f'{_esc(row.phase)} '
+ f"{_bar(row.share_permille, max_permille)}"
+ f'{_esc(_ms(row.worker_elapsed_ms))} '
+ f'{row.share_permille / 10:.1f}% '
+ f'{sig}
'
+ )
+
+
+def _iter_operation_tree(ops: tuple[OperationView, ...]) -> Iterable[OperationView]:
+ for op in ops:
+ yield op
+ yield from _iter_operation_tree(op.children)
+
+
+def _pipeline_process_spans(trace: TraceView) -> tuple[SpanView, ...]:
+ roots = trace.operation_tree or trace.correlated_operations
+ spans: list[SpanView] = []
+ seen: set[str] = set()
+ for op in _iter_operation_tree(roots):
+ for span in op.spans:
+ if span.name == "pipeline.process" and span.span_id not in seen:
+ spans.append(span)
+ seen.add(span.span_id)
+ return tuple(spans)
+
+
+def _empty_analysis_phase_section(trace: TraceView) -> str:
+ process_spans = _pipeline_process_spans(trace)
+ if not process_spans:
+ return ""
+ files_analyzed = sum(
+ span.counters.get("files_analyzed", 0) for span in process_spans
+ )
+ failed_files = sum(span.counters.get("failed_files", 0) for span in process_spans)
+ if files_analyzed == 0:
+ reason = (
+ "No uncached files were processed in this window; the analysis was "
+ "served from cache, so file extraction micro-stages did not run. "
+ "Use a cold cache or changed files to capture phase timings."
+ )
+ else:
+ reason = (
+ "pipeline.process ran, but no analysis phase counters were recorded. "
+ "Restart the producing process with CODECLONE_OBSERVABILITY_ENABLED=1 "
+ "and analysis phase instrumentation."
+ )
+ counters = (
+ f"pipeline.process files_analyzed={files_analyzed} · "
+ f"failed_files={failed_files}"
+ )
+ body = (
+ '
'
+ f"{_esc(reason)}"
+ f'
{_esc(counters)}
'
+ "
"
+ )
+ return _section(
+ "Analysis extract phases",
+ body,
+ subtitle=(
+ "Summed per-file worker elapsed time inside pipeline.process "
+ "(parse, walk, CFG, normalize). Dev-only; not repository quality."
+ ),
+ )
+
+
+def _analysis_phases_section(trace: TraceView) -> str:
+ agg = trace.aggregates
+ if not agg.analysis_phases:
+ return _empty_analysis_phase_section(trace)
+ max_permille = max((row.share_permille for row in agg.analysis_phases), default=1)
+ max_permille = max_permille or 1
+ lead_idx = max(
+ range(len(agg.analysis_phases)),
+ key=lambda i: agg.analysis_phases[i].share_permille,
+ default=-1,
+ )
+ rows = "".join(
+ _analysis_phase_row(row, max_permille, lead=(i == lead_idx))
+ for i, row in enumerate(agg.analysis_phases)
+ )
+ footer = (
+ f"Worker elapsed (summed): "
+ f"{_ms(agg.analysis_phase_worker_elapsed_total_ms or 0.0)} · "
+ f"pipeline.process wall: {_ms(agg.analysis_phase_pipeline_wall_ms or 0.0)} · "
+ f"files timed: {agg.analysis_phase_files_timed} · "
+ f"units eligible: {agg.analysis_phase_units_eligible}"
+ )
+ body = f'
{rows}
{_esc(footer)}
'
+ return _section(
+ "Analysis extract phases",
+ body,
+ subtitle=(
+ "Where the core spends its per-file extract time, ranked by share — "
+ "bars are scaled to the heaviest phase. Summed worker elapsed inside "
+ "pipeline.process; dev-only, not repository quality, and may exceed "
+ "parent pipeline wall under parallel execution."
+ ),
+ )
+
+
+_TABS: tuple[tuple[str, str], ...] = (
+ ("overview", "Overview"),
+ ("timeline", "Timeline"),
+ ("operations", "Operations"),
+ ("cost", "Cost"),
+ ("phases", "Phases"),
+)
+
+# One plain-language lead per tab: what the view answers, what to look at first.
+_TAB_LEADS: Mapping[str, str] = {
+ "overview": "Start here — what this run did, and where its time and memory "
+ "actually went.",
+ "timeline": "When everything happened — operations and their spans on one "
+ "shared time axis.",
+ "operations": "What ran — the finish→worker causality chains, nested by call "
+ "depth.",
+ "cost": "What it cost — context units, MCP payloads, and database work.",
+ "phases": "Inside analysis — pipeline stages and per-phase extract cost.",
+}
+
+
+def _tab_shell(panels: Mapping[str, str]) -> str:
+ """Wrap the section panels in CSS-only radio tabs.
+
+ The radio inputs are emitted first so the ``:checked ~`` sibling selectors
+ can light the active tab label and reveal the matching panel without any
+ script. An empty panel falls back to a placeholder so a view is never blank.
+ """
+ inputs = "".join(
+ f'
'
+ for idx, (tid, _) in enumerate(_TABS)
+ )
+ nav = (
+ '
'
+ + "".join(
+ f'{_esc(label)} '
+ for tid, label in _TABS
+ )
+ + " "
+ )
+ sections: list[str] = []
+ for tid, label in _TABS:
+ inner = panels.get(tid, "")
+ if not inner.strip():
+ inner = (
+ f'
No {_esc(label.lower())} data '
+ f"recorded for this window.
"
+ )
+ lead = _TAB_LEADS.get(tid, "")
+ lead_html = f'
{_esc(lead)}
' if lead else ""
+ sections.append(
+ f'
'
+ )
+ return f'{inputs}{nav}
{"".join(sections)}
'
+
+
def render_trace_html(trace: TraceView) -> str:
"""Render a ``TraceView`` as a self-contained, branded diagnosis cockpit."""
+ agg = trace.aggregates
foot = f"CodeClone · platform observability · schema {_esc(trace.schema_version)}"
+ panels = {
+ "overview": _summary(trace) + _waste_section(agg),
+ "timeline": _waterfall(trace),
+ "operations": _chain(trace),
+ "cost": (
+ _semantic(agg)
+ + _db_cost(agg)
+ + _db_fingerprints(agg)
+ + _agent(agg)
+ + _mcp(agg.mcp_tools)
+ ),
+ "phases": _pipeline_section(agg) + _analysis_phases_section(trace),
+ }
return (
'
'
'
'
"
CodeClone · Platform Observability "
f'
'
+ _header(trace)
- + _summary(trace)
- + _waste_section(trace.aggregates)
- + _waterfall(trace)
- + _chain(trace)
- + _semantic(trace.aggregates)
- + _db_cost(trace.aggregates)
- + _db_fingerprints(trace.aggregates)
- + _agent(trace.aggregates)
- + _mcp(trace.aggregates.mcp_tools)
- + _pipeline_section(trace.aggregates)
+ + _tab_shell(panels)
+ f''
+ "
"
)
diff --git a/codeclone/observability/render_json.py b/codeclone/observability/render_json.py
index b00d3217..c7536747 100644
--- a/codeclone/observability/render_json.py
+++ b/codeclone/observability/render_json.py
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Den Rozhnovskiy
-"""JSON renderer for the observability ``TraceView`` (Phase 29 output).
+"""JSON renderer for the observability ``TraceView``.
Deterministic: sorted keys, stable indentation. The read model is the source of
truth; this is a faithful projection of it.
diff --git a/codeclone/observability/runtime.py b/codeclone/observability/runtime.py
index 2bb19fc4..17d3ad3e 100644
--- a/codeclone/observability/runtime.py
+++ b/codeclone/observability/runtime.py
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Den Rozhnovskiy
-"""Observability write API (Phase 29 §4.3).
+"""Observability write API.
``bootstrap`` freezes the enabled decision once per process. When disabled,
``operation``/``span`` yield a cheap inert handle and return immediately — no
@@ -18,7 +18,7 @@
import sqlite3
import time
import uuid
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime, timezone
@@ -458,6 +458,15 @@ def record_elapsed_span(
_DB_WRITE_KINDS = frozenset({"insert", "update", "delete", "replace"})
+# Counter-semantics version. v1 counted db_queries/db_writes per *row*:
+# sqlite3.set_trace_callback fires once per executemany row, so a single batched
+# executemany was indistinguishable from an N+1 loop and tripped false
+# query_chatty verdicts. v2 (the _CountingConnection below) counts logical
+# *statements* — db_queries/db_writes are execute/executemany calls and db_rows
+# is the row volume. Bump on any counter-meaning change; old observer DBs carry
+# the previous semantics and are disposable (delete to avoid mixed history).
+DB_COUNTER_VERSION = 2
+
def _classify_sql(sql: str) -> str:
stripped = sql.lstrip()
@@ -466,15 +475,17 @@ def _classify_sql(sql: str) -> str:
return stripped.split(None, 1)[0].lower()
-def record_db_query(sql: str) -> None:
- """Trace-callback sink: attribute one SQL statement to the active span as a
- ``db_queries`` counter (plus ``db_writes`` for mutations). No-op outside a
- span. Performance telemetry only — never audit or contract truth.
+def _record_db_statement(sql: str, *, rows: int) -> None:
+ """Attribute one logical SQL statement to the active span: ``db_queries`` +1
+ (``db_writes`` +1 for mutations) and ``db_rows`` += ``rows`` (1 for
+ ``execute``, len(params) for ``executemany``). No-op outside a span.
+ Performance telemetry only — never audit or contract truth.
"""
span_handle = _CURRENT_SPAN.get()
if span_handle is None:
return
span_handle.add_counter("db_queries", 1)
+ span_handle.add_counter("db_rows", rows)
if _classify_sql(sql) in _DB_WRITE_KINDS:
span_handle.add_counter("db_writes", 1)
fingerprint = fingerprint_sql(sql).fingerprint
@@ -482,6 +493,16 @@ def record_db_query(sql: str) -> None:
span_handle.add_db_fingerprint(fingerprint)
+def record_db_query(sql: str) -> None:
+ """Record one logical query (1 statement, 1 row) on the active span.
+
+ Retained as the manual entry point for code that does DB work the counting
+ connection does not see (and used by tests). Equivalent to a single
+ ``execute`` for counting purposes.
+ """
+ _record_db_statement(sql, rows=1)
+
+
def record_counter(key: str, value: int = 1) -> None:
"""Add ``value`` to the named counter on the active span. No-op outside a
span (or when disabled). Companion to ``record_db_query`` for non-SQL
@@ -494,21 +515,53 @@ def record_counter(key: str, value: int = 1) -> None:
span_handle.add_counter(key, value)
-def instrument_db_connection(conn: sqlite3.Connection) -> None:
- """Attach the per-span DB-query counter to ``conn``. No-op (and no per-query
- trace overhead) when observability is disabled for this process.
+_SqlParams = Sequence[object] | Mapping[str, object]
+
+
+class _CountingConnection(sqlite3.Connection):
+ """``sqlite3.Connection`` that counts logical statements on the active span.
+
+ Overriding ``execute``/``executemany`` — instead of ``set_trace_callback``,
+ which fires once per executemany *row* — is what makes ``db_queries`` a true
+ statement count: one batched ``executemany`` is one query over many rows,
+ distinguishable from an N+1 loop. All store access goes through these entry
+ points (no bare cursors), so nothing escapes the count. Counting no-ops
+ outside a span, so connection open (pragmas, schema) is not attributed.
+ """
+
+ def execute( # type: ignore[override]
+ self, sql: str, parameters: _SqlParams = ()
+ ) -> sqlite3.Cursor:
+ _record_db_statement(sql, rows=1)
+ return super().execute(sql, parameters)
+
+ def executemany( # type: ignore[override]
+ self, sql: str, parameters: Iterable[_SqlParams]
+ ) -> sqlite3.Cursor:
+ materialized = list(parameters)
+ _record_db_statement(sql, rows=len(materialized))
+ return super().executemany(sql, materialized)
+
+ def executescript(self, sql_script: str) -> sqlite3.Cursor:
+ _record_db_statement(sql_script, rows=1)
+ return super().executescript(sql_script)
+
+
+def counting_connection_factory() -> type[sqlite3.Connection] | None:
+ """Return the per-span counting connection class when observability is
+ enabled, else ``None`` so callers open a plain connection with no overhead.
"""
- if _ENABLED:
- conn.set_trace_callback(record_db_query)
+ return _CountingConnection if _ENABLED else None
__all__ = [
+ "DB_COUNTER_VERSION",
"OperationHandle",
"SpanHandle",
"bind_root",
"bootstrap",
+ "counting_connection_factory",
"current_operation_context",
- "instrument_db_connection",
"is_observability_enabled",
"operation",
"payload_capture_enabled",
diff --git a/codeclone/observability/sqlite_access.py b/codeclone/observability/sqlite_access.py
index 372ea71b..5e385662 100644
--- a/codeclone/observability/sqlite_access.py
+++ b/codeclone/observability/sqlite_access.py
@@ -22,16 +22,15 @@ def open_instrumented_sqlite_db(
foreign_keys: bool = False,
synchronous: str | None = None,
) -> sqlite3.Connection:
- conn = open_sqlite_db(
+ from codeclone.observability.runtime import counting_connection_factory
+
+ return open_sqlite_db(
path,
ensure_schema=ensure_schema,
foreign_keys=foreign_keys,
synchronous=synchronous,
+ factory=counting_connection_factory(),
)
- from codeclone.observability.runtime import instrument_db_connection
-
- instrument_db_connection(conn)
- return conn
def open_instrumented_sqlite_db_readonly(
@@ -39,11 +38,13 @@ def open_instrumented_sqlite_db_readonly(
*,
validate_schema: Callable[[sqlite3.Connection], None],
) -> sqlite3.Connection:
- conn = open_sqlite_db_readonly(path, validate_schema=validate_schema)
- from codeclone.observability.runtime import instrument_db_connection
+ from codeclone.observability.runtime import counting_connection_factory
- instrument_db_connection(conn)
- return conn
+ return open_sqlite_db_readonly(
+ path,
+ validate_schema=validate_schema,
+ factory=counting_connection_factory(),
+ )
__all__ = [
diff --git a/codeclone/observability/store/reader.py b/codeclone/observability/store/reader.py
index 5ef6f15d..9b3000e8 100644
--- a/codeclone/observability/store/reader.py
+++ b/codeclone/observability/store/reader.py
@@ -14,18 +14,24 @@
import sqlite3
from collections import defaultdict
+from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import cast
import orjson
+from ...analysis.phase_ledger import (
+ PHASE_US_COUNTER_SUFFIXES,
+ PHASE_VOLUME_COUNTER_SUFFIXES,
+)
from ...contracts import PLATFORM_OBSERVABILITY_SCHEMA_VERSION
from ..db_fingerprint import describe_fingerprint
from ..views import (
AgentTokenRow,
AgentView,
AggregatesView,
+ AnalysisPhaseRow,
DbCostRow,
DbFingerprintRow,
McpToolAggregate,
@@ -48,12 +54,14 @@
_MEMORY_PIPELINE_PREFIX = "memory."
_SEMANTIC_COST_LIMIT = 8
_DB_FINGERPRINT_ROW_LIMIT = 15
+_PIPELINE_PROCESS_SPAN = "pipeline.process"
+_PHASE_HEAVY_PERMILLE = 250
# Waste thresholds: a no-op span is only worth flagging once it has spent time;
# an MCP response is "heavy" past these payload sizes.
_WASTE_NOOP_MS = 50.0
_HIGH_PAYLOAD_BYTES = 16 * 1024
-_HIGH_PAYLOAD_TOKENS = 4000
+_HIGH_PAYLOAD_CONTEXT_UNITS = 4000
def open_observability_store_readonly(root: Path) -> sqlite3.Connection | None:
@@ -332,13 +340,13 @@ def _waste(
surface="mcp",
detail=(
f"p95 {tool.p95_response_bytes / 1024:.0f} KB resp · "
- f"{tool.p95_response_tokens} tok"
+ f"{tool.p95_response_tokens} cu"
),
severity=float(tool.p95_response_bytes),
)
for tool in mcp_tools
if tool.p95_response_bytes >= _HIGH_PAYLOAD_BYTES
- or tool.p95_response_tokens >= _HIGH_PAYLOAD_TOKENS
+ or tool.p95_response_tokens >= _HIGH_PAYLOAD_CONTEXT_UNITS
)
items.sort(key=lambda w: (-w.severity, w.kind, w.subject))
return tuple(items)
@@ -428,6 +436,7 @@ def _db_costs(flat: list[OperationView]) -> tuple[DbCostRow, ...]:
span_count=len(spans),
total_queries=sum(s.counters.get("db_queries", 0) for s in spans),
total_writes=sum(s.counters.get("db_writes", 0) for s in spans),
+ total_rows=sum(s.counters.get("db_rows", 0) for s in spans),
max_queries=max(s.counters.get("db_queries", 0) for s in spans),
)
for name, spans in grouped.items()
@@ -466,6 +475,73 @@ def _db_fingerprints(flat: list[OperationView]) -> tuple[DbFingerprintRow, ...]:
return tuple(rows[:_DB_FINGERPRINT_ROW_LIMIT])
+@dataclass(frozen=True, slots=True)
+class _AnalysisPhaseBundle:
+ rows: tuple[AnalysisPhaseRow, ...]
+ worker_elapsed_total_ms: float | None
+ pipeline_wall_ms: float | None
+ source_spans: int
+ files_timed: int
+ units_eligible: int
+
+
+def _phase_name_from_counter(counter: str) -> str:
+ return counter[len("phase_") : -len("_us")]
+
+
+def _analysis_phase_bundle(flat: list[OperationView]) -> _AnalysisPhaseBundle:
+ pipeline_spans = [
+ span for op in flat for span in op.spans if span.name == _PIPELINE_PROCESS_SPAN
+ ]
+ contributing_spans = [
+ span
+ for span in pipeline_spans
+ if any(key in span.counters for key in PHASE_US_COUNTER_SUFFIXES)
+ ]
+ if not contributing_spans:
+ return _AnalysisPhaseBundle(
+ rows=(),
+ worker_elapsed_total_ms=None,
+ pipeline_wall_ms=None,
+ source_spans=0,
+ files_timed=0,
+ units_eligible=0,
+ )
+
+ phase_us = {
+ key: sum(span.counters.get(key, 0) for span in contributing_spans)
+ for key in PHASE_US_COUNTER_SUFFIXES
+ }
+ volume_totals = {
+ key: sum(span.counters.get(key, 0) for span in contributing_spans)
+ for key in PHASE_VOLUME_COUNTER_SUFFIXES
+ }
+ total_us = sum(phase_us.values())
+ rows = [
+ AnalysisPhaseRow(
+ phase=_phase_name_from_counter(key),
+ worker_elapsed_ms=round(value / 1000, 1),
+ share_permille=round(1000 * value / total_us) if total_us else 0,
+ verdict=(
+ "phase_heavy"
+ if total_us and round(1000 * value / total_us) >= _PHASE_HEAVY_PERMILLE
+ else "ok"
+ ),
+ )
+ for key, value in phase_us.items()
+ if value
+ ]
+ rows.sort(key=lambda row: (-row.worker_elapsed_ms, row.phase))
+ return _AnalysisPhaseBundle(
+ rows=tuple(rows),
+ worker_elapsed_total_ms=round(total_us / 1000, 1),
+ pipeline_wall_ms=round(sum(span.duration_ms for span in contributing_spans), 1),
+ source_spans=len(contributing_spans),
+ files_timed=volume_totals.get("files_timed", 0),
+ units_eligible=volume_totals.get("units_eligible", 0),
+ )
+
+
def _aggregates(
flat: list[OperationView], spans_by_op: dict[str, tuple[SpanView, ...]]
) -> AggregatesView:
@@ -532,6 +608,7 @@ def _aggregates(
mcp_tools = _mcp_tool_aggregates(flat)
cpu_ranked = sorted(flat, key=lambda v: (-_cpu_ms(v), v.operation_id))
heaviest_cpu = cpu_ranked[0] if cpu_ranked and _cpu_ms(cpu_ranked[0]) > 0 else None
+ analysis_phase_bundle = _analysis_phase_bundle(flat)
return AggregatesView(
operation_count=len(flat),
slowest=slowest,
@@ -551,6 +628,14 @@ def _aggregates(
heaviest_cpu=heaviest_cpu,
pipeline=_pipeline(flat),
db_fingerprints=_db_fingerprints(flat),
+ analysis_phases=analysis_phase_bundle.rows,
+ analysis_phase_worker_elapsed_total_ms=(
+ analysis_phase_bundle.worker_elapsed_total_ms
+ ),
+ analysis_phase_pipeline_wall_ms=analysis_phase_bundle.pipeline_wall_ms,
+ analysis_phase_source_spans=analysis_phase_bundle.source_spans,
+ analysis_phase_files_timed=analysis_phase_bundle.files_timed,
+ analysis_phase_units_eligible=analysis_phase_bundle.units_eligible,
)
diff --git a/codeclone/observability/store/schema.py b/codeclone/observability/store/schema.py
index 3f0cf22f..63814f50 100644
--- a/codeclone/observability/store/schema.py
+++ b/codeclone/observability/store/schema.py
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Den Rozhnovskiy
-"""Observability sqlite schema (Phase 29 §4.5).
+"""Observability sqlite schema.
Two tables — operations (surface-level) and spans (stage/subsystem) — plus a
meta row carrying the schema version. Profile columns are nullable
diff --git a/codeclone/observability/store/writer.py b/codeclone/observability/store/writer.py
index c8cb53a5..195415a4 100644
--- a/codeclone/observability/store/writer.py
+++ b/codeclone/observability/store/writer.py
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Den Rozhnovskiy
-"""Bounded, batched observability writer (Phase 29 §4.5).
+"""Bounded, batched observability writer.
A whole operation — its row plus every span — is persisted in a single sqlite
transaction. We do NOT copy the audit per-emit commit-per-row pattern.
diff --git a/codeclone/observability/views.py b/codeclone/observability/views.py
index 66b9e5a9..36cc95c6 100644
--- a/codeclone/observability/views.py
+++ b/codeclone/observability/views.py
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Den Rozhnovskiy
-"""Read-model views (Phase 29 §4.6).
+"""Read-model views.
``TraceView`` is the primary artifact; JSON/text/HTML renderers are projections
over it and must not drive the schema. Pure data, built by ``store/reader.py``.
@@ -102,15 +102,17 @@ class McpToolAggregate:
class DbCostRow:
"""SQLite work attributed to a span class (performance-truth, not audit).
- Aggregated from span db_queries/db_writes counters; ``max_queries`` is the
- worst single instance and ``queries`` ÷ a per-row productive count exposes
- N+1-shaped access (many reads, little produced)."""
+ Aggregated from span db_queries/db_writes/db_rows counters (v2 semantics:
+ logical statements, not per-row trace fires). ``max_queries`` is the worst
+ single instance; ``total_rows`` exposes executemany amplification, and a
+ high statement count with little produced is the N+1 shape."""
span_name: str
surface: str
span_count: int
total_queries: int
total_writes: int
+ total_rows: int
max_queries: int
@@ -131,9 +133,21 @@ class DbFingerprintRow:
summary: str = ""
+@dataclass(frozen=True, slots=True)
+class AnalysisPhaseRow:
+ phase: str
+ worker_elapsed_ms: float
+ share_permille: int
+ verdict: str
+
+
@dataclass(frozen=True, slots=True)
class AgentTokenRow:
- """One MCP tool's cumulative token economics across the window."""
+ """One MCP tool's cumulative context-unit economics across the window.
+
+ Field names keep the historical ``*_tokens`` spelling for storage/query
+ compatibility; values are deterministic context-unit estimates.
+ """
name: str
calls: int
@@ -143,9 +157,9 @@ class AgentTokenRow:
@dataclass(frozen=True, slots=True)
class AgentView:
- """Agentic context economics: how many tokens MCP tools pushed back into
- the agent's context (``response_tokens`` = context pressure), ranked by the
- biggest consumer. Built only when MCP operations are present."""
+ """Agentic context economics: context units MCP tools pushed back into the
+ agent context (``response_tokens`` = legacy field for context pressure),
+ ranked by the biggest consumer. Built only when MCP operations are present."""
mcp_calls: int = 0
request_tokens: int = 0
@@ -197,6 +211,12 @@ class AggregatesView:
heaviest_cpu: OperationView | None = None
pipeline: tuple[PipelineGroup, ...] = ()
db_fingerprints: tuple[DbFingerprintRow, ...] = ()
+ analysis_phases: tuple[AnalysisPhaseRow, ...] = ()
+ analysis_phase_worker_elapsed_total_ms: float | None = None
+ analysis_phase_pipeline_wall_ms: float | None = None
+ analysis_phase_source_spans: int = 0
+ analysis_phase_files_timed: int = 0
+ analysis_phase_units_eligible: int = 0
@dataclass(frozen=True, slots=True)
@@ -244,6 +264,7 @@ class TraceView:
"AgentTokenRow",
"AgentView",
"AggregatesView",
+ "AnalysisPhaseRow",
"DbCostRow",
"DbFingerprintRow",
"McpToolAggregate",
diff --git a/codeclone/report/document/builder.py b/codeclone/report/document/builder.py
index 9d22dfaa..b9239c98 100644
--- a/codeclone/report/document/builder.py
+++ b/codeclone/report/document/builder.py
@@ -23,7 +23,12 @@
)
from ._common import _collect_report_file_list
-from .derived import _build_derived_overview, _build_derived_suggestions
+from .derived import (
+ _build_derived_module_map,
+ _build_derived_overview,
+ _build_derived_review_queue,
+ _build_derived_suggestions,
+)
from .findings import _build_findings_payload
from .integrity import _build_integrity_payload
from .inventory import (
@@ -95,6 +100,8 @@ def build_report_document(
"suggestions": _build_derived_suggestions(suggestions),
"overview": overview_payload,
"hotlists": hotlists_payload,
+ "module_map": _build_derived_module_map(metrics_payload),
+ "review_queue": _build_derived_review_queue(findings_payload, suggestions),
}
integrity_payload = _build_integrity_payload(
report_schema_version=report_schema_version,
diff --git a/codeclone/report/document/derived.py b/codeclone/report/document/derived.py
index add20423..23f209fa 100644
--- a/codeclone/report/document/derived.py
+++ b/codeclone/report/document/derived.py
@@ -7,8 +7,8 @@
from __future__ import annotations
from collections import Counter
-from collections.abc import Mapping, Sequence
-from typing import TYPE_CHECKING
+from collections.abc import Callable, Mapping, Sequence
+from typing import TYPE_CHECKING, Final
from ...domain.findings import (
CATEGORY_COHESION,
@@ -45,6 +45,8 @@
design_group_id,
structural_group_id,
)
+from ...metrics.dependencies import select_dependency_graph_nodes
+from ...metrics.overloaded_modules import _score_quantile
from ...utils.coerce import as_float as _as_float
from ...utils.coerce import as_int as _as_int
from ...utils.coerce import as_mapping as _as_mapping
@@ -396,11 +398,12 @@ def _suggestion_finding_id(suggestion: Suggestion) -> str:
)
-def _build_derived_suggestions(
+def _sorted_suggestions(
suggestions: Sequence[Suggestion] | None,
-) -> list[dict[str, object]]:
- suggestion_rows = list(suggestions or ())
- suggestion_rows.sort(
+) -> list[Suggestion]:
+ """Deterministic priority order shared by every suggestion-derived view."""
+ rows = list(suggestions or ())
+ rows.sort(
key=lambda suggestion: (
-suggestion.priority,
SEVERITY_ORDER.get(suggestion.severity, 9),
@@ -408,6 +411,12 @@ def _build_derived_suggestions(
_suggestion_finding_id(suggestion),
)
)
+ return rows
+
+
+def _build_derived_suggestions(
+ suggestions: Sequence[Suggestion] | None,
+) -> list[dict[str, object]]:
return [
{
"id": f"suggestion:{_suggestion_finding_id(suggestion)}",
@@ -421,5 +430,687 @@ def _build_derived_suggestions(
"steps": list(suggestion.steps),
},
}
- for suggestion in suggestion_rows
+ for suggestion in _sorted_suggestions(suggestions)
+ ]
+
+
+_REVIEW_QUEUE_SCHEMA_VERSION: Final = "2"
+_REVIEW_SEVERITIES: Final = ("critical", "warning", "info")
+_REVIEW_FAMILIES: Final = ("clones", "structural", "dead_code", "design")
+_REVIEW_FAMILY_BY_FINDING: Final = {
+ FAMILY_CLONE: "clones",
+ FAMILY_STRUCTURAL: "structural",
+ FAMILY_DEAD_CODE: "dead_code",
+ FAMILY_DESIGN: "design",
+}
+_REVIEW_FAMILY_BY_SUGGESTION: Final = {
+ FAMILY_CLONES: "clones",
+ FAMILY_STRUCTURAL: "structural",
+}
+_CLONE_REVIEW_TITLES: Final = {
+ CLONE_KIND_FUNCTION: "Function clone group",
+ CLONE_KIND_BLOCK: "Block clone group",
+ CLONE_KIND_SEGMENT: "Segment clone group",
+}
+
+
+def _humanize(value: str) -> str:
+ text = value.replace("_", " ").strip()
+ return text[:1].upper() + text[1:] if text else text
+
+
+def _flatten_finding_groups(
+ findings: Mapping[str, object],
+) -> list[Mapping[str, object]]:
+ """Canonical findings across families, flattened (mirrors overview)."""
+ groups = _as_mapping(findings.get("groups"))
+ clones = _as_mapping(groups.get(FAMILY_CLONES))
+ flat: list[Mapping[str, object]] = [
+ _as_mapping(group)
+ for key in ("functions", "blocks", "segments")
+ for group in _as_sequence(clones.get(key))
+ ]
+ for family_key in (FAMILY_STRUCTURAL, FAMILY_DEAD_CODE, "design"):
+ flat.extend(
+ _as_mapping(group)
+ for group in _as_sequence(_as_mapping(groups.get(family_key)).get("groups"))
+ )
+ return flat
+
+
+def _finding_first_item(group: Mapping[str, object]) -> Mapping[str, object]:
+ items = _as_sequence(group.get("items"))
+ return _as_mapping(items[0]) if items else {}
+
+
+def _finding_review_title(group: Mapping[str, object]) -> str:
+ family = str(group.get("family"))
+ category = str(group.get("category"))
+ qualname = str(_finding_first_item(group).get("qualname", "")).strip()
+ if family == FAMILY_CLONE:
+ base = _CLONE_REVIEW_TITLES.get(category, "Clone group")
+ return f"{base} ({_as_int(group.get('count'))} occurrences)"
+ if family == FAMILY_DEAD_CODE:
+ return f"Unused {category}: {qualname}" if qualname else f"Unused {category}"
+ if family == FAMILY_DESIGN:
+ return f"{_humanize(category)}: {qualname}" if qualname else _humanize(category)
+ return _humanize(category)
+
+
+def _finding_review_location(group: Mapping[str, object]) -> str:
+ first = _finding_first_item(group)
+ # `path` is "" for absolute paths, so we never surface an absolute path —
+ # we fall back to the qualified name instead.
+ path = _safe_relative_path(first)
+ qualname = str(first.get("qualname", "")).strip()
+ line = _as_int(first.get("start_line"))
+ base = (f"{path}:{line}" if line else path) if path else qualname
+ extra = _as_int(group.get("count")) - 1
+ if extra > 0:
+ return f"{base} +{extra} more" if base else f"{extra + 1} locations"
+ return base
+
+
+def _finding_review_summary(group: Mapping[str, object]) -> str:
+ count = _as_int(group.get("count"))
+ spread = _as_mapping(group.get("spread"))
+ files = _as_int(spread.get("files"))
+ functions = _as_int(spread.get("functions"))
+ scope = str(_as_mapping(group.get("source_scope")).get("dominant_kind", "")).strip()
+ parts = [f"{count} occurrence{'s' if count != 1 else ''}"]
+ if functions or files:
+ parts.append(
+ f"{functions} function{'s' if functions != 1 else ''}"
+ f" / {files} file{'s' if files != 1 else ''}"
+ )
+ if scope:
+ parts.append(scope)
+ return " · ".join(parts)
+
+
+def _safe_relative_path(item: Mapping[str, object]) -> str:
+ """Relative path only — never surface an absolute path in the payload."""
+ path = str(item.get("relative_path", "")).strip()
+ return path if path and not _is_absolute_path(path) else ""
+
+
+def _finding_representative_rows(
+ group: Mapping[str, object],
+) -> list[dict[str, object]]:
+ rows = [
+ {
+ "relative_path": _safe_relative_path(item),
+ "start_line": _as_int(item.get("start_line")),
+ "end_line": _as_int(item.get("end_line")),
+ "qualname": str(item.get("qualname", "")),
+ "source_kind": str(item.get("source_kind", "")),
+ }
+ for item in (_as_mapping(row) for row in _as_sequence(group.get("items")))
+ ]
+ rows.sort(
+ key=lambda row: (
+ str(row["relative_path"]),
+ _as_int(row["start_line"]),
+ str(row["qualname"]),
+ )
+ )
+ return rows[:3]
+
+
+def _suggestion_review_fields(suggestion: Suggestion) -> dict[str, object]:
+ """Remediation fields shared by every suggestion-backed review item."""
+ return {
+ "source_kind": suggestion.source_kind,
+ "title": suggestion.title,
+ "summary": suggestion.fact_summary,
+ "location": suggestion.location_label or suggestion.location,
+ "representative_locations": _representative_location_rows(suggestion),
+ "effort": suggestion.effort,
+ "steps": list(suggestion.steps),
+ "has_action": True,
+ }
+
+
+def _finding_identity(group: Mapping[str, object]) -> dict[str, object]:
+ finding_id = str(group.get("id"))
+ return {
+ "id": finding_id,
+ "finding_id": finding_id,
+ "family": _REVIEW_FAMILY_BY_FINDING.get(str(group.get("family")), "design"),
+ "category": str(group.get("category", "")),
+ "severity": str(group.get("severity", SEVERITY_INFO)),
+ "priority": _as_float(group.get("priority")),
+ "novelty": str(group.get("novelty") or "known"),
+ }
+
+
+def _finding_review_item(
+ group: Mapping[str, object],
+ suggestion: Suggestion | None,
+) -> dict[str, object]:
+ item = _finding_identity(group)
+ if suggestion is not None:
+ item.update(_suggestion_review_fields(suggestion))
+ else:
+ item.update(
+ {
+ "source_kind": str(
+ _as_mapping(group.get("source_scope")).get("dominant_kind", "")
+ ),
+ "title": _finding_review_title(group),
+ "summary": _finding_review_summary(group),
+ "location": _finding_review_location(group),
+ "representative_locations": _finding_representative_rows(group),
+ "effort": "",
+ "steps": [],
+ "has_action": False,
+ }
+ )
+ return item
+
+
+def _suggestion_review_item(suggestion: Suggestion) -> dict[str, object]:
+ finding_id = _suggestion_finding_id(suggestion)
+ family = _REVIEW_FAMILY_BY_SUGGESTION.get(suggestion.finding_family) or (
+ "dead_code" if suggestion.category == CATEGORY_DEAD_CODE else "design"
+ )
+ return {
+ "id": finding_id,
+ "finding_id": finding_id,
+ "family": family,
+ "category": suggestion.category,
+ "severity": suggestion.severity,
+ "priority": suggestion.priority,
+ "novelty": "known",
+ **_suggestion_review_fields(suggestion),
+ }
+
+
+def _review_sort_key(item: Mapping[str, object]) -> tuple[float, int, str, str]:
+ return (
+ -_as_float(item.get("priority")),
+ SEVERITY_ORDER.get(str(item.get("severity")), 9),
+ str(item.get("title")),
+ str(item.get("finding_id")),
+ )
+
+
+def _dedup_append(
+ items: list[dict[str, object]],
+ seen: set[str],
+ finding_id: str,
+ item: dict[str, object],
+) -> None:
+ """Append a review item once per finding id (first writer wins)."""
+ if finding_id in seen:
+ return
+ seen.add(finding_id)
+ items.append(item)
+
+
+def _review_summary(items: Sequence[Mapping[str, object]]) -> dict[str, object]:
+ by_severity = dict.fromkeys(_REVIEW_SEVERITIES, 0)
+ by_family = dict.fromkeys(_REVIEW_FAMILIES, 0)
+ by_novelty = {"new": 0, "known": 0}
+ actionable = 0
+ for item in items:
+ severity = str(item.get("severity"))
+ if severity in by_severity:
+ by_severity[severity] += 1
+ family = str(item.get("family"))
+ by_family[family] = by_family.get(family, 0) + 1
+ by_novelty["new" if str(item.get("novelty")) == "new" else "known"] += 1
+ if item.get("has_action"):
+ actionable += 1
+ return {
+ "total": len(items),
+ "reviewed": 0,
+ "actionable": actionable,
+ "by_severity": by_severity,
+ "by_family": {key: count for key, count in sorted(by_family.items()) if count},
+ "by_novelty": by_novelty,
+ "top_priority": max(
+ (_as_float(item.get("priority")) for item in items), default=0.0
+ ),
+ }
+
+
+def _build_derived_review_queue(
+ findings: Mapping[str, object],
+ suggestions: Sequence[Suggestion] | None,
+) -> dict[str, object]:
+ """Prioritised cross-family review queue projected over canonical findings.
+
+ Every finding in ``findings.groups`` (clones, structural, dead-code, design)
+ becomes one review item, enriched with the matching suggestion's remediation
+ steps when one exists (the suggestion wins on title/summary/location).
+ Findings without a suggestion carry ``has_action=False``. The summary carries
+ the severity/family/novelty counts the review hub needs; ``reviewed`` starts
+ at 0 — the HTML tracks per-finding review state client-side.
+ """
+ suggestion_by_id: dict[str, Suggestion] = {}
+ for suggestion in suggestions or ():
+ suggestion_by_id.setdefault(_suggestion_finding_id(suggestion), suggestion)
+
+ items: list[dict[str, object]] = []
+ seen: set[str] = set()
+ for group in _flatten_finding_groups(findings):
+ finding_id = str(group.get("id"))
+ _dedup_append(
+ items,
+ seen,
+ finding_id,
+ _finding_review_item(group, suggestion_by_id.get(finding_id)),
+ )
+ for finding_id, suggestion in suggestion_by_id.items():
+ _dedup_append(items, seen, finding_id, _suggestion_review_item(suggestion))
+
+ items.sort(key=_review_sort_key)
+ return {
+ "schema_version": _REVIEW_QUEUE_SCHEMA_VERSION,
+ "scope": "report_only",
+ "summary": _review_summary(items),
+ "items": items,
+ }
+
+
+_MODULE_MAP_SCHEMA_VERSION: Final = "1"
+_MODULE_MAP_MAX_PACKAGE_NODES: Final = 28
+_MODULE_MAP_MAX_MODULE_NODES: Final = 40
+_MODULE_MAP_MAX_EDGES: Final = 120
+_MODULE_MAP_UNWIND_CANDIDATE_CAP: Final = 25
+_MODULE_MAP_OVERMERGE_MODULE_FLOOR: Final = 80
+_MODULE_MAP_MONOLITH_PACKAGE_CEILING: Final = 2
+_MODULE_MAP_OVERMERGE_PACKAGE_CEILING: Final = 3
+_MODULE_MAP_CANDIDATE: Final = "candidate"
+_MODULE_MAP_RANKED_ONLY: Final = "ranked_only"
+_MODULE_MAP_NON_CANDIDATE: Final = "non_candidate"
+_MODULE_MAP_SEED_POLICY: Final = "cycles_then_chains_then_degree"
+
+
+def _module_prefix(module: str, depth: int) -> str:
+ parts = module.split(".")
+ if len(parts) <= depth:
+ return module
+ return ".".join(parts[:depth])
+
+
+def _package_node_id(depth: int) -> Callable[[str], str]:
+ def _to_package(module: str) -> str:
+ return _module_prefix(module, depth)
+
+ return _to_package
+
+
+def _module_edges_from_items(edge_items: Sequence[object]) -> list[tuple[str, str]]:
+ edges: list[tuple[str, str]] = []
+ for item in edge_items:
+ mapping = _as_mapping(item)
+ source = str(mapping.get("source", "")).strip()
+ target = str(mapping.get("target", "")).strip()
+ if source and target:
+ edges.append((source, target))
+ return edges
+
+
+def _string_paths(raw: Sequence[object]) -> list[list[str]]:
+ return [[str(node) for node in _as_sequence(path)] for path in raw]
+
+
+def _module_map_unavailable_shell(reason: str) -> dict[str, object]:
+ def _empty_truncation() -> dict[str, object]:
+ return {
+ "truncated": False,
+ "node_universe_count": 0,
+ "node_shown_count": 0,
+ "edge_universe_count": 0,
+ "edge_shown_count": 0,
+ "seed_policy": _MODULE_MAP_SEED_POLICY,
+ }
+
+ return {
+ "schema_version": _MODULE_MAP_SCHEMA_VERSION,
+ "scope": "report_only",
+ "default_zoom": "packages",
+ "summary": {
+ "available": False,
+ "reason": reason,
+ "module_count": 0,
+ "package_count_depth2": 0,
+ "edge_count": 0,
+ "unwind_candidate_count": 0,
+ "overloaded_candidate_count": 0,
+ "overloaded_population_status": "limited",
+ },
+ "graph_packages": {
+ "zoom": "packages",
+ "package_depth": None,
+ "truncation": _empty_truncation(),
+ "nodes": [],
+ "edges": [],
+ },
+ "graph_modules": {
+ "zoom": "modules",
+ "package_depth": None,
+ "truncation": _empty_truncation(),
+ "nodes": [],
+ "edges": [],
+ },
+ "unwind_candidates": [],
+ }
+
+
+def _module_map_zoom_decision(
+ modules: Sequence[str], module_count: int
+) -> tuple[str, int]:
+ if module_count <= _MODULE_MAP_MAX_MODULE_NODES:
+ return "modules", 2
+ p1 = len({_module_prefix(module, 1) for module in modules})
+ p2 = len({_module_prefix(module, 2) for module in modules})
+ if p1 <= _MODULE_MAP_MONOLITH_PACKAGE_CEILING:
+ return "packages", 2
+ if (
+ p2 <= _MODULE_MAP_OVERMERGE_PACKAGE_CEILING
+ and module_count > _MODULE_MAP_OVERMERGE_MODULE_FLOOR
+ ):
+ return "packages", 3
+ if p2 <= _MODULE_MAP_MAX_PACKAGE_NODES:
+ return "packages", 2
+ if p1 <= _MODULE_MAP_MAX_PACKAGE_NODES:
+ return "packages", 1
+ return "packages", 2
+
+
+def _aggregate_node_overlay(
+ members: Sequence[str],
+ *,
+ overloaded_by_module: Mapping[str, Mapping[str, object]],
+ cycle_modules: frozenset[str],
+) -> dict[str, object]:
+ scores: list[float] = []
+ statuses: set[str] = set()
+ reasons: set[str] = set()
+ source_kinds: set[str] = set()
+ fan_in = 0
+ fan_out = 0
+ in_cycle = False
+ for module in members:
+ item = overloaded_by_module.get(module, {})
+ scores.append(_as_float(item.get("score")))
+ statuses.add(str(item.get("candidate_status", _MODULE_MAP_NON_CANDIDATE)))
+ reasons.update(
+ str(reason) for reason in _as_sequence(item.get("candidate_reasons"))
+ )
+ source_kinds.add(str(item.get("source_kind", "")))
+ fan_in += _as_int(item.get("fan_in"))
+ fan_out += _as_int(item.get("fan_out"))
+ in_cycle = in_cycle or module in cycle_modules
+ if _MODULE_MAP_CANDIDATE in statuses:
+ candidate_status = _MODULE_MAP_CANDIDATE
+ elif _MODULE_MAP_RANKED_ONLY in statuses:
+ candidate_status = _MODULE_MAP_RANKED_ONLY
+ else:
+ candidate_status = _MODULE_MAP_NON_CANDIDATE
+ return {
+ "fan_in": fan_in,
+ "fan_out": fan_out,
+ "source_kinds": sorted(source_kinds),
+ "in_cycle": in_cycle,
+ "overloaded": {
+ "score": max(scores) if scores else 0.0,
+ "candidate_status": candidate_status,
+ "candidate_reasons": sorted(reasons),
+ },
+ }
+
+
+def _module_map_node(
+ node_id: str,
+ *,
+ package_depth: int | None,
+ overloaded_by_module: Mapping[str, Mapping[str, object]],
+ cycle_modules: frozenset[str],
+) -> dict[str, object]:
+ if package_depth is not None:
+ members = sorted(
+ module
+ for module in overloaded_by_module
+ if _module_prefix(module, package_depth) == node_id
+ )
+ overlay = _aggregate_node_overlay(
+ members,
+ overloaded_by_module=overloaded_by_module,
+ cycle_modules=cycle_modules,
+ )
+ fan_in = _as_int(overlay["fan_in"])
+ fan_out = _as_int(overlay["fan_out"])
+ source_kinds: object = overlay["source_kinds"]
+ in_cycle = bool(overlay["in_cycle"])
+ overloaded: object = overlay["overloaded"]
+ else:
+ item = overloaded_by_module.get(node_id, {})
+ fan_in = _as_int(item.get("fan_in"))
+ fan_out = _as_int(item.get("fan_out"))
+ source_kinds = sorted({str(item.get("source_kind", ""))}) if item else []
+ in_cycle = node_id in cycle_modules
+ overloaded = {
+ "score": _as_float(item.get("score")),
+ "candidate_status": str(
+ item.get("candidate_status", _MODULE_MAP_NON_CANDIDATE)
+ ),
+ "candidate_reasons": sorted(
+ str(reason) for reason in _as_sequence(item.get("candidate_reasons"))
+ ),
+ }
+ return {
+ "id": node_id,
+ "label": node_id,
+ "fan_in": fan_in,
+ "fan_out": fan_out,
+ "total_degree": fan_in + fan_out,
+ "source_kinds": source_kinds,
+ "in_cycle": in_cycle,
+ "overloaded": overloaded,
+ }
+
+
+def _build_module_graph_view(
+ module_edges: Sequence[tuple[str, str]],
+ *,
+ zoom: str,
+ package_depth: int | None,
+ dep_cycles: Sequence[Sequence[str]],
+ longest_chains: Sequence[Sequence[str]],
+ max_nodes: int,
+ overloaded_by_module: Mapping[str, Mapping[str, object]],
+ cycle_modules: frozenset[str],
+) -> dict[str, object]:
+ weights: Counter[tuple[str, str]] = Counter()
+ node_id_fn: Callable[[str], str] | None
+ if package_depth is not None:
+ node_id_fn = _package_node_id(package_depth)
+ for source, target in module_edges:
+ edge = (
+ _module_prefix(source, package_depth),
+ _module_prefix(target, package_depth),
+ )
+ if edge[0] != edge[1]:
+ weights[edge] += 1
+ else:
+ node_id_fn = None
+ for source, target in module_edges:
+ if source != target:
+ weights[(source, target)] += 1
+ nodes, sampled_edges, truncation = select_dependency_graph_nodes(
+ sorted(weights),
+ dep_cycles=dep_cycles,
+ longest_chains=longest_chains,
+ max_nodes=max_nodes,
+ max_edges=_MODULE_MAP_MAX_EDGES,
+ node_id_fn=node_id_fn,
+ )
+ return {
+ "zoom": zoom,
+ "package_depth": package_depth,
+ "truncation": truncation,
+ "nodes": [
+ _module_map_node(
+ node_id,
+ package_depth=package_depth,
+ overloaded_by_module=overloaded_by_module,
+ cycle_modules=cycle_modules,
+ )
+ for node_id in nodes
+ ],
+ "edges": [
+ {"source": source, "target": target, "weight": weights[(source, target)]}
+ for source, target in sampled_edges
+ ],
+ }
+
+
+def _unwind_signals(
+ item: Mapping[str, object],
+ *,
+ chain_modules: frozenset[str],
+ p90_fan_in: float,
+) -> list[str]:
+ reasons = {str(reason) for reason in _as_sequence(item.get("candidate_reasons"))}
+ fan_in = _as_int(item.get("fan_in"))
+ fan_out = _as_int(item.get("fan_out"))
+ instability = _as_float(item.get("instability"))
+ signals: list[str] = []
+ if "dependency_pressure" in reasons:
+ signals.append("dependency_pressure")
+ if "hub_like_shape" in reasons:
+ signals.append("hub_like_shape")
+ if "repeated_import_pressure" in reasons:
+ signals.append("repeated_import_pressure")
+ if str(item.get("module")) in chain_modules:
+ signals.append("chain_bottleneck")
+ if instability >= 0.75 and fan_out >= 3:
+ signals.append("high_instability")
+ if fan_in >= p90_fan_in and fan_in > 2 * fan_out + 1:
+ signals.append("central_sink")
+ return signals
+
+
+def _module_map_unwind_candidates(
+ overloaded_items: Sequence[Mapping[str, object]],
+ *,
+ longest_chains: Sequence[Sequence[str]],
+) -> list[dict[str, object]]:
+ chain_modules = frozenset(str(node) for chain in longest_chains for node in chain)
+ fan_in_sorted = sorted(_as_int(item.get("fan_in")) for item in overloaded_items)
+ p90_fan_in = (
+ _score_quantile([float(value) for value in fan_in_sorted], 0.9)
+ if fan_in_sorted
+ else 0.0
+ )
+ rows: list[dict[str, object]] = []
+ for item in overloaded_items:
+ signals = _unwind_signals(
+ item, chain_modules=chain_modules, p90_fan_in=p90_fan_in
+ )
+ candidate_status = str(item.get("candidate_status", _MODULE_MAP_NON_CANDIDATE))
+ emit = bool(signals) and (
+ candidate_status == _MODULE_MAP_CANDIDATE
+ or "chain_bottleneck" in signals
+ or "high_instability" in signals
+ or "central_sink" in signals
+ )
+ if not emit:
+ continue
+ rows.append(
+ {
+ "module": str(item.get("module")),
+ "filepath": str(item.get("filepath", "")),
+ "source_kind": str(item.get("source_kind", "")),
+ "fan_in": _as_int(item.get("fan_in")),
+ "fan_out": _as_int(item.get("fan_out")),
+ "score": _as_float(item.get("score")),
+ "dependency_score": _as_float(item.get("dependency_score")),
+ "candidate_status": candidate_status,
+ "signals": signals,
+ }
+ )
+ rows.sort(
+ key=lambda row: (
+ -len(_as_sequence(row["signals"])),
+ -_as_float(row["dependency_score"]),
+ -_as_int(row["fan_in"]),
+ -_as_int(row["fan_out"]),
+ str(row["module"]),
+ )
+ )
+ return rows[:_MODULE_MAP_UNWIND_CANDIDATE_CAP]
+
+
+def _build_derived_module_map(
+ metrics_payload: Mapping[str, object],
+) -> dict[str, object]:
+ families = _as_mapping(metrics_payload.get("families"))
+ dependencies = _as_mapping(families.get("dependencies"))
+ module_edges = _module_edges_from_items(_as_sequence(dependencies.get("items")))
+ if not dependencies or not module_edges:
+ return _module_map_unavailable_shell("dependencies_skipped")
+ modules = sorted({node for edge in module_edges for node in edge})
+ module_count = len(modules)
+ dep_cycles = _string_paths(_as_sequence(dependencies.get("cycles")))
+ longest_chains = _string_paths(_as_sequence(dependencies.get("longest_chains")))
+ cycle_modules = frozenset(node for cycle in dep_cycles for node in cycle)
+ overloaded = _as_mapping(families.get("overloaded_modules"))
+ overloaded_items = [
+ _as_mapping(item) for item in _as_sequence(overloaded.get("items"))
]
+ overloaded_summary = _as_mapping(overloaded.get("summary"))
+ population_status = str(overloaded_summary.get("population_status") or "ok")
+ overloaded_by_module: dict[str, Mapping[str, object]] = {
+ str(item.get("module")): item for item in overloaded_items
+ }
+ zoom, package_depth = _module_map_zoom_decision(modules, module_count)
+ unwind = _module_map_unwind_candidates(
+ overloaded_items, longest_chains=longest_chains
+ )
+ overloaded_candidate_count = sum(
+ 1
+ for item in overloaded_items
+ if str(item.get("candidate_status")) == _MODULE_MAP_CANDIDATE
+ )
+ return {
+ "schema_version": _MODULE_MAP_SCHEMA_VERSION,
+ "scope": "report_only",
+ "default_zoom": zoom,
+ "summary": {
+ "available": True,
+ "module_count": module_count,
+ "package_count_depth2": len(
+ {_module_prefix(module, 2) for module in modules}
+ ),
+ "edge_count": len(set(module_edges)),
+ "unwind_candidate_count": len(unwind),
+ "overloaded_candidate_count": overloaded_candidate_count,
+ "overloaded_population_status": population_status,
+ },
+ "graph_packages": _build_module_graph_view(
+ module_edges,
+ zoom="packages",
+ package_depth=package_depth,
+ dep_cycles=dep_cycles,
+ longest_chains=longest_chains,
+ max_nodes=_MODULE_MAP_MAX_PACKAGE_NODES,
+ overloaded_by_module=overloaded_by_module,
+ cycle_modules=cycle_modules,
+ ),
+ "graph_modules": _build_module_graph_view(
+ module_edges,
+ zoom="modules",
+ package_depth=None,
+ dep_cycles=dep_cycles,
+ longest_chains=longest_chains,
+ max_nodes=_MODULE_MAP_MAX_MODULE_NODES,
+ overloaded_by_module=overloaded_by_module,
+ cycle_modules=cycle_modules,
+ ),
+ "unwind_candidates": unwind,
+ }
diff --git a/codeclone/report/html/assemble.py b/codeclone/report/html/assemble.py
index dacfd710..b0573d25 100644
--- a/codeclone/report/html/assemble.py
+++ b/codeclone/report/html/assemble.py
@@ -40,8 +40,10 @@
TAB_DEAD_CODE,
TAB_DEPENDENCIES,
TAB_FINDINGS,
+ TAB_MODULE_MAP,
TAB_OVERVIEW,
TAB_QUALITY,
+ TAB_REVIEW,
TAB_SUGGESTIONS,
TABLIST_ARIA_LABEL,
THEME_BUTTON_TEXT,
@@ -56,7 +58,9 @@
from .sections._dead_code import render_dead_code_panel
from .sections._dependencies import render_dependencies_panel
from .sections._meta import build_topbar_provenance_summary, render_meta_panel
+from .sections._module_map import render_module_map_panel
from .sections._overview import render_overview_panel
+from .sections._review import render_review_panel
from .sections._structural import render_structural_panel
from .sections._suggestions import render_suggestions_panel
from .template import FONT_CSS_URL, REPORT_TEMPLATE
@@ -111,8 +115,10 @@ def build_html_report(
# -- Render sections --
overview_html = render_overview_panel(ctx)
+ review_html = render_review_panel(ctx)
clones_html, _novelty_enabled, _total_new, _total_known = render_clones_panel(ctx)
quality_html = render_quality_panel(ctx)
+ module_map_html = render_module_map_panel(ctx)
dependencies_html = render_dependencies_panel(ctx)
dead_code_html = render_dead_code_panel(ctx)
suggestions_html = render_suggestions_panel(ctx)
@@ -136,6 +142,15 @@ def build_html_report(
== CONFIDENCE_HIGH
)
dep_cycles = len(_as_sequence(ctx.dependencies_map.get("cycles")))
+ module_map_summary = _as_mapping(
+ _as_mapping(ctx.derived_map.get("module_map")).get("summary")
+ )
+ module_map_unwind = _as_int(module_map_summary.get("unwind_candidate_count"))
+ review_total = _as_int(
+ _as_mapping(
+ _as_mapping(ctx.derived_map.get("review_queue")).get("summary")
+ ).get("total")
+ )
structural_count = len(
tuple(normalize_structural_findings(ctx.structural_findings))
)
@@ -152,23 +167,23 @@ def build_html_report(
_as_int(_as_mapping(ctx.complexity_map.get("summary")).get("high_risk"))
+ _as_int(_as_mapping(ctx.coupling_map.get("summary")).get("high_risk"))
+ _as_int(_as_mapping(ctx.cohesion_map.get("summary")).get("low_cohesion"))
- + _as_int(
- _as_mapping(ctx.overloaded_modules_map.get("summary")).get("candidates")
- )
+ coverage_review_items
+ _as_int(_as_mapping(ctx.security_surfaces_map.get("summary")).get("items"))
)
- def _tab_badge(count: int) -> str:
+ def _tab_badge(count: int, unit: str) -> str:
if count == 0:
return ""
- return f'
{count} '
+ title = f"{count} {unit}"
+ return f'
{count} '
# -- Main tab navigation --
tab_icon_keys: dict[str, str] = {
"overview": "overview",
+ "review": "review",
"clones": "clones",
"quality": "quality",
+ "module-map": "module-map",
"dependencies": "dependencies",
"dead-code": "dead-code",
"suggestions": "suggestions",
@@ -176,21 +191,48 @@ def _tab_badge(count: int) -> str:
}
tab_defs = [
("overview", TAB_OVERVIEW, overview_html, ""),
- ("clones", TAB_CLONES, clones_html, _tab_badge(ctx.clone_groups_total)),
- ("quality", TAB_QUALITY, quality_html, _tab_badge(quality_issues)),
- ("dependencies", TAB_DEPENDENCIES, dependencies_html, _tab_badge(dep_cycles)),
- ("dead-code", TAB_DEAD_CODE, dead_code_html, _tab_badge(dead_high_conf)),
+ (
+ "review",
+ TAB_REVIEW,
+ review_html,
+ _tab_badge(review_total, "findings to review"),
+ ),
+ (
+ "clones",
+ TAB_CLONES,
+ clones_html,
+ _tab_badge(ctx.clone_groups_total, "clone groups"),
+ ),
+ ("quality", TAB_QUALITY, quality_html, _tab_badge(quality_issues, "issues")),
+ (
+ "module-map",
+ TAB_MODULE_MAP,
+ module_map_html,
+ _tab_badge(module_map_unwind, "unwind candidates"),
+ ),
+ (
+ "dependencies",
+ TAB_DEPENDENCIES,
+ dependencies_html,
+ _tab_badge(dep_cycles, "dependency cycles"),
+ ),
+ (
+ "dead-code",
+ TAB_DEAD_CODE,
+ dead_code_html,
+ _tab_badge(dead_high_conf, "high-confidence dead-code items"),
+ ),
(
"suggestions",
TAB_SUGGESTIONS,
suggestions_html,
- _tab_badge(len(ctx.suggestions)),
+ _tab_badge(len(ctx.suggestions), "suggestions"),
),
(
"structural-findings",
TAB_FINDINGS,
structural_html,
- _tab_badge(structural_count),
+ _tab_badge(structural_count, "structural findings"),
),
]
diff --git a/codeclone/report/html/assets/css.py b/codeclone/report/html/assets/css.py
index af4776dd..4b08eb99 100644
--- a/codeclone/report/html/assets/css.py
+++ b/codeclone/report/html/assets/css.py
@@ -28,9 +28,9 @@
--bg-overlay:oklch(29% 0.033 275);
--bg-subtle:oklch(34% 0.038 275);
- /* Border — same hue, higher chroma for legibility */
- --border:oklch(32% 0.035 275);
- --border-strong:oklch(44% 0.045 275);
+ /* Border — quiet hairlines; -strong only for hover/emphasis */
+ --border:oklch(28% 0.018 275);
+ --border-strong:oklch(40% 0.028 275);
/* Text — muted greys keep a trace of indigo so they feel alive */
--text-primary:oklch(95% 0.010 275);
@@ -44,7 +44,8 @@
--accent-soft:oklch(30% 0.12 275);
/* Semantic — brand-adjacent, hue-rotated so they read as siblings
- of the indigo instead of raw Tailwind defaults */
+ of the indigo instead of raw Tailwind defaults. Light-mode lightness is
+ tuned so severity badge text clears WCAG AA (>=4.5:1) on its muted bg. */
--success:oklch(74% 0.15 162);
--success-muted:color-mix(in oklch,oklch(74% 0.15 162) 18%,transparent);
--warning:oklch(80% 0.15 82);
@@ -55,18 +56,34 @@
--info:oklch(72% 0.13 238);
--info-muted:color-mix(in oklch,oklch(72% 0.13 238) 18%,transparent);
- /* elevation */
- --shadow-sm:0 1px 2px rgba(0,0,0,.25);
- --shadow-md:0 2px 8px rgba(0,0,0,.3);
- --shadow-lg:0 4px 16px rgba(0,0,0,.35);
- --shadow-xl:0 8px 32px rgba(0,0,0,.4);
+ /* elevation — soft, diffuse, layered */
+ --shadow-sm:0 1px 2px rgba(0,0,0,.18);
+ --shadow-md:0 4px 14px -3px rgba(0,0,0,.34);
+ --shadow-lg:0 10px 30px -8px rgba(0,0,0,.44);
+ --shadow-xl:0 20px 50px -14px rgba(0,0,0,.55);
- /* radii */
+ /* radii — crisp, not bubbly */
--radius-sm:4px;
--radius-md:6px;
--radius-lg:8px;
--radius-xl:12px;
+ /* page-background glow (dark only; light overrides to transparent) */
+ --bg-glow:color-mix(in oklch,var(--accent-primary) 9%,transparent);
+
+ /* badge design code — one scale for every read-only label badge */
+ --badge-font:var(--font-sans);
+ --badge-size:.68rem;
+ --badge-weight:600;
+ --badge-tracking:.015em;
+ --badge-pad:2px var(--sp-2);
+ --badge-radius:var(--radius-sm);
+
+ /* count sort — tabular numerals shared by counts and micro-stats */
+ --count-font:var(--font-numeric);
+ --count-size:.64rem;
+ --count-weight:700;
+
/* spacing */
--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:20px;--sp-6:24px;--sp-8:32px;--sp-10:40px;
@@ -90,34 +107,34 @@
so the whole theme feels like one family in both modes. */
@media(prefers-color-scheme:light){
:root:not([data-theme]){
- --bg-body:oklch(98.5% 0.006 275);--bg-surface:#ffffff;
+ --bg-body:oklch(98.5% 0.006 275);--bg-surface:#ffffff;--bg-glow:transparent;
--bg-raised:oklch(97% 0.010 275);--bg-overlay:oklch(93% 0.015 275);--bg-subtle:oklch(88% 0.020 275);
- --border:oklch(88% 0.020 275);--border-strong:oklch(78% 0.028 275);
+ --border:oklch(92% 0.010 275);--border-strong:oklch(85% 0.016 275);
--text-primary:oklch(22% 0.040 275);--text-secondary:oklch(42% 0.048 275);--text-muted:oklch(58% 0.040 275);
--accent-primary:#4f46e5;--accent-hover:#6366f1;--accent-muted:color-mix(in oklch,#4f46e5 12%,transparent);
--accent-soft:oklch(94% 0.045 275);
- --success:oklch(52% 0.16 162);--success-muted:color-mix(in oklch,oklch(52% 0.16 162) 12%,transparent);
- --warning:oklch(60% 0.15 65);--warning-muted:color-mix(in oklch,oklch(60% 0.15 65) 12%,transparent);
- --error:oklch(55% 0.22 20);--error-muted:color-mix(in oklch,oklch(55% 0.22 20) 12%,transparent);
- --danger:oklch(55% 0.22 20);--info:oklch(52% 0.18 238);--info-muted:color-mix(in oklch,oklch(52% 0.18 238) 12%,transparent);
- --shadow-sm:0 1px 2px rgba(0,0,0,.06);--shadow-md:0 2px 8px rgba(0,0,0,.08);
- --shadow-lg:0 4px 16px rgba(0,0,0,.1);--shadow-xl:0 8px 32px rgba(0,0,0,.12);
+ --success:oklch(47% 0.16 162);--success-muted:color-mix(in oklch,oklch(52% 0.16 162) 12%,transparent);
+ --warning:oklch(51.5% 0.15 65);--warning-muted:color-mix(in oklch,oklch(60% 0.15 65) 12%,transparent);
+ --error:oklch(50.5% 0.22 20);--error-muted:color-mix(in oklch,oklch(55% 0.22 20) 12%,transparent);
+ --danger:oklch(50.5% 0.22 20);--info:oklch(48.5% 0.18 238);--info-muted:color-mix(in oklch,oklch(52% 0.18 238) 12%,transparent);
+ --shadow-sm:0 1px 2px rgba(17,20,38,.05);--shadow-md:0 4px 14px -3px rgba(17,20,38,.08);
+ --shadow-lg:0 12px 30px -8px rgba(17,20,38,.12);--shadow-xl:0 22px 50px -14px rgba(17,20,38,.16);
color-scheme:light;
}
}
[data-theme="light"]{
- --bg-body:oklch(98.5% 0.006 275);--bg-surface:#ffffff;
+ --bg-body:oklch(98.5% 0.006 275);--bg-surface:#ffffff;--bg-glow:transparent;
--bg-raised:oklch(97% 0.010 275);--bg-overlay:oklch(93% 0.015 275);--bg-subtle:oklch(88% 0.020 275);
- --border:oklch(88% 0.020 275);--border-strong:oklch(78% 0.028 275);
+ --border:oklch(92% 0.010 275);--border-strong:oklch(85% 0.016 275);
--text-primary:oklch(22% 0.040 275);--text-secondary:oklch(42% 0.048 275);--text-muted:oklch(58% 0.040 275);
--accent-primary:#4f46e5;--accent-hover:#6366f1;--accent-muted:color-mix(in oklch,#4f46e5 12%,transparent);
--accent-soft:oklch(94% 0.045 275);
- --success:oklch(52% 0.16 162);--success-muted:color-mix(in oklch,oklch(52% 0.16 162) 12%,transparent);
- --warning:oklch(60% 0.15 65);--warning-muted:color-mix(in oklch,oklch(60% 0.15 65) 12%,transparent);
- --error:oklch(55% 0.22 20);--error-muted:color-mix(in oklch,oklch(55% 0.22 20) 12%,transparent);
- --danger:oklch(55% 0.22 20);--info:oklch(52% 0.18 238);--info-muted:color-mix(in oklch,oklch(52% 0.18 238) 12%,transparent);
- --shadow-sm:0 1px 2px rgba(0,0,0,.06);--shadow-md:0 2px 8px rgba(0,0,0,.08);
- --shadow-lg:0 4px 16px rgba(0,0,0,.1);--shadow-xl:0 8px 32px rgba(0,0,0,.12);
+ --success:oklch(47% 0.16 162);--success-muted:color-mix(in oklch,oklch(52% 0.16 162) 12%,transparent);
+ --warning:oklch(51.5% 0.15 65);--warning-muted:color-mix(in oklch,oklch(60% 0.15 65) 12%,transparent);
+ --error:oklch(50.5% 0.22 20);--error-muted:color-mix(in oklch,oklch(55% 0.22 20) 12%,transparent);
+ --danger:oklch(50.5% 0.22 20);--info:oklch(48.5% 0.18 238);--info-muted:color-mix(in oklch,oklch(52% 0.18 238) 12%,transparent);
+ --shadow-sm:0 1px 2px rgba(17,20,38,.05);--shadow-md:0 4px 14px -3px rgba(17,20,38,.08);
+ --shadow-lg:0 12px 30px -8px rgba(17,20,38,.12);--shadow-xl:0 22px 50px -14px rgba(17,20,38,.16);
color-scheme:light;
}
"""
@@ -131,7 +148,10 @@
html{-webkit-text-size-adjust:100%;text-size-adjust:100%;-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;scroll-behavior:smooth;scrollbar-gutter:stable}
body{font-family:var(--font-sans);font-size:14px;line-height:1.6;color:var(--text-primary);
- background:var(--bg-body);overflow-x:hidden;
+ background:
+ radial-gradient(1100px 460px at 50% -12%,var(--bg-glow),transparent 70%),
+ var(--bg-body);
+ background-attachment:fixed;overflow-x:hidden;
/* Inter stylistic alternates:
zero — slashed zero (disambiguates 0 from O in metric values)
ss02 — disambiguation set (I/l/1/0 clear apart)
@@ -202,13 +222,18 @@
border:none;cursor:pointer;font-size:.85rem;font-weight:500;color:var(--text-muted);
white-space:nowrap;border-radius:var(--radius-md);transition:all var(--dur-fast) var(--ease)}
.main-tab:hover{color:var(--text-primary);background:var(--bg-raised)}
-.main-tab[aria-selected="true"]{color:var(--accent-primary);background:var(--accent-muted)}
+.main-tab[aria-selected="true"]{color:#fff;background:var(--accent-primary);
+ box-shadow:0 1px 4px color-mix(in oklch,var(--accent-primary) 42%,transparent)}
+.main-tab[aria-selected="true"]:hover{background:var(--accent-hover)}
.main-tab-icon{flex-shrink:0;opacity:.72}
+.main-tab[aria-selected="true"] .main-tab-icon{opacity:1}
.main-tab-label{display:inline-flex;align-items:center}
.tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;
- height:18px;padding:0 5px;font-size:.68rem;font-weight:700;border-radius:var(--radius-sm);
+ height:18px;padding:0 5px;border-radius:var(--radius-sm);
+ font-family:var(--count-font);font-size:var(--count-size);font-weight:var(--count-weight);
+ font-variant-numeric:tabular-nums;
background:var(--bg-overlay);color:var(--text-muted);margin-left:var(--sp-1)}
-.main-tab[aria-selected="true"] .tab-count{background:var(--accent-primary);
+.main-tab[aria-selected="true"] .tab-count{background:rgba(255,255,255,.24);
color:#fff}
/* Tab panels */
@@ -257,21 +282,38 @@
.btn.ghost:hover{background:var(--bg-raised);border-color:var(--border)}
.btn.btn-icon{padding:var(--sp-1);min-width:28px;justify-content:center}
.btn svg{width:14px;height:14px}
+.btn:hover{box-shadow:var(--shadow-sm)}
+
+/* Smart controls — one accent focus ring for every button + tactile press */
+button:focus-visible,a:focus-visible,summary:focus-visible{
+ outline:2px solid var(--accent-primary);outline-offset:2px}
+.btn:active,.prov-pill:active,.theme-toggle:active,.badge-btn:active,.badge-tab:active,
+.review-launchpad-cta:active,.review-toggle:active,.review-chip:active,
+.clone-nav-btn:active{transform:translateY(.5px) scale(.985)}
+@media(prefers-reduced-motion:reduce){
+ .btn:active,.prov-pill:active,.theme-toggle:active,.badge-btn:active,.badge-tab:active,
+ .review-launchpad-cta:active,.review-toggle:active,.review-chip:active,
+ .clone-nav-btn:active{transform:none}
+}
/* Inputs */
input[type="text"]{padding:var(--sp-1) var(--sp-3);font-size:.85rem;border:1px solid var(--border);
border-radius:var(--radius-md);background:var(--bg-body);color:var(--text-primary);outline:none;
- transition:border-color var(--dur-fast) var(--ease)}
-input[type="text"]:focus{border-color:var(--accent-primary);box-shadow:0 0 0 2px var(--accent-muted)}
+ transition:border-color var(--dur-fast) var(--ease),box-shadow var(--dur-fast) var(--ease)}
+input[type="text"]:hover{border-color:var(--border-strong)}
+input[type="text"]:focus{border-color:var(--accent-primary);box-shadow:0 0 0 3px var(--accent-muted)}
input[type="text"]::placeholder{color:var(--text-muted)}
/* Selects */
.select{padding:var(--sp-1) var(--sp-3);padding-right:var(--sp-6);font-size:.8rem;
border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-raised);
- color:var(--text-secondary);cursor:pointer;appearance:none;
+ color:var(--text-secondary);cursor:pointer;appearance:none;outline:none;
+ transition:border-color var(--dur-fast) var(--ease),box-shadow var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%236b6f88' stroke-width='2'%3E%3Cpath d='M3 4.5l3 3 3-3'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 8px center}
-.select:focus{border-color:var(--accent-primary);outline:none}
+.select:hover{border-color:var(--border-strong);color:var(--text-primary)}
+.select:focus{border-color:var(--accent-primary);box-shadow:0 0 0 3px var(--accent-muted)}
/* Checkbox labels */
.inline-check{display:inline-flex;align-items:center;gap:var(--sp-1);font-size:.8rem;
@@ -316,7 +358,7 @@
.filters-btn{display:inline-flex;align-items:center;gap:var(--sp-1);white-space:nowrap}
.filters-btn-ico{flex:none}
.filters-count{display:inline-flex;align-items:center;justify-content:center;
- min-width:18px;height:18px;padding:0 5px;border-radius:999px;
+ min-width:18px;height:18px;padding:0 5px;border-radius:var(--radius-sm);
background:var(--accent-primary);color:#fff;font-size:.68rem;font-weight:600;
line-height:1}
.filters-btn[aria-expanded="true"]{border-color:var(--accent-primary);
@@ -341,7 +383,7 @@
border-color:var(--border-strong)}
/* Suggestions count pill (right side of the shared toolbar). */
-.suggestions-count-label{font-size:.8rem;color:var(--text-muted);font-weight:500;
+.suggestions-count-label,.toolbar-count-label{font-size:.8rem;color:var(--text-muted);font-weight:500;
font-variant-numeric:tabular-nums;white-space:nowrap}
"""
@@ -350,16 +392,23 @@
# ---------------------------------------------------------------------------
_INSIGHT = """\
-.insight-banner{padding:var(--sp-3) var(--sp-4);border-radius:var(--radius-md);
- margin-bottom:var(--sp-4);border-left:3px solid var(--border);background:none}
-.insight-question{font-size:.78rem;font-weight:500;color:var(--text-muted);
- text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}
-.insight-answer{font-size:.82rem;color:var(--text-secondary);line-height:1.5}
-
-.insight-ok{border-left-color:var(--success);background:var(--success-muted)}
-.insight-warn{border-left-color:var(--warning);background:var(--warning-muted)}
-.insight-risk{border-left-color:var(--error);background:var(--error-muted)}
-.insight-info{border-left-color:var(--info);background:var(--info-muted)}
+.insight-banner{position:relative;padding:var(--sp-4) var(--sp-5);
+ border-radius:var(--radius-lg);margin-bottom:var(--sp-5);
+ border:1px solid var(--border);background:var(--bg-surface);overflow:hidden}
+.insight-banner::before{content:"";position:absolute;inset:0 auto 0 0;width:3px;
+ background:var(--border-strong)}
+.insight-question{font-size:.72rem;font-weight:600;color:var(--text-muted);
+ text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px}
+.insight-answer{font-size:.88rem;color:var(--text-secondary);line-height:1.55}
+
+.insight-ok::before{background:var(--success)}
+.insight-ok{background:color-mix(in oklch,var(--success-muted) 55%,var(--bg-surface))}
+.insight-warn::before{background:var(--warning)}
+.insight-warn{background:color-mix(in oklch,var(--warning-muted) 55%,var(--bg-surface))}
+.insight-risk::before{background:var(--error)}
+.insight-risk{background:color-mix(in oklch,var(--error-muted) 55%,var(--bg-surface))}
+.insight-info::before{background:var(--info)}
+.insight-info{background:color-mix(in oklch,var(--info-muted) 55%,var(--bg-surface))}
.insight-banner .overview-summary-grid{margin:0}
.insight-banner .overview-summary-item{background:none;border:none;border-radius:0;padding:0}
.insight-banner .overview-summary-label{font-size:.76rem;margin-bottom:var(--sp-2);
@@ -380,10 +429,11 @@
linear-gradient(to right,rgba(0,0,0,.15),transparent) left center / 14px 100% no-repeat scroll,
linear-gradient(to left,rgba(0,0,0,.15),transparent) right center / 14px 100% no-repeat scroll}
.table{inline-size:max-content;min-inline-size:100%;border-collapse:collapse;font-size:.82rem;
- font-family:var(--font-mono)}
+ font-family:var(--font-sans)}
.table th{position:sticky;top:0;z-index:2;padding:var(--sp-2) var(--sp-3);text-align:left;font-family:var(--font-sans);
- font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;
- color:var(--text-muted);background:var(--bg-overlay);border-bottom:1px solid var(--border);
+ font-weight:600;font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;
+ color:var(--text-secondary);background:var(--bg-overlay);
+ border-bottom:2px solid color-mix(in oklch,var(--accent-primary) 30%,var(--border));
white-space:nowrap;cursor:default;user-select:none}
.table th[data-sortable]{cursor:pointer}
.table th[data-sortable]:hover{color:var(--text-primary)}
@@ -391,17 +441,53 @@
.table th[aria-sort] .sort-icon{opacity:1;color:var(--accent-primary)}
.table td{padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--border);color:var(--text-secondary);
vertical-align:top}
-.table tr:last-child td{border-bottom:none}
-.table tr:hover td{background:var(--bg-raised)}
+.table tbody tr:nth-child(even) td{background:color-mix(in oklch,var(--bg-raised) 45%,transparent)}
+.table tbody tr:last-child td{border-bottom:none}
+.table tbody tr:hover td{background:var(--accent-muted)}
.table .col-name{font-weight:500;color:var(--text-primary);max-width:360px;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
.table .col-file,.table .col-path{color:var(--text-muted);max-width:240px;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
-.table .col-number,.table .col-num{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
+.table .col-number,.table .col-num{font-family:var(--font-numeric);
+ font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;color:var(--text-primary)}
.table .col-risk,.table .col-badge,.table .col-cat{white-space:nowrap}
.table .col-steps{max-width:120px;word-break:break-word}
.table .col-wide{max-width:320px;word-break:break-all}
+.table .col-score{min-width:130px;white-space:nowrap}
+.table .col-chips{max-width:300px}
.table-empty{padding:var(--sp-8);text-align:center;color:var(--text-muted);font-size:.9rem}
+
+/* Typed table cells: score bar, status pill, chips (shared badge vocabulary) */
+.score-bar{display:inline-flex;align-items:center;gap:7px;min-width:110px}
+.score-bar-track{flex:1;height:5px;border-radius:3px;background:var(--accent-muted);overflow:hidden}
+.score-bar-fill{display:block;height:100%;border-radius:3px;background:var(--accent-primary)}
+.score-bar--strong .score-bar-fill{background:var(--accent-hover)}
+.score-bar-val{font-family:var(--font-numeric);font-variant-numeric:tabular-nums;
+ font-size:.78rem;color:var(--text-secondary)}
+.score-bar--strong .score-bar-val{color:var(--accent-primary);font-weight:600}
+.metric-meter{display:inline-flex;align-items:center;gap:8px;width:100%;
+ flex-direction:row-reverse;justify-content:flex-start}
+.metric-meter-track{flex:1;max-width:60px;height:5px;border-radius:3px;
+ background:var(--bg-overlay);overflow:hidden}
+.metric-meter-fill{display:block;height:100%;border-radius:3px;
+ background:color-mix(in oklch,var(--accent-primary) 70%,var(--text-muted))}
+.metric-meter-val{font-family:var(--font-numeric);font-variant-numeric:tabular-nums;
+ font-size:.8rem;color:var(--text-primary);min-width:22px;text-align:right}
+.metric-meter--mid .metric-meter-fill{background:var(--warning)}
+.metric-meter--mid .metric-meter-val{color:var(--warning)}
+.metric-meter--high .metric-meter-fill{background:var(--error)}
+.metric-meter--high .metric-meter-val{color:var(--error);font-weight:600}
+.status-pill--candidate{background:var(--accent-muted);color:var(--accent-primary)}
+.status-pill--ranked{background:var(--bg-overlay);color:var(--text-secondary)}
+.status-pill--neutral{background:var(--bg-overlay);color:var(--text-muted)}
+.chip{margin:1px 3px 1px 0;background:var(--bg-overlay);color:var(--text-secondary);
+ border:1px solid var(--border)}
+/* Code sort: identifiers / globs in mono, distinct from sans label badges */
+.code-chip{display:inline-flex;align-items:center;max-width:100%;font-family:var(--font-mono);
+ font-size:.72rem;padding:2px var(--sp-2);border-radius:var(--radius-sm);
+ background:var(--bg-overlay);color:var(--text-secondary);border:1px solid var(--border);
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.table .col-code{max-width:240px}
"""
# ---------------------------------------------------------------------------
@@ -429,8 +515,8 @@
_SECTIONS = """\
.section{margin-bottom:var(--sp-6)}
-.subsection-title{font-size:1rem;font-weight:600;color:var(--text-primary);
- margin-bottom:var(--sp-3);padding-bottom:var(--sp-2);border-bottom:1px solid var(--border)}
+.subsection-title{font-size:.95rem;font-weight:600;color:var(--text-primary);
+ margin:var(--sp-4) 0 var(--sp-2);padding-bottom:var(--sp-2);border-bottom:1px solid var(--border)}
.section-body{display:flex;flex-direction:column;gap:var(--sp-3)}
/* Clone groups */
@@ -514,8 +600,16 @@
# ---------------------------------------------------------------------------
_BADGES = """\
-.risk-badge,.severity-badge{display:inline-flex;align-items:center;font-size:.68rem;font-weight:600;
- padding:2px var(--sp-2);border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.02em}
+/* One typographic scale for every read-only label badge; color/background and
+ any per-variant tweaks (uppercase, etc.) live in the modifiers below. */
+.risk-badge,.severity-badge,.source-kind-badge,.status-pill,
+.finding-meta-badge,.suggestion-chip,.chip,.launchpad-sev{
+ display:inline-flex;align-items:center;white-space:nowrap;line-height:1.2;
+ font-family:var(--badge-font);font-size:var(--badge-size);
+ font-weight:var(--badge-weight);letter-spacing:var(--badge-tracking);
+ padding:var(--badge-pad);border-radius:var(--badge-radius);
+ font-variant-numeric:tabular-nums}
+.risk-badge,.severity-badge{text-transform:uppercase}
.risk-critical,.severity-critical{background:var(--error-muted);color:var(--error)}
.risk-high,.severity-high{background:var(--error-muted);color:var(--error)}
.risk-warning,.severity-warning{background:var(--warning-muted);color:var(--warning)}
@@ -523,8 +617,7 @@
.risk-low,.severity-low{background:var(--success-muted);color:var(--success)}
.risk-info,.severity-info{background:var(--info-muted);color:var(--info)}
-.source-kind-badge{display:inline-flex;align-items:center;font-size:.68rem;font-weight:500;
- padding:2px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-overlay);color:var(--text-muted)}
+.source-kind-badge{background:var(--bg-overlay);color:var(--text-muted)}
.source-kind-production{background:var(--error-muted);color:var(--error)}
.source-kind-test,.source-kind-test_util{background:var(--info-muted);color:var(--info)}
.source-kind-fixture,.source-kind-conftest{background:var(--warning-muted);color:var(--warning)}
@@ -554,10 +647,14 @@
.overview-kpi-grid--with-health .meta-item{min-width:0}
.overview-kpi-grid--with-health .meta-item{min-height:0}
.overview-kpi-cards .meta-item{display:grid;grid-template-rows:auto 1fr auto;
- align-items:start;padding:var(--sp-3) var(--sp-4);gap:var(--sp-2);min-height:0}
-.overview-kpi-cards .meta-item .meta-label{font-size:.75rem;min-height:18px}
+ align-items:start;padding:var(--sp-3) var(--sp-4);gap:var(--sp-2);min-height:0;
+ box-shadow:var(--shadow-sm);transition:border-color var(--dur-fast) var(--ease),
+ box-shadow var(--dur-normal) var(--ease),transform var(--dur-fast) var(--ease)}
+.overview-kpi-cards .meta-item:hover{box-shadow:var(--shadow-md);transform:translateY(-1px)}
+.overview-kpi-cards .meta-item .meta-label{font-size:.68rem;min-height:18px;
+ text-transform:uppercase;letter-spacing:.05em;font-weight:600}
.overview-kpi-cards .meta-item .meta-value{display:flex;align-items:center;
- font-size:1.55rem;line-height:1;padding:var(--sp-1) 0}
+ font-size:1.85rem;line-height:1;padding:var(--sp-1) 0;letter-spacing:-0.02em}
.overview-kpi-cards .kpi-detail{margin-top:0;gap:4px;align-self:end}
.overview-kpi-cards .kpi-micro{padding:2px 6px;font-size:.65rem}
.overview-kpi-grid--with-health .overview-health-card{padding:var(--sp-2)}
@@ -596,10 +693,11 @@
transition:stroke-dashoffset 1s var(--ease)}
.health-ring-label{position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center}
-.health-ring-score{font-family:var(--font-numeric);font-size:1.85rem;font-weight:680;
+.health-ring-score{font-family:var(--font-numeric);font-size:2.15rem;font-weight:700;
color:var(--text-primary);font-variant-numeric:tabular-nums;line-height:1;
- letter-spacing:-0.018em}
-.health-ring-grade{font-size:.72rem;font-weight:500;color:var(--text-muted);margin-top:3px}
+ letter-spacing:-0.022em}
+.health-ring-grade{font-size:.7rem;font-weight:600;color:var(--text-muted);margin-top:4px;
+ text-transform:uppercase;letter-spacing:.06em}
.health-ring-delta{font-size:.65rem;font-weight:600;margin-top:3px}
.health-ring-delta--up{color:var(--success)}
.health-ring-delta--down{color:var(--error)}
@@ -664,13 +762,20 @@
.meta-item .meta-value--bad{color:var(--error)}
.meta-item .meta-value--warn{color:var(--warning)}
.meta-item .meta-value--muted{color:var(--text-muted)}
+.meta-item .meta-value--accent{color:var(--accent-primary)}
+.meta-item .meta-value-sec{font-family:var(--font-numeric);font-size:.9rem;font-weight:500;
+ color:var(--text-muted);margin-left:5px;letter-spacing:0}
+.meta-item .meta-subtext{font-family:var(--font-sans);font-size:.7rem;color:var(--text-muted);
+ margin-top:3px;line-height:1.35}
+.meta-item--accent{border-color:var(--accent-primary)}
+.meta-item--accent:hover{border-color:var(--accent-primary)}
.kpi-detail{display:flex;flex-wrap:wrap;gap:3px;margin-top:2px}
.kpi-detail code{font-size:.78rem}
-.kpi-micro{display:inline-flex;align-items:center;gap:3px;font-size:.62rem;
+.kpi-micro{display:inline-flex;align-items:center;gap:3px;font-size:var(--count-size);
padding:1px 5px;border-radius:var(--radius-sm);background:var(--bg-raised);
- white-space:nowrap;line-height:1.3;font-family:inherit}
-.kpi-micro-val{font-family:inherit;font-weight:500;font-variant-numeric:tabular-nums;
- color:var(--text-muted)}
+ white-space:nowrap;line-height:1.3;font-family:var(--font-sans)}
+.kpi-micro-val{font-family:var(--count-font);font-weight:var(--count-weight);
+ font-variant-numeric:tabular-nums;color:var(--text-muted)}
.kpi-micro-lbl{font-weight:400;color:var(--text-muted);text-transform:lowercase}
.kpi-micro--baselined{color:var(--success);font-weight:500;font-size:.6rem}
.kpi-delta{font-size:.62rem;font-weight:700;margin-left:auto;
@@ -700,7 +805,8 @@
.overview-cluster-copy{font-size:.82rem;color:var(--text-muted);margin-top:2px}
.overview-cluster-empty{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);
padding:var(--sp-5);text-align:center;color:var(--text-muted);font-size:.85rem}
-.empty-icon{color:var(--success);opacity:.35;width:32px;height:32px;flex-shrink:0}
+.empty-icon{color:var(--success);opacity:.35;width:32px;height:32px;flex-shrink:0;
+ margin-bottom:var(--sp-3)}
.overview-list{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--sp-2)}
/* Overview rows */
@@ -771,7 +877,7 @@
.breakdown-row .source-kind-badge{justify-content:center;min-width:0;width:100%;text-align:center}
.breakdown-count{font-size:.8rem;font-weight:600;font-variant-numeric:tabular-nums;
color:var(--text-primary);text-align:right}
-.breakdown-bar-track{height:6px;border-radius:3px;background:var(--bg-raised);overflow:hidden}
+.breakdown-bar-track{height:6px;border-radius:3px;background:var(--bg-raised);overflow:hidden;display:flex}
.breakdown-bar-fill{display:block;height:100%;border-radius:3px;
background:var(--accent-primary);transition:width .6s var(--ease)}
/* Directory hotspot entries */
@@ -828,7 +934,6 @@
.families-label{font-size:.75rem;font-weight:500;color:var(--text-secondary);text-align:right}
.families-count{font-size:.8rem;font-weight:600;font-variant-numeric:tabular-nums;
color:var(--text-primary);text-align:right}
-.breakdown-bar-track{display:flex}
.breakdown-bar-fill--baselined{opacity:.5}
.breakdown-bar-fill--new{border-radius:0 3px 3px 0}
.families-delta{font-size:.65rem;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
@@ -848,12 +953,23 @@
.stat-cards .meta-item .meta-value,.dep-stats .meta-item .meta-value{display:flex;align-items:center}
.stat-cards .kpi-detail,.dep-stats .kpi-detail{margin-top:0;align-self:end}
.dep-graph-wrap{overflow:hidden;margin-bottom:var(--sp-4);border:1px solid var(--border);
- border-radius:var(--radius-lg);background:var(--bg-surface);padding:var(--sp-4)}
-.dep-graph-svg{display:block;width:100%;height:auto;max-height:680px;margin:0 auto}
-.dep-graph-svg text{fill:var(--text-secondary);font-family:var(--font-mono)}
-.dep-node{transition:fill-opacity var(--dur-fast) var(--ease)}
-.dep-edge{transition:stroke-opacity var(--dur-fast) var(--ease)}
-.dep-label{transition:fill var(--dur-fast) var(--ease)}
+ border-radius:var(--radius-lg);
+ background:linear-gradient(180deg,var(--bg-surface),var(--bg-raised));
+ padding:var(--sp-5)}
+.dep-graph-svg{display:block;height:auto;margin:0 auto;overflow:visible}
+.dep-graph-svg text{font-family:var(--font-mono)}
+.dep-edge{transition:stroke-opacity var(--dur-fast) var(--ease),stroke-width var(--dur-fast) var(--ease)}
+.block-node{transition:opacity var(--dur-fast) var(--ease),filter var(--dur-fast) var(--ease);
+ vector-effect:non-scaling-stroke}
+.block-node-label{font-size:12px;font-weight:600;pointer-events:none;
+ letter-spacing:.01em;transition:opacity var(--dur-fast) var(--ease)}
+.block-node-ring{pointer-events:none;transition:opacity var(--dur-fast) var(--ease);
+ vector-effect:non-scaling-stroke}
+.dep-graph-svg[data-graph-density="wide"] .block-node-label{font-size:12.5px}
+.dep-graph-svg .block-node:hover{filter:brightness(1.08) drop-shadow(0 2px 6px rgb(79 70 229 / .18))}
+.mm-truncation-notice{margin-bottom:var(--sp-4);padding:var(--sp-2) var(--sp-4);
+ font-size:.8rem;color:var(--text-muted);background:var(--bg-raised);
+ border:1px solid var(--border);border-radius:var(--radius-lg)}
/* Hub bar */
.dep-hub-bar{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap;
@@ -864,7 +980,8 @@
.dep-hub-pill{display:inline-flex;align-items:center;gap:var(--sp-1);padding:var(--sp-1) var(--sp-2);
border-radius:var(--radius-sm);background:var(--bg-overlay);font-size:.8rem}
.dep-hub-name{color:var(--text-primary);font-family:var(--font-mono);font-size:.8rem}
-.dep-hub-deg{font-size:.68rem;font-weight:600;color:var(--accent-primary);
+.dep-hub-deg{font-family:var(--count-font);font-size:var(--count-size);
+ font-weight:var(--count-weight);font-variant-numeric:tabular-nums;color:var(--accent-primary);
background:var(--accent-muted);padding:2px var(--sp-2);border-radius:var(--radius-sm)}
/* Legend */
@@ -920,39 +1037,114 @@
/* List layout */
.suggestions-list{display:flex;flex-direction:column;gap:var(--sp-2)}
-/* Card — full-width row */
-.suggestion-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);
- overflow:hidden;transition:border-color var(--dur-fast) var(--ease),box-shadow var(--dur-fast) var(--ease)}
-.suggestion-card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-sm)}
-.suggestion-card[data-severity="critical"]{border-left:3px solid var(--error)}
-.suggestion-card[data-severity="warning"]{border-left:3px solid var(--warning)}
-.suggestion-card[data-severity="info"]{border-left:3px solid var(--info)}
-
-/* Header row: severity pill · title · meta badges */
-.suggestion-head{padding:var(--sp-3) var(--sp-4);display:flex;align-items:center;
- gap:var(--sp-2);flex-wrap:wrap}
-.suggestion-sev{font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;
- padding:2px var(--sp-2);border-radius:var(--radius-sm);white-space:nowrap}
-.suggestion-sev--critical{background:var(--error-muted);color:var(--error)}
-.suggestion-sev--warning{background:var(--warning-muted);color:var(--warning)}
-.suggestion-sev--info{background:var(--info-muted);color:var(--info)}
+/* Finding / review card — shared chrome (severity stripe · head · meta · body).
+ One source of truth for findings, suggestions, and the review queue. */
+.finding-card{position:relative;display:flex;background:var(--bg-surface);
+ border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;
+ box-shadow:var(--shadow-sm);
+ transition:border-color var(--dur-fast) var(--ease),
+ box-shadow var(--dur-normal) var(--ease),transform var(--dur-fast) var(--ease)}
+.finding-card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-md);
+ transform:translateY(-1px)}
+.finding-card-stripe{flex:0 0 4px;align-self:stretch;background:var(--border-strong)}
+.finding-card--critical{border-color:color-mix(in oklch,var(--error) 22%,var(--border))}
+.finding-card--critical .finding-card-stripe{background:var(--error)}
+.finding-card--warning{border-color:color-mix(in oklch,var(--warning) 16%,var(--border))}
+.finding-card--warning .finding-card-stripe{background:var(--warning)}
+.finding-card--info .finding-card-stripe{background:var(--info)}
+.finding-card-main{flex:1;min-width:0;padding:var(--sp-3) var(--sp-4)}
+.finding-card-head{display:flex;justify-content:space-between;gap:var(--sp-3);
+ align-items:flex-start}
+.finding-card-headings{min-width:0}
+.finding-card-eyebrow{font-size:.66rem;text-transform:uppercase;letter-spacing:.04em;
+ color:var(--text-muted)}
+.finding-card-title{display:flex;align-items:center;gap:var(--sp-2);margin-top:2px}
+.finding-card-title-text{font-size:.9rem;font-weight:600;color:var(--text-primary);
+ min-width:0;overflow:hidden;text-overflow:ellipsis}
+.finding-card-loc{font-family:var(--font-mono);font-size:.74rem;color:var(--text-secondary);
+ margin-top:4px;word-break:break-all}
+.finding-card-actions{flex-shrink:0}
+.finding-card-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
+.finding-meta-badge{background:var(--bg-overlay);color:var(--text-muted)}
+.finding-meta-badge--easy{color:var(--success);background:var(--success-muted, rgba(34,197,94,.1))}
+.finding-meta-badge--moderate{color:var(--warning);background:var(--warning-muted)}
+.finding-meta-badge--hard{color:var(--error);background:var(--error-muted)}
+.finding-meta-badge--new{color:var(--accent-primary);background:var(--accent-muted);
+ text-transform:uppercase;letter-spacing:.04em}
.suggestion-sev-inline{font-size:.68rem;font-weight:600;padding:2px var(--sp-2);
border-radius:var(--radius-sm)}
-.suggestion-title{font-weight:600;font-size:.85rem;color:var(--text-primary);flex:1;min-width:0}
-.suggestion-meta{display:flex;align-items:center;gap:var(--sp-2);flex-shrink:0;flex-wrap:wrap}
-.suggestion-meta-badge{font-size:.68rem;font-weight:600;padding:2px var(--sp-2);
- border-radius:var(--radius-sm);background:var(--bg-overlay);color:var(--text-muted);
- white-space:nowrap;line-height:1.2;font-variant-numeric:tabular-nums}
-.suggestion-effort--easy{color:var(--success);background:var(--success-muted, rgba(34,197,94,.1))}
-.suggestion-effort--moderate{color:var(--warning);background:var(--warning-muted)}
-.suggestion-effort--hard{color:var(--error);background:var(--error-muted)}
/* Body — context + summary */
-.suggestion-body{padding:0 var(--sp-4) var(--sp-3);display:flex;flex-direction:column;gap:var(--sp-1)}
+.finding-card-body{margin-top:9px;display:flex;flex-direction:column;gap:var(--sp-1)}
+
+/* Overview launchpad: entry banner into the Review hub */
+.review-launchpad{display:flex;align-items:center;justify-content:space-between;gap:var(--sp-4);
+ flex-wrap:wrap;margin-bottom:var(--sp-4);padding:var(--sp-3) var(--sp-4);
+ border:1px solid var(--accent-primary);border-radius:var(--radius-lg);
+ background:var(--accent-muted)}
+.review-launchpad-title{font-size:.95rem;font-weight:600;color:var(--text-primary)}
+.review-launchpad-sevs{display:flex;flex-wrap:wrap;gap:6px;margin-top:5px}
+.launchpad-sev{color:var(--text-secondary);background:var(--bg-overlay)}
+.launchpad-sev--critical{color:var(--danger);
+ background:color-mix(in oklch,var(--danger) 14%,transparent)}
+.launchpad-sev--warning{color:var(--warning);
+ background:color-mix(in oklch,var(--warning) 14%,transparent)}
+.launchpad-sev--info{color:var(--info);
+ background:color-mix(in oklch,var(--info) 14%,transparent)}
+.review-launchpad-cta{display:inline-flex;align-items:center;gap:7px;flex-shrink:0;
+ font-size:.82rem;font-weight:600;font-family:var(--font-sans);cursor:pointer;
+ padding:9px 16px;border-radius:var(--radius-md);border:0;
+ color:#fff;background:var(--accent-primary);
+ transition:background var(--dur-fast) var(--ease)}
+.review-launchpad-cta:hover{background:var(--accent-hover)}
+
+/* Review hub: progress · filters · queue · per-item reviewed toggle */
+.review-progress{background:var(--bg-surface);border:1px solid var(--border);
+ border-radius:var(--radius-lg);padding:var(--sp-3) var(--sp-4);margin-bottom:var(--sp-4)}
+.review-progress-head{display:flex;justify-content:space-between;align-items:baseline;
+ font-size:.78rem;color:var(--text-secondary);margin-bottom:7px}
+.review-progress-title{font-weight:500}
+.review-progress-label b{color:var(--text-primary);font-variant-numeric:tabular-nums}
+.review-progress-track{height:7px;border-radius:4px;background:var(--bg-overlay);overflow:hidden}
+.review-progress-bar{height:100%;border-radius:4px;background:var(--accent-primary);
+ transition:width var(--dur-base) var(--ease)}
+.review-queue{display:flex;flex-direction:column;gap:9px}
+.review-card[data-filter-hidden="true"]{display:none}
+
+/* Shared filter system — inline density: one-click toggle chips */
+.toolbar--filters{margin-bottom:var(--sp-4)}
+.filter-chips{display:flex;flex-wrap:wrap;gap:6px}
+.filter-reset{font-size:.72rem;padding:var(--sp-1) var(--sp-3);margin-right:var(--sp-2)}
+.filter-chip{display:inline-flex;align-items:center;gap:6px;cursor:pointer;
+ font-family:var(--font-sans);font-size:.72rem;font-weight:500;
+ padding:4px 10px;border-radius:var(--radius-md);
+ background:var(--bg-overlay);color:var(--text-secondary);border:1px solid var(--border);
+ transition:border-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease),
+ background var(--dur-fast) var(--ease)}
+.filter-chip:hover{border-color:var(--border-strong);color:var(--text-primary)}
+.filter-chip[aria-pressed="true"]{border-color:var(--accent-primary);
+ color:var(--accent-primary);background:var(--accent-muted)}
+.filter-chip--critical[aria-pressed="true"]{border-color:var(--danger);color:var(--danger);
+ background:color-mix(in oklch,var(--danger) 16%,transparent)}
+.filter-chip--warning[aria-pressed="true"]{border-color:var(--warning);color:var(--warning);
+ background:color-mix(in oklch,var(--warning) 16%,transparent)}
+.filter-chip--info[aria-pressed="true"]{border-color:var(--info);color:var(--info);
+ background:color-mix(in oklch,var(--info) 16%,transparent)}
+.filter-chip-count{font-family:var(--count-font);font-size:var(--count-size);
+ font-weight:var(--count-weight);font-variant-numeric:tabular-nums;opacity:.85}
+.review-toggle{display:inline-flex;align-items:center;justify-content:center;
+ width:30px;height:30px;border-radius:8px;cursor:pointer;color:var(--text-muted);
+ background:transparent;border:1px solid var(--border);
+ transition:border-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
+.review-toggle:hover{border-color:var(--accent-primary);color:var(--accent-primary)}
+.review-card.is-reviewed{opacity:.55}
+.review-card.is-reviewed .finding-card-title-text{text-decoration:line-through;
+ text-decoration-color:var(--text-muted)}
+.review-card.is-reviewed .review-toggle{background:var(--accent-primary);
+ border-color:var(--accent-primary);color:#fff}
.suggestion-context{display:flex;gap:var(--sp-1);flex-wrap:wrap}
-.suggestion-chip{font-size:.68rem;font-weight:500;padding:2px var(--sp-2);border-radius:var(--radius-sm);
- background:var(--bg-overlay);color:var(--text-muted);white-space:nowrap}
-.suggestion-summary{font-size:.8rem;font-family:var(--font-mono);color:var(--text-secondary);line-height:1.5}
+.suggestion-chip{background:var(--bg-overlay);color:var(--text-muted)}
+.suggestion-summary{font-size:.8rem;font-family:var(--font-sans);color:var(--text-secondary);line-height:1.5}
.suggestion-action{display:flex;align-items:center;gap:var(--sp-1);
font-size:.8rem;font-weight:500;color:var(--accent-primary);margin-top:var(--sp-1)}
.suggestion-action-icon{flex-shrink:0;color:var(--accent-primary)}
@@ -1003,22 +1195,8 @@
_STRUCTURAL = """\
/* Structural findings — list layout */
.sf-list{display:flex;flex-direction:column;gap:var(--sp-2)}
-.sf-card{background:var(--bg-surface);border:1px solid var(--border);border-left:3px solid var(--info);
- border-radius:var(--radius-lg);
- overflow:hidden;transition:border-color var(--dur-fast) var(--ease),box-shadow var(--dur-fast) var(--ease)}
-.sf-card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-sm)}
-
-/* Header row */
-.sf-head{padding:var(--sp-3) var(--sp-4);display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap}
-.sf-kind-badge{font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em;
- padding:2px var(--sp-2);border-radius:var(--radius-sm);white-space:nowrap;
- background:var(--info-muted);color:var(--info)}
-.sf-title{font-weight:600;font-size:.85rem;color:var(--text-primary);flex:1;min-width:0}
-.sf-meta{display:flex;align-items:center;gap:var(--sp-1);flex-shrink:0;flex-wrap:wrap}
+/* Card chrome is the shared .finding-card; only structural content rules below. */
.sf-why-btn{font-size:.72rem;color:var(--accent-primary);font-weight:500}
-
-/* Body */
-.sf-body{padding:0 var(--sp-4) var(--sp-3);display:flex;flex-direction:column;gap:var(--sp-2)}
.sf-chips{display:flex;flex-wrap:wrap;gap:var(--sp-1)}
.sf-scope-text{font-size:.8rem;font-family:var(--font-mono);color:var(--text-secondary)}
.sf-inline-action{display:flex;align-items:flex-start;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);
@@ -1040,7 +1218,6 @@
.sf-table{table-layout:fixed}
.sf-kind-meta{font-weight:normal;font-size:.8rem;color:var(--text-muted)}
-.subsection-title{font-size:.95rem;margin:var(--sp-4) 0 var(--sp-2)}
.finding-occurrences-more summary{font-size:.8rem;color:var(--accent-primary);cursor:pointer;
padding:var(--sp-1) var(--sp-3)}
.sf-card[data-filter-hidden="true"]{display:none}
@@ -1078,7 +1255,7 @@
border:1px solid var(--border);
box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 3%,transparent)}
.prov-section:last-child{margin-bottom:0}
-.prov-section-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.09em;
+.prov-section-title{font-size:.66rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;
color:var(--text-secondary);margin:0 calc(-1*var(--sp-4)) var(--sp-2);
padding:0 var(--sp-4) var(--sp-2);border:none;
border-bottom:1px solid color-mix(in srgb,var(--border) 60%,transparent);
@@ -1096,11 +1273,12 @@
/* Provenance summary badges */
.prov-summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px;
padding:var(--sp-2) var(--sp-4);border-top:1px solid var(--border)}
-.prov-badge{display:inline-flex;align-items:center;gap:4px;font-size:.68rem;
- padding:2px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-raised);
+.prov-badge{display:inline-flex;align-items:center;gap:4px;font-size:var(--badge-size);
+ padding:2px var(--sp-2);border-radius:var(--badge-radius);background:var(--bg-raised);
white-space:nowrap;line-height:1.3;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);
- font-family:var(--font-mono);letter-spacing:.005em}
-.prov-badge-val{font-weight:600;font-variant-numeric:tabular-nums;color:var(--text-primary)}
+ font-family:var(--badge-font);letter-spacing:var(--badge-tracking)}
+.prov-badge-val{font-family:var(--count-font);font-weight:var(--count-weight);
+ font-variant-numeric:tabular-nums;color:var(--text-primary)}
.prov-badge-lbl{font-weight:400;color:var(--text-muted);text-transform:lowercase}
.prov-badge--inline{padding:2px 8px}
.prov-badge--inline .prov-badge-val{font-weight:500}
@@ -1172,7 +1350,6 @@
_EMPTY = """\
.empty{display:flex;align-items:center;justify-content:center;padding:var(--sp-10)}
.empty-card{text-align:center;max-width:400px}
-.empty-icon{margin-bottom:var(--sp-3);color:var(--success)}
.empty-icon svg{width:40px;height:40px}
.empty-card h2{margin-bottom:var(--sp-2)}
.empty-card p{color:var(--text-secondary);font-size:.9rem}
@@ -1258,7 +1435,7 @@
color-mix(in srgb,var(--bg-raised) 55%,transparent) 0%,
var(--bg-surface) 100%)}
.prov-hero-badge{display:inline-flex;align-items:center;gap:7px;
- padding:6px 12px 6px 10px;border-radius:999px;font-weight:700;font-size:.78rem;
+ padding:6px 12px 6px 10px;border-radius:var(--radius-md);font-weight:700;font-size:.78rem;
letter-spacing:.005em;white-space:nowrap;flex-shrink:0;
border:1px solid var(--border);background:var(--bg-surface)}
.prov-hero-icon{flex-shrink:0}
@@ -1318,8 +1495,6 @@
.overview-row-spread{margin-left:0;width:100%}
.suggestion-head{flex-direction:column;align-items:flex-start}
.suggestion-facts{grid-template-columns:1fr}
- .sf-head{flex-direction:column;align-items:flex-start}
- .sf-meta{width:100%}
.dir-hotspot-head{flex-wrap:wrap;align-items:flex-start}
.dir-hotspot-detail{flex-wrap:wrap;align-items:flex-start}
.dir-hotspot-bar-track{width:min(148px,42%);min-width:96px}
diff --git a/codeclone/report/html/assets/js.py b/codeclone/report/html/assets/js.py
index e4c948e5..11bed3e1 100644
--- a/codeclone/report/html/assets/js.py
+++ b/codeclone/report/html/assets/js.py
@@ -91,6 +91,14 @@
tabs.forEach(t=>t.addEventListener('click',()=>activate(t.dataset.tab)));
+ // Cross-tab jump buttons (e.g. Overview launchpad -> Review)
+ $$('[data-goto-tab]').forEach(el=>{
+ el.addEventListener('click',()=>{
+ const id=el.dataset.gotoTab;
+ if(tabs.some(t=>t.dataset.tab===id)){activate(id);window.scrollTo(0,0)}
+ });
+ });
+
// Keyboard: arrow left/right
const tabList=$('[role="tablist"].main-tabs');
if(tabList){
@@ -463,33 +471,51 @@
_DEP_GRAPH = """\
(function initDepGraph(){
- const svg=$('.dep-graph-svg');
- if(!svg)return;
- const nodes=$$('.dep-node');
- const labels=$$('.dep-label');
- const edges=$$('.dep-edge');
-
- function highlight(name){
- nodes.forEach(n=>{n.style.fillOpacity=n.dataset.node===name?'1':'0.15'});
- labels.forEach(l=>{l.style.fill=l.dataset.node===name?'var(--text-primary)':'var(--text-muted)';
- l.style.fillOpacity=l.dataset.node===name?'1':'0.3'});
+ $$('.dep-graph-svg').forEach(svg=>{
+ const q=s=>[...svg.querySelectorAll(s)];
+ const nodes=q('.block-node');
+ const labels=q('.block-node-label');
+ const rings=q('.block-node-ring');
+ const edges=q('.dep-edge');
+ if(!nodes.length)return;
+
+ const adj={};
edges.forEach(e=>{
- const connected=e.dataset.source===name||e.dataset.target===name;
- e.style.strokeOpacity=connected?'0.8':'0.05';
- e.style.strokeWidth=connected?'2':'1';
+ const s=e.dataset.source,t=e.dataset.target;
+ e.dataset.baseWidth=e.getAttribute('stroke-width')||'1';
+ e.dataset.baseMarker=e.getAttribute('marker-end')||'';
+ (adj[s]=adj[s]||new Set()).add(t);
+ (adj[t]=adj[t]||new Set()).add(s);
});
- }
- function reset(){
- nodes.forEach(n=>{n.style.fillOpacity=''});
- labels.forEach(l=>{l.style.fill='';l.style.fillOpacity=''});
- edges.forEach(e=>{e.style.strokeOpacity='';e.style.strokeWidth=''});
- }
+ function highlight(name){
+ const near=adj[name]||new Set();
+ const on=n=>n===name||near.has(n);
+ [...nodes,...labels,...rings].forEach(el=>{
+ el.style.opacity=on(el.dataset.node)?'1':'0.16';
+ });
+ edges.forEach(e=>{
+ const connected=e.dataset.source===name||e.dataset.target===name;
+ e.style.strokeOpacity=connected?'0.9':'0.06';
+ e.style.strokeWidth=connected?String(Number(e.dataset.baseWidth||1)+0.7):e.dataset.baseWidth;
+ e.setAttribute('marker-end',connected?e.dataset.baseMarker:'none');
+ });
+ }
- [...nodes,...labels].forEach(el=>{
- el.addEventListener('mouseenter',()=>highlight(el.dataset.node));
- el.addEventListener('mouseleave',reset);
- el.style.cursor='pointer';
+ function reset(){
+ [...nodes,...labels,...rings].forEach(el=>{el.style.opacity=''});
+ edges.forEach(e=>{
+ e.style.strokeOpacity='';
+ e.style.strokeWidth=e.dataset.baseWidth||'';
+ e.setAttribute('marker-end',e.dataset.baseMarker||'');
+ });
+ }
+
+ [...nodes,...labels].forEach(el=>{
+ el.addEventListener('mouseenter',()=>highlight(el.dataset.node));
+ el.addEventListener('mouseleave',reset);
+ el.style.cursor='pointer';
+ });
});
})();
"""
@@ -850,6 +876,78 @@
})();
"""
+# ---------------------------------------------------------------------------
+# Review hub: per-finding reviewed state (localStorage) + progress + filters
+# ---------------------------------------------------------------------------
+
+_REVIEW = """\
+(function initReview(){
+ const panel=$('[data-review-panel]');
+ if(!panel)return;
+ const KEY='codeclone-reviewed';
+ function load(){try{return new Set(JSON.parse(localStorage.getItem(KEY)||'[]'))}catch(e){return new Set()}}
+ function save(s){try{localStorage.setItem(KEY,JSON.stringify([...s]))}catch(e){}}
+ const reviewed=load();
+ const cards=$$('[data-review-card]');
+ const total=cards.length;
+ const bar=$('[data-review-progress-bar]');
+ const label=$('[data-review-progress-label]');
+ function refresh(){
+ let done=0;
+ cards.forEach(c=>{
+ const on=reviewed.has(c.dataset.findingId);
+ c.classList.toggle('is-reviewed',on);
+ const btn=c.querySelector('[data-review-toggle]');
+ if(btn)btn.setAttribute('aria-pressed',on?'true':'false');
+ if(on)done++;
+ });
+ if(bar)bar.style.width=(total?Math.round(done/total*100):0)+'%';
+ if(label)label.textContent=done+' / '+total;
+ }
+ panel.addEventListener('click',function(e){
+ const btn=e.target.closest('[data-review-toggle]');
+ if(!btn)return;
+ const card=btn.closest('[data-review-card]');
+ if(!card)return;
+ const id=card.dataset.findingId;
+ if(reviewed.has(id))reviewed.delete(id);else reviewed.add(id);
+ save(reviewed);refresh();
+ });
+ const chips=$$('[data-filter-dim]');
+ const countLabel=$('[data-review-count]');
+ const resetBtn=$('[data-filter-reset]');
+ function applyFilters(){
+ const active={};
+ chips.forEach(ch=>{
+ if(ch.getAttribute('aria-pressed')==='true'){
+ (active[ch.dataset.filterDim]=active[ch.dataset.filterDim]||new Set())
+ .add(ch.dataset.filterValue);
+ }
+ });
+ const dims=Object.keys(active);
+ let shown=0;
+ cards.forEach(c=>{
+ const hide=dims.some(dim=>!active[dim].has(c.dataset[dim]));
+ c.setAttribute('data-filter-hidden',hide?'true':'false');
+ if(!hide)shown++;
+ });
+ if(countLabel)countLabel.textContent=shown+' shown';
+ if(resetBtn)resetBtn.hidden=dims.length===0;
+ }
+ chips.forEach(ch=>ch.addEventListener('click',function(){
+ ch.setAttribute('aria-pressed',
+ ch.getAttribute('aria-pressed')==='true'?'false':'true');
+ applyFilters();
+ }));
+ if(resetBtn)resetBtn.addEventListener('click',function(){
+ chips.forEach(ch=>ch.setAttribute('aria-pressed','false'));
+ applyFilters();
+ });
+ applyFilters();
+ refresh();
+})();
+"""
+
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -865,6 +963,7 @@
_MODALS,
_SUGGESTIONS,
_DEP_GRAPH,
+ _REVIEW,
_META_PANEL,
_EXPORT,
_CMD_PALETTE,
diff --git a/codeclone/report/html/primitives/filters.py b/codeclone/report/html/primitives/filters.py
index f578b163..daec53a5 100644
--- a/codeclone/report/html/primitives/filters.py
+++ b/codeclone/report/html/primitives/filters.py
@@ -57,3 +57,26 @@ def _render_select(
)
parts.append("")
return "".join(parts)
+
+
+def _render_filter_chips(
+ *,
+ dim: str,
+ options: Sequence[tuple[str, str, int]],
+) -> str:
+ """Render the inline density of the shared filter system.
+
+ One toggle chip per value of dimension *dim*. Each option is
+ ``(value, label, count)``. Chips carry ``data-filter-dim``/``data-filter-value``
+ and ``aria-pressed`` so the shared filter JS can toggle them; the value also
+ gets a ``filter-chip--
`` modifier for severity coloring.
+ """
+ chips = "".join(
+ f''
+ f"{_escape_html(label)}"
+ f'{count} '
+ for value, label, count in options
+ )
+ return f'{chips}
'
diff --git a/codeclone/report/html/sections/_clones.py b/codeclone/report/html/sections/_clones.py
index b18624fb..0e0e27c5 100644
--- a/codeclone/report/html/sections/_clones.py
+++ b/codeclone/report/html/sections/_clones.py
@@ -247,6 +247,12 @@ def _render_suppressed_clone_panel(
headers=("Kind", "Group", "File", "Type", "Occurrences", "Rule", "Pattern"),
rows=rows,
empty_message="No suppressed clone groups.",
+ column_types={
+ "Kind": "chips",
+ "Type": "chips",
+ "Rule": "code",
+ "Pattern": "code",
+ },
ctx=ctx,
)
diff --git a/codeclone/report/html/sections/_coupling.py b/codeclone/report/html/sections/_coupling.py
index 23a860d3..faa2aa1a 100644
--- a/codeclone/report/html/sections/_coupling.py
+++ b/codeclone/report/html/sections/_coupling.py
@@ -212,72 +212,15 @@ def _cohesion_cards(summary: Mapping[str, object]) -> str:
return f'{"".join(cards)}
'
-def _overloaded_cards(
- summary: Mapping[str, object],
- rows_data: Sequence[object],
-) -> str:
- candidates = _as_int(summary.get("candidates"))
- total_modules = _as_int(summary.get("total"))
- critical = sum(
- 1
- for r in rows_data
- if str(_as_mapping(r).get("candidate_status", "")).strip().lower() == "critical"
- )
- scores = [
- _as_int(_as_mapping(r).get("score"))
- for r in rows_data
- if _as_int(_as_mapping(r).get("score")) > 0
- ]
- max_score = max(scores) if scores else 0
- locs = [
- _as_int(_as_mapping(r).get("loc"))
- for r in rows_data
- if _as_int(_as_mapping(r).get("loc")) > 0
- ]
- avg_loc = int(sum(locs) / len(locs)) if locs else 0
- cards = [
- _stat_card(
- "Overloaded",
- candidates,
- detail=_micro_badges(("total analyzed", total_modules)),
- value_tone="bad" if candidates > 0 else "good",
- glossary_tip_fn=glossary_tip,
- ),
- _stat_card(
- "Critical",
- critical,
- value_tone="bad" if critical > 0 else "good",
- glossary_tip_fn=glossary_tip,
- ),
- _stat_card(
- "Max score",
- max_score,
- detail=_micro_badges(("threshold", summary.get("threshold", "n/a"))),
- value_tone="warn" if max_score > 0 else "muted",
- glossary_tip_fn=glossary_tip,
- ),
- _stat_card(
- "Avg LOC",
- avg_loc,
- detail=_micro_badges(("modules", len(locs))),
- value_tone="muted",
- glossary_tip_fn=glossary_tip,
- ),
- ]
- return f'{"".join(cards)}
'
-
-
def render_quality_panel(ctx: ReportContext) -> str:
"""Build the unified Quality tab (Complexity + Coupling + Cohesion sub-tabs)."""
coupling_summary = _as_mapping(ctx.coupling_map.get("summary"))
cohesion_summary = _as_mapping(ctx.cohesion_map.get("summary"))
complexity_summary = _as_mapping(ctx.complexity_map.get("summary"))
- overloaded_modules_summary = _as_mapping(ctx.overloaded_modules_map.get("summary"))
coverage_join_summary = coverage_join_quality_summary(ctx)
coupling_high_risk = _as_int(coupling_summary.get("high_risk"))
cohesion_low = _as_int(cohesion_summary.get("low_cohesion"))
complexity_high_risk = _as_int(complexity_summary.get("high_risk"))
- overloaded_module_candidates = _as_int(overloaded_modules_summary.get("candidates"))
coverage_review_items = coverage_join_quality_count(ctx)
security_surface_items = security_surfaces_quality_count(ctx)
coverage_hotspots = _as_int(coverage_join_summary.get("coverage_hotspots"))
@@ -296,7 +239,6 @@ def render_quality_panel(ctx: ReportContext) -> str:
f"High-complexity: {complexity_high_risk}; "
f"high-coupling: {coupling_high_risk}; "
f"low-cohesion: {cohesion_low}; "
- f"overloaded modules: {overloaded_module_candidates}; "
f"security surfaces: {security_surface_items}; "
f"max CC {cc_max}; "
f"max CBO {coupling_summary.get('max', 'n/a')}; "
@@ -310,9 +252,7 @@ def render_quality_panel(ctx: ReportContext) -> str:
)
else:
answer += " Coverage join unavailable."
- if overloaded_module_candidates > 0 or (
- coupling_high_risk > 0 and cohesion_low > 0
- ):
+ if coupling_high_risk > 0 and cohesion_low > 0:
tone = "risk"
elif (
coupling_high_risk > 0
@@ -343,6 +283,7 @@ def render_quality_panel(ctx: ReportContext) -> str:
headers=("Function", "File", "CC", "Nesting", "Risk"),
rows=cx_rows,
empty_message="Complexity metrics are not available.",
+ column_types={"CC": "meter", "Nesting": "meter"},
ctx=ctx,
)
@@ -366,6 +307,7 @@ def render_quality_panel(ctx: ReportContext) -> str:
rows=cp_rows,
empty_message="Coupling metrics are not available.",
raw_html_headers=("Coupled classes",),
+ column_types={"CBO": "meter"},
ctx=ctx,
)
@@ -389,40 +331,7 @@ def render_quality_panel(ctx: ReportContext) -> str:
headers=("Class", "File", "LCOM4", "Risk", "Methods", "Fields"),
rows=ch_rows,
empty_message="Cohesion metrics are not available.",
- ctx=ctx,
- )
-
- gm_rows_data = _as_sequence(ctx.overloaded_modules_map.get("items"))
- gm_rows = [
- (
- str(_as_mapping(r).get("module", "")),
- str(
- _as_mapping(r).get("relative_path")
- or _as_mapping(r).get("filepath")
- or ""
- ),
- str(_as_mapping(r).get("score", "")),
- str(_as_mapping(r).get("candidate_status", "")),
- str(_as_mapping(r).get("loc", "")),
- f"{_as_mapping(r).get('fan_in', '')}/{_as_mapping(r).get('fan_out', '')}",
- str(_as_mapping(r).get("complexity_total", "")),
- )
- for r in gm_rows_data[:50]
- ]
- gm_panel = _overloaded_cards(
- overloaded_modules_summary, gm_rows_data
- ) + render_rows_table(
- headers=(
- "Module",
- "File",
- "Score",
- "Status",
- "LOC",
- "Fan-in/out",
- "Complexity total",
- ),
- rows=gm_rows,
- empty_message="Overloaded-module profiling is not available.",
+ column_types={"LCOM4": "meter", "Methods": "meter", "Fields": "meter"},
ctx=ctx,
)
@@ -430,12 +339,6 @@ def render_quality_panel(ctx: ReportContext) -> str:
("complexity", "Complexity", complexity_high_risk, cx_panel),
("coupling", "Coupling (CBO)", coupling_high_risk, cp_panel),
("cohesion", "Cohesion (LCOM4)", cohesion_low, ch_panel),
- (
- "overloaded-modules",
- "Overloaded Modules",
- overloaded_module_candidates,
- gm_panel,
- ),
]
coverage_join_panel = render_coverage_join_panel(ctx)
if coverage_join_panel:
diff --git a/codeclone/report/html/sections/_coverage_join.py b/codeclone/report/html/sections/_coverage_join.py
index 48218504..f0fba344 100644
--- a/codeclone/report/html/sections/_coverage_join.py
+++ b/codeclone/report/html/sections/_coverage_join.py
@@ -85,6 +85,7 @@ def render_coverage_join_panel(ctx: ReportContext) -> str:
empty_message=_coverage_join_empty_message(),
empty_description=_coverage_join_empty_description(),
raw_html_headers=("Location",),
+ column_types={"CC": "meter", "Status": "chips"},
ctx=ctx,
)
)
diff --git a/codeclone/report/html/sections/_dead_code.py b/codeclone/report/html/sections/_dead_code.py
index ffdad1d9..10c4e59b 100644
--- a/codeclone/report/html/sections/_dead_code.py
+++ b/codeclone/report/html/sections/_dead_code.py
@@ -102,6 +102,7 @@ def render_dead_code_panel(ctx: ReportContext) -> str:
headers=("Name", "File", "Line", "Kind", "Confidence", "Rule", "Source"),
rows=suppressed_rows,
empty_message="No suppressed dead-code candidates.",
+ column_types={"Source": "source_kind"},
ctx=ctx,
)
diff --git a/codeclone/report/html/sections/_dependencies.py b/codeclone/report/html/sections/_dependencies.py
index c3fbbfbe..483da055 100644
--- a/codeclone/report/html/sections/_dependencies.py
+++ b/codeclone/report/html/sections/_dependencies.py
@@ -8,10 +8,10 @@
from __future__ import annotations
-import math
-from collections.abc import Mapping, Sequence
+from collections.abc import Sequence
from typing import TYPE_CHECKING
+from codeclone.metrics.dependencies import select_dependency_graph_nodes
from codeclone.utils import coerce as _coerce
from ..primitives.escape import _escape_html
@@ -23,6 +23,14 @@
_tab_empty,
)
from ..widgets.components import Tone, insight_block
+from ..widgets.dep_graph_layout import (
+ BlockNodeStyle,
+ _build_cycle_edges,
+ _build_degree_maps,
+ _hub_threshold,
+ block_node_style_for,
+ render_block_diagram,
+)
from ..widgets.glossary import glossary_tip
from ..widgets.tables import render_rows_table
@@ -41,327 +49,33 @@ def _select_dep_nodes(
dep_cycles: Sequence[object],
longest_chains: Sequence[object],
) -> tuple[list[str], list[tuple[str, str]]]:
- all_nodes = sorted({part for edge in edges for part in edge})
- if len(all_nodes) > 20:
- degree_count: dict[str, int] = dict.fromkeys(all_nodes, 0)
- for source, target in edges:
- degree_count[source] = degree_count.get(source, 0) + 1
- degree_count[target] = degree_count.get(target, 0) + 1
- all_node_set = set(all_nodes)
- nodes: list[str] = []
- node_set: set[str] = set()
-
- def _seed_node(node: object) -> None:
- node_name = str(node).strip()
- if (
- not node_name
- or node_name not in all_node_set
- or node_name in node_set
- or len(nodes) >= 20
- ):
- return
- nodes.append(node_name)
- node_set.add(node_name)
-
- # Keep the visual graph aligned with the dependency tables. When we
- # downsample a large graph, cycle members and longest-chain nodes must
- # remain visible instead of being dropped behind high-degree hubs.
- for cycle in dep_cycles:
- for node in _as_sequence(cycle):
- _seed_node(node)
- for chain in longest_chains:
- for node in _as_sequence(chain):
- _seed_node(node)
-
- for node in sorted(
- all_nodes, key=lambda item: (-degree_count.get(item, 0), item)
- ):
- _seed_node(node)
- if len(nodes) >= 20:
- break
- nodes.sort()
- else:
- nodes = all_nodes
- node_set = set(nodes)
- filtered = [
- (source, target)
- for source, target in edges
- if source in node_set and target in node_set
- ][:100]
+ # Shared deterministic sampler (metrics.dependencies). Dependencies tab keeps
+ # its historical caps (20 nodes / 100 edges) and module-level identity zoom.
+ nodes, filtered, _truncation = select_dependency_graph_nodes(
+ edges,
+ dep_cycles=dep_cycles,
+ longest_chains=longest_chains,
+ max_nodes=20,
+ max_edges=100,
+ )
return nodes, filtered
-def _build_degree_maps(
- nodes: Sequence[str],
- edges: Sequence[tuple[str, str]],
-) -> tuple[dict[str, int], dict[str, int]]:
- in_degree: dict[str, int] = dict.fromkeys(nodes, 0)
- out_degree: dict[str, int] = dict.fromkeys(nodes, 0)
- for source, target in edges:
- in_degree[target] += 1
- out_degree[source] += 1
- return in_degree, out_degree
-
-
-def _build_layer_groups(
- nodes: Sequence[str],
- edges: Sequence[tuple[str, str]],
- in_degree: Mapping[str, int],
- out_degree: Mapping[str, int],
-) -> dict[int, list[str]]:
- children: dict[str, list[str]] = {node: [] for node in nodes}
- for source, target in edges:
- children[source].append(target)
-
- layers: dict[str, int] = {}
- roots = sorted(node for node in nodes if in_degree[node] == 0)
- if not roots:
- roots = sorted(nodes, key=lambda node: -out_degree.get(node, 0))[:1]
- queue = list(roots)
- for node in queue:
- layers.setdefault(node, 0)
- while queue:
- node = queue.pop(0)
- for child in children.get(node, []):
- if child in layers:
- continue
- layers[child] = layers[node] + 1
- queue.append(child)
-
- max_layer = max(layers.values(), default=0)
- for node in nodes:
- if node not in layers:
- layers[node] = max_layer + 1
-
- layer_groups: dict[int, list[str]] = {}
- for node, layer in layers.items():
- layer_groups.setdefault(layer, []).append(node)
- for layer in layer_groups:
- layer_groups[layer].sort()
- return layer_groups
-
-
-def _layout_dep_graph(
- layer_groups: Mapping[int, Sequence[str]],
+def _dep_node_style(
+ node: str,
*,
- in_degree: Mapping[str, int],
- out_degree: Mapping[str, int],
-) -> tuple[int, int, int, dict[str, tuple[float, float]]]:
- num_layers = max(layer_groups.keys(), default=0) + 1
- max_per_layer = max((len(members) for members in layer_groups.values()), default=1)
- pad_x, pad_y = 56.0, 36.0
- prefer_horizontal = num_layers >= 6 and num_layers > max_per_layer + 2
-
- def _ordered_members(members: Sequence[str]) -> list[str]:
- if not prefer_horizontal or len(members) < 3:
- return list(members)
- ranked = sorted(
- members,
- key=lambda node: (
- -(in_degree.get(node, 0) + out_degree.get(node, 0)),
- node,
- ),
- )
- center = (len(ranked) - 1) / 2
- slot_order = sorted(
- range(len(ranked)),
- key=lambda index: (abs(index - center), index),
- )
- ordered = [""] * len(ranked)
- for node, slot in zip(ranked, slot_order, strict=False):
- ordered[slot] = node
- return ordered
-
- if prefer_horizontal:
- width = max(920, min(1600, num_layers * 118 + max_per_layer * 28 + 180))
- height = max(300, max_per_layer * 84 + 104)
- else:
- width = max(600, min(1200, max_per_layer * 70 + 140))
- height = max(260, num_layers * 80 + 80)
-
- positions: dict[str, tuple[float, float]] = {}
- for layer_index in range(num_layers):
- members = layer_groups.get(layer_index, [])
- count = len(members)
- if prefer_horizontal:
- members = _ordered_members(members)
- layer_step = (width - 2 * pad_x) / max(1, num_layers - 1)
- x = pad_x + layer_index * layer_step
- fan = min(14.0, layer_step * 0.12)
- offset_unit = fan / max(1, count - 1)
- center = (count - 1) / 2
- for index, node in enumerate(members):
- y = pad_y + (index + 0.5) * ((height - 2 * pad_y) / max(1, count))
- positions[node] = (x + (index - center) * offset_unit, y)
- continue
-
- y = pad_y + layer_index * ((height - 2 * pad_y) / max(1, num_layers - 1))
- for index, node in enumerate(members):
- x = pad_x + (index + 0.5) * ((width - 2 * pad_x) / max(1, count))
- positions[node] = (x, y)
- return width, height, max_per_layer, positions
-
-
-def _hub_threshold(
- nodes: Sequence[str], in_degree: Mapping[str, int], out_degree: Mapping[str, int]
-) -> int:
- degrees = [in_degree.get(node, 0) + out_degree.get(node, 0) for node in nodes]
- if not degrees:
- return 99
- degrees_sorted = sorted(degrees, reverse=True)
- return int(degrees_sorted[max(0, len(degrees_sorted) // 5)])
-
-
-def _build_node_radii(
- nodes: Sequence[str],
- in_degree: Mapping[str, int],
- out_degree: Mapping[str, int],
- cycle_node_set: set[str],
+ degree: int,
hub_threshold: int,
-) -> dict[str, float]:
- node_radii: dict[str, float] = {}
- for node in nodes:
- degree = in_degree.get(node, 0) + out_degree.get(node, 0)
- if node in cycle_node_set:
- node_radii[node] = min(8.0, max(5.0, 3.5 + degree * 0.4))
- elif degree >= hub_threshold and degree > 2:
- node_radii[node] = min(10.0, max(6.0, 4.0 + degree * 0.5))
- elif degree <= 1:
- node_radii[node] = 3.0
- else:
- node_radii[node] = min(6.0, max(3.5, 3.0 + degree * 0.3))
- return node_radii
-
-
-def _build_svg_defs() -> str:
- return (
- ""
- ''
- ' '
- ''
- ' '
- ' '
- ' '
- " "
+ in_cycle: bool,
+) -> BlockNodeStyle:
+ return block_node_style_for(
+ in_cycle=in_cycle,
+ is_hub=degree >= hub_threshold and degree > 2,
+ is_leaf=degree <= 1,
+ title=node,
)
-def _build_cycle_edges(dep_cycles: Sequence[object]) -> set[tuple[str, str]]:
- cycle_edges: set[tuple[str, str]] = set()
- for cycle in dep_cycles:
- parts = [str(part) for part in _as_sequence(cycle)]
- for index in range(len(parts)):
- cycle_edges.add((parts[index], parts[(index + 1) % len(parts)]))
- return cycle_edges
-
-
-def _render_dep_edges(
- edges: Sequence[tuple[str, str]],
- positions: Mapping[str, tuple[float, float]],
- node_radii: Mapping[str, float],
- cycle_edges: set[tuple[str, str]],
-) -> list[str]:
- rendered: list[str] = []
- for source, target in edges:
- x1, y1 = positions[source]
- x2, y2 = positions[target]
- source_radius, target_radius = node_radii[source], node_radii[target]
- dx, dy = x2 - x1, y2 - y1
- distance = math.sqrt(dx * dx + dy * dy) or 1.0
- ux, uy = dx / distance, dy / distance
- x1a, y1a = x1 + ux * (source_radius + 2), y1 + uy * (source_radius + 2)
- x2a, y2a = x2 - ux * (target_radius + 4), y2 - uy * (target_radius + 4)
- mx = (x1a + x2a) / 2 - (y2a - y1a) * 0.06
- my = (y1a + y2a) / 2 + (x2a - x1a) * 0.06
- is_cycle = (source, target) in cycle_edges
- stroke = "var(--danger)" if is_cycle else "var(--border-strong)"
- opacity = "0.6" if is_cycle else "0.3"
- marker = "dep-arrow-cycle" if is_cycle else "dep-arrow"
- rendered.append(
- f' '
- )
- return rendered
-
-
-def _render_dep_nodes_and_labels(
- nodes: Sequence[str],
- *,
- positions: Mapping[str, tuple[float, float]],
- node_radii: Mapping[str, float],
- in_degree: Mapping[str, int],
- out_degree: Mapping[str, int],
- cycle_node_set: set[str],
- hub_threshold: int,
- max_per_layer: int,
- prefer_horizontal: bool,
-) -> tuple[list[str], list[str]]:
- nodes_svg: list[str] = []
- labels_svg: list[str] = []
- rotate_labels = prefer_horizontal or max_per_layer > 6
-
- for node in nodes:
- x, y = positions[node]
- radius = node_radii[node]
- degree = in_degree.get(node, 0) + out_degree.get(node, 0)
- label = _short_label(node)
- is_cycle = node in cycle_node_set
- is_hub = degree >= hub_threshold and degree > 2
- is_secondary = not is_hub and not is_cycle
-
- if is_cycle:
- fill, fill_opacity, extra = (
- "var(--danger)",
- "0.85",
- 'stroke="var(--danger)" stroke-width="1.5" stroke-dasharray="3,2"',
- )
- elif is_hub:
- fill, fill_opacity, extra = (
- "var(--accent-primary)",
- "1",
- 'filter="url(#glow)"',
- )
- elif degree <= 1:
- fill, fill_opacity, extra = "var(--text-muted)", "0.4", ""
- else:
- fill, fill_opacity, extra = "var(--accent-primary)", "0.7", ""
-
- nodes_svg.append(
- f' '
- )
-
- font_size = "10" if is_hub else ("8" if is_secondary else "9")
- if rotate_labels:
- label_x = (
- x + radius + (4 if is_secondary else 6 if prefer_horizontal else 0)
- )
- label_y = (
- y - radius - (1 if is_secondary else 2 if prefer_horizontal else 6)
- )
- labels_svg.append(
- f''
- f"{_escape_html(node)} {_escape_html(label)} "
- )
- continue
-
- labels_svg.append(
- f''
- f"{_escape_html(node)} {_escape_html(label)} "
- )
-
- return nodes_svg, labels_svg
-
-
def _render_dep_svg(
edges: Sequence[tuple[str, str]],
cycle_node_set: set[str],
@@ -377,50 +91,23 @@ def _render_dep_svg(
longest_chains=longest_chains,
)
in_degree, out_degree = _build_degree_maps(nodes, filtered_edges)
- layer_groups = _build_layer_groups(nodes, filtered_edges, in_degree, out_degree)
- width, height, max_per_layer, positions = _layout_dep_graph(
- layer_groups,
- in_degree=in_degree,
- out_degree=out_degree,
- )
- prefer_horizontal = width > height
hub_threshold = _hub_threshold(nodes, in_degree, out_degree)
- node_radii = _build_node_radii(
- nodes,
- in_degree,
- out_degree,
- cycle_node_set,
- hub_threshold,
- )
cycle_edges = _build_cycle_edges(dep_cycles)
- defs = _build_svg_defs()
- edge_svg = _render_dep_edges(filtered_edges, positions, node_radii, cycle_edges)
- node_svg, label_svg = _render_dep_nodes_and_labels(
- nodes,
- positions=positions,
- node_radii=node_radii,
- in_degree=in_degree,
- out_degree=out_degree,
- cycle_node_set=cycle_node_set,
- hub_threshold=hub_threshold,
- max_per_layer=max_per_layer,
- prefer_horizontal=prefer_horizontal,
- )
- label_pad = 44 if prefer_horizontal else (50 if max_per_layer > 6 else 0)
- label_pad_x = 52 if prefer_horizontal else (28 if max_per_layer > 6 else 0)
- vb_x = -label_pad_x
- vb_y = -label_pad
- vb_w = width + label_pad_x * 2
- vb_h = height + label_pad
+ def _style(node: str) -> BlockNodeStyle:
+ return _dep_node_style(
+ node,
+ degree=in_degree.get(node, 0) + out_degree.get(node, 0),
+ hub_threshold=hub_threshold,
+ in_cycle=node in cycle_node_set,
+ )
- return (
- ''
- f''
- f"{defs}{''.join(edge_svg)}{''.join(node_svg)}{''.join(label_svg)}"
- "
"
+ return render_block_diagram(
+ nodes,
+ filtered_edges,
+ style_fn=_style,
+ aria_label="Module dependency graph",
+ danger_edges=cycle_edges,
)
@@ -530,16 +217,19 @@ def render_dependencies_panel(ctx: ReportContext) -> str:
else ""
)
- # Legend
+ # Legend (box swatches matching the block-diagram nodes)
legend = (
''
''
- ' Hub '
+ ' Hub'
''
- ' Leaf '
+ ' Leaf'
''
- ' Cycle
'
+ ' '
+ "Cycle "
)
# Tables
@@ -581,6 +271,7 @@ def render_dependencies_panel(ctx: ReportContext) -> str:
rows=dep_chain_rows,
empty_message="No dependency chains detected.",
raw_html_headers=("Longest chain",),
+ column_types={"Length": "meter"},
ctx=ctx,
)
+ '
'
diff --git a/codeclone/report/html/sections/_module_map.py b/codeclone/report/html/sections/_module_map.py
new file mode 100644
index 00000000..6cde35b4
--- /dev/null
+++ b/codeclone/report/html/sections/_module_map.py
@@ -0,0 +1,399 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2026 Den Rozhnovskiy
+
+"""Module map panel renderer.
+
+Render-only: draws the precomputed ``derived.module_map`` graph (sampled
+packages/modules), unwind-candidate triage, and a top-overloaded slice. No
+projection math lives here — the graph, truncation, and unwind rows are
+computed once in ``report.document.derived``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import TYPE_CHECKING
+
+from codeclone.utils import coerce as _coerce
+
+from ..widgets.badges import _micro_badges, _stat_card, _tab_empty
+from ..widgets.components import Tone, insight_block
+from ..widgets.dep_graph_layout import (
+ BlockNodeStyle,
+ _hub_threshold,
+ block_node_style_for,
+ render_block_diagram,
+)
+from ..widgets.glossary import glossary_tip
+from ..widgets.tables import render_rows_table
+from ..widgets.tabs import render_split_tabs
+
+if TYPE_CHECKING:
+ from .._context import ReportContext
+
+_as_int = _coerce.as_int
+_as_float = _coerce.as_float
+_as_mapping = _coerce.as_mapping
+_as_sequence = _coerce.as_sequence
+
+_CANDIDATE = "candidate"
+_OVERLOADED_TABLE_CAP = 50
+_OVERLOADED_HEADING = "Overloaded Modules"
+_EMPTY_GRAPH_MESSAGE = "Dependency graph is not available."
+_OVERLOADED_EMPTY_MESSAGE = "Overloaded-module profiling is not available."
+_METRICS_SKIPPED = "Metrics are skipped for this run."
+
+# Mandatory honesty copy (spec §11): report-only, sampled SVG, full tables.
+_MODULE_MAP_INSIGHT = (
+ "Report-only import-graph signals for refactor triage. Not CI gates. The SVG "
+ "may show a deterministic sample of packages/modules on large repos; unwind "
+ "and overload tables list module-level facts for the full codebase. Verify in "
+ "source before editing."
+)
+
+_MM_LEGEND = (
+ '
'
+)
+
+
+def _mm_node_title(node: Mapping[str, object], overloaded: Mapping[str, object]) -> str:
+ reasons = ", ".join(
+ str(reason) for reason in _as_sequence(overloaded.get("candidate_reasons"))
+ )
+ title = (
+ f"{node.get('id')} · in {_as_int(node.get('fan_in'))} · "
+ f"out {_as_int(node.get('fan_out'))} · "
+ f"score {_as_float(overloaded.get('score')):.2f}"
+ )
+ if reasons:
+ title = f"{title} · {reasons}"
+ return title
+
+
+def _mm_node_style(node: Mapping[str, object], *, hub_threshold: int) -> BlockNodeStyle:
+ total_degree = _as_int(node.get("total_degree"))
+ overloaded = _as_mapping(node.get("overloaded"))
+ is_candidate = str(overloaded.get("candidate_status")) == _CANDIDATE
+ is_tests = [str(k) for k in _as_sequence(node.get("source_kinds"))] == ["tests"]
+ return block_node_style_for(
+ in_cycle=bool(node.get("in_cycle")),
+ is_hub=total_degree >= hub_threshold and total_degree > 2,
+ is_leaf=total_degree <= 1,
+ ring="var(--warning)" if is_candidate else "",
+ dashed=is_tests,
+ title=_mm_node_title(node, overloaded),
+ )
+
+
+def _render_module_map_svg(graph: Mapping[str, object]) -> str:
+ nodes = [_as_mapping(node) for node in _as_sequence(graph.get("nodes"))]
+ if not nodes:
+ return _tab_empty(_EMPTY_GRAPH_MESSAGE)
+ node_ids = [str(node.get("id")) for node in nodes]
+ by_id = {str(node.get("id")): node for node in nodes}
+ edge_rows = [_as_mapping(edge) for edge in _as_sequence(graph.get("edges"))]
+ edges = [(str(e.get("source")), str(e.get("target"))) for e in edge_rows]
+ weights = {
+ (str(e.get("source")), str(e.get("target"))): _as_int(e.get("weight"))
+ for e in edge_rows
+ }
+ total_degree = {nid: _as_int(by_id[nid].get("total_degree")) for nid in node_ids}
+ hub_threshold = _hub_threshold(node_ids, total_degree, dict.fromkeys(node_ids, 0))
+
+ def _style(node_id: str) -> BlockNodeStyle:
+ return _mm_node_style(by_id[node_id], hub_threshold=hub_threshold)
+
+ return render_block_diagram(
+ node_ids,
+ edges,
+ style_fn=_style,
+ aria_label="Module map graph",
+ edge_weight_fn=lambda edge: weights.get(edge, 1),
+ )
+
+
+def _mm_stat_cards(
+ summary: Mapping[str, object], active_graph: Mapping[str, object]
+) -> str:
+ truncation = _as_mapping(active_graph.get("truncation"))
+ node_total = _as_int(truncation.get("node_universe_count"))
+ edge_total = _as_int(truncation.get("edge_universe_count"))
+ graph_subtext = (
+ "deterministic sample" if bool(truncation.get("truncated")) else "full graph"
+ )
+ cards = [
+ _stat_card(
+ "Nodes shown",
+ _as_int(truncation.get("node_shown_count")),
+ secondary=f"/ {node_total}",
+ subtext=graph_subtext,
+ css_class="meta-item",
+ glossary_tip_fn=glossary_tip,
+ ),
+ _stat_card(
+ "Edges shown",
+ _as_int(truncation.get("edge_shown_count")),
+ secondary=f"/ {edge_total}",
+ subtext=graph_subtext,
+ css_class="meta-item",
+ glossary_tip_fn=glossary_tip,
+ ),
+ _stat_card(
+ "Unwind candidates",
+ _as_int(summary.get("unwind_candidate_count")),
+ subtext=(
+ f"of {_as_int(summary.get('module_count'))} modules · "
+ f"{_as_int(summary.get('package_count_depth2'))} packages"
+ ),
+ value_tone="accent",
+ css_class="meta-item meta-item--accent",
+ glossary_tip_fn=glossary_tip,
+ ),
+ ]
+ return "".join(cards)
+
+
+def _mm_truncation_notice(active_graph: Mapping[str, object]) -> str:
+ truncation = _as_mapping(active_graph.get("truncation"))
+ if not bool(truncation.get("truncated")):
+ return ""
+ return (
+ '
'
+ f"Showing {_as_int(truncation.get('node_shown_count'))} of "
+ f"{_as_int(truncation.get('node_universe_count'))} nodes and "
+ f"{_as_int(truncation.get('edge_shown_count'))} of "
+ f"{_as_int(truncation.get('edge_universe_count'))} edges — a deterministic "
+ "sample seeded by cycles, then chains, then degree. Tables below are full."
+ "
"
+ )
+
+
+def _mm_zoom_toggle(
+ default_zoom: str,
+ graph_packages: Mapping[str, object],
+ graph_modules: Mapping[str, object],
+) -> str:
+ package_count = len(_as_sequence(graph_packages.get("nodes")))
+ module_count = len(_as_sequence(graph_modules.get("nodes")))
+ return render_split_tabs(
+ group_id="module-map-zoom",
+ active_id=default_zoom,
+ tabs=[
+ (
+ "packages",
+ "Packages",
+ package_count,
+ _render_module_map_svg(graph_packages),
+ ),
+ (
+ "modules",
+ "Modules",
+ module_count,
+ _render_module_map_svg(graph_modules),
+ ),
+ ],
+ )
+
+
+def _mm_unwind_table(unwind_candidates: Sequence[object], ctx: ReportContext) -> str:
+ rows = [
+ (
+ str(_as_mapping(row).get("module")),
+ str(_as_int(_as_mapping(row).get("fan_in"))),
+ str(_as_int(_as_mapping(row).get("fan_out"))),
+ f"{_as_float(_as_mapping(row).get('score')):.2f}",
+ str(_as_mapping(row).get("candidate_status")),
+ ", ".join(str(s) for s in _as_sequence(_as_mapping(row).get("signals"))),
+ )
+ for row in unwind_candidates
+ ]
+ return render_rows_table(
+ headers=("Module", "Fan-in", "Fan-out", "Score", "Status", "Signals"),
+ rows=rows,
+ empty_message="No unwind candidates detected.",
+ column_types={
+ "Fan-in": "meter",
+ "Fan-out": "meter",
+ "Score": "score",
+ "Status": "status",
+ "Signals": "chips",
+ },
+ ctx=ctx,
+ )
+
+
+def _overloaded_cards(
+ summary: Mapping[str, object],
+ rows_data: Sequence[object],
+) -> str:
+ candidates = _as_int(summary.get("candidates"))
+ total_modules = _as_int(summary.get("total"))
+ ranked_only = sum(
+ 1
+ for r in rows_data
+ if str(_as_mapping(r).get("candidate_status", "")).strip().lower()
+ == "ranked_only"
+ )
+ population_status = str(summary.get("population_status", "")).strip().lower()
+ max_score = _as_float(summary.get("top_score"))
+ if max_score <= 0.0:
+ row_scores = [_as_float(_as_mapping(r).get("score")) for r in rows_data]
+ max_score = max(row_scores) if row_scores else 0.0
+ cutoff = _as_float(summary.get("candidate_score_cutoff"))
+ locs = [
+ _as_int(_as_mapping(r).get("loc"))
+ for r in rows_data
+ if _as_int(_as_mapping(r).get("loc")) > 0
+ ]
+ avg_loc = int(sum(locs) / len(locs)) if locs else 0
+ cards = [
+ _stat_card(
+ "Overloaded",
+ candidates,
+ detail=_micro_badges(("total analyzed", total_modules)),
+ value_tone="bad" if candidates > 0 else "good",
+ glossary_tip_fn=glossary_tip,
+ ),
+ _stat_card(
+ "Ranked only",
+ ranked_only,
+ detail=_micro_badges(("population", population_status))
+ if population_status
+ else "",
+ value_tone=(
+ "warn"
+ if population_status == "limited"
+ else ("muted" if ranked_only else "good")
+ ),
+ glossary_tip_fn=glossary_tip,
+ ),
+ _stat_card(
+ "Max score",
+ f"{max_score:.2f}",
+ detail=_micro_badges(("cutoff", f"{cutoff:.2f}")) if cutoff > 0.0 else "",
+ value_tone="warn" if max_score > 0 else "muted",
+ glossary_tip_fn=glossary_tip,
+ ),
+ _stat_card(
+ "Avg LOC",
+ avg_loc,
+ detail=_micro_badges(("modules", len(locs))),
+ value_tone="muted",
+ glossary_tip_fn=glossary_tip,
+ ),
+ ]
+ return f'
'
+
+
+def _render_overloaded_modules_section(ctx: ReportContext) -> str:
+ """Render the full overloaded-modules profile (cards + table).
+
+ Driven by ``metrics.families.overloaded_modules`` directly, so it renders
+ independently of dependency-graph availability — overloaded responsibility
+ is module-level and belongs in the Module map regardless of graph sampling.
+ """
+ overloaded = _as_mapping(ctx.overloaded_modules_map)
+ if not overloaded:
+ return ""
+ summary = _as_mapping(overloaded.get("summary"))
+ rows_data = _as_sequence(overloaded.get("items"))
+ rows = [
+ (
+ str(_as_mapping(r).get("module", "")),
+ str(
+ _as_mapping(r).get("relative_path")
+ or _as_mapping(r).get("filepath")
+ or ""
+ ),
+ str(_as_mapping(r).get("score", "")),
+ str(_as_mapping(r).get("candidate_status", "")),
+ str(_as_mapping(r).get("loc", "")),
+ f"{_as_mapping(r).get('fan_in', '')}/{_as_mapping(r).get('fan_out', '')}",
+ str(_as_mapping(r).get("complexity_total", "")),
+ )
+ for r in rows_data[:_OVERLOADED_TABLE_CAP]
+ ]
+ return (
+ f'
'
+ + _overloaded_cards(summary, rows_data)
+ + render_rows_table(
+ headers=(
+ "Module",
+ "File",
+ "Score",
+ "Status",
+ "LOC",
+ "Fan-in/out",
+ "Complexity total",
+ ),
+ rows=rows,
+ empty_message=_OVERLOADED_EMPTY_MESSAGE,
+ column_types={
+ "Score": "score",
+ "Status": "status",
+ "LOC": "meter",
+ "Complexity total": "meter",
+ },
+ ctx=ctx,
+ )
+ )
+
+
+def _render_graph_block(ctx: ReportContext, module_map: Mapping[str, object]) -> str:
+ summary = _as_mapping(module_map.get("summary"))
+ if not module_map or not bool(summary.get("available")):
+ return _tab_empty(_EMPTY_GRAPH_MESSAGE)
+
+ default_zoom = str(module_map.get("default_zoom") or "packages")
+ graph_packages = _as_mapping(module_map.get("graph_packages"))
+ graph_modules = _as_mapping(module_map.get("graph_modules"))
+ active_graph = graph_packages if default_zoom == "packages" else graph_modules
+
+ return (
+ _mm_truncation_notice(active_graph)
+ + f'
'
+ + _mm_zoom_toggle(default_zoom, graph_packages, graph_modules)
+ + _MM_LEGEND
+ + '
'
+ + _mm_unwind_table(_as_sequence(module_map.get("unwind_candidates")), ctx)
+ )
+
+
+def render_module_map_panel(ctx: ReportContext) -> str:
+ module_map = _as_mapping(ctx.derived_map.get("module_map"))
+
+ answer = _MODULE_MAP_INSIGHT if ctx.metrics_available else _METRICS_SKIPPED
+ tone: Tone = "info"
+ insight = insight_block(
+ question="Where should refactoring unwind dependencies?",
+ answer=answer,
+ tone=tone,
+ )
+
+ # The import graph + unwind triage need the derived projection; the
+ # overloaded-modules profile is a module-level metrics view that renders
+ # independently (it moved here from the Quality tab — single home for
+ # module responsibility).
+ return (
+ insight
+ + _render_graph_block(ctx, module_map)
+ + _render_overloaded_modules_section(ctx)
+ )
diff --git a/codeclone/report/html/sections/_overview.py b/codeclone/report/html/sections/_overview.py
index 26a78b23..b7e7978d 100644
--- a/codeclone/report/html/sections/_overview.py
+++ b/codeclone/report/html/sections/_overview.py
@@ -786,6 +786,45 @@ def _overloaded_modules_section(ctx: ReportContext) -> str:
)
+_LAUNCHPAD_SEVERITIES = (
+ ("critical", "critical"),
+ ("warning", "warning"),
+ ("info", "info"),
+)
+_LAUNCHPAD_ARROW = (
+ '
'
+)
+
+
+def _review_launchpad_html(ctx: ReportContext) -> str:
+ """Entry banner: surface the review queue and jump into the Review tab."""
+ derived = _as_mapping(getattr(ctx, "derived_map", {}))
+ summary = _as_mapping(_as_mapping(derived.get("review_queue")).get("summary"))
+ total = _as_int(summary.get("total"))
+ if total <= 0:
+ return ""
+ by_severity = _as_mapping(summary.get("by_severity"))
+ chips = "".join(
+ f'
"
+ for key, label in _LAUNCHPAD_SEVERITIES
+ if _as_int(by_severity.get(key)) > 0
+ )
+ noun = "finding" if total == 1 else "findings"
+ return (
+ '
"
+ )
+
+
def render_overview_panel(ctx: ReportContext) -> str:
"""Build the Overview tab panel HTML."""
complexity_summary = _as_mapping(ctx.complexity_map.get("summary"))
@@ -1042,6 +1081,7 @@ def _baselined_detail(
answer=overview_answer,
tone=overview_tone,
)
+ + _review_launchpad_html(ctx)
+ '