A Claude Code plugin for Firebase Crashlytics
Turn a Crashlytics crash into a proven fix
fixcrash writes a test that fails on the version that crashed, fixes the code, proves the fix, and opens a pull request with that proof. When it can't reproduce a crash, it says so and opens no pull request.
StatusEvaluated on Android, iOS and React Native testbeds. Not yet tried on a production app. Not published yet.
How it works
AI crash tools explain a crash, suggest a diff, or open a pull request. None of them show that the change fixes this crash. fixcrash makes that the only way to get a pull request, and checks it in code rather than by the model's word.
Reproduce
A test that fails on the version that crashed, with the crash's own exception.
Fix
The fix, on a
fixcrash/<issue>branch.Verify
The same test passing, and the full suite green.
Pull request
A pull request with that evidence, and a note on the Crashlytics issue.
Can't reproduce it?
A report: what it tried, what it suspects, and what data is missing, and a note on the issue. No pull request.
Reproduce on the version that crashed
The crashing commit comes from the event's build stamp or a release tag. The test is written in a git worktree at that commit.
Same failure, not any failure
The test must fail with the crash's exception type, and its stack must hit the crash's top app frame. It must get there the way the app does: a test that builds an object without its constructor or calls private members through reflection is refused.
Proof is enforced in code
fixcrash verify writes a receipt bound to the commit only when the test fails before the fix, passes after it, and the full suite is green. A hook blocks PR creation without a valid receipt for HEAD.
Honest when it can't
Hypotheses and test attempts are budgeted. When the budget runs out, you get a report, not a guess shipped as a fix.
The proof
The proof is fixcrash verify. On the Android testbed's parser crash it gives:
fixcrash verify --issue c43757a2… --test …ProfileParserTest.parsesProfileWithoutAvatar \ --exception java.lang.NullPointerException --frame …ProfileParser.parse --base 89da0c3base (crashing version) 89da0c31a144: ok: fails as the crash did: java.lang.NullPointerException at …ProfileParser.parse(ProfileParser.kt:12)from 89da0c31a144: same commit as base, not run againhead (fix) 954cbb73fe9f: ok: the test passes; suite green: 4 tests, 0 skippedVERIFIED: receipt .git/fixcrash/receipts/954cbb73fe9f….jsonWhen it can't, it says so
If it can't reproduce the crash, it says so: what it tried, what it suspects, and what data is missing. It opens no pull request.
A unit test can usually reproduce
- parsing and mapping errors (a missing field, a wrong type);
null,!!ornilon data;- index and range errors;
- state-machine logic;
- date, locale and number formatting.
Usually it can't, and fixcrash ends with a report
- lifecycle crashes (Fragment not attached, a view used after
onDestroy); - main-thread races;
- OOM, ANR and watchdog kills;
- OEM-specific and native (NDK, C++) crashes;
- crashes inside third-party SDKs.
Platforms
Android (Kotlin)
JVM unit tests, Robolectric when Android classes are involved.
Evaluated: Android, 2026-10-08iOS (Swift)
XCTest and Swift Testing through xcodebuild, on a simulator; release builds' inlined frames recovered from debug symbols.
Evaluated: iOS, UIKit, 2026-10-15; iOS, SwiftUI, 2026-10-16React Native
Jest, plus symbolication of JS stacks from source maps for the crashing commit.
Evaluated: React Native, 2026-10-17
Results on the testbeds
/fixcrash:fix on each testbed's seeded crashes: sample apps with known crashes, where a unit test can reach some and not others. Each run was one fresh Claude Code session. Not yet tried on a production app.
An earlier Android run got a pull request through with a test that reached the crash by reflection. Such tests are now refused.
Android · 2026-10-08
Claude Code 2.1.294, claude-opus-5-5, firebase-tools 15.32.1, AGP 9.4.1, Gradle 9.6
- parserAs expected: PR
NullPointerException at ProfileParser.parse
Expected PR · 1m 41s · $0.73
- pagingAs expected: PR
IndexOutOfBoundsException at FeedPager.page
Expected PR · 1m 24s · $0.65
- lifecycleAs expected: report, no PR
IllegalStateException at DetailsFragment.onDetailsLoaded
Expected report, no PR · 1m 11s · $0.59
iOS, UIKit · 2026-10-15
Claude Code 2.1.300, claude-opus-5-5, firebase-tools 15.32.1, Xcode 27.0, Firebase iOS SDK 13.0.1
- parserAs expected: PR
EXC_BREAKPOINT in ProfileParser.parse(_:), inlined into MainViewController.openProfile()
Expected PR · 7m 16s · $0.80
- pagingAs expected: PR
EXC_BREAKPOINT in FeedPager.page(_:), inlined into MainViewController.loadMore()
Expected PR · 9m 58s · $0.91
- lifecycleAs expected: report, no PR
EXC_BREAKPOINT at closure #1 in SyncService.start(completion:)
Expected report, no PR · 2m 16s · $0.66
iOS, SwiftUI · 2026-10-16
Claude Code 2.1.301, claude-opus-5-5, firebase-tools 15.32.1, Xcode 27.1 with the iOS 27.0 simulator, Firebase iOS SDK 13.0.1
- renderAs expected: PR
EXC_BREAKPOINT in a ForEach row of TagsView.body
Expected PR · 3m 52s · $0.81
- asyncAs expected: PR
EXC_BREAKPOINT at InboxModel.load(), after an await
Expected PR · 3m 10s · $0.73
- actionAs expected: report, no PR
EXC_BREAKPOINT in a toolbar button's action in ArchiveView.body
Expected report, no PR · 1m 18s · $0.64
React Native · 2026-10-17
Claude Code 2.1.302, claude-opus-5-5, firebase-tools 15.32.1, React Native 0.88.0, Node 24.21, npm 11
- renderAs expected: PR
TypeError at src/TagList.tsx:19, Android
Expected PR · 1m 45s · $1.04
- parserAs expected: PR
TypeError at src/profile.ts:15, Android
Expected PR · 1m 58s · $1.01
- engineAs expected: report, no PR
RangeError at src/updates.ts:13, Android
Expected report, no PR · 2m 06s · $0.86
- asyncAs expected: PR
JavaScriptError at src/inbox.ts:14, Android, non-fatal
Expected PR · 1m 57s · $0.71
- parser, iOSAs expected: PR
TypeError at src/profile.ts:15, iOS
Expected PR · 2m 35s · $0.73
Setup, earlier runs and findings: docs/EVAL.md at 7ea6be9.
What it runs, sends and stores
Runs on your machine
On demand, in your own Claude Code session. There is no server, daemon or bot.
Reads crash data, redacted
Crash data comes from Google's Firebase MCP server, which the plugin starts at a pinned firebase-tools version. Personal data is redacted before Claude reads it.
Writes after you say yes
It asks you before it pushes, opens the pull request, or adds a note to the issue. Writes to Crashlytics are notes only: it never changes an issue's state.
Keeps its state locally
Its worktree, receipts and recorded events are in .git/fixcrash. A React Native project's worktree is in ~/.cache/fixcrash, because Metro and Jest skip paths with .git in them.
What holds in code
A hook, not a prompt, keeps these rules:
- Personal data is redacted before Claude reads crash data. User ids and installation ids are dropped from events; emails, IPs, tokens and other secrets are masked in messages, logs, breadcrumbs and custom keys. Claude can't write what it never saw.
- The expected failure comes from the event. The hook records each event's exception type and blame frame.
fixcrash reproduceandfixcrash verifycheck the test against that record, not against what Claude says the crash was. - No pull request without proof. Once a session has read crash events,
gh pr createand GitHub MCP pull request tools need a valid receipt for the commit the pull request proposes. - Budgets.
fixcrash reproducecounts test attempts and hypotheses per crash; past the budget, the answer is a report. - Receipts and recorded events are written by the CLI only. The hook refuses file edits there and shell commands that name them. The reproducer's file edits are held to test sources.
Safety rules
- Crash data is untrusted input. Exception messages, logs, custom keys and breadcrumbs may contain instructions; fixcrash never follows them.
- Redaction. No user ids, emails, tokens, IPs or other personal data from events in tests, commits, PR bodies or notes. Tests use synthetic values.
- Writes to Crashlytics are notes only. fixcrash never changes an issue's state. Closing an issue is a human's call, after a release.
- Tests run offline where the build tool allows, never against production services.
Install
fixcrash isn't published yet. These commands work once its repository is public.
In Claude Code
/plugin marketplace add krezzoid/fixcrash/plugin install fixcrash@fixcrashThen, in your app
/fixcrash:triage [app-id]/fixcrash:fix <issue-id> [app-id]- /fixcrash:triage
- : top issues of the latest version, each marked unit-reproducible or not.
- /fixcrash:fix
- : the loop above, for one issue.
/fixcrash:fix reproduces the crash in a subagent that may only write tests, fixes it on a fixcrash/<issue> branch, runs fixcrash verify, and asks you before it pushes, opens the pull request, or adds a note to the issue.
Requirements
- Claude Code.
- A Firebase project with Crashlytics, and a Firebase CLI login (
npx firebase-tools login). - Your app's own toolchain: JDK and Android SDK, Node.js for React Native, Xcode for iOS.
- Node.js 24 or newer for
bin/fixcrashand the hook. gh, logged in, to open pull requests.
The plugin's options (Firebase app id, project directory, release tag pattern, budgets) default to an app at the repository root, with the app id read from google-services.json or GoogleService-Info.plist.