patchcov 0.1.1

Patch coverage for git diffs: attribute lcov, llvm-cov, Cobertura, Go and JaCoCo reports to a diff and gate on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
# Usage

`patchcov diff` attributes a per-line coverage report to a git diff and
reports **patch coverage** — the share of the lines a change *added* that are
covered by tests — plus the actionable list of uncovered new lines, per-file
project deltas, and indirect coverage changes on unchanged code.

It renders the PR coverage comment in CI, and it is a plain CLI command you can
also run locally to check a branch before you push.

## What it computes

Given a coverage report and a diff, it produces:

- **Patch coverage** — fraction of *added* lines that are covered (the headline
  number, and what `--fail-under-patch` gates on).
- **Overall line coverage** — the project-wide total (what `--fail-under-lines`
  gates on).
- **Uncovered new lines** — an actionable `file:line` list of added lines with no
  coverage (optionally collapsed into ranges, e.g. `9-11`, with
  `--collapse-ranges`).
- **Per-file project deltas** — each touched file's overall covered-line change
  (requires a `--baseline-report`).
- **Indirect coverage changes** — coverage that flipped on lines the diff never
  touched (also requires a baseline).

Line coverage only; branch-coverage data in the report is ignored.

## Inputs

- `--report <PATH>` (**required**, repeatable) — the head coverage report. Five
  formats are accepted and **auto-detected** from content: lcov trace files,
  llvm-cov JSON (`cargo llvm-cov report --json`), Cobertura XML, JaCoCo XML, and Go
  coverprofiles (`go test -coverprofile`, see [Go coverprofiles]#go-coverprofiles).
  Override detection with
  `--report-format <auto|lcov|llvm-cov-json|cobertura|jacoco|go-coverprofile>`. Pass it
  once per shard to merge a [sharded run]#sharded-runs, or merge the shards
  into one file first with [`patchcov merge`]#merging-shards-into-one-file.
- `--base-ref <REV>` / `--head-ref <REV>` — the revisions to diff. Defaults are
  the merge-base of `origin/main` and `HEAD` for the base, and `HEAD` for the
  head (the revision the report was measured at).
- `--baseline-report <PATH>` (+ `--baseline-report-format`) — an optional
  *base-side* report. Supplying it enables the project-delta and
  indirect-change sections; without it you still get patch coverage and the
  uncovered-line list.

## JaCoCo XML

Native JaCoCo reports from Java, Kotlin and Scala are auto-detected by their
`<report>` root, including reports with an XML declaration and JaCoCo DTD.
Use `--report-format jacoco` to select it explicitly. Both `patchcov diff`
and `patchcov merge` accept module reports and aggregate reports with groups.
A line with `ci > 0` is covered, including a partially covered line with both
missed and covered instructions. Instruction counts become boolean hits;
branch counters and class/method summaries do not contribute to line coverage.

Paths are `package/SourceFile.java`, relative to the source root. For example,
`com/example/App.java` may correspond to `src/main/java/com/example/App.java`
in git. Use [path mappings](#package-relative-and-source-root-relative-paths)
to prepend that source root before diff attribution. Overall line coverage and
shard merging also work with source-root-relative paths. Identical package/file
paths across modules merge by covered-line union, so unrelated sources with the
same path must be distinguished before merging.

## Quick start

```bash
# 1. Produce a per-line report for the working tree (example: cargo-llvm-cov).
cargo llvm-cov --no-report      # instrument + run tests
cargo llvm-cov report --lcov --output-path head.lcov

# 2. Attribute it to the diff against the default merge-base.
patchcov diff --report head.lcov

# 3. Gate a branch locally: fail if patch coverage is under 80%, or overall
#    line coverage is under 70%.
patchcov diff --report head.lcov --fail-under-patch 80 --fail-under-lines 70

# 4. Full report with project deltas, as JSON for tooling.
patchcov diff \
  --report head.lcov --baseline-report base.lcov \
  -o json
```

## Report paths and repository paths

Coverage attribution joins report filenames to git's repo-relative paths. A
mismatch can leave patch coverage empty even when overall coverage is present.
Check the `SF:` records (lcov), `filename` attributes (Cobertura), or package and
sourcefile names (JaCoCo) against `git ls-files` before trusting a patch gate.

The path pipeline for `patchcov diff` is:

1. Parse the report. For a native Go coverprofile, strip the module import path
   from the repository root's `go.mod`, if available.
2. Apply explicit `diff.path-mappings`, if configured.
3. Strip `--strip-prefix` (default: the checkout working directory), then trim
   leading `./` and `/`.
4. Apply filename exclusions, read source markers, and join to the git diff.

These steps apply to each head shard and the baseline independently. Cobertura
`<sources>` are metadata: the parser uses each class's `filename` and does not
choose or prepend a source root. JaCoCo uses `package/SourceFile.java`, without
its module or `src/main/java` directory.

### Absolute paths from another runner

For reports generated under a different checkout, remove that runner's root:

```bash
patchcov diff --report head.lcov \
  --strip-prefix /home/runner/work/project/project
```

One strip prefix applies to both head and baseline. If they came from different
roots, map each root explicitly instead:

```yaml
# .patchcov/config.yaml
diff:
  path-mappings:
    - from: /ci/head/project
      to: ''
    - from: /ci/base/project
      to: ''
```

### Package-relative and source-root-relative paths

Use explicit directory replacements when a tool omits a directory git retains:

```yaml
# .patchcov/config.yaml — choose the mappings for your report, not all examples
diff:
  path-mappings:
    # nyc invoked inside packages/web: src/app.ts -> packages/web/src/app.ts
    - from: src
      to: packages/web/src
    # JaCoCo: com/example/App.java -> services/api/src/main/java/com/example/App.java
    - from: com/example
      to: services/api/src/main/java/com/example
    # Go module below repo root (no root go.mod to infer this import prefix)
    - from: example.com/project/service
      to: services/api
```

An empty `from: ''` prepends `to` to every relative report filename, useful for a
single source root such as `src/main/java` or a coverage.py report that contains
only basenames. It does not match absolute filenames. An empty `to: ''` removes
the matched prefix. Matches respect directory boundaries (`src` cannot match
`src2`), and the longest prefix wins. Each path is mapped once; replacements are
not chained. With mappings enabled, backslashes become `/` and leading `./` and
trailing `/` on prefixes are removed, allowing Windows runner prefixes such as
`from: 'C:\agent\project'`. Destinations must be repo-relative without `..`;
duplicate normalised source prefixes and misspelled mapping fields fail loudly.
Absent or empty mappings preserve the existing path behavior.

Unmatched filenames retain their paths and continue through prefix stripping.
Aliases mapping to one git path combine by maximum line hits. Avoid mapping
unrelated files to the same path: their coverage would be unioned. If two modules
both emit `src/App.java`, a single combined report cannot distinguish them; make
filenames unique in each module report before combining. There is no filesystem
search or guessed source-root selection.

Mappings use the same config discovery and override rules as the ignore-list.
They belong to `patchcov diff`; `patchcov merge` does not read this block. Merge
repo-relative reports, use its `--strip-prefix` for a common runner root, or
normalise module reports before merging them.

## Using with other languages

The parser depends on the report format, not the source language. Run coverage
against the head revision, produce a report below, then use
`patchcov diff --report <report>` (and optionally a baseline produced
at the base revision). Reports must include per-line records; summary-only JSON,
HTML, Clover XML and OpenCover XML are not supported. Branch/function data is
ignored. Install/configure each tool in your project before using these examples.

- **JavaScript / TypeScript — Istanbul/nyc lcov.**
  `npx nyc --reporter=lcov npm test` writes `coverage/lcov.info`. Run from the
  repository root or map package-relative `SF:` paths. For TypeScript, configure
  instrumentation and source maps so the report names original `.ts` files, not
  emitted JavaScript. See [nyc's configuration]https://github.com/istanbuljs/nyc.
- **Python — coverage.py Cobertura.**
  `coverage run -m pytest` followed by `coverage xml -o coverage.xml`.
  Prefer `[run] include = ...` over `source = ...` when you need complete paths;
  `source` can shorten filenames relative to source roots. Use mappings when
  needed; `<sources>` does not add the root automatically. See
  [coverage.py XML reporting]https://coverage.readthedocs.io/en/latest/commands/cmd_xml.html.
- **C / C++ — gcovr Cobertura.** Build with GCC `--coverage`, run the tests,
  then `gcovr --root . --cobertura coverage.xml` from the repo root. Set `--root`
  to the repository rather than a build subdirectory; filenames are relative
  to that root. See [gcovr's Cobertura output]https://gcovr.com/en/stable/output/cobertura.html.
- **Swift — llvm-cov lcov or JSON.** Run `swift test --enable-code-coverage`,
  obtain the test binary and profile under the build directory, then run
  `xcrun llvm-cov export <test-binary> -instr-profile=<profile.profdata> -format=lcov > coverage.lcov`.
  Omit `-format=lcov` for llvm-cov JSON; do not use `-summary-only`. Supply the
  instrumented binary for your platform and strip the original checkout prefix.
  See [llvm-cov export]https://www.llvm.org/docs/CommandGuide/llvm-cov.html#llvm-cov-export.
- **Ruby — SimpleCov with simplecov-lcov.** In the test helper, before loading
  application code, require `simplecov` and `simplecov-lcov`, set
  `SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true`,
  set `SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter`, then call
  `SimpleCov.start`. Run `bundle exec rspec` (or your test runner) and pass the
  generated `.lcov` file under `coverage/lcov/`. The filename can be configured;
  see [simplecov-lcov]https://github.com/fortissimo1997/simplecov-lcov.
- **PHP — PHPUnit Cobertura.**
  `XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-filter src --coverage-cobertura coverage.xml`.
  Enable Xdebug coverage mode or install PCOV, and set the source filter (or its
  XML configuration equivalent). See [PHPUnit coverage]https://docs.phpunit.de/en/12.5/code-coverage.html.
- **.NET — coverlet Cobertura.** For VSTest projects with `coverlet.collector`,
  `dotnet test --collect:"XPlat Code Coverage"` writes
  `TestResults/<id>/coverage.cobertura.xml`. Map source-root-relative filenames
  or runner prefixes as needed. Microsoft Testing Platform projects use
  `coverlet.MTP` and its `--coverlet --coverlet-output-format cobertura` options
  instead. See [coverlet integrations]https://github.com/coverlet-coverage/coverlet.
- **Dart — package:coverage lcov.**
  `dart pub global activate coverage` then
  `dart pub global run coverage:test_with_coverage` produces `coverage/lcov.info`
  from a package root. Explicit formatting uses `--lcov` and
  `--packages=.dart_tool/package_config.json` to resolve package URIs to source
  files; map or strip the resulting paths. See [package:coverage]https://pub.dev/packages/coverage.
- **Scala — scoverage Cobertura.** `sbt clean coverage test coverageReport`
  produces `target/scala-<version>/coverage-report/cobertura.xml` in each module.
  Map module/source prefixes as necessary. Do not pass `scoverage.xml`, whose
  schema is different. See [sbt-scoverage]https://github.com/scoverage/sbt-scoverage.
- **Go — native coverprofile.** `go test ./... -coverprofile=coverage.out`;
  see [Go coverprofiles]#go-coverprofiles for block-to-line semantics and root
  module inference. A nested module needs an explicit import-prefix mapping.
- **Java / Kotlin — native JaCoCo XML.** With the JaCoCo Maven plugin configured,
  `mvn verify` (with its report goal bound) produces `target/site/jacoco/jacoco.xml`;
  with Gradle's JaCoCo plugin, `./gradlew test jacocoTestReport` produces XML only
  if `reports.xml.required = true` is configured. Map the package path to the
  correct module's source root; see [JaCoCo XML]#jacoco-xml.

The [non-Rust fixtures](../tests/fixtures/non-rust/README.md) record
producer provenance and exercise git patch attribution without installing all
these toolchains to run the Rust tests.

## Output formats

`-o`/`--output` selects the renderer:

- `markdown` (default) — the PR-comment layout. The markdown footer can carry CI
  context via `--artifact-url`, `--run-url`, `--base-sha`, `--head-sha`, and
  `--commit-url` (a SHA-link prefix); these only affect the rendered links.
- `yaml` / `json` — structured output for scripting and downstream tooling.

## Gating

`--fail-under-patch <PCT>` makes the command exit non-zero when patch coverage is
below `<PCT>` percent, so it can fail a CI step or a local pre-push check. Without
it the command only reports and always exits zero.

`--fail-under-lines <PCT>` is the overall counterpart: it exits non-zero when
line coverage across the whole head report is below `<PCT>` percent. It gates on
the same `Total` the markdown headline prints, so it moves with the
`--ignore-filename-regex` list and `ignore` markers, exactly as the patch gate
does. Both gates can be set together, and every gate that fails is named in the
error.

- **The figure is per-line, not llvm-cov's summary.** It is covered lines over the
  distinct executable lines in the report's per-line records (an lcov's `DA:`
  records, or the JSON export's `segments`), so it differs from
  `cargo llvm-cov report --summary-only`, which counts per *function record* and
  so counts some lines more than once. Pick a threshold against this command's
  number, not against that one. On this repository's own unit-test suite (about
  97% covered) the per-line figure was **0.17 pp higher**; the cause and its
  direction in general are in
  [Why the total differs from llvm-cov's summary]#why-the-total-differs-from-llvm-covs-summary.
  `ignore` markers and `--ignore-filename-regex` move the gate figure further,
  since llvm-cov's summary knows nothing of them.
- **An unmeasurable total fails.** A report with no executable lines — empty, or
  fully excluded by `--ignore-filename-regex` — fails `--fail-under-lines`. This is
  deliberately unlike `--fail-under-patch`, where "no added lines" is a property of
  the change and passes. A gate that passed on an empty report would let a
  silently failed coverage run through.

Because the gate needs only the report, it works wherever `patchcov diff` does —
including when the report was produced elsewhere (for example, from several CI
jobs, see [Sharded runs](#sharded-runs)) and the profile data is not on the
machine running the gate.

## Why the total differs from llvm-cov's summary

One `cargo llvm-cov` run yields two different line counts, and the lcov it writes
contains both:

| Count                                                       |   Lines | Covered | Uncovered |       % |
|-------------------------------------------------------------|--------:|--------:|----------:|--------:|
| `llvm-cov report --summary-only` (the `TOTAL` row)          | 331,417 | 321,311 |    10,106 | 96.9507 |
| lcov `LF:` / `LH:` records, summed over files               | 331,417 | 321,311 |    10,106 | 96.9507 |
| lcov `DA:` records, counted — **what `patchcov diff` uses** | 311,968 | 302,979 |     8,989 | 97.1186 |

(A Rust library suite of 567 files, `cargo-llvm-cov` 0.8.7, rustc 1.98 /
LLVM 22.1.8.) llvm-cov writes `LF`/`LH` from the summary, so even a single lcov
disagrees with its own `DA` records. `patchcov diff` counts the `DA` records — and,
for the JSON export, rebuilds lines from `segments` — and never reads `LF`/`LH` or
the export's `summary`/`totals`, which hold the summary's figure too. The lcov and
the JSON export of one run give the same gate figure (identical to five decimal
places on this suite), and tests pin both that and which records are read.

**The summary counts per function record; the per-line view counts per line.** The
two agree until a line is mapped by more than one function record. Rebuilding the
summary from each function record's own lines (`llvm-cov show -name=<function>`, all
36,079 records) reproduces `files[].summary.lines` for every one of the 567 files and
accounts for the whole gap:

| Effect (summary − per-line)                                   |   Lines | Covered | Uncovered |
|---------------------------------------------------------------|--------:|--------:|----------:|
| 1. a line mapped by several function records counts once each | +19,449 | +18,435 |    +1,014 |
| 2. a generic's instantiations merge by `max`, not by union    |       0 |    −105 |      +105 |
| 3. a nested record that never ran overrides its enclosing one |       0 |      +2 |        −2 |
| total                                                         | +19,449 | +18,332 |    +1,117 |

1. **Shared lines.** rustc emits a separate function record for every closure and
   every `async fn` body, in addition to the enclosing function's, and the enclosing
   record also maps some of the inner one's lines: the line a closure starts on, an
   `async fn`'s signature line, and parts of a `#[tokio::test]` body. The summary
   adds a line once per record that maps it; the per-line view adds it once. This is
   the whole line-count gap (507 of the 567 files) and about 0.135 pp of the
   0.168 pp. The lcov is not missing any code: its `DA` list is the same lines
   without the repeats. Classified by their source text, about 78% of the repeats
   here are in test code and about two thirds are in `async fn` bodies, chiefly
   `#[tokio::test]`s, with closures most of the rest.
2. **Generic instantiations.** The summary merges the instantiations of one generic
   function by taking the larger *covered* count and the larger *line* count
   separately; the per-line view calls a line covered if any instantiation ran it.
   When `f::<u8>` takes one branch and `f::<u16>` the other, the summary reports a
   missed line that the per-line view does not. 32 files, 105 lines, about 0.034 pp.
3. **Nested records.** Where an inner record (a closure) never ran but the enclosing
   function did, the file view takes the inner region's count for the lines they
   share and the summary takes the enclosing record's. Two lines in this suite
   (`src/cli/git.rs`), about 0.0006 pp.

**The direction depends on the code.** Effect 2 can only lower the summary relative
to the per-line figure, and effect 3 can only raise it (and needs a closure that never
ran inside a function that did). Effect 1 lowers it when the shared lines are covered
less often than the rest of the code (94.8% against 97.0% here) and raises it when
they are covered more often. That suite read higher under `patchcov diff`, so a
threshold carried over from `llvm-cov report --fail-under-lines` was lenient; a
codebase whose closures and async bodies are better covered than the rest could
read lower. Measure it rather than assume.

**Why the gate does not use the summary.** The summary is a sum over function
records, and neither an lcov's `DA` records nor the JSON `segments` say which
records map a line, so it cannot be rebuilt from the per-line report that patch
coverage, `ignore` markers, `--ignore-filename-regex`, shard merging and baselines
all work on. The JSON export's `summary` does carry it — per file only — but using
it would give up those features and count every shared line twice. The per-line
figure has one more property worth having: each source line counts once, which is
the unit patch coverage already uses.

**Measuring the gap on your own report.** Both counts are in one llvm-cov lcov:

```bash
cargo llvm-cov report --lcov --output-path head.lcov
awk -F'[:,]' '
  /^DA:/ { da++; if ($3 > 0) dah++ }
  /^LF:/ { lf += $2 }
  /^LH:/ { lh += $2 }
  END {
    if (!da) { print "no DA records in the report"; exit 1 }
    printf "summary  (LF/LH): %d of %d lines (%.4f%%)\n", lh, lf, lf ? 100 * lh / lf : 0
    printf "per-line (DA):    %d of %d lines (%.4f%%)\n", dah, da, 100 * dah / da
  }' head.lcov
```

An lcov whose `LF`/`LH` count its own `DA` records, as the lcov format defines
them, prints the same figure twice, and there is nothing to measure.

## Diff scoping

By default the project-delta and indirect-change sections are scoped to **files
the diff touches**. Coverage is measured by two independent instrumented runs
(baseline vs head), so lines in untouched files can flip covered↔uncovered purely
from run-to-run variance and surface as phantom deltas. Genuine cross-file
effects still surface via a magnitude-gated "notable unchanged" note. Pass
`--all-files` to restore the unscoped (noisier) report. Patch coverage is
unaffected by this scoping.

The **headline total** applies the same tolerance the per-file sections do: a
move smaller than 0.05 pp renders neutral (`⚪`), and the direction emoji is
taken from the *rounded* value that is printed beside it, so a delta displayed
as `0 pp` is never painted red or green. When the total does move but no
per-file row and no "unchanged files also moved" entry accounts for it, the
headline is annotated `_(not attributable to this diff)_` — the move is then, by
construction, cross-run variance spread across files rather than an effect of
the PR. The percentage itself is always the real measured value.

## Path normalisation

Report paths are made repo-relative by stripping the repository working-directory
prefix. Override the stripped prefix with `--strip-prefix <PATH>` when the report
was generated under a different root (for example, a container build path that
differs from the checkout location).

## Go coverprofiles

`go test -coverprofile=cover.out ./...` writes its own format, read natively with
no `gocover-cobertura` or `gcov2lcov` step. It is detected by its `mode: set`,
`mode: count` or `mode: atomic` header line, or named with
`--report-format go-coverprofile`. Sharded profiles work like any other: repeat
`--report`, or [merge them](#merging-shards-into-one-file) (a `cat` of profiles
is also read, since a repeated `mode:` line is accepted).

```bash
go test -coverprofile=cover.out ./...
patchcov diff --report cover.out
```

A profile records **blocks**, source ranges written `file:startLine.startCol,endLine.endCol
numStmts count`, not per-line hits, so they are expanded to lines:

- **Every line a block spans takes the block's count.** Where blocks overlap, the
  larger count wins, the rule for any line several regions cover. A line shared by
  two blocks (`} else {`) therefore reads covered if either ran, because the
  blocks are sub-line spans and the model is per line.
- **A line is executable if a block with statements spans it.** The total is the
  number of such lines, so it differs from the statement-weighted percentage of
  `go tool cover -func` (a long multi-statement block counts its lines, not its
  statements). A block with no statements, such as an empty function body, holds
  nothing to execute and is not a line. The reasons the total differs from a
  tool's own summary are the ones under [Why the total differs from llvm-cov's
  summary](#why-the-total-differs-from-llvm-covs-summary).
- **Lines between a block's first and last statement all count.** Go starts a
  block at its first statement and ends it at its last, so a comment before the
  first statement or after the last is outside it. The profile does not say which
  lines in between hold code, so a blank or comment line there is an executable
  line, covered or not with the block. `ignore` markers can mask such lines.
- **`set`, `count` and `atomic` read the same way.** Only whether a count is zero
  matters to the output.

Go names files by import path (`github.com/org/repo/pkg/a.go`), not by path on
disk. The module path declared by the `go.mod` at the repository root is stripped
from them, which makes them repo-relative. When the module is **not** at the root
(a `services/api/go.mod`) or there is no `go.mod`, nothing is stripped and no file
matches the diff; pass the import path of the repository root instead, so the
subdirectory stays in the path: `--strip-prefix github.com/org/repo`.
`--ignore-filename-regex` sees the mapped paths. The `go.mod` is the one in the
working tree and is applied to `--baseline-report` too, so a baseline measured
before a module rename needs `--strip-prefix`.

## Sharded runs

When the instrumented test run is split across several concurrent CI jobs, each
job produces its own report. Pass them all to one aggregation step, one `--report`
each, and everything — the patch gate, `--fail-under-lines`, the deltas, the PR
comment — is computed over the merged result:

```bash
patchcov diff \
  --report shard-1.lcov --report shard-2.lcov --report shard-3.lcov \
  --baseline-report base.lcov \
  --fail-under-patch 80 --fail-under-lines 70
```

**How shards combine.** The merge is a *union*: the files are unioned, and for a
line present in several shards the larger hit count wins. In practice:

- A line **any** shard covered is covered, which is the point of sharding.
- A line only **one** shard instrumented is judged by that shard alone — it is
  neither covered nor uncovered "elsewhere". With a normal run every shard
  instruments the same code and lists every line, so this only matters when
  shards are built from different configurations.
- Hit counts are **maxed, not summed**. Nothing in the line-coverage output reads
  the count itself, so a total across shards is not available.
- Shard order never changes the output, and each shard may be in a different
  format (they are detected independently; an explicit `--report-format` applies
  to all of them).

**A bad shard cannot lower coverage unnoticed.** Merging ignores a shard that
contributes nothing, so a shard whose job failed would otherwise just make the
result look slightly worse. With more than one `--report`:

- a shard that is missing, unparseable, or has **no executable lines** fails the
  run, naming the shard (checked before `--ignore-filename-regex`, so a shard that
  only covers excluded files is not mistaken for a failed one);
- a shard whose absolute paths **all** fall outside the `--strip-prefix` root draws
  a warning on stderr: it was probably measured under a different workspace root,
  and its files would key under paths that exist nowhere in the repository. A shard
  with some in-tree paths is left alone, because reports legitimately name a few
  out-of-tree files (the standard library, vendored sources).

A single `--report` behaves exactly as it did before sharding existed: neither
check applies to it.

**Limits.**

- `--strip-prefix` is one value, so every shard has to share a workspace root.
  Shards from the same CI runner image do; a mix of, say, Linux and macOS runners
  does not, and the warning above is the signal.
- `--baseline-report` takes **one** report, so a baseline from a sharded run must
  be a single file. Make it with [`patchcov merge`]#merging-shards-into-one-file.
- The merge-base baseline recompute and `codecov.json` /
  `coverage-summary.txt` come from `cargo llvm-cov` in the reusable action and are
  not part of this command.

## Merging shards into one file

`patchcov diff --report` merges shards on the fly, but a single file is the
contract in some places: `--baseline-report` takes one report, a baseline publish
uploads one file, and so does a codecov upload. `patchcov merge` writes that file:

```bash
patchcov merge shard-1.lcov shard-2.lcov shard-3.lcov -o merged.lcov

# The merged file is an ordinary report, and the baseline a sharded run could not give.
patchcov diff --report head.lcov --baseline-report merged.lcov
```

- **Same merge, same checks.** The files are unioned and, for a line present in
  several inputs, the larger hit count wins, exactly as for repeated `--report`
  (see [how shards combine]#sharded-runs). Each input may be lcov, llvm-cov JSON,
  Cobertura, JaCoCo or a Go coverprofile, detected per file; `--report-format` applies to all of them. An
  input that is missing, unparseable or has no executable lines fails the run and
  names it — **including a lone input**, unlike `patchcov diff`, because the output
  is trusted by whatever reads it next. An input whose absolute paths all fall
  outside the strip prefix draws a warning on stderr; the file is still written,
  so check stderr when the merged file is published unattended. Both commands
  judge a shard with the same function, so the checks cannot drift apart.
- **Same total.** Reading the merged file as a single `--report` gives the same
  total, patch coverage and rendered output as passing the shards. On three real
  `cargo llvm-cov` shards of a small crate, the merged file, the shards and the
  single un-sharded run all reported 90.63%.
- **Deterministic.** The output is lcov with files sorted by path, each file's
  `DA` records sorted by line, and a trailing newline, so it is byte-identical
  for any order of the inputs.
- **Line coverage only.** The file carries `TN`, `SF`, `DA`, `LF`, `LH` and
  `end_of_record`. Function (`FN*`) and branch (`BRDA`) records are dropped — the
  model has no place for them — which is fine for `patchcov diff` and for a
  codecov line view but not for a consumer that wants branches. `LF`/`LH` count the
  `DA` records, so unlike `llvm-cov`'s own lcov the file agrees with itself (see
  [why the total differs]#why-the-total-differs-from-llvm-covs-summary). A file
  with no executable lines is kept as an empty record, so the text reads back as
  exactly the merged report. A path with a line break or leading or trailing
  whitespace, which lcov cannot carry, fails the merge.
- **Repo-relative paths.** Paths are written with the repository working
  directory stripped (found from `-C/--repo` or the current directory), or with
  `--strip-prefix <PATH>` when the shards were measured under another root. The
  file therefore reads back the same on any runner. A shard measured under
  another root would otherwise key the same file under a second path and never
  union with the others, which is what the shard warning under [Sharded runs]#sharded-runs is for. Outside a
  repository, and with no `--strip-prefix`, paths are written as the reports have
  them. A path outside the prefix is normalised as `patchcov diff` normalises it
  (a leading `./` or `/` is dropped), so a standard-library path such as
  `/rustc/<hash>/library/…` comes out without its leading `/`. If the repository
  exists but cannot be opened (say, a checkout owned by another user), the merge
  fails and asks for `--strip-prefix` rather than writing absolute paths.
- **Atomic.** Every input is read and checked before anything is written, and
  the file is replaced through a temporary file and a rename, through a symlink
  if the path is one and keeping an existing file's mode: a merge that fails
  creates nothing and leaves an existing file as it was, and `-o` may name one of
  the inputs. `-o` is a **path** here, whereas `patchcov diff -o` selects an
  output format, so a bare `markdown`, `yaml`, `json` or `lcov` is refused as a
  probable slip (write `./json` for a file of that name).
- **Not applied: filters.** `--ignore-filename-regex`, `.patchcov/config.yaml`
  and `ignore` markers are applied by `patchcov diff`, to the head and baseline
  alike, so the merged file stays a complete record.

**Do not join lcov files with `cat`.** It is equivalent to merging for this
command's parser, which unions repeated records, but `cargo llvm-cov` writes no
newline after its final `end_of_record`, so `cat shard-*.lcov` glues one shard's
last record onto the next shard's `SF:`. Earlier releases of this command's parser
silently dropped that file and misattributed the next one's lines (about 0.14 pp
low on this repository's own test suite), and other lcov consumers may do the same.
A join has no empty-shard check either: a failed shard just makes the total look
slightly worse. `patchcov merge` has neither problem.

## Excluding files (CPU-conditional / non-deterministic coverage)

Some source files have coverage that is **inherently non-deterministic across
runs** — a region gated on a *runtime* CPU-feature check (`is_x86_feature_detected!`,
runtime SIMD-level dispatch) is compiled and counted in the denominator but only
*executed* on a host whose CPU has the instruction. When the baseline and head
runs draw different runner CPUs, the file's coverage swings with no source
change, surfacing as phantom deltas or in the "unchanged files also moved" note.

`--ignore-filename-regex <REGEX>` excludes files whose repo-relative path matches
any of the given regexes from **both** the head and baseline reports before the
diff. Filtering both sides symmetrically keeps the total, per-file deltas, patch
coverage, indirect-change list, and the `--fail-under-patch` gate computed over
the same denominator, so an excluded file can never produce a spurious "moved"
entry. Matching is **unanchored** (partial), the same semantics as
`cargo llvm-cov --ignore-filename-regex`, and is applied **after** `--strip-prefix`
normalisation, so patterns match the repo-relative path. The flag is repeatable
and comma-separated; an empty pattern is treated as a no-op (a bare regex would
match every path).

### The comment says what the filter excluded

A filter leaves nothing in the numbers: a smaller total and an empty patch look
exactly like a diff that added no code. So whenever the filter removed at least one
file from a report, the comment says so:

```
_Excluded by ignore-filename-regex: 3 files (1 of them touched by this diff, adding 40 executable lines)._
```

The clause after the touched count appears when the diff added executable lines to
excluded files: those are lines the filter took out of the patch denominator, so a
mixed diff is visible even though it still shows a patch percentage. The files the
diff touched are listed beneath it (the first 20; the rest are counted),
and `-o json`/`-o yaml` carry an `excluded_files` object with `count`, `touched_count`,
`new_executable_lines`, every excluded path in `paths`, and the touched subset in
`touched`. Only files that were *in a report* count, as a head or a baseline entry,
once each; a path matching the regex that no report mentioned was never measured.

The empty-patch sentence tells its two causes apart. `_No new executable lines added
by this diff._` means the diff added no instrumented code. When the filter removed
files in which the diff added executable lines, it reads `_No new executable lines
in the files measured: 3 new executable lines are in files excluded by
ignore-filename-regex._` instead, and `excluded_files.new_executable_lines` holds the
count. Nothing is shown when the filter removed nothing, so a run with no filter, or
one that matches no file, renders exactly as before. The gates are unchanged: a patch
with no measured lines still passes `--fail-under-patch`.

### Declaring the ignore-list persistently in repo config

Because these files are CPU-conditional forever — a property of the
*repository*, not of a single command line — the ignore-list can be declared once
in version control under a `.patchcov/` directory at the repository root.
Create `.patchcov/config.yaml`:

```yaml
# .patchcov/config.yaml
diff:
  # repo-relative path regexes; same unanchored semantics as
  # --ignore-filename-regex, applied after --strip-prefix normalisation
  ignore-filename-regex:
    - 'src/bits/popcount\.rs'   # AVX-512 VPOPCNTDQ path is CPU-gated
    - 'src/dsv/simd/.*'         # runtime SIMD dispatch fallback arms
    - 'src/yaml/simd/.*'
```

- **Discovery** is the same for every command: `--config-dir <PATH>` wins, else
  `PATCHCOV_CONFIG_DIR`, else the nearest `.patchcov/` found walking up from the
  repo root (never past the repository boundary). There is no user-level or
  machine-level file: a setting that changes what a coverage gate reports
  belongs in version control, where a reviewer can see it.
- **Union, not replacement.** The config list is set-unioned with any
  `--ignore-filename-regex` passed on the command line, so the flag still works
  and only ever *adds* to the config list.
- **Empty / missing is a no-op** — behavior is unchanged when the file is absent
  or its list is empty. A present-but-malformed `config.yaml`, or an invalid
  regex from either source, is a hard error (it fails loudly rather than
  silently letting the excluded noise back in). Unknown keys are ignored, so the
  schema can grow without breaking older binaries.

This is the recommended path when patchcov runs **through a wrapper** (a CI
action, a task runner) that does not thread the flag through: patchcov reads
`.patchcov/config.yaml` directly from the checkout, so no wrapper change is
needed.

## Excluding or tolerating regions in source

Excluding a whole file is usually too blunt. The noise is typically **one
function** — a CPU-gated dispatch helper is ten lines inside a two-hundred-line
file, and ignoring the file hides far more real coverage than noise.

Config cannot name the region either: line numbers are invalidated by every edit
above them, and function *extents* are absent from the lcov `FN:` records (they
carry a start line and a mangled symbol, nothing more). So the region is
delimited **in the source**, with a comment that travels with the code:

```rust
// patchcov: coverage tolerate reason="CPU-gated: the avx512f arm only executes on Zen 4+ runners"
fn detect_fast_bmi2() -> bool {
    if is_x86_feature_detected!("avx512f") {
        return true;
    }
    cpuid_amd_zen3_or_later()
}
// patchcov: coverage end
```

patchcov scans **each revision's own source** — head from the working tree, base
from the base revision's blob — so no line number is ever recorded, and a region
that moves, grows, or disappears between base and head is handled by
construction.

### The two kinds

| Kind       | Effect on the percentages (total, per-file, patch)     | Effect on the delta signals (headline Δ, per-file Δ, notable, indirect) |
|------------|--------------------------------------------------------|-------------------------------------------------------------------------|
| `ignore`   | lines removed from **both** reports before analysis     | none — the lines no longer exist                                        |
| `tolerate` | lines kept; the reported coverage stays honest          | flips **masked**: each tolerated head line is scored with its baseline hit status |

`ignore` is the scoped twin of `ignore-filename-regex`. `tolerate` is the new
capability: **the number stays truthful, but the noise stops moving the needle.**

**Prefer `tolerate`.** Reach for `ignore` only when the code is genuinely
unreachable on CI and counting it in the denominator is simply wrong. A
tolerated region still shows its real coverage in the total and in the per-file
table; it just cannot manufacture a delta.

### Syntax

```text
patchcov: coverage ignore reason="…"     … patchcov: coverage end
patchcov: coverage tolerate reason="…"   … patchcov: coverage end
patchcov: coverage ignore-line reason="…"
patchcov: coverage tolerate-line reason="…"
```

- **Matching is a plain substring** anywhere on a line, so any comment syntax
  works — `//`, `#`, `--`, `<!-- -->`. Nothing parses the host language, which
  also means a marker inside a string literal *is* matched.
- **Both marker lines are inside the region.** They are comments, so they are
  never executable and never appear in a report.
- **`reason="…"` is mandatory** on every region start and single-line marker.
  Silencing has to be explained at the site.
- **Malformed markers are hard errors** naming `path:line`: a missing or empty
  reason, an unterminated quote, a nested region, an `end` with no start, or a
  region left open at end-of-file. An unterminated region is never widened
  silently to EOF — that would silence an unbounded amount of code nobody looked
  at.

### Check marker syntax locally

```bash
patchcov lint-markers
patchcov lint-markers src/bits/popcount.rs
patchcov lint-markers -C /path/to/repo src/bits/popcount.rs
patchcov lint-markers --include 'src/**/*.py' --include 'scripts/*.sh'
```

With no paths, the command checks every tracked text file in the Git index
against its current working-tree contents, including staged additions and
unstaged edits — in any language, since a marker is a plain substring in any
comment syntax. Deleted worktree files are skipped, as are symlinks and
submodules, and so is any file that is not valid UTF-8: binaries are never
flagged. Explicit paths select only those files, including untracked files, and
are relative to the repository root unless absolute; a non-UTF-8 file named
explicitly is skipped too, so a hook can pass every staged path. Malformed
markers print the same `path:line` diagnostics as `patchcov diff` and make the
command exit nonzero. No coverage report or `llvm-cov` run is needed.
`scripts/build.sh` runs this check after formatting.

To narrow the default scan, pass `--include <GLOB>` (repeatable) or declare the
same list in `.patchcov/config.yaml`:

```yaml
# .patchcov/config.yaml
lint-markers:
  include:
    - 'src/**/*.py'
    - '**/*.rs'
```

- **Globs** match the repo-relative, `/`-separated path. `*` stays within one
  path component and `**` crosses directories, so `*.py` is a top-level file and
  `**/*.py` is any. A file is scanned when it matches *any* glob; an invalid glob
  is a hard error.
- **The flag replaces the config list** rather than adding to it (unlike
  `diff.ignore-filename-regex`, which only ever grows), so a one-off `--include`
  can narrow a repo-wide list. With neither, every tracked text file is scanned.
- **Explicit paths ignore both**: naming a file is a selection, so `--include`
  and the config list apply only to the default scan.
- Discovery of `config.yaml` is the one `patchcov diff` uses
  (`--config-dir`, else `PATCHCOV_CONFIG_DIR`, else a walk-up for `.patchcov/`).
  A malformed file is a hard error, but a
  misspelled key is ignored, like every unknown key. A glob that matches no
  tracked file scans nothing and prints a warning.

Before this command scanned every tracked file it checked only `.rs` files. A
repository that runs it bare, in CI or a hook, may now see errors from files
that document the marker syntax (this repository's own `docs/` do, so its
`config.yaml` sets `include: ['**/*.rs']`). Restoring the old scope is one
line: `--include '**/*.rs'`, or that glob under `lint-markers.include`.

### How masking works, precisely

For each head file, the tolerated head lines are scored with the hit status of
their **base counterpart** (found through the same diff alignment used for
indirect changes; identity for a file the diff never touched). A tolerated line
with no counterpart — an added line — keeps its real status.

That effective coverage is what feeds the headline Δ, the per-file Δ, the
"unchanged files also moved" gate, and the indirect-change list. The **displayed
percentages are always the real, measured values**. So the motivating case
renders as `Total: 92.79% ⚪ 0 pp` rather than `🔴 -0.01 pp`, with `92.79%` still
the true number.

Two consequences worth stating outright:

- **Added lines inside a `tolerate` region stay in the patch-coverage
  denominator.** New code should still be tested even if it flaps later. Added
  lines inside an `ignore` region leave it, because they are gone from the head
  report entirely.
- **Without a baseline** (`--baseline-report` absent), `ignore` still applies —
  it shapes the total and the patch — while `tolerate` is a no-op, since there
  are no deltas to mask.

### Markers are never invisible

Whenever a marker applies, the markdown comment gains a collapsed note listing
each region's path, kind, line span as observed at head, and reason; the YAML and
JSON views carry the same information in a `markers` array. A reviewer can always
see what was silenced and why.

### Interaction with the file-level ignore-list

File-level exclusion wins. A file excluded by `--ignore-filename-regex` or
`config.yaml` is never read, so markers inside it are never scanned — and never
report errors.

## CI usage

A pull-request job needs three steps:

1. Measure the head commit and write a report (`head.lcov` above).
2. Measure the merge base the same way, for project deltas and indirect changes
   (`base.lcov`). This is optional: patch coverage needs only the head report.
3. Run `patchcov diff` and post its markdown as the PR comment, failing the job
   on the gates:

```bash
patchcov diff \
  --report head.lcov --baseline-report base.lcov \
  --base-ref "origin/${GITHUB_BASE_REF}" \
  --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
  --fail-under-patch 80 > coverage-comment.md
```

The markdown is written to stdout, and the exit status reflects the gates, so the
comment can still be posted when a gate fails: capture the status, post the file,
then exit with it. A sharded run passes one `--report` per shard, or merges them
first with [`patchcov merge`](#merging-shards-into-one-file).

## Flag reference

| Flag | Purpose |
|------|---------|
| `--report <PATH>` | Head coverage report (required); repeat once per shard to merge a [sharded run]#sharded-runs |
| `--report-format <FMT>` | Format of every `--report`: `auto` (default) \| `lcov` \| `llvm-cov-json` \| `cobertura` \| `jacoco` \| `go-coverprofile` |
| `--base-ref <REV>` | Base revision (default: merge-base of `origin/main` and `HEAD`) |
| `--head-ref <REV>` | Head revision the report was measured at (default: `HEAD`) |
| `--baseline-report <PATH>` | Base-side report; enables project deltas + indirect changes |
| `--baseline-report-format <FMT>` | Format of `--baseline-report` (auto-detected by default) |
| `-o, --output <FMT>` | `markdown` (default) \| `yaml` \| `json` |
| `--fail-under-patch <PCT>` | Exit non-zero when patch coverage is below `<PCT>` |
| `--fail-under-lines <PCT>` | Exit non-zero when overall line coverage is below `<PCT>`, or the report has no executable lines |
| `--collapse-ranges` | Collapse consecutive uncovered new lines into ranges |
| `--all-files` | Report deltas/indirect changes for all files, not just touched ones |
| `--strip-prefix <PATH>` | Prefix stripped from report paths to make them repo-relative |
| `--ignore-filename-regex <REGEX>` | Exclude matching files from both reports (repeatable/comma-separated); unioned with `.patchcov/config.yaml` |
| `--config-dir <PATH>` | Config dir searched for `config.yaml` (default: discovered `.patchcov/`, honoring `PATCHCOV_CONFIG_DIR`) |
| `-C, --repo <PATH>` | Operate as if started in `<PATH>` (like `git -C`) |
| `--artifact-url` / `--run-url` / `--commit-url` | Markdown-footer CI links |
| `--base-sha` / `--head-sha` | SHAs shown in the markdown `Comparing` line |

`patchcov merge` takes:

| Flag | Purpose |
|------|---------|
| `<REPORT>...` | Reports to merge, one per shard (required); lcov, llvm-cov JSON, Cobertura, JaCoCo or Go coverprofile |
| `--report-format <FMT>` | Format of every report: `auto` (default) \| `lcov` \| `llvm-cov-json` \| `cobertura` \| `jacoco` \| `go-coverprofile` |
| `-o, --output <PATH>` | File to write the merged lcov report to (required); a path, not a format |
| `--strip-prefix <PATH>` | Prefix stripped from report paths (default: the repository working directory) |
| `-C, --repo <PATH>` | Operate as if started in `<PATH>`; relative paths and the default prefix follow it |