Concepts¶
Every lens, table and finding is phrased in terms of five ideas.
Source unit¶
A source unit is the slice of the source tree Tepyd analyses as a whole (the row in every table, the thing that has or lacks tests). Units come from the units glob patterns, matched against src_root, first-match-wins.
There are two kinds, and the pattern decides which:
| Pattern | Yields | Unit name | Where its tests live |
|---|---|---|---|
"*", "modules/*" |
package units (directories only) | the path, e.g. modules/biz |
the mirrored directory, tests/<tier>/modules/biz/ |
"*.py", "core/*.py" |
module units (files only) | the stem, e.g. core/config |
test_config.py, both in the mirrored directory tests/<tier>/core/ and flat at the tier root |
A pattern whose last segment ends in .py yields modules; anything else yields packages. So units = ["*"] stays directories-only and units = ["*.py"] makes each top-level module a unit: the right choice for a flat package with no sub-packages.
Both kinds can be mixed. A layout with sub-packages and top-level modules wants units = ["*", "*.py"], or, to measure at module granularity throughout, something like ["core/*.py", "lenses/*.py", "*.py"].
Two rules keep discovery predictable:
- Anything whose path contains a part starting with
_or.is skipped:__init__.py,__pycache__,_private/,.hidden/. Module patterns additionally skiptest_*.py,*_test.py,conftest.pyandsetup.py. - A container whose children were already claimed is suppressed, so
modules/itself never doubles as a unit oncemodules/*exploded it.
Tier¶
A tier is one rung of the pyramid: a directory of tests of a given cost, e.g. tests/a_unit. Tiers are listed cheapest-first in config. The order matters throughout: the first tier is the unit tier, the last is the expensive cap. You can declare any number of tiers, rooted anywhere, including outside tests/.
Unit share¶
Unit share is the fraction of a unit's test code that lives in the cheapest tier. It is the headline pyramid-health metric, the one a tier's target_share gates.
Shape glyph¶
The shape glyph is a one-character read on a unit's pyramid, shown in the last column of shape:
| Glyph | Meaning |
|---|---|
▲ |
healthy: the cheapest tier is the largest and holds ≥ 40 % of test LOC |
◇ |
balanced: neither clearly healthy nor inverted |
▼ |
inverted: the most expensive tier is the largest and unit share < 30 % |
· |
no tests at all |
Layer awareness¶
By default every unit is judged against the same ideal: a broad unit base. That is right for a flat app and wrong for a layered one. In a hexagonal or onion architecture each layer has a natural tier: pure domain is unit-tested, the persistence layer integration-tested, the HTTP edge end-to-end-tested. Judging all three against the unit ideal turns real structure into a wall of findings that are correct by design.
Tell Tepyd which units each tier owns with expects, a list of globs over unit names:
[[tool.tepyd.tiers]]
name = "a_unit"
expects = ["domain", "domain/*", "services", "lib"] # pure logic
[[tool.tepyd.tiers]]
name = "b_integration"
expects = ["repositories", "infrastructure"] # the DB-bound layer
[[tool.tepyd.tiers]]
name = "c_e2e"
expects = ["web", "web/*"] # the HTTP edge
That one declaration changes three lenses:
gapsreports a unit outside a tier's scope as out of scope (n/a), and drops it from that tier'sX/Y mirroredfigure.shapejudges each unit against its expected home: a controller whose home is e2e is▲when its tests live at e2e.▼marks only test mass sitting above where it belongs. The codebase-widetarget_sharecheck likewise sets aside each unit's tests at its expected higher tiers before measuring.reportinherits both, so its findings fire only on genuine problems.
Two things expects does not do:
- It is not an exclusion. A unit with no behaviour to test anywhere, such as a pure
Protocol/ports layer, belongs inexclude, which drops it from every tier. - It never hides tests you wrote. Existing tests always count as present, even at a tier that did not expect them. Scope governs one thing only: whether an absence is a problem.
With no expects anywhere, every lens falls back to the classic, layer-blind behaviour.