#!/usr/bin/env python3
"""Generate local quality reports from SonarQube/SonarScanner results.
The scanner is the source of truth. This script reads the scanner report-task.txt,
queries the configured SonarQube/SonarCloud API for quality gate status, measures,
and file-level problem data, then writes reports/quality/summary.json and
quality-report.html.
"""
from __future__ import annotations
import argparse
import base64
import collections
import html
import json
import os
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
REPORT_DIR = PROJECT_ROOT / "reports" / "quality"
OUTPUT_HTML = REPORT_DIR / "quality-report.html"
SUMMARY_JSON = REPORT_DIR / "summary.json"
class SonarApiError(RuntimeError):
"""Raised when SonarQube API data cannot be retrieved."""
def percent_threshold(value: str) -> float:
try:
parsed = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be a number") from exc
if parsed < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return parsed
def read_properties(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def normalize_base_url(url: str) -> str:
return url.rstrip("/")
def api_get(base_url: str, endpoint: str, params: dict[str, str], token: str) -> dict:
query = urllib.parse.urlencode(params)
url = f"{normalize_base_url(base_url)}{endpoint}"
if query:
url = f"{url}?{query}"
request = urllib.request.Request(url)
if token:
auth = base64.b64encode(f"{token}:".encode("utf-8")).decode("ascii")
request.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001 - preserve CLI-friendly error context
raise SonarApiError(f"Failed to read {url}: {exc}") from exc
def api_get_optional(base_url: str, endpoint: str, params: dict[str, str], token: str) -> dict:
try:
return api_get(base_url, endpoint, params, token)
except SonarApiError as exc:
print(f"WARN: {exc}", file=sys.stderr)
return {}
def wait_for_analysis(base_url: str, ce_task_id: str, token: str, timeout_seconds: int = 180) -> str | None:
deadline = time.time() + timeout_seconds
last_status = "UNKNOWN"
while time.time() < deadline:
task = api_get(base_url, "/api/ce/task", {"id": ce_task_id}, token).get("task", {})
last_status = task.get("status", last_status)
if last_status == "SUCCESS":
return task.get("analysisId")
if last_status in {"FAILED", "CANCELED"}:
raise SonarApiError(f"SonarQube background task {ce_task_id} ended with status {last_status}")
time.sleep(3)
raise SonarApiError(f"Timed out waiting for SonarQube background task {ce_task_id}; last status: {last_status}")
def measure_float(measures: dict[str, float | None], key: str) -> float | None:
value = measures.get(key)
if value is None:
return None
return float(value)
def status_from_threshold(value: float | None, threshold: float, direction: str) -> bool | None:
if value is None:
return None
if direction == "max":
return value <= threshold
return value >= threshold
def to_float(value: Any) -> float | None:
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def component_path(component: dict[str, Any], project_key: str) -> str:
path = component.get("path") or component.get("name") or component.get("key", "")
if path.startswith(project_key + ":"):
path = path.split(":", 1)[1]
return str(path)
def folder_for(path: str) -> str:
parent = str(Path(path).parent)
return "." if parent == "." else parent
def sonar_component_link(base_url: str, project_key: str, path: str) -> str:
selected = f"{project_key}:{path}"
return f"{normalize_base_url(base_url)}/component_measures?id={urllib.parse.quote(project_key)}&selected={urllib.parse.quote(selected, safe='')}"
def sonar_issue_link(base_url: str, project_key: str, issue_key: str) -> str:
return f"{normalize_base_url(base_url)}/project/issues?open={urllib.parse.quote(issue_key)}&id={urllib.parse.quote(project_key)}"
def paginate_issues(base_url: str, project_key: str, token: str, max_items: int = 1000, types_filter: str | None = None) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
page = 1
page_size = 500
while len(issues) < max_items:
params: dict[str, str] = {
"componentKeys": project_key,
"resolved": "false",
"ps": str(page_size),
"p": str(page),
"s": "FILE_LINE",
"asc": "true",
}
if types_filter:
params["types"] = types_filter
payload = api_get_optional(
base_url,
"/api/issues/search",
params,
token,
)
batch = payload.get("issues", [])
if not batch:
break
issues.extend(batch)
paging = payload.get("paging", {})
total = int(paging.get("total", len(issues)))
if len(issues) >= total:
break
page += 1
return issues[:max_items]
def fetch_file_measures(base_url: str, project_key: str, token: str, max_items: int = 5000) -> list[dict[str, Any]]:
components: list[dict[str, Any]] = []
page = 1
page_size = 500
metric_keys = "coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_lines,ncloc"
while len(components) < max_items:
payload = api_get_optional(
base_url,
"/api/measures/component_tree",
{
"component": project_key,
"qualifiers": "FIL",
"metricKeys": metric_keys,
"ps": str(page_size),
"p": str(page),
},
token,
)
batch = payload.get("components", [])
if not batch:
break
components.extend(batch)
paging = payload.get("paging", {})
total = int(paging.get("total", len(components)))
if len(components) >= total:
break
page += 1
return components[:max_items]
def parse_measure_map(component: dict[str, Any]) -> dict[str, float | None]:
values: dict[str, float | None] = {}
for item in component.get("measures", []):
values[str(item.get("metric"))] = to_float(item.get("value"))
return values
def build_problem_map(
base_url: str,
project_key: str,
token: str,
coverage_threshold: float,
focus_mode: bool = False,
) -> dict[str, Any]:
issues = paginate_issues(base_url, project_key, token, types_filter="CODE_SMELL" if focus_mode else None)
file_components = fetch_file_measures(base_url, project_key, token)
files: dict[str, dict[str, Any]] = {}
def ensure_file(path: str) -> dict[str, Any]:
if path not in files:
files[path] = {
"path": path,
"folder": folder_for(path),
"issues": {"BUG": 0, "VULNERABILITY": 0, "CODE_SMELL": 0, "SECURITY_HOTSPOT": 0},
"issueList": [],
"coverage": None,
"linesToCover": None,
"uncoveredLines": None,
"duplicatedLinesDensity": None,
"duplicatedLines": None,
"ncloc": None,
"link": sonar_component_link(base_url, project_key, path),
}
return files[path]
for issue in issues:
component = str(issue.get("component", ""))
path = component.split(":", 1)[1] if component.startswith(project_key + ":") else component
if not path:
continue
record = ensure_file(path)
issue_type = str(issue.get("type") or "CODE_SMELL")
record["issues"][issue_type] = record["issues"].get(issue_type, 0) + 1
record["issueList"].append(
{
"key": issue.get("key"),
"type": issue_type,
"severity": issue.get("severity"),
"line": issue.get("line"),
"message": issue.get("message"),
"link": sonar_issue_link(base_url, project_key, str(issue.get("key", ""))),
}
)
for component in file_components:
path = component_path(component, project_key)
if not path:
continue
measures = parse_measure_map(component)
record = ensure_file(path)
record["coverage"] = measures.get("coverage")
record["linesToCover"] = measures.get("lines_to_cover")
record["uncoveredLines"] = measures.get("uncovered_lines")
record["duplicatedLinesDensity"] = measures.get("duplicated_lines_density")
record["duplicatedLines"] = measures.get("duplicated_lines")
record["ncloc"] = measures.get("ncloc")
for record in files.values():
issue_total = sum(int(v) for v in record["issues"].values())
coverage = record.get("coverage")
lines_to_cover = record.get("linesToCover") or 0
duplicated = record.get("duplicatedLines") or 0
duplicated_density = record.get("duplicatedLinesDensity") or 0
record["issueTotal"] = issue_total
record["coverageProblem"] = bool(lines_to_cover and coverage is not None and coverage < coverage_threshold)
record["duplicationProblem"] = bool(duplicated > 0 or duplicated_density > 0)
record["problemTotal"] = issue_total + (1 if record["coverageProblem"] else 0) + (1 if record["duplicationProblem"] else 0)
problem_files = [item for item in files.values() if item["problemTotal"] > 0]
problem_files.sort(
key=lambda item: (
-int(item["issueTotal"]),
-(float(item.get("duplicatedLinesDensity") or 0)),
float(item.get("coverage") if item.get("coverage") is not None else 101),
item["path"],
)
)
folders: dict[str, dict[str, Any]] = {}
for record in problem_files:
folder = record["folder"]
if folder not in folders:
folders[folder] = {
"folder": folder,
"files": [],
"issueTotal": 0,
"bugs": 0,
"vulnerabilities": 0,
"codeSmells": 0,
"securityHotspots": 0,
"coverageProblems": 0,
"duplicationProblems": 0,
}
folder_record = folders[folder]
folder_record["files"].append(record)
folder_record["issueTotal"] += int(record["issueTotal"])
folder_record["bugs"] += int(record["issues"].get("BUG", 0))
folder_record["vulnerabilities"] += int(record["issues"].get("VULNERABILITY", 0))
folder_record["codeSmells"] += int(record["issues"].get("CODE_SMELL", 0))
folder_record["securityHotspots"] += int(record["issues"].get("SECURITY_HOTSPOT", 0))
folder_record["coverageProblems"] += 1 if record["coverageProblem"] else 0
folder_record["duplicationProblems"] += 1 if record["duplicationProblem"] else 0
folder_list = sorted(
folders.values(),
key=lambda item: (
-int(item["issueTotal"]),
-int(item["duplicationProblems"]),
-int(item["coverageProblems"]),
item["folder"],
),
)
return {
"issuesTotal": len(issues),
"filesAnalyzed": len(file_components),
"problemFilesTotal": len(problem_files),
"foldersTotal": len(folder_list),
"folders": folder_list,
"topFiles": problem_files,
"limited": len(issues) >= 1000,
}
def render_issue_badges(record: dict[str, Any]) -> str:
labels = [
("BUG", "Bug", "bug"),
("VULNERABILITY", "Vuln", "vuln"),
("CODE_SMELL", "Smell", "smell"),
("SECURITY_HOTSPOT", "Hotspot", "hotspot"),
]
badges = []
for key, label, css in labels:
count = int(record.get("issues", {}).get(key, 0) or 0)
if count:
badges.append(f'<span class="pill {css}">{html.escape(label)} {count}</span>')
if record.get("duplicationProblem"):
density = record.get("duplicatedLinesDensity")
duplicated = record.get("duplicatedLines")
text = "Dup"
if density is not None:
text += f" {float(density):.1f}%"
if duplicated:
text += f" / {int(duplicated)} lines"
badges.append(f'<span class="pill dup">{html.escape(text)}</span>')
if record.get("coverageProblem"):
coverage = record.get("coverage")
uncovered = record.get("uncoveredLines")
text = "Cov"
if coverage is not None:
text += f" {float(coverage):.1f}%"
if uncovered:
text += f" / {int(uncovered)} uncovered"
badges.append(f'<span class="pill cov">{html.escape(text)}</span>')
return " ".join(badges) or '<span class="muted">No problem markers</span>'
def fmt_percent(value: Any) -> str:
parsed = to_float(value)
return "N/A" if parsed is None else f"{parsed:.1f}%"
def render_problem_map(problem_map: dict[str, Any]) -> str:
if not problem_map.get("problemFilesTotal"):
return """
<section class="section">
<h2>Problem Structure</h2>
<p class="muted">No file-level issues, duplication, or coverage gaps were returned by SonarQube.</p>
</section>
"""
top_rows = "".join(
"<tr>"
f"<td><a href=\"{html.escape(record['link'])}\">{html.escape(record['path'])}</a></td>"
f"<td>{render_issue_badges(record)}</td>"
f"<td>{fmt_percent(record.get('coverage'))}</td>"
f"<td>{fmt_percent(record.get('duplicatedLinesDensity'))}</td>"
"</tr>"
for record in problem_map.get("topFiles", [])
)
folder_blocks = []
for folder in problem_map.get("folders", []):
rows = "".join(
"<tr>"
f"<td class=\"path\"><a href=\"{html.escape(record['link'])}\">{html.escape(Path(record['path']).name)}</a><br><small>{html.escape(record['path'])}</small></td>"
f"<td>{render_issue_badges(record)}</td>"
f"<td>{fmt_percent(record.get('coverage'))}</td>"
f"<td>{fmt_percent(record.get('duplicatedLinesDensity'))}</td>"
"</tr>"
for record in folder.get("files", [])[:30]
)
extra = len(folder.get("files", [])) - 30
if extra > 0:
rows += f'<tr><td colspan="4" class="muted">+ {extra} more files in this folder</td></tr>'
folder_blocks.append(
f"""
<details class="folder" open>
<summary>
<strong>{html.escape(folder['folder'])}</strong>
<span class="muted">{len(folder.get('files', []))} files · {folder['issueTotal']} issues · {folder['duplicationProblems']} dup · {folder['coverageProblems']} coverage</span>
</summary>
<table>
<thead><tr><th>File</th><th>Problems</th><th>Coverage</th><th>Duplication</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</details>
"""
)
limit_note = ""
if problem_map.get("limited"):
limit_note = '<p class="warn">Issue list was capped at 1000 unresolved issues for report size.</p>'
return f"""
<section class="section">
<h2>Problem Structure / Full Quality Map</h2>
<p class="muted">File/folder map from SonarQube APIs: unresolved issues + duplicated files + files below coverage threshold.</p>
{limit_note}
<div class="cards compact">
<div class="card"><h3>Problem files</h3><div class="value small">{problem_map['problemFilesTotal']}</div></div>
<div class="card"><h3>Folders</h3><div class="value small">{problem_map['foldersTotal']}</div></div>
<div class="card"><h3>Unresolved issues</h3><div class="value small">{problem_map['issuesTotal']}</div></div>
<div class="card"><h3>Files analyzed</h3><div class="value small">{problem_map['filesAnalyzed']}</div></div>
</div>
<h3>Top problem files</h3>
<table>
<thead><tr><th>File</th><th>Problems</th><th>Coverage</th><th>Duplication</th></tr></thead>
<tbody>{top_rows}</tbody>
</table>
<h3>By folder</h3>
{''.join(folder_blocks)}
</section>
"""
def get_current_branch() -> str:
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5,
cwd=str(PROJECT_ROOT),
)
branch = result.stdout.strip()
return branch if branch and branch != "HEAD" else ""
except Exception:
return ""
def display_metric_key(key: Any) -> str:
metric = str(key or "")
labels = {
"new_duplicated_lines_density": "Duplication",
"duplicated_lines_density": "Duplication",
"new_violations": "New Issues",
"coverage": "Coverage",
}
return labels.get(metric, metric.replace("_", " ").title())
def short_path(path: Any, max_len: int = 42) -> str:
text = str(path or "")
if len(text) <= max_len:
return text
parts = text.split("/")
if len(parts) >= 3:
compact = f"{parts[0]}/…/{parts[-1]}"
if len(compact) <= max_len:
return compact
return text[: max_len - 1].rstrip("/") + "…"
def describe_gate_condition(item: dict[str, Any]) -> str:
metric_key = str(item.get("metricKey", ""))
label = display_metric_key(metric_key)
actual_raw = str(item.get("actualValue", ""))
threshold_raw = str(item.get("errorThreshold", ""))
actual = html.escape(actual_raw)
threshold = html.escape(threshold_raw)
comparator = str(item.get("comparator", "")).upper()
status = str(item.get("status", "")).upper()
failed = status not in ("OK", "PASS", "PASSED")
if metric_key == "new_violations":
return f"{actual} new issue{'s' if actual_raw != '1' else ''} exceed threshold {threshold}"
if metric_key in {"coverage"}:
return f"Coverage {actual}% is {'below' if failed else 'above'} threshold {threshold}%"
if metric_key in {"new_duplicated_lines_density", "duplicated_lines_density"}:
if failed or comparator in {"GT", ">"}:
return f"Duplication {actual}% exceeds threshold {threshold}%"
return f"Duplication {actual}% is within threshold {threshold}%"
return f"{html.escape(label)} {actual} {'exceeds' if failed else 'meets'} threshold {threshold}"
def _top_file_rows(files: list[dict[str, Any]], base_url: str = "", project_key: str = "", focus_mode: bool = False) -> str:
rows = ""
for record in files:
path = record.get("path", "")
full_path = str(path)
filename = html.escape(Path(full_path).name)
filepath = html.escape(short_path(str(Path(full_path).parent), 64))
full_path_escaped = html.escape(full_path)
parent_title = html.escape(str(Path(full_path).parent))
link = record.get("link", "")
if not link and base_url and project_key:
link = sonar_component_link(base_url, project_key, full_path)
link = html.escape(link)
issues = record.get("issues", {})
bugs = int(issues.get("BUG", 0) or 0)
vulns = int(issues.get("VULNERABILITY", 0) or 0)
smells = int(issues.get("CODE_SMELL", 0) or 0)
hotspots = int(issues.get("SECURITY_HOTSPOT", 0) or 0)
is_dup = record.get("duplicationProblem", False)
is_cov = record.get("coverageProblem", False)
issue_total = int(record.get("issueTotal", 0) or 0)
dup_density = to_float(record.get("duplicatedLinesDensity"))
dup_lines = to_float(record.get("duplicatedLines"))
cov_val = to_float(record.get("coverage"))
if not is_dup and (dup_density or dup_lines):
is_dup = True
if not is_cov and cov_val is not None and cov_val == 0.0 and not issue_total and not is_dup:
is_cov = True
pills = []
if bugs and not focus_mode:
pills.append('<span class="pill pill-bug">BUG</span>')
if vulns and not focus_mode:
pills.append('<span class="pill pill-vuln">VULN</span>')
if smells:
pills.append('<span class="pill pill-smell">SMELL</span>')
if hotspots and not focus_mode:
pills.append('<span class="pill pill-hotspot">HOTSPOT</span>')
if is_dup:
pills.append('<span class="pill pill-dup">DUP</span>')
if is_cov:
pills.append('<span class="pill pill-cov">COV</span>')
type_html = " ".join(pills) if pills else '<span class="muted">—</span>'
details: list[str] = []
if issue_total:
details.append(f"{issue_total} issue{'s' if issue_total != 1 else ''}")
if is_dup and dup_density is not None:
details.append(f"{dup_density:.1f}% dup")
if dup_lines:
details.append(f"{int(dup_lines)} lines")
if is_cov and cov_val is not None:
details.append(f"{cov_val:.1f}% cov")
detail_text = html.escape(" · ".join(details)) if details else "—"
anchor_open = f'<a href="{link}" title="{full_path_escaped}">' if link else ""
anchor_close = "</a>" if link else ""
rows += (
"<tr>"
f'<td><div class="filename">{anchor_open}{filename}{anchor_close}</div>'
f'<div class="path" title="{parent_title}">{filepath}</div></td>'
f"<td>{type_html}</td>"
f"<td>{detail_text}</td>"
"</tr>\n"
)
return rows
def render_html(
summary: dict,
measures: dict[str, float | None],
gate_conditions: list[dict],
problem_map: dict[str, Any],
branch: str = "",
focus_mode: bool = False,
code_smells_summary: dict | None = None,
) -> str:
gate = summary["gate"]
duplicate = summary["duplicate"]
coverage = summary["coverage"]
dashboard_url = summary.get("dashboardUrl") or ""
is_passed = gate == "passed"
gate_badge_class = "badge-pass" if is_passed else "badge-fail"
gate_badge_text = "✓ GATE PASSED" if is_passed else "✗ GATE FAILED"
branch_or_scope = html.escape(branch) if branch else html.escape(summary.get("scope", ""))
focus_badge = '<span style="background:#0d2a2a;border:1px solid #39d2c0;color:#39d2c0;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:600">FOCUS MODE</span>' if focus_mode else ""
smell_cs = code_smells_summary or {}
smell_count = smell_cs.get("count")
smell_threshold = smell_cs.get("threshold", 0)
smell_passed = smell_cs.get("passed", True)
smell_card_extra = "pass" if smell_passed else "fail"
smell_color = "green" if smell_passed else "red"
if is_passed:
gate_banner_extra = "pass"
gate_banner_icon = "✓"
gate_banner_title = "Focus Gate Passed" if focus_mode else "Quality Gate Passed"
gate_banner_sub = f"All {len(gate_conditions)} condition{'s' if len(gate_conditions) != 1 else ''} met"
else:
gate_banner_extra = ""
gate_banner_icon = "✗"
gate_banner_title = "Focus Gate Failed" if focus_mode else "Quality Gate Failed"
failed = [c for c in gate_conditions if str(c.get("status", "")).upper() not in ("OK", "PASS", "PASSED")]
sub_parts = [describe_gate_condition(c) for c in failed[:3]]
gate_banner_sub = " · ".join(sub_parts) if sub_parts else "See gate conditions below"
dup_pct = to_float(duplicate.get("percentage"))
cov_pct = to_float(coverage.get("percentage"))
dup_threshold = float(duplicate.get("threshold") or 3)
cov_threshold = float(coverage.get("threshold") or 80)
dup_passed = duplicate.get("passed")
cov_passed = coverage.get("passed")
files_analyzed = int(problem_map.get("filesAnalyzed", 0))
issues_total = int(problem_map.get("issuesTotal", 0))
problem_files_total = int(problem_map.get("problemFilesTotal", 0))
folders_total = int(problem_map.get("foldersTotal", 0))
def fmt_pct(v: Any) -> str:
parsed = to_float(v)
return "N/A" if parsed is None else f"{parsed:.1f}%"
def bar_w(v: Any) -> int:
parsed = to_float(v)
return 0 if parsed is None else max(0, min(100, int(parsed)))
dup_color = "green" if dup_passed else "red"
cov_color = "green" if cov_passed else "red"
dup_card_extra = "pass" if dup_passed else "fail"
cov_card_extra = "pass" if cov_passed else "fail"
# Sidebar gate check rows
sidebar_gate = ""
if focus_mode:
_focus_checks = [
("Coverage", cov_passed),
("Duplication", dup_passed),
("Code Smells", smell_passed),
]
for clabel_full, cpassed in _focus_checks:
clabel = html.escape(clabel_full)
count_style = 'style="color:#3fb950;background:#1a3d2b"' if cpassed else ""
count_text = "✓ pass" if cpassed else "✗ fail"
fail_cls = "" if cpassed else " fail"
sidebar_gate += f'<div class="sidebar-item{fail_cls}" role="button" tabindex="0" onclick="drillTo(\'sec-gate-conds\')"><span class="sidebar-label" title="{clabel}">{clabel}</span><span class="count" {count_style}>{count_text}</span></div>\n'
else:
for cond in gate_conditions:
cstatus = str(cond.get("status", "")).upper()
cpassed = cstatus in ("OK", "PASS", "PASSED")
clabel_full = display_metric_key(cond.get("metricKey", ""))
clabel = html.escape(clabel_full)
count_style = 'style="color:#3fb950;background:#1a3d2b"' if cpassed else ""
count_text = "✓ pass" if cpassed else "✗ fail"
fail_cls = "" if cpassed else " fail"
sidebar_gate += f'<div class="sidebar-item{fail_cls}" role="button" tabindex="0" onclick="drillTo(\'sec-gate-conds\')"><span class="sidebar-label" title="{clabel}">{clabel}</span><span class="count" {count_style}>{count_text}</span></div>\n'
if not sidebar_gate:
sidebar_gate = '<div class="sidebar-item muted">No conditions</div>\n'
sidebar_folders = ""
for i, folder in enumerate(problem_map.get("folders", [])[:6]):
folder_id = f"folder-{i}"
fname_full = str(folder.get("folder", "?"))
fname = html.escape(short_path(fname_full, 34))
fname_title = html.escape(fname_full)
fcount = int(folder.get("issueTotal", 0))
sidebar_folders += f'<div class="sidebar-item" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\', \'{folder_id}\')"><span class="sidebar-label" title="{fname_title}">{fname}</span><span class="count">{fcount}</span></div>\n'
# All problem files (list view)
top_files_all = problem_map.get("topFiles", [])
top_rows = _top_file_rows(top_files_all, focus_mode=focus_mode)
if not top_rows:
top_rows = '<tr><td colspan="3" class="muted" style="padding:14px">No problem files found.</td></tr>'
# Folder detail sections
folder_sections = ""
for i, folder in enumerate(problem_map.get("folders", [])):
folder_id = f"folder-{i}"
fname_full = str(folder.get("folder", ""))
fname = html.escape(short_path(fname_full, 70))
fname_title = html.escape(fname_full)
ffiles = folder.get("files", [])
fissues = int(folder.get("issueTotal", 0))
fdup = int(folder.get("duplicationProblems", 0))
fcov = int(folder.get("coverageProblems", 0))
file_rows = _top_file_rows(ffiles[:30], focus_mode=focus_mode)
extra = len(ffiles) - 30
if extra > 0:
file_rows += f'<tr><td colspan="3" class="muted">+ {extra} more files</td></tr>\n'
folder_sections += f""" <details id="{folder_id}" class="folder">
<summary><span class="folder-name" title="{fname_title}">{fname}</span><span>{len(ffiles)} files · {fissues} issues · {fdup} dup · {fcov} cov</span></summary>
<div class="issues-table" style="border-radius:0;border-left:0;border-right:0;border-bottom:0">
<table><thead><tr><th>File</th><th>Type</th><th>Detail</th></tr></thead>
<tbody>{file_rows}</tbody></table>
</div>
</details>\n"""
if not folder_sections:
folder_sections = '<p class="muted" style="padding:14px 0">No folder-level data available.</p>'
# Gate conditions table
def _cstatus_html(item: dict) -> str:
s = str(item.get("status", "")).upper()
cls = "pass" if s in ("OK", "PASS", "PASSED") else "fail"
return f'<span class="gate-status {cls}">{html.escape(s)}</span>'
if focus_mode:
_focus_rows_data = [
("Coverage", f"{fmt_pct(cov_pct)}", f">= {cov_threshold:g}%", "OK" if cov_passed else "ERROR"),
("Duplication", f"{fmt_pct(dup_pct)}", f"<= {dup_threshold:g}%", "OK" if dup_passed else "ERROR"),
("Code Smells", str(smell_count) if smell_count is not None else "N/A", f"<= {smell_threshold}", "OK" if smell_passed else "ERROR"),
]
condition_rows = "".join(
"<tr>"
f"<td>{html.escape(label)}</td>"
f"<td>{html.escape(actual)}</td>"
f"<td>{html.escape(threshold)}</td>"
f"<td>{_cstatus_html({'status': status})}</td>"
"</tr>"
for label, actual, threshold, status in _focus_rows_data
)
else:
condition_rows = "".join(
"<tr>"
f"<td>{html.escape(display_metric_key(item.get('metricKey', '')))}</td>"
f"<td>{html.escape(str(item.get('actualValue', '')))}</td>"
f"<td>{html.escape(str(item.get('comparator', '')))} {html.escape(str(item.get('errorThreshold', '')))}</td>"
f"<td>{_cstatus_html(item)}</td>"
"</tr>"
for item in gate_conditions
) or '<tr><td colspan="4" class="muted">No quality gate conditions returned.</td></tr>'
dashboard_button = ""
if dashboard_url:
dashboard_button = f'<a class="button" href="{html.escape(dashboard_url)}">Open SonarQube Dashboard →</a>'
limit_note = ""
if problem_map.get("limited"):
limit_note = '<div class="warn" style="margin-bottom:8px">Issue list capped at 1,000 unresolved issues.</div>'
if focus_mode:
_smell_val = str(smell_count) if smell_count is not None else "N/A"
_smell_bar = min(100, int((smell_count or 0) / max(smell_threshold, 1) * 100)) if smell_threshold > 0 else (0 if (smell_count or 0) == 0 else 100)
_smell_sub = "threshold <=" + str(smell_threshold) + " - " + ("pass" if smell_passed else "fail")
metrics_row_html = (
'<div class="metrics-row" style="grid-template-columns:repeat(3,1fr)">'
f'<div class="metric-card {cov_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to coverage by folder">'
'<div class="label">Coverage</div>'
f'<div class="value">{fmt_pct(cov_pct)}</div>'
f'<div class="sub">threshold {cov_threshold:g}% - {"pass" if cov_passed else "fail"}</div>'
f'<div class="progress-bar"><div class="progress-fill {cov_color}" style="width:{bar_w(cov_pct)}%"></div></div>'
"</div>"
f'<div class="metric-card {dup_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to duplication by folder">'
'<div class="label">Duplication</div>'
f'<div class="value">{fmt_pct(dup_pct)}</div>'
f'<div class="sub">threshold {dup_threshold:g}% - {"pass" if dup_passed else "fail"}</div>'
f'<div class="progress-bar"><div class="progress-fill {dup_color}" style="width:{bar_w(dup_pct)}%"></div></div>'
"</div>"
f'<div class="metric-card {smell_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'list\')" title="Drill down to problem files">'
'<div class="label">Code Smells</div>'
f'<div class="value">{_smell_val}</div>'
f'<div class="sub">{_smell_sub}</div>'
f'<div class="progress-bar"><div class="progress-fill {smell_color}" style="width:{_smell_bar}%"></div></div>'
"</div>"
"</div>"
)
else:
metrics_row_html = (
'<div class="metrics-row">'
'<div class="metric-card" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to folder breakdown">'
'<div class="label">Files Analyzed</div>'
f'<div class="value">{files_analyzed}</div>'
f'<div class="sub">across {folders_total} folders</div>'
"</div>"
f'<div class="metric-card {"fail" if issues_total > 0 else ""}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'list\')" title="Drill down to problem files">'
'<div class="label">Total Issues</div>'
f'<div class="value">{issues_total}</div>'
f'<div class="sub">in {problem_files_total} problem files</div>'
"</div>"
f'<div class="metric-card {dup_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to duplication by folder">'
'<div class="label">Duplication</div>'
f'<div class="value">{fmt_pct(dup_pct)}</div>'
f'<div class="sub">threshold {dup_threshold:g}% · {"pass" if dup_passed else "fail"}</div>'
f'<div class="progress-bar"><div class="progress-fill {dup_color}" style="width:{bar_w(dup_pct)}%"></div></div>'
"</div>"
f'<div class="metric-card {cov_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to coverage by folder">'
'<div class="label">Coverage</div>'
f'<div class="value">{fmt_pct(cov_pct)}</div>'
f'<div class="sub">threshold {cov_threshold:g}% · {"pass" if cov_passed else "fail"}</div>'
f'<div class="progress-bar"><div class="progress-fill {cov_color}" style="width:{bar_w(cov_pct)}%"></div></div>'
"</div>"
"</div>"
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>top-flutter / Quality Report</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
:root {{ --bg:#0d1117; --surface:#161b22; --surface2:#21262d; --border:#30363d; --text:#c9d1d9; --strong:#f0f6fc; --muted:#8b949e; --blue:#58a6ff; --red:#f85149; --green:#3fb950; --yellow:#e3b341; --cyan:#39d2c0; }}
[data-theme="light"] {{ --bg:#f6f8fa; --surface:#ffffff; --surface2:#f1f4f8; --border:#d0d7de; --text:#24292f; --strong:#0d1117; --muted:#57606a; --blue:#0969da; --red:#cf222e; --green:#1a7f37; --yellow:#9a6700; --cyan:#0550ae; }}
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; font-size: 13px; line-height: 1.5; }}
a {{ color: #58a6ff; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.muted {{ color: #8b949e; font-size: 11px; }}
.warn {{ background: #2d2414; color: #e3b341; border: 1px solid #e3b341; border-radius: 6px; padding: 10px 14px; font-size: 12px; }}
.button {{ display: inline-block; padding: 6px 14px; border-radius: 6px; background: #21262d; border: 1px solid #30363d; color: #58a6ff; font-size: 12px; font-weight: 500; margin-top: 8px; }}
.button:hover {{ border-color: #58a6ff; text-decoration: none; }}
/* App shell */
.app {{ display: flex; flex-direction: column; min-height: 100vh; }}
.app-header {{ background: #161b22; border-bottom: 1px solid #30363d; padding: 11px 24px; display: flex; align-items: center; gap: 12px; }}
.tab-bar {{ background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; }}
.tab {{ padding: 10px 18px; font-size: 12px; border-bottom: 2px solid transparent; color: #8b949e; }}
.tab.active {{ color: #f0f6fc; border-bottom-color: #1f6feb; }}
.app-header h1 {{ font-size: 14px; font-weight: 600; color: #f0f6fc; white-space: nowrap; }}
.branch {{ background: #21262d; border: 1px solid #30363d; border-radius: 4px; padding: 2px 8px; font-size: 11px; color: #8b949e; white-space: nowrap; }}
.badge-fail {{ background: #3d1414; border: 1px solid #f85149; color: #f85149; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; white-space: nowrap; }}
.badge-pass {{ background: #1a3d2b; border: 1px solid #3fb950; color: #3fb950; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; white-space: nowrap; }}
.header-right {{ margin-left: auto; font-size: 11px; color: #8b949e; display: flex; align-items: center; gap: 12px; white-space: nowrap; }}
.theme-toggle {{ min-height: 32px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; padding: 6px 14px; color: #8b949e; cursor: pointer; font-size: 11px; font-weight: 500; }}
.theme-toggle:hover {{ border-color: #58a6ff; color: #58a6ff; }}
/* Layout */
.content {{ padding: 20px 24px 40px; flex: 1; }}
.polish-layout {{ display: grid; grid-template-columns: 220px 1fr; gap: 20px; }}
/* Sidebar */
.sidebar {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px 16px; align-self: start; }}
.sidebar h3 {{ font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin: 14px 0 8px; }}
.sidebar h3:first-child {{ margin-top: 0; }}
.sidebar-item {{ padding: 5px 8px; border-radius: 4px; margin-bottom: 2px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; overflow: hidden; font-size: 12px; color: #c9d1d9; }}
.sidebar-label {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.sidebar-item.active {{ background: rgba(31,111,235,.13); color: #58a6ff; }}
.sidebar-item.fail {{ color: #f85149; }}
.sidebar-item .count {{ background: #21262d; border-radius: 10px; padding: 1px 6px; font-size: 10px; color: #8b949e; flex-shrink: 0; white-space: nowrap; }}
.sidebar-item.fail .count {{ background: #3d1414; color: #f85149; }}
.sidebar-divider {{ border: none; border-top: 1px solid #30363d; margin: 10px 0; }}
/* Main report */
.main-report {{ display: flex; flex-direction: column; gap: 16px; }}
/* Gate banner */
.gate-banner {{ background: #3d1414; border: 1px solid #f85149; border-radius: 8px; padding: 14px 18px; display: flex; align-items: flex-start; gap: 12px; }}
.gate-banner.pass {{ background: #1a3d2b; border-color: #3fb950; }}
.gate-banner .icon {{ font-size: 18px; line-height: 1.4; flex-shrink: 0; }}
.gate-banner .title {{ font-weight: 600; color: #f85149; font-size: 14px; }}
.gate-banner.pass .title {{ color: #3fb950; }}
.gate-banner .sub {{ font-size: 12px; color: #c9d1d9; margin-top: 3px; }}
/* Metrics row */
.metrics-row {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }}
.metric-card {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; }}
.metric-card .label {{ font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; }}
.metric-card .value {{ font-size: 22px; font-weight: 700; color: #f0f6fc; }}
.metric-card .sub {{ font-size: 11px; color: #8b949e; margin-top: 4px; }}
.metric-card.pass .value {{ color: #3fb950; }}
.metric-card.fail .value {{ color: #f85149; }}
.metric-card[role="button"] {{ cursor: pointer; transition: border-color .15s, background .15s; }}
.metric-card[role="button"]:hover {{ border-color: #58a6ff; background: #1c2128; }}
.metric-card[role="button"]:focus {{ outline: 2px solid #58a6ff; outline-offset: 2px; }}
.sidebar-item[role="button"] {{ cursor: pointer; transition: background .12s; }}
.sidebar-item[role="button"]:hover {{ background: rgba(88,166,255,.1); color: #58a6ff; }}
.sidebar-item[role="button"]:focus {{ outline: 2px solid #58a6ff; outline-offset: -2px; }}
.drill-highlight {{ animation: drillpulse .6s ease-out; }}
@keyframes drillpulse {{ 0%,100% {{ box-shadow: none; }} 40% {{ box-shadow: 0 0 0 3px rgba(88,166,255,.45); }} }}
.progress-bar {{ height: 5px; background: #21262d; border-radius: 3px; overflow: hidden; margin-top: 8px; }}
.progress-fill {{ height: 100%; border-radius: 3px; }}
.progress-fill.green {{ background: #3fb950; }}
.progress-fill.red {{ background: #f85149; }}
/* Section */
.section-title {{ font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px; }}
/* Issues table */
.issues-table {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }}
.issues-table table {{ width: 100%; border-collapse: collapse; }}
.issues-table th {{ background: #21262d; padding: 8px 14px; text-align: left; font-size: 11px; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }}
.issues-table td {{ padding: 8px 14px; font-size: 12px; border-bottom: 1px solid #21262d; vertical-align: top; }}
.issues-table tr:last-child td {{ border-bottom: none; }}
.issues-table tr:hover td {{ background: #1c2128; }}
.filename {{ color: #c9d1d9; }}
.path {{ color: #8b949e; font-size: 11px; font-family: monospace; margin-top: 2px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
/* Pills */
.pill {{ border-radius: 10px; padding: 2px 7px; font-size: 10px; font-weight: 600; display: inline-block; margin: 1px; }}
.pill-bug {{ background: #3d1414; color: #f85149; }}
.pill-vuln {{ background: #2d1a00; color: #e3b341; }}
.pill-smell {{ background: #1a2535; color: #58a6ff; }}
.pill-hotspot {{ background: #0d2a2a; color: #39d2c0; }}
.pill-dup {{ background: #2d2414; color: #e3b341; }}
.pill-cov {{ background: #1a2535; color: #58a6ff; }}
/* Folder details */
details.folder {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }}
details.folder summary {{ cursor: pointer; padding: 11px 16px; font-size: 13px; font-weight: 600; list-style: none; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; color: #f0f6fc; }}
details.folder summary::-webkit-details-marker {{ display: none; }}
details.folder summary::before {{ content: "▶"; font-size: 9px; color: #8b949e; transition: transform .15s; flex-shrink: 0; }}
details[open].folder summary::before {{ transform: rotate(90deg); }}
.folder-name {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f0f6fc; }}
details.folder summary span:not(.folder-name) {{ color: #8b949e; font-size: 12px; font-weight: 400; white-space: nowrap; }}
/* Gate conditions table */
.gate-table {{ width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; font-size: 12px; }}
.gate-table th {{ background: #21262d; padding: 8px 14px; text-align: left; font-size: 11px; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }}
.gate-table td {{ padding: 8px 14px; border-bottom: 1px solid #21262d; }}
.gate-table tr:last-child td {{ border-bottom: none; }}
.gate-status {{ font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; }}
.gate-status.pass {{ background: #1a3d2b; color: #3fb950; }}
.gate-status.fail {{ background: #3d1414; color: #f85149; }}
/* Footer */
.app-footer {{ padding: 12px 24px; border-top: 1px solid #30363d; background: #161b22; font-size: 11px; color: #8b949e; display: flex; justify-content: space-between; align-items: center; }}
/* Problem files tabs */
.pf-tabs {{ display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 12px; background: var(--surface); border-radius: 8px 8px 0 0; padding: 0 4px; }}
.panel-tab {{ padding: 9px 18px; font-size: 12px; font-weight: 500; color: var(--muted); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; transition: color .15s, border-color .15s; line-height: 1; }}
.panel-tab.active {{ color: var(--strong); border-bottom-color: #1f6feb; }}
.panel-tab:hover:not(.active) {{ color: var(--text); }}
.panel-tab:focus {{ outline: 2px solid #58a6ff; outline-offset: -2px; border-radius: 3px; }}
@media (max-width: 900px) {{
.app-header {{ flex-wrap: wrap; }}
.header-right {{ width: 100%; margin-left: 0; justify-content: space-between; }}
.polish-layout {{ grid-template-columns: 1fr; }}
.metrics-row {{ grid-template-columns: 1fr 1fr; }}
}}
</style>
<script>
(function() {{
var t = localStorage.getItem('qr-theme') || 'dark';
document.documentElement.setAttribute('data-theme', t);
document.addEventListener('DOMContentLoaded', function() {{
var label = document.getElementById('themeLbl');
if (label) label.textContent = t === 'light' ? 'Dark' : 'Light';
}});
}})();
function toggleTheme() {{
var t = document.documentElement.getAttribute('data-theme') || localStorage.getItem('qr-theme') || 'dark';
var next = t === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('qr-theme', next);
document.getElementById('themeLbl').textContent = next === 'light' ? 'Dark' : 'Light';
}}
function showProblemView(view, folderId) {{
var pvList = document.getElementById('pv-list');
var pvFolder = document.getElementById('pv-folder');
var tabList = document.getElementById('tab-pf-list');
var tabFolder = document.getElementById('tab-pf-folder');
if (!pvList || !pvFolder) return;
var toFolder = (view === 'folder');
pvList.style.display = toFolder ? 'none' : '';
pvFolder.style.display = toFolder ? '' : 'none';
if (tabList) {{ tabList.classList.toggle('active', !toFolder); tabList.setAttribute('aria-selected', String(!toFolder)); }}
if (tabFolder) {{ tabFolder.classList.toggle('active', toFolder); tabFolder.setAttribute('aria-selected', String(toFolder)); }}
if (toFolder && folderId) {{
setTimeout(function() {{
var el = document.getElementById(folderId);
if (el) {{ el.open = true; el.scrollIntoView({{behavior: 'smooth', block: 'start'}}); }}
}}, 60);
}}
}}
function drillTo(id, view, folderId) {{
if (view) showProblemView(view, folderId);
var el = document.getElementById(id);
if (!el) return;
if (el.tagName === 'DETAILS') {{ el.open = true; }}
el.scrollIntoView({{behavior: 'smooth', block: 'start'}});
el.classList.add('drill-highlight');
setTimeout(function() {{ el.classList.remove('drill-highlight'); }}, 1200);
}}
document.addEventListener('keydown', function(e) {{
if ((e.key === 'Enter' || e.key === ' ') && e.target.getAttribute('role') === 'button') {{
e.preventDefault(); e.target.click();
}}
}});
</script>
</head>
<body>
<div class="app">
<div class="app-header">
<h1>top-flutter / Quality Report</h1>
{f'<span class="branch">{branch_or_scope}</span>' if branch_or_scope else ''}
<span class="{gate_badge_class}">{gate_badge_text}</span>
{focus_badge}
<div class="header-right">
<span>{files_analyzed} files · {issues_total} issues · {problem_files_total} problem files</span>
<button class="theme-toggle" onclick="toggleTheme()"><span id="themeLbl">Light</span></button>
</div>
</div>
<div class="tab-bar"><div class="tab active">Option A — Polish Current Report</div></div>
<div class="content">
<div class="polish-layout">
<div class="sidebar">
<h3>Navigation</h3>
<div class="sidebar-item active" role="button" tabindex="0" onclick="drillTo('sec-gate')"><span class="sidebar-label">Overview</span></div>
<hr class="sidebar-divider">
<h3>Gate Checks</h3>
{sidebar_gate}
<hr class="sidebar-divider">
<h3>Issues</h3>
<div class="sidebar-item" role="button" tabindex="0" onclick="drillTo('sec-problem-files', 'list')"><span class="sidebar-label">By File</span><span class="count">{problem_files_total}</span></div>
<div class="sidebar-item" role="button" tabindex="0" onclick="drillTo('sec-problem-files', 'list')"><span class="sidebar-label">All Issues</span><span class="count">{issues_total}</span></div>
<hr class="sidebar-divider">
<h3>Folders</h3>
{sidebar_folders if sidebar_folders else '<div class="sidebar-item muted">No folder data</div>'}
</div>
<div class="main-report">
<div id="sec-gate" class="gate-banner {gate_banner_extra}">
<div class="icon">{gate_banner_icon}</div>
<div>
<div class="title">{html.escape(gate_banner_title)}</div>
<div class="sub">{gate_banner_sub}</div>
{f'<div style="margin-top:8px">{dashboard_button}</div>' if dashboard_button else ''}
</div>
</div>
{metrics_row_html}
{limit_note}
<div id="sec-problem-files">
<div class="section-title">Problem Files</div>
<div class="pf-tabs" role="tablist" aria-label="Problem files view">
<button id="tab-pf-list" class="panel-tab active" role="tab" aria-selected="true" onclick="showProblemView('list')">List View</button>
<button id="tab-pf-folder" class="panel-tab" role="tab" aria-selected="false" onclick="showProblemView('folder')">Folder View</button>
</div>
<div id="pv-list">
<div class="issues-table">
<table>
<thead><tr><th>File</th><th>Type</th><th>Detail</th></tr></thead>
<tbody>{top_rows}</tbody>
</table>
</div>
</div>
<div id="pv-folder" style="display:none">
{folder_sections}
</div>
</div>
<div id="sec-gate-conds">
<div class="section-title">Quality Gate Conditions</div>
<table class="gate-table">
<thead><tr><th>Metric</th><th>Actual</th><th>Threshold</th><th>Status</th></tr></thead>
<tbody>{condition_rows}</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="app-footer">
<span>top-flutter · {html.escape(summary.get('projectKey', ''))} · Quality Report</span>
<span>Source: SonarQube · {html.escape(summary.get('scope', ''))}</span>
</div>
</div>
</body>
</html>
"""
def generate_report() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--report-task", required=True, help="Path to .scannerwork/report-task.txt")
parser.add_argument("--project-key", required=True, help="SonarQube project key")
parser.add_argument("--scope", default="Whole Project", help="Display label for this run")
parser.add_argument("--coverage", help="Path to lcov.info used by scanner", default=None)
parser.add_argument("--coverage-threshold", type=percent_threshold, default=80)
parser.add_argument("--dup-threshold", type=percent_threshold, default=3)
parser.add_argument("--focus", default="", help="Focus areas: coverage,duplication,smell")
parser.add_argument("--smell-threshold", type=int, default=0, help="Max code smells allowed in focus gate")
args = parser.parse_args()
focus_mode = bool(args.focus)
report_task = Path(args.report_task)
task_values = read_properties(report_task)
base_url = os.environ.get("SONAR_HOST_URL") or task_values.get("serverUrl") or task_values.get("dashboardUrl", "").split("/dashboard", 1)[0]
ce_task_id = task_values.get("ceTaskId")
token = os.environ.get("SONAR_TOKEN") or os.environ.get("SONAR_LOGIN") or ""
if not base_url:
raise SonarApiError("Could not determine SonarQube URL from SONAR_HOST_URL or report-task.txt")
if not ce_task_id:
raise SonarApiError(f"Missing ceTaskId in {report_task}")
analysis_id = wait_for_analysis(base_url, ce_task_id, token)
gate_params = {"analysisId": analysis_id} if analysis_id else {"projectKey": args.project_key}
gate_payload = api_get(
base_url,
"/api/qualitygates/project_status",
gate_params,
token,
)
project_status = gate_payload.get("projectStatus", {})
gate_status = str(project_status.get("status", "ERROR")).upper()
gate_conditions = project_status.get("conditions", [])
metric_keys = "duplicated_lines_density,duplicated_lines,coverage,ncloc,bugs,vulnerabilities,code_smells"
measures_payload = api_get(
base_url,
"/api/measures/component",
{"component": args.project_key, "metricKeys": metric_keys},
token,
)
measures: dict[str, float | None] = {key: None for key in metric_keys.split(",")}
for item in measures_payload.get("component", {}).get("measures", []):
key = item.get("metric")
value = item.get("value")
if key in measures and value is not None:
measures[key] = to_float(value)
problem_map = build_problem_map(base_url, args.project_key, token, args.coverage_threshold, focus_mode=focus_mode)
duplication_pct = measure_float(measures, "duplicated_lines_density")
coverage_pct = measure_float(measures, "coverage")
code_smells_count = measures.get("code_smells")
dup_passed = status_from_threshold(duplication_pct, args.dup_threshold, "max")
cov_passed = status_from_threshold(coverage_pct, args.coverage_threshold, "min")
smell_passed = (code_smells_count is None or int(code_smells_count) <= args.smell_threshold) if focus_mode else True
code_smells_summary = {
"count": int(code_smells_count) if code_smells_count is not None else None,
"threshold": args.smell_threshold,
"passed": smell_passed,
}
if focus_mode:
focus_gate_passed = dup_passed and cov_passed and smell_passed
gate_display = "passed" if focus_gate_passed else "failed"
gate_source = "focus"
else:
gate_display = "passed" if gate_status == "OK" else "failed"
gate_source = "sonar"
summary = {
"scope": args.scope,
"source": "sonarqube",
"projectKey": args.project_key,
"dashboardUrl": task_values.get("dashboardUrl"),
"analysisId": analysis_id,
"duplicate": {
"percentage": duplication_pct,
"threshold": args.dup_threshold,
"passed": dup_passed,
},
"coverage": {
"percentage": coverage_pct,
"threshold": args.coverage_threshold,
"passed": cov_passed,
"reportPath": args.coverage,
},
"qualityGateStatus": gate_status,
"gate": gate_display,
"gateSource": gate_source,
"focusMode": focus_mode,
"codeSmells": code_smells_summary,
"problemMap": {
"issuesTotal": problem_map["issuesTotal"],
"filesAnalyzed": problem_map["filesAnalyzed"],
"problemFilesTotal": problem_map["problemFilesTotal"],
"foldersTotal": problem_map["foldersTotal"],
"topFiles": [
{
"path": item["path"],
"issueTotal": item["issueTotal"],
"coverage": item["coverage"],
"duplicatedLinesDensity": item["duplicatedLinesDensity"],
"duplicatedLines": item["duplicatedLines"],
}
for item in problem_map.get("topFiles", [])[:20]
],
},
}
REPORT_DIR.mkdir(parents=True, exist_ok=True)
SUMMARY_JSON.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
OUTPUT_HTML.write_text(render_html(summary, measures, gate_conditions, problem_map, branch=get_current_branch(), focus_mode=focus_mode, code_smells_summary=code_smells_summary), encoding="utf-8")
print(f"Summary generated: {SUMMARY_JSON}")
print(f"Report generated: {OUTPUT_HTML}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(generate_report())
except SonarApiError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Quality Report</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-light">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-dark" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dart.min.js"></script>
<style>
/* ── Design tokens ──────────────────────────────────────────────
Tokenized from the existing report styling. Legacy aliases (--bg,
--surface, etc.) remain mapped so the visual design is preserved. */
:root {
/* Color primitives: dark theme */
--color-canvas-dark: #0d1117;
--color-surface-dark: #161b22;
--color-surface-raised-dark: #21262d;
--color-border-dark: #30363d;
--color-text-dark: #e6edf3;
--color-text-muted-dark: #8b949e;
--color-success-dark: #3fb950;
--color-danger-dark: #f85149;
--color-accent-dark: #58a6ff;
--color-warning-dark: #d29922;
--color-info-dark: #39d2c0;
/* Semantic colors */
--color-canvas: var(--color-canvas-dark);
--color-surface: var(--color-surface-dark);
--color-surface-raised: var(--color-surface-raised-dark);
--color-border: var(--color-border-dark);
--color-text: var(--color-text-dark);
--color-text-muted: var(--color-text-muted-dark);
--color-success: var(--color-success-dark);
--color-danger: var(--color-danger-dark);
--color-accent: var(--color-accent-dark);
--color-warning: var(--color-warning-dark);
--color-info: var(--color-info-dark);
--color-code-bg: var(--color-canvas-dark);
--color-on-badge: #0d1117;
--color-gate-pass-bg: rgba(63,185,80,0.1);
--color-gate-fail-bg: rgba(248,81,73,0.1);
--color-filter-bg: rgba(88,166,255,0.1);
--color-active-bg: rgba(88,166,255,0.15);
--color-overlay: rgba(1,4,9,0.62);
/* Typography, space, radius, motion, layout */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
--space-1: 4px; --space-2: 6px; --space-3: 8px; --space-4: 10px; --space-5: 12px; --space-6: 14px; --space-7: 16px; --space-8: 20px; --space-9: 24px;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 10px; --radius-pill: 20px;
--shadow-popover: 0 18px 60px rgba(0,0,0,0.35);
--shadow-card: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08);
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.20), 0 2px 4px rgba(0,0,0,0.12);
--duration-fast: 0.15s;
--duration-med: 0.2s;
--layout-sidebar-width: 320px;
/* Legacy aliases preserved for existing rules */
--bg: var(--color-canvas);
--surface: var(--color-surface);
--surface2: var(--color-surface-raised);
--border: var(--color-border);
--text: var(--color-text);
--text-dim: var(--color-text-muted);
--green: var(--color-success);
--red: var(--color-danger);
--blue: var(--color-accent);
--yellow: var(--color-warning);
--cyan: var(--color-info);
--sidebar-w: var(--layout-sidebar-width);
--code-bg: var(--color-code-bg);
--badge-text: var(--color-on-badge);
--gate-pass-bg: var(--color-gate-pass-bg);
--gate-fail-bg: var(--color-gate-fail-bg);
--filter-bg: var(--color-filter-bg);
--active-bg: var(--color-active-bg);
}
[data-theme="light"] {
--color-canvas: #ffffff;
--color-surface: #f6f8fa;
--color-surface-raised: #e8ecf0;
--color-border: #d0d7de;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-success: #1a7f37;
--color-danger: #cf222e;
--color-accent: #0969da;
--color-warning: #9a6700;
--color-info: #0550ae;
--color-code-bg: #f6f8fa;
--color-on-badge: #ffffff;
--color-gate-pass-bg: rgba(26,127,55,0.08);
--color-gate-fail-bg: rgba(207,34,46,0.08);
--color-filter-bg: rgba(9,105,218,0.08);
--color-active-bg: rgba(9,105,218,0.1);
--color-overlay: rgba(31,35,40,0.45);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-header h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
.sidebar-search {
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
outline: none;
}
.sidebar-search:focus { border-color: var(--blue); }
.sidebar-search::placeholder { color: var(--text-dim); }
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-tab {
flex: 1;
padding: 8px;
text-align: center;
font-size: 12px;
font-weight: 500;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.sidebar-tab:hover { color: var(--text); }
.sidebar-tab.active { color: var(--blue); border-bottom-color: var(--blue); }
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.sidebar-content::-webkit-scrollbar { width: 6px; }
.sidebar-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.sidebar-panel { display: none; }
.sidebar-panel.active { display: block; }
.show-all-btn {
width: 100%;
padding: 8px;
margin-bottom: 8px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--blue);
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.show-all-btn:hover { background: var(--border); }
/* Tree view */
.tree-folder { padding: 1px 0; }
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.folder-row:hover { background: var(--surface2); }
.folder-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 9px;
color: var(--text-dim);
transition: transform 0.15s;
flex-shrink: 0;
}
.folder-toggle.open { transform: rotate(90deg); }
.folder-icon { font-size: 12px; flex-shrink: 0; }
.folder-name { font-size: 11px; font-weight: 500; color: var(--text); }
.folder-children {
margin-left: 12px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.tree-file {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
transition: background 0.1s;
}
.tree-file:hover { background: var(--surface2); }
.tree-file.active { background: var(--blue); color: var(--bg); }
.tree-file.active .file-name { color: var(--bg); }
.tree-file.active .file-count { background: var(--bg); color: var(--blue); }
.file-icon { font-size: 12px; flex-shrink: 0; }
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--text);
}
.file-count {
background: var(--surface2);
color: var(--text-dim);
padding: 1px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
}
/* List view */
.list-file {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 2px;
}
.list-file:hover { background: var(--surface2); }
.list-file.active { background: var(--active-bg); }
.list-file-name {
display: block;
font-size: 11px;
font-weight: 500;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
}
.list-file-path {
display: block;
font-size: 10px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.list-file-count {
display: inline-block;
background: var(--surface2);
color: var(--text-dim);
padding: 0 6px;
border-radius: 10px;
font-size: 10px;
margin-top: 2px;
}
/* ── Main ── */
.main {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.main::-webkit-scrollbar { width: 8px; }
.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.main-header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.main-header h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
.main-header .subtitle { color: var(--text-dim); font-size: 13px; }
.gate {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 14px 24px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 16px;
font-weight: 600;
}
.gate.passed { background: var(--gate-pass-bg); border: 1px solid var(--green); color: var(--green); }
.gate.failed { background: var(--gate-fail-bg); border: 1px solid var(--red); color: var(--red); }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
text-align: center;
transition: box-shadow var(--duration-fast), border-color var(--duration-fast);
}
.stat-card:hover { box-shadow: var(--shadow-card-hover); border-color: var(--blue); }
.stat-value { font-size: 24px; font-weight: 700; color: var(--blue); }
.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
padding: 8px 0;
}
.toolbar-search {
flex: 1;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
}
.toolbar-search:focus { border-color: var(--blue); }
.toolbar-search::placeholder { color: var(--text-dim); }
.btn {
padding: 8px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.btn:hover { background: var(--border); }
.filter-tag {
display: none;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--filter-bg);
border: 1px solid var(--blue);
border-radius: 6px;
margin-bottom: 12px;
font-size: 12px;
color: var(--blue);
}
.filter-tag.active { display: inline-flex; }
.filter-clear {
cursor: pointer;
font-size: 14px;
font-weight: bold;
margin-left: 4px;
}
.filter-clear:hover { color: var(--red); }
/* Relationship panel */
.rel-panel {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.rel-panel.active { display: block; }
.rel-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.rel-header-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: var(--blue);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rel-header-stat {
font-size: 12px;
color: var(--text-dim);
flex-shrink: 0;
}
.rel-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.rel-table th {
text-align: left;
padding: 8px 12px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rel-table td {
padding: 8px 12px;
border-top: 1px solid var(--border);
}
.rel-table tr:hover { background: var(--surface2); cursor: pointer; }
.rel-file-link {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
font-size: 12px;
}
.rel-count { color: var(--yellow); font-weight: 600; }
.rel-lines { color: var(--text-dim); }
.result-count { color: var(--text-dim); font-size: 12px; margin-bottom: 12px; }
/* Clone cards */
.clone-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: auto 60px;
}
.clone-card.highlight { border-color: var(--blue); }
.clone-header {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
gap: 10px;
transition: background 0.15s;
}
.clone-header:hover { background: var(--surface2); }
.clone-title { display: flex; align-items: center; gap: 6px; min-width: 120px; }
.clone-badge {
background: var(--yellow);
color: var(--badge-text);
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.clone-lines { color: var(--text-dim); font-size: 11px; }
.clone-type-badge {
background: rgba(210,153,34,0.15);
color: var(--yellow);
border: 1px solid rgba(210,153,34,0.3);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.clone-files-row { flex: 1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.file-tag {
background: var(--surface2);
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
}
.vs { color: var(--text-dim); font-size: 10px; }
.toggle-icon { color: var(--text-dim); font-size: 11px; transition: transform 0.2s; }
.toggle-icon.open { transform: rotate(90deg); }
.clone-list-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 70vh;
padding: 8px;
background: var(--bg);
}
.clone-list-wrap .clone-card:last-child { margin-bottom: 0; }
.clone-body { border-top: 1px solid var(--border); }
.code-compare {
display: grid;
grid-template-columns: 1fr 1fr;
}
.code-panel { overflow: auto; }
.code-panel + .code-panel { border-left: 1px solid var(--border); }
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.panel-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--blue);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.panel-range { color: var(--text-dim); flex-shrink: 0; margin-left: 8px; }
.code-wrapper {
display: flex;
overflow-x: auto;
background: var(--code-bg);
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
}
.code-wrapper .line-numbers {
flex-shrink: 0;
padding: 8px 0;
text-align: right;
user-select: none;
border-right: 1px solid var(--border);
}
.code-wrapper .line-numbers > div {
padding: 0 10px 0 12px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.7;
height: 1.7em;
}
.code-wrapper .line-numbers > div.hl {
background: rgba(248, 81, 73, 0.18);
color: var(--red);
font-weight: 600;
}
.code-wrapper .code-content {
flex: 1;
margin: 0;
padding: 8px 0;
overflow-x: auto;
background: transparent;
}
.code-wrapper .code-content code {
display: block;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.code-wrapper .code-content .code-line {
display: block;
padding: 0 12px;
height: 1.7em;
line-height: 1.7;
}
.code-wrapper .code-content .code-line.hl {
background: rgba(248, 81, 73, 0.18);
border-left: 3px solid var(--red);
padding-left: 9px;
}
[data-theme="light"] .code-wrapper .code-content .code-line.hl {
background: rgba(255, 220, 220, 0.7);
}
[data-theme="light"] .code-wrapper .line-numbers > div.hl {
background: rgba(255, 220, 220, 0.7);
}
.code-panel code.hljs {
background: transparent;
padding: 0;
line-height: 1.7;
}
/* Main tabs */
.main-tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
gap: 4px;
}
.main-tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
border-radius: 6px 6px 0 0;
}
.main-tab:hover { color: var(--text); background: var(--surface); }
.main-tab.active { color: var(--blue); border-bottom-color: var(--blue); background: var(--surface); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Overview gauge */
.overview-gates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.gate-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.gate-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
margin-bottom: 8px;
}
.gate-card-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.gate-card-bar {
height: 6px;
border-radius: 3px;
background: var(--surface2);
margin-top: 8px;
overflow: hidden;
}
.gate-card-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.gate-card-detail {
font-size: 11px;
color: var(--text-dim);
margin-top: 6px;
}
/* Coverage table */
.cov-table-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 70vh;
margin-top: 12px;
}
.cov-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.cov-table th {
text-align: left;
padding: 10px 12px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 2px solid var(--border);
}
.cov-table th:hover { color: var(--text); }
.cov-table td {
padding: 7px 12px;
border-top: 1px solid var(--border);
}
.cov-table tr:hover { background: var(--surface); }
.cov-row { cursor: default; }
.cov-file-action {
display: inline-flex;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.cov-file-action:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 3px; }
.cov-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--cyan);
}
.cov-bar-wrap {
width: 100%;
height: 8px;
background: var(--surface2);
border-radius: 4px;
overflow: hidden;
min-width: 80px;
}
.cov-bar {
height: 100%;
border-radius: 4px;
}
/* Coverage sidebar */
.cov-sidebar-file {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
font-size: 12px;
margin-bottom: 2px;
}
.cov-sidebar-file:hover { background: var(--surface2); }
.cov-sidebar-name {
flex: 1;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cov-sidebar-bar {
width: 50px;
height: 6px;
background: var(--surface2);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.cov-sidebar-pct {
font-size: 11px;
font-weight: 600;
min-width: 32px;
text-align: right;
flex-shrink: 0;
}
/* Coverage drill-down */
.coverage-table-view.hidden, .coverage-detail-view.hidden { display: none; }
.coverage-detail-view {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.cov-detail-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.cov-detail-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--cyan);
font-size: 13px;
font-weight: 600;
}
.cov-detail-stats { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cov-stat-pill { padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: 12px; background: var(--bg); }
.cov-legend { display: flex; gap: 10px; align-items: center; margin-left: auto; color: var(--text-dim); font-size: 11px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.legend-covered { background: rgba(63,185,80,0.35); border: 1px solid var(--green); }
.legend-uncovered { background: rgba(248,81,73,0.35); border: 1px solid var(--red); }
.legend-neutral { background: var(--surface2); border: 1px solid var(--border); }
.cov-source-wrap { max-height: 72vh; overflow: auto; background: var(--code-bg); }
.cov-source-line { display: grid; grid-template-columns: 72px 1fr; min-height: 1.65em; font-family: var(--font-mono); font-size: 12px; line-height: 1.65; }
.cov-source-line .ln { padding: 0 10px; text-align: right; color: var(--text-dim); user-select: none; border-right: 1px solid var(--border); }
.cov-source-line .src { padding: 0 12px; white-space: pre; overflow: visible; }
.cov-source-line.covered { background: rgba(63,185,80,0.10); }
.cov-source-line.covered .ln { color: var(--green); }
.cov-source-line.uncovered { background: rgba(248,81,73,0.16); }
.cov-source-line.uncovered .ln { color: var(--red); font-weight: 600; }
.cov-source-line.neutral { background: transparent; }
/* Sidebar sections */
.sidebar-section { display: none; }
.sidebar-section.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
/* Theme toggle */
.theme-toggle {
position: fixed;
top: 16px;
right: 24px;
z-index: 999;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
color: var(--text);
transition: all 0.2s;
}
.theme-toggle:hover { background: var(--surface2); }
.theme-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.theme-toggle-icon { font-size: 16px; }
/* Focus-visible for interactive elements */
button:focus-visible, [role="button"]:focus-visible,
.sidebar-tab:focus-visible, .folder-row:focus-visible, .tree-file:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
/* ── Contextual Copilot Prompt Modal ── */
.copilot-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 6px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--blue);
background: var(--filter-bg);
color: var(--blue);
cursor: pointer;
font-size: 12px;
font-weight: 600;
min-height: 32px;
white-space: nowrap;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
}
.copilot-btn:hover { background: var(--blue); color: var(--badge-text); }
.copilot-btn.compact { padding: 4px 9px; font-size: 11px; min-height: 32px; }
.cov-list-item .copilot-btn { margin-top: 6px; width: 100%; }
.prompt-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
padding: var(--space-9);
background: var(--color-overlay);
pointer-events: none;
}
.prompt-modal-backdrop.active { display: flex; }
.prompt-modal {
width: min(860px, 96vw);
pointer-events: auto;
max-height: 86vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-popover);
}
.prompt-modal-header {
display: flex;
align-items: center;
gap: var(--space-5);
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.prompt-modal-title { font-size: 15px; font-weight: 700; color: var(--text); }
.prompt-modal-file {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
color: var(--cyan);
}
.prompt-modal-close {
border: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.prompt-modal-close:hover { color: var(--red); }
.prompt-modal-body { padding: 16px; overflow: auto; background: var(--bg); }
.prompt-modal-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.65;
}
.prompt-modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.prompt-copy-btn {
padding: 8px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--blue);
background: var(--blue);
color: var(--badge-text);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.prompt-copy-btn.copied { background: var(--green); border-color: var(--green); }
@media (max-width: 1000px) {
.sidebar { width: 260px; min-width: 260px; }
.code-compare { grid-template-columns: 1fr; }
.code-panel + .code-panel { border-left: none; border-top: 1px solid var(--border); }
.stats { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle colour theme">
<span class="theme-toggle-icon" id="themeIcon">☼</span>
<span id="themeLabel">Light</span>
</button>
<!-- INJECT_BODY -->
<script>
// INJECT_DATA
// Theme toggle
function setHljsTheme(theme) {
document.getElementById('hljs-light').disabled = (theme === 'dark');
document.getElementById('hljs-dark').disabled = (theme !== 'dark');
}
function toggleTheme() {
const html = document.documentElement;
const isLight = html.getAttribute('data-theme') === 'light';
const next = isLight ? 'dark' : 'light';
html.setAttribute('data-theme', next);
document.getElementById('themeIcon').innerHTML = isLight ? '☾' : '☼';
document.getElementById('themeLabel').textContent = isLight ? 'Dark' : 'Light';
setHljsTheme(next);
localStorage.setItem('dup-report-theme', next);
}
(function() {
const saved = localStorage.getItem('dup-report-theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'dark') {
document.getElementById('themeIcon').innerHTML = '☾';
document.getElementById('themeLabel').textContent = 'Dark';
}
setHljsTheme(theme);
})();
// Syntax highlighting + line wrapping
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('code.language-dart').forEach(el => {
const mask = (el.dataset.hl || '').split(',');
const rawText = el.textContent;
const result = hljs.highlight(rawText, { language: 'dart', ignoreIllegals: true });
const lines = result.value.split('\n');
el.innerHTML = lines.map((line, idx) => {
const isHl = mask[idx] === '1';
return '<span class="code-line' + (isHl ? ' hl' : '') + '">' + (line || ' ') + '</span>';
}).join('');
el.classList.add('hljs');
});
});
// Tab switching
function switchMainTab(tab) {
closePromptModal();
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
const sidebar = document.querySelector('.sidebar');
const hasSidebar = (tab === 'duplicates' || tab === 'coverage');
if (sidebar) {
sidebar.style.display = hasSidebar ? 'flex' : 'none';
document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
const section = document.getElementById('sidebar-' + tab);
if (section) section.classList.add('active');
}
}
// Coverage sidebar tab switch
function switchCovTab(el, panel) {
const section = document.getElementById('sidebar-coverage');
section.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
section.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById('panel-' + panel).classList.add('active');
}
// Coverage sidebar filter
function filterCovSidebar() {
const q = document.getElementById('covSidebarSearch').value.toLowerCase();
const section = document.getElementById('sidebar-coverage');
section.querySelectorAll('.tree-file').forEach(el => {
const title = (el.querySelector('.file-name').title || '').toLowerCase();
el.style.display = (!q || title.includes(q)) ? 'flex' : 'none';
});
section.querySelectorAll('.cov-list-item').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
});
if (q) {
section.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
section.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
section.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '📂');
}
}
// Contextual Copilot prompts
let currentPromptId = null;
function openPromptModal(id) {
const prompt = PROMPTS_DATA[id];
if (!prompt) return;
currentPromptId = id;
const modal = document.getElementById('promptModal');
const title = document.getElementById('promptModalTitle');
const file = document.getElementById('promptModalFile');
const text = document.getElementById('promptModalText');
const copyBtn = document.getElementById('promptCopyBtn');
title.textContent = prompt.type === 'coverage' ? 'Coverage Copilot Prompt' : 'Duplicate Refactor Copilot Prompt';
file.textContent = prompt.file || '';
file.title = prompt.file || '';
text.textContent = prompt.text || '';
copyBtn.innerHTML = '📋 Copy for Copilot';
copyBtn.classList.remove('copied');
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePromptModal() {
const modal = document.getElementById('promptModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
currentPromptId = null;
}
document.addEventListener('click', function(e) {
const modal = document.getElementById('promptModal');
if (!modal || !modal.classList.contains('active')) return;
const tabAtPoint = Array.from(document.querySelectorAll('.main-tab')).find(tab => {
const rect = tab.getBoundingClientRect();
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
});
if (tabAtPoint) {
e.preventDefault();
e.stopPropagation();
closePromptModal();
tabAtPoint.click();
return;
}
if (e.target.closest('.prompt-modal')) return;
closePromptModal();
}, true);
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePromptModal();
});
function copyPrompt(id, btn) {
const text = (PROMPTS_DATA[id] || {}).text || '';
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => flashCopied(btn));
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
flashCopied(btn);
}
}
function flashCopied(btn) {
const orig = btn.innerHTML;
btn.innerHTML = '✓ Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('copied'); }, 1800);
}
function showCoverageTable(options) {
const opts = options || {};
const tableView = document.getElementById('coverageTableView');
const detailView = document.getElementById('coverageDetailView');
if (tableView) tableView.classList.remove('hidden');
if (detailView) detailView.classList.add('hidden');
document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
if (opts.scroll !== false && tableView) tableView.scrollIntoView({ block: 'start' });
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function openCoverageDetail(filePath) {
const detail = COVERAGE_DETAILS[filePath];
if (!detail) return;
const tableView = document.getElementById('coverageTableView');
const detailView = document.getElementById('coverageDetailView');
const sourceWrap = document.getElementById('covSourceWrap');
if (!tableView || !detailView || !sourceWrap) return;
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const coverageTab = document.getElementById('tab-coverage');
if (coverageTab) coverageTab.classList.add('active');
const tabs = Array.from(document.querySelectorAll('.main-tab'));
const covTabButton = tabs.find(t => t.textContent.includes('Coverage'));
if (covTabButton) covTabButton.classList.add('active');
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.style.display = 'flex';
document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
const section = document.getElementById('sidebar-coverage');
if (section) section.classList.add('active');
}
tableView.classList.add('hidden');
detailView.classList.remove('hidden');
setText('covDetailFile', detail.path || filePath);
const title = document.getElementById('covDetailFile');
if (title) title.title = detail.path || filePath;
const missed = Math.max((detail.total || 0) - (detail.hit || 0), 0);
setText('covDetailPct', 'Coverage: ' + Number(detail.pct || 0).toFixed(1) + '%');
setText('covDetailHit', (detail.hit || 0) + ' covered / ' + (detail.total || 0) + ' executable lines');
setText('covDetailMiss', missed + ' uncovered executable line' + (missed === 1 ? '' : 's'));
const promptBtn = document.getElementById('covDetailPromptBtn');
if (promptBtn) {
promptBtn.onclick = function(e) { e.stopPropagation(); openPromptModal(detail.promptId); };
}
sourceWrap.textContent = '';
const lineHits = detail.lines || {};
(detail.source || []).forEach((line, idx) => {
const lineNumber = idx + 1;
const key = String(lineNumber);
const executable = Object.prototype.hasOwnProperty.call(lineHits, key);
const hits = executable ? Number(lineHits[key]) : 0;
const row = document.createElement('div');
row.className = 'cov-source-line ' + (executable ? (hits > 0 ? 'covered' : 'uncovered') : 'neutral');
if (executable) row.title = hits + ' hit' + (hits === 1 ? '' : 's');
const gutter = document.createElement('span');
gutter.className = 'ln';
gutter.textContent = executable ? (lineNumber + ' ' + (hits > 0 ? '✓' : '✕')) : String(lineNumber);
const src = document.createElement('span');
src.className = 'src';
src.textContent = line || ' ';
row.appendChild(gutter);
row.appendChild(src);
sourceWrap.appendChild(row);
});
document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.cov-row').forEach(row => {
const fileCell = row.querySelector('.cov-file');
if (fileCell && fileCell.textContent === filePath) row.classList.add('active');
});
document.querySelectorAll('.cov-tree-file, .cov-list-item').forEach(el => {
const label = el.querySelector('.file-name, .list-file-name');
if (label && label.title === filePath) el.classList.add('active');
});
const main = document.querySelector('.main');
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
}
// Coverage table filter
function filterCovTable() {
const q = document.getElementById('covSearch').value.toLowerCase();
const rows = document.querySelectorAll('#covBody tr');
let visible = 0;
rows.forEach(row => {
const text = row.textContent.toLowerCase();
const show = !q || text.includes(q);
row.style.display = show ? '' : 'none';
if (show) visible++;
});
const el = document.getElementById('covCount');
if (el) el.textContent = 'Showing ' + visible + ' files';
}
let covSortCol = -1, covSortAsc = true;
function sortCovTable(col) {
const tbody = document.getElementById('covBody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (covSortCol === col) { covSortAsc = !covSortAsc; } else { covSortCol = col; covSortAsc = true; }
rows.sort((a, b) => {
let va = a.cells[col].textContent.trim();
let vb = b.cells[col].textContent.trim();
const na = parseFloat(va.replace('%',''));
const nb = parseFloat(vb.replace('%',''));
if (!isNaN(na) && !isNaN(nb)) return covSortAsc ? na - nb : nb - na;
return covSortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
rows.forEach(r => tbody.appendChild(r));
}
function toggleClone(i) {
const body = document.getElementById('body-' + i);
const icon = document.getElementById('icon-' + i);
if (body.style.display === 'none') {
body.style.display = 'block';
icon.classList.add('open');
} else {
body.style.display = 'none';
icon.classList.remove('open');
}
}
function expandAll() {
document.querySelectorAll('.clone-card').forEach(c => {
if (c.style.display !== 'none') {
c.querySelector('.clone-body').style.display = 'block';
c.querySelector('.toggle-icon').classList.add('open');
}
});
}
function collapseAll() {
document.querySelectorAll('.clone-body').forEach(b => b.style.display = 'none');
document.querySelectorAll('.toggle-icon').forEach(i => i.classList.remove('open'));
}
function filterClones() {
const q = document.getElementById('mainSearch').value.toLowerCase();
let visible = 0;
document.querySelectorAll('.clone-card').forEach(card => {
if (card.dataset.hidden === 'true') return;
const text = card.textContent.toLowerCase();
const show = !q || text.includes(q);
card.style.display = show ? 'block' : 'none';
if (show) visible++;
});
document.getElementById('resultCount').textContent = 'Showing ' + visible + ' clones';
}
function renderRelPanel(filePath) {
const rels = FILE_RELATIONS[filePath];
const panel = document.getElementById('relPanel');
if (!rels || Object.keys(rels).length === 0) {
panel.classList.remove('active');
return;
}
const fname = filePath.split('/').pop();
const relFiles = Object.keys(rels);
const totalLines = relFiles.reduce((s, k) => s + rels[k].lines, 0);
document.getElementById('relFileName').textContent = fname;
document.getElementById('relFileName').title = filePath;
document.getElementById('relStat').textContent = relFiles.length + ' related file' + (relFiles.length > 1 ? 's' : '') + ' · ' + totalLines + ' duplicate lines';
const tbody = document.getElementById('relBody');
tbody.innerHTML = '';
relFiles.forEach(other => {
const info = rels[other];
const otherName = other.split('/').pop();
const tr = document.createElement('tr');
tr.onclick = function() {
// Filter to show only clones between these two files
document.querySelectorAll('.clone-card').forEach(card => {
const idx = parseInt(card.dataset.index);
if (info.clones.includes(idx)) {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.add('highlight');
} else {
card.style.display = 'none';
card.dataset.hidden = 'true';
card.classList.remove('highlight');
}
});
document.getElementById('resultCount').textContent = 'Showing ' + info.clones.length + ' of ' + TOTAL + ' clones (' + fname + ' vs ' + otherName + ')';
};
const fileTd = document.createElement('td');
const fileSpan = document.createElement('span');
fileSpan.className = 'rel-file-link';
fileSpan.title = other;
fileSpan.textContent = otherName;
fileTd.appendChild(fileSpan);
const countTd = document.createElement('td');
const countSpan = document.createElement('span');
countSpan.className = 'rel-count';
countSpan.textContent = info.clones.length;
countTd.appendChild(countSpan);
const linesTd = document.createElement('td');
const linesSpan = document.createElement('span');
linesSpan.className = 'rel-lines';
linesSpan.textContent = info.lines;
linesTd.appendChild(linesSpan);
tr.appendChild(fileTd);
tr.appendChild(countTd);
tr.appendChild(linesTd);
tbody.appendChild(tr);
});
panel.classList.add('active');
}
function filterByFile(filePath, cloneIds) {
// Clear highlights
document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
// Highlight clicked item
event.currentTarget.classList.add('active');
document.querySelectorAll('.clone-card').forEach(card => {
const idx = parseInt(card.dataset.index);
if (cloneIds.includes(idx)) {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.add('highlight');
} else {
card.style.display = 'none';
card.dataset.hidden = 'true';
card.classList.remove('highlight');
}
});
renderRelPanel(filePath);
const fname = filePath.split('/').pop();
document.getElementById('filterName').textContent = fname;
document.getElementById('filterName').title = filePath;
document.getElementById('filterTag').classList.add('active');
document.getElementById('resultCount').textContent = 'Showing ' + cloneIds.length + ' of ' + TOTAL + ' clones';
document.getElementById('mainSearch').value = '';
}
function showAll() {
document.querySelectorAll('.clone-card').forEach(card => {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.remove('highlight');
});
document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
document.getElementById('filterTag').classList.remove('active');
document.getElementById('relPanel').classList.remove('active');
document.getElementById('resultCount').textContent = 'Showing ' + TOTAL + ' clones';
document.getElementById('mainSearch').value = '';
}
function switchTab(tab) {
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('panel-' + tab).classList.add('active');
}
function toggleFolder(row) {
const toggle = row.querySelector('.folder-toggle');
const icon = row.querySelector('.folder-icon');
toggle.classList.toggle('open');
const children = row.parentElement.querySelector('.folder-children');
const isOpen = children.style.display !== 'none';
children.style.display = isOpen ? 'none' : 'block';
icon.innerHTML = isOpen ? '📁' : '📂';
}
function filterSidebar() {
const q = document.getElementById('sidebarSearch').value.toLowerCase();
// Filter tree files
document.querySelectorAll('.tree-file').forEach(el => {
const name = el.querySelector('.file-name').textContent.toLowerCase();
const title = (el.querySelector('.file-name').title || '').toLowerCase();
el.style.display = (!q || name.includes(q) || title.includes(q)) ? 'flex' : 'none';
});
// Filter list files
document.querySelectorAll('.list-file').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
});
if (q) {
document.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
document.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
document.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '📂');
}
}
let sortAsc = false;
function sortByLines() {
const list = document.getElementById('cloneList');
const cards = Array.from(list.querySelectorAll('.clone-card'));
sortAsc = !sortAsc;
cards.sort((a, b) => {
const la = parseInt(a.dataset.lines);
const lb = parseInt(b.dataset.lines);
return sortAsc ? la - lb : lb - la;
});
cards.forEach(c => list.appendChild(c));
document.getElementById('sortBtn').innerHTML = 'Sort: Lines ' + (sortAsc ? '↑' : '↓');
}
</script>
</body>
</html>
# Quality Check
Local and CI quality gate for the Flutter monorepo. **SonarScanner and SonarQube are the source of truth** for analysis, duplication, coverage metrics, and the quality gate result. The local script runs the same scanner engine used in CI, then writes a small HTML report and `summary.json` from SonarQube API results.
## Files
```text
scripts/quality/
├── setup.sh # First-run setup for portable local SonarQube/scanner (no Homebrew/Docker/admin)
├── local-quality.sh # Recommended entrypoint; defaults to portable local SonarQube
├── portable-local-sonar.sh # Portable local SonarQube runner used by local-quality.sh
├── quality-check.sh # Core engine: runs SonarScanner, then generates local reports
├── generate-report.py # Reads scanner task metadata and SonarQube API metrics
├── template.html # Self-contained HTML/CSS/JS template for quality-report.html
├── DESIGN.md # Design tokens and UI spec — start here for visual changes
└── README.md
```
Output goes to `reports/quality/`:
| File | Description |
|------|-------------|
| `quality-report.html` | Local HTML summary plus Full Quality Map by folder/file from SonarQube APIs |
| `summary.json` | Machine-readable quality gate result and top problem files for CI/local automation |
## Prerequisites
Recommended local flow uses portable zip-based tooling and does **not** require Homebrew, Docker, Colima, sudo, or admin access.
Required for setup/run:
- `curl`
- `unzip`
- `python3` — stdlib only, no extra packages needed
- Java **JDK 17** on `PATH`, `JAVA_HOME`, or `PORTABLE_JAVA_HOME` (SonarQube 10.7 is run with Java 17; on macOS the scripts prefer `/usr/libexec/java_home -v 17`)
Optional coverage file generated before running the check:
- Whole repo: `reports/coverage/clean_combined_lcov.info`
- Package: `<package>/coverage/lcov.info`
Remote/team SonarQube mode also needs:
- **SonarQube/SonarCloud access** via `SONAR_HOST_URL` or `sonar.host.url`
- **Authentication token** in `SONAR_TOKEN`
## Recommended local flow
### 1) First-run setup
```bash
bash scripts/quality/setup.sh
```
This creates `~/.cache/top-flutter-quality/`, downloads/extracts the configured SonarQube and sonar-scanner zips, downloads/installs the configured `insideapp-oss/sonar-flutter` plugin into the portable SonarQube, verifies `sonar.sh`, `sonar-scanner`, and plugin placement, and prints the next command. Downloads are cached for later runs.
Environment overrides are shared with `portable-local-sonar.sh`:
```bash
TOP_FLUTTER_QUALITY_CACHE=/tmp/top-flutter-quality bash scripts/quality/setup.sh
SONARQUBE_PORTABLE_VERSION=10.7.0.96327 bash scripts/quality/setup.sh
SONAR_SCANNER_PORTABLE_VERSION=6.2.1.4610 bash scripts/quality/setup.sh
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2 bash scripts/quality/setup.sh
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0 bash scripts/quality/setup.sh # skip plugin install
```
Use `bash scripts/quality/setup.sh --help` to see all setup overrides without downloading anything.
### 2) Daily run
```bash
bash scripts/quality/local-quality.sh
```
`local-quality.sh` now defaults to portable local SonarQube:
1. Uses cached SonarQube and sonar-scanner under `~/.cache/top-flutter-quality/`.
2. Configures the portable SonarQube bind address to `127.0.0.1:${SONAR_LOCAL_PORT:-19102}` (default port `19102`, override with `SONAR_LOCAL_PORT`).
3. Ensures the cached `insideapp-oss/sonar-flutter` plugin is installed under SonarQube `extensions/plugins/` (downloads it if setup was skipped).
4. Starts SonarQube from the zip using its bundled launcher — no Docker/Colima.
5. Creates/caches and validates a portable local token in `~/.sonarqube-local/top-flutter-portable-<port>.token` when `SONAR_TOKEN` is not provided (override with `SONAR_LOCAL_TOKEN_FILE`).
6. Runs the same `quality-check.sh` engine with the portable sonar-scanner first on `PATH`.
7. Writes `reports/quality/summary.json` and `reports/quality/quality-report.html`.
8. Stops SonarQube unless you keep it running.
Common examples:
```bash
# Keep SonarQube running after the scan (inspect dashboard at http://localhost:19102)
bash scripts/quality/local-quality.sh --keep-sonar-local
# Minimal focus: coverage >= 80%, duplication <= 3%, code smells <= 0
bash scripts/quality/local-quality.sh --minimal-focus
bash scripts/quality/local-quality.sh --focus-minimal # alias
# Single package label and package coverage source
bash scripts/quality/local-quality.sh packages/features/create_rm
# Point to a different JDK without changing system settings
PORTABLE_JAVA_HOME=~/.cache/jdk/jdk-17 bash scripts/quality/local-quality.sh
# Override cache location
TOP_FLUTTER_QUALITY_CACHE=/tmp/sq-cache bash scripts/quality/local-quality.sh
# Override the portable SonarQube port if 19102 is already in use
SONAR_LOCAL_PORT=9103 bash scripts/quality/local-quality.sh
```
`--portable-local` is still accepted as a backward-compatible no-op, but it is no longer needed.
**Options:**
| Flag | Default | Description |
|------|---------|-------------|
| `--keep-sonar-local` | off | Keep portable local SonarQube running after the scan |
| `--keep-server` | off | Alias for `--keep-sonar-local` |
| `-d, --dup-threshold N` | `3` | Max allowed duplication percentage used in local summary fields |
| `-c, --cov-threshold N` | `80` | Min required coverage percentage used in local summary fields |
| `--focus AREAS` | — | Focus report on `coverage,duplication,smell` — gate derived locally from those three thresholds; BUG/VULN/HOTSPOT hidden |
| `--minimal-focus` | — | Shorthand for `--focus coverage,duplication,smell` |
| `--focus-minimal` | — | Alias for `--minimal-focus` |
| `--smell-threshold N` | `0` | Max code smells allowed when `--focus` is active |
| `--legacy-local` | off | Optional legacy Homebrew + Colima/Docker local mode |
| `-h, --help` | — | Show help |
Portable local SonarQube installs the Flutter/Dart analyzer plugin by default, matching legacy mode:
```bash
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=1
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2
SONAR_FLUTTER_PLUGIN_URL=https://github.com/insideapp-oss/sonar-flutter/releases/download/${SONAR_FLUTTER_PLUGIN_VERSION}/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar
SONAR_FLUTTER_PLUGIN_JAR=~/.cache/top-flutter-quality/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar
```
Set `SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0` only if you are intentionally running without Dart/Flutter-specific Sonar rules.
## Direct usage (advanced)
```bash
# Whole project against a remote/team SonarQube host
SONAR_HOST_URL=https://sonar.example.com SONAR_TOKEN=*** SONAR_PROJECT_KEY=top-flutter \
bash scripts/quality/quality-check.sh
# Single package against a remote/team SonarQube host
SONAR_HOST_URL=https://sonar.example.com SONAR_TOKEN=*** SONAR_PROJECT_KEY=top-flutter \
bash scripts/quality/quality-check.sh packages/features/create_rm
# Custom fallback thresholds for the local summary
bash scripts/quality/quality-check.sh -d 5 -c 70 packages/features/home
```
## Focus / Minimal mode
Use focus mode to narrow the quality gate to only three areas — **Coverage**, **Duplication**, and **Code Smell** — and hide BUG/VULN/HOTSPOT issues in the HTML report. The gate is derived locally from those three thresholds, not from SonarQube's configured gate.
```bash
# Minimal focus through the recommended local entrypoint
bash scripts/quality/local-quality.sh --minimal-focus
# Same with a looser smell threshold for a legacy package
bash scripts/quality/local-quality.sh --minimal-focus --smell-threshold 20 packages/features/home
# Direct engine usage against an already configured SonarQube host
bash scripts/quality/quality-check.sh --focus coverage,duplication,smell --smell-threshold 5
```
In focus mode the HTML report shows:
- **FOCUS MODE** badge in the header
- 3-card metrics row: Coverage · Duplication · Code Smells
- "Focus Gate Passed/Failed" banner (not the Sonar gate)
- Gate conditions table with the three local thresholds
- Only CODE_SMELL pills visible in the problem-file rows
`summary.json` gains three extra fields when focus mode is active:
```json
{
"gate": "passed",
"gateSource": "focus",
"focusMode": true,
"codeSmells": { "count": 3, "threshold": 5, "passed": true }
}
```
`gateSource` is `"focus"` (local threshold check) or `"sonar"` (Sonar's configured quality gate). The raw `qualityGateStatus` from SonarQube is still present and unchanged.
## Legacy Homebrew/Docker local mode (optional)
The previous local path is still available, but it is no longer the recommended default. Use it only if you specifically want Colima/Docker-managed SonarQube:
```bash
bash scripts/quality/local-quality.sh --legacy-local
```
Legacy mode requires Homebrew and will install missing packages (`colima`, `docker`, `sonar-scanner`, `jq`, `python`) via `brew install`. You can also call the core engine directly:
```bash
bash scripts/quality/quality-check.sh --sonar-local
bash scripts/quality/quality-check.sh --sonar-local packages/features/create_rm
```
Legacy flow:
```text
1. Start Colima if Docker is not already available
2. Create/start sonarqube-local container if needed
3. Download/install `insideapp-oss/sonar-flutter` plugin into the local container unless disabled
4. Wait until http://localhost:9000/api/system/status = UP
5. Create/cache a local token in ~/.sonarqube-local/top-flutter.token when SONAR_TOKEN is not provided
6. Run sonar-scanner against localhost only
7. Generate reports/quality/summary.json and reports/quality/quality-report.html
8. Stop the SonarQube container and Colima if this script started them
```
The legacy local mode hard-guards the host URL and refuses anything except:
```text
http://localhost:*
http://127.0.0.1:*
```
This prevents accidental scans against the team SonarQube server.
### Legacy resource knobs
Defaults are conservative for a resource-limited Mac:
```bash
COLIMA_CPU=2
COLIMA_MEMORY=4
COLIMA_DISK=20
SONAR_LOCAL_PORT=9000
SONAR_LOCAL_IMAGE=sonarqube:community
SONAR_LOCAL_CONTAINER=sonarqube-local
SONAR_LOCAL_PROJECT_KEY=top-flutter-local
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=1
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2
```
Examples:
```bash
# Try a slightly smaller VM. If SonarQube fails to start, go back to 4GB.
COLIMA_MEMORY=3 COLIMA_DISK=15 bash scripts/quality/local-quality.sh --legacy-local
# Keep the Docker-backed local server open after the scan.
bash scripts/quality/local-quality.sh --legacy-local --keep-sonar-local
# Equivalent env switch.
SONAR_LOCAL_KEEP_RUNNING=1 bash scripts/quality/local-quality.sh --legacy-local
```
First legacy run will pull `sonarqube:community`, download `sonar-flutter-plugin`, and create Docker volumes. Later runs reuse the local container/volumes.
## Coverage setup
The script does not run tests. Generate coverage first when coverage should be included in the scanner analysis.
**Single package:**
```bash
cd packages/features/create_rm
very_good test --coverage
cd -
bash scripts/quality/local-quality.sh packages/features/create_rm
```
**Whole project:**
```bash
bash scripts/combine-coverage.sh # writes reports/coverage/clean_combined_lcov.info
bash scripts/quality/local-quality.sh
```
## Quality gate
`summary.json` schema:
```json
{
"scope": "packages/features/create_rm",
"source": "sonarqube",
"projectKey": "top-flutter",
"dashboardUrl": "https://sonar.example.com/dashboard?id=top-flutter",
"analysisId": "...",
"duplicate": { "percentage": 4.73, "threshold": 3, "passed": false },
"coverage": { "percentage": 75.42, "threshold": 80, "passed": false, "reportPath": ".../lcov.info" },
"qualityGateStatus": "ERROR",
"gate": "failed",
"gateSource": "sonar",
"focusMode": false,
"codeSmells": { "count": 12, "threshold": 0, "passed": false }
}
```
- `gate` is `"passed"` or `"failed"`. In default mode it mirrors SonarQube's gate status. In focus mode it is computed locally from the three focus thresholds.
- `gateSource` is `"sonar"` (default) or `"focus"` (when `--focus`/`--minimal-focus` is used).
- `qualityGateStatus` is always the raw SonarQube gate status string and is never overridden.
- `duplicate` and `coverage` percentages come from SonarQube measures, not a separate local analyzer.
- `problemMap` contains a compact file/folder quality map from SonarQube APIs:
- unresolved issues from `/api/issues/search`
- duplicated files from `/api/measures/component_tree`
- coverage gaps from `/api/measures/component_tree`
- Exit code is `1` when the SonarQube quality gate fails.
## Configuration
Analysis scope, duplication behavior, coverage input, and exclusions live in the repository root `sonar-project.properties`. Keep CI and local runs aligned by changing that file rather than adding separate local quality rules.
## UI customisation
The generated `quality-report.html` is fully self-contained — all CSS and JS are inlined, no build step required.
> **Current source of truth for the generated HTML: `generate-report.py`.**
> The `render_html()` function in `generate-report.py` owns the inline CSS, JS, and HTML structure written to `quality-report.html`. `template.html` is a reference/design document and is **not** used by `quality-check.sh` at runtime. To wire `template.html` into generation, update `render_html()` to read and substitute into it.
Key points:
- **Dark/light mode** — the report ships with a toggle button (top-right, `class="theme-toggle"`). Default follows `prefers-color-scheme`; preference saved to `localStorage` key `dup-report-theme`. CSS variables for both themes live in `render_html()` `:root` (dark default) and `[data-theme="light"]` blocks.
- **CSS variables** — layout colors are tokens (`--bg`, `--surface`, `--surface2`, `--border`, `--text`, `--muted`, `--green`, `--red`, `--blue`, `--yellow`, `--cyan`, `--gate-pass-bg`, `--gate-fail-bg`). Badge `.pill` variants have `[data-theme="light"]` overrides.
- **`template.html` / `DESIGN.md`** — document design tokens and a richer sidebar/main layout that can replace the current inline HTML when `render_html()` is updated to use it.