Skip to main content

pcdlint (Prompt-Cache Determinism Linter)

CI Status PyPI version Python versions License: MIT

A static taint analysis linter that detects code patterns silently invalidating LLM Prompt Caching (OpenAI & Anthropic SDKs).


The $10,000 Bug Explained

LLM prompt caching works by storing the prefix of a prompt in a fast cache. When the prefix matches a cached version, the model skips re-processing the prefix — costing up to 90% less than a full write, depending on the provider and model (see OpenAI's pricing and Anthropic's pricing).

The catch? The cache key is computed from the exact byte sequence of the prompt prefix. A single non-deterministic character at position 0 — like datetime.now() — changes every single byte, making the cache completely useless.

Full Write Cost:  ██████████  ($0.030 / 1K tokens)
Cache Read Cost:  █          ($0.003 / 1K tokens)  ← 90% cheaper

One datetime.now() at character 0 silently destroys cache hits, wasting money on every request.


❌ Bad Code (0% Cache Hit)

from datetime import datetime

system = f"Time: {datetime.now()}\n{STATIC_RULES}"
#                                    ^^^^^^^^^^^^ PREFIX TAINTED — every request has a unique prefix

client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "system", "content": system}],  # Cache MISS every time!
)

✅ Good Code (90% Cost Reduction)

from datetime import datetime

system = f"{STATIC_RULES}\nTime: {datetime.now()}"
#       ^^^^^^^^^^^^^^ Static prefix first, dynamic value at the end — cache prefix preserved

client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "system", "content": system}],  # Cache HIT — prefix bytes match!
)

How caching differs by provider

Both providers cache a prefix, and neither will cache anything if the bytes keep moving — but they decide what counts as a hit differently:

OpenAI Anthropic
Opting in automatic explicit cache_control breakpoints
What must match the longest prefix that still matches every byte up to the last breakpoint
Dynamic value at the end partial hit on the part that still matched —
Dynamic value before the breakpoint partial hit 100% miss

So the same code is not equally bad everywhere. OpenAI falls back to the longest prefix that still matches, which is why PCL001 only complains when the dynamic value comes before the static text. Anthropic serves a cached prefix only when it is byte-identical up to a cache_control breakpoint, so STATIC + f"{now}" inside the block carrying that breakpoint throws away the whole cache — that is what PCL005 reports.

