Pub cache damage: recognising it, and repairing it
The local pub cache has repeatedly lost package content on mbp. Every occurrence has presented as a defect somewhere else
— that is the whole reason this note exists. One sighting looked exactly like an API break in tom_build_base
and cost a detour into version archaeology; another looked like a broken test harness. Nobody suspects the cache, because nothing about the error message mentions it.
Check first, investigate second:
_bin/check_pub_cache.sh # ~1s over the whole cache; exit 1 = damage
_bin/check_package_configs.py # ~3s over the workspace; exit 1 = a project
# whose package config names a missing root
The two look at the same damage from opposite ends, and each is blind where the other sees. check_pub_cache.sh
inspects cached directories, so a directory that is gone leaves it reporting "healthy".
check_package_configs.py reads every project's .dart_tool/package_config.json
and reports each package root that no longer exists — which is how a gone directory actually shows up.
Neither runs by itself, which is the gap PubCacheIntegrity closes.
The check that runs without being remembered
PubCacheIntegrity (tom_build_base 2.9.0+) stats every hosted entry of a project's pubspec.lock
against the cache and reports what it cannot supply, with the expected path and the repair. testkit :test / :baseline run it before launching the test runner, so the regression gate names the package instead of letting the failure arrive as a compile error. Any tool built on tom_build_base can call it:
final problems = PubCacheIntegrity.checkProject(projectPath: path);
if (problems.isNotEmpty) {
stderr.writeln(PubCacheIntegrity.describe(problems, projectPath: path));
}
It is lock-driven where check_package_configs.py is config-driven: the lock says what the project resolved, the package config says what a previous resolve wrote down. Prefer the script for a workspace-wide sweep, the API for one project inside a tool.
Why a missing package does not look like a missing package
It surfaces as Error: Undefined name '<Symbol>' at every use site, and when compiling, as
AOT compilation failed / Bad state: Generating AOT kernel dill failed!. The error names the file that
uses the symbol, so it reads as an API renamed upstream — which sends the reader into CHANGELOGs looking for a rename that does not exist.
dart pub get reports success throughout, because the lock is satisfiable.
No compiler output can distinguish an absent cache directory from an absent symbol, so this is not a matter of attention: one session misread it twice, for
tom_build_base-2.7.1 and then tom_d4rt-1.29.0. When a symbol you can see in the dependency's source is reported undefined, check the cache before the CHANGELOG.
---
The two shapes, and why the difference matters
They look alike and they do not have the same remedy.
| Shape | What you see | Does dart pub get fix it? |
|---|---|---|
| A — the directory is gone |
pub re-downloads on the next resolve; until then every project whose config still names it fails downstream, and only
check_package_configs.py
sees it
|
Yes |
B — the directory is there but empty, or has no pubspec.yaml |
dart test aborts with a pub stack trace before running a single test |
No |
Shape B is the trap. Pub treats the directory's existence as "installed", so a resolve walks straight past it and reports success —
Changed 47 dependencies! — while the package stays broken. Measured: after emptying a cached package,
dart pub get reported success and the package was still empty.
That is why the shorthand "dart pub get fixes it" is only half true, and the half it misses is the half that wastes an afternoon.
Repairing shape B
Delete the damaged directory, then resolve:
rm -rf ~/.pub-cache/hosted/pub.dev/<package>-<version>
cd <the package that failed> && dart pub get
dart pub cache repair --all also works, by a route worth knowing: it does not mend a damaged package in place — it
deletes what it cannot reinstall (reporting Failed to reinstall), and the next
dart pub get then re-downloads it. It reinstalls every healthy package on the way, so it is much slower than deleting the one directory. Reach for it when several are damaged.
---
Sightings
Five over two days during the TomSpecs documentation campaign, all on mbp:
_fe_analyzer_shared-105.0.0lost its directory, twice (shape A).-
_fe_analyzer_shared-100.0.0had nopubspec.yaml, sodart testin
tom_specs_model aborted before running a test (shape B). - tom_d4rt_generator
could not see TomBuildConfig / yamlToMap in tom_build_base although both cached versions declare them.
The third is probably not cache damage at all — it is the signature of a stale .dart_tool/package_config.json
pointing at an older resolution, which is why dart pub get cleared it. Worth separating: a
resolution problem and a cache problem present differently and only one of them is this note's subject.
The whole cache was recreated on 2026-09-08 at 22:37, on mbp. Found on 2026-09-11 while investigating example projects that no longer resolved:
-
~/.pub-cacheitself,hosted/,hosted-hashes/, the oldest cached
packages and the oldest active_roots entries all carry that one timestamp — the signature of a cache rebuilt from empty, not of packages pruned one by one. -
active_roots held 122 entries, against 292 when the section below was written. - 81 of the workspace's 271 package configs named at least one package root that no longer existed;
lints-5.1.1 alone was missing from 30 of them. Nothing had re-downloaded those packages because nothing had resolved those projects since.
check_pub_cache.sh reported the cache healthy throughout, correctly: every directory still in it was intact. The damage was entirely in what had been removed, which is why
check_package_configs.py exists.
---
What has been ruled out, and how
Recorded so the next investigation starts where this one stopped rather than repeating it. Each was tested against an isolated
PUB_CACHE, never the real one.
| Candidate | Verdict | How |
|---|---|---|
Interrupted extraction
— a killed
pub get
leaving a half-written package
|
Ruled out |
Killed a
pub get
mid-extraction: 0 partial packages. Pub extracts into
_temp/dirXXXX
and renames into place, so a kill leaves a temp directory and nothing else.
|
Concurrent pub get racing |
Ruled out | Six concurrent resolves into one fresh cache: all exited 0, 0 damaged packages of 47. |
| Workspace tooling deleting cache entries | Ruled out |
Every workspace reference to
PUB_CACHE
/
~/.pub-cache
is read-only — resolving paths to read sources. Nothing writes or deletes.
|
| Two SDKs sharing one cache | Ruled out | Only one dart on this machine (Flutter's bundled SDK, 3.12.2). |
dart pub cache gc run by our tooling |
Ruled out | Nothing in the workspace or _ai invokes it. |
dart pub cache gc ("Prunes unused packages from the system cache") remains the one documented
mechanism that removes packages, and the cache tracks active_roots — 292 entries on this machine — to decide what is unused. It declines to prune when it finds no active projects, so it is safe by itself. It has not been excluded as a cause when
run by a human or another agent session, and this workspace does create and discard many short-lived project roots (tests that resolve in
systemTemp, sample projects, generated _v0 packages), which is exactly the population a root-based GC reasons about.
The 2026-09-08 recreation is a different mechanism from the losses above: the cache was emptied as a whole, which
dart pub cache clean does by design ("Clears the global PUB_CACHE") and which deleting ~/.pub-cache
does too. Neither command appears in this machine's shell history or in the agent trails under _ai/, so it did not come from our tooling or a recorded session.
The cause is not established. The remaining candidates are a gc or clean
run from outside our tooling, or a macOS-level actor (backup, cleanup, security software).
---
If you see it again
Capture this before repairing — it is what would settle the cause:
1. _bin/check_pub_cache.sh output (which packages, which shape). 2. ls -la ~/.pub-cache/_temp
— leftovers indicate interrupted extractions. 3. find ~/.pub-cache/active_roots -type f | wc -l
— a sharp drop suggests a GC. 4. ls -la on the damaged directory: an mtime tells you when, which is worth more than anything else here. 5.
ls -la ~/.pub-cache and the oldest entries of hosted/pub.dev — one shared timestamp across the top-level directories means the whole cache was recreated, and
_bin/check_package_configs.py --summary then shows the reach.
Then repair by shape, as above.