From 6470ecb55d4f57e967e195f46d2d169c41a38650 Mon Sep 17 00:00:00 2001 From: Robin Bate Boerop Date: Mon, 18 May 2026 10:46:58 -0700 Subject: [PATCH] test: mixed-pp lib soundness + precision baselines (pair) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two co-designed fixtures covering an unwrapped lib with per-module preprocessing where one module is default-pp (Some-entry in the per-lib index) and one is staged-pps (None-entry): - `mixed-per-module-preprocess.t` (soundness): consumer references the Some-entry module; build succeeds under `--sandbox=copy` because today's wide cmi glob over `mylib`'s objdir covers the staged-pps module's cmi. - `mixed-per-module-preprocess-precision.t` (precision): consumer references the Some-entry module while the staged-pps module's source contains an unresolvable identifier. Today, the wide glob pulls the bad module into consumer's deps and the build fails on the unbound identifier — pinning the current over-invalidation. The forthcoming per-module narrowing work (#14492) will flip the precision test's expected output from the compile error back to silent exit-0, demonstrating that the staged-pps None-entry module is no longer pulled in when the consumer doesn't reference it. Signed-off-by: Robin Bate Boerop --- .../mixed-per-module-preprocess-precision.t | 85 +++++++++++++++++++ .../mixed-per-module-preprocess.t | 79 +++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess-precision.t create mode 100644 test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess.t diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess-precision.t b/test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess-precision.t new file mode 100644 index 00000000000..f4c337dd3b9 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess-precision.t @@ -0,0 +1,85 @@ +Reproduction: today, building only the consumer's `.cmo` of an +executable that depends on `mylib` (where `mylib` has two modules, +`a` default-pp and `b` `(staged_pps ...)`) fails on a compile error +in `mylib/b.ml` — even though the consumer references only `A`. +The cctx-wide `.cmi` glob over `mylib`'s objdir pulls `b.cmi` into +the consumer's compile rule, which forces dune to compile `b.ml`, +and `b.ml` contains an unresolvable identifier. + +Companion to `mixed-per-module-preprocess.t` (the soundness sibling). + + $ make_dune_project 3.24 + +A no-op staged ppx (identical to the soundness reproducer). + + $ mkdir ppx + $ cat > ppx/dune < (library + > (name ppx_noop) + > (kind ppx_rewriter) + > (ppx.driver (main Ppx_noop.main))) + > EOF + $ cat > ppx/ppx_noop.ml < let main () = + > let n = Array.length Sys.argv in + > if n < 4 || Sys.argv.(1) <> "--as-ppx" then assert false; + > let input = Sys.argv.(n - 2) in + > let output = Sys.argv.(n - 1) in + > Filename.quote_command "cp" [input; output] + > |> Sys.command + > |> exit + > EOF + +`mylib`: `a` uses default preprocessing (Some-entry); `b` uses +`(staged_pps ...)` (None-entry). `a`'s source is independent of `b`; +`b.ml` contains an unresolvable identifier so any attempt to compile +it will fail. + + $ mkdir mylib + $ cat > mylib/dune < (library + > (name mylib) + > (wrapped false) + > (preprocess (per_module ((staged_pps ppx_noop) b)))) + > EOF + $ cat > mylib/a.ml < let answer = 42 + > EOF + $ cat > mylib/b.ml < let bar = no_such_thing + > EOF + +`consumer` references only `A`: + + $ mkdir consumer + $ cat > consumer/dune < (executable (name consumer) (libraries mylib)) + > EOF + $ cat > consumer/consumer.ml < let () = print_int A.answer + > EOF + +The consumer's compile rule tracks the wide glob over `mylib`'s +byte objdir — which materialises `b.cmi` and so forces dune to +compile `b.ml`. + + $ dune rules --root . --format=json --deps '%{cmo:consumer/consumer}' > deps.json + $ jq -r 'include "dune"; .[] | depsGlobs + > | select(.dir | endswith("mylib/.mylib.objs/byte")) + > | .dir + " " + .predicate' < deps.json + _build/default/mylib/.mylib.objs/byte *.cmi + $ jq -r 'include "dune"; .[] | depsFilePaths + > | select(endswith("mylib/.mylib.objs/byte/a.cmi"))' < deps.json + $ jq -r 'include "dune"; .[] | depsFilePaths + > | select(endswith("mylib/.mylib.objs/byte/b.cmi"))' < deps.json + +Build only the consumer's `.cmo` (compile rule, not link). Today, +dune attempts to compile `b.ml` to produce `b.cmi` and fails on +the unresolvable identifier. + + $ dune build '%{cmo:consumer/consumer}' + File "mylib/b.ml", line 1, characters 10-23: + 1 | let bar = no_such_thing + ^^^^^^^^^^^^^ + Error: Unbound value no_such_thing + [1] diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess.t b/test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess.t new file mode 100644 index 00000000000..24c7e94bff4 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/mixed-per-module-preprocess.t @@ -0,0 +1,79 @@ +An unwrapped library has two modules with mixed preprocessing: `a` +uses default preprocessing, `b` uses `(staged_pps ...)`. The +consumer references `A.identity` whose type is `B.t -> B.t`. Pins +that the consumer's compile rule correctly tracks `b.cmi` as a sandbox- +required dep — even though the consumer never names `B` in source. + + $ make_dune_project 3.24 + +A no-op staged ppx, modelled on +`test/blackbox-tests/test-cases/staged-pps-relative-directory-gh8158.t`. +The driver copies its input verbatim. + + $ mkdir ppx + $ cat > ppx/dune < (library + > (name ppx_noop) + > (kind ppx_rewriter) + > (ppx.driver (main Ppx_noop.main))) + > EOF + $ cat > ppx/ppx_noop.ml < let main () = + > let n = Array.length Sys.argv in + > if n < 4 || Sys.argv.(1) <> "--as-ppx" then assert false; + > let input = Sys.argv.(n - 2) in + > let output = Sys.argv.(n - 1) in + > Filename.quote_command "cp" [input; output] + > |> Sys.command + > |> exit + > EOF + +`mylib` is `(wrapped false)` with `a` default-pp and `b` staged-pps. +`a`'s interface mentions `B.t`, so the consumer's call to +`A.identity` forces the compiler to load `b.cmi` to resolve the +type. + + $ mkdir mylib + $ cat > mylib/dune < (library + > (name mylib) + > (wrapped false) + > (preprocess (per_module ((staged_pps ppx_noop) b)))) + > EOF + $ cat > mylib/a.mli < val identity : B.t -> B.t + > EOF + $ cat > mylib/a.ml < let identity (x : B.t) = x + > EOF + $ cat > mylib/b.ml < type t = int + > let zero : t = 0 + > EOF + +`consumer` references `A.identity` but never names `B`. The +`--sandbox=copy` build below is the discriminator: if a regression +dropped `b.cmi` from the consumer's compile-rule deps, the sandbox +would not stage it and the build would fail with "no such file" +deterministically rather than passing silently from a stale +`_build/`. + + $ mkdir consumer + $ cat > consumer/dune < (executable (name consumer) (libraries mylib)) + > EOF + $ cat > consumer/consumer.ml < let _ = A.identity 0 + > EOF + + $ dune build --sandbox=copy consumer/consumer.exe + +The consumer's compile rule for `consumer` tracks `mylib`'s byte +objdir — both `a.cmi` (referenced) and `b.cmi` (needed for the +type of `A.identity`). + + $ dune rules --root . --format=json --deps '%{cmo:consumer/consumer}' > deps.json + $ jq -r 'include "dune"; .[] | depsGlobs + > | select(.dir | endswith("mylib/.mylib.objs/byte")) + > | .dir + " " + .predicate' < deps.json + _build/default/mylib/.mylib.objs/byte *.cmi