By Contexory

Error Triage

Locates 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 that owns the failure. A pasted stack trace is the fastest input but not a requirement — it works from a CI or crash log, or from a failure the user only describes, in which case its first step is establishing what to capture. Use when something has already failed at runtime and the user wants to know why — a pasted exception, a job log, or a fault that appears in one environment and not another. Not for a failure the user describes as intermittent, where the rate has to be measured before anything can be diagnosed.

1 supporting file

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 file

It 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 catch or guard for this exact case somewhere else, which tells you the

    team 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:

  1. Owning framepath:line, and the one-sentence reason it is the owning frame

    rather than the top frame

  2. What broke — the assumption that stopped holding
  3. When it started — from blame and log, or "unchanged for N months, so the input

    changed"

  4. Reproduction — the command, and whether it actually reproduced
  5. 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/catch to

    make 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

1 file

scripts/trace_map.pyPython6.6 KB
#!/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())