Skip to content

Index

agent_cover.reporting

Report Generation.

This module converts the raw coverage data into human-readable formats.

🔗 Architectural Relationships

This is the Sink of the data pipeline. It is strictly read-only regarding the registry.

  • Consumes: AgentRegistry (Definitions, Executions, Hits).
  • Consumes: AgentCoverConfig (For business logic descriptions).
  • Produces: HTML Sites and XML Cobertura files.

⚙️ How it works

It merges the Static Definitions (what exists) with the Runtime Executions (what ran) to calculate coverage percentages.

Usage

from agent_cover.registry import get_registry
from agent_cover.reporting import generate_html_report

reg = get_registry()

generate_html_report(
    definitions=reg.definitions,
    executions=reg.executions,
    output_dir="site/coverage"
)

Functions

generate_cobertura_xml(definitions, executions, config=None, decision_hits=None, output_file='coverage.xml', writer_func=None, timestamp=None)

Generates a Cobertura XML coverage report.

This function calculates coverage statistics for both actual source code lines and virtual decision lines (from YAML configuration). It constructs an XML tree following the Cobertura DTD and writes it to disk.

Parameters:

Name Type Description Default
definitions Dict[str, Any]

A dictionary containing the definitions of code items (e.g., lines, statements) to be tracked.

required
executions Any

A collection (set or dict) of definition keys that were actually executed during the run.

required
config Optional[Any]

An optional configuration object containing decisions. If provided, coverage for these decisions is added to the report.

None
decision_hits Optional[Dict[str, Set[str]]]

An optional dictionary mapping decision IDs to sets of observed values. Used to calculate 'virtual' coverage.

None
output_file str

The file path for the output XML report. Defaults to "coverage.xml".

'coverage.xml'
writer_func Optional[Callable[[str, str], None]]

An optional callable to handle the file writing process. If None, the default disk writer is used. Useful for testing or writing to non-disk buffers.

None
timestamp Optional[float]

An optional Unix timestamp (float) to force a specific time in the report header. If None, the current system time is used.

None
Source code in src/agent_cover/reporting/xml.py
def generate_cobertura_xml(
    definitions: Dict[str, Any],
    executions: Any,
    config: Optional[Any] = None,
    decision_hits: Optional[Dict[str, Set[str]]] = None,
    output_file: str = "coverage.xml",
    writer_func: Optional[Callable[[str, str], None]] = None,
    timestamp: Optional[float] = None,
) -> None:
    """Generates a Cobertura XML coverage report.

    This function calculates coverage statistics for both actual source code lines
    and virtual decision lines (from YAML configuration). It constructs an XML
    tree following the Cobertura DTD and writes it to disk.

    Args:
        definitions: A dictionary containing the definitions of code items
            (e.g., lines, statements) to be tracked.
        executions: A collection (set or dict) of definition keys that were
            actually executed during the run.
        config: An optional configuration object containing `decisions`.
            If provided, coverage for these decisions is added to the report.
        decision_hits: An optional dictionary mapping decision IDs to sets of
            observed values. Used to calculate 'virtual' coverage.
        output_file: The file path for the output XML report. Defaults to
            "coverage.xml".
        writer_func: An optional callable to handle the file writing process.
            If None, the default disk writer is used. Useful for testing or
            writing to non-disk buffers.
        timestamp: An optional Unix timestamp (float) to force a specific time
            in the report header. If None, the current system time is used.
    """
    # Use default file writer if none is provided
    if writer_func is None:
        writer_func = _default_file_writer

    decision_hits = decision_hits or {}

    # Determine timestamp (Timestamp injection)
    ts = timestamp if timestamp is not None else get_timestamp()

    root = ET.Element("coverage")
    root.set("version", "1.0")
    root.set("timestamp", str(int(ts)))

    file_items = {k: v for k, v in definitions.items()}
    total_lines = len(file_items)
    covered_lines = sum(1 for k in file_items if k in executions)

    yaml_lines = 0
    yaml_covered = 0
    if config and config.decisions:
        for dec in config.decisions:
            yaml_lines += len(dec.expected_values)
            hits = decision_hits.get(dec.id, set())
            yaml_covered += sum(1 for v in dec.expected_values if str(v) in hits)

    grand_total = total_lines + yaml_lines
    grand_covered = covered_lines + yaml_covered

    line_rate = grand_covered / grand_total if grand_total > 0 else 0
    root.set("line-rate", f"{line_rate:.4f}")
    root.set("lines-covered", str(grand_covered))
    root.set("lines-valid", str(grand_total))

    packages = ET.SubElement(root, "packages")

    # Package 1: Source Code
    files_data: Dict[str, List[Dict[str, Any]]] = {}
    for key, meta in file_items.items():
        # --- ID Parsing Logic ---
        if key.startswith("RAW:"):
            # Format: RAW:/path/to/file.py::VAR_NAME
            fpath = key[4:].split("::")[0]
        elif key.startswith("FILE:"):
            # Format: FILE:/path/to/template.jinja2
            fpath = key[5:]
        else:
            # Format: /path/to/file.py:123 or /path/to/file.py:TOOL:name
            fpath = key.split(":")[0]

        if fpath not in files_data:
            files_data[fpath] = []

        # --- Line Number Extraction ---
        # Priority 1: Metadata (Reliable, set by scanners)
        lineno = meta.get("line_number", 0)

        # Priority 2: ID Parsing (Legacy fallback for old standard IDs)
        if lineno == 0 and not key.startswith("RAW:") and not key.startswith("FILE:"):
            try:
                parts = key.split(":")
                if len(parts) >= 2:
                    # Check if second part is a line number (path:123)
                    candidate = parts[1].split("#")[0]
                    if candidate.isdigit():
                        lineno = int(candidate)
            except (IndexError, ValueError):
                pass

        files_data[fpath].append({"line": lineno, "hit": key in executions})

    src_pkg = ET.SubElement(packages, "package")
    src_pkg.set("name", "source_code")
    src_classes = ET.SubElement(src_pkg, "classes")

    for fpath, lines in files_data.items():
        try:
            rel_path = os.path.relpath(fpath, os.getcwd())
        except ValueError:
            # Handles cases where paths are on different drives (Windows)
            rel_path = fpath

        cls = ET.SubElement(src_classes, "class")
        cls.set("name", rel_path)
        cls.set("filename", rel_path)

        xml_lines = ET.SubElement(cls, "lines")
        for item in lines:
            le = ET.SubElement(xml_lines, "line")
            le.set("number", str(item["line"]))
            le.set("hits", "1" if item["hit"] else "0")

    # Package 2: Virtual Decisions
    if config and config.decisions:
        dec_pkg = ET.SubElement(packages, "package")
        dec_pkg.set("name", "decisions")
        dec_classes = ET.SubElement(dec_pkg, "classes")

        cls = ET.SubElement(dec_classes, "class")
        cls.set("name", "agent-cover.yaml")
        cls.set("filename", "agent-cover.yaml")
        xml_lines = ET.SubElement(cls, "lines")

        virtual_line_cnt = 1
        for dec in config.decisions:
            hits = decision_hits.get(dec.id, set())
            for val in dec.expected_values:
                is_hit = str(val) in hits
                le = ET.SubElement(xml_lines, "line")
                le.set("number", str(virtual_line_cnt))
                le.set("hits", "1" if is_hit else "0")
                virtual_line_cnt += 1

    try:
        # Indent XML if supported (Python 3.9+)
        if hasattr(ET, "indent"):
            ET.indent(root, space="  ", level=0)

        xml_str = ET.tostring(root, encoding="utf-8", method="xml").decode("utf-8")

        if not xml_str.startswith("<?xml"):
            xml_str = '<?xml version="1.0" encoding="UTF-8"?>\n' + xml_str

        writer_func(output_file, xml_str)

    except Exception as e:
        logger.warning(f"XML Error: {e}", exc_info=True)

