Matchers

A matcher recognises one tool's failure output and pulls the lines around it into the evidence. 35 ship by default, covering the test frameworks, compilers, linters and package managers most pipelines run. They are data, not code — every one is overridable from your own .ci-doctor.yml.

How a pack is shaped

There are two forms, and a pack uses one or the other:

FormFieldsUse when
Anchorpattern + before/afterThe failure is one recognisable line, and the useful context is a known number of lines around it.
Blockstart + endThe tool prints a delimited region — === FAILURES === through to the summary line.

Both carry a priority, which decides who survives when the evidence exceeds the token budget: whole low-priority windows are shed before the survivors are trimmed. Rank a pack by how diagnostic it is, not how loud — npm ERR! (75) always trails the compiler errors that caused it, so it loses to tsc (80).

Anchor on the cause, not the summary. A tool's final2 tests failed line comes after the detail explaining why, so a summary-only anchor windows straight past the evidence. Anchor on something only your tool prints, too: a bare ^ERROR: .*failed also matchesERROR: Job failed: exit code 1, the line that ends every failed GitLab job.

What happens to a match

Matching is only the first step. Every hit becomes a window, windows that touch are merged, and priority decides which ones survive the budget — all of it incore/extract.py, before a model is ever involved.

core/extract.py — matching, merging, shedding, rendering.

Two details follow from this. Merged windows take the highest priority they absorbed, so a good pack never loses rank by sitting next to a weak one. And shedding stops at one window: a single unbounded start/end block can exceed the budget alone, and cutting inside it iscore/budget.py's job, not the matcher's.

The shipped catalogue

Generated from config/defaults.yml, so this is the 35 packs that actually ship, in the order they are declared. anchor packs list the context lines they keep either side of the hit; block packs run from their start to their end.

Test frameworks and the build drivers that wrap them

pytestblockstart → end · priority 90

pytest's === FAILURES === block, from the first failing test through to the short summary.

start
^=+ FAILURES =+
end
^=+ short test summary
jestblockstart → end · priority 90

A jest failure — the bullet or a FAIL src/x.test.ts header — up to the Tests: tally. The JS filename is what stops it eating go test's own FAIL line.

start
^\s*●|^FAIL\s+\S+\.(test|spec)\.[jt]sx?\b
end
^Tests:
go_testanchor−2 / +15 lines · priority 85

go test's per-test --- FAIL marker, plus the assertion output printed under it.

pattern
^--- FAIL
maven_gradleanchor−3 / +15 lines · priority 80

Maven's [ERROR] lines and the BUILD FAILURE banner both Maven and Gradle print.

pattern
\[ERROR\]|FAILURE: Build failed|BUILD FAILURE
tscanchor−1 / +3 lines · priority 80

A TypeScript compile error (error TS2345:) with the source excerpt around it.

pattern
error TS\d+:
npmanchor−2 / +10 lines · priority 75

npm ERR! lines. Ranked under the compilers deliberately — npm reports the failure it wrapped, never the cause.

pattern
npm ERR!

Other JS package managers + the node runtime itself

pnpmanchor−1 / +10 lines · priority 82

pnpm's ERR_PNPM_* codes and the ELIFECYCLE a failed lifecycle script exits with.

pattern
ERR_PNPM_\w+|^\s*ELIFECYCLE\b
yarnanchor−1 / +8 lines · priority 82

Yarn Classic's error <Capital> lines and Berry's ➤ YN####: diagnostic codes.

pattern
^error [A-Z]|➤ YN\d{4}
bunanchor−2 / +12 lines · priority 84

A failed bun script or test. Anchors on the marker as well as the summary, because the summary trails the assertion detail.

pattern
error: script ".+" exited with code|error: No version matching|^\s*✗ |^\s*\d+ fail\b
node_runtimeanchor−12 / +6 lines · priority 84

An uncaught Node error: internal stack frames, ESM/CJS resolution failures, unhandled rejections. before: 12 because the message sits *above* the stack.

pattern
^\s*at .+\(node:internal|^Node\.js v\d+\.|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM|UnhandledPromiseRejection

Containers, and the runner killing the job

docker_buildanchor−3 / +8 lines · priority 75

BuildKit giving up — failed to solve / executor failed running — around the Dockerfile step that failed.

pattern
failed to solve|executor failed running
oomanchor−5 / +2 lines · priority 95

The job being killed: exit code 137, Killed, Out of memory. Top priority — nothing else in the log explains a SIGKILL.

pattern
exit code 137|Killed|Out of memory

Rust (cargo)

rust_testblockstart → end · priority 90

cargo test's failures: block through to the test result: line.

start
^failures:$
end
^test result:
rust_panicanchor−2 / +8 lines · priority 88

A Rust panic, with the message and the frames that follow it.

pattern
^thread '.*' panicked at
rust_compileanchor−0 / +8 lines · priority 82

rustc's coded errors (error[E0308]) and its own driver messages. A bare error: is not Rust enough — bun prints one too.

pattern
^error\[E\d+\]: |^error: (could not compile|aborting due to|linking with)

.NET (dotnet build / dotnet test)

dotnet_testanchor−1 / +12 lines · priority 88

dotnet test: xunit/nunit [FAIL] markers and the Failed! summary line.

pattern
\[FAIL\]|^\s*Failed\s+\S+\s+\[\d|^Failed!\s+-\s+Failed:
dotnet_buildanchor−1 / +3 lines · priority 82

MSBuild, Roslyn and NuGet error codes — error CS0103, error NU1101, error MSB3073.

pattern
error (CS|MSB|NU|AD)\d+

Ruby (rspec / minitest / bundler)

rspecblockstart → end · priority 90

RSpec's Failures: block through to the N examples, N failures tally.

start
^Failures:$
end
^\d+ examples?, \d+ failures?
minitestanchor−0 / +10 lines · priority 88

Minitest's numbered 1) Failure: entries and the assertion beneath each one.

pattern
^\s*\d+\) (Failure|Error):
ruby_exceptionanchor−2 / +10 lines · priority 80

An unhandled Ruby exception — a backtrace frame naming the error class.

pattern
\.rb:\d+:in .+\(\w*(Error|Exception)\)
bundleranchor−1 / +6 lines · priority 80

Bundler failing to resolve: a missing gem, or a version conflict it prints the tree for.

pattern
Could not find gem|Bundler::(GemNotFound|VersionConflict)

PHP (phpunit / composer)

phpunitblockstart → end · priority 90

PHPUnit's There were N failures block through to its FAILURES/ERRORS/OK footer.

start
^There (was|were) \d+ (failure|error)
end
^(FAILURES|ERRORS|OK)
php_fatalanchor−1 / +8 lines · priority 85

A PHP fatal or parse error and the stack trace after it.

pattern
PHP (Fatal|Parse) error|^Fatal error:
composeranchor−1 / +12 lines · priority 80

Composer's resolver refusing the requirements, with the explanation it prints underneath.

pattern
Your requirements could not be resolved

Gradle (its own block; maven_gradle catches the [ERROR] style)

gradleblockstart → end · priority 88

Gradle's * What went wrong: block, stopping before the * Try: boilerplate advice.

start
^\* What went wrong:
end
^\* (Try|Get more help)

Bazel

bazelanchor−2 / +10 lines · priority 85

Bazel's ERROR path:line:col: and failed targets. The path:line:col is what keeps it off ERROR: Job failed: exit code 1.

pattern
^ERROR: \S+:\d+:\d+: .*(failed|FAILED)|^FAILED: Build did NOT complete|^//\S+\s+FAILED in \d

Browser e2e (Playwright / Cypress)

playwrightanchor−0 / +20 lines · priority 88

A Playwright failure — the numbered 1) suite › test header — plus the 20 lines of trace and diff after it.

pattern
^\s*\d+\) .+ ›
cypressanchor−3 / +12 lines · priority 88

A CypressError or a retry timeout, with the command log leading up to it.

pattern
CypressError|Timed out retrying after \d+ms

Language runtimes not covered by a framework matcher

python_tracebackanchor−1 / +25 lines · priority 85

A Python traceback. Weighted after, because the exception type is at the *bottom* of a traceback, not the top.

pattern
^\s*Traceback \(most recent call last\):
go_panicanchor−2 / +20 lines · priority 88

A Go panic: or runtime fatal error: with the goroutine dump beneath it.

pattern
^panic: |^fatal error:
cc_cppanchor−3 / +8 lines · priority 80

C/C++ builds: CMake errors, make: *** failures, and linker undefined reference errors.

pattern
CMake Error|make(\[\d+\])?: \*\*\* |undefined reference to|collect2: error

Linters / type checkers / IaC

eslintanchor−2 / +6 lines · priority 78

ESLint's line:col error rule-name entries and its ✖ N problems footer.

pattern
^\s*\d+:\d+\s+error\s+|✖ \d+ problems?
python_lintanchor−0 / +3 lines · priority 78

A file.py:12: error diagnostic from mypy, ruff, flake8 or pylint.

pattern
^\S+\.py:\d+:(\d+:)? (error|[EFWCN]\d+)
terraformanchor−1 / +12 lines · priority 80

Terraform's boxed │ Error: and the on main.tf line 12 it points at.

pattern
^\s*│\s*Error: |on .+\.tf line \d+

Fallback: a tool with no pack of its own

generic_erroranchor−5 / +20 lines · priority 50

The fallback for a tool with no pack of its own: any line starting ERROR or FATAL. Lowest priority, so it is the first thing shed under budget pressure.

pattern
^(ERROR|FATAL)\b

Adding your own

Matcher lists merge by id, which is the one exception to "lists replace". A new id is added alongside the shipped packs:

# .ci-doctor.yml — a new id is *added* to the shipped packs
extraction:
  matchers:
    - id: my_service_errors
      pattern: '^\[myservice\] (ERROR|PANIC)'
      before: 3
      after: 12
      priority: 85

Reusing a shipped id retunes that pack instead, field by field:

# A shipped id *overrides* that pack, field by field, and logs a warning.
extraction:
  matchers:
    - id: pytest
      priority: 95        # start:/end: are inherited, not blanked

Only the fields you write are overridden — setting priority onpytest keeps its start/end, because blanking them would leave a matcher that can never fire. ci-doctor logs a warning naming any shipped pack you override, so an accidental id collision is visible rather than silent.

Check it actually fires

The most common mistake is a pack that never matches. It fails silently: the tail window still returns output, so the report looks fine while your matcher does nothing. Replay a real failing log and confirm your lines are in the evidence:

# Prove it fires before you trust it — a matcher that never matches
# fails silently, because the tail window still returns something.
ci-doctor analyze path/to/failing-job.log

Contributing a pack upstream? It needs a fixture log for both providers plus an expected verdict — see GUIDELINES.md §5.1. The a href=/ci-doctor/concepts/>concepts 

page explains where extraction sits in the pipeline.