From 7cc520216327c7a135f5d3e15777df0a5055c68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Tsnobiladz=C3=A9?= <2479216+tsnobip@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:50:17 +0200 Subject: [PATCH 1/2] Spell files below source dir "." without a leading "./" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: With a source dir "." (or "./src"), `rescript` rebuilt the same modules and everything depending on them on every build, while `rescript-rust` compiled nothing. Discovery joined the dir to each file name, giving "./A.res", but bsc records "A.res" in the AST and outputs are expected at "A.js" and "ui/U.js". Cleanup matched no AST to "./A.res" and removed it, and the inventory of "." listed "./ui/U.js", which matched no module's output, so the next build removed it and recompiled U. For a package with `"sources": [{"dir": "."}, {"dir": "ui"}]`: Before (text): unchanged builds of that package Cleaned 1/2 Parsed 1 source files Compiled 1 modules After (text): unchanged builds of that package Cleaned 0/2 Parsed 0 source files Compiled 0 modules Discovery now drops "." segments from file paths, and the output path of a module of the package root no longer starts with "./". Directory paths stay as written, so `-bs-gentype-source-dir`, `.sourcedirs.json` and `-bs-package-output` are unchanged. Review: Why not normalize where paths are compared? Cleanup, parse freshness, compile assets and the output inventory all key on these paths; fixing their spelling once covers every comparison. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> --- changes/root-source-dir-rebuild.fix.md | 2 + rewatch-ocaml/build_artifacts.ml | 11 +++- rewatch-ocaml/source.ml | 26 ++++++++- .../compile/23-root-source-dir-rebuild.sh | 55 +++++++++++++++++++ rewatch/tests/suite.sh | 1 + 5 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 changes/root-source-dir-rebuild.fix.md create mode 100755 rewatch/tests/compile/23-root-source-dir-rebuild.sh diff --git a/changes/root-source-dir-rebuild.fix.md b/changes/root-source-dir-rebuild.fix.md new file mode 100644 index 00000000000..032a3e32574 --- /dev/null +++ b/changes/root-source-dir-rebuild.fix.md @@ -0,0 +1,2 @@ +- Stop `rescript` from rebuilding the modules of a `"."` or `"./src"` source + dir, and their dependents, on every build when nothing changed. diff --git a/rewatch-ocaml/build_artifacts.ml b/rewatch-ocaml/build_artifacts.ml index 19d00d4dd65..6657c291952 100644 --- a/rewatch-ocaml/build_artifacts.ml +++ b/rewatch-ocaml/build_artifacts.ml @@ -12,10 +12,15 @@ let relative_output_directory path (spec : Config.package_spec) = let generated_js_path (config : Config.t) path (spec : Config.package_spec) = let output_dir = relative_output_directory path spec in + let output = + Filename.remove_extension (Filename.basename path) + ^ Config.package_spec_suffix config spec + in + (* An in-source output beside a module of the package root is "A.js", as + the source inventory spells it, not "./A.js". *) Filename.concat config.root - (Filename.concat output_dir - (Filename.remove_extension (Filename.basename path) - ^ Config.package_spec_suffix config spec)) + (if output_dir = Filename.current_dir_name then output + else Filename.concat output_dir output) let published_ast_path ~ocaml_dir source = Filename.concat ocaml_dir (Filename.basename (Source.ast_path source)) diff --git a/rewatch-ocaml/source.ml b/rewatch-ocaml/source.ml index 750f8a838d5..214b71b3106 100644 --- a/rewatch-ocaml/source.ml +++ b/rewatch-ocaml/source.ml @@ -81,6 +81,22 @@ let interface_mismatch_error implementation interface = different path names or different cases: `%s` vs `%s`" implementation interface) +(* [path] without its "." segments, so a file found below source dir "." or + "./src" has the path bsc records for it, "A.res" or "src/A.res". Cleanup and + freshness match modules to their ASTs and outputs by these paths: a + "./A.res" matched no AST, and a "./ui/U.js" in the inventory of "." matched + no module's output, so each build removed them and rebuilt the module. *) +let rec without_current_dir_segments path = + let directory = Filename.dirname path in + let base = Filename.basename path in + if directory = path || path = Filename.current_dir_name then path + else if base = Filename.current_dir_name then + without_current_dir_segments directory + else + match without_current_dir_segments directory with + | directory when directory = Filename.current_dir_name -> base + | directory -> Filename.concat directory base + (* A package source tree has three consumers with deliberately different recursion rules. Compilation follows directory links and honors source activation/subdirs; cleanup inventories every real descendant but treats @@ -134,7 +150,11 @@ let scan_source ~root (source : Config.source) ~discover_modules ~on_missing | Some kind -> candidates := { - file = {path = relative_path; modified = metadata.Unix.st_mtime}; + file = + { + path = without_current_dir_segments relative_path; + modified = metadata.Unix.st_mtime; + }; kind; is_dev = source.is_dev; } @@ -143,7 +163,9 @@ let scan_source ~root (source : Config.source) ~discover_modules ~on_missing List.iter (fun name -> let relative_path = Filename.concat relative name in - let absolute_path = Filename.concat root relative_path in + let absolute_path = + Filename.concat root (without_current_dir_segments relative_path) + in match Unix.lstat absolute_path with | metadata -> ( match metadata.Unix.st_kind with diff --git a/rewatch/tests/compile/23-root-source-dir-rebuild.sh b/rewatch/tests/compile/23-root-source-dir-rebuild.sh new file mode 100755 index 00000000000..19c79f517de --- /dev/null +++ b/rewatch/tests/compile/23-root-source-dir-rebuild.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +cd $(dirname $0) +source "../utils.sh" + +bold "Test: Modules of source dir \".\" are not rebuilt when nothing changed" + +fixture=$(mktemp -d 2>/dev/null || mktemp -d -t rewatch-root-source-dir) +trap "rm -rf '$fixture'" EXIT + +mkdir -p "$fixture/ui" + +# "." spells its modules "./Root.res" unless the build system drops the +# segment, while bsc records "Root.res" in the AST and outputs go to "Root.mjs" +# and "ui/Component.mjs". A mismatch makes every build clean those artifacts. +cat > "$fixture/rescript.json" <<'JSON' +{ + "name": "rewatch-root-source-dir", + "sources": [{ "dir": "." }, { "dir": "ui" }], + "package-specs": { "module": "esmodule", "in-source": true }, + "suffix": ".mjs" +} +JSON + +cat > "$fixture/Root.res" <<'RES' +let value = 1 +RES + +cat > "$fixture/ui/Component.res" <<'RES' +let value = Root.value + 1 +RES + +cd "$fixture" +if ! compiler_output=$("$REWATCH_EXECUTABLE" build 2>&1); then + error "Initial fixture build failed" + printf "%s\n" "$compiler_output" >&2 + exit 1 +fi + +# Several unchanged builds, since a removed output only shows on the next one. +for attempt in 1 2 3; do + compiler_output=$("$REWATCH_EXECUTABLE" build 2>&1) + if ! printf '%s\n' "$compiler_output" | grep -q "Compiled 0 modules"; then + error "Unchanged build $attempt recompiled modules" + printf "%s\n" "$compiler_output" >&2 + exit 1 + fi +done + +if [ ! -f Root.mjs ] || [ ! -f ui/Component.mjs ]; then + error "Unchanged builds removed generated outputs" + exit 1 +fi + +success "Modules of source dir \".\" are not rebuilt when nothing changed" diff --git a/rewatch/tests/suite.sh b/rewatch/tests/suite.sh index 05e12d053f8..f964dcfd711 100755 --- a/rewatch/tests/suite.sh +++ b/rewatch/tests/suite.sh @@ -87,6 +87,7 @@ fi ./compile/19-utf8-warning.sh && ./compile/20-schedule-independent-modules-after-error.sh && ./compile/21-rebuild-dependency-for-package-specs.sh && +./compile/23-root-source-dir-rebuild.sh && ./compile/14-no-testrepo-changes.sh && ./compile/15-no-new-files.sh && ./compile/16-snapshots-unchanged.sh && From c7d3a03966418841e38af8f79d030f816e5eccd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Tsnobiladz=C3=A9?= <2479216+tsnobip@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:17:01 +0200 Subject: [PATCH 2/2] Spell out-of-source outputs of root modules without "/./" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `"in-source": false`, the output path of a module of source dir "." was "lib/es6/./A.mjs" while the output inventory finds "lib/es6/A.mjs". Only the missing lib/bs mirror kept cleanup from deleting the real output, so the output path now drops every "." segment, and the shared test also builds the out-of-source layout. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> --- rewatch-ocaml/build_artifacts.ml | 14 ++-- rewatch-ocaml/source.mli | 4 ++ .../compile/23-root-source-dir-rebuild.sh | 72 ++++++++++--------- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/rewatch-ocaml/build_artifacts.ml b/rewatch-ocaml/build_artifacts.ml index 6657c291952..2b48144049d 100644 --- a/rewatch-ocaml/build_artifacts.ml +++ b/rewatch-ocaml/build_artifacts.ml @@ -12,15 +12,13 @@ let relative_output_directory path (spec : Config.package_spec) = let generated_js_path (config : Config.t) path (spec : Config.package_spec) = let output_dir = relative_output_directory path spec in - let output = - Filename.remove_extension (Filename.basename path) - ^ Config.package_spec_suffix config spec - in - (* An in-source output beside a module of the package root is "A.js", as - the source inventory spells it, not "./A.js". *) + (* The output of a module of the package root is "A.js" or "lib/es6/A.js", + as the output inventory spells it, not "./A.js" or "lib/es6/./A.js". *) Filename.concat config.root - (if output_dir = Filename.current_dir_name then output - else Filename.concat output_dir output) + (Source.without_current_dir_segments + (Filename.concat output_dir + (Filename.remove_extension (Filename.basename path) + ^ Config.package_spec_suffix config spec))) let published_ast_path ~ocaml_dir source = Filename.concat ocaml_dir (Filename.basename (Source.ast_path source)) diff --git a/rewatch-ocaml/source.mli b/rewatch-ocaml/source.mli index aca9f6f8df8..c81748d8b92 100644 --- a/rewatch-ocaml/source.mli +++ b/rewatch-ocaml/source.mli @@ -23,6 +23,10 @@ type source_kind = Implementation | Interface val source_kind : string -> source_kind option val module_name : string -> string + +val without_current_dir_segments : string -> string +(** [path] without its "." segments: "./src/./A.res" is "src/A.res". *) + val namespace_members : entry:string option -> module_ list -> module_ list val duplicate_error : diff --git a/rewatch/tests/compile/23-root-source-dir-rebuild.sh b/rewatch/tests/compile/23-root-source-dir-rebuild.sh index 19c79f517de..eaa05917468 100755 --- a/rewatch/tests/compile/23-root-source-dir-rebuild.sh +++ b/rewatch/tests/compile/23-root-source-dir-rebuild.sh @@ -5,51 +5,53 @@ source "../utils.sh" bold "Test: Modules of source dir \".\" are not rebuilt when nothing changed" -fixture=$(mktemp -d 2>/dev/null || mktemp -d -t rewatch-root-source-dir) -trap "rm -rf '$fixture'" EXIT - -mkdir -p "$fixture/ui" +fixtures=$(mktemp -d 2>/dev/null || mktemp -d -t rewatch-root-source-dir) +trap "rm -rf '$fixtures'" EXIT # "." spells its modules "./Root.res" unless the build system drops the -# segment, while bsc records "Root.res" in the AST and outputs go to "Root.mjs" -# and "ui/Component.mjs". A mismatch makes every build clean those artifacts. -cat > "$fixture/rescript.json" <<'JSON' +# segment, while bsc records "Root.res" in the AST and the output inventory +# finds "Root.mjs" or "lib/es6/Root.mjs". A mismatch makes every build clean +# those artifacts. +check_layout() { + local name=$1 in_source=$2 root_output=$3 ui_output=$4 + local fixture="$fixtures/$name" + mkdir -p "$fixture/ui" + + cat > "$fixture/rescript.json" < "$fixture/Root.res" <<'RES' -let value = 1 -RES - -cat > "$fixture/ui/Component.res" <<'RES' -let value = Root.value + 1 -RES - -cd "$fixture" -if ! compiler_output=$("$REWATCH_EXECUTABLE" build 2>&1); then - error "Initial fixture build failed" - printf "%s\n" "$compiler_output" >&2 - exit 1 -fi - -# Several unchanged builds, since a removed output only shows on the next one. -for attempt in 1 2 3; do - compiler_output=$("$REWATCH_EXECUTABLE" build 2>&1) - if ! printf '%s\n' "$compiler_output" | grep -q "Compiled 0 modules"; then - error "Unchanged build $attempt recompiled modules" + printf 'let value = 1\n' > "$fixture/Root.res" + printf 'let value = Root.value + 1\n' > "$fixture/ui/Component.res" + + if ! compiler_output=$(cd "$fixture" && "$REWATCH_EXECUTABLE" build 2>&1); then + error "Initial $name fixture build failed" printf "%s\n" "$compiler_output" >&2 - exit 1 + return 1 fi -done -if [ ! -f Root.mjs ] || [ ! -f ui/Component.mjs ]; then - error "Unchanged builds removed generated outputs" - exit 1 -fi + # Several unchanged builds, since a removed output only shows on the next one. + for attempt in 1 2 3; do + compiler_output=$(cd "$fixture" && "$REWATCH_EXECUTABLE" build 2>&1) + if ! printf '%s\n' "$compiler_output" | grep -q "Compiled 0 modules"; then + error "Unchanged $name build $attempt recompiled modules" + printf "%s\n" "$compiler_output" >&2 + return 1 + fi + done + + if [ ! -f "$fixture/$root_output" ] || [ ! -f "$fixture/$ui_output" ]; then + error "Unchanged $name builds removed generated outputs" + return 1 + fi +} + +check_layout in-source true Root.mjs ui/Component.mjs || exit 1 +check_layout out-of-source false lib/es6/Root.mjs lib/es6/ui/Component.mjs || exit 1 success "Modules of source dir \".\" are not rebuilt when nothing changed"