cats-eo

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:

  1. 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.
  2. Discipline law suitescats-eo-laws defines the optic and typeclass laws; cats-eo-tests and the integration modules run them against concrete instances.
  3. Statement / branch coverage (scoverage) — the project's primary runtime-quality signal. See the CLAUDE.md coverage 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.
  4. 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 schemes module grew real runtime machinery (the ArrayDeque fold machine, the effectful M-drivers, PSVec) and the opaque-carrier compose work added runtime dispatch to core.

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:

Two modules can't currently be scored, for reasons worth recording:

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.