Skip to content

fix(all): recompile when a source file is added or its output is missing - #4870

Draft
dbrattli wants to merge 1 commit into
mainfrom
fix/cli-cache-invalidation-on-added-file
Draft

fix(all): recompile when a source file is added or its output is missing#4870
dbrattli wants to merge 1 commit into
mainfrom
fix/cli-cache-invalidation-on-added-file

Conversation

@dbrattli

@dbrattli dbrattli commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

Adding a <Compile Include> through an imported .props could leave a build broken while still reporting success:

Retrieving project options from cache, in case of issues run `dotnet fable clean` …
Project and references (2 source files) parsed in 123ms
Skipped compilation because all generated files are up-to-date!

Exit code 0 — but the new module was never generated, and the code referencing it was left calling into nothing. On Python that surfaced at run time as TypeError: exceptions must derive from BaseException, nowhere near the cause; on Beam as an unrelated-looking error FSHARP: … 'Scalars' is not defined.

This is a bad failure for CI: a pipeline that adds a file and reuses a warm cache can green-light a build whose new code was never compiled.

Two independent defects, both reproduced from source and both fixed here.

1. The project options cache ignored imported .props/.targets

Cache validity was decided by comparing the timestamps of the .fsproj and its references. A .props contributes <Compile Include> items without any .fsproj being touched, so the cached — and now stale — source list was reused.

Adding the file directly to the .fsproj always worked; only the imported case was affected, which confirms the narrower trigger.

Fixed by collecting the files that take part in a project's MSBuild evaluation — transitive <Import Project>, plus Directory.Build.props/.targets and Directory.Packages.props found by walking upwards — and invalidating on those too. Only $(MSBuildThisFileDirectory) and $(MSBuildProjectDirectory) are expanded; an import path built from any other property is skipped, since missing an import costs a stale cache while guessing wrong costs a bogus path.

2. A missing output file counted as up-to-date

getFilesToCompile selects a source whose output does not exist — "if files have been deleted, we should likely recompile" (Main.fs:945) — and then areCompiledFilesUpToDate reported that same file as up-to-date (Main.fs:981) and skipped the compilation it had just asked for.

No .props needed to hit this one: delete any generated file and rebuildSkipped compilation because all generated files are up-to-date!, exit 0, file still missing.

Trade-off

A file whose generated code is empty is never written to disk, so it is indistinguishable from one that was never compiled. Projects containing such a file lose the skip-compilation shortcut.

This is real, not hypothetical: module Empty emits no Empty.js on JS/Dart/Beam (Python always writes), and such a project did take the skip path before. Measured cost: ~1.2s recompile instead of a skip. Normal projects still skip — verified, 0.23–0.28s, so the added import walk costs nothing measurable.

Taken deliberately: losing a shortcut is recoverable, silently shipping a half-generated build is not.

Verification

Integration tests in CacheInvalidationTests.fs for both cases, confirmed to fail without this change and pass with it (checked in both directions). The deleted-output fixture deliberately contains two source files — with only one, the existing "no compiled files found" guard rescues the build and masks the bug.

Checked by hand on Python, JavaScript and Beam, which now behave identically:

  • add via .fsproj, add via imported .props, a .props shared by two projects built in turn, remove from the compile list, deleted output regenerated

Suites: Python 2458 passed, Integration 42 + 116 passed, fantomas clean.

Not addressed

Found alongside, and out of scope here:

  • Output files are written even when the compile fails. Reproduced standalone (exit 1, a.py written anyway). This is what poisons the output tree so the next run takes the skip path. Fable compiles each file as it type-checks (streaming, for parallelism) and project-wide diagnostics only arrive at FSharpCompilationFinished, so gating writes on "no errors" means buffering output or restructuring that pipeline.
  • The Python backend emits raise 1 — a non-exception — from F#'s error-recovery AST. A symptom of the above: it is generating code from an AST that failed type-checking.

With the two cache defects fixed, both only surface on a compile that already exits non-zero and prints the error — noisy rather than silent.

🤖 Generated with Claude Code

Adding a `<Compile Include>` through an imported .props could leave a
build broken while still reporting success.

Two caches were involved and neither noticed the new file:

  Retrieving project options from cache, in case of issues run `dotnet fable clean` …
  Project and references (2 source files) parsed in 123ms
  Skipped compilation because all generated files are up-to-date!

Exit code 0, but the new module was never generated and the code that
referenced it was left calling into nothing.

The project options cache was validated by comparing the timestamps of
the .fsproj and its references. A .props contributes `<Compile Include>`
items without any .fsproj being touched, so the cached — and now stale —
source list was reused. Adding the file directly to the .fsproj always
worked; only the imported case was affected. Collect the files that take
part in a project's MSBuild evaluation (transitive `<Import Project>`,
plus Directory.Build.props/targets and Directory.Packages.props found
upwards) and invalidate on those too.

Separately, `areCompiledFilesUpToDate` reported a source whose output
file does not exist as up-to-date. `getFilesToCompile` selects exactly
those files for compilation — "if files have been deleted, we should
likely recompile" — so the two disagreed, and the skip won. Deleting a
generated file and rebuilding therefore did nothing at all, no .props
required. Treat a missing output as out-of-date.

That costs the skip-compilation shortcut to projects containing a file
whose generated code is empty, since those are never written to disk and
are now indistinguishable from a file that was never compiled. Losing a
shortcut is recoverable; silently shipping a half-generated build is not.

Both cases are covered by integration tests that fail without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// property cannot be resolved without evaluating MSBuild, so it is skipped:
// missing an import costs a stale cache, guessing wrong costs a bogus path.
let expandMacros (fileDir: string) (path: string) =
let withSep = fileDir + string IO.Path.DirectorySeparatorChar
@dbrattli
dbrattli marked this pull request as draft August 4, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants