diff --git a/.factory/prompts/crash/fix.md b/.factory/prompts/crash/fix.md new file mode 100644 index 0000000000000000000000000000000000000000..0899c55c837716e3cceffdcfafd88d96e31b1881 --- /dev/null +++ b/.factory/prompts/crash/fix.md @@ -0,0 +1,84 @@ +# Crash Fix + +You are fixing a crash that has been analyzed and has a reproduction test case. Your goal is to implement a minimal, correct fix that resolves the root cause and makes the reproduction test pass. + +## Inputs + +Before starting, you should have: + +1. **ANALYSIS.md** — the crash analysis from the investigation phase. Read it thoroughly. +2. **A failing test** — a reproduction test that triggers the crash. Run it first to confirm it fails as expected. + +If either is missing, ask the user to provide them or run the investigation phase first (`/prompt crash/investigate`). + +## Workflow + +### Step 1: Confirm the Failing Test + +Run the reproduction test and verify it fails with the expected crash: + +``` +cargo test -p +``` + +Read the failure output. Confirm the panic message and stack trace match what ANALYSIS.md describes. If the test doesn't fail, or fails differently than expected, stop and reassess before proceeding. + +### Step 2: Understand the Fix + +Read the "Suggested Fix" section of ANALYSIS.md and the relevant source code. Before writing any code, be clear on: + +1. **What invariant is being violated** — what property of the data does the crashing code assume? +2. **Where the invariant breaks** — which function produces the bad state? + +### Step 3: Implement the Fix + +Apply the minimal change needed to resolve the root cause. Guidelines: + +- **Fix the root cause, not the symptom.** Don't just catch the panic with a bounds check if the real problem is an incorrect offset calculation. Fix the calculation. +- **Preserve existing behavior** for all non-crashing cases. The fix should only change what happens in the scenario that was previously crashing. +- **Don't add unnecessary changes.** No drive-by improvements, keep the diff focused. +- **Add a comment only if the fix is non-obvious.** If a reader might wonder "why is this check here?", a brief comment explaining the crash scenario is appropriate. +- **Consider long term maintainability** Please make a targeted fix while being sure to consider the long term maintainability and reliability of the codebase + +### Step 4: Verify the Fix + +Run the reproduction test and confirm it passes: + +``` +cargo test -p +``` + +Then run the full test suite for the affected crate to check for regressions: + +``` +cargo test -p +``` + +If any tests fail, determine whether the fix introduced a regression. Fix regressions before proceeding. + +### Step 5: Run Clippy + +``` +./script/clippy +``` + +Address any new warnings introduced by your change. + +### Step 6: Summarize + +Write a brief summary of the fix for use in a PR description. Include: + +- **What was the bug** — one sentence on the root cause. +- **What the fix does** — one sentence on the change. +- **How it was verified** — note that the reproduction test now passes. +- **Sentry issue link** — if available from ANALYSIS.md. + +We use the following template for pull request descriptions. Please add information to answer the relevant sections, especially for release notes. + +``` + + +Release Notes: + +- N/A *or* Added/Fixed/Improved ... +``` diff --git a/.factory/prompts/crash/investigate.md b/.factory/prompts/crash/investigate.md new file mode 100644 index 0000000000000000000000000000000000000000..93d35a0f0b95250fb14a8a9bba659b057e07b2d2 --- /dev/null +++ b/.factory/prompts/crash/investigate.md @@ -0,0 +1,89 @@ +# Crash Investigation + +You are investigating a crash that was observed in the wild. Your goal is to understand the root cause and produce a minimal reproduction test case that triggers the same crash. This test will be used to verify a fix and prevent regressions. + +## Workflow + +### Step 1: Get the Crash Report + +If given a Sentry issue ID (like `ZED-4VS` or a numeric ID), there are several ways to fetch the crash data: + +**Option A: Sentry MCP server (preferred if available)** +If the Sentry MCP server is configured as a context server, use its tools directly (e.g., `get_sentry_issue`) to fetch the issue details and stack trace. This is the simplest path — no tokens or scripts needed. + +**Option B: Fetch script** +Run the fetch script from the terminal: + +``` +script/sentry-fetch +``` + +This reads authentication from `~/.sentryclirc` (set up via `sentry-cli login`) or the `SENTRY_AUTH_TOKEN` environment variable. + +**Option C: Crash report provided directly** +If the crash report was provided inline or as a file, read it carefully before proceeding. + +### Step 2: Analyze the Stack Trace + +Read the stack trace bottom-to-top (from crash site upward) and identify: + +1. **The crash site** — the exact function and line where the panic/abort occurs. +2. **The immediate cause** — what operation failed (e.g., slice indexing on a non-char-boundary, out-of-bounds access, unwrap on None). +3. **The relevant application frames** — filter out crash handler, signal handler, parking_lot, and stdlib frames. Focus on frames marked "(In app)". +4. **The data flow** — trace how the invalid data reached the crash site. What computed the bad index, the None value, etc.? + +Find the relevant source files in the repository and read them. Pay close attention to: +- The crashing function and its callers +- How inputs to the crashing operation are computed +- Any assumptions the code makes about its inputs (string encoding, array lengths, option values) + +### Step 3: Identify the Root Cause + +Work backwards from the crash site to determine **what sequence of events or data conditions** produces the invalid state. + +Ask yourself: *What user action or sequence of actions could lead to this state?* The crash came from a real user, so there is some natural usage pattern that triggers it. + +### Step 4: Write a Reproduction Test + +Write a minimal test case that: + +1. **Mimics user actions** rather than constructing corrupt state directly. Work from the top down: what does the user do (open a file, type text, trigger a completion, etc.) that eventually causes the internal state to become invalid? +2. **Exercises the same code path** as the crash. The test should fail in the same function with the same kind of error (e.g., same panic message pattern). +3. **Is minimal** — include only what's necessary to trigger the crash. Remove anything that isn't load-bearing. +4. **Lives in the right place** — add the test to the existing test module of the crate where the bug lives. Follow the existing test patterns in that module. +5. **Avoid overly verbose comments** - the test should be self-explanatory and concise. More detailed descriptions of the test can go in ANALYSIS.md (see the next section). + +When the test fails, its stack trace should share the key application frames from the original crash report. The outermost frames (crash handler, signal handling) will differ since we're in a test environment — that's expected. + +If you can't reproduce the exact crash but can demonstrate the same class of bug (e.g., same function panicking with a similar invalid input), that is still valuable. Note the difference in your analysis. + +### Step 5: Write the Analysis + +Create an `ANALYSIS.md` file (in the working directory root, or wherever instructed) with these sections: + +```markdown +# Crash Analysis: + +## Crash Summary +- **Sentry Issue:** +- **Error:** +- **Crash Site:** + +## Root Cause + + +## Reproduction + `> + +## Suggested Fix + +``` + +## Guidelines + +- **Don't guess.** If you're unsure about a code path, read the source. Use `grep` to find relevant functions, types, and call sites. +- **Check the git history.** If the crash appeared in a specific version, `git log` on the relevant files may reveal a recent change that introduced the bug. +- **Look at existing tests.** The crate likely has tests that show how to set up the relevant subsystem. Follow those patterns rather than inventing new test infrastructure. diff --git a/script/sentry-fetch b/script/sentry-fetch new file mode 100755 index 0000000000000000000000000000000000000000..73541d44dcda8b63ca7f9eaa2d6220b127fa9118 --- /dev/null +++ b/script/sentry-fetch @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Fetch a crash report from Sentry and output formatted markdown. + +Usage: + script/sentry-fetch + script/sentry-fetch ZED-4VS + script/sentry-fetch 7243282041 + +Authentication (checked in order): + 1. SENTRY_AUTH_TOKEN environment variable + 2. Token from ~/.sentryclirc (written by `sentry-cli login`) + +If neither is found, the script will print setup instructions and exit. +""" + +import argparse +import configparser +import json +import os +import sys +import urllib.error +import urllib.request + +SENTRY_BASE_URL = "https://sentry.io/api/0" +DEFAULT_SENTRY_ORG = "zed-dev" + + +def main(): + parser = argparse.ArgumentParser( + description="Fetch a crash report from Sentry and output formatted markdown." + ) + parser.add_argument( + "issue", + help="Sentry issue short ID (e.g. ZED-4VS) or numeric issue ID", + ) + args = parser.parse_args() + + token = find_auth_token() + if not token: + print( + "Error: No Sentry auth token found.", + file=sys.stderr, + ) + print( + "\nSet up authentication using one of these methods:\n" + " 1. Run `sentry-cli login` (stores token in ~/.sentryclirc)\n" + " 2. Set the SENTRY_AUTH_TOKEN environment variable\n" + "\nGet a token at https://sentry.io/settings/auth-tokens/", + file=sys.stderr, + ) + sys.exit(1) + + try: + issue_id, short_id, issue = resolve_issue(args.issue, token) + event = fetch_latest_event(issue_id, token) + except FetchError as err: + print(f"Error: {err}", file=sys.stderr) + sys.exit(1) + + markdown = format_crash_report(issue, event, short_id) + print(markdown) + + +class FetchError(Exception): + pass + + +def find_auth_token(): + """Find a Sentry auth token from environment or ~/.sentryclirc. + + Checks in order: + 1. SENTRY_AUTH_TOKEN environment variable + 2. auth.token in ~/.sentryclirc (INI format, written by `sentry-cli login`) + """ + token = os.environ.get("SENTRY_AUTH_TOKEN") + if token: + return token + + sentryclirc_path = os.path.expanduser("~/.sentryclirc") + if os.path.isfile(sentryclirc_path): + config = configparser.ConfigParser() + try: + config.read(sentryclirc_path) + token = config.get("auth", "token", fallback=None) + if token: + return token + except configparser.Error: + pass + + return None + + +def api_get(path, token): + """Make an authenticated GET request to the Sentry API.""" + url = f"{SENTRY_BASE_URL}{path}" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as err: + body = err.read().decode("utf-8", errors="replace") + try: + detail = json.loads(body).get("detail", body) + except (json.JSONDecodeError, AttributeError): + detail = body + raise FetchError(f"Sentry API returned HTTP {err.code} for {path}: {detail}") + except urllib.error.URLError as err: + raise FetchError(f"Failed to connect to Sentry API: {err.reason}") + + +def resolve_issue(identifier, token): + """Resolve a Sentry issue by short ID or numeric ID. + + Returns (issue_id, short_id, issue_data). + """ + if identifier.isdigit(): + issue = api_get(f"/issues/{identifier}/", token) + return identifier, issue.get("shortId", identifier), issue + + result = api_get(f"/organizations/{DEFAULT_SENTRY_ORG}/shortids/{identifier}/", token) + group_id = str(result["groupId"]) + issue = api_get(f"/issues/{group_id}/", token) + return group_id, identifier, issue + + +def fetch_latest_event(issue_id, token): + """Fetch the latest event for an issue.""" + return api_get(f"/issues/{issue_id}/events/latest/", token) + + +def format_crash_report(issue, event, short_id): + """Format a Sentry issue and event as a markdown crash report.""" + lines = [] + + title = issue.get("title", "Unknown Crash") + lines.append(f"# {title}") + lines.append("") + + issue_id = issue.get("id", "unknown") + project = issue.get("project", {}) + project_slug = ( + project.get("slug", "unknown") if isinstance(project, dict) else str(project) + ) + first_seen = issue.get("firstSeen", "unknown") + last_seen = issue.get("lastSeen", "unknown") + count = issue.get("count", "unknown") + sentry_url = f"https://sentry.io/organizations/{DEFAULT_SENTRY_ORG}/issues/{issue_id}/" + + lines.append(f"**Short ID:** {short_id}") + lines.append(f"**Issue ID:** {issue_id}") + lines.append(f"**Project:** {project_slug}") + lines.append(f"**Sentry URL:** {sentry_url}") + lines.append(f"**First Seen:** {first_seen}") + lines.append(f"**Last Seen:** {last_seen}") + lines.append(f"**Event Count:** {count}") + lines.append("") + + format_tags(lines, event) + format_entries(lines, event) + + return "\n".join(lines) + + +def format_tags(lines, event): + """Extract and format tags from the event.""" + tags = event.get("tags", []) + if not tags: + return + + lines.append("## Tags") + lines.append("") + for tag in tags: + key = tag.get("key", "") if isinstance(tag, dict) else "" + value = tag.get("value", "") if isinstance(tag, dict) else "" + if key: + lines.append(f"- **{key}:** {value}") + lines.append("") + + +def format_entries(lines, event): + """Format exception and thread entries from the event.""" + entries = event.get("entries", []) + + for entry in entries: + entry_type = entry.get("type", "") + + if entry_type == "exception": + format_exceptions(lines, entry) + elif entry_type == "threads": + format_threads(lines, entry) + + +def format_exceptions(lines, entry): + """Format exception entries.""" + exceptions = entry.get("data", {}).get("values", []) + if not exceptions: + return + + lines.append("## Exceptions") + lines.append("") + + for i, exc in enumerate(exceptions): + exc_type = exc.get("type", "Unknown") + exc_value = exc.get("value", "") + mechanism = exc.get("mechanism", {}) + + lines.append(f"### Exception {i + 1}") + lines.append(f"**Type:** {exc_type}") + if exc_value: + lines.append(f"**Value:** {exc_value}") + if mechanism: + mech_type = mechanism.get("type", "unknown") + handled = mechanism.get("handled") + if handled is not None: + lines.append(f"**Mechanism:** {mech_type} (handled: {handled})") + else: + lines.append(f"**Mechanism:** {mech_type}") + lines.append("") + + stacktrace = exc.get("stacktrace") + if stacktrace: + frames = stacktrace.get("frames", []) + lines.append("#### Stacktrace") + lines.append("") + lines.append("```") + lines.append(format_frames(frames)) + lines.append("```") + lines.append("") + + +def format_threads(lines, entry): + """Format thread entries, focusing on crashed and current threads.""" + threads = entry.get("data", {}).get("values", []) + if not threads: + return + + crashed_threads = [t for t in threads if t.get("crashed", False)] + current_threads = [ + t for t in threads if t.get("current", False) and not t.get("crashed", False) + ] + other_threads = [ + t + for t in threads + if not t.get("crashed", False) and not t.get("current", False) + ] + + lines.append("## Threads") + lines.append("") + + for thread in crashed_threads + current_threads: + format_single_thread(lines, thread, show_frames=True) + + if other_threads: + lines.append(f"*({len(other_threads)} other threads omitted)*") + lines.append("") + + +def format_single_thread(lines, thread, show_frames=False): + """Format a single thread entry.""" + thread_id = thread.get("id", "?") + thread_name = thread.get("name", "unnamed") + crashed = thread.get("crashed", False) + current = thread.get("current", False) + + markers = [] + if crashed: + markers.append("CRASHED") + if current: + markers.append("current") + marker_str = f" ({', '.join(markers)})" if markers else "" + + lines.append(f"### Thread {thread_id}: {thread_name}{marker_str}") + lines.append("") + + if not show_frames: + return + + stacktrace = thread.get("stacktrace") + if not stacktrace: + return + + frames = stacktrace.get("frames", []) + if frames: + lines.append("```") + lines.append(format_frames(frames)) + lines.append("```") + lines.append("") + + +def format_frames(frames): + """Format stack trace frames for display. + + Sentry provides frames from outermost caller to innermost callee, + so we reverse them to show the most recent (crashing) call first, + matching the convention used in most crash report displays. + """ + output_lines = [] + + for frame in reversed(frames): + func = frame.get("function") or frame.get("symbol") or "unknown" + filename = ( + frame.get("filename") + or frame.get("absPath") + or frame.get("abs_path") + or "unknown file" + ) + line_no = frame.get("lineNo") or frame.get("lineno") + in_app = frame.get("inApp", frame.get("in_app", False)) + + app_marker = "(In app)" if in_app else "(Not in app)" + line_info = f"Line {line_no}" if line_no else "Line null" + + output_lines.append(f" {func} in {filename} [{line_info}] {app_marker}") + + context_lines = build_context_lines(frame, line_no) + output_lines.extend(context_lines) + + return "\n".join(output_lines) + + +def build_context_lines(frame, suspect_line_no): + """Build context code lines for a single frame. + + Handles both Sentry response formats: + - preContext/contextLine/postContext (separate fields) + - context as an array of [line_no, code] tuples + """ + output = [] + + pre_context = frame.get("preContext") or frame.get("pre_context") or [] + context_line = frame.get("contextLine") or frame.get("context_line") + post_context = frame.get("postContext") or frame.get("post_context") or [] + + if context_line is not None or pre_context or post_context: + for code_line in pre_context: + output.append(f" {code_line}") + if context_line is not None: + output.append(f" {context_line} <-- SUSPECT LINE") + for code_line in post_context: + output.append(f" {code_line}") + return output + + context = frame.get("context") or [] + for ctx_entry in context: + if isinstance(ctx_entry, list) and len(ctx_entry) >= 2: + ctx_line_no = ctx_entry[0] + ctx_code = ctx_entry[1] + suspect = " <-- SUSPECT LINE" if ctx_line_no == suspect_line_no else "" + output.append(f" {ctx_code}{suspect}") + + return output + + +if __name__ == "__main__": + main()