Skip to content

crimes v0.17.0 — calibration, and the first wire-format change

Theme: the tool turned on itself. Every change here came from crimes scanning crimes, and two of them are bugs the product found in its own source.

This is the first release to move schema_version0.3.00.4.0. The bump buys one thing: a suppression can now name the finding its author actually looked at.

Read this if you have pinned suppressions or a baseline

Section titled “Read this if you have pinned suppressions or a baseline”

Entries for magic_domain_literal_scatter, exact_duplicate_block, and near_duplicate_block stop matching and need re-recording. Every other finding type is byte-identical to 0.16.0 — same fingerprints, same entries, nothing to do.

Terminal window
$ crimes feedback recheck

will surface the affected entries with the reason. Re-record each with a fresh crimes ignore, and re-run crimes baseline save.

An un-migrated file still loads — the accepted schema window is 0.1.0 through 0.4.0. It just matches fewer findings for those three types: the old fingerprint reads as fixed, the new one as new.

Full field-by-field notes: Migrating from 0.3.0 to 0.4.0.

A fingerprint was <type>::<file>::<symbol-or-empty>. That is unique for almost every detector, and not unique for three of them.

magic_domain_literal_scatter reports one finding per scattered literal and carries no symbol at all. exact_duplicate_block and near_duplicate_block report one finding per duplicate group, anchored on the lex-first file of the group — and one file is routinely the anchor of more than one group.

On a self-scan that produced 3 colliding fingerprints covering 7 findings. The known limitation documented in fingerprint.ts said the consequence was that crimes diff conflates them. The sharper one is suppression targeting:

crimes ignore on the "property" finding also silently suppresses "subprocess", because they are the same fingerprint.

A user suppressing one thing got a second thing suppressed without being told. That is a safety property, not a cosmetic one.

Setting symbol to the literal value would have worked mechanically. It was rejected: fingerprint.ts documents symbol as naming a specific declaration, and a string literal is not a declaration. Overloading it would have silently changed what the field means for every consumer reading it.

A per-detector patch was rejected too. The bug is in the fingerprint function’s inputs, so it gets one general answer:

interface Finding {
symbol?: string;
/** Tiebreaker when (type, file, symbol) is not unique. */
discriminator?: string;
}
<type>::<file>::<symbol-or-empty>[::<discriminator>]

The segment is omitted entirely when unset, so the overwhelming majority of fingerprints are unchanged strings. Only the detectors that were colliding change shape.

Each colliding detector had a natural value already to hand: the literal for scatter, the 12-character body hash for the duplicate-block pair — the same characters already printed in its evidence line, so a fingerprint and the finding it names can be matched up by eye.

The rule a detector must follow is documented on the field: the value has to be stable across scans of the same code. A counter or a per-scan index would satisfy the type and break every pinned entry the moment an unrelated finding appeared.

Self-scan collisions: 3 → 0.

large_file had two shapes, domain and test_file. Everything else — including your README, your schema reference, your ADRs — was measured against the 300-line domain-code budget and charged as a God File for being thorough.

ShapeThresholdSeverity at thresholdat 2×
domainconfig (default 300)mediumhigh
test_file1500lowmedium
docs1000lowmedium

docs matches .md, .mdx, .markdown, .rst, .adoc, .asciidoc, .txt. Configurable as thresholds.largeFile.docs.

Two boundaries held on purpose:

  • Data formats are not docs. .json, .yaml, .csv stay on the domain budget. A 3000-line config file is a finding worth having, and stretching “it isn’t code” to cover it would trade real precision for a quieter scan.
  • agent_risk sits above test_file, below domain. An oversized document is a genuine context cost — an agent told to follow it has to load the whole thing to find the paragraph that applies — so it does not get the test-file discount.

This repo had been carrying 19 **/*.md suppressions as an explicit placeholder for this shape. All 19 are gone. Fourteen had already stopped matching anything; five now surface as honest low findings instead of being hidden. That is the intended outcome — a shape that silenced exactly what the suppression silenced would be the suppression with extra steps.

Unbounded fan-out. buildFunctionHashIndex and buildJsxShapeIndex each Promise.all-ed one readFile per candidate file. On a repo with 20k source files that asks the OS for 20k descriptors at once and fails with EMFILE. The product’s own unbounded_async_fanout detector flagged both, and was right.

Both now read through a bounded worker pool. The limit is sized against the descriptor budget rather than the CPU count — the work is a read plus a parse, so the pool exists to keep the fd count flat, not to saturate cores — and sits an order of magnitude under the default ulimit -n on macOS and Linux.

Note what was not done: the detector’s bounded-helper recognition was not widened to whitelist the new helper. It stops firing because the Promise.all is genuinely gone.

Non-reproducible evidence. exact_duplicate_block was not deterministic across runs on an unchanged tree. Three consecutive scans of the same commit agreed on finding identity and severity counts, but the same anchor file reported hash 3dbfcb76d2cc… across 6 file(s) on one run and hash 3d33dfe315b3… across 9 file(s) on another.

Same root cause: the index inserted into its maps from inside the Promise.all callbacks, so map order tracked which read finished first, and a function belonging to more than one duplicate group picked its group by iteration order. Hits are now collected per file and inserted in sorted order, and both duplicate detectors sort hash keys before iterating.

Identity was always stable, so baselines and diff were never affected. What was broken was the evidence string a user reads, which is supposed to be reproducible.

  • crimes feedback recheck knows about all of it. The per-detector release-notes map gained 0.17 entries for all four affected detectors, so a resurfaced suppression explains why it came back instead of falling through to “detector behaviour unchanged”.
  • dependency_provenance_gap honours ! negation in pnpm packages: and npm/yarn workspaces globs. A package the package manager deliberately excludes is no longer compared against a lockfile that correctly does not contain it. Found by the detector reporting it about this repo.
  • docs/calibration-followups.md is closed out. Every open item has a disposition and the evidence behind it, including two whose recorded status turned out to be wrong.

96/96 combinations, complete matrix.

agent0.16.00.17.0
claude0.840.84
codex0.580.57

This is a product delta, not a measurement correction. Nothing in the scorer, the judge prompts, the scenario rubrics, or the fixture finding sets changed. What moved is what crimes reports, and scanning the fixtures with both builds isolates it exactly: four markdown files reclassify from high to low in 03-node-cli-tool, and one drops below threshold in 04-monorepo. Every other fixture is finding-for-finding identical — 330 findings to 329.

Neither aggregate move is claimable as a change. The fixtures whose findings actually moved are TS/JS repos whose markdown was never the subject of a scenario, so the delta this baseline records is real but sits almost entirely outside what the scenarios ask about. That is the expected shape for a change that reclassifies prose.

pnpm verify # format:check + lint + build + typecheck + test

1873 tests across six packages, up from 1828 at 0.16.0 (that span also covers the !-negation fix and the eval-runner resume work). New coverage in this release: the bounded-concurrency helper (in-flight ceiling, input-order results, serial fallback on a non-positive limit), the discriminator’s back-compat guarantee that an unset value produces the byte-identical pre-0.4.0 string, per-detector fingerprint separation, a five-run determinism guard on exact_duplicate_block, and the full docs shape including the deliberate data-format exclusions.