crimes v0.15.0 — polyglot IA + monorepo coverage
Theme: the findings no single-language tool can produce.
0.12.0 made crimes run on any repo. 0.14.0 taught it Python. This
release is the reason those two mattered: crimes now reports
disagreements between languages — the class of problem where every
individual file is correct, every type checker passes, and the system
is still broken.
$ crimes scan .
🚨 packages/api/billing/plans.py 1. Cross-Language Type Drift · Plan `Plan` is declared as a closed set in both languages and the two have diverged by 3 values. Whichever side is narrower rejects values the other already produces.
⚠️ packages/web/src/workspace-client.ts 1. Cross-Language Route Drift The frontend calls PUT /api/teams/:id/plan, but the backend declares that path as POST. The path exists, so this fails as a 405 rather than a 404 — which is easy to misread as a permissions problem.Schema stays 0.3.0. Finding.pack: "cross-language" and
ScanReport.coverage.by_package land additively; fingerprints are
unchanged, so baselines, suppressions, triage and feedback carry over.
Three cross-language detectors
Section titled “Three cross-language detectors”| detector | the disagreement |
|---|---|
cross_language_route_drift | the frontend calls a path the backend doesn’t serve, or the two disagree on the HTTP method |
cross_language_type_drift | a closed set — a Python Enum, a TS string-literal union — listed differently on each side |
cross_language_concept_alias_drift | the same concept named team in one language and workspace in the other |
These are the wedge. A type checker stops at the language boundary; two
linters each see half the problem; nothing in either toolchain notices
that fetch("/api/workspaces") has no route behind it. The failures
land at runtime, usually as a 404 that reads like a data problem or a
validation error that reads like a bad request.
The agent-risk case is sharper still. Asked to “add a field to the
workspace”, an agent greps for workspace, finds only the TypeScript
half, and confidently ships one side of a two-sided change.
How they run
Section titled “How they run”Unlike every other detector, these run once per scan rather than once per file — a cross-language finding is by definition about two files, so there is no single “current” one. The detector gets the whole parsed corpus and picks its own anchor.
Findings still carry a file, because fingerprints are
<type>::<file>::<symbol> and every downstream surface keys off it. The
anchor is the file a reader would edit first; the other side is in
related_files.
What they will not do
Section titled “What they will not do”The false-positive surface here is roughly squared — two languages’ worth of source to mismatch — so all three are deliberately narrow:
- Never fire one-sided. Each returns early unless both languages
are present with the relevant evidence. In a JS-only repo, “no
backend route” for every
fetchwould be noise proportional to the repo’s size. There is a test per detector asserting this. - Only quotable evidence. A path assembled at runtime
(
@app.get(PREFIX + "/users"),fetch(`${base}/users`)) is skipped on both sides. A union with any non-literal member ("free" | string) is dropped whole rather than captured partially — a partial member list would invent a disagreement that isn’t there. - Require real overlap before calling it drift. Two same-named
Statustypes sharing no members are different concepts.
Matching is on literal strings, not resolved symbols. A
cross-language import graph stays deferred, so route drift lines up the
path text both sides wrote down, with parameter syntaxes normalised
(/users/{user_id}, /users/<int:user_id>, /users/:id and
/users/${id} all compare equal). A route that only exists at
runtime is invisible to it, and the finding’s own evidence says so
rather than leaving a reader to infer it.
coverage.by_package
Section titled “coverage.by_package”On a monorepo — two or more directories carrying a package manifest — coverage gains a per-package breakdown:
$ crimes scan . --explain-coverage
packages (2): packages/api 138 files (py 138) packages/web 412 files (js 412)The repo-wide files_by_language says a repo is 75% TypeScript.
by_package says which part is the Python one, which is what decides
where a change is risky — in a mostly-TypeScript repo a single Python
service otherwise looks like a rounding error.
Absent on single-package repos, so presence is itself the “this is a
monorepo” signal. dominant_language requires a strict majority: a
package that is 45% Python and 40% TypeScript gets null rather than a
label, because calling either one dominant puts a confident answer on a
coin flip.
New parser surfaces
Section titled “New parser surfaces”The design spec listed three detectors without noting that neither pack
captured any of what they need. Both gained surfaces consumed only
by the cross-language pack — a fetch("/api/users") is unremarkable on
its own and becomes evidence only when a Python route disagrees with it.
language-py:routes(framework route decorators with their path literals, handler and receiver),ParsedPyClass.members(Enum values, Pydantic and dataclass fields), andParsedPyClass.docstring.language-js:fetchSites(fetch / axios / client calls with literal same-origin paths, verb resolved from the callee or from{ method }) andstringUnionTypes(type aliases that are closed sets of string literals).
Fixed: crimes context was blind to Python
Section titled “Fixed: crimes context was blind to Python”This is a fix to behaviour 0.14.0 shipped, and it is the most
important line in these notes if you run crimes on a Python repo.
crimes context — the pre-edit briefing, and what the PreToolUse
hook calls before every agent edit — reported no findings for Python
files:
$ crimes scan . 4 findings on domain/invoicing.py
$ crimes context domain/invoicing.py risk: NONE (0 findings)runDetectorsOnTarget handled only the universal and language-js
packs. Nothing surfaced it, because parsing a .py file with the
TypeScript parser returns an empty ParsedFile rather than throwing
— so the Python detectors ran against a file the parser saw as blank,
found nothing, and the briefing reported a clean file.
If you adopted 0.14.0 on a Python codebase, every pre-edit briefing
since has been answering “nothing to worry about”. crimes scan,
diff, verdict and baseline were unaffected.
Fixed, with two regression tests: one asserting a Python file’s
findings reach context, one asserting a genuinely clean Python file
still reports none. Cross-language detectors are wired into context
at the same time — a cross-language finding is exactly the one an agent
must see before editing one side of a two-sided change.
Known limitation in monorepos
Section titled “Known limitation in monorepos”crimes context auto-scopes to the nearest package root to keep the
briefing fast. In a monorepo that root is one package, so the other
language is out of scope and the cross-language detectors correctly
decline to fire one-sided:
$ crimes context packages/api/billing/plans.pyrisk: NONE (0 findings) # scoped to packages/api
$ crimes context packages/api/billing/plans.py --root .risk: HIGH (2 findings) # whole monorepo in scopePass --root at the monorepo root when you want them. Widening the
context root automatically would make every pre-edit hook call parse
the entire monorepo, which is the wrong trade for a surface that runs
on every agent edit. crimes scan is unaffected.
Two calibration bugs the fixture caught
Section titled “Two calibration bugs the fixture caught”Both were found by building the polyglot fixture, not by the unit tests — worth recording, because both were the kind of mistake that produces fewer findings and so looks like nothing is wrong.
Type drift measured overlap against the union. That is inverted for this purpose: the more two sets have drifted, the lower the union ratio, so the more real the drift the less likely it was to be reported. A four-member Python enum and a three-member TS union sharing two values — a textbook drift, and exactly what the fixture contains — scored 0.4 and was silently dropped. Now measured against the smaller set, where it scores 0.67.
Alias drift recorded only the first matching alias per text. A docstring reading “the team, called a workspace in the UI” names both, but only whichever appeared earlier in the group’s alias list was kept — so the outcome depended on the order aliases happen to be listed. Now every alias present is recorded, which means a docstring that documents the mapping genuinely suppresses the finding. That behaviour is deliberate and tested: a codebase that writes its mapping down where a reader will find it has this problem far less than one that does not.
- The website version guard fired on its first release. Added at the
end of 0.14.0 after crimes.sh advertised
0.12.0through two releases;pnpm --filter @crimes/website buildnow fails when the landing page’s JSON-LDsoftwareVersiondisagrees withpackages/cli/package.json. It caught 0.15.0 immediately. - The landing page’s language claim is honest again. It described crimes as scanning “TypeScript and JavaScript repositories” in six places, and its FAQ still called Python “future work”.
docs/releasing.mdgained the website surfaces as an explicit step, replacing the line that said no per-releaseindex.htmledit was needed — the line that caused the drift.
Eval harness
Section titled “Eval harness”New polyglot fixture 13-polyglot-monorepo — a Python billing service
and a TypeScript web client that disagree three ways at once — and four
scenarios across four kinds. 48 scenarios over 13 fixtures.
The aggregate is not a like-for-like comparison: the scenario set grew from 44 to 48, and the four new ones are hard. Holding the set fixed:
Two samples of each release:
| pre-existing (n=44) | new cross-language (n=4) | |
|---|---|---|
claude 0.14.0 / r2 | 0.902 / 0.879 | — |
claude 0.15.0 / r2 | 0.888 / 0.897 | 0.825 / 0.625 |
codex 0.14.0 / r2 | 0.532 / 0.514 | — |
codex 0.15.0 / r2 | 0.534 / 0.505 | 0.292 / 0.333 |
On the scenarios that existed before this release, both agents are flat: claude’s two samples bracket its two prior ones, codex’s overlap. No regression.
claude handles the cross-language scenarios (0.825 / 0.625, with two of four at 1.00 in the first sample) — the design spec’s success criterion for this release was that polyglot scenarios pass on at least claude. codex does not (0.292 / 0.333), continuing exactly the pattern it showed on the Python scenarios in 0.14.0. Two samples cannot separate “weak on cross-language reasoning” from “unstable on it”, and neither reading is a statement about crimes.
evals:variance across both samples: claude mean 0.879 (avg
per-scenario σ 0.046), codex mean 0.502 (σ 0.061).
Per-scenario-kind numbers are not quoted: still uninterpretable at these counts, and now more so with one cross-language scenario per kind.
Upgrading
Section titled “Upgrading”npm install -g crimes@0.15.0Schema stays 0.3.0; fingerprints are unchanged.
What changes in your output:
crimes contexton a Python file will report findings it was silently omitting. If you are on 0.14.0 with a Python codebase, this is the reason to upgrade — see above.- A polyglot repo will report new cross-language findings. They are additive; nothing that fired before stops firing.
- A monorepo’s
--explain-coveragegains a packages block, andcoverage.by_packageappears in JSON. - Single-language repos are unaffected. All three detectors return early unless two languages are present.
To disable the pack entirely:
{ "detectors": { "disable": [ "cross_language_route_drift", "cross_language_type_drift", "cross_language_concept_alias_drift"] } }