Skip to content

clean: "if-file-deleted" can permanently cache incomplete output when the build tool is incremental #1471

Description

@justinfagnani

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

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions