Engineering Workflow No. 043Editor's pick

Systematic Debugging skill for Claude Code

Root causes, not lucky patches. Stops Claude from guessing at fixes. It reproduces the failure first, narrows the search with git bisect or input reduction, tests ranked hypotheses one variable at a time, and only then changes code. Every fix ends with a regression test that fails without it and a short root-cause note you can paste into the PR.

  • namesystematic-debugging
  • size156 lines · ≈1,550 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Finds the root cause of bugs with a reproduce-isolate-hypothesize-verify loop instead of guess-and-patch. Covers failing tests, stack traces, regressions, flaky behavior, wrong output, and "works on my machine" issues, and ends with a regression test and a short root-cause note. Use when the user reports a bug, pastes an error or stack trace, says something "stopped working", or when a previous fix attempt did not hold.

Try asking

  • The checkout total shows NaN for some carts — find out why
  • This test passes locally but fails in CI about one run in ten
  • It worked last week and now the export endpoint returns 500. Track it down

Use it when

  • You have an error, stack trace, or wrong output to explain
  • Something broke after a deploy or dependency upgrade
  • A test is flaky or only fails in one environment
  • A previous fix attempt did not hold

Skip it when

How to check the result

The original reproduction passes, a new regression test fails when the fix is reverted, and the full suite stays green.

The common mistake it prevents

Wrapping the failing call in try/catch or adding a retry makes the symptom vanish while the real defect keeps corrupting data elsewhere.

~/.claude/skills/systematic-debugging/SKILL.md

--- name: systematic-debugging description: Finds the root cause of bugs with a reproduce-isolate-hypothesize-verify loop instead of guess-and-patch. Covers failing tests, stack… license: MIT ---

A bug is fixed when you can explain the mechanism, a test that failed now passes, and nothing else changed behavior. A symptom that disappears after an unexplained edit is not fixed — it is hidden.

Ground rules

  • Do not edit code until you can reproduce the failure or have a written reason why reproduction is impossible.
  • Change one variable per experiment. Revert experiments that did not confirm anything.
  • Read the actual error. The first line of the trace and the deepest frame inside the project’s own code matter more than the framework frames between them.
  • Treat every “this can’t be the problem” as a hypothesis to test, not a fact.
  • After two failed fix attempts, stop patching and return to step 2 with fresh evidence.

Workflow

Copy this checklist into your working notes and tick items as you go:

- [ ] 1. Capture the exact symptom
- [ ] 2. Reproduce reliably (ideally as a failing test)
- [ ] 3. Narrow the search space
- [ ] 4. Form ranked hypotheses
- [ ] 5. Test hypotheses with minimal experiments
- [ ] 6. Fix the cause, not the symptom
- [ ] 7. Verify and guard against regression
- [ ] 8. Report the root cause

1. Capture the exact symptom

Record before touching anything:

  • Expected vs. actual behavior, in one sentence each.
  • The full error text and stack trace (not a paraphrase).
  • Environment: runtime and dependency versions, OS, config/env vars that differ from the working case, data involved.
  • When it started: last known good commit, deploy, or dependency bump.

Useful commands:

git log --oneline -15                 # what changed recently
git diff <last-good>..HEAD --stat     # scope of change since it worked
node -v; python3 --version            # runtime versions actually in use

2. Reproduce reliably

Prefer the smallest reproduction in this order:

  1. An automated failing test in the project’s existing test framework.
  2. A single command or script that fails deterministically.
  3. A documented sequence of manual steps.

If the failure is intermittent, loop it to measure the rate before and after any fix:

for i in $(seq 1 50); do npm test -- -t "checkout total" >/dev/null 2>&1 || echo "fail $i"; done

Intermittent failures are usually ordering, timing, shared state, or environment. For those, read references/flaky-and-heisenbugs.md.

3. Narrow the search space

Pick the cheapest technique that cuts the space in half:

SituationTechnique
It worked at a known commitgit bisect (automate with git bisect run <test-cmd>)
Large input triggers itDelta-reduce the input until removing anything makes it pass
Long pipeline / many layersCheck the data at the midpoint; recurse into the bad half
Works in env A, fails in BDiff the environments: versions, env vars, config, data, locale, timezone
Exception far from causeFind where the bad value was first created, not where it was used
git bisect start
git bisect bad HEAD
git bisect good <last-good-sha>
git bisect run npm test -- -t "checkout total"
git bisect reset

4. Form ranked hypotheses

Write two to four candidate explanations. For each, note what evidence would confirm or rule it out. Rank by likelihood multiplied by cheapness to test. Common root-cause families to check against:

  • Wrong assumption about input shape: null/undefined, empty collection, unicode, timezone, numeric precision, string vs. number.
  • State: stale cache, mutation of shared objects, missing reset between tests, global singletons.
  • Ordering and timing: unawaited promise, race between writers, event fired before listener attached, retries applied twice.
  • Boundaries: off-by-one, inclusive vs. exclusive ranges, pagination edges, limits.
  • Integration drift: dependency upgrade, API contract change, schema migration not run, feature flag state.
  • Configuration: wrong env file loaded, precedence of config sources, missing secret.

5. Test hypotheses with minimal experiments

  • Add targeted logging or assertions at the boundary that distinguishes hypotheses. Log values and types: console.log({ total, type: typeof total }).
  • Use a debugger or breakpoint() / debugger; when inspecting many variables.
  • Prefer experiments that can disprove a hypothesis outright.
  • Write down each result. Remove temporary logging before finishing.

6. Fix the cause, not the symptom

  • Fix where the wrong value or behavior originates.
  • Red flags that you are patching a symptom: adding a try/catch that swallows the error, a null check without knowing why the value is null, a sleep or retry to hide a race, special-casing the exact failing input.
  • Keep the fix minimal. Do not bundle refactors with a bug fix; propose them separately.
  • Search for the same pattern elsewhere: grep -rn "<pattern>" src/ — sibling bugs are common.

7. Verify and guard against regression

  1. The reproduction from step 2 now passes.
  2. Add or keep a regression test that fails without the fix. Confirm by temporarily reverting the fix and watching the test fail.
  3. Run the full relevant test suite, plus lint and type checks.
  4. For intermittent bugs, rerun the loop from step 2 and compare failure rates.

8. Report the root cause

End with a short note in this shape:

**Symptom:** Checkout total showed NaN for carts with a discounted bundle.
**Root cause:** `applyDiscount()` received the price as a string from the CSV import
and `"19.99" * 1` worked but `"19.99" + 0.5` concatenated. Introduced in a1b2c3d.
**Fix:** Parse prices to integer cents at the import boundary (src/import/csv.ts:42).
**Verification:** New test `bundle discount totals` fails before, passes after;
full suite green.
**Follow-ups:** Two other importers use the same pattern (listed in PR).

When you cannot reproduce

  • Ask for (or gather) logs with timestamps, request IDs, exact versions, and input data.
  • Add structured diagnostics at suspected boundaries behind a flag, ship them, and wait for the next occurrence rather than guessing at a fix.
  • State plainly that the fix is speculative if you must ship one, and say what evidence would confirm it.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/flaky-and-heisenbugs.md 50 lines · 2.4 KB
# Flaky failures and heisenbugs

Read this when a failure does not reproduce on every run.

## Measure first

Run the failing case at least 30–50 times and record the failure rate. A fix is only
confirmed when the rate drops to zero over a run at least as long as the one that showed
failures. "It passed three times" proves nothing for a 10% flake.

## Classify by what changes the rate

| Experiment | If the rate changes, suspect |
|---|---|
| Run the test alone vs. with the full suite | Shared state or test ordering |
| Randomize or reverse test order | Order dependence (leaked globals, DB rows, mocks) |
| Run with parallelism 1 vs. N | Shared ports, files, DB schemas, or temp dirs |
| Add CPU load (`stress`, parallel builds) | Timing assumptions, missing awaits, timeouts |
| Freeze the clock / change TZ | Date math, DST boundaries, midnight rollovers |
| Fix the random seed | Unseeded randomness in data factories |
| Run against a fresh DB | Data left over from earlier runs |

## Common mechanisms and real fixes

- **Missing await / unhandled promise:** the assertion runs before the work completes.
  Fix by awaiting the operation or waiting on an observable condition, never with a
  fixed sleep.
- **Polling UI tests:** use the framework's auto-waiting assertions (e.g. Playwright
  `expect(locator).toHaveText()`), not `waitForTimeout`.
- **Leaked state:** reset mocks, timers, singletons, and env vars in `afterEach`;
  isolate DB state with per-test transactions or unique schemas.
- **Port and file collisions:** bind to port 0 and read the assigned port; create temp
  dirs per test.
- **Time:** inject a clock; avoid `new Date()` in the code under test; test DST and
  month-end explicitly.
- **Network:** stub third-party calls at the HTTP boundary; flakes from real network are
  not bugs in your code but are bugs in your test.

## Observer effects

If adding logging or a debugger makes the bug disappear, the bug is almost always
timing-related. Switch to low-overhead evidence: record timestamps into an in-memory
buffer and dump it on failure, or capture a trace (OpenTelemetry, `strace`, browser
performance trace) instead of stepping through.

## Quarantine policy

If a flaky test blocks others and cannot be fixed immediately, quarantine it with a
linked issue and an owner, and keep running it in a non-blocking job so the failure rate
stays visible. Deleting or permanently skipping it hides a real defect.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc