Skip to content

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.

Terminal window
$ 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: 0

Schema stays 0.3.0pack 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.


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.

detectorwhat it reads
large_function.pyline budget per function shape, plus nesting depth
direct_date.pydatetime.now() / utcnow() / date.today() / time.time(), and whether the result is naive
mixed_utc_local_methods.pymodules reading the clock through both utcnow() and local now()
sync_io_in_hotpath.pyopen / requests.* / urlopen / subprocess.* / time.sleep inside handlers and domain code
boolean_naming_drift.pynames bound to boolean expressions without an is_ / has_ / should_ prefix
weak_test_signal.pypytest / unittest files whose test functions assert nothing
circular_dependency.pystrongly-connected components among Python modules
deep_import.pydotted depth and long relative climbs

They are not ports. Where Python’s failure mode differs, the detector says so:

  • direct_date.py charges naive datetimes, which has no JS analogue. datetime.now() without tz= returns a value carrying no offset; comparing it against an aware datetime raises TypeError, and comparing it against another naive one silently assumes both came from the same zone.
  • circular_dependency.py explains an ImportError at 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 under pytest and fails under gunicorn. 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.py escalates inside async 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.

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 ascoverslanguages
billing.test.tsbillingJS/TS
billing_test.pybillingPython, Go
test_billing.pybillingPython

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

Terminal window
$ 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.py and setup.cfg are project-root markers. Root detection only knew package.json, so running crimes context from a subdirectory of a Python repo found no root, fell back to the working directory, and scanned a subtree that excluded tests/.
  • foo_test.ts is now consistently a test file. likely_tests had 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.

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.

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:

beforeafter
claude, Python scenarios0.4970.967
codex, Python scenarios0.0890.261
claude, aggregate0.8320.907
codex, aggregate0.4670.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.

Two samples of this release, against the three most recent reference samples:

versionclaudecodex
0.12.20.870.64
0.12.2-r20.860.66
0.13.10.850.64
0.14.00.860.57
0.14.0-r20.860.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.10.888
claude 0.14.0 / r20.897 / 0.8790.933 / 0.883
codex 0.13.10.605
codex 0.14.0 / r20.569 / 0.5760.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.

Terminal window
npm install -g crimes@0.14.0

Schema 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 context may list tests it previously missed. The tests/ and test_* conventions now feed likely_tests, and foo_test.ts is consistently treated as a test file. If you have tooling asserting on likely_tests being 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"] } }