Skip to content

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.

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


detectorthe disagreement
cross_language_route_driftthe frontend calls a path the backend doesn’t serve, or the two disagree on the HTTP method
cross_language_type_drifta closed set — a Python Enum, a TS string-literal union — listed differently on each side
cross_language_concept_alias_driftthe 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.

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.

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 fetch would 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 Status types 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.

On a monorepo — two or more directories carrying a package manifest — coverage gains a per-package breakdown:

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

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), and ParsedPyClass.docstring.
  • language-js: fetchSites (fetch / axios / client calls with literal same-origin paths, verb resolved from the callee or from { method }) and stringUnionTypes (type aliases that are closed sets of string literals).

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:

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

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:

Terminal window
$ crimes context packages/api/billing/plans.py
risk: NONE (0 findings) # scoped to packages/api
$ crimes context packages/api/billing/plans.py --root .
risk: HIGH (2 findings) # whole monorepo in scope

Pass --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.

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.0 through two releases; pnpm --filter @crimes/website build now fails when the landing page’s JSON-LD softwareVersion disagrees with packages/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.md gained the website surfaces as an explicit step, replacing the line that said no per-release index.html edit was needed — the line that caused the drift.

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 / r20.902 / 0.879
claude 0.15.0 / r20.888 / 0.8970.825 / 0.625
codex 0.14.0 / r20.532 / 0.514
codex 0.15.0 / r20.534 / 0.5050.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.

Terminal window
npm install -g crimes@0.15.0

Schema stays 0.3.0; fingerprints are unchanged.

What changes in your output:

  • crimes context on 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-coverage gains a packages block, and coverage.by_package appears 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"
] } }