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 — delivering a 10x read discount versus the full write cost.

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)  ← 10x 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-4",
    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-4",
    messages=[{"role": "system", "content": system}],  # Cache HIT — prefix bytes match!
)

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 produces non-deterministic output
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

PCL001 and PCL004 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

# 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

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.

--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 on its own line the whole file
# pcdlint: disable=PCL002 alone on its own line the whole file, the named rules only

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.

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@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pcdlint
      - run: pcdlint check src/ --fail-on-warn
      # Findings appear directly on the PR diff via Code Scanning.
      - run: pcdlint check src/ --format sarif > pcdlint.sarif
      - uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: pcdlint.sarif

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 last check for:

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

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.2.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.2.0
File Size Uploaded
pcdlint-0.2.0.tar.gz 58.0 kB Details

Built distribution (wheel)

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

Total release size: 98.4 kB

Release files / pcdlint-0.2.0.tar.gz

Download URL pcdlint-0.2.0.tar.gz
Size 58.0 kB
Tags Source
SHA-256 checksum
How to use checksums
2352bb0a279b60a63faf7d193b2915359c7d722c5a2759c7e95f25fa3c143bde
BLAKE2b-256 checksum
How to use checksums
bf5b71e79a21c9731f0773bd7355cd5f8f1a8b6d1b64e472769aaee12dd705c1
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 28, 2026.

Transparency log

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

Download URL pcdlint-0.2.0-py3-none-any.whl
Size 40.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c4c4819fcd91d9b3372943316a307fef007e0ac1ee86f6ec9c97883fa70d001f
BLAKE2b-256 checksum
How to use checksums
6c460f8ecd0ace7648c85f9b216d6d7d29ce8a5e3141f17ca1e4cc1cbd4632bd
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 28, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.0

2 release files

This release

0.2.0 This release

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