Skip to content

Impact

impact

ImpactTool — change impact analysis.

ImpactTool

Bases: AXMTool

Analyze blast radius of changing a symbol.

Registered as ast_impact via axm.tools entry point. Workspace-aware: if path is a uv workspace root, analyzes impact across all member packages.

Source code in packages/axm-ast/src/axm_ast/tools/impact.py
Python
class ImpactTool(AXMTool):
    """Analyze blast radius of changing a symbol.

    Registered as ``ast_impact`` via axm.tools entry point.
    Workspace-aware: if path is a uv workspace root, analyzes
    impact across all member packages.
    """

    @property
    def name(self) -> str:
        """Return tool name for registry lookup."""
        return "ast_impact"

    @safe_execute
    def execute(
        self,
        *,
        path: str = ".",
        symbol: str | None = None,
        symbols: list[str] | None = None,
        exclude_tests: bool = False,
        detail: str | None = None,
        **kwargs: object,
    ) -> ToolResult:
        """Analyze change impact for a symbol.

        Args:
            path: Path to package or workspace directory.
            symbol: Symbol name to analyze (required if symbols is not provided).
            symbols: Optional list of symbol names for batch inspection.
            exclude_tests: If True, exclude test files from impact analysis.
            detail: Output detail level. Use ``"compact"`` for a markdown
                table summary instead of the full JSON dict.
            **kwargs: Extra options. ``test_filter`` (``"none"``,
                ``"all"``, ``"related"``) controls test caller filtering.

        Returns:
            ToolResult with impact analysis.
        """
        if not symbol and not symbols:
            return ToolResult(success=False, error="symbol parameter is required")

        project_path = Path(path).resolve()
        if not project_path.is_dir():
            raise NotADirectoryError(f"Not a directory: {project_path}")

        raw_filter = kwargs.get("test_filter")
        test_filter: str | None = raw_filter if isinstance(raw_filter, str) else None
        tf: dict[str, str | None] = (
            {"test_filter": test_filter} if test_filter is not None else {}
        )

        if symbols is not None:
            return self._execute_batch(
                project_path,
                symbols,
                exclude_tests,
                detail,
                **tf,
            )

        if symbol is None:
            return ToolResult(success=False, error="symbol parameter is required")
        return self._execute_single(
            project_path,
            symbol,
            exclude_tests,
            detail,
            **tf,
        )

    def _execute_batch(
        self,
        project_path: Path,
        symbols: list[str],
        exclude_tests: bool,
        detail: str | None,
        *,
        test_filter: str | None = None,
    ) -> ToolResult:
        """Run batch impact analysis for multiple symbols.

        In compact mode, returns formatted markdown via ``ToolResult.text``
        with an empty ``data`` dict. In full mode, returns per-symbol dicts
        under ``data["symbols"]``.
        """
        if not isinstance(symbols, list):
            return ToolResult(success=False, error="symbols parameter must be a list")
        if not symbols:
            return ToolResult(success=False, error="symbols list must not be empty")
        results: list[ImpactResult] = []
        for sym in symbols:
            tf: dict[str, str | None] = (
                {"test_filter": test_filter} if test_filter is not None else {}
            )
            results.append(
                self._analyze_single(
                    project_path,
                    sym,
                    exclude_tests=exclude_tests,
                    **tf,
                )
            )
        if detail == "compact":
            return ToolResult(
                success=True,
                data={},
                text=format_impact_compact(cast("list[Mapping[str, object]]", results)),
            )
        text = self._render_batch_text(results)
        return ToolResult(
            success=True,
            data={"symbols": cast("list[dict[str, object]]", results)},
            text=text,
        )

    @staticmethod
    def _render_batch_text(results: list[ImpactResult]) -> str | None:
        """Render batch text from results, returning *None* on failure."""
        try:
            if any("score" in r or "callers" in r for r in results):
                return render_impact_batch_text(results)
        except (KeyError, TypeError):
            pass
        return None

    def _execute_single(
        self,
        project_path: Path,
        symbol: str,
        exclude_tests: bool,
        detail: str | None,
        *,
        test_filter: str | None = None,
    ) -> ToolResult:
        """Run single-symbol impact analysis with optional compact output.

        In compact mode, returns formatted markdown via ``ToolResult.text``
        with an empty ``data`` dict. Otherwise delegates to
        ``_analyze_single_result``.
        """
        tf = {"test_filter": test_filter} if test_filter is not None else {}
        if detail == "compact":
            result = self._analyze_single(
                project_path,
                symbol,
                exclude_tests=exclude_tests,
                **tf,
            )
            return ToolResult(
                success=True,
                data={},
                text=format_impact_compact(result),
            )
        return self._analyze_single_result(
            project_path,
            symbol,
            exclude_tests=exclude_tests,
            **tf,
        )

    def _analyze_single(
        self,
        project_path: Path,
        symbol: str,
        *,
        exclude_tests: bool = False,
        test_filter: str | None = None,
    ) -> ImpactResult:
        """Run impact analysis for a single symbol.

        Returns:
            Impact dict with ``score`` key on success,
            or ``{"symbol": name, "error": msg}`` on failure.
        """
        try:
            try:
                from axm_ast.core.impact import analyze_impact_workspace

                impact = analyze_impact_workspace(
                    project_path,
                    symbol,
                    exclude_tests=exclude_tests,
                    test_filter=test_filter,
                )
            except ValueError:
                from axm_ast.core.impact import analyze_impact

                impact = analyze_impact(
                    project_path,
                    symbol,
                    exclude_tests=exclude_tests,
                    test_filter=test_filter,
                )

            if impact.get("definition") is None:
                return cast(
                    "ImpactResult",
                    {"symbol": symbol, "error": f"Symbol '{symbol}' not found"},
                )
            return impact
        except Exception as exc:  # noqa: BLE001 — final boundary
            return cast(
                "ImpactResult",
                log_and_fallback(logger, exc, {"symbol": symbol, "error": str(exc)}),
            )

    def _analyze_single_result(
        self,
        project_path: Path,
        symbol: str,
        *,
        exclude_tests: bool = False,
        test_filter: str | None = None,
    ) -> ToolResult:
        """Run single-symbol impact analysis and return a ToolResult."""
        tf: dict[str, str | None] = (
            {"test_filter": test_filter} if test_filter is not None else {}
        )
        result = self._analyze_single(
            project_path,
            symbol,
            exclude_tests=exclude_tests,
            **tf,
        )
        if "error" in result:
            err = result.get("error", "")
            return ToolResult(
                success=False,
                error=err if isinstance(err, str) else str(err),
            )
        try:
            text: str | None = render_impact_text(result)
        except (KeyError, TypeError):
            text = None
        return ToolResult(
            success=True,
            data=cast("dict[str, object]", result),
            text=text,
        )
name property

Return tool name for registry lookup.

execute(*, path='.', symbol=None, symbols=None, exclude_tests=False, detail=None, **kwargs)

Analyze change impact for a symbol.

Parameters:

Name Type Description Default
path str

Path to package or workspace directory.

'.'
symbol str | None

Symbol name to analyze (required if symbols is not provided).

None
symbols list[str] | None

Optional list of symbol names for batch inspection.

None
exclude_tests bool

If True, exclude test files from impact analysis.

False
detail str | None

Output detail level. Use "compact" for a markdown table summary instead of the full JSON dict.

None
**kwargs object

Extra options. test_filter ("none", "all", "related") controls test caller filtering.

{}

Returns:

Type Description
ToolResult

ToolResult with impact analysis.

Source code in packages/axm-ast/src/axm_ast/tools/impact.py
Python
@safe_execute
def execute(
    self,
    *,
    path: str = ".",
    symbol: str | None = None,
    symbols: list[str] | None = None,
    exclude_tests: bool = False,
    detail: str | None = None,
    **kwargs: object,
) -> ToolResult:
    """Analyze change impact for a symbol.

    Args:
        path: Path to package or workspace directory.
        symbol: Symbol name to analyze (required if symbols is not provided).
        symbols: Optional list of symbol names for batch inspection.
        exclude_tests: If True, exclude test files from impact analysis.
        detail: Output detail level. Use ``"compact"`` for a markdown
            table summary instead of the full JSON dict.
        **kwargs: Extra options. ``test_filter`` (``"none"``,
            ``"all"``, ``"related"``) controls test caller filtering.

    Returns:
        ToolResult with impact analysis.
    """
    if not symbol and not symbols:
        return ToolResult(success=False, error="symbol parameter is required")

    project_path = Path(path).resolve()
    if not project_path.is_dir():
        raise NotADirectoryError(f"Not a directory: {project_path}")

    raw_filter = kwargs.get("test_filter")
    test_filter: str | None = raw_filter if isinstance(raw_filter, str) else None
    tf: dict[str, str | None] = (
        {"test_filter": test_filter} if test_filter is not None else {}
    )

    if symbols is not None:
        return self._execute_batch(
            project_path,
            symbols,
            exclude_tests,
            detail,
            **tf,
        )

    if symbol is None:
        return ToolResult(success=False, error="symbol parameter is required")
    return self._execute_single(
        project_path,
        symbol,
        exclude_tests,
        detail,
        **tf,
    )

