crimes v0.14.0 — the Python language pack
Theme: a second language pack, and proof that the pack seam introduced in 0.12.0 is genuinely reusable rather than a JS-shaped abstraction with one implementation.
crimes scan now parses .py / .pyi and reports Python findings
alongside JavaScript ones in a single report.
$ crimes scan . --explain-coverage
coverage breakdown: files discovered: 550 packs loaded: universal, language-js, language-py files by language pack: language-js (.ts/.tsx/.js/.jsx/.mjs/.cjs/.cts/.mts): 412 language-py (.py/.pyi): 138 files with only universal coverage: 0Schema stays 0.3.0 — pack and coverage shipped in 0.12.0, and
Finding.pack: "language-py" lands additively. Fingerprints are
unchanged, so baselines, suppressions, triage and feedback all carry
over.
Eight detectors
Section titled “Eight detectors”Chosen to prove the seam, not to reach catalogue parity with the JS side. Between them they exercise every kind of evidence a language pack can produce — parsed functions, matched call sites, whole-file correlation, enclosing-scope chains, declarations, test discovery, the cross-file import graph, and import specifiers. Anything still JS-shaped in the seam would have failed visibly against one of them.
| detector | what it reads |
|---|---|
large_function.py | line budget per function shape, plus nesting depth |
direct_date.py | datetime.now() / utcnow() / date.today() / time.time(), and whether the result is naive |
mixed_utc_local_methods.py | modules reading the clock through both utcnow() and local now() |
sync_io_in_hotpath.py | open / requests.* / urlopen / subprocess.* / time.sleep inside handlers and domain code |
boolean_naming_drift.py | names bound to boolean expressions without an is_ / has_ / should_ prefix |
weak_test_signal.py | pytest / unittest files whose test functions assert nothing |
circular_dependency.py | strongly-connected components among Python modules |
deep_import.py | dotted depth and long relative climbs |
They are not ports. Where Python’s failure mode differs, the detector says so:
direct_date.pycharges naive datetimes, which has no JS analogue.datetime.now()withouttz=returns a value carrying no offset; comparing it against an aware datetime raisesTypeError, and comparing it against another naive one silently assumes both came from the same zone.circular_dependency.pyexplains anImportErrorat startup, not a bundling problem. Python executes a module top-to-bottom on first import, so a module in a cycle can be observed half-initialised — and whether it raises depends on which module the process imports first, so the same cycle passes underpytestand fails undergunicorn. It also warns against the usual workaround: moving the import inside a function removes the error without removing the cycle and hides it from every static reader.sync_io_in_hotpath.pyescalates insideasync def, where a blocking call stalls the entire event loop for every concurrent request rather than occupying one sync worker.
Every detector sets its own evidence-scaled scores.agent_risk.
Further Python detectors can land additively in patch releases without their own minor bump.
No Python runtime, and no native code
Section titled “No Python runtime, and no native code”The pack parses via a vendored WebAssembly build of
tree-sitter-python, executed by web-tree-sitter. It never shells out
to an interpreter and does not care whether one is installed.
It also ships no native addon and no install scripts. The design
spec called for the tree-sitter + tree-sitter-python npm packages,
but packages/cli publishes as a single self-contained bundle whose
only real runtime dependency is typescript. A native addon cannot be
bundled, and tree-sitter-python ships install scripts plus 7.5 MB of
per-platform prebuilds with a node-gyp compile as the fallback when
none match the host. For a CLI whose canonical first run is
npx crimes scan, that is an install-failure mode. The WASM route adds
~450 KB to the tarball and works identically everywhere Node runs.
Parser initialisation is lazy — a repo with no .py files never
loads the grammar and pays nothing for the pack existing.
Attribution and update instructions:
packages/language-py/vendor/ATTRIBUTION.md.
If you have an unusual packaging layout, CRIMES_PY_GRAMMAR_WASM
overrides grammar discovery.
Two scoring fixes that would otherwise mis-rank Python
Section titled “Two scoring fixes that would otherwise mis-rank Python”Neither was in the design spec. Both are consequences of 0.13.0’s scoring changes meeting this pack, and both would have silently produced wrong output.
test_gap now understands Python’s test convention
Section titled “test_gap now understands Python’s test convention”Pairing a test to the file it covers is per-language, and only the JS convention was understood:
| written as | covers | languages |
|---|---|---|
billing.test.ts | billing | JS/TS |
billing_test.py | billing | Python, Go |
test_billing.py | billing | Python |
Every convention except Python’s dominant one is a suffix. The old
logic only stripped .test / .spec suffixes, so test_billing never
matched billing, no sibling was ever found, and every Python file
scored test_gap: 1.0 — “no test at all” — no matter how well
covered. Since 0.13.0 that is 0.20 of agent_risk, so the whole pack
would have been systematically over-ranked against JS.
A test in a dedicated directory now pairs by basename for tests/ as
well as __tests__/. That applies to both languages, so a JS repo
keeping its tests outside src/ is also no longer scored as having
none. It is the one change in this release that moves JS rankings; it
landed as its own calibration commit so the delta is attributable.
blast_radius is real for Python
Section titled “blast_radius is real for Python”blast_radius is derived purely from the import graph, and the graph
resolved through tsconfig.json and ts.ScriptKind. Python had no
equivalent, so every Python file scored 0 — on a signal that went
from contributing nothing (r=0.06) to being a real one (r=0.48) in
0.13.0.
Rather than ship six detectors and declare a gap, 0.14.0 builds real
Python module resolution. Package roots are found the way Python finds
them — walk up from a file while each directory contains an
__init__.py — so flat layouts and src/ layouts fall out of one rule
with neither special-cased. Absolute, relative (from . import x,
from ..rates import y), and submodule imports all resolve.
The resulting edges merge into the same ImportGraph as the JS
ones rather than a parallel structure, because every consumer is
already language-agnostic. That is also what unlocked
circular_dependency.py and deep_import.py, 2 of the 8 detectors on
the slate, and the test_gap: 0 tier (“a test file imports this”),
which no Python module could previously reach.
Specifiers Python cannot statically resolve — PEP 420 namespace
packages, importlib, runtime sys.path manipulation, installed
distributions — become external edges rather than guesses. A missed
edge understates blast radius; a guessed one would invent a dependency
and produce a circular_dependency finding that does not exist.
crimes context learned the same conventions
Section titled “crimes context learned the same conventions”Both fixes above are about scan. crimes context — the command every
agent integration leads with — reached the same wrong answer by a
different route, because “does this test cover that file” had two
independent implementations. Teaching only the scoring one about Python
left this:
$ crimes context billing/rates.py
Likely tests (no sibling, __tests__, .test, .spec, _test, or _spec files matched the target basename)for a module with tests/test_rates.py asserting against it. The
convention table now lives in one place and both callers read it.
likely_tests also gained a Python import matcher — the JS one resolves
relative path specifiers (./foo), which finds nothing in a language
that imports by dotted module path.
Two related fixes fell out:
pyproject.toml,setup.pyandsetup.cfgare project-root markers. Root detection only knewpackage.json, so runningcrimes contextfrom a subdirectory of a Python repo found no root, fell back to the working directory, and scanned a subtree that excludedtests/.foo_test.tsis now consistently a test file.likely_testshad treated it as one since 0.4.0 while the shared classifier did not, so a file was a test or not depending on which code path asked.
Detector ids are now qualified per pack
Section titled “Detector ids are now qualified per pack”Python detectors are addressed as large_function.py:
{ "detectors": { "disable": ["large_function.py"] } }This turns off Python’s without touching JavaScript’s, and keeps the
config registry from holding two entries under one id. Finding.type
stays abstract (large_function), so cross-language grouping,
fingerprints, baselines, suppressions and triage are unaffected. The JS
detectors keep their unqualified ids because existing configs reference
them. Full table in docs/packs.md.
Eval harness
Section titled “Eval harness”Two Python fixtures — 11-py-service (FastAPI-shaped, carries seven of
the eight charges) and 12-py-tested (three modules at three coverage
levels: real tests, tests that assert nothing, no tests) — and six
scenarios spanning all five scenario kinds.
A scoring defect, found by disbelieving the first result
Section titled “A scoring defect, found by disbelieving the first result”The first 0.14.0 baseline reported codex collapsing on Python — 0.089 across the six new scenarios — and one scenario scoring a hard 0.00 for both agents. That last part is the tell: two very different agents failing identically is usually the apparatus, not the agents. Claude’s response to it opened with a code block containing the exact file path the rubric wanted.
The scorer’s extractFilePaths matched a hardcoded extension list with
no py in it, so every referenced_files check on a Python scenario
failed automatically. Re-scoring the same cached responses:
| before | after | |
|---|---|---|
| claude, Python scenarios | 0.497 | 0.967 |
| codex, Python scenarios | 0.089 | 0.261 |
| claude, aggregate | 0.832 | 0.907 |
| codex, aggregate | 0.467 | 0.534 |
This is the third JS-hardcoded assumption this release surfaced,
after test_gap pairing and likely_tests — and the second time this
particular list has caused it (0.8.0 added the asset extensions for
exactly the same reason). It is now a named constant carrying the rule
that adding a language pack means adding its extensions, pre-seeded for
several languages that have no pack yet.
The invalid baseline was deleted rather than kept, because
evals:variance treats any <version> / <version>-* directory as a
sample and retaining it would corrupt the noise estimate it exists to
produce.
Read the aggregate, not the per-kind breakdown. Six new scenarios
spread across five kinds cannot move a per-kind number meaningfully:
per_scenario_kind is already not interpretable at 7–8 scenarios per
kind, where plan/claude ranged 0.64–0.88 across three runs of
identical code. The per-agent aggregate is the number worth reading,
and the measured noise band there is claude σ 0.029 / codex σ 0.012 —
so treat any move under ~6pp as noise. We chose not to inflate the
scenario count purely to make a grouping legible.
Results
Section titled “Results”Two samples of this release, against the three most recent reference samples:
| version | claude | codex |
|---|---|---|
0.12.2 | 0.87 | 0.64 |
0.12.2-r2 | 0.86 | 0.66 |
0.13.1 | 0.85 | 0.64 |
0.14.0 | 0.86 | 0.57 |
0.14.0-r2 | 0.86 | 0.56 |
claude sits dead centre of the reference band and the two samples agree exactly. codex is ~8pp below its band, consistently across both samples, so that is a real move rather than noise.
It is not a regression, and the aggregate is not a like-for-like comparison — the scenario set grew from 38 to 44. Holding the scenario set fixed:
| pre-existing (n=38) | new Python | |
|---|---|---|
claude 0.13.1 | 0.888 | — |
claude 0.14.0 / r2 | 0.897 / 0.879 | 0.933 / 0.883 |
codex 0.13.1 | 0.605 | — |
codex 0.14.0 / r2 | 0.569 / 0.576 | 0.297 / 0.117 |
On the scenarios that existed before this release, claude is flat and codex is down 3pp — both inside the band. The aggregate drop is entirely the new scenarios diluting codex’s mean, the same effect 0.8.0 saw when it added harder scenarios.
claude handles the Python scenarios well; codex does not, and its
variance there is high (review-11-py-async-blocking ranged 0.00–1.00
across the two samples). Two samples cannot separate “codex is weak on
Python” from “codex is unstable on Python”. Both readings fit the data,
and neither is a statement about crimes.
Upgrading
Section titled “Upgrading”npm install -g crimes@0.14.0Schema stays 0.3.0. Fingerprints are unchanged, so baselines,
suppressions, triage and feedback carry over untouched.
Three things will change in your output:
- A repo with Python files will report new findings. They were previously covered only by the universal pack.
- A repo that keeps its tests in a
tests/directory will see rankings shift, in either language — those files were being scored as having no tests at all. crimes contextmay list tests it previously missed. Thetests/andtest_*conventions now feedlikely_tests, andfoo_test.tsis consistently treated as a test file. If you have tooling asserting onlikely_testsbeing empty, re-check it.
If you want the previous behaviour for a specific Python detector, disable it by its qualified id rather than the shared one:
{ "detectors": { "disable": ["boolean_naming_drift.py"] } }