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.

ShapeWhat you seeDoes 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.0 lost its directory, twice (shape A).
  • _fe_analyzer_shared-100.0.0 had no pubspec.yaml, so dart test in

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-cache itself, 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.

CandidateVerdictHow
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.