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
.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.
- 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.
- 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
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
.mypy_cachehas entries from a prior run (e.g., a previoustrunk checkon the base branch, or a directmypyinvocation). These entries reflect the base branch version of a module.attr-definederrors for methods that exist on HEAD but not on the base branch.trunk check --allis immune because it has no upstream processes to refresh stale cache entries.Reproduction
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 inlinters/mypy/plugin.yaml, similar to how the ruff plugin already handles this: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:Environment