Quality Assurance
cats-eo is mostly a type-level library: a large part of its correctness is
discharged by the compiler before any test runs. The quality signals it tracks
reflect that, in roughly increasing cost-to-fool order:
- Types as tests — the composition matrix pins, at compile time, exactly which optic families compose with which (and at what strength), and which combinations are deliberately rejected. A regression that loosened or broke the lattice fails to compile.
- Discipline law suites —
cats-eo-lawsdefines the optic and typeclass laws;cats-eo-testsand the integration modules run them against concrete instances. - Statement / branch coverage (scoverage) — the project's
primary runtime-quality signal. See the
CLAUDE.mdcoverage note for why ~70–80 % is the expected ceiling: the remainder is pure type-level machinery with no runtime footprint, or code reachable only once a downstream carrier instance is added. - Mutation testing (stryker4s) — the strongest and
most expensive signal, and the one that historically did not pay its way
here. It was reintroduced once the
schemesmodule grew real runtime machinery (theArrayDequefold machine, the effectful M-drivers,PSVec) and the opaque-carrier compose work added runtime dispatch tocore.
The numbers below are regenerated from the live reports by
site/tools/gen-qa-report.py;
see Regenerating these numbers.
Composition matrix
Every (outer family ∘ inner family) pair either composes — import-free and
without a type ascription, landing at the strength described in the
optic taxonomy — or it does not compile, because the cell is
void by design (building through a non-invertible optic, writing through a
read-only one, reading through a write-only one). The grid is the pass/fail
projection of
CompositionMatrixSpec,
which is the single source of truth — if a cell starts needing an import or an
ascription, that spec goes red.
| outer ∘ inner | iso | lens | prism | optional | trav | getter | affold | fold | modify | review | unfold |
|---|---|---|---|---|---|---|---|---|---|---|---|
| iso | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| lens | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| prism | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| optional | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| trav | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| getter | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
| affold | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
| fold | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
| modify | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ |
| review | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✓ |
| unfold | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✓ |
✓ composes import-free at the strength shown in the optic taxonomy; ✗ does not compile (void by design — building through a read-only optic, reading through a write-only one, etc.). 87 composing / 34 void cells, pinned by CompositionMatrixSpec.
Coverage
Statement and branch coverage per package, from the cross-module scoverage
aggregate (sbt coverageAll). BC/SC is the branch-coverage ÷
statement-coverage ratio — a value well below 1 flags a package whose
conditionals are under-exercised relative to its straight-line code, even when
its statement coverage looks healthy. Packages with no branch statements show
—.
| Package | Statements | Stmt % | Branches | Branch % | BC/SC |
|---|---|---|---|---|---|
dev.constructive.eo |
33/35 | 94.3% | — | — | — |
dev.constructive.eo.accessor |
8/8 | 100.0% | — | — | — |
dev.constructive.eo.avro |
2020/2392 | 84.4% | 431/556 | 77.5% | 0.92 |
dev.constructive.eo.avro.circe |
205/213 | 96.2% | 32/34 | 94.1% | 0.98 |
dev.constructive.eo.avro.vulcan |
12/14 | 85.7% | — | — | — |
dev.constructive.eo.circe |
673/842 | 79.9% | 135/176 | 76.7% | 0.96 |
dev.constructive.eo.compose |
89/117 | 76.1% | 7/8 | 87.5% | 1.15 |
dev.constructive.eo.data |
1051/1182 | 88.9% | 185/205 | 90.2% | 1.01 |
dev.constructive.eo.forgetful |
28/28 | 100.0% | — | — | — |
dev.constructive.eo.generics |
410/494 | 83.0% | 70/87 | 80.5% | 0.97 |
dev.constructive.eo.jsoniter |
662/677 | 97.8% | 192/199 | 96.5% | 0.99 |
dev.constructive.eo.laws |
175/175 | 100.0% | 6/6 | 100.0% | 1.00 |
dev.constructive.eo.laws.data |
29/29 | 100.0% | — | — | — |
dev.constructive.eo.laws.data.discipline |
21/21 | 100.0% | — | — | — |
dev.constructive.eo.laws.discipline |
208/208 | 100.0% | — | — | — |
dev.constructive.eo.laws.discipline.internal |
21/21 | 100.0% | — | — | — |
dev.constructive.eo.laws.eo |
86/86 | 100.0% | — | — | — |
dev.constructive.eo.laws.eo.discipline |
129/135 | 95.6% | — | — | — |
dev.constructive.eo.laws.typeclass |
29/29 | 100.0% | — | — | — |
dev.constructive.eo.laws.typeclass.discipline |
36/36 | 100.0% | — | — | — |
dev.constructive.eo.optics |
503/567 | 88.7% | 55/59 | 93.2% | 1.05 |
dev.constructive.eo.schemes |
211/211 | 100.0% | 47/47 | 100.0% | 1.00 |
dev.constructive.eo.schemes.laws |
4/4 | 100.0% | — | — | — |
dev.constructive.eo.schemes.laws.discipline |
6/6 | 100.0% | — | — | — |
Mutation testing
stryker4s mutates each module's main sources and re-runs that module's own
test suite per mutant. Score (total) counts no-coverage mutants against the
score; Score (covered) is restricted to mutants on exercised lines. Timeout
mutants count as detected (the mutant visibly broke the run). Compile err
mutants — mutations that don't type-check, common in this codebase's
match-type / opaque-carrier code — are excluded from both scores.
StringLiteral mutants are excluded build-wide
(ThisBuild / strykerExcludedMutations): in this codebase string literals are
error messages, vestigial-arm labels, and discipline rule-set names — nothing
any suite asserts on, so they survive as pure noise and drown the genuine
survivors (in laws, 99 of 101 unfiltered survivors were rule-set name
labels). They appear as Ignored in the HTML reports and are counted in no
score.
Caveats that keep the table honest:
core— its behavioural suite lives in the separatetests/module (which depends onlawsandgenerics, so core can'tdependsOnit back — that's a project cycle).mutationAllinstead task-borrows the compiled suite: it appendstests/Test/definedTestsandtests/Test/fullClasspathto core's Test scope for the mutation run only. This is sound because stryker compiles every mutant into core's classes behind runtime switches (binary-compatible), so specs compiled against unmutated core still exercise the mutated bytecode. Core's row below is scored against the full cross-module suite.genericsis macro code: it expands at compile time, so its mutants leave no runtime footprint for a test run to cover. Mutation testing structurally can't score it — the derived-optic laws ingenerics/testguard it instead.lawshas no in-module tests, so it borrows thetests/suite the same way core does. Mutating the law definitions is the "who tests the tests" probe: a killed mutant means the discipline suites notice a corrupted law; a survivor pinpoints a law whose discriminating power nothing exercises. The surviving mutants are all law-weakening mutations (missIsEmpty's guard →falseinAffineFoldLaws,&&→||inModifyFLaws.functorIdentity/functorComposition): every instance under test satisfies the weakened law too, so nothing fails. Killing those requires negative fixtures — deliberately unlawful instances pinned to fail the suite — which is the concrete follow-up this table surfaces.
Two modules can't currently be scored, for reasons worth recording:
jsoniter— instrumentingPathParser.parseField(one large byte-cursor method) with its mutants overflows the JVM's 64 KB per-method bytecode limit, so the sandbox won't compile. It's un-mutatable in place without splitting the method or excluding it.avro— stryker's forked test-runner fails to initialise in the sandbox (the initial test run dies in well under a second, emitting no test output), even though the same specs pass under plainsbt test. Reproduced under both JDK 21 and JDK 25 with the default and legacy runners.
The high-signal rows are therefore core and laws (via the borrowed suite),
schemes, and circe — modules whose mutated code is genuinely exercised at
run time by the suite stryker runs.
| Module | Killed | Timeout | Survived | No cov | Compile err | Score (total) | Score (covered) | Notes |
|---|---|---|---|---|---|---|---|---|
core |
185 | 5 | 21 | 0 | 3 | 90.0% | 90.0% | Scored against the cross-module suite in tests/, task-borrowed into core's Test scope by mutationAll. |
laws |
85 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | Borrowed tests/ suite; the negative fixtures in UnlawfulFixturesSpec keep the law-weakening mutants dead — see prose. |
generics |
0 | 0 | 0 | 58 | 0 | 0.0% | — | Macro code: it expands at compile time, so mutants leave no runtime footprint for the test run to cover. |
schemes |
70 | 0 | 12 | 0 | 5 | 85.4% | 85.4% | |
schemes-laws |
1 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | Recursion-scheme laws (hylo fusion so far; more expected). Like laws, mutating it probes whether the law spec notices a corrupted law. |
circe |
35 | 0 | 3 | 15 | 0 | 66.0% | 92.1% | |
avro |
283 | 0 | 57 | 34 | 6 | 75.7% | 83.2% | Not scored: stryker's forked test-runner fails to initialise in the sandbox (the specs pass under plain sbt test). |
jsoniter |
321 | 4 | 77 | 0 | 0 | 80.8% | 80.8% | Not scored: instrumenting PathParser.parseField blows the JVM 64 KB method-size limit — one giant byte-cursor method, un-mutatable in place. |
Regenerating these numbers
# Statement / branch coverage (cross-module aggregate):
SBT_OPTS="-Xmx6g" sbt coverageAll
# → target/scala-3.8.3/scoverage-report/ (HTML + scoverage.xml)
# Mutation testing across the runtime-logic modules:
SBT_OPTS="-Xmx6g" sbt mutationAll
# → <module>/target/stryker4s-report/<ts>/ (index.html + report.json)
# Rewrite the tables above from those reports + CompositionMatrixSpec:
python3 site/tools/gen-qa-report.py # in place
python3 site/tools/gen-qa-report.py --check # CI: non-zero if stale
Both aliases relax the always-on -Werror (tlFatalWarnings) first, since
instrumented sources can surface -Wunused warnings; the larger heap is
because the set reapply re-evaluates the Laika docs settings. Mutation runs
with project <m>; stryker (not <m>/stryker) so specs2 is visible to the
test runner — see
project/plugins.sbt.
The quality.yml
workflow runs all three on every release tag (and on demand via
workflow_dispatch), uploading the HTML reports and the regenerated tables as
build artifacts.