format_impact_compact(impact)

Format impact analysis as a compact markdown table.

Accepts either a single impact dict or a list of per-symbol reports. When given a list, each symbol gets its own row with per-symbol callers.

Parameters:

Name Type Description Default
impact Mapping[str, object] | list[Mapping[str, object]]

Single impact dict or list of per-symbol impact dicts.

required

Returns:

Type Description
str

Markdown string with symbol table, caller details, and test footer.

Source code in packages/axm-ast/src/axm_ast/tools/impact.py
Python
def format_impact_compact(
    impact: Mapping[str, object] | list[Mapping[str, object]],
) -> str:
    """Format impact analysis as a compact markdown table.

    Accepts either a single impact dict or a list of per-symbol reports.
    When given a list, each symbol gets its own row with per-symbol callers.

    Args:
        impact: Single impact dict or list of per-symbol impact dicts.

    Returns:
        Markdown string with symbol table, caller details, and test footer.
    """
    if isinstance(impact, list):
        score = _max_score(impact)
        return format_impact_compact_multi(impact, score)

    # Single-report dict path
    score_raw = impact.get("score", "UNKNOWN")
    score = score_raw if isinstance(score_raw, str) else "UNKNOWN"
    return format_impact_compact_multi([impact], score)

format_impact_compact_multi(reports, score)

Format multiple impact reports as a compact table with per-symbol callers.

Each symbol gets its own row with its own Prod / Direct tests / Indirect tests columns. Each row displays the per-symbol score from the report.

Parameters:

Name Type Description Default
reports list[Mapping[str, object]]

Individual per-symbol impact dicts.

required
score str

Kept for backward compatibility (not used for row display).

required

Returns:

Type Description
str

Markdown table string.

Source code in packages/axm-ast/src/axm_ast/tools/impact.py
Python
def format_impact_compact_multi(
    reports: list[Mapping[str, object]],
    score: str,
) -> str:
    """Format multiple impact reports as a compact table with per-symbol callers.

    Each symbol gets its own row with its own Prod / Direct tests / Indirect
    tests columns.  Each row displays the per-symbol score from the report.

    Args:
        reports: Individual per-symbol impact dicts.
        score: Kept for backward compatibility (not used for row display).

    Returns:
        Markdown table string.
    """
    lines: list[str] = [
        "| Symbol | Location | Score | Prod | Direct tests | Indirect tests |",
        "|---|---|---|---|---|---|",
    ]
    for report in reports:
        row_score_raw = report.get("score", "LOW")
        row_score = row_score_raw if isinstance(row_score_raw, str) else "LOW"
        lines.append(_format_symbol_row(report, row_score))

    # Aggregate test_files across all reports
    seen: set[str] = set()
    all_test_files: list[str] = []
    for report in reports:
        report_tfs = report.get("test_files", [])
        if not isinstance(report_tfs, list):
            continue
        for tf in report_tfs:
            if isinstance(tf, str) and tf not in seen:
                seen.add(tf)
                all_test_files.append(tf)
    lines.append("")
    lines.append(f"Tests: {_format_test_files_compact(all_test_files)}")
    return "\n".join(lines)

render_impact_batch_text(reports)

Render multiple impact reports as human-readable text.

Source code in packages/axm-ast/src/axm_ast/tools/impact_text.py
Python
def render_impact_batch_text(reports: list[ImpactResult]) -> str:
    """Render multiple impact reports as human-readable text."""
    if not reports:
        return ""

    try:
        score_order = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
        best = "LOW"
        for r in reports:
            s = r.get("score", "LOW")
            if score_order.get(s, 0) > score_order.get(best, 0):
                best = s
        header = f"ast_impact | {len(reports)} symbols | max={best}"
        sections: list[str] = [header]
        for r in reports:
            symbol = r.get("symbol", "?")
            score = r.get("score", "UNKNOWN")
            section_header = f"## {symbol} | {score}"
            body = _render_impact_single(r)
            body_lines = body.split("\n")[1:]
            sections.append(section_header + "\n" + "\n".join(body_lines))
        return "\n\n".join(sections)
    except (KeyError, TypeError, AttributeError):
        return ""

render_impact_text(report)

Render a single impact report as human-readable text.

Source code in packages/axm-ast/src/axm_ast/tools/impact_text.py
Python
def render_impact_text(report: ImpactResult) -> str:
    """Render a single impact report as human-readable text."""
    try:
        return _render_impact_single(report)
    except (KeyError, TypeError, AttributeError):
        symbol = report.get("symbol", "?") if isinstance(report, dict) else "?"
        return f"ast_impact | {symbol} | render error"