A cache_control on the request itself (Anthropic's automatic caching) counts the same way: it applies the breakpoint to the last cacheable block, so taint anywhere in system or messages is a 100% miss there too.

See OpenAI's prompt caching guide and Anthropic's prompt caching docs.


Rule Reference

Rule ID Name Severity --fix Description
PCL001 prefix-taint-injection ERROR — Dynamic value placed before static prompt text invalidates cache prefix
PCL002 unsorted-json-in-prefix WARNING ✅ json.dumps() without sort_keys=True: key order follows how the dict was built, so two runs can disagree. A dict/list literal is exempt — its order is the source order.
PCL003 set-iteration-in-prompt ERROR ✅ Python's PYTHONHASHSEED randomizes set iteration order across processes
PCL004 dynamic-tools-mutation WARNING — Changing tool definition order invalidates the entire prompt cache hierarchy
PCL005 taint-before-cache-breakpoint ERROR — Dynamic value anywhere before an Anthropic cache_control breakpoint, on a block or on the request itself (automatic caching)

PCL001, PCL004 and PCL005 have no mechanical fix: only you know where the dynamic value belongs, or what the tool order should be.


Installation

pip install pcdlint

Or install from source with dev dependencies:

pip install -e ".[dev]"

CLI Usage

# Check a single file
pcdlint check src/my_app.py

# Check multiple paths
pcdlint check src/ tests/

# Output as JSON
pcdlint check src/ --format json

# Output SARIF 2.1.0 for GitHub Code Scanning
pcdlint check src/ --format sarif > pcdlint.sarif

# Apply the mechanical fixes (PCL002 sort_keys, PCL003 sorted) in place
pcdlint check src/ --fix

# Only report findings on lines this branch changed (default ref: HEAD)
pcdlint check src/ --diff origin/main

# Fail on warnings (useful for CI)
pcdlint check src/ --fail-on-warn

# Run only some rules / skip others (overrides [tool.pcdlint])
pcdlint check src/ --select PCL001,PCL003
pcdlint check src/ --ignore PCL002

# Leave files out of the scan entirely (overrides [tool.pcdlint])
pcdlint check src/ --exclude "tests/*" --exclude "*.min.py"

# Equivalent module forms (no console script needed)
python -m pcdlint check src/
python -m pclint check src/

--fix rewrites only what it can rewrite safely, re-reads the files so the report and the exit code describe what is on disk now, and tells you how many findings it had to leave to you. A rewrite that would no longer parse is reported and skipped rather than written.

--diff needs git. It compares the working tree against REF and keeps a finding only when the line the finding points at changed — a finding on an untouched line stays hidden even if the value it refers to was edited. Pair it with --fix to repair only what a PR introduced.

A file git does not know about yet counts as wholly new, so every finding in it is reported; files your .gitignore excludes are left out entirely.

Exit codes

Code Meaning
0 No findings
1 Findings at ERROR severity, or any WARNING when --fail-on-warn is set
2 A path could not be analyzed: missing, not a .py file, not valid UTF-8, a syntax error, or --diff could not run (no git, not a repository, unknown ref)

Exit code 2 exists so a typo'd path or a broken file can never look like a clean run in CI. Errors are printed to stderr; --format json output on stdout stays valid JSON. A failed --diff also exits 2: reporting "no findings" because git was unavailable would be the linter lying about your code.

Configuration

Rules can be turned on and off from pyproject.toml or the command line:

[tool.pcdlint]
select = ["PCL001", "PCL003"]   # run only these rules
ignore = ["PCL002"]             # skip these, applied after select
exclude = ["tests/*", "*.min.py"]  # never scan these at all
taint-sources = ["mypkg.jitter"]    # calls you know are nondeterministic
sinks = ["mywrapper.ask"]           # calls that take a prompt

The nearest pyproject.toml above each analyzed file is used, so a monorepo can give every package its own rule set. select is an allowlist; ignore is a denylist applied after it. exclude drops files a directory walk would pick up, before anything is analyzed — matched against the bare file name and the path relative to what you passed, so "*.min.py" and "tests/*" both do what they look like. A file you name on the command line is always checked.

taint-sources and sinks are the escape hatch for calls this linter cannot know about: the first names qualified calls that are nondeterministic (mypkg.jitter() taints like uuid.uuid4()), the second names qualified calls that take a prompt, so everything inside one is judged as payload. Both are taken at your word — a name is matched exactly or on a dot boundary, and nothing else about the call has to look like an LLM API. A typo is an error rather than a rule that silently never fires.

A folder holding a pyvenv.cfg is skipped, whatever it is called: venv311/, .venv-py312/ and .tox/py311/ are caught without needing a name on the built-in skip list. That list — packaging output and tool caches — is .venv, venv, node_modules, .git, __pycache__, build, dist, .tox, .nox, .mypy_cache, .ruff_cache, .pytest_cache, .eggs, htmlcov, site-packages and .ipynb_checkpoints. env is deliberately not on it: it is a common name for application code, and the pyvenv.cfg rule already catches a virtualenv whatever it is called. A path you name on the command line is never skipped by any of this.

--select and --ignore take a comma-separated list and are repeatable. Each flag replaces the corresponding config value for that run rather than merging with it, so --ignore PCL002 overrides a configured ignore list outright.

A config with an unknown key, an unknown rule id, or broken TOML exits 2 with a message on stderr. It never falls back to defaults — otherwise select = ["PCL999"] would make the run look clean.

Suppressing a Finding

Switch rules off on a single line with a # pcdlint: disable comment, placed on the line pcdlint reports:

system = f"Time: {now}\n{STATIC_RULES}"  # pcdlint: disable
Form Scope
... # pcdlint: disable that line, every rule
... # pcdlint: disable=PCL001,PCL003 that line, the named rules only
# pcdlint: disable alone, above the first statement of the file the whole file
# pcdlint: disable=PCL002 alone, above the first statement the whole file, the named rules only
# pcdlint: disable alone, further down the next line of code

Only a marker written above the first statement of the file switches the whole file off — that is where it has always lived. The same marker further down belongs to the line of code beneath it, so putting it on its own line above the call you mean does what you expect instead of muting every other finding in the file. Blank and comment-only lines between the marker and its target are skipped.

Comments are read with Python's tokenizer, so the same text inside a string literal is data and does nothing. A marker naming a rule that does not exist suppresses nothing — a typo surfaces the finding instead of hiding it.

The marker may sit anywhere in the comment and be written without padding, so it can share a token with another pragma. All of these suppress:

system = ...  # type: ignore  # pcdlint: disable
system = ...  # noqa: E501 pcdlint: disable
system = ...  #pcdlint:disable

It still has to be a complete marker: # pcdlint: disable-all and prose that merely mentions the keyword do not count.

Comment the line pcdlint prints. For PCL001 that is the line holding the system=... argument, not the line where the tainted value was built.

GitHub Actions CI Integration

name: pcdlint
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write   # required to upload SARIF
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: "3.12"
      - run: pip install pcdlint
      # Findings appear directly on the PR diff via Code Scanning.
      # pcdlint exits 1 for findings and 2 when it could not analyze. Only 1
      # may be absorbed here: run this step before the enforcing one, with a
      # bare `>`, and it fails on every run that has findings -- which is
      # exactly the run whose results you wanted uploaded.
      - name: Emit SARIF
        run: pcdlint check src/ --format sarif > pcdlint.sarif || test $? -eq 1
      - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
        if: always()
        with:
          sarif_file: pcdlint.sarif
      - name: Enforce
        run: pcdlint check src/ --fail-on-warn

Code Scanning has to be enabled for the repository (free for public repos). Add continue-on-error: true to the upload step if some of your repositories do not have it enabled.

To lint only the lines a pull request changed, swap the enforcing step for:

      - name: Enforce
        run: pcdlint check src/ --diff "origin/${{ github.base_ref }}" --fail-on-warn

--diff needs the base branch to exist locally, and a default checkout is a shallow, single-ref clone where origin/<base> does not -- pcdlint would exit 2 with "unknown revision". Give that variant a full history:

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0

How It Works

  1. AST Parsing: Uses Python's built-in ast module to parse source files — no external compilers needed.
  2. Taint Tracking: Identifies non-deterministic sources (datetime.now(), uuid.uuid4(), os.urandom(), etc.) and tracks their propagation through f-strings, concatenation, and .format().
  3. Prefix Analysis: Precisely determines if a tainted expression appears before a static prefix solid in the prompt string.
  4. Rule Matching: Checks tainted prompts against LLM API sink calls (chat.completions.create, messages.create, etc.) and reports violations.

Metadata

Release files for pcdlint 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pcdlint 0.3.0
File Size Uploaded
pcdlint-0.3.0.tar.gz 105.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pcdlint 0.3.0
File Interpreter ABI Platform
pcdlint-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 170.2 kB

Release files / pcdlint-0.3.0.tar.gz

Download URL pcdlint-0.3.0.tar.gz
Size 105.6 kB
Tags Source
SHA-256 checksum
How to use checksums
1dfeceb70af9485a0bbcb3d900edba6101e52479fd0a56cb9d850e44d6672b01
BLAKE2b-256 checksum
How to use checksums
fc56dbda43c6224a8af96c90ebc7f93a31dc36089bca5b433780863c5db10463
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / pcdlint-0.3.0-py3-none-any.whl

Download URL pcdlint-0.3.0-py3-none-any.whl
Size 64.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
860e04f9b0e3f4e612817b455b51838d13892d87d65ffda0f55e5ae3f51d54f3
BLAKE2b-256 checksum
How to use checksums
4e8b634397c99c1cfcb9b9ffdfc77d847bc0859b60e2ec7c031c24299293d481
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page