# SkillPatch skill: Bug Analyzer

Analyze a reported bug end-to-end: understand the codebase, identify root cause, locate affected files, provide actionable debugging steps, produce targeted code changes, attribute blame via git history, rate severity, and emit a self-contained HTML report. Use whenever a user reports or describes a bug and wants a structured investigation.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/bug-analyzer
curl -sSL https://skillpatch.dev/install_skill/bug-analyzer | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (2)

- `SKILL.md`
- `scripts/generate_report.py`


### `SKILL.md`

````markdown
---
name: bug-analyzer
description: Analyze a reported bug end-to-end: understand the codebase, identify root cause, locate affected files, provide actionable debugging steps, produce targeted code changes, attribute blame via git history, rate severity, and emit a self-contained HTML report. Use whenever a user reports or describes a bug and wants a structured investigation.
---

# Bug Analyzer

Performs a full automated investigation of a reported bug and produces a structured HTML report. The goal is to help developers debug faster — pinpointing the fault, explaining why it occurs, providing steps to reproduce and verify it, and identifying the most effective code changes needed to resolve it.

## When to use

Invoke **only** when the user is reporting or investigating a defect in existing behaviour:
- Reports a bug ("I'm seeing X crash", "Y is broken", "this function returns wrong output")
- Pastes an error message, stack trace, or failing test output
- Asks for a root-cause analysis or bug investigation on code that already exists

Do **not** invoke for:
- Feature requests or new functionality ("add X", "implement Y", "build Z")
- Feature updates or enhancements to existing behaviour ("change X to do Y instead")
- Architecture or design questions
- General code review (use `/code-review` for that)
- Refactoring or performance work that is not caused by a defect

If the request is ambiguous, ask: "Is this a defect in existing behaviour, or a request for new/changed functionality?" Only proceed if the answer is clearly a defect.

## Required input

Collect the following before starting. Ask the user for anything missing:

| Input | Required | Notes |
|-------|----------|-------|
| Bug description | Yes | What the user observed |
| Steps to reproduce | Strongly preferred | Exact steps, commands, or inputs |
| Error message / stack trace | If available | Paste verbatim |
| Expected vs actual behavior | Strongly preferred | Makes root-cause clearer |
| Affected files / modules | Optional | User's initial suspicion |

## Workflow

Work through these phases **in order**. Complete each phase before moving to the next.

---

### Phase 1 — Codebase orientation

Before touching the bug, build a map of the project. **Try lgraph MCP first; fall back to manual exploration if unavailable.**

#### 1a — lgraph MCP orientation (use when available)

Check whether Latentgraph context was injected at session start — look for a `toon`-fenced block from `get_project_overview` in your current conversation context.

**If lgraph is available:**
1. The `get_project_overview` data is already in your context from the session-start hook. Read it to understand the project architecture, top modules, and entry points.
2. Call `ask_codebase("<bug description>")` with the full bug description. This returns a synthesised answer plus a `citations[]` list of relevant source-file paths — use these as your primary file candidates in Phase 2, supplementing with grep rather than replacing it entirely.
3. Note the language, framework, module structure, and entry points from the overview.

**If lgraph is unavailable** (no injected context, or the hook block says "(fetch failed)"):
Skip to 1b — all phases degrade gracefully to grep and raw file reads.

#### 1b — Manual orientation (always run; primary path when lgraph unavailable)

1. Read any `README.md` at the root.
2. Run `find . -maxdepth 3 -type f | grep -v '.git\|node_modules\|__pycache__\|\.pyc\|dist\|build' | head -120` to get a directory tree.
3. Identify: language(s), framework(s), entry points, test runner, and dependency manager.
4. Note any unusual patterns (monorepo, generated code, vendored deps) that affect where bugs typically live.

Record a short "codebase map" — you'll embed it in the report.

---

### Phase 2 — Bug scoping

Using the bug description, narrow the search space:

1. If lgraph `ask_codebase` returned citations in Phase 1a, start with those files as primary candidates; use grep to fill any gaps.
2. Extract **keywords** from the description (function names, class names, error strings, HTTP paths, CLI flags).
3. Grep for each keyword:
   ```bash
   grep -rn "<keyword>" . --include="*.py" --include="*.ts" --include="*.js" --include="*.go" --include="*.java" --include="*.rb" --include="*.rs" -l
   ```
   Adapt the `--include` flags to the project's language(s).
4. Read the **top candidate files** in full — not just the matching lines.
5. Trace the execution path: entry point → caller chain → the failing code.
6. List every file that touches the broken path.

---

### Phase 3 — Root cause identification

Do not assume where the bug is. Read actual code at every step and confirm each finding before moving on.

#### 3a — Full execution trace

Starting from the entry point identified in Phase 2, read every function in the call chain completely — not excerpts, not grep matches alone.

**If lgraph is available**, call `get_call_chain` before tracing manually:
```
get_call_chain(symbol="<file>::<function>", direction="callers")   # who calls this function
get_call_chain(symbol="<file>::<function>", direction="callees")   # what this function calls
```
Use the returned chain as a structural map. Still read the actual source at each hop to confirm runtime behaviour — the graph shows edges, not values.

**If lgraph is unavailable**, locate callers and definitions with grep:
```bash
grep -rn "<function_name>" . --include="*.js" --include="*.ts"   # adapt to language
grep -rn "def <function_name>\|function <function_name>\|<function_name> =" . --include="*.py"
```

Stop only when you have read the code at every level from the entry point down to the line that produces the wrong output. Record the **exact file path and line number** at each level.

#### 3b — Fault localisation

For each file in the trace, read the relevant functions in full and check for:

- Off-by-one errors, wrong operator, sign inversion
- Null/undefined dereferences, type coercions, implicit conversions
- Wrong assumptions about a data contract, API convention, or unit (e.g. sign of a constant, coordinate system, index base)
- Race conditions, missing locks, shared-state mutation
- Missing error handling, swallowed exceptions

For every candidate fault, record:
- **File** (exact path)
- **Line range** (exact start–end lines)
- **Function / method name**
- **What is wrong** — describe the specific expression or value that is incorrect, not a general category
- **Why it is wrong** — explain the violated invariant or contract with reference to actual values

Do not record a file unless you have read the relevant code and confirmed the fault. No assumptions.

#### 3c — All contributing files

A bug often spans more than one file (e.g. a wrong value set in one place and misread in another, or a broken implementation masked by a broken test). After localising the primary fault:

1. Check every file that **writes, transforms, or passes** the faulty value — read those sections in full.
2. Check every file that **reads or asserts on** the faulty value — read those sections in full.
3. **If lgraph is available**, call `get_dependencies` on each affected file to surface implicit couplings (shared config, event bus, runtime-injected dependencies) that may also carry the faulty value.
4. For each additional file found, record file path, line range, function name, and how it contributes to the bug (producer / consumer / masking test / shared utility).

The goal is a complete, evidence-based list of every file and line range implicated in the bug before any fix is proposed.

---

### Phase 4 — Lateral Impact Sweep

Search systematically for other code locations that implement the same pattern, share the same API contract, or mirror the same logic as the primary fault. The goal is to surface systemic issues rather than fixing only the first occurrence.

#### 4a — Pattern extraction

From the fault identified in Phase 3, extract:
- The **primary symbol or pattern** (e.g. function name, method call, API usage, type operation — e.g. `TryGetValueAs`, `MapToIPv4()`)
- Any **structural equivalents** (similar function names, the same interface implemented differently, analogous operations in other modules)

#### 4b — Codebase-wide search

**If lgraph is available:**
```
get_symbol(name="<symbol_name>")   # locate all definitions and usages by name across the project
```
Then grep for any text patterns not captured by the symbol lookup:
```bash
grep -rn "<pattern_or_symbol>" . --include="*.cs" --include="*.ts"   # adapt to language
```

**If lgraph is unavailable**, use grep only:
```bash
grep -rn "<pattern_or_symbol>" . --include="*.py"   # adapt to language
```

#### 4c — Analysis of each hit

For every match found, read it in context and determine:
- **Affected** (`true`) — the same logical error applies; needs the same fix
- **Needs review** — similar pattern but different context; a human should decide
- **Unaffected** — same symbol, different use, bug does not apply

Record **only affected and needs-review** locations; omit clean ones. For each, note:
- **File** (exact path)
- **Line range**
- **Function / method name**
- **Pattern matched** — what makes this similar to the primary fault
- **Affected** — `true`, `"needs-review"`, or `false`
- **Explanation** — why it is affected or why it warrants human review

---

### Phase 5 — Test Coverage Verification

Once the affected source files are known, verify whether tests exist and whether they cover the identified bug.

#### 5a — Locate test files

For each affected source file, search for corresponding test files using the project's naming convention:

```bash
# C# — *Test.cs, *Tests.cs
find . -name "*Test.cs" -o -name "*Tests.cs" | grep -v '.git\|bin\|obj'

# JavaScript / TypeScript — *.test.ts, *.spec.ts, __tests__/
find . -name "*.test.ts" -o -name "*.spec.ts" -o -path "*/__tests__/*" | grep -v node_modules

# Python — test_*.py, *_test.py
find . -name "test_*.py" -o -name "*_test.py" | grep -v '.git\|__pycache__'

# Go — *_test.go
find . -name "*_test.go" | grep -v '.git\|vendor'

# Java / Kotlin — *Test.java, *Test.kt
find . -name "*Test.java" -o -name "*Test.kt" | grep -v '.git\|build\|target'
```

Read each candidate test file in full to understand its scope.

#### 5b — Coverage analysis

For each test file found, determine:
1. Does it test the **function / method where the primary fault lives**?
2. Does it exercise the **specific code path** that triggers the bug (the exact input conditions)?
3. Does it include:
   - A **nominal case** that passes through the faulty path?
   - A **boundary case** at the exact failure threshold?
   - A **negative case** that should definitively not trigger the bug?

#### 5c — Gap identification and recommendations

If no tests cover the faulty path, or if coverage is incomplete, identify each gap and propose a concrete test:

For each recommended test, specify:
- **Test file** — which file to modify or create (follow the project's naming convention)
- **Test name** — following the project's convention (e.g. `Test_ProcessRequest_NullInput`, `test_process_request_null_input`)
- **Scenario** — the exact situation being exercised
- **Rationale** — why this case is necessary given the identified bug
- **Suggested code** — a concrete stub with setup, action, and assertion

---

### Phase 6 — Debugging workflow & effective code changes

This phase has two parts. Complete **6a** before **6b**.

---

#### 6a — Debugging steps

Before proposing any code changes, produce actionable steps a developer can follow immediately to reproduce, isolate, and confirm the bug on their own:

1. Write a **minimal reproduction snippet** — the shortest self-contained code that triggers the wrong behaviour (3–5 lines, not a full file). Include exact inputs and the wrong output observed.
2. Identify the **inspection point**: which variable, return value, or intermediate state at which exact line number reveals the bug. Specify what value the developer should log or inspect, and what value they should expect to see vs. what they actually get.
3. Describe how to **verify the hypothesis without any code change** — e.g. temporarily log a suspect value, run an existing test with modified input, or add a breakpoint at the fault site.

Record these as `debugging_steps` — they go into the report.

---

#### 6b — Effective code changes

Once the fault is confirmed, identify the most effective changes to resolve it. For each change (aim for 1–3 solutions):

**Step 1 — Test intent analysis (required when tests are affected)**

Before writing any test patch, answer explicitly for each affected test:
- Is the test's **data** wrong? (The input/value used doesn't match the stated scenario — fix by changing the test data.)
- Or is the test's **assertion** wrong? (The data is correct but the expected result is inverted — fix by negating the assertion.)

Choosing the wrong answer produces a semantically different patch. State your reasoning explicitly.

**Step 2 — Test coverage rationale (required for all test changes)**

Don't propose the minimal change that makes the test pass. Instead, reason about what cases need to be covered to gain confidence the fix is correct:
- **Nominal cases**: the expected happy path
- **Boundary cases**: inputs at or just beyond the limit (e.g. a value exactly at a threshold, a plane just touching a face)
- **Negative cases**: inputs that should definitively *not* trigger the condition

List the cases you're covering and why. This rationale goes into the report as `test_coverage_rationale`.

**Step 3 — Write the unified diff patch**

```
--- a/<filepath>
+++ b/<filepath>
@@ -<start>,<count> +<start>,<count> @@
 <context line>
-<removed line>
+<added line>
 <context line>
```

Label each: "Primary fix", "Alternative: …", etc.

**Step 4 — Verification steps**

After each patch, provide concrete steps to confirm the fix is correct:
- Which test command to run and what output to expect
- A quick inline check (e.g. a one-liner that should now return `true`)
- Any existing tests that will break and must be updated as a result

These go into the report as `verification_steps` per solution.

**Step 5 — Follow-up**

Note any migration steps, config changes, or related functions identified in the lateral impact sweep (Phase 4) that also need updating.

---

### Phase 7 — Commit history analysis

The goal is to find the **single commit that introduced the bug** — regardless of how long ago it was — and identify the responsible author.

Run these git commands in order:

```bash
# 1. Blame the exact broken lines to find the last commit that touched them
git blame -L <start>,<end> <affected_file>

# 2. Search the full history for when the broken string/symbol was introduced
git log -S "<broken_string_or_symbol>" --oneline -- <affected_file>

# 3. Show the full patch of the suspect commit to confirm it introduced the bug
git show <commit_sha>

# 4. If needed, walk all commits that ever touched the file (no date limit)
git log --oneline -- <affected_file1> <affected_file2>
```

**Identifying the introducing commit**: it is the earliest commit in which the faulty code appeared in its current (broken) form. Use `git log -S` first — it searches the entire history and is the fastest way to pinpoint it without a date constraint.

Collect for each relevant commit:
- SHA (first 8 chars)
- Author name + email
- Date
- Commit message
- Which affected file(s) it touched
- Whether it is the **introducing commit** (mark exactly one as `true`)

---

### Phase 8 — Severity rating

Rate severity on this scale:

| Rating | Label | Criteria |
|--------|-------|----------|
| 5 | Critical | Data loss, security vulnerability, system crash in production, affects all users |
| 4 | High | Major feature broken, significant performance regression, affects most users |
| 3 | Medium | Feature partially broken, workaround exists, affects some users |
| 2 | Low | Minor visual or UX issue, rare edge case, easy workaround |
| 1 | Trivial | Cosmetic, no functional impact |

