From 645f5bd16e65a0137b756d526725d3ea01cba114 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 10:38:34 +0200 Subject: [PATCH 01/10] Fix GenType path resolution on Windows Signed-off-by: Florian Hammerschmidt --- compiler/gentype/module_resolver.ml | 9 +++---- rewatch/src/build/parse.rs | 9 ++++++- rewatch/src/config.rs | 38 ++++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/compiler/gentype/module_resolver.ml b/compiler/gentype/module_resolver.ml index 698cb75d72d..7e42e5034b8 100644 --- a/compiler/gentype/module_resolver.ml +++ b/compiler/gentype/module_resolver.ml @@ -133,11 +133,10 @@ let resolve_module ~(config : Config.t) ~import_extension ~output_file_relative if Sys.file_exists module_name_res_file then candidate else let rec path_to_list path = - let is_root = path |> Filename.basename = path in - match is_root with - | true -> [path] - | false -> - (path |> Filename.basename) :: (path |> Filename.dirname |> path_to_list) + let parent = Filename.dirname path in + (* On Windows, the basename of a drive root omits the drive prefix. *) + if Filename.basename path = path || parent = path then [path] + else (path |> Filename.basename) :: path_to_list parent in match module_name |> apply ~resolver ~use_bs_dependencies with | None -> candidate diff --git a/rewatch/src/build/parse.rs b/rewatch/src/build/parse.rs index 87944965ae3..9d4e71533af 100644 --- a/rewatch/src/build/parse.rs +++ b/rewatch/src/build/parse.rs @@ -436,11 +436,18 @@ fn generate_ast( // generate the dir of the ast_path (it mirrors the source file dir) let ast_parent_path = package.get_build_path().join(ast_path.parent().unwrap()); helpers::create_path(&ast_parent_path); + // Keep absolute source locations in the AST in the same path form as the + // canonical project root passed during compilation. This matters for + // Windows paths that have both short and expanded representations. + let parse_working_dir = build_path_abs + .canonicalize() + .map(helpers::StrippedVerbatimPath::to_stripped_verbatim_path) + .unwrap_or_else(|_| build_path_abs.clone()); /* Create .ast */ let result = match Some( Command::new(&build_state.compiler_info.bsc_path) - .current_dir(&build_path_abs) + .current_dir(&parse_working_dir) .args(parser_args) .output() .map_err(|e| { diff --git a/rewatch/src/config.rs b/rewatch/src/config.rs index dfdf67dcb05..b01935e14c6 100644 --- a/rewatch/src/config.rs +++ b/rewatch/src/config.rs @@ -1029,10 +1029,15 @@ impl Config { } pub fn get_project_root_args(&self) -> Vec { - vec![ - "-bs-project-root".to_string(), - self.get_package_root().to_string_lossy().to_string(), - ] + // The parser records locations from a canonical working directory. + // Use the same representation here so GenType can strip this prefix, + // including when Windows supplied the project through an 8.3 path. + let root = self + .get_package_root() + .canonicalize() + .map(helpers::StrippedVerbatimPath::to_stripped_verbatim_path) + .unwrap_or_else(|_| self.get_package_root().to_path_buf()); + vec!["-bs-project-root".to_string(), root.to_string_lossy().to_string()] } pub fn get_warning_args(&self, is_local_dep: bool, warn_error_override: Option) -> Vec { @@ -1599,6 +1604,31 @@ pub mod tests { assert!(args.contains(&"@teamwalnut/app".to_string())); } + #[test] + fn test_compiler_project_root_is_canonicalized() { + let temp_dir = tempfile::tempdir().unwrap(); + let nested_dir = temp_dir.path().join("nested"); + fs::create_dir(&nested_dir).unwrap(); + let config_path = nested_dir.join("..").join("rescript.json"); + fs::write( + temp_dir.path().join("rescript.json"), + r#"{"name":"test","gentypeconfig":{}}"#, + ) + .unwrap(); + + let config = Config::new(&config_path).unwrap(); + let expected_root = temp_dir + .path() + .canonicalize() + .map(helpers::StrippedVerbatimPath::to_stripped_verbatim_path) + .unwrap() + .to_string_lossy() + .to_string(); + + let project_root_args = config.get_project_root_args(); + assert_eq!(project_root_args, ["-bs-project-root", &expected_root]); + } + #[test] fn test_gentype_shims_object_and_array_forms() { let object_form = serde_json::from_str::(r#"{"From": "To"}"#).unwrap(); From a803304bd920cdab025f992733e3f9fae63cb980 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 11:25:10 +0200 Subject: [PATCH 02/10] Add changelog entry Signed-off-by: Florian Hammerschmidt --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfb72da74f..3c5fddcca9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Fix `reanalyze` reporting labels and variant cases of a re-exported type (`type y = x = {...}`) as dead in the editor. The re-export linking existed only in the batch pipeline, while the editor runs the reactive one; both are now the same pipeline. The `reanalyze -reactive` flag is gone with it, since analysis is always reactive. https://github.com/rescript-lang/rescript/issues/8647 - Fix `reanalyze` reporting record labels reached through a record coercion as dead. The typed tree now keeps the source type of a coercion, so reading a label on the target counts as reading the source label of the same name. https://github.com/rescript-lang/rescript/issues/8643 +- Fix GenType path resolution hanging on Windows when project paths use different short and canonical forms. https://github.com/rescript-lang/rescript/pull/8639 - Fix constant folding of pattern matches on unboxed variants whose payload overlaps a literal constructor, so inlined calls agree with runtime matching. Reject multi-argument unboxed constructors instead of crashing. https://github.com/rescript-lang/rescript/pull/8631 - Fix escaped backticks and interpolation openers in backquoted `%raw`, `%ffi`, and `%re` payloads leaking into emitted JavaScript. https://github.com/rescript-lang/rescript/pull/8630 - Fix the side-effect analysis treating bigint exponentiation and bounds-checked array and string reads as pure, which let dead-code elimination drop an unused one that throws: `let _ = 2n ** -1n` no longer raised. https://github.com/rescript-lang/rescript/pull/8617 From 6480b16ed311cdbc19d3e9a96fc1cad1661e4b84 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 11:30:41 +0200 Subject: [PATCH 03/10] Test GenType project roots Signed-off-by: Florian Hammerschmidt --- rewatch/tests/gentype/project-root.test.mjs | 36 +++++++++++++++++++++ rewatch/tests/suite.sh | 1 + 2 files changed, 37 insertions(+) create mode 100644 rewatch/tests/gentype/project-root.test.mjs diff --git a/rewatch/tests/gentype/project-root.test.mjs b/rewatch/tests/gentype/project-root.test.mjs new file mode 100644 index 00000000000..af29c542a07 --- /dev/null +++ b/rewatch/tests/gentype/project-root.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import {mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; + +const executable = process.env.REWATCH_EXECUTABLE; +assert(executable, "REWATCH_EXECUTABLE must be set"); + +const projectDir = mkdtempSync(join(tmpdir(), "rescript-gentype-root-")); + +try { + mkdirSync(join(projectDir, "src")); + writeFileSync( + join(projectDir, "rescript.json"), + `${JSON.stringify({name: "gentype-project-root", sources: ["src"], gentypeconfig: {}}, null, 2)}\n`, + ); + writeFileSync(join(projectDir, "src", "A.res"), "@genType\ntype t = {value: int}\n"); + writeFileSync( + join(projectDir, "src", "B.res"), + "@genType\nlet getValue = (record: A.t) => record.value\n", + ); + + const result = spawnSync(executable, ["build"], { + cwd: projectDir, + encoding: "utf8", + env: process.env, + timeout: 30_000, + }); + + assert.ifError(result.error); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(readFileSync(join(projectDir, "src", "B.gen.tsx"), "utf8"), /A\.gen/); +} finally { + rmSync(projectDir, {recursive: true, force: true}); +} diff --git a/rewatch/tests/suite.sh b/rewatch/tests/suite.sh index 22cc77935d7..335cae6f544 100755 --- a/rewatch/tests/suite.sh +++ b/rewatch/tests/suite.sh @@ -132,6 +132,7 @@ fi ./compile/17-prod-flag.sh && ./compile/18-external-dep-uncurried-dot.sh && ./compile/19-utf8-warning.sh && +node ./gentype/project-root.test.mjs && ./compile/14-no-testrepo-changes.sh && ./compile/15-no-new-files.sh && ./compile/16-snapshots-unchanged.sh && From f98eabe12ee20998879fe5f4dc177b7c44e12ba7 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 13:15:52 +0200 Subject: [PATCH 04/10] Run GenType path test through Windows shim Signed-off-by: Florian Hammerschmidt --- rewatch/tests/gentype/project-root.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rewatch/tests/gentype/project-root.test.mjs b/rewatch/tests/gentype/project-root.test.mjs index af29c542a07..65ad845513c 100644 --- a/rewatch/tests/gentype/project-root.test.mjs +++ b/rewatch/tests/gentype/project-root.test.mjs @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; import {spawnSync} from "node:child_process"; -import {mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; const executable = process.env.REWATCH_EXECUTABLE; assert(executable, "REWATCH_EXECUTABLE must be set"); +const executableForNode = + process.platform === "win32" && existsSync(`${executable}.cmd`) ? `${executable}.cmd` : executable; const projectDir = mkdtempSync(join(tmpdir(), "rescript-gentype-root-")); @@ -21,7 +23,7 @@ try { "@genType\nlet getValue = (record: A.t) => record.value\n", ); - const result = spawnSync(executable, ["build"], { + const result = spawnSync(executableForNode, ["build"], { cwd: projectDir, encoding: "utf8", env: process.env, From 0c3f35a410538b5ebb4ab3c7c8f34c6538818d4f Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 13:20:16 +0200 Subject: [PATCH 05/10] Exercise GenType through a Windows short path Signed-off-by: Florian Hammerschmidt --- rewatch/tests/gentype/project-root.test.mjs | 49 ++++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/rewatch/tests/gentype/project-root.test.mjs b/rewatch/tests/gentype/project-root.test.mjs index 65ad845513c..fa8001bec62 100644 --- a/rewatch/tests/gentype/project-root.test.mjs +++ b/rewatch/tests/gentype/project-root.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import {spawnSync} from "node:child_process"; -import {existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; @@ -9,22 +9,54 @@ assert(executable, "REWATCH_EXECUTABLE must be set"); const executableForNode = process.platform === "win32" && existsSync(`${executable}.cmd`) ? `${executable}.cmd` : executable; +function getBuildDirectory(projectDir) { + if (process.platform !== "win32") { + return projectDir; + } + + const expandedPath = realpathSync.native(projectDir); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `for %I in ("${expandedPath}") do @echo %~sI`], { + encoding: "utf8", + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + + const shortPath = result.stdout.trim(); + assert(shortPath, "Windows did not return a short path for the temporary project"); + assert.notEqual( + shortPath.toLowerCase(), + expandedPath.toLowerCase(), + "Windows short and expanded project paths must differ for this regression", + ); + return shortPath; +} + const projectDir = mkdtempSync(join(tmpdir(), "rescript-gentype-root-")); try { - mkdirSync(join(projectDir, "src")); + mkdirSync(join(projectDir, "src-a")); + mkdirSync(join(projectDir, "src-b", "nested"), {recursive: true}); writeFileSync( join(projectDir, "rescript.json"), - `${JSON.stringify({name: "gentype-project-root", sources: ["src"], gentypeconfig: {}}, null, 2)}\n`, + `${JSON.stringify( + { + name: "gentype-project-root", + sources: ["src-a", {dir: "src-b", subdirs: true}], + gentypeconfig: {}, + }, + null, + 2, + )}\n`, ); - writeFileSync(join(projectDir, "src", "A.res"), "@genType\ntype t = {value: int}\n"); + writeFileSync(join(projectDir, "src-a", "A.res"), "@genType\ntype t = {value: int}\n"); writeFileSync( - join(projectDir, "src", "B.res"), + join(projectDir, "src-b", "nested", "B.res"), "@genType\nlet getValue = (record: A.t) => record.value\n", ); + const buildDirectory = getBuildDirectory(projectDir); const result = spawnSync(executableForNode, ["build"], { - cwd: projectDir, + cwd: buildDirectory, encoding: "utf8", env: process.env, timeout: 30_000, @@ -32,7 +64,10 @@ try { assert.ifError(result.error); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.match(readFileSync(join(projectDir, "src", "B.gen.tsx"), "utf8"), /A\.gen/); + assert.match( + readFileSync(join(projectDir, "src-b", "nested", "B.gen.tsx"), "utf8"), + /\.\.\/\.\.\/src-a\/A\.gen/, + ); } finally { rmSync(projectDir, {recursive: true, force: true}); } From 6ecf20e83380467027db4cdcf498bdd0e047d2a3 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 13:36:56 +0200 Subject: [PATCH 06/10] Use a numbered wrapper for the GenType test Signed-off-by: Florian Hammerschmidt --- rewatch/tests/gentype/01-gentype-project-root.sh | 8 ++++++++ .../gentype/{project-root.test.mjs => project-root.mjs} | 0 rewatch/tests/suite.sh | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100755 rewatch/tests/gentype/01-gentype-project-root.sh rename rewatch/tests/gentype/{project-root.test.mjs => project-root.mjs} (100%) diff --git a/rewatch/tests/gentype/01-gentype-project-root.sh b/rewatch/tests/gentype/01-gentype-project-root.sh new file mode 100755 index 00000000000..365c787530c --- /dev/null +++ b/rewatch/tests/gentype/01-gentype-project-root.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +source "../utils.sh" + +bold "Test: GenType resolves modules through a Windows short project path" +node ./project-root.mjs +success "GenType resolved the short project path" diff --git a/rewatch/tests/gentype/project-root.test.mjs b/rewatch/tests/gentype/project-root.mjs similarity index 100% rename from rewatch/tests/gentype/project-root.test.mjs rename to rewatch/tests/gentype/project-root.mjs diff --git a/rewatch/tests/suite.sh b/rewatch/tests/suite.sh index 335cae6f544..5fccd7cf4b1 100755 --- a/rewatch/tests/suite.sh +++ b/rewatch/tests/suite.sh @@ -132,7 +132,7 @@ fi ./compile/17-prod-flag.sh && ./compile/18-external-dep-uncurried-dot.sh && ./compile/19-utf8-warning.sh && -node ./gentype/project-root.test.mjs && +./gentype/01-gentype-project-root.sh && ./compile/14-no-testrepo-changes.sh && ./compile/15-no-new-files.sh && ./compile/16-snapshots-unchanged.sh && From a45573b38228111d80fb889be54b8df5a6807294 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 10 Sep 2026 13:43:22 +0200 Subject: [PATCH 07/10] Group the GenType integration test Signed-off-by: Florian Hammerschmidt --- rewatch/tests/suite.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rewatch/tests/suite.sh b/rewatch/tests/suite.sh index 5fccd7cf4b1..039684456d3 100755 --- a/rewatch/tests/suite.sh +++ b/rewatch/tests/suite.sh @@ -132,11 +132,13 @@ fi ./compile/17-prod-flag.sh && ./compile/18-external-dep-uncurried-dot.sh && ./compile/19-utf8-warning.sh && -./gentype/01-gentype-project-root.sh && ./compile/14-no-testrepo-changes.sh && ./compile/15-no-new-files.sh && ./compile/16-snapshots-unchanged.sh && +# GenType tests +./gentype/01-gentype-project-root.sh && + # Watch tests ./watch/01-watch-recompile.sh && ./watch/02-watch-warnings-persist.sh && From 3102f42d77c5fb29e7a91112f12a3291d10ef414 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Fri, 11 Sep 2026 12:24:16 +0200 Subject: [PATCH 08/10] Fix GenType path test on CI Signed-off-by: Florian Hammerschmidt --- rewatch/src/build/parse.rs | 9 +--- rewatch/src/config.rs | 2 +- rewatch/tests/gentype/project-root.mjs | 60 +++++++++++++++++++++----- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/rewatch/src/build/parse.rs b/rewatch/src/build/parse.rs index 9d4e71533af..87944965ae3 100644 --- a/rewatch/src/build/parse.rs +++ b/rewatch/src/build/parse.rs @@ -436,18 +436,11 @@ fn generate_ast( // generate the dir of the ast_path (it mirrors the source file dir) let ast_parent_path = package.get_build_path().join(ast_path.parent().unwrap()); helpers::create_path(&ast_parent_path); - // Keep absolute source locations in the AST in the same path form as the - // canonical project root passed during compilation. This matters for - // Windows paths that have both short and expanded representations. - let parse_working_dir = build_path_abs - .canonicalize() - .map(helpers::StrippedVerbatimPath::to_stripped_verbatim_path) - .unwrap_or_else(|_| build_path_abs.clone()); /* Create .ast */ let result = match Some( Command::new(&build_state.compiler_info.bsc_path) - .current_dir(&parse_working_dir) + .current_dir(&build_path_abs) .args(parser_args) .output() .map_err(|e| { diff --git a/rewatch/src/config.rs b/rewatch/src/config.rs index b01935e14c6..44304ae1009 100644 --- a/rewatch/src/config.rs +++ b/rewatch/src/config.rs @@ -1029,7 +1029,7 @@ impl Config { } pub fn get_project_root_args(&self) -> Vec { - // The parser records locations from a canonical working directory. + // Package discovery gives the parser a canonical working directory. // Use the same representation here so GenType can strip this prefix, // including when Windows supplied the project through an 8.3 path. let root = self diff --git a/rewatch/tests/gentype/project-root.mjs b/rewatch/tests/gentype/project-root.mjs index fa8001bec62..a3a1cf80a9e 100644 --- a/rewatch/tests/gentype/project-root.mjs +++ b/rewatch/tests/gentype/project-root.mjs @@ -6,8 +6,44 @@ import {join} from "node:path"; const executable = process.env.REWATCH_EXECUTABLE; assert(executable, "REWATCH_EXECUTABLE must be set"); -const executableForNode = - process.platform === "win32" && existsSync(`${executable}.cmd`) ? `${executable}.cmd` : executable; + +function runBuild(buildDirectory) { + if (process.platform !== "win32") { + return spawnSync(executable, ["build"], { + cwd: buildDirectory, + encoding: "utf8", + env: process.env, + timeout: 30_000, + }); + } + + const executablePath = executable.endsWith(".exe") ? executable : `${executable}.exe`; + const commandShim = executable.endsWith(".cmd") ? executable : `${executable}.cmd`; + const resolvedExecutable = existsSync(executablePath) + ? executablePath + : existsSync(commandShim) + ? commandShim + : executable; + + return spawnSync( + process.env.ComSpec ?? "cmd.exe", + [ + "/d", + "/c", + 'cd /d "%REWATCH_TEST_PROJECT%" && "%REWATCH_TEST_EXECUTABLE%" build', + ], + { + encoding: "utf8", + env: { + ...process.env, + REWATCH_TEST_EXECUTABLE: resolvedExecutable, + REWATCH_TEST_PROJECT: buildDirectory, + }, + timeout: 30_000, + windowsVerbatimArguments: true, + }, + ); +} function getBuildDirectory(projectDir) { if (process.platform !== "win32") { @@ -15,9 +51,15 @@ function getBuildDirectory(projectDir) { } const expandedPath = realpathSync.native(projectDir); - const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `for %I in ("${expandedPath}") do @echo %~sI`], { - encoding: "utf8", - }); + const result = spawnSync( + process.env.ComSpec ?? "cmd.exe", + ["/d", "/c", 'for %I in ("%REWATCH_TEST_PROJECT%") do @echo %~sI'], + { + encoding: "utf8", + env: {...process.env, REWATCH_TEST_PROJECT: expandedPath}, + windowsVerbatimArguments: true, + }, + ); assert.ifError(result.error); assert.equal(result.status, 0, result.stderr); @@ -55,12 +97,8 @@ try { ); const buildDirectory = getBuildDirectory(projectDir); - const result = spawnSync(executableForNode, ["build"], { - cwd: buildDirectory, - encoding: "utf8", - env: process.env, - timeout: 30_000, - }); + assert(existsSync(buildDirectory), `Build directory does not exist: ${buildDirectory}`); + const result = runBuild(buildDirectory); assert.ifError(result.error); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); From c286781887265583729bdac3a56c65d777d090ca Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 14:13:36 +0200 Subject: [PATCH 09/10] Tighten GenType project path regression coverage Signed-off-by: Florian Hammerschmidt --- changes/gentype-windows-paths.fix.md | 2 +- .../tests/gentype/01-gentype-project-root.sh | 4 +- rewatch/tests/gentype/project-root.mjs | 38 ++++++++++--------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/changes/gentype-windows-paths.fix.md b/changes/gentype-windows-paths.fix.md index d3873b38431..e826291c3c4 100644 --- a/changes/gentype-windows-paths.fix.md +++ b/changes/gentype-windows-paths.fix.md @@ -1 +1 @@ -- Fix GenType path resolution hanging on Windows when project paths use different short and canonical forms. https://github.com/rescript-lang/rescript/pull/8639 +- Fix GenType path resolution for Windows short paths and POSIX symlinked project paths, and prevent traversal from hanging at a filesystem root. https://github.com/rescript-lang/rescript/pull/8639 diff --git a/rewatch/tests/gentype/01-gentype-project-root.sh b/rewatch/tests/gentype/01-gentype-project-root.sh index 365c787530c..61d278e2319 100755 --- a/rewatch/tests/gentype/01-gentype-project-root.sh +++ b/rewatch/tests/gentype/01-gentype-project-root.sh @@ -3,6 +3,6 @@ set -euo pipefail cd "$(dirname "$0")" source "../utils.sh" -bold "Test: GenType resolves modules through a Windows short project path" +bold "Test: GenType resolves modules through a non-canonical project path" node ./project-root.mjs -success "GenType resolved the short project path" +success "GenType project-root checks completed" diff --git a/rewatch/tests/gentype/project-root.mjs b/rewatch/tests/gentype/project-root.mjs index a3a1cf80a9e..86d905d9e6f 100644 --- a/rewatch/tests/gentype/project-root.mjs +++ b/rewatch/tests/gentype/project-root.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import {spawnSync} from "node:child_process"; -import {existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync} from "node:fs"; +import {existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; @@ -9,8 +9,8 @@ assert(executable, "REWATCH_EXECUTABLE must be set"); function runBuild(buildDirectory) { if (process.platform !== "win32") { - return spawnSync(executable, ["build"], { - cwd: buildDirectory, + return spawnSync(executable, ["build", buildDirectory], { + cwd: tmpdir(), encoding: "utf8", env: process.env, timeout: 30_000, @@ -47,7 +47,9 @@ function runBuild(buildDirectory) { function getBuildDirectory(projectDir) { if (process.platform !== "win32") { - return projectDir; + const symlink = `${projectDir}-link`; + symlinkSync(projectDir, symlink, "dir"); + return symlink; } const expandedPath = realpathSync.native(projectDir); @@ -65,11 +67,10 @@ function getBuildDirectory(projectDir) { const shortPath = result.stdout.trim(); assert(shortPath, "Windows did not return a short path for the temporary project"); - assert.notEqual( - shortPath.toLowerCase(), - expandedPath.toLowerCase(), - "Windows short and expanded project paths must differ for this regression", - ); + if (shortPath.toLowerCase() === expandedPath.toLowerCase()) { + console.log("SKIP: Windows 8.3 short names are unavailable on this volume"); + return null; + } return shortPath; } @@ -97,15 +98,18 @@ try { ); const buildDirectory = getBuildDirectory(projectDir); - assert(existsSync(buildDirectory), `Build directory does not exist: ${buildDirectory}`); - const result = runBuild(buildDirectory); + if (buildDirectory !== null) { + assert(existsSync(buildDirectory), `Build directory does not exist: ${buildDirectory}`); + const result = runBuild(buildDirectory); - assert.ifError(result.error); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.match( - readFileSync(join(projectDir, "src-b", "nested", "B.gen.tsx"), "utf8"), - /\.\.\/\.\.\/src-a\/A\.gen/, - ); + assert.ifError(result.error); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match( + readFileSync(join(projectDir, "src-b", "nested", "B.gen.tsx"), "utf8"), + /from '\.\.\/\.\.\/src-a\/A\.gen'/, + ); + } } finally { + rmSync(`${projectDir}-link`, {force: true}); rmSync(projectDir, {recursive: true, force: true}); } From 23fe47e59b9ebe56f6da0437c11841e4e12760e4 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Wed, 14 Oct 2026 23:27:18 +0200 Subject: [PATCH 10/10] Preserve GenType editor paths and test Windows root traversal Signed-off-by: Florian Hammerschmidt --- compiler/gentype/module_resolver.ml | 12 +++---- rewatch/src/build.rs | 1 + rewatch/src/build/compile.rs | 9 ++++- rewatch/src/config.rs | 44 ----------------------- rewatch/tests/gentype/project-root.mjs | 46 +++++++++++++++++++----- scripts/test.js | 24 ++++++++++++- tests/ounit_tests/ounit_gentype_tests.ml | 14 ++++++++ 7 files changed, 90 insertions(+), 60 deletions(-) diff --git a/compiler/gentype/module_resolver.ml b/compiler/gentype/module_resolver.ml index 01af1d499e0..b60ce5c8c52 100644 --- a/compiler/gentype/module_resolver.ml +++ b/compiler/gentype/module_resolver.ml @@ -109,6 +109,12 @@ let create_lazy_resolver ~config ~extensions ~exclude_file = let apply ~resolver ~use_bs_dependencies module_name = module_name |> Lazy.force resolver.lazy_find ~use_bs_dependencies +let rec path_to_list path = + let parent = Filename.dirname path in + (* On Windows, the basename of a drive root omits the drive prefix. *) + if Filename.basename path = path || parent = path then [path] + else (path |> Filename.basename) :: path_to_list parent + (** Resolve a reference to ModuleName, and produce a path suitable for require. E.g. require "../foo/bar/ModuleName.ext" where ext is ".res" or ".js". *) let resolve_module ~(config : Config.t) ~import_extension ~output_file_relative @@ -132,12 +138,6 @@ let resolve_module ~(config : Config.t) ~import_extension ~output_file_relative in if Sys.file_exists module_name_res_file then candidate else - let rec path_to_list path = - let parent = Filename.dirname path in - (* On Windows, the basename of a drive root omits the drive prefix. *) - if Filename.basename path = path || parent = path then [path] - else (path |> Filename.basename) :: path_to_list parent - in match module_name |> apply ~resolver ~use_bs_dependencies with | None -> candidate | Some (resolved_module_dir, case, bs_dependencies) -> diff --git a/rewatch/src/build.rs b/rewatch/src/build.rs index 1db5dbc218a..87ae6e2656d 100644 --- a/rewatch/src/build.rs +++ b/rewatch/src/build.rs @@ -134,6 +134,7 @@ pub fn get_compiler_args(rescript_file_path: &Path) -> Result { }; let compiler_args = compile::compiler_args( &project_context.current_config, + ¤t_package, &ast_path, relative_filename, is_interface, diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index b7a64fa747b..c4677632cf1 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -751,6 +751,9 @@ pub fn get_runtime_path_args( pub fn compiler_args( config: &config::Config, + // Match the parser's root spelling: canonical during builds, caller-supplied + // for compiler-args consumers such as editor incremental type checking. + project_root: &Path, ast_path: &Path, file_path: &Path, is_interface: bool, @@ -833,7 +836,10 @@ pub fn compiler_args( }; let package_name_arg = vec!["-bs-package-name".to_string(), config.name.to_owned()]; - let project_root_args = config.get_project_root_args(); + let project_root_args = vec![ + "-bs-project-root".to_string(), + project_root.to_string_lossy().to_string(), + ]; let implementation_args = if is_interface { debug!("Compiling interface file: {}", &module_name); @@ -1022,6 +1028,7 @@ fn compile_file( let current_package_dirs: &[PathBuf] = package.gentype_dirs.as_deref().unwrap_or(&[]); let to_mjs_args = compiler_args( &package.config, + &package.path, ast_path, implementation_file_path, is_interface, diff --git a/rewatch/src/config.rs b/rewatch/src/config.rs index 56e1c5ebb4f..fb0b329737b 100644 --- a/rewatch/src/config.rs +++ b/rewatch/src/config.rs @@ -1009,25 +1009,6 @@ impl Config { args } - /// Directory containing the `rescript.json` this config was parsed from. - fn get_package_root(&self) -> &Path { - self.path - .parent() - .expect("rescript.json path should always have a parent directory") - } - - pub fn get_project_root_args(&self) -> Vec { - // Package discovery gives the parser a canonical working directory. - // Use the same representation here so GenType can strip this prefix, - // including when Windows supplied the project through an 8.3 path. - let root = self - .get_package_root() - .canonicalize() - .map(helpers::StrippedVerbatimPath::to_stripped_verbatim_path) - .unwrap_or_else(|_| self.get_package_root().to_path_buf()); - vec!["-bs-project-root".to_string(), root.to_string_lossy().to_string()] - } - pub fn get_warning_args(&self, is_local_dep: bool, warn_error_override: Option) -> Vec { // Ignore warning config for non local dependencies (node_module dependencies) if !is_local_dep { @@ -1592,31 +1573,6 @@ pub mod tests { assert!(args.contains(&"@teamwalnut/app".to_string())); } - #[test] - fn test_compiler_project_root_is_canonicalized() { - let temp_dir = tempfile::tempdir().unwrap(); - let nested_dir = temp_dir.path().join("nested"); - fs::create_dir(&nested_dir).unwrap(); - let config_path = nested_dir.join("..").join("rescript.json"); - fs::write( - temp_dir.path().join("rescript.json"), - r#"{"name":"test","gentypeconfig":{}}"#, - ) - .unwrap(); - - let config = Config::new(&config_path).unwrap(); - let expected_root = temp_dir - .path() - .canonicalize() - .map(helpers::StrippedVerbatimPath::to_stripped_verbatim_path) - .unwrap() - .to_string_lossy() - .to_string(); - - let project_root_args = config.get_project_root_args(); - assert_eq!(project_root_args, ["-bs-project-root", &expected_root]); - } - #[test] fn test_gentype_shims_object_and_array_forms() { let object_form = serde_json::from_str::(r#"{"From": "To"}"#).unwrap(); diff --git a/rewatch/tests/gentype/project-root.mjs b/rewatch/tests/gentype/project-root.mjs index 86d905d9e6f..6e8569df557 100644 --- a/rewatch/tests/gentype/project-root.mjs +++ b/rewatch/tests/gentype/project-root.mjs @@ -7,6 +7,14 @@ import {join} from "node:path"; const executable = process.env.REWATCH_EXECUTABLE; assert(executable, "REWATCH_EXECUTABLE must be set"); +const executablePath = executable.endsWith(".exe") ? executable : `${executable}.exe`; +const commandShim = executable.endsWith(".cmd") ? executable : `${executable}.cmd`; +const resolvedExecutable = existsSync(executablePath) + ? executablePath + : existsSync(commandShim) + ? commandShim + : executable; + function runBuild(buildDirectory) { if (process.platform !== "win32") { return spawnSync(executable, ["build", buildDirectory], { @@ -17,14 +25,6 @@ function runBuild(buildDirectory) { }); } - const executablePath = executable.endsWith(".exe") ? executable : `${executable}.exe`; - const commandShim = executable.endsWith(".cmd") ? executable : `${executable}.cmd`; - const resolvedExecutable = existsSync(executablePath) - ? executablePath - : existsSync(commandShim) - ? commandShim - : executable; - return spawnSync( process.env.ComSpec ?? "cmd.exe", [ @@ -74,6 +74,35 @@ function getBuildDirectory(projectDir) { return shortPath; } +function checkEditorCompilation(buildDirectory) { + const options = {encoding: "utf8", timeout: 30_000}; + const result = spawnSync( + resolvedExecutable, + ["compiler-args", join(buildDirectory, "src-a", "A.res")], + {...options, shell: resolvedExecutable.endsWith(".cmd")}, + ); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + const {compiler_args: args} = JSON.parse(result.stdout); + assert.equal(args[args.indexOf("-bs-project-root") + 1], buildDirectory); + + // The editor compiles an absolute incremental source instead of the AST, + // and omits generated-JavaScript output while retaining the GenType flags. + args.splice(args.indexOf("-bs-package-output"), 2); + args.pop(); + const incrementalDir = join(buildDirectory, "lib", "bs", "___incremental"); + mkdirSync(incrementalDir); + const source = join(incrementalDir, "Editor.res"); + writeFileSync(source, "@genType\nlet value = 42\n"); + const compiled = spawnSync(process.env.RESCRIPT_BSC_EXE, [...args, source], { + ...options, + cwd: join(buildDirectory, "lib", "bs"), + }); + assert.ifError(compiled.error); + assert.equal(compiled.status, 0, `${compiled.stdout}\n${compiled.stderr}`); + assert(existsSync(join(incrementalDir, "Editor.gen.tsx"))); +} + const projectDir = mkdtempSync(join(tmpdir(), "rescript-gentype-root-")); try { @@ -108,6 +137,7 @@ try { readFileSync(join(projectDir, "src-b", "nested", "B.gen.tsx"), "utf8"), /from '\.\.\/\.\.\/src-a\/A\.gen'/, ); + checkEditorCompilation(buildDirectory); } } finally { rmSync(`${projectDir}-link`, {force: true}); diff --git a/scripts/test.js b/scripts/test.js index e5e26839043..79ca4569583 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -65,7 +65,29 @@ if (formatTest) { if (ounitTest) { if (process.platform === "win32") { - console.log("Skipping OUnit tests on Windows"); + // GenType's drive-root regression needs the native Windows Filename module. + const binary = path.join( + projectDir, + "_build/default/tests/ounit_tests/ounit_tests_main.exe", + ); + const { stdout } = await execBin(binary, ["-list-test"], { + shell: false, + throwOnFail: true, + }); + const tests = stdout + .split(/\r?\n/) + .filter(test => test.includes(":gentype:")); + if (tests.length === 0) { + throw new Error("No GenType unit tests found"); + } + await execBin( + binary, + tests.flatMap(test => ["-only-test", test]), + { + stdio: "inherit", + shell: false, + }, + ); } else { await execBin(ounitTestBin, [], { stdio: "inherit", diff --git a/tests/ounit_tests/ounit_gentype_tests.ml b/tests/ounit_tests/ounit_gentype_tests.ml index 2b3e363efbc..eed2cb147e3 100644 --- a/tests/ounit_tests/ounit_gentype_tests.ml +++ b/tests/ounit_tests/ounit_gentype_tests.ml @@ -3,6 +3,20 @@ open OUnit let suites = "gentype" >::: [ + ( "split relative module paths" >:: fun _ -> + assert_equal ["."] (Module_resolver.path_to_list "."); + assert_equal ["src"] (Module_resolver.path_to_list "src"); + assert_equal ["nested"; "src"] + (Module_resolver.path_to_list (Filename.concat "src" "nested")) ); + ( "stop at Windows drive roots" >:: fun _ -> + skip_if (not Sys.win32) "Windows drive roots require Windows"; + List.iter + (fun root -> + assert_equal [root] (Module_resolver.path_to_list root); + assert_equal ["nested"; "src"; root] + (Module_resolver.path_to_list + (Filename.concat root (Filename.concat "src" "nested")))) + ["C:\\"; "D:\\"] ); ( "escape semantic import paths" >:: fun _ -> let emit path = path |> Import_path.from_string_unsafe |> Import_path.emit