Skip to content

Commit c9d0b63

Browse files
committed
fix: preserve Built stage and detect cross-flush file overwrites
TypecheckOnly passes (typecheck_dependents, full_typecheck) no longer downgrade Built modules to TypeChecked when cmi/cmt hashes are unchanged — the .cmj and JS on disk remain valid. Cross-flush delete+create sequences (LLM file overwrites, git checkout) are now detected via a recently_deleted set on PendingState. When a file is deleted in one flush and created in the next, it is routed through file_build (FullCompile) to emit JS, instead of triggering two TypecheckOnly project rebuilds.
1 parent 6b14efe commit c9d0b63

4 files changed

Lines changed: 278 additions & 23 deletions

File tree

rewatch/src/build/compile.rs

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,18 @@ pub fn process_in_waves(
587587
"cmt",
588588
));
589589

590+
// Extract Built hashes before taking the mutable borrow below.
591+
// Used to preserve Built state when TypecheckOnly produces identical artifacts.
592+
let prev_built_hashes = match prev_stage {
593+
CompilationStage::Built {
594+
cmi_hash,
595+
cmt_hash,
596+
cmj_hash,
597+
..
598+
} => Some((*cmi_hash, *cmt_hash, *cmj_hash)),
599+
_ => None,
600+
};
601+
590602
if let Some(Module::SourceFile(sf)) = build_state.build_state.modules.get_mut(name) {
591603
let compile_warnings = module_compile_warnings.remove(name);
592604
match (mode.emits_js(), cmi_hash, cmt_hash) {
@@ -639,18 +651,26 @@ pub fn process_in_waves(
639651
}
640652
}
641653
(false, Some(cmi), Some(cmt)) => {
642-
sf.set_compilation_stage(CompilationStage::TypeChecked {
643-
implementation_source_hash,
644-
implementation_ast_hash,
645-
interface_source_hash,
646-
interface_ast_hash,
647-
cmi_hash: cmi,
648-
cmt_hash: cmt,
649-
compiled_at: now,
650-
implementation_parse_warnings,
651-
interface_parse_warnings,
652-
compile_warnings,
653-
});
654+
// If the module was already Built and the type artifacts
655+
// didn't change, skip — the .cmj and JS on disk are still
656+
// valid. This prevents TypecheckOnly passes from losing
657+
// the JS-produced marker.
658+
if !prev_built_hashes
659+
.is_some_and(|(old_cmi, old_cmt, _)| old_cmi == cmi && old_cmt == cmt)
660+
{
661+
sf.set_compilation_stage(CompilationStage::TypeChecked {
662+
implementation_source_hash,
663+
implementation_ast_hash,
664+
interface_source_hash,
665+
interface_ast_hash,
666+
cmi_hash: cmi,
667+
cmt_hash: cmt,
668+
compiled_at: now,
669+
implementation_parse_warnings,
670+
interface_parse_warnings,
671+
compile_warnings,
672+
});
673+
}
654674
}
655675
_ => {
656676
sf.set_compilation_stage(CompilationStage::SourceDirty);

rewatch/src/lsp/queue.rs

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ struct PendingState {
130130
/// File changes that require full project rebuilds.
131131
/// Flush resolves project roots and groups these into per-project sets.
132132
build_projects: PendingProjectBuilds,
133+
/// Files deleted in a recent flush that haven't been matched by a create.
134+
/// Used to detect cross-flush delete+create (file overwrite) patterns
135+
/// from LLMs and git operations.
136+
recently_deleted: HashSet<PathBuf>,
133137
}
134138

135139
/// Debug summary of what a flush is about to process.
@@ -357,6 +361,7 @@ impl PendingState {
357361
typechecks: HashMap::new(),
358362
compile_files: HashMap::new(),
359363
build_projects: PendingProjectBuilds::new(),
364+
recently_deleted: HashSet::new(),
360365
}
361366
}
362367

@@ -424,27 +429,37 @@ impl PendingState {
424429
if let Ok(uri) = Url::from_file_path(&file_path) {
425430
self.typechecks.remove(&uri);
426431
self.compile_files.remove(&uri);
427-
// If this file was previously deleted in the same batch,
428-
// it was recreated (e.g. git checkout). Schedule an
429-
// incremental build to emit JS after the full rebuild.
430-
if self.build_projects.deleted_files.contains(&file_path) {
432+
}
433+
// Check both same-flush (build_projects.deleted_files) and
434+
// cross-flush (recently_deleted) for the delete+create pattern.
435+
let same_flush_delete = self.build_projects.deleted_files.remove(&file_path);
436+
let cross_flush_delete = self.recently_deleted.remove(&file_path);
437+
if same_flush_delete || cross_flush_delete {
438+
// Delete + create = content overwrite (LLM, git checkout).
439+
// Still need project_build to re-add the module to state,
440+
// plus file_build to emit JS with FullCompile.
441+
self.build_projects.created_files.insert(file_path.clone());
442+
if let Ok(uri) = Url::from_file_path(&file_path) {
431443
self.compile_files.insert(
432444
uri,
433445
PendingFileBuild {
434-
file_path: file_path.clone(),
446+
file_path,
435447
buffer_content: None,
436448
generation: 0,
437449
},
438450
);
439451
}
452+
} else {
453+
// Genuinely new file — needs full project rebuild.
454+
self.build_projects.created_files.insert(file_path);
440455
}
441-
self.build_projects.created_files.insert(file_path);
442456
}
443457
QueueEvent::FileDeleted { file_path } => {
444458
if let Ok(uri) = Url::from_file_path(&file_path) {
445459
self.typechecks.remove(&uri);
446460
self.compile_files.remove(&uri);
447461
}
462+
self.recently_deleted.insert(file_path.clone());
448463
self.build_projects.deleted_files.insert(file_path);
449464
}
450465
QueueEvent::ConfigChanged { file_path } => {
@@ -560,6 +575,14 @@ async fn flush_inner(
560575
let _flush_guard = diagnostic_store.as_ref().map(|s| FlushGuard::new(s));
561576
let store_ref = diagnostic_store.as_deref();
562577

578+
// Rotate recently_deleted: clear old entries from previous flushes,
579+
// carry forward this flush's deletes for cross-flush detection.
580+
// Must happen before project_build::run takes build_projects.
581+
state.recently_deleted.clear();
582+
state
583+
.recently_deleted
584+
.extend(state.build_projects.deleted_files.iter().cloned());
585+
563586
// Step 0: Run full builds (file creation / deletion).
564587
let has_full_builds = !state.build_projects.is_empty();
565588
if has_full_builds {
@@ -933,10 +956,10 @@ mod tests {
933956
assert!(state.build_projects.created_files.contains(&test_path("New.res")));
934957
}
935958

936-
// -- Recreated files (delete + create) → promoted to compile_files --
959+
// -- Recreated files (delete + create) → treated as save --
937960

938961
#[test]
939-
fn delete_then_create_promotes_to_compile_files() {
962+
fn delete_then_create_treated_as_overwrite() {
940963
let mut state = PendingState::new();
941964

942965
// File deleted (e.g. git checkout starts)
@@ -949,9 +972,10 @@ mod tests {
949972
file_path: test_path("Pkmn.res"),
950973
});
951974

952-
// Should be in both build_projects sets
975+
// Delete+create = overwrite. Removed from deleted_files,
976+
// but still in created_files (project_build re-adds the module).
953977
assert!(
954-
state
978+
!state
955979
.build_projects
956980
.deleted_files
957981
.contains(&test_path("Pkmn.res"))
@@ -1102,4 +1126,67 @@ mod tests {
11021126
assert!(state.typechecks.contains_key(&uri_b));
11031127
assert!(!state.typechecks.contains_key(&uri_a));
11041128
}
1129+
1130+
// -- Cross-flush delete+create detection --
1131+
1132+
/// Simulate the recently_deleted rotation that flush_inner performs.
1133+
fn simulate_flush_rotation(state: &mut PendingState) {
1134+
state.recently_deleted.clear();
1135+
state
1136+
.recently_deleted
1137+
.extend(state.build_projects.deleted_files.iter().cloned());
1138+
// Simulate project_build taking build_projects
1139+
state.build_projects = PendingProjectBuilds::new();
1140+
// Simulate file_build/file_typecheck taking their maps
1141+
state.compile_files.clear();
1142+
state.typechecks.clear();
1143+
}
1144+
1145+
#[test]
1146+
fn cross_flush_delete_then_create_treated_as_overwrite() {
1147+
let mut state = PendingState::new();
1148+
1149+
// Flush N: file is deleted
1150+
state.merge(QueueEvent::FileDeleted {
1151+
file_path: test_path("Pkmn.res"),
1152+
});
1153+
1154+
// Simulate flush N completing
1155+
simulate_flush_rotation(&mut state);
1156+
1157+
// Flush N+1: same file is created (cross-flush overwrite)
1158+
state.merge(QueueEvent::FileCreated {
1159+
file_path: test_path("Pkmn.res"),
1160+
});
1161+
1162+
// Should be in created_files (project_build re-adds module to state)
1163+
// AND in compile_files (file_build emits JS)
1164+
assert!(
1165+
state
1166+
.build_projects
1167+
.created_files
1168+
.contains(&test_path("Pkmn.res"))
1169+
);
1170+
assert!(state.compile_files.contains_key(&test_uri("Pkmn.res")));
1171+
}
1172+
1173+
#[test]
1174+
fn recently_deleted_cleared_after_rotation_without_create() {
1175+
let mut state = PendingState::new();
1176+
1177+
// Flush N: file is deleted
1178+
state.merge(QueueEvent::FileDeleted {
1179+
file_path: test_path("Gone.res"),
1180+
});
1181+
1182+
// Simulate flush N completing — recently_deleted carries {Gone.res}
1183+
simulate_flush_rotation(&mut state);
1184+
assert!(state.recently_deleted.contains(&test_path("Gone.res")));
1185+
1186+
// Flush N+1: no create arrives, flush completes
1187+
simulate_flush_rotation(&mut state);
1188+
1189+
// recently_deleted should now be empty (no new deletes to carry forward)
1190+
assert!(state.recently_deleted.is_empty());
1191+
}
11051192
}

tests/rewatch_tests/tests/lsp/__snapshots__/built-downgrade.test.mjs.snap

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,94 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`lsp compile mode preservation > external file overwrite via delete+create still produces JS 1`] = `
4+
[
5+
"rewatch.lsp",
6+
" lsp.initialized",
7+
" lsp.register_watchers[watcher_count=3]",
8+
" lsp.discover_package[name=@rewatch-test/dep-chain]",
9+
" lsp.source_dir[dir=packages/dep-chain/src, recursive=true]",
10+
" lsp.initial_build[project=@rewatch-test/dep-chain]",
11+
" build.load_package_sources[package=@rewatch-test/dep-chain]",
12+
" packages.parse_packages",
13+
" clean.cleanup_previous_build",
14+
" build.parse[dirty_modules=4]",
15+
" build.parse_file[module=App, package=@rewatch-test/dep-chain]",
16+
" build.parse_file[module=Button, package=@rewatch-test/dep-chain]",
17+
" build.parse_file[module=Leaf, package=@rewatch-test/dep-chain]",
18+
" build.parse_file[module=Main, package=@rewatch-test/dep-chain]",
19+
" full_typecheck[module_count=4, output=lsp]",
20+
" build.typecheck",
21+
" build.typecheck_wave[file_count=2]",
22+
" build.typecheck_file[module=Button, package=@rewatch-test/dep-chain]",
23+
" build.typecheck_file[module=Leaf, package=@rewatch-test/dep-chain]",
24+
" build.typecheck_wave[file_count=1]",
25+
" build.typecheck_file[module=App, package=@rewatch-test/dep-chain]",
26+
" build.typecheck_wave[file_count=1]",
27+
" build.typecheck_file[module=Main, package=@rewatch-test/dep-chain]",
28+
" lsp.did_save[file=packages/dep-chain/src/Leaf.res]",
29+
" lsp.flush[incremental_builds=packages/dep-chain/src/Leaf.res]",
30+
" lsp.flush.file_build.batch[modules=["Leaf"]]",
31+
" lsp.flush.file_build.compile_dependencies",
32+
" build.parse[dirty_modules=1]",
33+
" build.parse_file[module=Leaf, package=@rewatch-test/dep-chain]",
34+
" compile_dependencies[module_count=4, output=lsp]",
35+
" build.compile",
36+
" build.compile_wave[file_count=1]",
37+
" build.compile_file[module=Leaf, package=@rewatch-test/dep-chain, suffix=.mjs, module_system=esmodule]",
38+
" lsp.flush.file_build.typecheck_dependents[dependent_count=2]",
39+
" typecheck_dependents[module_count=4, output=lsp]",
40+
" build.typecheck",
41+
" build.typecheck_wave[file_count=1]",
42+
" build.typecheck_file[module=App, package=@rewatch-test/dep-chain]",
43+
" build.typecheck_wave[file_count=1]",
44+
" build.typecheck_file[module=Main, package=@rewatch-test/dep-chain]",
45+
" lsp.flush.file_build.compile_resolved",
46+
" lsp.did_change_watched_files[file_count=1]",
47+
" lsp.flush[project_builds=deleted: packages/dep-chain/src/Leaf.res]",
48+
" lsp.flush.project_build[project=@rewatch-test/dep-chain]",
49+
" build.load_package_sources[package=@rewatch-test/dep-chain]",
50+
" packages.parse_packages",
51+
" clean.cleanup_previous_build",
52+
" build.parse[dirty_modules=0]",
53+
" full_typecheck[module_count=3, output=lsp]",
54+
" build.typecheck",
55+
" build.typecheck_wave[file_count=1]",
56+
" build.typecheck_file[module=App, package=@rewatch-test/dep-chain]",
57+
" build.compile_error",
58+
" lsp.did_change_watched_files[file_count=1]",
59+
" lsp.flush[project_builds=created: packages/dep-chain/src/Leaf.res, incremental_builds=packages/dep-chain/src/Leaf.res]",
60+
" lsp.flush.project_build[project=@rewatch-test/dep-chain]",
61+
" build.load_package_sources[package=@rewatch-test/dep-chain]",
62+
" packages.parse_packages",
63+
" clean.cleanup_previous_build",
64+
" build.parse[dirty_modules=1]",
65+
" build.parse_file[module=Leaf, package=@rewatch-test/dep-chain]",
66+
" full_typecheck[module_count=4, output=lsp]",
67+
" build.typecheck",
68+
" build.typecheck_wave[file_count=1]",
69+
" build.typecheck_file[module=Leaf, package=@rewatch-test/dep-chain]",
70+
" build.typecheck_wave[file_count=1]",
71+
" build.typecheck_file[module=App, package=@rewatch-test/dep-chain]",
72+
" build.typecheck_wave[file_count=1]",
73+
" lsp.flush.file_build.batch[modules=["Leaf"]]",
74+
" lsp.flush.file_build.compile_dependencies",
75+
" build.parse[dirty_modules=1]",
76+
" build.parse_file[module=Leaf, package=@rewatch-test/dep-chain]",
77+
" compile_dependencies[module_count=4, output=lsp]",
78+
" build.compile",
79+
" build.compile_wave[file_count=1]",
80+
" build.compile_file[module=Leaf, package=@rewatch-test/dep-chain, suffix=.mjs, module_system=esmodule]",
81+
" lsp.flush.file_build.typecheck_dependents[dependent_count=2]",
82+
" typecheck_dependents[module_count=4, output=lsp]",
83+
" build.typecheck",
84+
" build.typecheck_wave[file_count=1]",
85+
" build.typecheck_file[module=App, package=@rewatch-test/dep-chain]",
86+
" build.typecheck_wave[file_count=1]",
87+
" build.typecheck_file[module=Main, package=@rewatch-test/dep-chain]",
88+
" lsp.flush.file_build.compile_resolved",
89+
]
90+
`;
91+
392
exports[`lsp compile mode preservation > preserves FullCompile intent across intervening typecheck passes 1`] = `
493
[
594
"rewatch.lsp",

tests/rewatch_tests/tests/lsp/built-downgrade.test.mjs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync } from "node:fs";
1+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
import path from "node:path";
33
import { pathToFileURL } from "node:url";
44
import { describe, expect, it } from "vitest";
@@ -96,4 +96,63 @@ describe("lsp compile mode preservation", { timeout: 60_000 }, () => {
9696
},
9797
{ cwd: "packages/dep-chain" },
9898
));
99+
100+
// Regression test: cross-flush delete+create (LLM file overwrite) must
101+
// produce JS output, not trigger two TypecheckOnly project rebuilds.
102+
//
103+
// Scenario:
104+
// 1. Save Leaf.res to produce Leaf.mjs (initial build is TypecheckOnly).
105+
// 2. Delete Leaf.res and notify as Deleted — triggers project_build flush.
106+
// 3. Wait for the delete to land in a separate flush (200ms > debounce).
107+
// 4. Write new content and notify as Created — should be treated as save.
108+
// 5. Verify Leaf.mjs exists with the updated content.
109+
it("external file overwrite via delete+create still produces JS", () =>
110+
runLspTest(
111+
async ({ lsp, lspCwd, writeFile, deleteFile }) => {
112+
const rootUri = pathToFileURL(lspCwd).href;
113+
await lsp.initialize(rootUri);
114+
await lsp.waitForNotification("rescript/buildFinished", 30000);
115+
116+
// Save Leaf.res to produce Leaf.mjs (initial build is TypecheckOnly, no JS yet)
117+
await writeFile("src/Leaf.res", 'let value = "hello"\n');
118+
await lsp.waitForNotification("rescript/buildFinished", 30000);
119+
120+
const leafMjs = path.join(lspCwd, "src", "Leaf.mjs");
121+
expect(existsSync(leafMjs), "Leaf.mjs should exist after save").toBe(
122+
true,
123+
);
124+
125+
// Simulate LLM-style file overwrite: delete then create in separate flushes.
126+
await deleteFile("src/Leaf.res");
127+
lsp.notifyWatchedFilesChanged([
128+
{ relativePath: "src/Leaf.res", type: 3 }, // Deleted
129+
]);
130+
131+
// Wait for the delete flush to complete
132+
await lsp.waitForNotification("rescript/buildFinished", 30000);
133+
134+
// Write new content and notify as created
135+
const fullPath = path.join(lspCwd, "src", "Leaf.res");
136+
writeFileSync(fullPath, 'let value = "updated"\n');
137+
lsp.notifyWatchedFilesChanged([
138+
{ relativePath: "src/Leaf.res", type: 1 }, // Created
139+
]);
140+
141+
// Wait for the create flush to complete
142+
await lsp.waitForNotification("rescript/buildFinished", 30000);
143+
144+
// Leaf.mjs should exist with updated content.
145+
// Without the fix, the delete triggers project_build (TypecheckOnly),
146+
// and the create triggers another project_build — neither emits JS.
147+
// With the fix, the cross-flush delete+create is detected and treated
148+
// as a save, which goes through file_build (FullCompile) → JS emitted.
149+
expect(
150+
existsSync(leafMjs),
151+
"Leaf.mjs must exist after delete+create overwrite",
152+
).toBe(true);
153+
const content = readFileSync(leafMjs, "utf8");
154+
expect(content).toContain("updated");
155+
},
156+
{ cwd: "packages/dep-chain" },
157+
));
99158
});

0 commit comments

Comments
 (0)