Also assess:
- **Blast radius**: how many users / code paths are affected
- **Exploitability**: can it be triggered externally?
- **Regression risk**: does the proposed fix risk breaking other things?

---

### Phase 9 — Generate HTML report

Once all phases are complete, call the report generator:

```bash
python3 "<SKILL>/scripts/generate_report.py" '<json_payload>'
```

Where `<SKILL>` is this skill's folder (e.g. `~/.claude/skills/bug-analyzer`).

The JSON payload must match this schema — build it from your findings:

```json
{
  "bug_title": "Short title of the bug",
  "report_path": "./bug_report.html",
  "summary": {
    "description": "User-provided description",
    "steps_to_reproduce": ["Step 1", "Step 2"],
    "expected_behavior": "What should happen",
    "actual_behavior": "What actually happens",
    "error_message": "Stack trace or error text (empty string if none)"
  },
  "codebase": {
    "language": "Python / TypeScript / Go / etc.",
    "framework": "Django / React / Gin / etc. (empty string if none)",
    "entry_points": ["main.py", "src/index.ts"],
    "notes": "Any unusual project structure notes"
  },
  "code_analysis": [
    {
      "file": "src/foo.py",
      "lines": "42-67",
      "function": "process_request",
      "issue": "Explanation of what is wrong — must reference actual code read from the file. Include the specific expression or value that is wrong and why."
    }
  ],
  "root_cause": {
    "summary": "One-paragraph plain-English explanation",
    "contributing_factors": ["Factor 1", "Factor 2"]
  },
  "lateral_impact": [
    {
      "file": "src/bar.py",
      "lines": "120-145",
      "function": "similar_function",
      "pattern": "TryGetValueAs<T> usage",
      "affected": true,
      "explanation": "Contains the same pattern and is subject to the same bug"
    }
  ],
  "test_coverage": {
    "test_files_found": ["tests/test_foo.py"],
    "existing_coverage": "Brief description of what is already tested",
    "gaps": ["No test for null input path", "Missing boundary value test at threshold"],
    "recommended_tests": [
      {
        "test_file": "tests/test_foo.py",
        "test_name": "test_process_request_null_input",
        "scenario": "Verifies that process_request returns None when called with a null argument",
        "rationale": "No existing test exercises the null-dereference path identified in the bug",
        "suggested_code": "def test_process_request_null_input():\n    result = process_request(None)\n    assert result is None"
      }
    ]
  },
  "debugging_steps": [
    "Minimal reproduction: create a Box3 from (0,0,0)→(1,1,1) and call intersectsPlane(new Plane(new Vector3(0,1,0), -0.5)) — should return true but returns false",
    "Inspect at line 387: log min, max, plane.constant before the return. Expected: min≈0, max≈1, constant≈-0.5.",
    "Verify without changing code: temporarily change the return to log both sides and observe that min<=(-constant)<=max holds but min<=(constant) does not."
  ],
  "solutions": [
    {
      "label": "Primary fix",
      "description": "What this fix does",
      "diff": "--- a/src/foo.py\n+++ b/src/foo.py\n@@ -42,7 +42,7 @@\n ...",
      "test_coverage_rationale": "Covers: nominal intersection, boundary touch, just-outside, negative case, diagonal corner.",
      "verification_steps": [
        "Run: npm test -- Box3 and confirm all intersectsPlane assertions pass",
        "Quick check: new Box3(zero3,one3).intersectsPlane(new Plane(new Vector3(0,1,0),-0.5)) should now return true",
        "Note: existing test assertion for plane b will fail after the fix — its assertion must be negated"
      ],
      "notes": "Migration or follow-up steps"
    }
  ],
  "commits": [
    {
      "sha
...<truncated>
````


### `scripts/generate_report.py`

```
#!/usr/bin/env python3
"""
Bug Analyzer – HTML Report Generator
Usage: python3 generate_report.py '<json_payload>'
Writes a self-contained HTML report and prints the absolute path to stdout.
"""

import json
import os
import sys
from datetime import datetime
from html import escape


def severity_color(score: int) -> str:
    return {5: "#c0392b", 4: "#e67e22", 3: "#f39c12", 2: "#27ae60", 1: "#2980b9"}.get(score, "#7f8c8d")


def severity_bg(score: int) -> str:
    return {5: "#fdf0f0", 4: "#fef5ec", 3: "#fefaec", 2: "#edfaf1", 1: "#eaf4fb"}.get(score, "#f5f5f5")


def diff_to_html(diff: str) -> str:
    if not diff.strip():
        return "<em>No diff provided.</em>"
    lines = []
    for line in diff.splitlines():
        esc = escape(line)
        if line.startswith("+++") or line.startswith("---"):
            lines.append(f'<span class="diff-file">{esc}</span>')
        elif line.startswith("@@"):
            lines.append(f'<span class="diff-hunk">{esc}</span>')
        elif line.startswith("+"):
            lines.append(f'<span class="diff-add">{esc}</span>')
        elif line.startswith("-"):
            lines.append(f'<span class="diff-del">{esc}</span>')
        else:
            lines.append(f'<span class="diff-ctx">{esc}</span>')
    return "\n".join(lines)


