fix(all): recompile when a source file is added or its output is missing - #4870
Draft
dbrattli wants to merge 1 commit into
Draft
fix(all): recompile when a source file is added or its output is missing#4870dbrattli wants to merge 1 commit into
dbrattli wants to merge 1 commit into
Conversation
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
marked this pull request as draft
August 4, 2026 05:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Adding a
<Compile Include>through an imported.propscould leave a build broken while still reporting success: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-lookingerror 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
.fsprojand its references. A.propscontributes<Compile Include>items without any.fsprojbeing touched, so the cached — and now stale — source list was reused.Adding the file directly to the
.fsprojalways 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>, plusDirectory.Build.props/.targetsandDirectory.Packages.propsfound 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
getFilesToCompileselects a source whose output does not exist — "if files have been deleted, we should likely recompile" (Main.fs:945) — and thenareCompiledFilesUpToDatereported that same file as up-to-date (Main.fs:981) and skipped the compilation it had just asked for.No
.propsneeded to hit this one: delete any generated file and rebuild →Skipped 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 Emptyemits noEmpty.json 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.fsfor 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:
.fsproj, add via imported.props, a.propsshared by two projects built in turn, remove from the compile list, deleted output regeneratedSuites: Python 2458 passed, Integration 42 + 116 passed, fantomas clean.
Not addressed
Found alongside, and out of scope here:
a.pywritten 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 atFSharpCompilationFinished, so gating writes on "no errors" means buffering output or restructuring that pipeline.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