Quality Check Scripts
Changed in this deploy: template.html
#!/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
#!/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 = '📂' if expanded else '📁'
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}">▶</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">📔</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">📄</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 = "✓" if all_passed else "✗"
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'🤖 {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}
# ── 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)
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>'
)
else:
files_row_content = (
f'<span class="file-tag">{html.escape(p1.split("/")[-1])}</span>'
f'<span class="vs">vs</span>'
f'<span class="file-tag">{html.escape(p2.split("/")[-1])}</span>'
)
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}">▶</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>""")
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>🔍 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>📈 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, "cov-tree")">Tree</div>
<div class="sidebar-tab" onclick="switchCovTab(this, "cov-list")">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">☼</span>
<span id="themeLabel">Light</span>
</button>
<!-- Main -->
<div class="main">
<div class="main-header">
<h1>✅ Code Quality Report</h1>
<div class="subtitle">{html.escape(scope_label)} · Generated {data["statistics"]["detectionDate"][:10]}</div>
</div>
<div class="gate {status_class}">
{"✅" if all_passed else "❌"} Quality Gate {status_text}
</div>
<div class="main-tabs">
<div class="main-tab active" onclick="switchMainTab('overview')">🏠 Overview</div>
<div class="main-tab" onclick="switchMainTab('duplicates')">🔍 Duplicates ({stats.get("clones", 0)})</div>
{"" if not cov_data else '<div class="main-tab" onclick="switchMainTab(' + "'coverage'" + ')">📈 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">📜 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}% · {"✅ PASSED" if dup_passed else "❌ 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 · {stats.get("clones", 0)} clones</div>
</div>
<div class="gate-card">
<div class="gate-card-title">🧡 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)}% · {"✅ PASSED" if cov_passed is True else "❌ FAILED" if cov_passed is False else "⚪ 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 · {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">📄 Lines Scanned</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.get("clones", 0)}</div>
<div class="stat-label">📋 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">📚 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">⚠️ Files < {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 ↓</button>
</div>
<div class="filter-tag" id="filterTag">
Filtered: <strong id="filterName"></strong>
<span class="filter-clear" onclick="showAll()">×</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="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()">← Back to coverage table</button>
<div class="cov-detail-title" id="covDetailFile"></div>
<button type="button" class="copilot-btn" id="covDetailPromptBtn">🤖 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()">×</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)">📋 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()
<!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: 12px; flex-shrink: 0; }
.folder-name { font-size: 11px; font-weight: 500; color: var(--text); }
.folder-children {
margin-left: 12px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.tree-file {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
transition: background 0.1s;
}
.tree-file:hover { background: var(--surface2); }
.tree-file.active { background: var(--blue); color: var(--bg); }
.tree-file.active .file-name { color: var(--bg); }
.tree-file.active .file-count { background: var(--bg); color: var(--blue); }
.file-icon { font-size: 12px; flex-shrink: 0; }
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--text);
}
.file-count {
background: var(--surface2);
color: var(--text-dim);
padding: 1px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
}
/* List view */
.list-file {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 2px;
}
.list-file:hover { background: var(--surface2); }
.list-file.active { background: var(--active-bg); }
.list-file-name {
display: block;
font-size: 11px;
font-weight: 500;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
}
.list-file-path {
display: block;
font-size: 10px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.list-file-count {
display: inline-block;
background: var(--surface2);
color: var(--text-dim);
padding: 0 6px;
border-radius: 10px;
font-size: 10px;
margin-top: 2px;
}
/* ── Main ── */
.main {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.main::-webkit-scrollbar { width: 8px; }
.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.main-header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.main-header h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
.main-header .subtitle { color: var(--text-dim); font-size: 13px; }
.gate {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 14px 24px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 16px;
font-weight: 600;
}
.gate.passed { background: var(--gate-pass-bg); border: 1px solid var(--green); color: var(--green); }
.gate.failed { background: var(--gate-fail-bg); border: 1px solid var(--red); color: var(--red); }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.stat-value { font-size: 24px; font-weight: 700; color: var(--blue); }
.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
padding: 8px 0;
}
.toolbar-search {
flex: 1;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
}
.toolbar-search:focus { border-color: var(--blue); }
.toolbar-search::placeholder { color: var(--text-dim); }
.btn {
padding: 8px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.btn:hover { background: var(--border); }
.filter-tag {
display: none;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--filter-bg);
border: 1px solid var(--blue);
border-radius: 6px;
margin-bottom: 12px;
font-size: 12px;
color: var(--blue);
}
.filter-tag.active { display: inline-flex; }
.filter-clear {
cursor: pointer;
font-size: 14px;
font-weight: bold;
margin-left: 4px;
}
.filter-clear:hover { color: var(--red); }
/* Relationship panel */
.rel-panel {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.rel-panel.active { display: block; }
.rel-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.rel-header-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: var(--blue);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rel-header-stat {
font-size: 12px;
color: var(--text-dim);
flex-shrink: 0;
}
.rel-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.rel-table th {
text-align: left;
padding: 8px 12px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rel-table td {
padding: 8px 12px;
border-top: 1px solid var(--border);
}
.rel-table tr:hover { background: var(--surface2); cursor: pointer; }
.rel-file-link {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
font-size: 12px;
}
.rel-count { color: var(--yellow); font-weight: 600; }
.rel-lines { color: var(--text-dim); }
.result-count { color: var(--text-dim); font-size: 12px; margin-bottom: 12px; }
/* Clone cards */
.clone-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: auto 60px;
}
.clone-card.highlight { border-color: var(--blue); }
.clone-header {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
gap: 10px;
transition: background 0.15s;
}
.clone-header:hover { background: var(--surface2); }
.clone-title { display: flex; align-items: center; gap: 6px; min-width: 120px; }
.clone-badge {
background: var(--yellow);
color: var(--badge-text);
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.clone-lines { color: var(--text-dim); font-size: 11px; }
.clone-type-badge {
background: rgba(210,153,34,0.15);
color: var(--yellow);
border: 1px solid rgba(210,153,34,0.3);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.clone-files-row { flex: 1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.file-tag {
background: var(--surface2);
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
}
.vs { color: var(--text-dim); font-size: 10px; }
.toggle-icon { color: var(--text-dim); font-size: 11px; transition: transform 0.2s; }
.toggle-icon.open { transform: rotate(90deg); }
.clone-list-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 70vh;
padding: 8px;
background: var(--bg);
}
.clone-list-wrap .clone-card:last-child { margin-bottom: 0; }
.clone-body { border-top: 1px solid var(--border); }
.code-compare {
display: grid;
grid-template-columns: 1fr 1fr;
}
.code-panel { overflow: auto; }
.code-panel + .code-panel { border-left: 1px solid var(--border); }
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.panel-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--blue);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.panel-range { color: var(--text-dim); flex-shrink: 0; margin-left: 8px; }
.code-wrapper {
display: flex;
overflow-x: auto;
background: var(--code-bg);
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
}
.code-wrapper .line-numbers {
flex-shrink: 0;
padding: 8px 0;
text-align: right;
user-select: none;
border-right: 1px solid var(--border);
}
.code-wrapper .line-numbers > div {
padding: 0 10px 0 12px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.7;
height: 1.7em;
}
.code-wrapper .line-numbers > div.hl {
background: rgba(248, 81, 73, 0.18);
color: var(--red);
font-weight: 600;
}
.code-wrapper .code-content {
flex: 1;
margin: 0;
padding: 8px 0;
overflow-x: auto;
background: transparent;
}
.code-wrapper .code-content code {
display: block;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.code-wrapper .code-content .code-line {
display: block;
padding: 0 12px;
height: 1.7em;
line-height: 1.7;
}
.code-wrapper .code-content .code-line.hl {
background: rgba(248, 81, 73, 0.18);
border-left: 3px solid var(--red);
padding-left: 9px;
}
[data-theme="light"] .code-wrapper .code-content .code-line.hl {
background: rgba(255, 220, 220, 0.7);
}
[data-theme="light"] .code-wrapper .line-numbers > div.hl {
background: rgba(255, 220, 220, 0.7);
}
.code-panel code.hljs {
background: transparent;
padding: 0;
line-height: 1.7;
}
/* Main tabs */
.main-tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
gap: 4px;
}
.main-tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
border-radius: 6px 6px 0 0;
}
.main-tab:hover { color: var(--text); background: var(--surface); }
.main-tab.active { color: var(--blue); border-bottom-color: var(--blue); background: var(--surface); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Overview gauge */
.overview-gates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.gate-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.gate-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
margin-bottom: 8px;
}
.gate-card-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.gate-card-bar {
height: 6px;
border-radius: 3px;
background: var(--surface2);
margin-top: 8px;
overflow: hidden;
}
.gate-card-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.gate-card-detail {
font-size: 11px;
color: var(--text-dim);
margin-top: 6px;
}
/* Coverage table */
.cov-table-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 70vh;
margin-top: 12px;
}
.cov-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.cov-table th {
text-align: left;
padding: 10px 12px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 2px solid var(--border);
}
.cov-table th:hover { color: var(--text); }
.cov-table td {
padding: 7px 12px;
border-top: 1px solid var(--border);
}
.cov-table tr:hover { background: var(--surface); }
.cov-row { cursor: default; }
.cov-file-action {
display: inline-flex;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.cov-file-action:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 3px; }
.cov-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--cyan);
}
.cov-bar-wrap {
width: 100%;
height: 8px;
background: var(--surface2);
border-radius: 4px;
overflow: hidden;
min-width: 80px;
}
.cov-bar {
height: 100%;
border-radius: 4px;
}
/* Coverage sidebar */
.cov-sidebar-file {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
font-size: 12px;
margin-bottom: 2px;
}
.cov-sidebar-file:hover { background: var(--surface2); }
.cov-sidebar-name {
flex: 1;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cov-sidebar-bar {
width: 50px;
height: 6px;
background: var(--surface2);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.cov-sidebar-pct {
font-size: 11px;
font-weight: 600;
min-width: 32px;
text-align: right;
flex-shrink: 0;
}
/* Coverage drill-down */
.coverage-table-view.hidden, .coverage-detail-view.hidden { display: none; }
.coverage-detail-view {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.cov-detail-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.cov-detail-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--cyan);
font-size: 13px;
font-weight: 600;
}
.cov-detail-stats { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cov-stat-pill { padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: 12px; background: var(--bg); }
.cov-legend { display: flex; gap: 10px; align-items: center; margin-left: auto; color: var(--text-dim); font-size: 11px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.legend-covered { background: rgba(63,185,80,0.35); border: 1px solid var(--green); }
.legend-uncovered { background: rgba(248,81,73,0.35); border: 1px solid var(--red); }
.legend-neutral { background: var(--surface2); border: 1px solid var(--border); }
.cov-source-wrap { max-height: 72vh; overflow: auto; background: var(--code-bg); }
.cov-source-line { display: grid; grid-template-columns: 72px 1fr; min-height: 1.65em; font-family: var(--font-mono); font-size: 12px; line-height: 1.65; }
.cov-source-line .ln { padding: 0 10px; text-align: right; color: var(--text-dim); user-select: none; border-right: 1px solid var(--border); }
.cov-source-line .src { padding: 0 12px; white-space: pre; overflow: visible; }
.cov-source-line.covered { background: rgba(63,185,80,0.10); }
.cov-source-line.covered .ln { color: var(--green); }
.cov-source-line.uncovered { background: rgba(248,81,73,0.16); }
.cov-source-line.uncovered .ln { color: var(--red); font-weight: 600; }
.cov-source-line.neutral { background: transparent; }
/* Sidebar sections */
.sidebar-section { display: none; }
.sidebar-section.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
/* Theme toggle */
.theme-toggle {
position: fixed;
top: 16px;
right: 24px;
z-index: 999;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
color: var(--text);
transition: all 0.2s;
}
.theme-toggle:hover { background: var(--surface2); }
.theme-toggle-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 ? '☾' : '☼';
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 = '☾';
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();
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
const sidebar = document.querySelector('.sidebar');
const hasSidebar = (tab === 'duplicates' || tab === 'coverage');
if (sidebar) {
sidebar.style.display = hasSidebar ? 'flex' : 'none';
document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
const section = document.getElementById('sidebar-' + tab);
if (section) section.classList.add('active');
}
}
// Coverage sidebar tab switch
function switchCovTab(el, panel) {
const section = document.getElementById('sidebar-coverage');
section.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
section.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById('panel-' + panel).classList.add('active');
}
// Coverage sidebar filter
function filterCovSidebar() {
const q = document.getElementById('covSidebarSearch').value.toLowerCase();
const section = document.getElementById('sidebar-coverage');
section.querySelectorAll('.tree-file').forEach(el => {
const title = (el.querySelector('.file-name').title || '').toLowerCase();
el.style.display = (!q || title.includes(q)) ? 'flex' : 'none';
});
section.querySelectorAll('.cov-list-item').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
});
if (q) {
section.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
section.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
section.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '📂');
}
}
// Contextual Copilot prompts
let currentPromptId = null;
function openPromptModal(id) {
const prompt = PROMPTS_DATA[id];
if (!prompt) return;
currentPromptId = id;
const modal = document.getElementById('promptModal');
const title = document.getElementById('promptModalTitle');
const file = document.getElementById('promptModalFile');
const text = document.getElementById('promptModalText');
const copyBtn = document.getElementById('promptCopyBtn');
title.textContent = prompt.type === 'coverage' ? 'Coverage Copilot Prompt' : 'Duplicate Refactor Copilot Prompt';
file.textContent = prompt.file || '';
file.title = prompt.file || '';
text.textContent = prompt.text || '';
copyBtn.innerHTML = '📋 Copy for Copilot';
copyBtn.classList.remove('copied');
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePromptModal() {
const modal = document.getElementById('promptModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
currentPromptId = null;
}
document.addEventListener('click', function(e) {
const modal = document.getElementById('promptModal');
if (!modal || !modal.classList.contains('active')) return;
const tabAtPoint = Array.from(document.querySelectorAll('.main-tab')).find(tab => {
const rect = tab.getBoundingClientRect();
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
});
if (tabAtPoint) {
e.preventDefault();
e.stopPropagation();
closePromptModal();
tabAtPoint.click();
return;
}
if (e.target.closest('.prompt-modal')) return;
closePromptModal();
}, true);
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePromptModal();
});
function copyPrompt(id, btn) {
const text = (PROMPTS_DATA[id] || {}).text || '';
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => flashCopied(btn));
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
flashCopied(btn);
}
}
function flashCopied(btn) {
const orig = btn.innerHTML;
btn.innerHTML = '✓ Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('copied'); }, 1800);
}
function showCoverageTable(options) {
const opts = options || {};
const tableView = document.getElementById('coverageTableView');
const detailView = document.getElementById('coverageDetailView');
if (tableView) tableView.classList.remove('hidden');
if (detailView) detailView.classList.add('hidden');
document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
if (opts.scroll !== false && tableView) tableView.scrollIntoView({ block: 'start' });
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function openCoverageDetail(filePath) {
const detail = COVERAGE_DETAILS[filePath];
if (!detail) return;
const tableView = document.getElementById('coverageTableView');
const detailView = document.getElementById('coverageDetailView');
const sourceWrap = document.getElementById('covSourceWrap');
if (!tableView || !detailView || !sourceWrap) return;
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const coverageTab = document.getElementById('tab-coverage');
if (coverageTab) coverageTab.classList.add('active');
const tabs = Array.from(document.querySelectorAll('.main-tab'));
const covTabButton = tabs.find(t => t.textContent.includes('Coverage'));
if (covTabButton) covTabButton.classList.add('active');
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.style.display = 'flex';
document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
const section = document.getElementById('sidebar-coverage');
if (section) section.classList.add('active');
}
tableView.classList.add('hidden');
detailView.classList.remove('hidden');
setText('covDetailFile', detail.path || filePath);
const title = document.getElementById('covDetailFile');
if (title) title.title = detail.path || filePath;
const missed = Math.max((detail.total || 0) - (detail.hit || 0), 0);
setText('covDetailPct', 'Coverage: ' + Number(detail.pct || 0).toFixed(1) + '%');
setText('covDetailHit', (detail.hit || 0) + ' covered / ' + (detail.total || 0) + ' executable lines');
setText('covDetailMiss', missed + ' uncovered executable line' + (missed === 1 ? '' : 's'));
const promptBtn = document.getElementById('covDetailPromptBtn');
if (promptBtn) {
promptBtn.onclick = function(e) { e.stopPropagation(); openPromptModal(detail.promptId); };
}
sourceWrap.textContent = '';
const lineHits = detail.lines || {};
(detail.source || []).forEach((line, idx) => {
const lineNumber = idx + 1;
const key = String(lineNumber);
const executable = Object.prototype.hasOwnProperty.call(lineHits, key);
const hits = executable ? Number(lineHits[key]) : 0;
const row = document.createElement('div');
row.className = 'cov-source-line ' + (executable ? (hits > 0 ? 'covered' : 'uncovered') : 'neutral');
if (executable) row.title = hits + ' hit' + (hits === 1 ? '' : 's');
const gutter = document.createElement('span');
gutter.className = 'ln';
gutter.textContent = executable ? (lineNumber + ' ' + (hits > 0 ? '✓' : '✕')) : String(lineNumber);
const src = document.createElement('span');
src.className = 'src';
src.textContent = line || ' ';
row.appendChild(gutter);
row.appendChild(src);
sourceWrap.appendChild(row);
});
document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.cov-row').forEach(row => {
const fileCell = row.querySelector('.cov-file');
if (fileCell && fileCell.textContent === filePath) row.classList.add('active');
});
document.querySelectorAll('.cov-tree-file, .cov-list-item').forEach(el => {
const label = el.querySelector('.file-name, .list-file-name');
if (label && label.title === filePath) el.classList.add('active');
});
const main = document.querySelector('.main');
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
}
// Coverage table filter
function filterCovTable() {
const q = document.getElementById('covSearch').value.toLowerCase();
const rows = document.querySelectorAll('#covBody tr');
let visible = 0;
rows.forEach(row => {
const text = row.textContent.toLowerCase();
const show = !q || text.includes(q);
row.style.display = show ? '' : 'none';
if (show) visible++;
});
const el = document.getElementById('covCount');
if (el) el.textContent = 'Showing ' + visible + ' files';
}
let covSortCol = -1, covSortAsc = true;
function sortCovTable(col) {
const tbody = document.getElementById('covBody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (covSortCol === col) { covSortAsc = !covSortAsc; } else { covSortCol = col; covSortAsc = true; }
rows.sort((a, b) => {
let va = a.cells[col].textContent.trim();
let vb = b.cells[col].textContent.trim();
const na = parseFloat(va.replace('%',''));
const nb = parseFloat(vb.replace('%',''));
if (!isNaN(na) && !isNaN(nb)) return covSortAsc ? na - nb : nb - na;
return covSortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
rows.forEach(r => tbody.appendChild(r));
}
function toggleClone(i) {
const body = document.getElementById('body-' + i);
const icon = document.getElementById('icon-' + i);
if (body.style.display === 'none') {
body.style.display = 'block';
icon.classList.add('open');
} else {
body.style.display = 'none';
icon.classList.remove('open');
}
}
function expandAll() {
document.querySelectorAll('.clone-card').forEach(c => {
if (c.style.display !== 'none') {
c.querySelector('.clone-body').style.display = 'block';
c.querySelector('.toggle-icon').classList.add('open');
}
});
}
function collapseAll() {
document.querySelectorAll('.clone-body').forEach(b => b.style.display = 'none');
document.querySelectorAll('.toggle-icon').forEach(i => i.classList.remove('open'));
}
function filterClones() {
const q = document.getElementById('mainSearch').value.toLowerCase();
let visible = 0;
document.querySelectorAll('.clone-card').forEach(card => {
if (card.dataset.hidden === 'true') return;
const text = card.textContent.toLowerCase();
const show = !q || text.includes(q);
card.style.display = show ? 'block' : 'none';
if (show) visible++;
});
document.getElementById('resultCount').textContent = 'Showing ' + visible + ' clones';
}
function renderRelPanel(filePath) {
const rels = FILE_RELATIONS[filePath];
const panel = document.getElementById('relPanel');
if (!rels || Object.keys(rels).length === 0) {
panel.classList.remove('active');
return;
}
const fname = filePath.split('/').pop();
const relFiles = Object.keys(rels);
const totalLines = relFiles.reduce((s, k) => s + rels[k].lines, 0);
document.getElementById('relFileName').textContent = fname;
document.getElementById('relFileName').title = filePath;
document.getElementById('relStat').textContent = relFiles.length + ' related file' + (relFiles.length > 1 ? 's' : '') + ' · ' + totalLines + ' duplicate lines';
const tbody = document.getElementById('relBody');
tbody.innerHTML = '';
relFiles.forEach(other => {
const info = rels[other];
const otherName = other.split('/').pop();
const tr = document.createElement('tr');
tr.onclick = function() {
// Filter to show only clones between these two files
document.querySelectorAll('.clone-card').forEach(card => {
const idx = parseInt(card.dataset.index);
if (info.clones.includes(idx)) {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.add('highlight');
} else {
card.style.display = 'none';
card.dataset.hidden = 'true';
card.classList.remove('highlight');
}
});
document.getElementById('resultCount').textContent = 'Showing ' + info.clones.length + ' of ' + TOTAL + ' clones (' + fname + ' vs ' + otherName + ')';
};
const fileTd = document.createElement('td');
const fileSpan = document.createElement('span');
fileSpan.className = 'rel-file-link';
fileSpan.title = other;
fileSpan.textContent = otherName;
fileTd.appendChild(fileSpan);
const countTd = document.createElement('td');
const countSpan = document.createElement('span');
countSpan.className = 'rel-count';
countSpan.textContent = info.clones.length;
countTd.appendChild(countSpan);
const linesTd = document.createElement('td');
const linesSpan = document.createElement('span');
linesSpan.className = 'rel-lines';
linesSpan.textContent = info.lines;
linesTd.appendChild(linesSpan);
tr.appendChild(fileTd);
tr.appendChild(countTd);
tr.appendChild(linesTd);
tbody.appendChild(tr);
});
panel.classList.add('active');
}
function filterByFile(filePath, cloneIds) {
// Clear highlights
document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
// Highlight clicked item
event.currentTarget.classList.add('active');
document.querySelectorAll('.clone-card').forEach(card => {
const idx = parseInt(card.dataset.index);
if (cloneIds.includes(idx)) {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.add('highlight');
} else {
card.style.display = 'none';
card.dataset.hidden = 'true';
card.classList.remove('highlight');
}
});
renderRelPanel(filePath);
const fname = filePath.split('/').pop();
document.getElementById('filterName').textContent = fname;
document.getElementById('filterName').title = filePath;
document.getElementById('filterTag').classList.add('active');
document.getElementById('resultCount').textContent = 'Showing ' + cloneIds.length + ' of ' + TOTAL + ' clones';
document.getElementById('mainSearch').value = '';
}
function showAll() {
document.querySelectorAll('.clone-card').forEach(card => {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.remove('highlight');
});
document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
document.getElementById('filterTag').classList.remove('active');
document.getElementById('relPanel').classList.remove('active');
document.getElementById('resultCount').textContent = 'Showing ' + TOTAL + ' clones';
document.getElementById('mainSearch').value = '';
}
function switchTab(tab) {
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('panel-' + tab).classList.add('active');
}
function toggleFolder(row) {
const toggle = row.querySelector('.folder-toggle');
const icon = row.querySelector('.folder-icon');
toggle.classList.toggle('open');
const children = row.parentElement.querySelector('.folder-children');
const isOpen = children.style.display !== 'none';
children.style.display = isOpen ? 'none' : 'block';
icon.innerHTML = isOpen ? '📁' : '📂';
}
function filterSidebar() {
const q = document.getElementById('sidebarSearch').value.toLowerCase();
// Filter tree files
document.querySelectorAll('.tree-file').forEach(el => {
const name = el.querySelector('.file-name').textContent.toLowerCase();
const title = (el.querySelector('.file-name').title || '').toLowerCase();
el.style.display = (!q || name.includes(q) || title.includes(q)) ? 'flex' : 'none';
});
// Filter list files
document.querySelectorAll('.list-file').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
});
if (q) {
document.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
document.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
document.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '📂');
}
}
let sortAsc = false;
function sortByLines() {
const list = document.getElementById('cloneList');
const cards = Array.from(list.querySelectorAll('.clone-card'));
sortAsc = !sortAsc;
cards.sort((a, b) => {
const la = parseInt(a.dataset.lines);
const lb = parseInt(b.dataset.lines);
return sortAsc ? la - lb : lb - la;
});
cards.forEach(c => list.appendChild(c));
document.getElementById('sortBtn').innerHTML = 'Sort: Lines ' + (sortAsc ? '↑' : '↓');
}
</script>
</body>
</script>
</body>
</html>
{
"threshold": 3,
"minTokens": 100,
"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
}
# 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/**`