Skip to content

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

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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions