Skip to content

davioe/cachelint

Repository files navigation

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:

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

  1. Lint your prompt files for known silent-invalidator patterns:

    cachelint lint prompts/system.md prompts/tools.json
  2. Hash them and commit the lockfile (like package-lock.jsonhash writes, check verifies):

    cachelint hash prompts/system.md prompts/tools.json   # writes cachelint.lock
    git add cachelint.lock && git commit -m "lock prompt-cache prefixes"
  3. 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 Pro

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

[![cachelint](https://img.shields.io/github/actions/workflow/status/OWNER/REPO/cachelint.yml?label=cachelint)](https://github.com/OWNER/REPO/actions/workflows/cachelint.yml)

Or the label-only variant if you don't run the workflow on every push:

[![prompt cache: protected](https://img.shields.io/badge/prompt_cache-protected-brightgreen)](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-check

Both 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 optional cost block (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 finding

Cost 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 (--exact uses your ANTHROPIC_API_KEY). No telemetry, ever.
  • Determinism. cachelint.lock is byte-identical across Windows / macOS / Linux and Node 22/24. Full spec: docs/determinism-contract.md.
  • Config is data, not code. .json / .yaml only — no .ts/.js config, 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.

About

Linter + CI gate for silent LLM prompt-cache invalidations — find the patterns that quietly bust your prompt cache and put a dollar figure on them

Resources

License

Unknown, Unknown licenses found

Licenses found

Unknown
LICENSE
Unknown
LICENSE-PRO.md

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors