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 baseFor 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
- Change type — signature, behaviour, or both
- Radius — symbols changed, call sites reached, how many are untested
- Specific risks — file, line, and the assumption that no longer holds
- Invisible edges — dynamic dispatch, cross-repo consumers, serialized boundaries the
text search cannot reach
- 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
scripts/blast_radius.pyPython
#!/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())
More skills from Contexory
- Doc Drift DetectorChecks a document's checkable claims against the code it describes — file paths that no longer exist, commands that are no longer defined, flags and symbols that have been renamed, version numbers…
- Flaky Test FinderProves whether a test is flaky instead of reasoning about it, by re-running it many times and reporting the observed failure rate, then narrowing the cause to ordering, shared state, timing or…
- Repo Onboarding MapProduces the first-thirty-minutes map of a whole repository — how it is laid out, how to run and test it, which files carry the most change, and where the decisions were written down. Reads what the…
- Test Gap FinderFinds the untested code that actually matters, by ranking coverage gaps against how often each file changes. Reads an existing coverage report when there is one and falls back to structural pairing…
- Pr Self ReviewReviews your own diff before anyone else has to. Inventories every changed file, finds the ones that gained behaviour without gaining a test, flags newly exported surface, and separates what the diff…
- Error TriageLocates the cause of a runtime failure inside this repository. Maps stack frames to real source files, separates first-party code from vendored frames, and surfaces the recent changes to the line…