Error triage
A stack trace names the frame where the process gave up, which is rarely the frame that caused the failure. This skill finds the second one.
Procedure
Work in order. Do not skip to step 4.
1. Map the trace to real files
Run the mapper on the trace. It accepts a file path or - for stdin, and it works on
Node, Python, Go and Java formats:
<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/trace_map.py - # paste the trace on stdin
python3 <skill-dir>/scripts/trace_map.py /tmp/ci.log # or read it from a fileIt prints every frame in order, marks each as project or vendor, drops frames whose
file does not exist on disk, and labels the first surviving project frame OWNING. It
then runs git blame on that line and git log on that file.
If the mapper resolves no project frames at all, say so and stop the mechanical part — the trace is from a different build of this code, or from a dependency's internals. Ask for the commit the trace came from rather than guessing at frames that do not exist.
2. Read the owning frame, and the two frames either side of it
Read the actual source. The trace tells you a line number; it does not tell you what the line assumed. What you are looking for is the assumption that no longer holds — a value that can now be null, a shape that changed, an order that is no longer guaranteed.
3. Check whether the owning line is new
The mapper already printed git blame for the line and the last three commits touching
the file. A failure in a line that changed this week has a different explanation from one
that has been stable for two years and only started failing now. If the line is old, the
thing that changed is upstream of it — its input changed, not its logic. Follow the
input.
4. Look for the same failure elsewhere before proposing anything
Grep the repository for the error's distinctive text and for the symbol in the owning frame. Three things are worth finding:
- an existing test that covers this path, which tells you what behaviour was intended
- an existing
catchor guard for this exact case somewhere else, which tells you theteam already knows about it and has a house pattern for it
- a second call site with the same shape, which tells you the fix belongs one level up
5. Reproduce before you propose
State the smallest command that reproduces it, and run it. If you cannot reproduce it, say that plainly and give the two or three candidate causes ranked, with what would distinguish them. A confident fix for an unreproduced error is the failure mode this skill exists to prevent.
Output
Report in this order, and keep it short:
- Owning frame —
path:line, and the one-sentence reason it is the owning framerather than the top frame
- What broke — the assumption that stopped holding
- When it started — from blame and log, or "unchanged for N months, so the input
changed"
- Reproduction — the command, and whether it actually reproduced
- Fix — only if step 5 succeeded. Otherwise: ranked candidates and the distinguishing
test
What this skill deliberately does not do
The precision floor matters more than coverage here. A triage tool that confidently names the wrong line is worse than no tool, because it sends the reader somewhere specific.
- It does not diagnose from the top frame alone. If every project frame is filtered
out, it reports that instead of falling back to the vendor frame.
- It does not propose edits inside vendored code.
node_modules,site-packages,vendor/and the standard library are read-only context. A fix there is a version constraint or a call-site change, never an edit. - It does not rewrite the error handling it passes through. Adding a
try/catchtomake a symptom disappear is out of scope, and is usually the wrong fix.
- It does not guess at line numbers when the trace is minified and no source map
resolves. It says the trace is unusable and asks for one from a non-minified build.
- It does not touch the failing code before reproducing it.
When this is the wrong tool
- The failure is a test that passes on re-run. That is intermittency, and reasoning
from the source is actively misleading — measure the rate before diagnosing anything.
- The failure appeared right after a dependency bump. Read that dependency's changelog
against the APIs you actually import first; a changed default explains a whole class of new failures faster than a trace will.
- There is no error yet and you want to know what a diff might break. That is a
blast-radius question. Triage firing on it would invent a failure to explain.
Supporting files
scripts/trace_map.pyPython
#!/usr/bin/env python3
"""Map a stack trace onto files that actually exist in this repository.
The mechanical half of triage: which frames are yours, which are vendored, which
resolve to a real file, and what git knows about the first one that does. The
judgement half — *why* that line broke — is left to the reader, deliberately.
Reads a trace from a file argument or from stdin when passed `-`. Recognises
Node/V8, Python, Go and Java frame formats; a trace mixing several (a Node
process logging a Python subprocess failure) is handled frame by frame.
Only the standard library, so it runs anywhere python3 does.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from dataclasses import dataclass
# Frame formats, in the order we try them. Each pattern must yield `file` and
# `line` groups; `fn` is optional and only used for display.
FRAME_PATTERNS = [
# V8: "at fn (/abs/file.ts:12:34)" / "at /abs/file.js:12:34" / "at fn (file.ts:12)"
re.compile(
r"^\s*at\s+(?:(?P<fn>.+?)\s+\()?(?P<file>[^()\s]+?):(?P<line>\d+)(?::\d+)?\)?\s*$"
),
# CPython: ' File "/abs/file.py", line 42, in fn'
re.compile(
r'^\s*File\s+"(?P<file>[^"]+)",\s+line\s+(?P<line>\d+)(?:,\s+in\s+(?P<fn>.+))?\s*$'
),
# JVM: "at com.foo.Bar.baz(Bar.java:42)"
re.compile(r"^\s*at\s+(?P<fn>[\w$.]+)\((?P<file>[\w$.]+\.java):(?P<line>\d+)\)\s*$"),
# Go: "\t/abs/file.go:42 +0x1d" — the function is on the preceding line, which
# we do not need, so it is not captured.
re.compile(r"^\s+(?P<file>\/[^\s:]+\.go):(?P<line>\d+)(?:\s+\+0x[0-9a-f]+)?\s*$"),
]
# A frame is vendored if its path contains any of these. `node:` and `<anonymous>`
# are runtime-internal rather than on-disk, and are filtered the same way.
VENDOR_MARKERS = (
"node_modules",
".pnpm",
"site-packages",
"dist-packages",
"/vendor/",
".venv",
"/virtualenv",
"/usr/lib/python",
"/usr/local/go/src/",
"/usr/local/lib/",
"internal/modules/",
"node:internal",
"<anonymous>",
"/.cargo/",
"/.rustup/",
)
@dataclass
class Frame:
raw: str
path: str
line: int
fn: str | None
resolved: str | None = None # repo-relative, only when the file exists
@property
def vendored(self) -> bool:
return any(m in self.path for m in VENDOR_MARKERS)
def repo_root() -> str:
"""Git top level, or the working directory when this is not a repository."""
try:
out = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
return out.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return os.getcwd()
def parse(text: str) -> list[Frame]:
frames: list[Frame] = []
for raw in text.splitlines():
for pattern in FRAME_PATTERNS:
m = pattern.match(raw)
if not m:
continue
frames.append(
Frame(
raw=raw.strip(),
path=m.group("file"),
line=int(m.group("line")),
fn=(m.groupdict().get("fn") or None),
)
)
break
return frames
def resolve(path: str, root: str) -> str | None:
"""Find `path` under `root`, tolerating that the trace came from another machine.
A CI trace carries absolute paths like `/home/runner/work/app/src/db.ts` that
exist nowhere locally. Matching progressively shorter suffixes recovers the
repo-relative path without needing to know the build's directory layout.
Longest suffix wins, so a two-segment match is preferred to a bare filename —
which is what keeps `src/index.ts` from resolving to `test/index.ts`.
"""
parts = [p for p in path.replace("\\", "/").split("/") if p not in ("", ".")]
for start in range(len(parts)):
candidate = os.path.join(root, *parts[start:])
if os.path.isfile(candidate):
return os.path.relpath(candidate, root)
return None
def git(args: list[str], root: str) -> str:
try:
out = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=root, check=True
)
return out.stdout.rstrip()
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
def report(frames: list[Frame], root: str) -> int:
if not frames:
print("No stack frames recognised in the input.")
print("Supported formats: V8/Node, CPython, Go, JVM.")
return 2
print(f"repo root: {root}")
print(f"{len(frames)} frame(s) parsed\n")
owning: Frame | None = None
for i, f in enumerate(frames):
f.resolved = None if f.vendored else resolve(f.path, root)
if f.resolved and owning is None:
owning = f
kind = "vendor" if f.vendored else ("project" if f.resolved else "unresolved")
mark = "OWNING" if f is owning else " "
where = f.resolved or f.path
fn = f" — {f.fn}" if f.fn else ""
print(f" {mark} [{kind:>10}] {where}:{f.line}{fn}")
if owning is None:
print(
"\nNo project frame resolved to a file on disk.\n"
"Every frame is vendored, or the trace came from a different build of "
"this code.\n"
"Ask for the commit the trace was produced from before going further — "
"do not diagnose from the vendor frames."
)
return 1
print(f"\n── owning frame: {owning.resolved}:{owning.line} ──\n")
blame = git(
["blame", "-L", f"{owning.line},{owning.line}", "--date=short", "--", owning.resolved],
root,
)
print("git blame:")
print(f" {blame}" if blame else " (unavailable — not a git repository?)")
log = git(
["log", "-3", "--date=short", "--format=%h %ad %an %s", "--", owning.resolved],
root,
)
print("\nlast commits touching this file:")
print("\n".join(f" {ln}" for ln in log.splitlines()) if log else " (none)")
print(
"\nNext: read the owning frame and the frames either side of it. If blame shows "
"the line is old, its input changed rather than its logic — follow the input."
)
return 0
def main() -> int:
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} <trace-file>|-", file=sys.stderr)
return 64
src = sys.argv[1]
text = sys.stdin.read() if src == "-" else open(src, encoding="utf-8", errors="replace").read()
return report(parse(text), repo_root())
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…
- Regression Risk MapperMaps 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…
- 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…