cachelint
v0.1.1
Published
A linter + CI gate for LLM prompt-cache regressions. Catches the silent-invalidator class (interpolated timestamps, unsorted JSON, BOM, mixed line endings) and fails PRs when a committed prompt's stable-prefix hash moves.
Downloads
325
Maintainers
Readme
cachelint
A linter + CI gate for LLM prompt-cache regressions.
Anthropic and OpenAI prompt caching are prefix matches: a single byte change
anywhere in the cacheable prefix invalidates the cache. Common causes — an
interpolated {{ now }} in a system prompt, an unsorted JSON.stringify, a
leading BOM, mixed line endings — sail through code review and quietly 10–20×
your token cost in production. It's documented in Anthropic's own bug tracker:
anthropics/claude-code#34629—cache_read_input_tokensnever grows, ~20× cost increaseanthropics/claude-code#46829— TTL silently regressed 1h → 5m, 15–53% overpaymentanthropics/claude-code#41930— widespread quota drain, March 23 2026- Anthropic's April 23 2026 engineering postmortem — a caching-logic bug was one of the three root causes
- …and cross-ecosystem reports (
openclaw/openclaw#19534,pydantic-ai#3453,BerriAI/litellm#5285, …) all tracing back to dynamic content in a "stable" prompt.
cachelint does one thing: it catches your own committed prompt content
silently breaking the cacheable prefix — at edit time and in CI — before it
ships. It is zero-cost to run (no servers, no paid APIs, no telemetry).
Install
npm i -g cachelint # or: npx cachelint …ESM-only, Node ≥ 22. Ships a cachelint binary and a library
(import { lint, hash, check } from "cachelint").
Quickstart
Lint your prompt files for known silent-invalidator patterns:
cachelint lint prompts/system.md prompts/tools.jsonHash them and commit the lockfile (like
package-lock.json—hashwrites,checkverifies):cachelint hash prompts/system.md prompts/tools.json # writes cachelint.lock git add cachelint.lock && git commit -m "lock prompt-cache prefixes"Check in CI — fails the build if a committed prompt's stable-prefix hash moved without a fresh
cachelint hash:cachelint check
That's it. If check fails and the change was intentional, re-run
cachelint hash … and commit the updated cachelint.lock.
Optional config
cachelint.config.json (or .yaml / .yml — data only, never executable):
{
"$schema": "https://raw.githubusercontent.com/davioe/cachelint/main/schema/cachelint.config.schema.json",
"prompt_globs": ["prompts/**/*.md", "prompts/**/*.json"],
"bundles": [
{ "name": "system", "files": ["prompts/system.md", "prompts/tools.json"] }
],
"disable": [], // e.g. ["R003"]
"rules": { "R001": "error" } // override default severities; "off" disables
}With prompt_globs set you can just run cachelint lint / cachelint hash /
cachelint check with no arguments.
Suppressing a finding
Inline, ESLint-style — cachelint-disable-next-line works in any comment style:
<!-- cachelint-disable-next-line R001 -->
The treaty was signed on 1989-11-09T18:53:00Z.GitHub Action
# .github/workflows/cachelint.yml
permissions:
contents: read
# security-events: write # only if you set `sarif:` below (Pro)
jobs:
cachelint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: davioe/cachelint@<commit-sha> # pin to a SHA, not a tag
with:
paths: 'prompts/**/*.md prompts/**/*.json'
# license: ${{ secrets.CACHELINT_LICENSE_KEY }} # optional, for ProThe action masks the license input, installs an exact cachelint version
(never a floating tag), runs lint then check, and (Pro) uploads SARIF for PR
annotations. Pin the action itself to a commit SHA, not a moving tag.
CI job summary
On GitHub Actions — and on any CI that exposes a GITHUB_STEP_SUMMARY file
path — cachelint lint and cachelint check each append a titled
cache-health section (findings table, bundle stability, cost estimates) to the
job summary, even on thrown errors. It is best-effort and purely cosmetic:
it never affects exit codes, respects --no-cost, redacts secrets, and is a
silent no-op where the env var is absent. Opt out by setting
CACHELINT_NO_STEP_SUMMARY to any non-empty value — e.g.
CACHELINT_NO_STEP_SUMMARY=1 (or the action's no-step-summary: "true"
input).
Fork PRs on public repos
Fork PRs don't get repo secrets, so a license-gated step would fail on them. If
you use a Pro feature, gate just that and let the free lint + check (which
need no secret) run everywhere:
- uses: davioe/cachelint@<sha>
continue-on-error: ${{ github.event.pull_request.head.repo.fork }}
with:
paths: 'prompts/**/*'
license: ${{ secrets.CACHELINT_LICENSE_KEY }}
sarif: cachelint.sarif(The free tier alone catches the whole silent-invalidator class; Pro adds the SARIF annotations on top.)
README badge (once published)
Once the repo is public, advertise the gate with a static shields.io badge —
swap in your OWNER/REPO and workflow file name:
[](https://github.com/OWNER/REPO/actions/workflows/cachelint.yml)Or the label-only variant if you don't run the workflow on every push:
[](https://github.com/davioe/cachelint)Pre-commit hooks
The repo ships .pre-commit-hooks.yaml with two
hooks for the pre-commit framework — cachelint
(lint) and cachelint-check (the lockfile gate). (Once published — the
repo: URL goes live when the repo does.)
# .pre-commit-config.yaml
repos:
- repo: https://github.com/davioe/cachelint
rev: v0.1.0 # pin to a release tag
hooks:
- id: cachelint
- id: cachelint-checkBoth hooks run config-scoped on the whole project (pass_filenames: false,
always_run: true) — deliberate: check must see the full bundle set
(passed filenames would redefine the bundles), and lint stays scoped to
prompt_globs so R003 behaves exactly like CI.
What gets caught
| Rule | Pattern | Severity | Why it matters | Docs |
| ----- | ---------------------------------------------------------------------- | -------- | --------------------------------------------------- | ---- |
| R001 | Interpolated timestamp ({{ now }}, Date.now(), datetime.now(), "Current Date & Time:", ISO-8601 datetimes) | warn | Prefix bytes change every call → 100% cache miss after that point | R001 |
| R002 | Interpolated UUID / random (crypto.randomUUID(), Math.random(), uuidv4(), nanoid(), {{ requestId }}, …) | warn | Same — non-determinism in the prefix | R002 |
| R003 | Unsorted JSON.stringify (no key-sort replacer) — only on files in prompt_globs / cachelint.lock / named literally | warn | Object key order isn't guaranteed stable → cache thrash | R003 |
| R004 | Leading UTF-8 BOM | error | An invisible char at the head of the prompt; cross-OS checkouts introduce it | R004 |
| R005 | Mixed CRLF / LF / CR line endings in one file | error | Hashes differently on Windows vs Linux even though the text "looks" identical | R005 |
cachelint hash / check normalize line endings + strip the BOM before
hashing, so the gate isn't fooled — but the lint rules still flag the source
so the inconsistency gets fixed. Pre-launch the false-positive rate of
R001–R003 is audited against real OSS repos and published in each rule's page.
Output
- Human (default, on stderr):
file:line:col level RID message, plus a fix hint and a docs link per finding. --json(on stdout): a stable schema —{ version, configPath, diagnostics, warnings, exitCode }, plus an optionalcostblock (absent under--no-cost).--sarif <path>(Pro): SARIF v2.1 for GitHub Code Scanning.
Cost estimates
Findings carry a deliberately conservative dollar estimate of what the broken
prefix costs you (full recipe, assumptions, and error bounds:
docs/cost-model.md). Per finding, cachelint lint
prints:
prompts/random.md:1:13 warning R002 `crypto.randomUUID()` in prompt content — a fresh UUID every call
› Session id: crypto.randomUUID()
fix: Generate the id outside the cached prefix (or after the last breakpoint).
why: https://github.com/davioe/cachelint/blob/main/docs/rules/R002.md
→ invalidates ~8 stable prefix tokens from here
≈ $0.022 wasted per 1,000 calls (est. if caching is enabled, claude-sonnet-4-6 pricing, 2026-06)…and a run summary:
cachelint: 5 problems (2 errors, 3 warnings)
cachelint: est. waste ≈ $0.068 per 1,000 calls · ~19 prefix tokens at risk (est. if caching is enabled, claude-sonnet-4-6 pricing, 2026-06)A failing cachelint check prints the waste/at-risk totals before its
verdict (and a green check prints a value receipt of what the stable
prefixes protect):
cachelint: cachelint.lock is out of date —
~ bundle "prompts/a.md" stablePrefixHash moved:
was: 70a6822d8636ba2c31494cda7700b158cf426837df5fe6515ba228d817615f15
now: ccbaa0ede490b50c246fb4f98e227968653eff0f5a8f666d1fba78ae6cb33d87
prompts/a.md: 6cdf29393117 → 6a02769c47b4
If this change is intentional: run `cachelint hash …` and commit the updated cachelint.lock.
prompts/a.md:2:13 warning R001 template helper injects the current date/time into the prompt
why: https://github.com/davioe/cachelint/blob/main/docs/rules/R001.md
→ invalidates ~3 stable prefix tokens from here
prompts/b.md:1:1 error R004 file begins with a UTF-8 BOM (U+FEFF) — an invisible character at the head of the prompt
why: https://github.com/davioe/cachelint/blob/main/docs/rules/R004.md
~4 prefix tokens at risk (byte-stable — changes on edit/regeneration, not per call)
cachelint: est. waste ~3 stable prefix tokens per call (under $0.01 per 1,000 calls) · ~4 prefix tokens at risk
cachelint: FAILED — stable-prefix hash moved; 1 error-severity findingCost output is cosmetic — it never affects exit codes, hashing, or the
lockfile — and --no-cost removes it from every surface (human, --json,
CI summary). See docs/cost-model.md for why the
numbers are defensible: volatility classes, the earliest-divergence model,
and every conservatism guarantee.
Exit codes (v1.0 stability contract)
| code | meaning |
| ---- | ------- |
| 0 | ok (or only warn-severity findings without --strict) |
| 1 | a stablePrefixHash moved (regression), or an error-severity rule fired (or a warn under --strict) |
| 2 | a Pro feature (--sarif, --exact, --pack) was invoked without a license |
| 3 | cachelint.lock is missing or corrupt |
| 4 | config or source error |
OSS vs Pro
| | Free (MIT) | Pro |
| --- | --- | --- |
| cachelint lint (all rules, pragmas, --json) | ✅ | ✅ |
| cachelint hash / check — single and multi-file bundles | ✅ | ✅ |
| The GitHub Action | ✅ | ✅ |
| --sarif PR annotations (GitHub Code Scanning¹) | — | ✅ |
| --exact token counting (Anthropic count-tokens API, your key) | — | ✅ |
| Provider bug-pattern packs (--pack anthropic-2026-q1) | — | ✅ |
¹ Uploading SARIF to a private repo requires GitHub Advanced Security; on
public repos it works on the free plan. Disclosed in LICENSE-PRO.md.
Pro is a one-time individual / annual team license. Keys are offline-verifiable
(Ed25519 via node:crypto) — no servers, no telemetry, works in air-gapped CI.
cachelint activate <key> to enable, cachelint license status to inspect,
cachelint deactivate to remove; CACHELINT_LICENSE_KEY env for CI. The buyer's
email is embedded in the key and printed by cachelint --version — the only
anti-share signal, disclosed at checkout. See docs/licensing.md.
Design contract
- Zero operating cost. No servers. No paid APIs (
--exactuses yourANTHROPIC_API_KEY). No telemetry, ever. - Determinism.
cachelint.lockis byte-identical across Windows / macOS / Linux and Node 22/24. Full spec:docs/determinism-contract.md. - Config is data, not code.
.json/.yamlonly — no.ts/.jsconfig, so a shared config can't execute anything. - Rules are pure functions (no I/O, no network) — enforced by a sandbox harness, so future community rule packs are safe by construction.
- No secret ever leaks. API keys and license tokens are redacted in every
output path, including
--verbose.
License
Free core: MIT (LICENSE). Pro features: LICENSE-PRO.md.
Contributing
See CONTRIBUTING.md. Implementation plan:
docs/plans/2026-05-11-002-feat-cachelint-mvp-plan.md.
