Metric types¶
Every metric has a type from the table below, a name, and the params its
type requires. tingle list --types prints the same table from your
installed version, and works without a config file.
| Type | Params | Counts |
|---|---|---|
regex_count |
pattern (positional), flags, ignore_lines |
regex matches in the range's files |
regex_spread |
pattern (positional), flags, ignore_lines |
files the regex matches in, however often |
symbol_uses |
symbol (positional), ignore_lines |
references to a function/class in Python files |
symbol_spread |
symbol (positional), ignore_lines |
Python files referencing it, however often |
toml_list_length |
key (positional), file = pyproject.toml |
entries of the list at a dotted TOML key |
toml_table_array |
key (positional), file = pyproject.toml, label, explode |
entries of a TOML array of tables (e.g. [[tool.mypy.overrides]]) |
ini_list_length |
file, section, option |
comma/newline-separated entries of an INI option |
file_count |
over_lines |
files in the range, or only those longer than over_lines |
line_count |
— | lines in the range's files |
The positional param is what tingle add TYPE VALUE binds its VALUE
argument to. Everything else is set with --param key=value, repeatable —
except the two params that are not strings, flags and explode, which
add cannot write: put those in the TOML yourself.
regex_count¶
Counts regex matches across the files in the metric's ranges.
flags accepts IGNORECASE, MULTILINE, and DOTALL. Invalid patterns
are rejected at config validation time, not at run time.
Diff mode counts per line
Patterns containing newlines never match in diff mode, so the Total column can disagree with the diff columns for such patterns.
Excusing lines with ignore_lines¶
The four regex and symbol types all accept ignore_lines: a list of regexes
matched against the line a hit sits on. Any hit on a matching line is not
counted, does not appear among the occurrences, and leaves no trace in the
per-file details.
Some uses of a thing are not debt, and no range glob can separate them —
they live on different lines of the same file. ANY in an assertion is real
debt; ANY standing in for a form that cannot be compared is not:
A pattern is searched anywhere in the line, so it needs no anchoring and indentation cannot defeat it. Both sides of a diff are filtered against their own text, so a line excused on the branch is equally excused in the base — otherwise the net would count a removal it never counted as an addition.
Multi-line matches are tested on their first line
A regex_count match is located at the line it starts on, so that is
the only line ignore_lines sees.
symbol_uses¶
Counts references to a function or class in Python files — the metric for watching a strangler-fig migration retire a legacy class.
This is static analysis, and the distinction between a dotted and a bare symbol matters:
- A dotted symbol (
myapp.legacy.OldClient) follows import bindings — plain, aliased,from-imports, and best-effort relative imports — and counts the import of the symbol itself as one use. Attribute chains count once. - A bare symbol (
OldClient) counts every same-named name or attribute, which can overcount.
from x import * falls back to bare counting for that file, with a warning.
Re-exports, string references, and getattr are invisible to it.
Spread: regex_spread and symbol_spread¶
regex_count and symbol_uses measure volume — how many times a thing is
written. regex_spread and symbol_spread run the same search and measure
reach — how many files it has got into. A file with forty matches counts
once, exactly like a file with one.
Every param works as it does on the counting sibling: pattern, flags and
ignore_lines on regex_spread, symbol and ignore_lines on
symbol_spread, with the same positional argument for tingle add.
Why you would want the smaller number¶
Volume is the wrong measure when the goal is containment rather than removal.
Fixing a bug in a legacy module rewrites lines that already counted, so a
counting metric jumps and tingle check fails a branch that spread
nothing. Meanwhile a branch that adds one fresh import of the old base class in
a brand-new file barely moves the number at all — though that is the thing you
actually wanted to stop.
A spread metric inverts both. The bug fix nets zero. The new file is +1.
In a diff, what counts is crossing¶
The spread types do not measure the branch the way the counting types do. There is no line-by-line matching: both sides of every changed file are read whole and re-analysed, and what counts is crossing.
- A file that matches now and did not at the merge-base: +1.
- A file that matched then and does not now: −1.
- A file that matched before and matches still: 0, however much of it the branch rewrote.
Creating a file that matches, and deleting one that did, fall out of the same comparison — a created file has no base side, a deleted one no current side. Only changed files are examined, which is sound: a match cannot appear in a file the branch never touched.
$ tingle stat --diff
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━┳━━━━━━━┓
┃ Metric ┃ Type ┃ Added ┃ Removed ┃ Net ┃ Total ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━╇━━━━━╇━━━━━━━┩
│ legacy-uses │ symbol_uses │ +8 │ -3 │ +5 │ 💀 11 │
│ legacy-arch │ symbol_spread │ +1 │ 0 │ +1 │ 🔥 3 │
└───────────────┴───────────────┴───────┴─────────┴─────┴───────┘
$ tingle report --diff --metric legacy-arch
legacy-arch (symbol_spread): +1 / -0 (net +1)
+ src/d.py
One branch, two readings of it: eight references added and three removed, all but two of them churn inside files that already used the class — and exactly one file newly reaching for it.
regex_spread has no multi-line caveat
Because both sides are matched full-text, a pattern containing a newline
works in diff mode exactly as it does in the Total column, and
MULTILINE / DOTALL mean the same thing in both. The
warning above about regex_count in diff mode does not
apply here.
ignore_lines and the report¶
Excusing happens before the collapse, so a file whose every hit is excused does not count at all, while a file with one hit left over still counts once.
Each counted file is reported at its first hit, so tingle report gives a
line to open:
$ tingle report --metric legacy-arch
legacy-arch (symbol_spread): 🔥 3
src/a.py:1
src/b.py:1
src/d.py:1
The per-file hit count is kept in the details of tingle report --json, so
the machine-readable output still says how heavily each file is involved, not
merely that it is. For a spread metric those details therefore sum to more than
the value — three files holding eleven references between them is "value": 3
with details summing to 11. That is deliberate, and it is the one place the two
disagree.
It also means a spread metric and its counting sibling emit identical
details and differ only in value, which is the cleanest way to see what the
collapse did.
toml_list_length¶
The length of the list at a dotted key in a TOML file — how you count ignored lint rules.
[[metrics]]
name = "ruff-ignores"
type = "toml_list_length"
key = "tool.ruff.lint.ignore"
file = "pyproject.toml" # the default
If the key holds a table of lists (ruff's per-file-ignores, say), the
lengths are summed.
A missing file, a missing key, or malformed content is a warning plus a value of 0 — not an error. The file may legitimately not exist yet.
toml_table_array¶
Counts the tables of a TOML array of tables, such as
[[tool.mypy.overrides]].
[[metrics]]
name = "mypy-overrides"
type = "toml_table_array"
key = "tool.mypy.overrides"
label = "module"
explode = true
label names a field used to describe each occurrence, so the report reads
pyproject.toml: foo.* instead of a raw dict. A list-valued label is joined
with ,.
By default one table counts as one. explode = true instead counts each
element of the label list separately — one override silencing five modules
counts as five. It requires label.
ini_list_length¶
Entries of a comma- or newline-separated INI option. All three params are required.
[[metrics]]
name = "pylintrc-disables"
type = "ini_list_length"
file = ".pylintrc"
section = "MESSAGES CONTROL"
option = "disable"
file_count and line_count¶
Files in the metric's ranges, and total lines in those files. The range is the whole definition. Point them at a package that should disappear.
line_count is the usual candidate for [check] ignore — lines of code are
expected to grow. See the CI gate.
Counting only the oversized files¶
file_count takes an optional over_lines: a gate that counts only the
files longer than it. It answers "how many files are over 1k lines", which
neither metric could on its own — line_count sums its per-file
measurements into one total, and a plain file_count counts everything.
The gate is strict: over_lines = 1000 counts a file of 1001 lines, not
one of exactly 1000. Each counted file carries its length, so tingle report
says by how much a file is over, not merely that it is:
$ tingle report --metric huge-files
huge-files (file_count): 🚨 2
src/legacy/api.py: 1420 lines
src/legacy/models.py: 1077 lines
In a diff, what counts is crossing the gate rather than being created: a file that grows past it is new debt though it already existed, and one refactored back under it is debt paid off. Creating a file above the gate and deleting an oversized one fall out of the same comparison.
Without over_lines, file_count behaves exactly as before and opens no
files at all.
The config-file types read files, not ranges¶
toml_list_length, toml_table_array, and ini_list_length read the file
named by their file param, resolved relative to the project root. They
ignore ranges completely — setting range on one has no effect.