def steps_html(steps: list) -> str:
    if not steps:
        return "<p><em>No steps provided.</em></p>"
    items = "".join(f"<li>{escape(s)}</li>" for s in steps)
    return f"<ol>{items}</ol>"


def factors_html(factors: list) -> str:
    if not factors:
        return ""
    items = "".join(f"<li>{escape(f)}</li>" for f in factors)
    return f"<ul class='factor-list'>{items}</ul>"


def debugging_steps_html(steps: list) -> str:
    if not steps:
        return "<p><em>No debugging steps provided.</em></p>"
    items = "".join(
        f'<div class="debug-step"><span class="debug-num">{i}</span><p>{escape(s)}</p></div>'
        for i, s in enumerate(steps, 1)
    )
    return items


def code_analysis_html(analyses: list) -> str:
    if not analyses:
        return "<p><em>No code analysis entries.</em></p>"
    rows = []
    for a in analyses:
        rows.append(f"""
        <div class="analysis-card">
          <div class="analysis-header">
            <span class="file-badge">{escape(a.get('file', ''))}</span>
            <span class="lines-badge">Lines {escape(a.get('lines', 'N/A'))}</span>
            <span class="fn-badge">{escape(a.get('function', ''))}</span>
          </div>
          <p class="analysis-issue">{escape(a.get('issue', ''))}</p>
        </div>""")
    return "\n".join(rows)


def verification_steps_html(steps: list) -> str:
    if not steps:
        return ""
    items = "".join(f"<li>{escape(v)}</li>" for v in steps)
    return f'<div class="verify-block"><p class="verify-label">Verification steps</p><ol>{items}</ol></div>'


def solutions_html(solutions: list) -> str:
    if not solutions:
        return "<p><em>No solutions provided.</em></p>"
    blocks = []
    for i, s in enumerate(solutions, 1):
        notes = f'<p class="solution-notes"><strong>Follow-up:</strong> {escape(s.get("notes", ""))}</p>' if s.get("notes") else ""
        coverage = (
            f'<p class="coverage-label">Test coverage rationale</p>'
            f'<p class="coverage-body">{escape(s.get("test_coverage_rationale", ""))}</p>'
            if s.get("test_coverage_rationale") else ""
        )
        verify = verification_steps_html(s.get("verification_steps", []))
        blocks.append(f"""
        <div class="solution-block">
          <div class="solution-label">Solution {i}: {escape(s.get('label', ''))}</div>
          <p class="solution-desc">{escape(s.get('description', ''))}</p>
          <pre class="diff-block"><code>{diff_to_html(s.get('diff', ''))}</code></pre>
          {coverage}
          {verify}
          {notes}
        </div>""")
    return "\n".join(blocks)


def commits_html(commits: list) -> str:
    if not commits:
        return "<p><em>No git history available.</em></p>"
    rows = []
    for c in commits:
        introducing = ""
        if c.get("is_introducing_commit"):
            introducing = '<span class="introducing-badge">Introducing commit</span>'
        files = ", ".join(escape(f) for f in c.get("files", []))
        rows.append(f"""
        <div class="commit-row {'commit-introducing' if c.get('is_introducing_commit') else ''}">
          <div class="commit-meta">
            <code class="commit-sha">{escape(c.get('sha', ''))}</code>
            {introducing}
            <span class="commit-date">{escape(c.get('date', ''))}</span>
          </div>
          <p class="commit-message">{escape(c.get('message', ''))}</p>
          <p class="commit-author">
            <strong>{escape(c.get('author', ''))}</strong>
            &lt;{escape(c.get('email', ''))}&gt;
          </p>
          <p class="commit-files"><strong>Files:</strong> {files}</p>
        </div>""")
    return "\n".join(rows)


def lateral_impact_html(impacts: list) -> str:
    if not impacts:
        return "<p><em>No additional locations affected by this pattern.</em></p>"
    cards = []
    for item in impacts:
        affected = item.get("affected", False)
        if affected is True:
            badge_cls = "impact-affected"
            badge_text = "Affected"
        elif affected == "needs-review":
            badge_cls = "impact-review"
            badge_text = "Needs Review"
        else:
            badge_cls = "impact-safe"
            badge_text = "Unaffected"
        cards.append(f"""
        <div class="impact-card">
          <div class="impact-header">
            <span class="file-badge">{escape(item.get('file', ''))}</span>
            <span class="lines-badge">Lines {escape(item.get('lines', 'N/A'))}</span>
            <span class="fn-badge">{escape(item.get('function', ''))}</span>
            <span class="impact-badge {badge_cls}">{badge_text}</span>
          </div>
          <p class="impact-pattern"><strong>Pattern:</strong> {escape(item.get('pattern', ''))}</p>
          <p class="analysis-issue">{escape(item.get('explanation', ''))}</p>
        </div>""")
    return "\n".join(cards)


def test_coverage_html(tc: dict) -> str:
    if not tc:
        return "<p><em>Test coverage analysis not performed.</em></p>"

    test_files = tc.get("test_files_found", [])
    if test_files:
        file_items = "".join(f"<li><code>{escape(f)}</code></li>" for f in test_files)
        files_html = f'<p class="tc-label">Test files found</p><ul class="ep-list">{file_items}</ul>'
    else:
        files_html = '<p class="tc-label">Test files found</p><p class="tc-empty">No test files found for the affected source files.</p>'

    existing = tc.get("existing_coverage", "")
    existing_html = (
        f'<p class="tc-label" style="margin-top:18px">Existing coverage</p>'
        f'<p class="tc-body">{escape(existing)}</p>'
    ) if existing else ""

    gaps = tc.get("gaps", [])
    gaps_html = ""
    if gaps:
        gap_items = "".join(f"<li>{escape(g)}</li>" for g in gaps)
        gaps_html = (
            f'<p class="tc-label" style="margin-top:18px">Coverage gaps</p>'
            f'<ul class="gap-list">{gap_items}</ul>'
        )

    recs = tc.get("recommended_tests", [])
    recs_html = ""
    if recs:
        rec_cards = []
        for r in recs:
            code = r.get("suggested_code", "")
            code_block = f'<pre class="test-code-block"><code>{escape(code)}</code></pre>' if code else ""
            scenario = r.get("scenario", "")
            scenario_html = f'<p class="tc-body"><strong>Scenario:</strong> {escape(scenario)}</p>' if scenario else ""
            rec_cards.append(f"""
            <div class="test-rec-card">
              <div class="analysis-header">
                <span class="file-badge">{escape(r.get('test_file', ''))}</span>
                <span class="test-name-badge">{escape(r.get('test_name', ''))}</span>
              </div>
              {scenario_html}
              <p class="test-rationale">{escape(r.get('rationale', ''))}</p>
              {code_block}
            </div>""")
        recs_html = (
            '<p class="tc-label" style="margin-top:18px">Recommended tests</p>'
            + "\n".join(rec_cards)
        )

    return files_html + existing_html + gaps_html + recs_html


def render(data: dict) -> str:
    summary = data.get("summary", {})
    codebase = data.get("codebase", {})
    root_cause = data.get("root_cause", {})
    severity = data.get("severity", {})
    lateral_impact = data.get("lateral_impact", [])
    test_coverage = data.get("test_coverage", {})
    score = severity.get("score", 0)
    sev_color = severity_color(score)
    sev_bg = severity_bg(score)
    generated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    ep_list = "".join(f"<li><code>{escape(e)}</code></li>" for e in codebase.get("entry_points", []))

    toc_lateral = '<li><a href="#lateral-impact">Lateral Impact Sweep</a></li>' if lateral_impact else ""
    toc_test_coverage = '<li><a href="#test-coverage">Test Coverage</a></li>' if test_coverage else ""

    lateral_section = f"""
  <div class="section" id="lateral-impact">
    <div class="section-title"><span class="section-icon">&#x1F504;</span> Lateral Impact Sweep</div>
    {lateral_impact_html(lateral_impact)}
  </div>
""" if lateral_impact else ""

    test_coverage_section = f"""
  <div class="section" id="test-coverage">
    <div class="section-title"><span class="section-icon">&#x1F9EA;</span> Test Coverage</div>
    {test_coverage_html(test_coverage)}
  </div>
""" if test_coverage else ""

    html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Bug Report – {escape(data.get('bug_title', 'Analysis'))}</title>
<style>
  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
          background: #f0f2f5; color: #1a1a2e; line-height: 1.6; }}
  a {{ color: #3498db; }}

  /* ── Header ── */
  .report-header {{ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 60%, #0f3460 100%);
                    color: #fff; padding: 48px 40px 36px; }}
  .report-header h1 {{ font-size: 2rem; font-weight: 700; margin-bottom: 8px; }}
  .report-header .meta {{ opacity: 0.65; font-size: 0.85rem; }}
  .sev-pill {{ display: inline-block; padding: 4px 14px; border-radius: 20px;
               font-weight: 700; font-size: 0.9rem; margin-top: 14px;
               background: {sev_color}; color: #fff; letter-spacing: 0.03em; }}

  /* ── Layout ── */
  .container {{ max-width: 1100px; margin: 0 auto; padding: 32px 20px 60px; }}
  .toc {{ background: #fff; border-radius: 12px; padding: 24px 28px; margin-bottom: 32px;
          box-shadow: 0 2px 8px rgba(0,0,0,.07); }}
  .toc h2 {{ font-size: 1rem; font-weight: 600; color: #555; margin-bottom: 12px; text-transform: uppercase; letter-spacing: .05em; }}
  .toc ol {{ padding-left: 20px; }}
  .toc li {{ margin-bottom: 6px; }}

  /* ── Section card ── */
  .section {{ background: #fff; border-radius: 12px; padding: 32px 36px; margin-bottom: 28px;
              box-shadow: 0 2px 8px rgba(0,0,0,.07); }}
  .section-title {{ display: flex; align-items: center; gap: 10px;
                    font-size: 1.25rem; font-weight: 700; margin-bottom: 22px;
                    padding-bottom: 14px; border-bottom: 2px solid #f0f2f5; color: #0f3460; }}
  .section-icon {{ font-size: 1.3rem; }}

  /* ── Summary ── */
  .info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }}
  @media (max-width: 700px) {{ .info-grid {{ grid-template-columns: 1fr; }} }}
  .info-box {{ background: #f8f9fc; border-radius: 8px; padding: 16px 20px; }}
  .info-box label {{ display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
                     color: #888; letter-spacing: .06em; margin-bottom: 6px; }}
  .info-box p, .info-box ol {{ font-size: 0.95rem; color: #333; }}
  .info-box ol {{ padding-left: 18px; }}
  .error-block {{ font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.82rem;
                  background: #1e1e2e; color: #cdd6f4; padding: 14px 18px; border-radius: 8px;
                  overflow-x: auto; white-space: pre-wrap; word-break: break-all; margin-top: 8px; }}

  /* ── Codebase ── */
  .cb-grid {{ display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }}
  .cb-chip {{ background: #eaf4fb; color: #1a6fa0; border-radius: 6px;
              padding: 5px 12px; font-size: 0.85rem; font-weight: 600; }}
  .cb-chip span {{ font-weight: 400; color: #444; }}
  ul.ep-list {{ list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; }}
  ul.ep-list li code {{ background: #f0f2f5; border-radius: 4px; padding: 3px 8px; font-size: 0.83rem; }}

  /* ── Analysis cards ── */
  .analysis-card {{ border: 1px solid #e8eaf0; border-radius: 8px; padding: 16px 20px; margin-bottom: 14px; }}
  .analysis-header {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }}
  .file-badge {{ background: #1a1a2e; color: #fff; border-radius: 5px; padding: 3px 10px; font-size: 0.8rem; font-family: monospace; }}
  .lines-badge {{ background: #eaf4fb; color: #1a6fa0; border-radius: 5px; padding: 3px 10px; font-size: 0.8rem; }}
  .fn-badge {{ background: #f0f2f5; color: #555; border-radius: 5px; padding: 3px 10px; font-size: 0.8rem; font-family: monospace; }}
  .analysis-issue {{ color: #444; font-size: 0.95rem; line-height: 1.55; }}

  /* ── Root cause ── */
  .root-summary {{ font-size: 1rem; line-height: 1.7; color: #333; margin-bottom: 16px;
                   padding: 16px 20px; background: #fffbf0; border-left: 4px solid #f39c12; border-radius: 0 8px 8px 0; }}
  .factor-list {{ padding-left: 20px; color: #555; }}
  .factor-list li {{ margin-bottom: 6px; }}

  /* ── Lateral Impact ── */
  .impact-card {{ border: 1px solid #e8eaf0; border-radius: 8px; padding: 16px 20px; margin-bottom: 14px; }}
  .impact-header {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }}
  .impact-badge {{ border-radius: 5px; padding: 3px 10px; font-size: 0.8rem; font-weight: 600; }}
  .impact-affected {{ background: #fdf0f0; color: #c0392b; border: 1px solid #e74c3c; }}
  .impact-review {{ background: #fef5ec; color: #d35400; border: 1px solid #e67e22; }}
  .impact-safe {{ background: #edfaf1; color: #1e8449; border: 1px solid #27ae60; }}
  .impact-pattern {{ color: #555; font-size: 0.88rem; margin-bottom: 6px; }}

  /* ── Test Coverage ── */
  .tc-label {{ font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: #888;
               letter-spacing: .06em; margin-bottom: 8px; display: block; }}
  .tc-body {{ color: #555; font-size: 0.92rem; line-height: 1.55; }}
  .tc-empty {{ color: #999; font-style: italic; font-size: 0.9rem; }}
  .gap-list {{ padding-left: 20px; color: #c0392b; margin-top: 4px; }}
  .gap-list li {{ margin-bottom: 6px; font-size: 0.92rem; }}
  .test-rec-card {{ border: 1px solid #d5e8d4; border-radius: 8px; padding: 16px 20px;
                    margin-bottom: 14px; background: #fafffe; }}
  .test-name-badge {{ background: #d5e8d4; color: #1e5e2e; border-radius: 5px; padding: 3px 10px;
                      font-size: 0.8rem; font-family: monospace; }}
  .test-rationale {{ color: #666; font-size: 0.88rem; font-style: italic; margin-top: 6px; margin-bottom: 8px; }}
  .test-code-block {{ background: #1e1e2e; color: #cdd6f4; padding: 14px 18px; border-radius: 6px;
                      overflow-x: auto; font-family: 'SFMono-Regular', Consolas, monospace;
                      font-size: 0.82rem; line-height: 1.6; white-space: pre; margin-top: 10px; }}

  /* ── Solutions / Diff ── */
  .solution-block {{ border: 1px solid #e8eaf0; border-radius: 10px; overflow: hidden; margin-bottom: 22px; }}
  .solution-label {{ background: #0f3460; color: #fff; padding: 10px 20px; font-weight: 700; font-size: 0.95rem; }}
  .solution-desc {{ padding: 14px 20px 4px; color: #444; }}
  .solution-notes {{ padding: 0 20px 14px; color: #666; font-size: 0.88rem; font-style: italic; }}
  .diff-block {{ background: #1e1e2e; color: #cdd6f4; margin: 0; padding: 18px 20px;
                 overflow-x: auto; font-family: 'SFMono-Regular', Consolas, monospace;
                 font-size: 0.82rem; line-height: 1.6; white-space: pre; }}
  .diff-file {{ color: #89b4fa; }}
  .diff-hunk {{ color: #cba6f7; }}
  .diff-add {{ color: #a6e3a1; background: rgba(166,227,161,.08); display: block; }}
  .diff-del {{ color: #f38ba8; background: rgba(243,139,168,.08); display: block; }}
  .diff-ctx {{ color: #9399b2; display: block; }}

  /* ── Debugging steps ── */
  .debug-step {{ display: flex; gap: 14px; align-items: flex-start; padding: 12px 0;
                 border-bottom: 1px solid #f0f2f5; }}
  .debug-step:last-child {{ border-bottom: none; }}
  .debug-num {{ flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%;
                background: #0f3460; color: #fff; font-size: 0.8rem; font-weight: 700;
                display: flex; align-items: center; justify-content: center; margin-top: 2px; }}
  .debug-step p {{ color: #333; font-size: 0.95rem; line-height: 1.55; font-family: 'SFMono-Regular', Consolas, monospace; }}

  /* ── Solution extras ── */
  .coverage-label {{ font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: #888;
                      letter-spacing: .06em; padding: 10px 20px 4px; }}
  .coverage-body {{ padding: 0 20px 10px; color: #555; font-size: 0.88rem; font-style: italic; }}
  .verify-block {{ background: #f0f9f0; border-top: 1px solid #c3e6cb; padding: 12px 20px; }}
  .verify-label {{ font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: #27ae60;
                    letter-spacing: .06em; margin-bottom: 6px; }}
  .verify-block ol {{ padding-left: 18px; color: #2d6a2d; font-size: 0.88rem; }}
  .verify-block li {{ margin-bottom: 4px; }}

  /* ── Commits ── */
  .commit-row {{ border: 1px solid #e8eaf0; border-radius: 8px; padding: 16px 20px; margin-bottom: 14px; }}
  .commit-introducing {{ border-color: #e74c3c; background: #fdf5f5; }}
  .commit-meta {{ display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }}
  .commit-sha {{ background: #1e1e2e; color: #89b4fa; padding: 2px 8px; border-radius: 4px; font-size: 0.85rem; }}
  .introducing-badge {{ background: #e74c3c; color: #fff; padding: 2px 10px; border-radius: 12px;
                         font-size: 0.75rem; font-weight: 700; }}
  .commit-date {{ color: #999; font-size: 0.85rem; margin-left: auto; }}
  .commit-message {{ font-weight: 600; color: #222; margin-bottom: 4px; }}
  .commit-author {{ font-size: 0.85rem; color: #666; margin-bottom: 4px; }}
  .commit-files {{ font-size: 0.83rem; color: #888; }}

  /* ── Severity ── */
  .sev-card {{ background: {sev_bg}; border: 2px solid {sev_color};
               border-radius: 12px; padding: 24px 28px; }}
  .sev-score {{ font-size: 3rem; font-weight: 800; color: {sev_color}; line-height: 1; }}
  .sev-label {{ font-size: 1.2rem; font-weight: 700; color: {sev_color}; margin-bottom: 20px; }}
  .sev-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-top: 18px; }}
  .sev-item label {{ display: block; font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
                     color: #888; letter-spacing: .06em; margin-bottom: 4px; }}
  .sev-item p {{ font-size: 0.92rem; color: #333; }}

  /* ── Footer ── */
  .report-footer {{ text-align: center; color: #aaa; font-size: 0.8rem; padding: 20px; }}
</style>
</head>
<body>

<div class="report-header">
  <h1>Bug Analysis Report</h1>
  <p class="meta">Generated {generated}</p>
  <h
...<truncated>
```
