pytestblockpytest's === FAILURES === block, from the first failing test through to the short summary.
- start
^=+ FAILURES =+- end
^=+ short test summary
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.
There are two forms, and a pack uses one or the other:
| Form | Fields | Use when |
|---|---|---|
| Anchor | pattern + before/after | The failure is one recognisable line, and the useful context is a known number of lines around it. |
| Block | start + end | The 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).
2 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.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.
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.
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.
pytestblockpytest's === FAILURES === block, from the first failing test through to the short summary.
^=+ FAILURES =+^=+ short test summaryjestblockA 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.
^\s*●|^FAIL\s+\S+\.(test|spec)\.[jt]sx?\b^Tests:go_testanchorgo test's per-test --- FAIL marker, plus the assertion output printed under it.
^--- FAILmaven_gradleanchorMaven's [ERROR] lines and the BUILD FAILURE banner both Maven and Gradle print.
\[ERROR\]|FAILURE: Build failed|BUILD FAILUREtscanchorA TypeScript compile error (error TS2345:) with the source excerpt around it.
error TS\d+:npmanchornpm ERR! lines. Ranked under the compilers deliberately — npm reports the failure it wrapped, never the cause.
npm ERR!pnpmanchorpnpm's ERR_PNPM_* codes and the ELIFECYCLE a failed lifecycle script exits with.
ERR_PNPM_\w+|^\s*ELIFECYCLE\byarnanchorYarn Classic's error <Capital> lines and Berry's ➤ YN####: diagnostic codes.
^error [A-Z]|➤ YN\d{4}bunanchorA failed bun script or test. Anchors on the ✗ marker as well as the summary, because the summary trails the assertion detail.
error: script ".+" exited with code|error: No version matching|^\s*✗ |^\s*\d+ fail\bnode_runtimeanchorAn uncaught Node error: internal stack frames, ESM/CJS resolution failures, unhandled rejections. before: 12 because the message sits *above* the stack.
^\s*at .+\(node:internal|^Node\.js v\d+\.|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM|UnhandledPromiseRejectiondocker_buildanchorBuildKit giving up — failed to solve / executor failed running — around the Dockerfile step that failed.
failed to solve|executor failed runningoomanchorThe job being killed: exit code 137, Killed, Out of memory. Top priority — nothing else in the log explains a SIGKILL.
exit code 137|Killed|Out of memoryrust_testblockcargo test's failures: block through to the test result: line.
^failures:$^test result:rust_panicanchorA Rust panic, with the message and the frames that follow it.
^thread '.*' panicked atrust_compileanchorrustc's coded errors (error[E0308]) and its own driver messages. A bare error: is not Rust enough — bun prints one too.
^error\[E\d+\]: |^error: (could not compile|aborting due to|linking with)dotnet_testanchordotnet test: xunit/nunit [FAIL] markers and the Failed! summary line.
\[FAIL\]|^\s*Failed\s+\S+\s+\[\d|^Failed!\s+-\s+Failed:dotnet_buildanchorMSBuild, Roslyn and NuGet error codes — error CS0103, error NU1101, error MSB3073.
error (CS|MSB|NU|AD)\d+rspecblockRSpec's Failures: block through to the N examples, N failures tally.
^Failures:$^\d+ examples?, \d+ failures?minitestanchorMinitest's numbered 1) Failure: entries and the assertion beneath each one.
^\s*\d+\) (Failure|Error):ruby_exceptionanchorAn unhandled Ruby exception — a backtrace frame naming the error class.
\.rb:\d+:in .+\(\w*(Error|Exception)\)bundleranchorBundler failing to resolve: a missing gem, or a version conflict it prints the tree for.
Could not find gem|Bundler::(GemNotFound|VersionConflict)phpunitblockPHPUnit's There were N failures block through to its FAILURES/ERRORS/OK footer.
^There (was|were) \d+ (failure|error)^(FAILURES|ERRORS|OK)php_fatalanchorA PHP fatal or parse error and the stack trace after it.
PHP (Fatal|Parse) error|^Fatal error:composeranchorComposer's resolver refusing the requirements, with the explanation it prints underneath.
Your requirements could not be resolvedgradleblockGradle's * What went wrong: block, stopping before the * Try: boilerplate advice.
^\* What went wrong:^\* (Try|Get more help)bazelanchorBazel's ERROR path:line:col: and failed targets. The path:line:col is what keeps it off ERROR: Job failed: exit code 1.
^ERROR: \S+:\d+:\d+: .*(failed|FAILED)|^FAILED: Build did NOT complete|^//\S+\s+FAILED in \dplaywrightanchorA Playwright failure — the numbered 1) suite › test header — plus the 20 lines of trace and diff after it.
^\s*\d+\) .+ › cypressanchorA CypressError or a retry timeout, with the command log leading up to it.
CypressError|Timed out retrying after \d+mspython_tracebackanchorA Python traceback. Weighted after, because the exception type is at the *bottom* of a traceback, not the top.
^\s*Traceback \(most recent call last\):go_panicanchorA Go panic: or runtime fatal error: with the goroutine dump beneath it.
^panic: |^fatal error: cc_cppanchorC/C++ builds: CMake errors, make: *** failures, and linker undefined reference errors.
CMake Error|make(\[\d+\])?: \*\*\* |undefined reference to|collect2: erroreslintanchorESLint's line:col error rule-name entries and its ✖ N problems footer.
^\s*\d+:\d+\s+error\s+|✖ \d+ problems?python_lintanchorA file.py:12: error diagnostic from mypy, ruff, flake8 or pylint.
^\S+\.py:\d+:(\d+:)? (error|[EFWCN]\d+)terraformanchorTerraform's boxed │ Error: and the on main.tf line 12 it points at.
^\s*│\s*Error: |on .+\.tf line \d+generic_erroranchorThe 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.
^(ERROR|FATAL)\bMatcher 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: 85Reusing 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 blankedOnly 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.
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.logContributing 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.