Quality Check Scripts

Changed in this deploy: generate-report.py, template.html
quality-check.sh
#!/bin/bash

##############################################
# Code Quality Check (Duplicate + Coverage)
# Duplicate Threshold: 3% | Coverage Threshold: 80%
##############################################

set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REPORT_DIR="$PROJECT_ROOT/reports/quality"
JSON_REPORT="$REPORT_DIR/jscpd-report.json"
SUMMARY_JSON="$REPORT_DIR/summary.json"
DUP_THRESHOLD=3
COV_THRESHOLD=80
PACKAGE_PATH=""

GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

# ── Help ──
show_help() {
  echo ""
  echo "Usage: $(basename "$0") [OPTIONS] [PACKAGE_PATH]"
  echo ""
  echo "Run code quality checks (duplicate detection + coverage)."
  echo ""
  echo "Arguments:"
  echo "  PACKAGE_PATH          Package to scan (e.g. packages/features/create_rm)"
  echo "                        If omitted, scans the whole project."
  echo ""
  echo "Options:"
  echo "  -h, --help            Show this help message"
  echo "  -d, --dup-threshold N Max duplication % (default: 3)"
  echo "  -c, --cov-threshold N Min coverage % (default: 80)"
  echo ""
  echo "Examples:"
  echo "  $(basename "$0")                                    # Whole project"
  echo "  $(basename "$0") packages/features/create_rm        # Single package"
  echo "  $(basename "$0") -d 5 -c 70 packages/features/home  # Custom thresholds"
  echo ""
  exit 0
}

# ── Parse args ──
while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help) show_help ;;
    -d|--dup-threshold)
      if [[ $# -lt 2 ]]; then
        echo -e "${RED}✗ Missing value for $1${NC}"
        exit 1
      fi
      DUP_THRESHOLD="$2"
      shift 2
      ;;
    -c|--cov-threshold)
      if [[ $# -lt 2 ]]; then
        echo -e "${RED}✗ Missing value for $1${NC}"
        exit 1
      fi
      COV_THRESHOLD="$2"
      shift 2
      ;;
    *) PACKAGE_PATH="$1"; shift ;;
  esac
done

validate_threshold() {
  local name="$1"
  local value="$2"
  if ! [[ "$value" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]]; then
    echo -e "${RED}✗ ${name} must be a non-negative number: ${value}${NC}"
    exit 1
  fi
}

validate_threshold "Duplicate threshold" "$DUP_THRESHOLD"
validate_threshold "Coverage threshold" "$COV_THRESHOLD"

SCAN_TARGET="."
SCAN_LABEL="Whole Project"
COVERAGE_FILE=""

if [ -n "$PACKAGE_PATH" ]; then
  PACKAGE_PATH="${PACKAGE_PATH%/}"
  if [ ! -d "$PROJECT_ROOT/$PACKAGE_PATH" ]; then
    echo -e "${RED}✗ Package not found: $PACKAGE_PATH${NC}"
    exit 1
  fi
  SCAN_TARGET="$PACKAGE_PATH"
  SCAN_LABEL="$PACKAGE_PATH"

  LCOV_PATH="$PROJECT_ROOT/$PACKAGE_PATH/coverage/lcov.info"
  if [ -f "$LCOV_PATH" ]; then
    COVERAGE_FILE="$LCOV_PATH"
  fi
else
  COMBINED_LCOV="$PROJECT_ROOT/reports/coverage/clean_combined_lcov.info"
  if [ -f "$COMBINED_LCOV" ]; then
    COVERAGE_FILE="$COMBINED_LCOV"
  fi
fi

echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN}  Code Quality Check${NC}"
echo -e "${CYAN}  Scope: ${SCAN_LABEL}${NC}"
echo -e "${CYAN}  Duplicate: ≤${DUP_THRESHOLD}% | Coverage: ≥${COV_THRESHOLD}%${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"

# ── Duplicate Detection ──
echo ""
echo -e "${YELLOW}▸ [1/2] Scanning for duplicate code...${NC}"
echo ""

if ! command -v npx &>/dev/null; then
  echo -e "${RED}✗ npx not found. Please install Node.js first.${NC}"
  exit 1
fi

mkdir -p "$REPORT_DIR"

cd "$PROJECT_ROOT"
rm -f "$JSON_REPORT"
JSCPD_STATUS=0
npx jscpd "$SCAN_TARGET" --config "$SCRIPT_DIR/.jscpd.json" || JSCPD_STATUS=$?

echo ""

if [ ! -f "$JSON_REPORT" ]; then
  echo -e "${RED}✗ jscpd failed and JSON report was not generated at $JSON_REPORT${NC}"
  exit "$JSCPD_STATUS"
fi

if [ "$JSCPD_STATUS" -ne 0 ]; then
  echo -e "${YELLOW}⚠ jscpd exited with status ${JSCPD_STATUS}; continuing with the freshly generated JSON report.${NC}"
fi

read -r DUP_PCT DUP_LINES TOTAL_LINES CLONES <<< "$(python3 - "$JSON_REPORT" <<'PY'
import json
import sys
with open(sys.argv[1]) as f:
    data = json.load(f)
t = data.get('statistics', {}).get('total', {})
print(f'{t.get("percentage", 0):.2f} {t.get("duplicatedLines", 0)} {t.get("lines", 0)} {t.get("clones", 0)}')
PY
)"

DUP_PASS=$(python3 - "$DUP_PCT" "$DUP_THRESHOLD" <<'PY'
import sys
print('1' if float(sys.argv[1]) <= float(sys.argv[2]) else '0')
PY
)

echo -e "${CYAN}  Duplicate Results${NC}"
echo -e "  Lines scanned:    ${TOTAL_LINES}"
echo -e "  Duplicated lines: ${DUP_LINES} (${CLONES} clones)"
echo -e "  Duplication:      ${DUP_PCT}% (max ${DUP_THRESHOLD}%)"
if [ "$DUP_PASS" = "1" ]; then
  echo -e "  Status:           ${GREEN}✅ PASSED${NC}"
else
  echo -e "  Status:           ${RED}❌ FAILED${NC}"
fi

# ── Coverage ──
COV_PCT="N/A"
COV_PASS="skip"
COV_LINES_HIT=0
COV_LINES_TOTAL=0

echo ""
echo -e "${YELLOW}▸ [2/2] Reading coverage data...${NC}"

if [ -n "$COVERAGE_FILE" ]; then
  read -r COV_LINES_HIT COV_LINES_TOTAL COV_PCT <<< "$(python3 - "$COVERAGE_FILE" <<'PY'
import re
import sys
lh, lf = 0, 0
with open(sys.argv[1]) as f:
    for line in f:
        m = re.match(r'^LH:(\d+)', line)
        if m: lh += int(m.group(1))
        m = re.match(r'^LF:(\d+)', line)
        if m: lf += int(m.group(1))
pct = (lh / lf * 100) if lf > 0 else 0
print(f'{lh} {lf} {pct:.2f}')
PY
)"

  COV_PASS=$(python3 - "$COV_PCT" "$COV_THRESHOLD" <<'PY'
import sys
print('1' if float(sys.argv[1]) >= float(sys.argv[2]) else '0')
PY
)

  echo -e "${CYAN}  Coverage Results${NC}"
  echo -e "  Lines covered:    ${COV_LINES_HIT} / ${COV_LINES_TOTAL}"
  echo -e "  Coverage:         ${COV_PCT}% (min ${COV_THRESHOLD}%)"
  if [ "$COV_PASS" = "1" ]; then
    echo -e "  Status:           ${GREEN}✅ PASSED${NC}"
  else
    echo -e "  Status:           ${RED}❌ FAILED${NC}"
  fi
else
  echo -e "  ${YELLOW}⚠ No coverage data found${NC}"
fi

# ── Quality Gate Summary ──
echo ""

GATE_STATUS="passed"
GATE_REASONS=""

if [ "$DUP_PASS" != "1" ]; then
  GATE_STATUS="failed"
  GATE_REASONS="  Duplicate: ${DUP_PCT}% > ${DUP_THRESHOLD}%"
fi

if [ "$COV_PASS" = "0" ]; then
  GATE_STATUS="failed"
  GATE_REASONS="${GATE_REASONS}\n  Coverage:  ${COV_PCT}% < ${COV_THRESHOLD}%"
fi

if [ "$GATE_STATUS" = "passed" ]; then
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  echo -e "${GREEN}  ✅ QUALITY GATE PASSED${NC}"
  echo -e "${GREEN}  Duplicate: ${DUP_PCT}% ≤ ${DUP_THRESHOLD}%${NC}"
  if [ "$COV_PASS" = "1" ]; then
    echo -e "${GREEN}  Coverage:  ${COV_PCT}% ≥ ${COV_THRESHOLD}%${NC}"
  elif [ "$COV_PASS" = "skip" ]; then
    echo -e "${GREEN}  Coverage:  N/A (no data)${NC}"
  fi
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
else
  echo -e "${RED}══════════════════════════════════════════════════${NC}"
  echo -e "${RED}  ❌ QUALITY GATE FAILED${NC}"
  echo -e "${RED}$(echo -e "$GATE_REASONS")${NC}"
  echo -e "${RED}══════════════════════════════════════════════════${NC}"
fi

# ── Generate Report ──
echo ""
echo -e "${YELLOW}▸ Generating detailed HTML report...${NC}"

REPORT_ARGS=(--dup-threshold "$DUP_THRESHOLD")
if [ -n "$COVERAGE_FILE" ]; then
  REPORT_ARGS+=(--coverage "$COVERAGE_FILE" --coverage-threshold "$COV_THRESHOLD")
fi
if [ -n "$PACKAGE_PATH" ]; then
  REPORT_ARGS+=(--package "$PACKAGE_PATH")
fi

python3 "$SCRIPT_DIR/generate-report.py" "${REPORT_ARGS[@]}"

# ── Summary JSON for CI ──
python3 - "$SUMMARY_JSON" "$SCAN_LABEL" "$DUP_PCT" "$DUP_THRESHOLD" "$DUP_PASS" "$COV_PCT" "$COV_THRESHOLD" "$COV_PASS" "$GATE_STATUS" <<'PY'
import json
import sys
summary_path, scan_label, dup_pct, dup_threshold, dup_pass, cov_pct, cov_threshold, cov_pass, gate_status = sys.argv[1:]
summary = {
    'scope': scan_label,
    'duplicate': {
        'percentage': float(dup_pct),
        'threshold': float(dup_threshold),
        'passed': dup_pass == '1'
    },
    'coverage': {
        'percentage': None if cov_pct == 'N/A' else float(cov_pct),
        'threshold': float(cov_threshold),
        'passed': None if cov_pass == 'skip' else (cov_pass == '1')
    },
    'gate': gate_status
}
with open(summary_path, 'w') as f:
    json.dump(summary, f, indent=2)
PY

echo ""
echo -e "${CYAN}▸ HTML Report: ${REPORT_DIR}/quality-report.html${NC}"
echo -e "${CYAN}▸ Summary:     ${SUMMARY_JSON}${NC}"

if [[ "$OSTYPE" =~ ^darwin ]]; then
  open "$REPORT_DIR/quality-report.html" 2>/dev/null || true
fi

echo ""

if [ "$GATE_STATUS" != "passed" ]; then
  exit 1
fi

exit 0
generate-report.py ✦ Updated
#!/usr/bin/env python3

import json
import html
import sys
import os
import re
import argparse
from pathlib import Path
from collections import defaultdict

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
JSON_REPORT = PROJECT_ROOT / "reports" / "quality" / "jscpd-report.json"
OUTPUT_HTML = PROJECT_ROOT / "reports" / "quality" / "quality-report.html"


def js_json(value) -> str:
    """Serialize data for embedding inside a <script> block."""
    return (
        json.dumps(value, ensure_ascii=False)
        .replace("</", "<\\/")
        .replace("\u2028", "\\u2028")
        .replace("\u2029", "\\u2029")
    )


def js_attr_call(function_name: str, *args) -> str:
    """Build a safely escaped inline JS handler attribute value."""
    return html.escape(
        f"{function_name}(" + ", ".join(js_json(arg) for arg in args) + ")",
        quote=True,
    )


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 parse_lcov(lcov_path: str) -> dict:
    lh, lf = 0, 0
    files = {}
    cur_file = None
    cur_lh, cur_lf = 0, 0
    cur_lines = {}
    lcov_file = Path(lcov_path).resolve()
    coverage_root = lcov_file.parent.parent if lcov_file.parent.name == "coverage" else PROJECT_ROOT

    def source_path_for(sf_path: str) -> str:
        path = Path(sf_path)
        if path.is_absolute():
            return str(path)
        project_candidate = PROJECT_ROOT / path
        if project_candidate.exists():
            return str(project_candidate)
        package_candidate = coverage_root / path
        return str(package_candidate)

    with open(lcov_path) as f:
        for line in f:
            line = line.strip()
            m = re.match(r'^SF:(.*)', line)
            if m:
                cur_file = m.group(1)
                cur_lh, cur_lf = 0, 0
                cur_lines = {}
                continue
            m = re.match(r'^DA:(\d+),(\d+)', line)
            if m and cur_file:
                cur_lines[int(m.group(1))] = int(m.group(2))
                continue
            m = re.match(r'^LH:(\d+)', line)
            if m:
                cur_lh = int(m.group(1))
            m = re.match(r'^LF:(\d+)', line)
            if m:
                cur_lf = int(m.group(1))
            if line == 'end_of_record' and cur_file:
                if not cur_lf and cur_lines:
                    cur_lf = len(cur_lines)
                if not cur_lh and cur_lines:
                    cur_lh = sum(1 for hits in cur_lines.values() if hits > 0)
                pct = (cur_lh / cur_lf * 100) if cur_lf > 0 else 0
                files[cur_file] = {
                    "hit": cur_lh,
                    "total": cur_lf,
                    "pct": pct,
                    "lines": cur_lines,
                    "source_path": source_path_for(cur_file),
                }
                lh += cur_lh
                lf += cur_lf
                cur_file = None
    pct = (lh / lf * 100) if lf > 0 else 0
    return {"hit": lh, "total": lf, "pct": pct, "files": files}


def short_path(full_path: str) -> str:
    root = str(PROJECT_ROOT)
    if full_path.startswith(root):
        return full_path[len(root) + 1:]
    return full_path


def resolve_source_path(full_path: str) -> Path:
    path = Path(full_path)
    if path.is_absolute():
        return path
    return PROJECT_ROOT / path


def read_full_source(file_path: str):
    try:
        return resolve_source_path(file_path).read_text(errors="replace").splitlines()
    except Exception:
        return ["(file not readable)"]


CONTEXT_LINES = 5


def read_file_lines(file_path: str, start: int, end: int):
    try:
        with open(file_path, "r") as f:
            lines = f.readlines()
        ctx_start = max(0, start - 1 - CONTEXT_LINES)
        ctx_end = min(len(lines), end + CONTEXT_LINES)
        selected = lines[ctx_start:ctx_end]
        line_nums = []
        code_lines = []
        highlights = []
        for i, line in enumerate(selected, start=ctx_start + 1):
            line_nums.append(str(i))
            code_lines.append(line.rstrip())
            is_dup = start <= i <= end
            highlights.append(is_dup)
        return line_nums, code_lines, highlights
    except Exception:
        return [], ["(file not readable)"], [False]


def build_file_tree(file_data: dict, mode="duplicates", expand_depth=1) -> str:
    tree = {}
    for fpath in sorted(file_data.keys()):
        parts = fpath.split("/")
        node = tree
        for p in parts:
            if p not in node:
                node[p] = {}
            node = node[p]

    def render_node(node, path_so_far="", depth=0):
        items = []
        folders = []
        files = []
        for name in sorted(node.keys()):
            current_path = f"{path_so_far}/{name}" if path_so_far else name
            children = node[name]
            if children:
                folders.append((name, current_path, children))
            else:
                files.append((name, current_path))

        for name, current_path, children in folders:
            expanded = depth < expand_depth
            toggle_cls = ' open' if expanded else ''
            folder_icon = '&#128194;' if expanded else '&#128193;'
            display = 'block' if expanded else 'none'
            items.append(
                f'<div class="tree-folder">'
                f'<div class="folder-row" onclick="toggleFolder(this)">'
                f'<span class="folder-toggle{toggle_cls}">&#9654;</span>'
                f'<span class="folder-icon">{folder_icon}</span>'
                f'<span class="folder-name">{html.escape(name)}</span>'
                f'</div>'
                f'<div class="folder-children" style="display:{display}">'
                f'{render_node(children, current_path, depth+1)}'
                f'</div></div>'
            )

        for name, current_path in files:
            if mode == "duplicates":
                entry = file_data.get(current_path, {})
                clone_ids = entry.get("clones", []) if isinstance(entry, dict) else entry
                dup_pct = entry.get("dup_pct", 0) if isinstance(entry, dict) else 0
                dup_lines = entry.get("dup_lines", 0) if isinstance(entry, dict) else 0
                count = len(clone_ids)
                filter_call = js_attr_call("filterByFile", current_path, clone_ids)
                pct_color = "var(--red)" if dup_pct > 10 else "var(--yellow)" if dup_pct > 3 else "var(--text-dim)"
                items.append(
                    f'<div class="tree-file" '
                    f'onclick="{filter_call}">'
                    f'<span class="file-icon">&#128212;</span>'
                    f'<span class="file-name" title="{html.escape(current_path)}">{html.escape(name)}</span>'
                    f'<span class="file-count">{count}</span>'
                    f'<span title="{dup_lines} dup lines" style="color:{pct_color};font-size:10px;margin-left:3px;">{dup_pct:.1f}%</span>'
                    f'</div>'
                )
            else:
                info = file_data[current_path]
                pct = info["pct"]
                bar_color = "var(--green)" if pct >= 80 else "var(--red)" if pct < 50 else "var(--yellow)"
                open_call = js_attr_call("openCoverageDetail", current_path)
                items.append(
                    f'<div class="tree-file cov-tree-file" onclick="{open_call}">'
                    f'<span class="file-icon">&#128196;</span>'
                    f'<span class="file-name" title="{html.escape(current_path)}">{html.escape(name)}</span>'
                    f'<span class="cov-sidebar-bar"><span style="width:{pct:.0f}%;background:{bar_color};height:100%;border-radius:3px;display:block;"></span></span>'
                    f'<span class="cov-sidebar-pct" style="color:{bar_color}">{pct:.0f}%</span>'
                    f'</div>'
                )
        return "\n".join(items)

    return render_node(tree)


def generate_report():
    parser = argparse.ArgumentParser()
    parser.add_argument("--coverage", help="Path to lcov.info file")
    parser.add_argument("--coverage-threshold", type=percent_threshold, default=80)
    parser.add_argument("--dup-threshold", type=percent_threshold, default=3)
    parser.add_argument("--package", help="Package path")
    args = parser.parse_args()

    cov_data = None
    cov_threshold = args.coverage_threshold
    dup_threshold = args.dup_threshold
    if args.coverage and os.path.exists(args.coverage):
        cov_data = parse_lcov(args.coverage)

    with open(JSON_REPORT) as f:
        data = json.load(f)

    stats = data["statistics"]["total"]
    duplicates = data.get("duplicates", [])
    pct = stats.get("percentage", 0)
    dup_passed = pct <= dup_threshold

    cov_passed = None
    if cov_data:
        cov_passed = cov_data["pct"] >= cov_threshold

    all_passed = dup_passed and (cov_passed is not False)
    status_class = "passed" if all_passed else "failed"
    status_text = "PASSED" if all_passed else "FAILED"
    status_icon = "&#10003;" if all_passed else "&#10007;"
    scope_label = args.package or "Whole Project"

    file_clone_map = defaultdict(list)
    file_relations = defaultdict(lambda: defaultdict(lambda: {"clones": [], "lines": 0}))
    file_dup_lines = defaultdict(int)
    file_full_path: dict[str, str] = {}
    file_list_items = []

    # ── Contextual Copilot Prompt Generation ──
    # Prompts are exposed from the relevant duplicate/coverage rows instead of a
    # separate tab. Keep them serialized as data and render into the modal with
    # textContent to avoid injecting file paths or prompt text as HTML.
    prompts_data = {}
    pid = 0

    def add_prompt(prompt_type: str, fpath: str, prompt_text: str) -> int:
        nonlocal pid
        prompt_id = pid
        prompts_data[prompt_id] = {"type": prompt_type, "file": fpath, "text": prompt_text}
        pid += 1
        return prompt_id

    def copilot_button(prompt_id: int, label: str = "Copilot Prompt", compact: bool = False) -> str:
        button_class = "copilot-btn compact" if compact else "copilot-btn"
        return (
            f'<button type="button" class="{button_class}" '
            f'onclick="event.stopPropagation(); openPromptModal({prompt_id})">'
            f'&#129302; {html.escape(label)}</button>'
        )

    # ── Pass 1: collect per-file stats ──
    for i, d in enumerate(duplicates):
        f1 = d["firstFile"]
        f2 = d["secondFile"]
        lines = d.get("lines", 0)
        p1 = short_path(f1["name"])
        p2 = short_path(f2["name"])
        file_clone_map[p1].append(i)
        file_clone_map[p2].append(i)
        file_full_path[p1] = f1["name"]
        file_full_path[p2] = f2["name"]
        file_dup_lines[p1] += f1["end"] - f1["start"] + 1
        file_dup_lines[p2] += f2["end"] - f2["start"] + 1
        file_relations[p1][p2]["clones"].append(i)
        file_relations[p1][p2]["lines"] += lines
        file_relations[p2][p1]["clones"].append(i)
        file_relations[p2][p1]["lines"] += lines

    file_total_lines: dict[str, int] = {}
    for fpath, full_path in file_full_path.items():
        try:
            file_total_lines[fpath] = len(resolve_source_path(full_path).read_text(errors="replace").splitlines())
        except Exception:
            file_total_lines[fpath] = 0

    file_stats_map: dict[str, dict] = {}
    for fpath, ids in file_clone_map.items():
        dl = file_dup_lines.get(fpath, 0)
        tl = file_total_lines.get(fpath, 0)
        dp = min((dl / tl * 100) if tl > 0 else 0, 100.0)
        file_stats_map[fpath] = {"clones": ids, "dup_lines": dl, "total_lines": tl, "dup_pct": dp}

    def _stat_color(dp: float) -> str:
        return "var(--red)" if dp > 10 else "var(--yellow)" if dp > 3 else "var(--text-dim)"

    # ── Pass 2: build clone card HTML ──
    clone_rows = []
    for i, d in enumerate(duplicates):
        f1 = d["firstFile"]
        f2 = d["secondFile"]
        lines = d.get("lines", 0)

        p1 = short_path(f1["name"])
        p2 = short_path(f2["name"])
        is_self = (p1 == p2)

        s1 = file_stats_map.get(p1, {})
        s2 = file_stats_map.get(p2, {})
        dl1, dp1 = s1.get("dup_lines", 0), s1.get("dup_pct", 0.0)
        dl2, dp2 = s2.get("dup_lines", 0), s2.get("dup_pct", 0.0)

        stat1 = f'<span class="file-dup-stat" style="color:{_stat_color(dp1)}">{dl1}L·{dp1:.1f}%</span>'
        stat2 = f'<span class="file-dup-stat" style="color:{_stat_color(dp2)}">{dl2}L·{dp2:.1f}%</span>'

        if is_self:
            files_row_content = (
                f'<span class="clone-type-badge">within file</span>'
                f'<span class="file-tag">{html.escape(p1.split("/")[-1])}</span>'
                f'{stat1}'
            )
        else:
            files_row_content = (
                f'<span class="file-tag">{html.escape(p1.split("/")[-1])}</span>'
                f'{stat1}'
                f'<span class="vs">vs</span>'
                f'<span class="file-tag">{html.escape(p2.split("/")[-1])}</span>'
                f'{stat2}'
            )

        lnums1, clines1, hl1 = read_file_lines(f1["name"], f1["start"], f1["end"])
        lnums2, clines2, hl2 = read_file_lines(f2["name"], f2["start"], f2["end"])

        def build_gutter(lnums, highlights):
            parts = []
            for ln, is_hl in zip(lnums, highlights):
                cls = ' class="hl"' if is_hl else ''
                parts.append(f'<div{cls}>{ln}</div>')
            return "\n".join(parts)

        def hl_mask(highlights):
            return ",".join("1" if h else "0" for h in highlights)

        gutter1 = build_gutter(lnums1, hl1)
        gutter2 = build_gutter(lnums2, hl2)
        code1 = "\n".join(clines1)
        code2 = "\n".join(clines2)
        mask1 = hl_mask(hl1)
        mask2 = hl_mask(hl2)

        dup_prompt_text = (
            "You are reviewing Dart/Flutter code. The duplicate clone below should be refactored.\n\n"
            + f"Clone: #{i + 1}\n"
            + f"Files:\n"
            + f"  - {p1} (L{f1['start']}-{f1['end']})\n"
            + f"  - {p2} (L{f2['start']}-{f2['end']})\n"
            + f"Duplicated lines: {lines}\n\n"
            + "Please refactor the duplicated code while preserving behavior:\n"
            + "1. Identify the common logic pattern shared between these locations\n"
            + "2. Extract it into a reusable method, helper, mixin, or abstraction that fits the existing architecture\n"
            + "3. Replace duplicate occurrences with the extracted abstraction\n"
            + "4. Keep public APIs and behavior compatible unless a change is explicitly justified\n"
            + "5. Follow existing code style and project conventions\n"
            + "6. Do NOT modify Flutter UI/widgets/screens/pages; limit changes to non-UI refactoring unless explicitly approved"
        )
        dup_prompt_id = add_prompt("duplicate", f"{p1} ↔ {p2}", dup_prompt_text)

        clone_rows.append(f"""
        <div class="clone-card" data-index="{i}" data-lines="{lines}"
             data-file1="{html.escape(p1)}" data-file2="{html.escape(p2)}">
          <div class="clone-header" onclick="toggleClone({i})">
            <div class="clone-title">
              <span class="clone-badge">#{i + 1}</span>
              <span class="clone-lines">{lines} lines</span>
            </div>
            <div class="clone-files-row">
              {files_row_content}
            </div>
            {copilot_button(dup_prompt_id, "Copilot Prompt", True)}
            <span class="toggle-icon" id="icon-{i}">&#9654;</span>
          </div>
          <div class="clone-body" id="body-{i}" style="display:none;">
            <div class="code-compare">
              <div class="code-panel">
                <div class="panel-header">
                  <span class="panel-file" title="{html.escape(p1)}">{html.escape(p1)}</span>
                  <span class="panel-range">L{f1["start"]}-{f1["end"]}</span>
                </div>
                <div class="code-wrapper">
                  <div class="line-numbers">{gutter1}</div>
                  <pre class="code-content"><code class="language-dart" data-hl="{mask1}">{html.escape(code1)}</code></pre>
                </div>
              </div>
              <div class="code-panel">
                <div class="panel-header">
                  <span class="panel-file" title="{html.escape(p2)}">{html.escape(p2)}</span>
                  <span class="panel-range">L{f2["start"]}-{f2["end"]}</span>
                </div>
                <div class="code-wrapper">
                  <div class="line-numbers">{gutter2}</div>
                  <pre class="code-content"><code class="language-dart" data-hl="{mask2}">{html.escape(code2)}</code></pre>
                </div>
              </div>
            </div>
          </div>
        </div>""")


    dup_file_table_rows = ""
    for fpath in sorted(file_stats_map.keys(), key=lambda f: -file_stats_map[f]["dup_pct"]):
        info = file_stats_map[fpath]
        dp = info["dup_pct"]
        dl = info["dup_lines"]
        tl = info["total_lines"]
        clones = len(info["clones"])
        bar_color = "var(--red)" if dp > 10 else "var(--yellow)" if dp > 3 else "var(--green)"
        filter_call = js_attr_call("filterByFile", fpath, info["clones"])
        dup_file_table_rows += (
            f'<tr class="dup-file-row">'
            f'<td><button type="button" class="cov-file-action" onclick="{filter_call}">'
            f'<span class="cov-file">{html.escape(fpath)}</span></button></td>'
            f'<td>{dl}</td>'
            f'<td>{tl}</td>'
            f'<td><div class="cov-bar-wrap"><div class="cov-bar" '
            f'style="width:{min(dp, 100):.0f}%;background:{bar_color}"></div></div></td>'
            f'<td style="color:{bar_color};font-weight:600;">{dp:.1f}%</td>'
            f'<td>{clones}</td>'
            f'</tr>\n'
        )

    for fpath in sorted(file_stats_map.keys()):
        info = file_stats_map[fpath]
        ids = info["clones"]
        fname = fpath.split("/")[-1]
        dl = info["dup_lines"]
        dp = info["dup_pct"]
        pct_color = "var(--red)" if dp > 10 else "var(--yellow)" if dp > 3 else "var(--text-dim)"
        filter_call = js_attr_call("filterByFile", fpath, ids)
        file_list_items.append(
            f'<div class="list-file" onclick="{filter_call}">'
            f'<span class="list-file-name" title="{html.escape(fpath)}">{html.escape(fname)}</span>'
            f'<span class="list-file-path">{html.escape(fpath)}</span>'
            f'<div style="display:flex;align-items:center;gap:4px;margin-top:3px;">'
            f'<span class="list-file-count">{len(ids)} clones</span>'
            f'<span class="list-file-count">{dl} dup lines</span>'
            f'<span style="color:{pct_color};font-size:10px;font-weight:600;">{dp:.1f}%</span>'
            f'</div>'
            f'</div>'
        )

    tree_html = build_file_tree(file_stats_map, mode="duplicates")

    cov_tree_html = ""
    cov_list_items = []
    coverage_prompt_ids = {}
    coverage_details_data = {}
    if cov_data:
        cov_tree_html = build_file_tree(cov_data["files"], mode="coverage")
        for fpath in sorted(cov_data["files"].keys(), key=lambda f: cov_data["files"][f]["pct"]):
            info = cov_data["files"][fpath]
            fname = fpath.split("/")[-1]
            bar_color = "var(--green)" if info["pct"] >= cov_threshold else "var(--red)" if info["pct"] < 50 else "var(--yellow)"
            cov_prompt_text = (
                "You are a Dart/Flutter testing expert. The file below needs coverage-focused tests.\n\n"
                + f"File: {fpath}\n"
                + f"Current coverage: {info['pct']:.1f}% ({info['hit']} covered / {info['total']} executable lines)\n"
                + "Goal: reach 100% line coverage for this file.\n"
                + f"Quality gate threshold remains: {cov_threshold:g}%\n\n"
                + "Please write or improve tests for this file:\n"
                + "1. Use the coverage details to prioritize uncovered executable lines and raise this file to 100% line coverage\n"
                + "2. Identify key functions, methods, branches, and error paths that lack test coverage\n"
                + "3. Add focused unit/widget tests using the existing project test patterns and flutter_test where appropriate\n"
                + "4. Cover happy paths, edge cases, and failure scenarios\n"
                + "5. Use the project's existing mocking approach for external dependencies\n"
                + "6. Place tests in the corresponding test/ directory and keep names consistent with existing tests\n"
                + "7. Do NOT modify Flutter UI/widgets/screens/pages; add tests only unless explicitly approved"
            )
            cov_prompt_id = add_prompt("coverage", fpath, cov_prompt_text)
            coverage_prompt_ids[fpath] = cov_prompt_id
            coverage_details_data[fpath] = {
                "path": fpath,
                "hit": info["hit"],
                "total": info["total"],
                "pct": info["pct"],
                "lines": info.get("lines", {}),
                "source": read_full_source(info.get("source_path", fpath)),
                "promptId": cov_prompt_id,
            }
            open_call = js_attr_call("openCoverageDetail", fpath)
            cov_list_items.append(
                f'<div class="list-file cov-list-item" onclick="{open_call}">'
                f'<div style="display:flex;align-items:center;gap:6px;">'
                f'<span class="list-file-name" title="{html.escape(fpath)}">{html.escape(fname)}</span>'
                f'<span class="cov-sidebar-pct" style="color:{bar_color}">{info["pct"]:.0f}%</span>'
                f'</div>'
                f'<span class="list-file-path">{html.escape(fpath)}</span>'
                f'<div class="cov-sidebar-bar" style="width:100%;margin-top:3px;"><span style="width:{info["pct"]:.0f}%;background:{bar_color};height:100%;border-radius:3px;display:block;"></span></div>'
                f'{copilot_button(cov_prompt_id, "Prompt", True)}'
                f'</div>'
            )

    relations_js = {}
    for fpath, rels in file_relations.items():
        relations_js[fpath] = {}
        for other, info in sorted(rels.items(), key=lambda x: -x[1]["lines"]):
            relations_js[fpath][other] = {
                "clones": info["clones"],
                "lines": info["lines"],
            }
    relations_json = js_json(relations_js)

    prompts_json = js_json(prompts_data)
    coverage_details_json = js_json(coverage_details_data)

    cov_table_rows = ""
    if cov_data:
        for fpath in sorted(cov_data["files"].keys(), key=lambda f: cov_data["files"][f]["pct"]):
            info = cov_data["files"][fpath]
            bar_color = "var(--green)" if info["pct"] >= cov_threshold else "var(--red)" if info["pct"] < 50 else "var(--yellow)"
            cov_prompt_id = coverage_prompt_ids[fpath]
            open_call = js_attr_call("openCoverageDetail", fpath)
            cov_table_rows += f"""<tr class=\"cov-row\">
              <td><button type="button" class="cov-file-action" onclick="{open_call}"><span class="cov-file">{html.escape(fpath)}</span></button></td>
              <td>{info["hit"]}</td>
              <td>{info["total"]}</td>
              <td>
                <div class="cov-bar-wrap">
                  <div class="cov-bar" style="width:{info['pct']:.0f}%;background:{bar_color}"></div>
                </div>
              </td>
              <td style="color:{bar_color};font-weight:600;">{info["pct"]:.1f}%</td>
              <td>{copilot_button(cov_prompt_id, "Copilot Prompt", True)}</td>
            </tr>\n"""

    _tpl = (Path(__file__).parent / "template.html").read_text()
    body_html = f"""

<!-- Sidebar -->
<div class="sidebar" style="display:none">
  <!-- Duplicates sidebar -->
  <div class="sidebar-section active" id="sidebar-duplicates">
    <div class="sidebar-header">
      <h2>&#128269; Duplicate Files</h2>
      <input type="text" class="sidebar-search" id="sidebarSearch"
             placeholder="Filter files..." oninput="filterSidebar()">
    </div>
    <div class="sidebar-tabs">
      <div class="sidebar-tab active" onclick="switchTab('tree')">Tree</div>
      <div class="sidebar-tab" onclick="switchTab('list')">List ({len(file_clone_map)})</div>
    </div>
    <div class="sidebar-content">
      <div class="sidebar-panel active" id="panel-tree">
        <button class="show-all-btn" onclick="showAll()">Show All ({len(duplicates)} clones)</button>
        {tree_html}
      </div>
      <div class="sidebar-panel" id="panel-list">
        <button class="show-all-btn" onclick="showAll()">Show All ({len(duplicates)} clones)</button>
        {"".join(file_list_items)}
      </div>
    </div>
  </div>
  <!-- Coverage sidebar -->
  {"" if not cov_data else f'''<div class="sidebar-section" id="sidebar-coverage">
    <div class="sidebar-header">
      <h2>&#128200; Coverage Files</h2>
      <input type="text" class="sidebar-search" id="covSidebarSearch"
             placeholder="Filter files..." oninput="filterCovSidebar()">
    </div>
    <div class="sidebar-tabs">
      <div class="sidebar-tab active" onclick="switchCovTab(this, &quot;cov-tree&quot;)">Tree</div>
      <div class="sidebar-tab" onclick="switchCovTab(this, &quot;cov-list&quot;)">List ({len(cov_data["files"])})</div>
    </div>
    <div class="sidebar-content">
      <div class="sidebar-panel active" id="panel-cov-tree">
        {cov_tree_html}
      </div>
      <div class="sidebar-panel" id="panel-cov-list">
        {"".join(cov_list_items)}
      </div>
    </div>
  </div>'''}
</div>

<!-- Theme Toggle -->
<button class="theme-toggle" onclick="toggleTheme()">
  <span class="theme-toggle-icon" id="themeIcon">&#9788;</span>
  <span id="themeLabel">Light</span>
</button>

<!-- Main -->
<div class="main">
  <div class="main-header">
    <h1>&#9989; Code Quality Report</h1>
    <div class="subtitle">{html.escape(scope_label)} &middot; Generated {data["statistics"]["detectionDate"][:10]}</div>
  </div>

  <div class="gate {status_class}">
    {"&#9989;" if all_passed else "&#10060;"} Quality Gate {status_text}
  </div>

  <div class="main-tabs">
    <div class="main-tab active" onclick="switchMainTab('overview')">&#127968; Overview</div>
    <div class="main-tab" onclick="switchMainTab('duplicates')">&#128269; Duplicates ({stats.get("clones", 0)})</div>
    {"" if not cov_data else '<div class="main-tab" onclick="switchMainTab(' + "'coverage'" + ')">&#128200; Coverage (' + str(len(cov_data["files"])) + ' files)</div>'}
  </div>

  <!-- Overview Tab -->
  <div class="tab-panel active" id="tab-overview">
    <div class="overview-gates">
      <div class="gate-card">
        <div class="gate-card-title">&#128220; Duplication</div>
        <div class="gate-card-value" style="color: {"var(--green)" if dup_passed else "var(--red)"};">{pct:.2f}%</div>
        <div class="gate-card-detail">Threshold: ≤ {dup_threshold:g}% &middot; {"&#9989; PASSED" if dup_passed else "&#10060; FAILED"}</div>
        <div class="gate-card-bar"><div class="gate-card-fill" style="width:{min(pct/max(dup_threshold*2,1)*100,100):.0f}%;background:{"var(--green)" if dup_passed else "var(--red)"};"></div></div>
        <div class="gate-card-detail">{stats.get("duplicatedLines", 0):,} duplicated lines / {stats.get("lines", 0):,} total &middot; {stats.get("clones", 0)} clones</div>
      </div>
      <div class="gate-card">
        <div class="gate-card-title">&#129505; Coverage</div>
        <div class="gate-card-value" style="color: {"var(--green)" if cov_passed is True else "var(--red)" if cov_passed is False else "var(--text-dim)"};">{f'{cov_data["pct"]:.1f}%' if cov_data else 'N/A'}</div>
        <div class="gate-card-detail">Threshold: ≥ {int(cov_threshold)}% &middot; {"&#9989; PASSED" if cov_passed is True else "&#10060; FAILED" if cov_passed is False else "&#9898; No data"}</div>
        {"" if not cov_data else f'''<div class="gate-card-bar"><div class="gate-card-fill" style="width:{cov_data["pct"]:.0f}%;background:{"var(--green)" if cov_passed else "var(--red)"};"></div></div>
        <div class="gate-card-detail">{cov_data["hit"]:,} covered / {cov_data["total"]:,} total lines &middot; {len([f for f,v in cov_data["files"].items() if v["pct"]==0])} files at 0%</div>'''}
      </div>
    </div>

    <div class="stats">
      <div class="stat-card">
        <div class="stat-value">{stats.get("lines", 0):,}</div>
        <div class="stat-label">&#128196; Lines Scanned</div>
      </div>
      <div class="stat-card">
        <div class="stat-value">{stats.get("clones", 0)}</div>
        <div class="stat-label">&#128203; Duplicate Clones</div>
      </div>
      <div class="stat-card">
        <div class="stat-value">{f'{len(cov_data["files"])}' if cov_data else 'N/A'}</div>
        <div class="stat-label">&#128218; Files Covered</div>
      </div>
      <div class="stat-card">
        <div class="stat-value">{f'{len([f for f,v in cov_data["files"].items() if v["pct"] < cov_threshold])}' if cov_data else 'N/A'}</div>
        <div class="stat-label">&#9888;&#65039; Files &lt; {int(cov_threshold)}% Coverage</div>
      </div>
    </div>
  </div>

  <!-- Duplicates Tab -->
  <div class="tab-panel" id="tab-duplicates">
    <div class="toolbar">
      <input type="text" class="toolbar-search" id="mainSearch"
             placeholder="Search clones..." oninput="filterClones()">
      <button class="btn" onclick="expandAll()">Expand All</button>
      <button class="btn" onclick="collapseAll()">Collapse All</button>
      <button class="btn" id="sortBtn" onclick="sortByLines()">Sort: Lines &#8595;</button>
    </div>

    <div class="filter-tag" id="filterTag">
      Filtered: <strong id="filterName"></strong>
      <span class="filter-clear" onclick="showAll()">&times;</span>
    </div>

    <div class="rel-panel" id="relPanel">
      <div class="rel-header">
        <span class="rel-header-file" id="relFileName"></span>
        <span class="rel-header-stat" id="relStat"></span>
      </div>
      <table class="rel-table">
        <thead>
          <tr>
            <th>Related File</th>
            <th>Clones</th>
            <th>Lines</th>
          </tr>
        </thead>
        <tbody id="relBody"></tbody>
      </table>
    </div>

    <div class="dup-files-section">
      <div class="dup-files-toggle" onclick="toggleDupFiles()">
        <span class="dup-files-arrow" id="dupFilesArrow">&#9654;</span>
        Files ({len(file_stats_map)} files with duplicates)
      </div>
      <div id="dupFilesTableWrap" style="display:none">
        <div class="cov-table-wrap">
          <table class="cov-table" id="dupFileTable">
            <thead>
              <tr>
                <th onclick="sortDupFileTable(0)">File</th>
                <th onclick="sortDupFileTable(1)">Dup Lines</th>
                <th onclick="sortDupFileTable(2)">Total Lines</th>
                <th>Bar</th>
                <th onclick="sortDupFileTable(4)">Dup %</th>
                <th onclick="sortDupFileTable(5)">Clones</th>
              </tr>
            </thead>
            <tbody id="dupFileBody">
              {dup_file_table_rows}
            </tbody>
          </table>
        </div>
      </div>
    </div>

    <div class="result-count" id="resultCount">Showing {len(duplicates)} clones</div>

    <div class="clone-list-wrap" id="cloneList">
      {"".join(clone_rows)}
    </div>
  </div>

  <!-- Coverage Tab -->
  {"" if not cov_data else f'''<div class="tab-panel" id="tab-coverage">
    <div class="coverage-table-view" id="coverageTableView">
      <div class="toolbar">
        <input type="text" class="toolbar-search" id="covSearch"
               placeholder="Search files..." oninput="filterCovTable()">
        <button class="btn" onclick="sortCovTable(0)">Sort: File</button>
        <button class="btn" onclick="sortCovTable(4)">Sort: Coverage</button>
      </div>
      <div class="result-count" id="covCount">Showing {len(cov_data["files"])} files</div>
      <div class="cov-table-wrap">
        <table class="cov-table" id="covTable">
          <thead>
            <tr>
              <th onclick="sortCovTable(0)">File</th>
              <th onclick="sortCovTable(1)">Hit</th>
              <th onclick="sortCovTable(2)">Total</th>
              <th>Bar</th>
              <th onclick="sortCovTable(4)">Coverage</th>
              <th>Prompt</th>
            </tr>
          </thead>
          <tbody id="covBody">
            {cov_table_rows}
          </tbody>
        </table>
      </div>
    </div>
    <div class="coverage-detail-view hidden" id="coverageDetailView" aria-live="polite">
      <div class="cov-detail-header">
        <button type="button" class="btn" onclick="showCoverageTable()">&#8592; Back to coverage table</button>
        <div class="cov-detail-title" id="covDetailFile"></div>
        <button type="button" class="copilot-btn" id="covDetailPromptBtn">&#129302; Copilot Prompt</button>
      </div>
      <div class="cov-detail-stats">
        <span class="cov-stat-pill" id="covDetailPct"></span>
        <span class="cov-stat-pill" id="covDetailHit"></span>
        <span class="cov-stat-pill" id="covDetailMiss"></span>
        <span class="cov-legend">
          <span><span class="legend-dot legend-covered"></span>Covered</span>
          <span><span class="legend-dot legend-uncovered"></span>Uncovered executable</span>
          <span><span class="legend-dot legend-neutral"></span>Non-executable</span>
        </span>
      </div>
      <div class="cov-source-wrap" id="covSourceWrap"></div>
    </div>
  </div>'''}

</div>

<!-- Contextual Copilot Prompt Modal -->
<div class="prompt-modal-backdrop" id="promptModal" aria-hidden="true" onclick="if (event.target === this) closePromptModal()">
  <div class="prompt-modal" role="dialog" aria-modal="true" aria-labelledby="promptModalTitle">
    <div class="prompt-modal-header">
      <div class="prompt-modal-title" id="promptModalTitle">Copilot Prompt</div>
      <div class="prompt-modal-file" id="promptModalFile"></div>
      <button type="button" class="prompt-modal-close" aria-label="Close prompt" onclick="closePromptModal()">&times;</button>
    </div>
    <div class="prompt-modal-body">
      <pre class="prompt-modal-text" id="promptModalText"></pre>
    </div>
    <div class="prompt-modal-actions">
      <button type="button" class="prompt-copy-btn" id="promptCopyBtn" onclick="copyPrompt(currentPromptId, this)">&#128203; Copy for Copilot</button>
    </div>
  </div>
</div>

"""
    js_data = (
        f"const PROMPTS_DATA = {prompts_json};\n"
        f"const COVERAGE_DETAILS = {coverage_details_json};\n"
        f"const TOTAL = {len(duplicates)};\n"
        f"const FILE_RELATIONS = {relations_json};\n"
    )
    report_html = (
        _tpl
        .replace("<!-- INJECT_BODY -->", body_html)
        .replace("// INJECT_DATA", js_data)
    )

    OUTPUT_HTML.parent.mkdir(parents=True, exist_ok=True)
    with open(OUTPUT_HTML, "w") as f:
        f.write(report_html)
    print(f"Report generated: {OUTPUT_HTML}")


