Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

### Fixed

- **A failing PHPUnit assertion is no longer reported as a Composer dependency failure.** The
`php_composer_failure` rule carried PHPUnit's own verdict markers — `FAILURES!`, `Failed asserting`,
the `Tests: … Failures:` summary — plus the bare `composer install`/`composer update` commands that
run green in nearly every PHP job. symfony/symfony's `Unit Tests (8.3)` run (29551386048) installed
its dependencies cleanly and then failed one assertion in `ErrorHandler`; PatchRail answered
`php_composer_failure` at 0.95, sending a maintainer to debug an installation that had succeeded.
The rule now witnesses only genuine dependency errors (`Your requirements could not be resolved`, a
platform mismatch, a drifted lockfile) and the autoload `Class … not found`; a plain test failure
carries nothing and lands on `unknown` — the honest ceiling, since PatchRail has no PHP
test-failure class. Real composer failures are untouched (issue #377; committed real-world log and
benchmark row in `docs/real-world-benchmark.md`).
- **A doc build that merely *installs* pytest is no longer reported as a failing pytest run.** The
`python_test_failure` signal `\bpytest\b` treated `-`, `<`, `>` and `=` as word boundaries, so it
matched `pytest` inside every dependency spec a Python job installs — the plugin package
Expand Down
64 changes: 49 additions & 15 deletions docs/real-world-benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
The fixture zoo (`examples/ci-triage`, 223 logs) says PatchRail is right 223 times out of 223. That
number is worth exactly nothing to you: we wrote both the logs and the answers.

This page is the other benchmark. Eleven **real failed CI runs from public repositories** — pandas,
deno, svelte, Home Assistant, Prometheus, Grafana, ruff, PyTorch, Envoy, containerd, React — with
their logs committed to this repo unmodified, exactly as `gh run view --log-failed` returned them.
Every verdict below is the output of a command you can run yourself — including the three where the
This page is the other benchmark. Twelve **real failed CI runs from public repositories** — pandas,
deno, svelte, Home Assistant, Prometheus, Grafana, ruff, PyTorch, Envoy, containerd, React, Symfony —
with their logs committed to this repo unmodified, exactly as `gh run view --log-failed` returned them.
Every verdict below is the output of a command you can run yourself — including the five where the
honest answer is **`unknown`**, one of them because the failure never made it into the log.

## Reproduce it
Expand All @@ -27,11 +27,11 @@ working in ninety days is a claim, not evidence.
## Results

