Skip to content

`crimes@0.8.0` — Extended Lens: Date, Naming, Hot-Path, and Asset Crimes

Draft release notes for the GitHub Release tagged v0.8.0. The body below is what should go in the Releases page when you cut the tag — that triggers .github/workflows/release.yml and publishes to npm via Trusted Publishing.

crimes@0.8.0 extends the lens to four families of “common-sense” crimes that mainstream linters don’t catch: timezone-and-date crimes, narrow naming-tier crimes, hot-path I/O crimes, and asset-weight crimes. One config feature plus thirteen new detectors land together. Detector count: 34 → 47. Five accumulated patch bumps (0.7.6 → 0.7.15) roll up into this release.

  • One new config feature. detectors.options.<id> — per-detector exemption values, sitting between detectors.disable (kills the detector everywhere) and crimes ignore (suppresses one specific finding). Each detector registers its own zod schema; typos surface at config-load time.
  • Date / time family (5 detectors). timezone_unsafe_parse, mixed_utc_local_methods, locale_drift, dst_naive_arithmetic, date_string_concat. The silent-bug class linters most consistently miss.
  • Naming-tier family (2 detectors). boolean_naming_drift, singular_plural_type_mismatch. Names that lie to readers and agents.
  • Hot-path / portability family (3 detectors). sync_io_in_hotpath, hardcoded_local_path, hardcoded_localhost. Patterns that survive code review and break in production.
  • Asset family (3 detectors) — first non-source detectors. oversized_raster, raster_should_be_vector, svg_with_embedded_raster. A new second-pass pipeline lets crimes walk images alongside source without disturbing the existing parsed-AST contract.
  • Eight new eval scenarios spread across all five scenario kinds — one per detector that warrants its own scenario. Total per agent: 30 → 38.
  • Zero medium-or-higher self-scan findings from the new detectors. Self-scan stays clean.

Schema: schema_version stays at "0.1.0". Every new detector emits the existing Finding shape; new parser fields are additive on ParsedFile. Existing scan JSON files load unchanged.

The 0.3.0 → 0.7.5 arc built out structural, IA, duplication, frontend, and calibration coverage. The 0.8.0 thesis:

Extend the lens to cover four families of “common-sense” crimes that linters don’t catch and humans / agents repeatedly ship.

Every new detector here was audited against ESLint / Biome / Knip / typescript-eslint / Semgrep / SonarQube before keeping. The rule: either no mainstream tool catches the pattern, or the tool exists but is rarely configured and our angle (change-risk or agent-risk) is sharper than the linter angle. Three candidates from the original brainstorm were rejected during research because ESLint covers them cleanly (@ts-ignore discipline, as any, await in loop); two more deferred to 0.9.0 with type-info work (date_equality_misuse, exported_stop_word_name); one dropped as too rare to matter in modern TS code (catch_rethrow_no_context).

Research, decisions, and ship-vs-defer rationale all recorded at .planning/0.8.0-extended-lens.md and .planning/0.8.0-RESEARCH.md.

detectors.options.<id> is the third tier of suppression alongside detectors.disable and crimes ignore:

{
"detectors": {
"options": {
"boolean_naming_drift": {
"allowedNames": ["pristine", "processed"]
},
"hardcoded_local_path": {
"allowedPaths": ["/Users/ci-runner/cache"]
},
"oversized_raster": {
"allowedPaths": ["public/hero/"]
}
}
}
}

Each detector registers an optional optionsSchema: z.ZodType on its declaration; the config loader validates every supplied options block against the registered schema at load time. Typos for both detector ids (“hardcoded_local_pth”) and option keys (“allowdPaths”) raise ConfigParseError (exit code 2) — never silently no-op. Consumed by every 0.8.0 detector with built-in exemption surface, plus retro-fitted onto the existing detector list where it improves configurability.

Prototyped against the crimes monorepo, the bundled fixture, and ~600 files of external open-source TS/JS before keeping (see Investigation 8 of the research doc for the FP-surface evidence). Two candidates from the original brainstorm — date_equality_misuse and date_string_concat v2 — required type info to be robust and deferred to 0.9.0.

  • timezone_unsafe_parse (“Timezone Roulette”) — flags new Date("…") whose string argument has no Z and no ±HH:MM offset. YYYY-MM-DD is read as UTC midnight; YYYY-MM-DDTHH:MM:SS without zone is read as local time. Either way the developer is betting on a timezone the runtime won’t honour. Severity medium-high, confidence 0.90. Per-project allowlist via detectors.options.timezone_unsafe_parse.allowedLiterals.
  • mixed_utc_local_methods (“Half-UTC, Half-Local”) — flags Date instances where get*UTC* / get* methods of opposing families are read on the same receiver identifier in the same file. Silent bug class: tests pass in UTC, production drifts by the host’s offset. Severity high.
  • locale_drift (“Host-Locale Drift”) — flags .toLocaleDateString() / .toLocaleString() / .toLocaleTimeString() invoked without a locale argument. Output depends on the host’s default locale; user-facing renderers need an explicit pick.
  • dst_naive_arithmetic (“DST-Naive Day Math”) — flags + 86400000 / + 604800000 (day / week / 28-day / 30-day / year millisecond constants) and folded equivalents like 24 * 60 * 60 * 1000. Day-level millisecond arithmetic silently misfires on DST transition days.
  • date_string_concat (“Date String Sewing”) — flags "…" + d.getUTCMonth() and the reverse — hand-rolled date string assembly. Smell rather than guaranteed bug, but a tell that the project should reach for Intl.DateTimeFormat or toISOString().
  • boolean_naming_drift (“Unprefixed Boolean”) — declarations annotated : boolean or initialised from a boolean expression (!x / a === b / etc.) whose name lacks the is/has/should/can/will/did/was/were/are/… prefix family. Ships with a built-in React-state allowlist (loading, ready, active, disabled, expanded, pending, open, closed, visible, hidden, selected, focused, dirty, valid, submitting, editing, dragging, hovering, checked, busy, empty, full, online, offline, mounted, unmounted) so idiomatic React doesn’t trip the detector. User extensions via detectors.options.boolean_naming_drift.allowedNames. Severity low-medium, confidence 0.80.
  • singular_plural_type_mismatch (“Plural Mismatch”) — declarations where the name’s plural shape disagrees with the type’s array shape (users: User, invoice: Invoice[], users: Array<User>). v1 fires on bare identifier and simple- array annotations only — aliased and generic types deferred to 0.9.0 type-info work. Hand-rolled pluraliser (~60 lines, no dependency) plus uncountable-noun allowlist (data, information, news, software, staff, …). Severity low-medium, confidence 0.70.

Hot-path / portability family (3 detectors)

Section titled “Hot-path / portability family (3 detectors)”
  • sync_io_in_hotpath (“Sync I/O in Hot Path”) — node:fs *Sync methods (readFileSync, writeFileSync, existsSync, statSync, readdirSync, …) and the synchronous process- spawning helpers (execSync, spawnSync, execFileSync) invoked inside route handlers, page exports, React components, or domain functions. Test-callback and CLI-registrar ancestors anywhere in the enclosing chain suppress the finding (sync I/O in those shapes is either intentional or test-only). Severity caps at high for request surfaces; pure-domain findings stay low so a CLI’s own legitimate sync I/O doesn’t dominate the default report.
  • hardcoded_local_path (“Localhost-on-Disk”) — /Users/<name>/…, /home/<name>/…, Windows C:\Users\<name>\… (back- and forward-slashed). Skips files under scripts/, examples/, fixtures/, test/, tests/. Severity medium-high, confidence 0.90. Per-project allowlists via detectors.options.hardcoded_local_path.allowedPaths.
  • hardcoded_localhost (“Dev-Server URL”) — localhost:NNNN, 127.0.0.1:NNNN, 0.0.0.0:NNNN, [::1]:NNNN in non-test, non-config source. Skips .env*, *.config.*, docker-compose*, Dockerfile*, README*.md, CHANGELOG*.md, and the scripts//examples//docs//fixtures//test//tests//__tests__/ directories. Severity medium-high, confidence 0.90.

Asset family (3 detectors) — first non-source detectors

Section titled “Asset family (3 detectors) — first non-source detectors”

The asset pass is the first crimes pipeline that operates on files other than TS/JS source. Source detectors stay on the parsed-AST contract; asset detectors get a slimmer AssetDetectorContext carrying { file, absolutePath, extension, byteSize, read(), config } where read() is lazy and per-file cached. The two pools share one detectors.options.<id> namespace and one detectors.enable / disable list.

  • oversized_raster (“Oversized Raster”) — file size against thresholds.assetWeight.{low,medium,high}Kb (defaults 200 / 500 / 1000, mirroring Core Web Vitals “good / needs improvement / poor” guidance for content images). Pure-stat detector: flagging a 5 MB hero is one fs.stat syscall.
  • raster_should_be_vector (“Icon-Sized Raster”) — PNG / JPEG / GIF whose width AND height both fit ≤ 64 px. Header-only dimension parse via a ~80-line in-tree reader (no image-size dependency added; WebP / AVIF return undefined from the parser and are silently skipped in v1). Configurable iconSizeMax.
  • svg_with_embedded_raster (“SVG With Embedded Raster”) — SVG files containing <image href="data:image/*;base64,…"> (or xlink:href). The pattern defeats the entire reason to use SVG: the vector container ships, but the actual content is a raster blob. Severity medium for one embed, high for two-plus.

Additive ParsedFile fields — no schema bump, no existing detector touched:

  • dateMethodCalls (phase 2a) — every Date.prototype method call with receiver / family (UTC vs local) / line / arg count.
  • dateArithmetic (phase 2a) — every + / - whose numeric operand matches a day / week / month / year millisecond constant. Folds nested * (so 24 * 60 * 60 * 1000 is recognised as a day).
  • dateStringConcats (phase 2a) — "…" + d.dateMethod() and the reverse.
  • typedDeclarations (phase 3a) — every named declaration (const / let / var / param / property) with optional type annotation text and InitializerKind. Destructuring patterns intentionally skipped — naming-tier detectors only consider simple identifier names.
  • syncIoCalls (phase 4a) — every node:fs *Sync and child-process-sync call site, with the full chain of enclosing function-like ancestors (innermost first). Lets detectors apply their own shape policy without re-walking the AST.

Following the 0.7.5 “one scenario per detector that warrants its own scenario” pattern:

  • refactor-01-plural-mismatch — agent proposes name-vs-type fixes for two declarations.
  • context-01-boolean-naming — agent picks the React-state- idiom-vs-policy-violation distinction across two unprefixed booleans.
  • bugfix-01-sync-io-hotpath — production handler stalls under concurrent load; agent identifies the per-request blocking calls and proposes async migration.
  • plan-01-hardcoded-local-path — portability sweep; agent picks os.homedir() / process.env.* / relative paths case-by- case rather than recommending one for everything.
  • review-01-hardcoded-localhost — PR review of a new helper exposing a localhost:NNNN URL; agent recommends a specific configuration mechanism, not the generic “move to config”.
  • context-01-raster-icon — agent inspects an icon-sized PNG before reusing its pattern and explains why an SVG is the better starting point.
  • refactor-01-svg-embedded-raster — agent refactors an SVG carrying embedded base64 rasters, splitting “this becomes vector paths” from “this becomes a standalone raster referenced by URL”.
  • review-01-oversized-raster — agent reviews a new hero PNG, quotes the byte size from finding evidence, and picks AVIF / WebP with rationale.

Total per agent: 30 → 38. All 38 reconcile against their fixtures (pnpm --filter evals-runner evals:verify-scenarios green). New fixture violations live in examples/messy-ts-app/src/api/files.ts (sync I/O + hardcoded paths + dev-server URL) and examples/messy-ts-app/src/assets/{hero-banner.png,check-icon.png,partner-logo.svg} (asset trio).

Two measurement bugs surfaced during the consolidated re-run and landed in the same release:

  • DETECTOR_IDS in the scorer now unions builtInDetectors with builtInAssetDetectors. Asset-detector slugs weren’t recognised before — codex review-01-oversized-raster scored 0/3 despite naming oversized_raster verbatim.
  • extractFilePaths regex extended with asset extensions (png, jpg, jpeg, gif, webp, avif, svg). Image paths weren’t picked up by the file-reference check.

Both are pure calibration; no agent or detector behaviour changed. The 0.7.14 results dir was never committed; 0.7.15 carries the corrected baseline.

76 runs (38 scenarios × 2 agents) in 5m 44s on the reference laptop:

AgentStructural pass rateΔ vs 0.7.8
claude0.85+1pp
codex0.74-4pp

Per scenario kind:

Kindclaudecodexclaude Δcodex Δ
bugfix0.560.50-12pp-18pp
context1.001.00+0pp+0pp
plan0.940.59+14pp+8pp
refactor0.890.78+1pp+0pp
review0.810.76+0pp-9pp

The two visible shifts both come from the new 0.8.0 scenarios:

  • bugfix down 12-18pp. bugfix-01-sync-io-hotpath is genuinely harder than the date-family bugfix scenario — the agent has to connect “production stalls under load” to a specific blocking- call class and propose the smallest async migration. Both agents miss pieces of that.
  • plan up 14pp / 8pp. plan-01-hardcoded-local-path lands cleanly — both agents quote the literal path and split the remediation across os.homedir() / process.env / relative paths cleanly. The signal: portability migrations are an agent strength, not a weakness.

Result transcripts and rubric scores at evals/results/0.7.15/, replayable against future scoring tweaks via pnpm run evals:replay.

  • No schema bump. schema_version stays at "0.1.0". Existing consumers unchanged.
  • No new commands. CLI surface byte-equivalent to 0.7.5.
  • No type checker in language-js. The naming-tier v1 stays syntactic — aliased and generic type annotations silently skip. v2 (graduating to type info via raw ts API) is on the 0.9.0 candidate list.
  • No image-size dependency. The icon-size detector uses a ~80-line in-tree header parser covering PNG / GIF / JPEG. WebP / AVIF support deferred to 0.9.0 with the dependency.
  • No LLM-assisted detector modes. Evals call LLMs; detectors stay deterministic.
  • No Python. Still deferred per PRD §26.

Three detectors emerged from research with sharp framings but required either type info or speculative evidence:

  • date_equality_misusedateA === dateB between two Date parameters. Syntactic v1 missed too many real cases (function parameters typed Date aren’t named date/d). Re-evaluate with type info.
  • exported_stop_word_namedata / Manager / Helper exported at module top level. Exports-only prototype found zero hits across 500+ files — the signal lives at parameter and local-variable scope, where ESLint’s id-denylist already sits.
  • dead_export with edit-distance variant — a dead export whose name is within edit distance 1 of a live export (“agent picks wrong-but-close name”). Plausible but unproven; needs eval-run evidence before committing detector design.

Plus the v2 of singular_plural_type_mismatch once type info lands.

Terminal window
npm install -g crimes@0.8.0
crimes --version # crimes@0.8.0
npx crimes scan .

If you have crimes.config.json and want to tune the new detectors, the configurable surface is documented in docs/configuration.md:

  • thresholds.assetWeight.{lowKb, mediumKb, highKb}oversized_raster thresholds.
  • assets.include / assets.exclude — asset discovery scope.
  • detectors.options.<id> — per-detector exemption values for every 0.8.0 detector with allowlist surface.

If your CI pipeline does --fail-on high on crimes scan or crimes baseline check, new findings of severity ≥ high will gate the build. Run crimes scan --changed --fail-on high on the upgrade PR to see what would trip — or crimes baseline save to pin the current state and only fail on regressions.