generate_html_report(definitions, executions, output_dir='prompt_html', decision_config=None, decision_hits=None, writer_func=None, timestamp=None)

Generates a static HTML website for coverage results.

It creates an index.html file with embedded CSS, making the report portable (single folder, no external dependencies).

Parameters:

Name Type Description Default
definitions Dict[str, Any]

A dictionary containing the static definitions of prompts and tools found in the codebase.

required
executions Set[str]

A set of identifiers representing the code paths that were executed during the run.

required
output_dir str

The directory where the HTML report should be saved. Defaults to "prompt_html".

'prompt_html'
decision_config Optional[Any]

Configuration object containing business logic decisions (YAML based) to be tracked.

None
decision_hits Optional[Dict[str, Set[str]]]

A dictionary mapping decision IDs to sets of observed values, used to calculate coverage of expected values.

None
writer_func Optional[Callable[[str, str], None]]

An optional callable to handle file writing. If None, defaults to writing to the local disk.

None
timestamp Optional[float]

An optional timestamp (float) to use for the report generation time. If None, the current time is used.

None

Returns:

Type Description
str

The absolute path to the output directory where the report was generated.

Source code in src/agent_cover/reporting/html.py
def generate_html_report(
    definitions: Dict[str, Any],
    executions: Set[str],
    output_dir: str = "prompt_html",
    decision_config: Optional[Any] = None,
    decision_hits: Optional[Dict[str, Set[str]]] = None,
    writer_func: Optional[Callable[[str, str], None]] = None,
    timestamp: Optional[float] = None,
) -> str:
    """Generates a static HTML website for coverage results.

    It creates an `index.html` file with embedded CSS, making the report portable
    (single folder, no external dependencies).

    Args:
        definitions: A dictionary containing the static definitions of prompts
            and tools found in the codebase.
        executions: A set of identifiers representing the code paths that were
            executed during the run.
        output_dir: The directory where the HTML report should be saved.
            Defaults to "prompt_html".
        decision_config: Configuration object containing business logic decisions
            (YAML based) to be tracked.
        decision_hits: A dictionary mapping decision IDs to sets of observed values,
            used to calculate coverage of expected values.
        writer_func: An optional callable to handle file writing. If None,
            defaults to writing to the local disk.
        timestamp: An optional timestamp (float) to use for the report generation time.
            If None, the current time is used.

    Returns:
        The absolute path to the output directory where the report was generated.
    """
    if writer_func is None:
        writer_func = _default_file_writer

    # Create actual directory only if using the default writer
    if writer_func == _default_file_writer:
        if not os.path.exists(output_dir):
            os.makedirs(output_dir)

    # Use injected or current timestamp
    time_str = format_iso_time(timestamp)

    # --- 1. PROMPT DATA PREP ---
    prompts = {k: v for k, v in definitions.items() if v.get("type") == "PROMPT"}

    # --- 2. TOOLS DATA PREP ---
    tools = {k: v for k, v in definitions.items() if v.get("type") == "TOOL"}

    # --- HTML GENERATION ---

    # A. PROMPT SECTION
    prompts_html, prompts_summary = _render_file_table(prompts, executions, "Prompts")

    # B. DECISION SECTION
    yaml_html = ""
    yaml_stats = {"total": 0, "covered": 0}
    if decision_config and decision_config.decisions:
        rows = ""
        for dec in decision_config.decisions:
            hits = decision_hits.get(dec.id, set()) if decision_hits else set()

            val_html = ""
            covered_count = 0
            for val in dec.expected_values:
                is_hit = str(val) in hits
                css = "hit" if is_hit else "miss"
                val_html += f'<span class="tag {css}">{val}</span>'
                if is_hit:
                    covered_count += 1

            yaml_stats["total"] += len(dec.expected_values)
            yaml_stats["covered"] += covered_count

            row_pct = (
                (covered_count / len(dec.expected_values) * 100)
                if dec.expected_values
                else 0
            )
            color = "green" if row_pct == 100 else "orange" if row_pct > 50 else "red"

            rows += f"""
            <tr>
                <td><strong>{dec.id}</strong><br><small style="color:#999">{dec.description}</small></td>
                <td><code>{dec.target_field}</code></td>
                <td>
                    <div class="bar-container"><div class="bar {color}" style="width: {row_pct}%"></div></div>
                    <span class="num">{int(row_pct)}%</span>
                </td>
                <td>{val_html}</td>
            </tr>
            """

        yaml_html = f"""
        <h3>🧠 Business Logic (Configured)</h3>
        <table>
            <thead>
                <tr>
                    <th width="30%">Decision ID</th>
                    <th width="15%">Field</th>
                    <th width="20%">Coverage</th>
                    <th>Expected Values</th>
                </tr>
            </thead>
            <tbody>{rows}</tbody>
        </table>
        """

    # B2. Tools Code Table
    tools_html, tools_summary = _render_file_table(tools, executions, "Internal Tools")

    # B3. Unified Decision Summary
    tot_dec = yaml_stats["total"] + tools_summary["total"]
    cov_dec = yaml_stats["covered"] + tools_summary["executed"]
    dec_pct = (cov_dec / tot_dec * 100) if tot_dec > 0 else 0

    decision_summary_card = f"""
    <div class="summary-card card-decision">
        <strong>🧭 Decision Coverage:</strong>
        <span class="badge {"green" if dec_pct > 80 else "red"}">{dec_pct:.1f}%</span>
        <span style="margin-left: 20px; color:#666;">
            (Business Logic: {yaml_stats["covered"]}/{yaml_stats["total"]} items) +
            (Internal Tools: {tools_summary["executed"]}/{tools_summary["total"]} calls)
        </span>
    </div>
    """

    prompt_pct = (
        (prompts_summary["executed"] / prompts_summary["total"] * 100)
        if prompts_summary["total"] > 0
        else 0
    )
    prompt_summary_card = f"""
    <div class="summary-card card-prompt">
        <strong>📝 Prompt Coverage:</strong>
        <span class="badge {"green" if prompt_pct > 80 else "red"}">{prompt_pct:.1f}%</span>
        <span style="margin-left: 20px; color:#666;">
            {prompts_summary["executed"]} / {prompts_summary["total"]} items covered
        </span>
    </div>
    """

    # --- FINAL ASSEMBLY ---
    html_content = f"""
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>Agent Coverage Report</title>
        <style>{CSS}</style>
    </head>
    <body>
        <h1>🛡️ Agent Coverage Report</h1>
        <div class="meta">Generated on {time_str}</div>

        {prompt_summary_card}
        {decision_summary_card}

        <h2>📝 Prompt Details</h2>
        {prompts_html if prompts_summary["total"] > 0 else "<p>No prompts detected.</p>"}

        <h2>🧭 Decision Details</h2>
        {yaml_html}
        {tools_html if tools_summary["total"] > 0 else ""}

        {"<p>No decisions or tools detected.</p>" if (not yaml_html and tools_summary["total"] == 0) else ""}

    </body>
    </html>
    """

    out_path = os.path.join(output_dir, "index.html")
    writer_func(out_path, html_content)

    return os.path.abspath(output_dir)