`before` is patchrail 0.6.1, the last release that predates these fixes. `after` is `main`. PyPI
serves **0.7.3** today and ships every fix below except the most recent: the pandas fix (#347) landed
on `main` after 0.7.3 was cut and ships in the next release, so it is the one row where `main` is
ahead of `pip install patchrail`. On the other ten logs, re-measured 2026-07-17, `0.7.3` and `main`
return the identical verdict, and the CLI and action changes merged since (#364–#373) moved none of
them.
serves **0.7.3** today and ships every fix below except the two most recent: the pandas fix (#347)
and the Symfony fix landed on `main` after 0.7.3 was cut and ship in the next release, so they are
the two rows where `main` is ahead of `pip install patchrail` (`0.7.3` returns the old verdict on
both). On the other ten logs, re-measured 2026-07-17, `0.7.3` and `main` return the identical
verdict, and the CLI and action changes merged since (#364–#373) moved none of them.

| repo (run) | what actually failed | before | after | |
|---|---|---|---|---|
Expand All @@ -46,15 +46,17 @@ them.
| [envoy](https://github.com/envoyproxy/envoy/actions/runs/29363920524) | one directory under its coverage threshold | `ci_job_timeout` 0.53 | `code_coverage_threshold` 0.53 | ✅ fixed |
| [containerd](https://github.com/containerd/containerd/actions/runs/29358848438) | a Go integration test failed (`TestContainerCgroupWritable`) | `runner_resource_exhaustion` 0.89 | `go_test_failure` 0.71 | ✅ fixed |
| [React](https://github.com/facebook/react/actions/runs/29335289512) | a file was not `prettier`-formatted | `node_dependency_install` 0.53 | `javascript_lint` 0.53 | ✅ fixed |
| [Symfony](https://github.com/symfony/symfony/actions/runs/29551386048) | a PHPUnit assertion in `ErrorHandler`, after `composer` had succeeded | `php_composer_failure` 0.95 | `unknown` 0.15 | ✅ fixed ([#377](https://github.com/patchrail/patchrail/issues/377)) |

Three of the eleven were classified identically before and after. That is the point of showing them:
Three of the twelve were classified identically before and after. That is the point of showing them:
the fixes below were narrow enough not to disturb the logs that already worked.

## Where PatchRail stops at `unknown`

As of this measurement, none of the eleven is confidently wrong. Three answer `unknown` — ruff and
svelte because PatchRail has no class for what broke, and pandas because the failure is not in the
log at all. `unknown` there is a limit, not a diagnosis, and the honest thing to say.
As of this measurement, none of the twelve is confidently wrong. Five answer `unknown` — ruff, svelte
and Symfony because PatchRail has no class for what broke, pytorch because a lint runner never wrote
the report its `jq` step then failed to read, and pandas because the failure is not in the log at all.
`unknown` there is a limit, not a diagnosis, and the honest thing to say.

### pandas — a package list is not a test run ([#347](https://github.com/patchrail/patchrail/issues/347))

Expand Down Expand Up @@ -276,6 +278,37 @@ matches, while a token that really is unset (`GITHUB_TOKEN is not set`) still do
precedes it. Left with no witness, the log answers `unknown` and hands the `jq` failure back: honest,
because PatchRail has no class for a lint runner that never produced its report.

### Symfony — `php_composer_failure` → `unknown` ([#377](https://github.com/patchrail/patchrail/issues/377))

A PHPUnit assertion that failed is not a Composer that failed.

Symfony's `Unit Tests (8.3)` job locked, installed and autoloaded its dependencies cleanly, ran the
suite, and one assertion in `src/Symfony/Component/ErrorHandler` came out wrong:

```
Testing src/Symfony/Component/ErrorHandler
There was 1 failure:
1) Symfony\Component\ErrorHandler\Tests\Error\FatalErrorTest::testGetTraceWithoutTraceArgs
Failed asserting that an array has the key 'args'.
FAILURES!
Tests: 128, Assertions: 379, Failures: 1, Skipped: 2.
##[error]KO src/Symfony/Component/ErrorHandler
```

PatchRail answered `php_composer_failure` at **0.95** — telling a maintainer whose `composer update`
had gone green to go debug dependency installation. The rule was carrying PHPUnit's own verdict
markers — `FAILURES!`, `Failed asserting`, the `Tests: … Failures:` summary — as if a failing test
were a failing install, and it also counted the bare `composer install` / `composer update` commands,
which nearly every PHP job runs whether or not anything breaks (Symfony echoes both as setup).

None of those is a Composer failure. The rule now witnesses only genuine dependency errors — an
unresolvable requirement, a platform mismatch, a lockfile that has drifted — and the autoload
`Class … not found`. With the test-verdict markers and the setup commands gone, a plain assertion
failure carries nothing here and the log answers `unknown`: PatchRail has no PHP test-failure class,
so `unknown` is the honest ceiling, the same one ruff and svelte land on. A real composer failure is
untouched — `Your requirements could not be resolved`, `requires php`, the lockfile-drift warning all
still carry the rule at full confidence, and the five committed composer fixtures still pass.

### httpx and prefect — pytest's own verdict ([#320](https://github.com/patchrail/patchrail/pull/320), `93b6391`)

Not in the table above (we no longer hold those logs), but the same shape and the reason two of the
Expand All @@ -293,6 +326,7 @@ that matched a real error.
- `--log-failed` returns the failed job's steps, and — as pandas shows — sometimes the failure is not
in them. PatchRail cannot classify what it was not given, and should say `unknown` when that
happens. For pandas, it now does ([#347](https://github.com/patchrail/patchrail/issues/347)).
- Eleven logs is not a statistic. It is a set of cases you can check by hand, chosen because they
were the failed runs sitting in these repos on 2026-07-14, not because they flattered the tool.
- Twelve logs is not a statistic. It is a set of cases you can check by hand, chosen because they
were the failed runs sitting in these repos on 2026-07-14 (and Symfony on 2026-07-17), not because
they flattered the tool.
- Every number here is the output of a command in this page, against a file in this repo. Re-run them.
2 changes: 1 addition & 1 deletion examples/ci-triage/php-composer-lock-drift.expected.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"failure_class": "php_composer_failure",
"minimum_confidence": 0.85
"minimum_confidence": 0.7
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"failure_class": "php_composer_failure",
"minimum_confidence": 0.7
"minimum_confidence": 0.5
}
4 changes: 2 additions & 2 deletions examples/ci-triage/php-phpunit-failure.expected.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"failure_class": "php_composer_failure",
"minimum_confidence": 0.85
"failure_class": "unknown",
"minimum_confidence": 0.15
}
4 changes: 3 additions & 1 deletion examples/real-world/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Real-world CI logs

Failed CI runs from public repositories, fetched with `gh run view <id> --repo <repo> --log-failed`
on 2026-07-14 and committed **unmodified**. They are the evidence behind
on 2026-07-14 (Symfony on 2026-07-17) and committed **unmodified** (Symfony's ANSI color codes, which
`gh` returned in caret notation, were the one exception, stripped for legibility). They are the evidence behind
[docs/real-world-benchmark.md](../../docs/real-world-benchmark.md), where each one is measured
against the version PyPI serves and against `main`.

Expand All @@ -18,6 +19,7 @@ against the version PyPI serves and against `main`.
| `envoy-29363920524-excerpt.log` | envoyproxy/envoy | [29363920524](https://github.com/envoyproxy/envoy/actions/runs/29363920524) (excerpt: the log is 590KB; the echoed `timeout-minutes` config and the coverage gate that actually failed are kept verbatim, and pinned by `tests/test_timeout_minutes_declaration.py`) |
| `containerd-29358848438-excerpt.log` | containerd/containerd | [29358848438](https://github.com/containerd/containerd/actions/runs/29358848438) (excerpt: the log is 9MB; the Go test that failed, the three provoked container-OOM/exit-137/cgroup lines, and the `make` error that ended the job are kept verbatim, and pinned by `tests/test_container_runtime_oom_noise.py`) |
| `react-29335289512-excerpt.log` | facebook/react | [29335289512](https://github.com/facebook/react/actions/runs/29335289512) (excerpt: the failing "Run prettier" step — the formatting message, the unformatted file, and the `yarn run` footer that carried the wrong verdict — kept verbatim, and pinned by `tests/test_yarn_run_footer_notice.py`) |
| `symfony-29551386048-excerpt.log` | symfony/symfony | [29551386048](https://github.com/symfony/symfony/actions/runs/29551386048) (excerpt: the 2.3MB `Unit Tests (8.3)` log's `composer update` success and the `ErrorHandler` assertion that actually failed kept verbatim, ANSI color codes stripped, and pinned by `tests/test_php_test_failure_not_composer.py`) |

These are **not** fixtures. They carry no expected-class labels and are not scored by
`patchrail ci benchmark`, which runs against the sanitized zoo in `examples/ci-triage/`. They exist
Expand Down
34 changes: 34 additions & 0 deletions examples/real-world/symfony-29551386048-excerpt.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:24.3475656Z COMPOSER_UP: composer update --no-progress --ansi --ignore-platform-req=php+
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:24.3601203Z ##[group]composer update
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6173708Z - Installing sebastian/comparator (4.0.10): Extracting archive
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6176483Z - Installing sebastian/object-reflector (2.0.4): Extracting archive
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6179307Z - Installing sebastian/global-state (5.0.8): Extracting archive
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6182121Z - Installing sebastian/object-enumerator (4.0.4): Extracting archive
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6184842Z - Installing sebastian/resource-operations (3.0.4): Extracting archive
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6187670Z - Installing sebastian/type (3.2.1): Extracting archive
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.6204185Z - Installing symfony/phpunit-bridge (9.6.99): Symlinking from ../../vendor/symfony/phpunit-bridge
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:33.9561240Z Generating optimized autoload files
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:02:34.0881132Z 25 packages you are using are looking for funding.
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7845567Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7858537Z Testing /home/runner/work/symfony/symfony/src/Symfony/Component/ErrorHandler
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7876543Z ...S.......................F..............S.................... 63 / 128 ( 49%)
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7905758Z ............................................................... 126 / 128 ( 98%)
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7935755Z .. 128 / 128 (100%)
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7965413Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.7995725Z Time: 00:02.721, Memory: 16.00 MB
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8055409Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8085875Z There was 1 failure:
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8115402Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8151857Z 1) Symfony\Component\ErrorHandler\Tests\Error\FatalErrorTest::testGetTraceWithoutTraceArgs
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8175751Z Failed asserting that an array has the key 'args'.
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8205392Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8236058Z /home/runner/work/symfony/symfony/src/Symfony/Component/ErrorHandler/Tests/Error/FatalErrorTest.php:38
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8265968Z /home/runner/work/symfony/symfony/src/Symfony/Component/ErrorHandler/Tests/Error/FatalErrorTest.php:37
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8295447Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8326803Z FAILURES!
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8327679Z Tests: 128, Assertions: 379, Failures: 1, Skipped: 2.
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8328450Z Job exited with: 1
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8328657Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8374181Z ##[error]KO src/Symfony/Component/ErrorHandler
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:03:17.8383969Z
Unit Tests (8.3) UNKNOWN STEP 2026-07-17T03:04:07.7921791Z ##[error]Process completed with exit code 1.
24 changes: 14 additions & 10 deletions src/patchrail/ci/classify.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,26 +884,30 @@
},
{
"failure_class": "php_composer_failure",
"likely_subsystem": "PHP Composer dependency installation or PHPUnit lifecycle",
"likely_subsystem": "PHP Composer dependency resolution or autoload",
# PHPUnit running and reporting a failed assertion is not a Composer failure.
# `FAILURES!`, `Failed asserting` and the `Tests: ... Failures:` summary are the
# verdict of tests that ran; folding them in here labelled a symfony/symfony run
# whose `composer update` step SUCCEEDED as `php_composer_failure` at 0.95 --
# sending the maintainer to debug dependency installation for a failed assertion in
# src/Symfony/Component/ErrorHandler. The bare `composer install`/`composer update`
# commands run green in nearly every PHP job (symfony echoes both as setup), so
# their presence is not a failure either. This rule now witnesses only genuine
# resolution/lock/platform errors and the autoload `Class ... not found`; a plain
# PHPUnit test failure carries no signal here and lands on `unknown`, the honest
# ceiling until PatchRail has a PHP test-failure class.
"patterns": [
r"\bcomposer install\b",
r"\bcomposer update\b",
r"Your requirements could not be resolved to an installable set of packages",
r"requires php",
r"Problem \d+",
r"lock file is not up to date",
r"not present in the lock file",
r"\bvendor/bin/phpunit\b",
r"\bphpunit\b",
r"FAILURES!",
r"Tests: .*Failures?:",
r"Failed asserting",
r"Class .* not found",
],
"reproduction_command": "composer install && vendor/bin/phpunit",
"minimal_repair_strategy": (
"Reproduce the failing Composer or PHPUnit command, then fix the narrow "
"composer.json, lockfile, PHP platform, autoload, or test drift before rerunning it."
"Reproduce the failing Composer command, then fix the narrow composer.json, "
"lockfile, PHP platform, or autoload map before rerunning it."
),
},
{
Expand Down
6 changes: 4 additions & 2 deletions tests/test_ci_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1372,7 +1372,8 @@ def test_ci_benchmark_checks_fixture_expectations(self) -> None:
"java_build_failure": {"failed": 0, "passed": 10, "total_cases": 10},
"javascript_lint": {"failed": 0, "passed": 11, "total_cases": 11},
"node_dependency_install": {"failed": 0, "passed": 22, "total_cases": 22},
"php_composer_failure": {"failed": 0, "passed": 6, "total_cases": 6},
"php_composer_failure": {"failed": 0, "passed": 5, "total_cases": 5},
"unknown": {"failed": 0, "passed": 1, "total_cases": 1},
"python_dependency_resolution": {"failed": 0, "passed": 28, "total_cases": 28},
"python_test_failure": {"failed": 0, "passed": 11, "total_cases": 11},
"ruby_bundle_failure": {"failed": 0, "passed": 12, "total_cases": 12},
Expand Down Expand Up @@ -1412,6 +1413,7 @@ def test_ci_benchmark_checks_fixture_expectations(self) -> None:
"javascript_lint",
"node_dependency_install",
"php_composer_failure",
"unknown",
"python_dependency_resolution",
"python_test_failure",
"ruby_bundle_failure",
Expand Down Expand Up @@ -1547,7 +1549,7 @@ def test_ci_benchmark_coverage_gate_can_require_depth_per_class(self) -> None:
failure["failure_class"]: failure
for failure in fail_payload["coverage_gate"]["failures"]
}
self.assertEqual(failing_classes["php_composer_failure"]["total_cases"], 6)
self.assertEqual(failing_classes["php_composer_failure"]["total_cases"], 5)
self.assertEqual(failing_classes["php_composer_failure"]["minimum_cases"], 7)

def test_ci_benchmark_rejects_negative_coverage_gate(self) -> None:
Expand Down
Loading