if __name__ == "__main__":
    generate_report()
template.html ✦ Updated
<!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);
    --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;
  }
  .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: 15px; flex-shrink: 0; }
  .folder-name { font-size: 13px; 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: 12px;
    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: 14px; 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: 12px;
    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: 13px;
    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;
  }

  .dup-files-section { margin-bottom: 12px; }
  .dup-files-toggle {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 5px 10px; border-radius: 6px; cursor: pointer;
    font-size: 12px; color: var(--text-dim); user-select: none;
  }
  .dup-files-toggle:hover { background: var(--surface2); color: var(--text); }
  .dup-files-arrow { font-size: 10px; transition: transform 0.15s; display: inline-block; }
  .dup-files-arrow.open { transform: rotate(90deg); }

  /* ── 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;
  }
  .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;
  }
  .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;
  }
  .file-dup-stat {
    font-size: 10px;
    font-family: var(--font-mono);
    opacity: 0.9;
  }
  .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-icon { font-size: 16px; }

  /* ── 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>
<!-- 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 ? '&#9790;' : '&#9788;';
  document.getElementById('themeLabel').textContent = isLight ? 'Dark' : 'Light';
  setHljsTheme(next);
  localStorage.setItem('dup-report-theme', next);
}
(function() {
  const saved = localStorage.getItem('dup-report-theme');
  if (saved === 'dark') {
    document.documentElement.setAttribute('data-theme', 'dark');
    document.getElementById('themeIcon').innerHTML = '&#9790;';
    document.getElementById('themeLabel').textContent = 'Dark';
    setHljsTheme('dark');
  } else {
    setHljsTheme('light');
  }
})();

// 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();
  showCoverageTable({ scroll: false });
  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 = '&#128194;');
  }
}

// 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 = '&#128203; 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 = '&#10003; 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';
}

// Coverage table sort
let dupFileSortCol = -1, dupFileSortAsc = true;
function toggleDupFiles() {
  const wrap = document.getElementById('dupFilesTableWrap');
  const arrow = document.getElementById('dupFilesArrow');
  if (!wrap) return;
  const open = wrap.style.display !== 'none';
  wrap.style.display = open ? 'none' : 'block';
  arrow.classList.toggle('open', !open);
}
function sortDupFileTable(col) {
  const tbody = document.getElementById('dupFileBody');
  if (!tbody) return;
  const rows = Array.from(tbody.querySelectorAll('tr'));
  if (dupFileSortCol === col) { dupFileSortAsc = !dupFileSortAsc; } else { dupFileSortCol = col; dupFileSortAsc = false; }
  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 dupFileSortAsc ? na - nb : nb - na;
    return dupFileSortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
  });
  rows.forEach(r => tbody.appendChild(r));
}

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 ? '&#128193;' : '&#128194;';
}

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 = '&#128194;');
  }
}

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 ? '&#8593;' : '&#8595;');
}
</script>
</body>
</script>
</body>
</html>
.jscpd.json
{
  "threshold": 3,
  "reporters": ["html", "json", "console"],
  "output": "reports/quality",
  "format": ["dart"],
  "ignore": [
    "**/*.freezed.dart",
    "**/*.g.dart",
    "**/*.gr.dart",
    "**/*.config.dart",
    "**/generated/**",
    "**/*.theme_extension.dart",
    "**/test/**",
    "**/test/**/*.mocks.dart",

    "packages/core/**",
    "packages/design_system/**",
    "packages/features/feature_template/**",
    "packages/plugins/face_recognition/**",

    "**/lib/widget/**",
    "**/lib/widgets/**",
    "**/lib/ui/widget/**",
    "**/lib/ui/widgets/**",
    "**/lib/ui/**/widget/**",
    "**/lib/ui/**/widgets/**",
    "**/lib/src/ui/**",
    "**/lib/src/widget/**",
    "**/lib/src/widgets/**",
    "**/lib/src/ui/widget/**",
    "**/lib/src/ui/widgets/**",
    "**/lib/src/ui/**/widget/**",
    "**/lib/src/ui/**/widgets/**",
    "**/lib/**/ui/**",

    "web/**",
    "**/.dart_tool/**",
    "**/build/**",
    "**/.gradle/**",
    "**/.symlinks/**",
    "**/Pods/**",
    "**/Flutter/ephemeral/**",
    "**/ios/Pods/**",
    "**/android/.gradle/**",
    "**/.pub-cache/**",
    "**/example/**",
    "scripts/**",
    "**/app/**"
  ],
  "absolute": true,
  "gitignore": true
}
README.md
# Quality Check

SonarQube-style code quality dashboard for the Flutter monorepo. Runs two checks — duplicate detection and test coverage — then produces an interactive HTML report and a `summary.json` for CI.

## Files

```
scripts/quality/
├── quality-check.sh      # Entry point — runs jscpd, reads lcov, calls generate-report.py
├── generate-report.py    # Builds the HTML report from jscpd JSON + lcov data
├── template.html         # Static HTML/CSS/JS template; generate-report.py injects data
└── .jscpd.json           # jscpd config — Dart only, excludes generated/widget/DS files
```

Output goes to `reports/quality/`:

| File | Description |
|------|-------------|
| `quality-report.html` | Interactive HTML report (auto-opens on macOS) |
| `summary.json` | Machine-readable quality gate result for CI |
| `jscpd-report.json` | Raw jscpd output |

## Prerequisites

- **Node.js / npx** — for jscpd duplicate detection
- **Python 3** — stdlib only, no extra packages needed
- **very_good CLI** — to generate `coverage/lcov.info` before running coverage check

## Usage

```bash
# Whole project
bash scripts/quality/quality-check.sh

# Single package
bash scripts/quality/quality-check.sh packages/features/create_rm

# Custom thresholds
bash scripts/quality/quality-check.sh -d 5 -c 70 packages/features/home
```

**Options:**

| Flag | Default | Description |
|------|---------|-------------|
| `-d, --dup-threshold N` | `3` | Max allowed duplication % |
| `-c, --cov-threshold N` | `80` | Min required coverage % |
| `-h, --help` | — | Show help |

## Coverage setup

Coverage must be generated before running the check. The script reads `lcov.info` — it does not run tests.

**Single package:**

```bash
cd packages/features/create_rm
very_good test --coverage
cd -
bash scripts/quality/quality-check.sh packages/features/create_rm
```

**Whole project** — combine lcov files first:

```bash
bash scripts/combine-coverage.sh   # → reports/coverage/clean_combined_lcov.info
bash scripts/quality/quality-check.sh
```

## HTML Report tabs

| Tab | Description |
|-----|-------------|
| Overview | Quality gate summary, pass/fail status, key stats |
| Duplicates | Clone list with side-by-side code view and file tree |
| Coverage | File coverage table with sortable columns and file tree |
| AI Prompts | Copilot-ready prompts — refactor prompts for duplicate files, test prompts for low-coverage files |

## Quality gate

`summary.json` schema:

```json
{
  "scope": "packages/features/create_rm",
  "duplicate": { "percentage": 4.73, "threshold": 3, "passed": false },
  "coverage":  { "percentage": 75.42, "threshold": 80, "passed": false },
  "gate": "failed"
}
```

- `gate` is `"passed"` only when **both** checks pass.
- `coverage.passed` is `null` when no `lcov.info` was found (coverage check skipped).
- Exit code is `1` when gate fails — use directly in CI.

**CI example:**

```yaml
- name: Quality gate
  run: bash scripts/quality/quality-check.sh packages/features/my_feature
```

## jscpd exclusions

`.jscpd.json` excludes the following (aligned with `sonar-project.properties`):

- **Generated files:** `*.freezed.dart`, `*.g.dart`, `*.gr.dart`, `*.config.dart`, `**/generated/**`, `*.theme_extension.dart`
- **Widget/UI dirs:** all `lib/**/widget/**`, `lib/**/widgets/**`, `lib/**/ui/**` variants
- **Excluded packages:** `packages/core/**`, `packages/design_system/**`, `packages/features/feature_template/**`, `packages/plugins/face_recognition/**`
- **Build artifacts:** `build/`, `.dart_tool/`, `.gradle/`, `.symlinks/`, `Pods/`, `.pub-cache/`
- **Tests:** `**/test/**`