clean: "if-file-deleted" can permanently cache incomplete output when the build tool is incremental
- Dominant language
- TypeScript
- Stars
- 6.4k
- Forks
- 128
- Avg merge
- 4d 10h
- Merged PRs (30d)
- 4
Description
_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
```jsonc
// package.json
{
"scripts": {"build": "wireit"},
"wireit": {
"build": {
"command": "tsc",
"files": ["src/**/*.ts", "tsconfig.json"],
"output": ["lib", "tsconfig.tsbuildinfo"],
"clean": "if-file-deleted"
}
}
}
```
with `composite: true` (or `incremental: true`), `outDir: lib`, and at least two source files, `a.ts` and `b.ts`.
```bash
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:
```js
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.
Contributor guide
Research direction
Start with #shouldClean() and trace its 'if-file-deleted' path alongside #outputManifestIsFresh(), #readPreviousFingerprint(), and #handleCacheHit(). Use the package.json and tsc reproduction described in the issue, then verify that removing an output causes a rebuild and that normal incremental builds still preserve valid outputs.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- build-system, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100