Skip to content

mypy: shared .mypy_cache causes false positives in hold-the-line #1121

@maxitg

Description

@maxitg

Problem

Trunk's hold-the-line runs mypy per-file in parallel. The HEAD runs execute from the repo directory while upstream runs execute from a temp sandbox, but both share the same .mypy_cache (symlinked from the sandbox). Since mypy's cache is not safe for concurrent use (python/mypy#10221, python/mypy#16784), this causes false positives.

Mechanism

  1. .mypy_cache has entries from a prior run (e.g., a previous trunk check on the base branch, or a direct mypy invocation). These entries reflect the base branch version of a module.
  2. During hold-the-line, upstream mypy processes run with the base branch files. They re-validate and refresh the cache entries for the base branch version — making them appear fresh.
  3. HEAD mypy processes (running from the repo dir with the feature branch files) read these freshly-refreshed-but-stale cache entries and report false attr-defined errors for methods that exist on HEAD but not on the base branch.

trunk check --all is immune because it has no upstream processes to refresh stale cache entries.

Reproduction

mkdir repro && cd repro && git init

# -- master: simple class --
mkdir -p lib app
echo "" > lib/__init__.py
echo "" > app/__init__.py

cat > lib/color.py << 'PY'
class Color:
    def __init__(self, name: str) -> None:
        self.name = name
PY

cat > app/palette.py << 'PY'
from lib.color import Color

def defaults() -> list[Color]:
    return [Color("red"), Color("blue")]
PY

# Add more modules to increase parallelism
for i in $(seq 1 30); do
cat > "app/painter_${i}.py" << PY
from lib.color import Color

def paint(c: Color) -> str:
    return c.name
PY
done

git add -A && git commit -m "initial"

# Init trunk and enable mypy
trunk init
trunk check enable mypy
git add -A && git commit --amend -m "initial"

# -- feature branch: add a classmethod and call it --
git checkout -b feature

cat > lib/color.py << 'PY'
from pathlib import Path

class Color:
    def __init__(self, name: str) -> None:
        self.name = name

    @classmethod
    def from_file(cls, path: Path) -> "Color":
        return cls(path.read_text().strip())
PY

cat > app/palette.py << 'PY'
from pathlib import Path
from lib.color import Color

def defaults() -> list[Color]:
    return [Color("red"), Color("blue")]

def load(path: Path) -> Color:
    return Color.from_file(path)
PY

for i in $(seq 1 30); do
cat > "app/painter_${i}.py" << PY
from pathlib import Path
from lib.color import Color

def paint(c: Color) -> str:
    return c.name

def load(p: Path) -> Color:
    return Color.from_file(p)
PY
done

git add -A && git commit -m "add Color.from_file()"

# -- trigger: run trunk on master to seed cache, then check feature --
git checkout master
trunk check --filter=mypy --all    # seeds .mypy_cache with master types
git checkout feature

trunk check --filter=mypy          # ✖ ~12 false "type[Color]" has no attribute "from_file"
trunk check --filter=mypy --all    # ✔ No issues

The number of false positives varies per run (race-dependent), but it reproduces reliably when the cache is seeded.

Root cause

The mypy plugin does not use --cache-dir, so all parallel invocations (HEAD and upstream) write to the same .mypy_cache. The upstream runs refresh stale cache entries with base-branch type information, which HEAD runs then consume via --follow-imports=silent.

Suggested fix

Add --cache-dir ${cachedir} to the mypy run command in linters/mypy/plugin.yaml, similar to how the ruff plugin already handles this:

run:
  mypy --ignore-missing-imports --follow-imports=silent --show-error-codes
  --show-column-numbers --cache-dir ${cachedir} ${target}

This gives each invocation its own isolated cache directory. The tradeoff is that mypy can't reuse cached type information across runs, but since each invocation targets a single file with --follow-imports=silent, the performance impact is modest.

Workaround

Override the mypy definition in .trunk/trunk.yaml:

lint:
  definitions:
    - name: mypy
      commands:
        - name: lint
          run:
            mypy --ignore-missing-imports --follow-imports=silent --show-error-codes
            --show-column-numbers --cache-dir ${cachedir} ${target}

Environment

  • trunk cli: 1.25.0
  • mypy: 1.19.1
  • trunk-io/plugins: v1.7.3
  • OS: Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions