By Contexory

Regression Risk Mapper

Maps what a change can reach — a change being considered as readily as one already made, since the question is usually asked before the edit exists. Finds the symbols involved, traces every call site and importer across the repository, and marks which of those call sites are exercised by a test and which are not. Use when the user asks what something might break, what depends on a symbol they are about to modify, rename or change the return type of, how far the blast radius of a refactor extends, or whether an edit is safe to make. Not for finding usages as an end in itself — with no change in prospect, that is a plain search and answering it with a risk analysis is more than was asked.

1 supporting file

Regression risk mapper

The diff shows what you changed. It does not show who was relying on it. This skill answers the second question, and it answers it with call sites rather than with intuition.

The failure it prevents is specific and common: a signature or behaviour is changed with its two obvious callers updated, and the third caller — in another package, reached through a re-export, written before anyone currently on the team joined — is discovered in production.

Procedure

1. Map the radius

<skill-dir> is the directory this SKILL.md was loaded from — the skill installs outside your project, so its script is named by full path, never relatively.

python3 <skill-dir>/scripts/blast_radius.py             # working tree vs merge-base with main
python3 <skill-dir>/scripts/blast_radius.py origin/main # or an explicit base

For every symbol the diff modifies, the script finds every file referencing it, marks each reference direct or re-export, and marks whether the referencing file has a corresponding test file. It prints a per-symbol radius, ordered by number of call sites.

Read the caveat it prints. The search is textual. It cannot see dynamic dispatch, string-keyed lookup, reflection, or a caller in another repository, and it says so — those are the paths that stay invisible and they are exactly where the expensive surprises live.

2. Separate signature changes from behaviour changes

They fail differently and need different checks:

  • Signature change — the compiler or type checker finds the callers for you. Run the

    type check and trust it. The risk here is the untyped edge: JSON boundaries, dynamic imports, plugin entry points, anything crossing a process.

  • Behaviour change — nothing finds the callers for you. Same signature, different

    result: a changed default, a different sort order, a null where an empty array used to be, a function that now throws. This is where the radius map earns its keep, and every call site has to be read.

Say which of the two you are dealing with before going further. If it is both, treat it as behaviour.

3. Read the uncovered call sites first

The script marks call sites in files with no paired test. Those are the ones where a regression ships silently. Read each and ask the narrow question: does this caller depend on the thing that changed, or does it merely use the same symbol?

4. Follow re-exports one hop further

A barrel file that re-exports a changed symbol turns the radius into everything importing the barrel. The script marks these; expand them once, and say plainly if the result is too large to enumerate rather than pretending to have checked it.

5. Name the specific regression, or say there is none

The output is not "this is risky". It is "renderInvoice in billing/pdf.ts assumes this returns a sorted array, and it no longer does" — or an explicit "no caller depends on the changed behaviour", which is a real and useful answer.

Output

  1. Change type — signature, behaviour, or both
  2. Radius — symbols changed, call sites reached, how many are untested
  3. Specific risks — file, line, and the assumption that no longer holds
  4. Invisible edges — dynamic dispatch, cross-repo consumers, serialized boundaries the

    text search cannot reach

  5. Verdict — what to test before merging, or "nothing depends on this"

What this skill deliberately does not do

  • It does not claim the map is complete. A textual search has known blind spots, and

    the report names them every time rather than in a footnote. A blast-radius tool that implies completeness is more dangerous than none.

  • It does not fix the callers. It reports; changing them is a separate decision with

    its own review.

  • It does not flag every reference as a risk. A file that imports a changed symbol but

    does not touch the changed behaviour is not a finding, and reporting it as one buries the two that matter.

  • It does not run the test suite to find out. That is slower than reading, and a green

    suite would not prove the uncovered call sites are safe — it would prove they are uncovered.

  • It does not extend past one re-export hop without saying so.

When this is the wrong tool

  • The failure has already happened and you have a trace. Start there — a real frame

    beats a predicted radius every time.

  • You want the change reviewed on its own terms, inside the diff. That is a review;

    this skill's whole job is to leave the diff.

  • The change is a dependency version rather than your own code. The changed surface is

    someone else's, and reading their changelog against your call sites is a different procedure.

Supporting files

1 file

scripts/blast_radius.pyPython11 KB
#!/usr/bin/env python3
"""Find everything that references the symbols a diff touched.

The mechanical half of "what could this break": which symbols moved, who names
them, and which of those callers sit in files with no test. Whether a caller
actually depends on the changed *behaviour* is a reading task, and the script
says so rather than implying its list is a risk assessment.

    python3 blast_radius.py [base-ref]

Textual search, with the blind spots that implies — dynamic dispatch, string-keyed
lookup, reflection and consumers in other repositories are invisible here, and the
report repeats that every run. Standard library only.
"""

from __future__ import annotations

import os
import re
import subprocess
import sys
from collections import defaultdict

SOURCE_RE = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|cs)$")
TEST_RE = re.compile(r"(^|/)(tests?|__tests__|spec)/|\.(test|spec)\.[jt]sx?$|_test\.(py|go)$|test_.*\.py$")
SKIP_RE = re.compile(r"(^|/)(node_modules|dist|build|\.next|coverage|vendor|__pycache__)(/|$)")

# Symbols whose definition line changed, restricted to the *public* surface.
#
# An earlier version matched any `const x =`, which pulled every local in every
# changed test body into the map — `spy`, `res`, `rows`, `elapsed` — and then
# reported a thousand references for each. That is not a blast radius, it is a
# concordance, and a reader who sees it once stops reading the tool's output.
#
# Two rules keep it honest: the definition must be exported (or top-level, for
# languages where export is implicit), and it must sit at column zero, so a
# nested helper inside a function body is never mistaken for API.
DEFINITION_RE = re.compile(
    r"^[+-](?:export\s+(?:default\s+)?(?:async\s+)?"
    r"(?:function|const|let|var|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)"
    r"|pub\s+(?:fn|struct|trait|enum)\s+([A-Za-z_][\w]*)"
    r"|def\s+([A-Za-z_][\w]*)"
    r"|class\s+([A-Za-z_][\w]*)"
    r"|func\s+(?:\([^)]*\)\s*)?([A-Z][\w]*))"
)

# Languages with no export keyword need a second gate, or the rule above admits
# nothing and everything: Python has no `export`, so *every* module-level `def`
# reads as public API. On this pack's own scripts that meant 47 "changed
# symbols" — `git`, `walk`, `parse`, `report`, `usage`, `collect` — whose top
# five each blew past NOISE_THRESHOLD and printed "too common to map" instead of
# a radius. TOO_COMMON cannot keep up; the words are ordinary.
#
# The honest test for public is whether anything imports it by name. A helper
# nobody imports has no blast radius outside its own file by definition, which
# is exactly the question this script answers.
NEEDS_IMPORT_PROOF = (".py",)
IMPORTED_NAME_RE = re.compile(
    r"^\s*from\s+[\w.]+\s+import\s+(.+)$|^\s*import\s+([\w.]+)\s*(?:as\s+\w+)?\s*$"
)

# Past this many references a symbol is a common word rather than an API, and
# listing its call sites is worse than saying so.
NOISE_THRESHOLD = 200
RE_EXPORT_RE = re.compile(r"^\s*export\s+(?:\*|\{[^}]*\})\s+from\s+")

# Identifiers common enough that their call sites are noise rather than a radius.
TOO_COMMON = {
    "main", "run", "get", "set", "init", "handler", "index", "config", "options",
    "data", "value", "result", "error", "test", "setup", "teardown", "props", "state",
}


def git(args: list[str]) -> str:
    out = subprocess.run(["git", *args], capture_output=True, text=True)
    return out.stdout if out.returncode == 0 else ""


def diff_path(header: str) -> str | None:
    """Path from a `---`/`+++` header line, or None for `/dev/null`.

    A **deleted** file's `+++` is `/dev/null` and its only name is on the `---`
    line. Reading `+++ b/` alone leaves the parser pointed at the previous file,
    which defeats the test-file exclusion below: the deleted file's name never
    reaches `TEST_RE`, so symbols defined only in a deleted test are mapped as
    changed API — exactly the concordance the guard exists to prevent.
    """
    rest = header.strip()
    if rest == "/dev/null":
        return None
    return rest[2:] if rest[:2] in ("a/", "b/") else rest


def default_base() -> str:
    for branch in ("origin/main", "main", "origin/master", "master"):
        base = git(["merge-base", "HEAD", branch]).strip()
        if base:
            return base
    return "HEAD~1"


def changed_symbols(base: str) -> tuple[set[str], set[str]]:
    """(symbols whose definitions changed, files the diff touched)."""
    symbols: set[str] = set()
    files: set[str] = set()
    current: str | None = None
    old_path: str | None = None
    in_hunks = False

    for line in git(["diff", "--unified=0", base, "--"]).splitlines():
        # Headers are read only before a file's first `@@`, so a removed line
        # whose own text begins with `---` cannot be mistaken for one.
        if line.startswith("diff --git "):
            current, old_path, in_hunks = None, None, False
            continue
        if not in_hunks:
            if line.startswith("--- "):
                old_path = diff_path(line[4:])
            elif line.startswith("+++ "):
                current = diff_path(line[4:]) or old_path
                if current and SOURCE_RE.search(current):
                    files.add(current)
            elif line.startswith("@@"):
                in_hunks = True
            continue
        # A symbol defined in a test file is test scaffolding, not API. Its
        # callers are the test itself, and mapping them tells the reader nothing
        # about what production code can break.
        if not current or not SOURCE_RE.search(current) or TEST_RE.search(current):
            continue
        m = DEFINITION_RE.match(line)
        if m:
            name = next((g for g in m.groups() if g), None)
            if name and name.lower() not in TOO_COMMON and len(name) > 2:
                symbols.add((name, current))
    return symbols, files


def imported_names(root: str) -> set[str]:
    """Every identifier some file imports by name.

    The public-surface test for languages with no export keyword — see
    NEEDS_IMPORT_PROOF. Collected once over the whole tree, because a symbol is
    public if *anyone* imports it, not if its own module says so.
    """
    found: set[str] = set()
    for rel in walk_sources(root):
        try:
            with open(rel, encoding="utf-8", errors="replace") as handle:
                lines = handle.readlines()
        except OSError:
            continue
        for line in lines:
            m = IMPORTED_NAME_RE.match(line)
            if not m:
                continue
            clause = m.group(1) or m.group(2) or ""
            for token in re.split(r"[,\s()]+", clause):
                token = token.strip().strip("\\")
                if token and token not in ("as", "import", "from", "*"):
                    found.add(token.split(".")[-1])
    return found


def walk_sources(root: str) -> list[str]:
    out: list[str] = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
        for name in filenames:
            rel = os.path.relpath(os.path.join(dirpath, name), root).replace("\\", "/")
            if SOURCE_RE.search(rel) and not SKIP_RE.search(f"/{rel}"):
                out.append(rel)
    return out


def has_paired_test(rel: str, all_files: set[str]) -> bool:
    if TEST_RE.search(rel):
        return True
    stem, _ = os.path.splitext(rel)
    return any(
        f"{stem}{suffix}" in all_files
        for suffix in (".test.ts", ".test.tsx", ".spec.ts", ".test.js", ".spec.js", "_test.py", "_test.go")
    )


def main() -> int:
    base = sys.argv[1] if len(sys.argv) > 1 else default_base()
    root = git(["rev-parse", "--show-toplevel"]).strip() or os.getcwd()
    os.chdir(root)

    defined, touched = changed_symbols(base)

    # Apply the second gate only where the language needs it, and only once the
    # tree has been scanned — a Python `def` nobody imports is a private helper,
    # whatever its indentation says.
    if any(path.endswith(NEEDS_IMPORT_PROOF) for _, path in defined):
        importable = imported_names(root)
        symbols = {
            name for name, path in defined
            if not path.endswith(NEEDS_IMPORT_PROOF) or name in importable
        }
        dropped = len(defined) - len(symbols)
    else:
        symbols = {name for name, _ in defined}
        dropped = 0

    print(f"base: {base}")
    if dropped:
        print(
            f"({dropped} changed definition(s) in export-less languages are imported "
            "nowhere — private helpers, no radius outside their own file)"
        )
    if not symbols:
        print(
            "No changed symbol definitions found in this diff.\n"
            "The change may be behaviour-only inside existing functions — in which case "
            "map the radius of the enclosing function by name instead."
        )
        return 0

    print(f"{len(symbols)} changed symbol(s): {', '.join(sorted(symbols))}\n")

    sources = walk_sources(root)
    all_files = set(sources)
    radius: dict[str, list[tuple[str, int, str]]] = defaultdict(list)

    patterns = {s: re.compile(rf"\b{re.escape(s)}\b") for s in symbols}
    for rel in sources:
        if rel in touched:
            continue
        try:
            with open(rel, encoding="utf-8", errors="replace") as handle:
                lines = handle.readlines()
        except OSError:
            continue
        for i, line in enumerate(lines, 1):
            for sym, pattern in patterns.items():
                if pattern.search(line):
                    kind = "re-export" if RE_EXPORT_RE.match(line) else "direct"
                    radius[sym].append((rel, i, kind))

    for sym in sorted(symbols, key=lambda s: -len(radius[s])):
        sites = radius[sym]
        print(f"── {sym}: {len(sites)} reference(s) outside the diff ──")
        if not sites:
            print("   none. Nothing outside this diff names it.\n")
            continue
        if len(sites) > NOISE_THRESHOLD:
            print(
                f"   too common to map — {len(sites)} references means this name is a "
                "word, not an interface.\n   Narrow it by hand (search the import, not "
                "the identifier) if the change is behavioural.\n"
            )
            continue
        untested = 0
        for rel, line_no, kind in sites[:25]:
            tested = has_paired_test(rel, all_files)
            if not tested:
                untested += 1
            mark = "  " if tested else "!!"
            print(f"   {mark} [{kind:>9}] {rel}:{line_no}")
        if len(sites) > 25:
            print(f"      … and {len(sites) - 25} more")
        print(f"   {untested} of the shown call sites are in files with no paired test\n")

    print(
        "!! marks a call site with no paired test — a regression there ships silently.\n"
        "\nBlind spots this search cannot cover, every run: dynamic dispatch, "
        "string-keyed lookup, reflection, serialized boundaries, and consumers in other "
        "repositories. Say so in your report; do not present this map as complete."
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())