Note: This bug was diagnosed, and the issue written, by Claude Opus 5
Wireit version: 0.14.12 · Node: 25.6.1 · OS: macOS 15.6
Summary
If an output file goes missing while tsconfig.tsbuildinfo (or any other incremental-tool state) survives, tsc will not re-emit it, exits 0, and Wireit caches the incomplete output as a good result. Every downstream script then fails with ERR_MODULE_NOT_FOUND on a module whose source is sitting right there, and it stays broken across runs until some unrelated input changes.
Wireit already detects this situation on the fresh path — #outputManifestIsFresh() compares the on-disk outputs against the previous run's manifest — but that check is nested inside if (await this.#fingerprintIsFresh(fingerprint)), so it is skipped exactly when the script is about to run. #shouldClean() for 'if-file-deleted' then consults only #anyInputFilesDeletedSinceLastRun(). Nothing looks at the outputs, so the output directory is left as-is for a tool that has no idea a file is gone.
Reproduction
with composite: true (or incremental: true), outDir: lib, and at least two source files, a.ts and b.ts.
npm run build # lib/a.js and lib/b.js exist
rm lib/a.js # an interrupted build, a stray rm, a bad merge
echo '// touch' >> src/b.ts # any input edit, in an unrelated file
npm run build
# ✅ [build] Executed successfully
ls lib/a.js
# ls: lib/a.js: No such file or directory
tsc saw a.ts unchanged in tsconfig.tsbuildinfo and emitted nothing for it. Wireit then wrote a fingerprint and cached lib/ without a.js.
From here the cache entry is poisoned: npm run build reports "Already fresh" or restores the same incomplete output from cache. Deleting lib/ does not help, because the fingerprint is computed from inputs. The only recovery is deleting the .wireit directory (and tsconfig.tsbuildinfo) by hand.
In a monorepo this surfaces as unrelated packages failing to import a dependency, which sends you looking for a missing dependencies: entry in the Wireit config — the config is fine.
Why the current behaviour is surprising
#handleCacheHit already makes exactly the right argument for the other direction:
If we are restoring from cache, we should always delete existing output. The
purpose of clean:false and clean:if-file-deleted is to allow tools with
incremental build (like tsc --build) to work. However, this only applies when
the tool is able to observe each incremental change to the input files.
A missing output file is likewise a change the tool cannot observe. tsc trusts tsbuildinfo, not the filesystem, so it cannot notice that its own output was removed.
Proposal
In the needs-run path, when clean is "if-file-deleted", also compare the current outputs against the previous manifest, and clean if they differ:
case 'if-file-deleted': {
const prevFingerprint = await this.#readPreviousFingerprint();
if (prevFingerprint === undefined) return true;
if (this.#anyInputFilesDeletedSinceLastRun(fingerprint, prevFingerprint)) return true;
// New: the tool cannot observe changes to its own output.
return !(await this.#outputManifestIsFresh()).value;
}
Both pieces already exist; this just wires the existing manifest check into the clean decision. Incremental builds keep working in the normal case — the manifest matches, nothing is cleaned. The full rebuild happens only when the output really was tampered with, which is the case where incrementality is unsound anyway.
Cost is one extra glob + stat of the output files on the run path, which the fresh path already pays.
If a behaviour change is unwelcome, an opt-in (clean: "if-file-deleted-or-output-modified") would work too, though it seems hard to argue that anyone wants the current behaviour.
Possibly related: #70 (which introduced if-file-deleted), #245.
Note: This bug was diagnosed, and the issue written, by Claude Opus 5
Wireit version: 0.14.12 · Node: 25.6.1 · OS: macOS 15.6
Summary
If an output file goes missing while
tsconfig.tsbuildinfo(or any other incremental-tool state) survives,tscwill not re-emit it, exits 0, and Wireit caches the incomplete output as a good result. Every downstream script then fails withERR_MODULE_NOT_FOUNDon a module whose source is sitting right there, and it stays broken across runs until some unrelated input changes.Wireit already detects this situation on the fresh path —
#outputManifestIsFresh()compares the on-disk outputs against the previous run'smanifest— but that check is nested insideif (await this.#fingerprintIsFresh(fingerprint)), so it is skipped exactly when the script is about to run.#shouldClean()for'if-file-deleted'then consults only#anyInputFilesDeletedSinceLastRun(). Nothing looks at the outputs, so the output directory is left as-is for a tool that has no idea a file is gone.Reproduction
with
composite: true(orincremental: true),outDir: lib, and at least two source files,a.tsandb.ts.tscsawa.tsunchanged intsconfig.tsbuildinfoand emitted nothing for it. Wireit then wrote a fingerprint and cachedlib/withouta.js.From here the cache entry is poisoned:
npm run buildreports "Already fresh" or restores the same incomplete output from cache. Deletinglib/does not help, because the fingerprint is computed from inputs. The only recovery is deleting the.wireitdirectory (andtsconfig.tsbuildinfo) by hand.In a monorepo this surfaces as unrelated packages failing to import a dependency, which sends you looking for a missing
dependencies:entry in the Wireit config — the config is fine.Why the current behaviour is surprising
#handleCacheHitalready makes exactly the right argument for the other direction:A missing output file is likewise a change the tool cannot observe.
tsctruststsbuildinfo, not the filesystem, so it cannot notice that its own output was removed.Proposal
In the needs-run path, when
cleanis"if-file-deleted", also compare the current outputs against the previousmanifest, and clean if they differ:Both pieces already exist; this just wires the existing manifest check into the clean decision. Incremental builds keep working in the normal case — the manifest matches, nothing is cleaned. The full rebuild happens only when the output really was tampered with, which is the case where incrementality is unsound anyway.
Cost is one extra glob +
statof the output files on the run path, which the fresh path already pays.If a behaviour change is unwelcome, an opt-in (
clean: "if-file-deleted-or-output-modified") would work too, though it seems hard to argue that anyone wants the current behaviour.Possibly related: #70 (which introduced
if-file-deleted), #245.