crimes v0.27.0 — a finding names its claim, and a pin that matches nothing says so
Theme: three things in this release were saying less than they knew. A finding knew which of several statements it was making and reported only the detector’s name. A triage entry knew it had stopped matching and reported nothing at all. A CI gate knew it had scanned an empty directory and reported success. Each of them now says the thing it already knew.
schema_version moves to 0.8.0. Findings gain a claim field and
fingerprints change for eleven detector types. Read
Upgrading before you pull this — your existing pins will
not match, and 0.27.0 is the release that tells you so.
Upgrading
Section titled “Upgrading”Every suppression, baseline pin and triage disposition recorded
against one of the eleven affected types stops matching. Fingerprints
went from <type>::<file>::<symbol>[::<discriminator>] to
<type>/<claim>::<file>::<symbol>[::<discriminator>].
This is not silent any more, which is the point of shipping the two
changes together. Scan after upgrading and read coverage.warnings[]:
crimes scan . --format json | jq '.coverage.warnings[] | select(.kind | endswith("_entries_unmatched"))'The warning distinguishes a pin orphaned by this change from one that matches nothing because the code was fixed — those look identical otherwise, and only one of them is good news.
To re-pin: crimes scan --format json, take the new fingerprint
values, and rewrite .crimes/triage.json with
crimes triage --apply. That command is much easier to feed than it
was — see The agent path.
Worth doing rather than mechanically re-keying: a pin recorded against a whole multi-claim type was silencing claims nobody verified. This is the release where you find out which.
One type, one claim
Section titled “One type, one claim”type was doing two jobs — naming the detector, and standing in for
what the detector alleged. Those coincide only while a detector says
exactly one thing, and eleven of them say more.
weak_test_signal says two:
Test "…" contains no expect/assert calls.Test "…" only uses weak assertion matchers.Different questions, different answers, different fixes. Triage,
suppressions, baseline and detectors.disable all key on type, so a
consumer who verifies one claim and judges the type silences the rest.
That is not hypothetical. On a 761-file TypeScript repository, three
findings of the first shape were checked, all three were false — they
assert through a same-file helper the detector cannot follow — the type
was silenced, and 67 correct findings went with the 38 false ones.
Those 67 named something real: 449 instances of
expect(screen.getBy*(…)).toBeTruthy(), where the query already throws
when it finds nothing, so the assertion proves nothing the query had not
already proved.
crimes is built for agents, and an agent triages by type. This is the
main path, not an edge case.
An audit of all ~70 detectors at source — not of the fixture corpus,
which under-represents this badly — found eleven types making more
than one claim. config_drift fires once across all 16 fixtures and
has eight claim kinds.
config_drift keeps one finding per environment variable and carries a
composite claim, atoms sorted: splitting it would shred the
variable-level grouping a reviewer actually wants. Any rule assuming one
claim per finding is wrong for that detector.
detectors.disable accepts <type>/<claim>, so the coarse kill switch
— the one actually used to bury those 67 findings — can be aimed at the
claim that was wrong instead of the whole detector.
Scores do not move. 388 findings across 16 fixtures: 0 score changes, 0 severity changes, 137 fingerprints changed, 4 summaries changed. This changes identity, not ranking.
A live fingerprint collision fell out of the audit:
cross_language_route_drift’s 404 and 405 findings carried no symbol
and no discriminator, so both were
cross_language_route_drift::<file>::. crimes ignore on one silenced
the other. 1 duplicate fingerprint before, 0 after.
A pin that matches nothing says so
Section titled “A pin that matches nothing says so”applyTriageFilter and partitionFindings both iterate findings and
look each one up in a fingerprint-keyed map. An entry nobody looks up
is never visited, so an entry that stopped matching was a silent
no-op: triage --apply said “Applied N entries”, the next scan showed
the findings again, and nothing connected the two facts.
This repository is its own reproduction. Of 115 triage entries, 63
match nothing and the scan said so nowhere. Fifty-six of those are
large_function::… against a live large_function/too_long::… — the
finding was never fixed, the pin lost track of it.
examples/messy-ts-app is a second: the suppression its own docs point
at as the worked example stopped applying, and suppressed_count has
been quietly absent ever since.
recordUnmatchedPins is a reverse pass over both files, reported
through coverage.warnings[] — the field PRD.md already gives the job
of making a suppression “never silent”, and where
working_set_path_unmatched already sits as the same shape of problem:
you named something, it matched nothing, the scan proceeded anyway.
Stale and fixed are told apart rather than lumped together.
A contract bug came with it: coverage.warnings[].files was documented
as always >= 1, and the new kinds can reach 0 — a fingerprint whose
file segment is empty names no file at all, so the bucket has entries
and nothing to count files with. Rather than invent a file to keep the
invariant true, the contract carves those kinds out and points at
entries, which is always >= 1.
The agent path
Section titled “The agent path”crimes triage refuses the interactive walk in CI and in any non-TTY,
so --apply is the route for an agent, a script or a CI job. It
validated its input against the on-disk schema — which asks for four
envelope fields describing the file crimes writes rather than anything
the caller is asserting, plus type and file per entry that are
already inside the fingerprint. The error formatter reports one bad
field per run by design, which is right for a config a person edits and
expensive when each re-run is a tool call.
Authoring a first payload took seven attempts. Three fields now:
[{ "fingerprint": "large_function/too_long::src/big.ts::doStuff", "disposition": "wont-fix", "reason": "generated file — rewritten by codegen" }]A bare array works, {"entries": […]} works, and a whole triage
document envelope-and-all works, so an existing .crimes/triage.json
round-trips. symbol: "" is accepted rather than rejected. Every
problem is reported at once.
Resurfaced findings reached the report with no id and no
fingerprint — the two fields every consumer addresses a finding by.
Ids were assigned before resurfaced entries were collected and
prepended, so --show-triaged, which exists precisely to let a caller
act on them, surfaced the one kind of finding nobody could act on.
Three gates that were not gates
Section titled “Three gates that were not gates”evals-pr.yml never called evals:setup. Fixtures 02–04 are OSS bodies
cloned at a pinned SHA and gitignored on purpose, so a fresh CI checkout
holds only each fixture’s .crimes-eval-meta.json and every scan ran
against an empty directory. The failure that produces is worse than
useless: every scenario reports its expected detectors as “not in
fixture scan”, which reads exactly like a detector regression in
whatever change opened the PR.
It had never run. The workflow triggers on pull_request and this
repository’s history is direct-to-main; the first pull request ever
opened here is what executed it.
Beside it, evals:replay re-scored zero result files and exited 0,
after which evals:diff reported its own input missing and also
exited 0. The last two steps passed vacuously. Editing the gate now runs
the gate, and a replay that did not happen fails.
One Node, and it is the one the project is developed on
Section titled “One Node, and it is the one the project is developed on”Four places named a Node version and three named a different one: a CI matrix on 20 and 22, evals on 22, release on 24, and nothing at all pinning local development. Node 20 reached end-of-life in April 2026, so the matrix tested a dead runtime and an ageing one while nothing exercised the version the work was actually being done on.
.nvmrc is added at 26.7.0 and every workflow reads
node-version-file: .nvmrc. Collapsing the matrix removed four
if: matrix.node == '22' gates that existed only to stop
platform-independent work running twice. Verified on both 24 and 26
before choosing.
engines.node stays >=18 and the tsup node18 targets are untouched
— that is the contract with people who install the published CLI, and
narrowing it is a separate decision.
Still unsettled
Section titled “Still unsettled”- S1, the recency default.
0.26.0measured it properly for the first time and the result is a genuine trade, not a defect. Still unchosen; it reorders 99.9% of a large report and cannot share a baseline with anything else. - M6, Homebrew and standalone binaries. Deferred again. The risk is WASM asset resolution, not packaging.
weak_test_signal’s granularity. Universal emits one finding per hollow test, Python one per file.0.27.0split its claims, which is a different axis and does not settle this.@types/nodeis^22whileengines.nodesays>=18. The code typechecks against a wider API surface than it promises and nothing verifies the gap.