Skip to content

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.


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[]:

Terminal window
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.


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.


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.


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.


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.


  • S1, the recency default. 0.26.0 measured 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.0 split its claims, which is a different axis and does not settle this.
  • @types/node is ^22 while engines.node says >=18. The code typechecks against a wider API surface than it promises and nothing verifies the gap.