From fe31780f4c2ae8fb0707d627e423e3006f1594ec Mon Sep 17 00:00:00 2001 From: Robin Bate Boerop Date: Thu, 23 Apr 2026 13:40:32 -0700 Subject: [PATCH 1/3] test: add baseline observational test for per-module rebuild count Add a blackbox test that records, for a consumer module C referencing only A2 of an unwrapped library [base] with three entry modules A1/A2/A3 (each with an explicit interface), the rebuild-target count for C when each of A1/A2/A3 has its signature edited. On current main, editing any of A1/A2/A3 rebuilds C because the consumer depends conservatively on a directory-wide glob over [base]'s object dir. The test records A1=1, A3=1, A2>0. The test anchors the tightening surface for https://github.com/ocaml/dune/issues/4572: once per-module filtering lands, editing A1 or A3 is expected to leave C untouched, at which point the recorded counts are promoted. Signed-off-by: Robin Bate Boerop --- .../unwrapped-tight-deps.t | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t new file mode 100644 index 00000000000..c38926c5eb9 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t @@ -0,0 +1,103 @@ +Baseline: consumer-module rebuild count when individual modules of +an unwrapped dependency library change. + +This is an observational test. It records the number of rebuild +targets for a single consumer module C when each of three entry +modules (A1, A2, A3) of the dependency library [base] has its +interface edited. C references only A2. + +On current main, editing any one of A1/A2/A3 causes C to rebuild +because library-level dependency filtering (and, within that, +per-module tightening) is not yet in place: the consumer is +conservatively rebuilt whenever any entry module's cmi changes. +Work on https://github.com/ocaml/dune/issues/4572 is expected to +tighten this, at which point editing A1 or A3 leaves C untouched +and the emitted counts are promoted. + +See: https://github.com/ocaml/dune/issues/4572 + + $ cat > dune-project < (lang dune 3.22) + > EOF + +base is an unwrapped library with three entry modules, each with an +explicit interface so signature changes propagate through .cmi files: + + $ mkdir base + $ cat > base/dune < (library (name base) (wrapped false)) + > EOF + $ cat > base/a1.ml < let v = 1 + > EOF + $ cat > base/a1.mli < val v : int + > EOF + $ cat > base/a2.ml < let v = 2 + > EOF + $ cat > base/a2.mli < val v : int + > EOF + $ cat > base/a3.ml < let v = 3 + > EOF + $ cat > base/a3.mli < val v : int + > EOF + +consumer has a single module that references only A2 from base: + + $ mkdir consumer + $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries base)) + > EOF + $ cat > consumer/c.ml < let w = A2.v + > EOF + + $ dune build @check + +Edit A1's interface — a module C does not reference — and record +the rebuild-target count for C: + + $ cat > base/a1.mli < val v : int + > val extra : unit -> int + > EOF + $ cat > base/a1.ml < let v = 1 + > let extra () = 7 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length' + 1 + +Same for A3: + + $ cat > base/a3.mli < val v : int + > val other : string + > EOF + $ cat > base/a3.ml < let v = 3 + > let other = "hi" + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length' + 1 + +Edit A2's interface — the one module C does reference — and check +that the count is positive (C must rebuild): + + $ cat > base/a2.mli < val v : int + > val new_fn : int -> int + > EOF + $ cat > base/a2.ml < let v = 2 + > let new_fn x = x + 1 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length > 0' + true From 2721c1a12ba9e04b05da0001fca659728874fc40 Mon Sep 17 00:00:00 2001 From: Robin Bate Boerop Date: Thu, 23 Apr 2026 15:49:18 -0700 Subject: [PATCH 2/3] test: make consumer multi-module to isolate per-module filter Previously [consumer] was a single-module library stanza. Dune skips ocamldep for single-module stanzas with no library deps as an optimisation, and the per-module-lib-deps filter depends on ocamldep output. So under the filter's feature branch the test's A1/A3 rebuild counts flipped from 1 to 0 for two independent reasons: the filter running on ocamldep output, and a concurrent tweak to the skip-ocamldep optimisation to run ocamldep for single-module stanzas that have library deps. Add an unused module [d.ml] to [consumer]. Ocamldep now runs unconditionally for [consumer], and the recorded rebuild count for [c] reflects only the per-module filter's work, not the skip-ocamldep behaviour. The probe regex still matches [c] only so [d] being in the rebuild set does not affect the count. Signed-off-by: Robin Bate Boerop --- .../per-module-lib-deps/unwrapped-tight-deps.t | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t index c38926c5eb9..15a2ed9d520 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t @@ -46,7 +46,13 @@ explicit interface so signature changes propagate through .cmi files: > val v : int > EOF -consumer has a single module that references only A2 from base: +consumer has two modules. The module of interest, [c.ml], references +only [A2] from [base]. A second, unused module [d.ml] is present only +to keep [consumer] a multi-module stanza: dune skips ocamldep for +single-module stanzas with no library deps as an optimisation, and +the per-module-lib-deps filter depends on ocamldep output. Including +[d.ml] isolates this test from the skip-ocamldep optimisation so the +rebuild count for [c] reflects only the per-module filter's work: $ mkdir consumer $ cat > consumer/dune < consumer/c.ml < let w = A2.v > EOF + $ cat > consumer/d.ml < let _ = () + > EOF $ dune build @check From f56a633e1436341b344f63429239b1f08fad9fbf Mon Sep 17 00:00:00 2001 From: Robin Bate Boerop Date: Sun, 26 Apr 2026 18:28:19 -0700 Subject: [PATCH 3/3] test: print rebuild target names instead of counts Per rgrinberg review on #14310: replace the [length]-based jq pipeline with the idiomatic [targetsMatchingFilter] pattern used in sibling tests (e.g. alias-reexport.t). The recorded output now shows the actual target paths under [_build/default/consumer/] that are rebuilt when each of A1/A2/A3 is edited, rather than a bare count. This also unifies the three blocks: A1, A3, and A2 all use the same pipeline. Once #14116's per-module filter lands, the A1/A3 blocks promote to [], while A2 keeps the c.cmi/c.cmo/c.cmt list. Signed-off-by: Robin Bate Boerop --- .../unwrapped-tight-deps.t | 131 +++++++++++------- 1 file changed, 80 insertions(+), 51 deletions(-) diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t index 15a2ed9d520..449f2da858d 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t @@ -1,112 +1,141 @@ -Baseline: consumer-module rebuild count when individual modules of -an unwrapped dependency library change. +Baseline: consumer-module rebuild targets when individual modules +of an unwrapped dependency library change. -This is an observational test. It records the number of rebuild -targets for a single consumer module C when each of three entry -modules (A1, A2, A3) of the dependency library [base] has its -interface edited. C references only A2. +This is an observational test. It records the rebuild targets for +the consumer module [consumer] when each of three entry modules of +the dependency library [dep_lib] has its interface edited. +[consumer] references only [Referenced_dep] from [dep_lib]; +[Unread_dep_a] and [Unread_dep_b] are present but unreferenced. -On current main, editing any one of A1/A2/A3 causes C to rebuild +On current main, editing any of the three rebuilds [consumer] because library-level dependency filtering (and, within that, per-module tightening) is not yet in place: the consumer is conservatively rebuilt whenever any entry module's cmi changes. Work on https://github.com/ocaml/dune/issues/4572 is expected to -tighten this, at which point editing A1 or A3 leaves C untouched -and the emitted counts are promoted. +tighten this, at which point editing [Unread_dep_a] or +[Unread_dep_b] leaves [consumer] untouched and the emitted target +list becomes empty. See: https://github.com/ocaml/dune/issues/4572 $ cat > dune-project < (lang dune 3.22) + > (lang dune 3.23) > EOF -base is an unwrapped library with three entry modules, each with an -explicit interface so signature changes propagate through .cmi files: +[dep_lib] is an unwrapped library with three entry modules, each +with an explicit interface so signature changes propagate through +.cmi files: - $ mkdir base - $ cat > base/dune < (library (name base) (wrapped false)) + $ mkdir dep_lib + $ cat > dep_lib/dune < (library (name dep_lib) (wrapped false)) > EOF - $ cat > base/a1.ml < dep_lib/unread_dep_a.ml < let v = 1 > EOF - $ cat > base/a1.mli < dep_lib/unread_dep_a.mli < val v : int > EOF - $ cat > base/a2.ml < dep_lib/referenced_dep.ml < let v = 2 > EOF - $ cat > base/a2.mli < dep_lib/referenced_dep.mli < val v : int > EOF - $ cat > base/a3.ml < dep_lib/unread_dep_b.ml < let v = 3 > EOF - $ cat > base/a3.mli < dep_lib/unread_dep_b.mli < val v : int > EOF -consumer has two modules. The module of interest, [c.ml], references -only [A2] from [base]. A second, unused module [d.ml] is present only -to keep [consumer] a multi-module stanza: dune skips ocamldep for -single-module stanzas with no library deps as an optimisation, and -the per-module-lib-deps filter depends on ocamldep output. Including -[d.ml] isolates this test from the skip-ocamldep optimisation so the -rebuild count for [c] reflects only the per-module filter's work: +[consumer_lib] has two modules. The module of interest, [consumer], +references only [Referenced_dep] from [dep_lib]. A second, unused +module [filler] is present only to keep [consumer_lib] a multi- +module stanza: dune skips ocamldep for single-module stanzas with +no library deps as an optimisation, and the per-module-lib-deps +filter depends on ocamldep output. Including [filler] isolates +this test from the skip-ocamldep optimisation so the rebuild +targets for [consumer] reflect only the per-module filter's work: - $ mkdir consumer - $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries base)) + $ mkdir consumer_lib + $ cat > consumer_lib/dune < (library (name consumer_lib) (wrapped false) (libraries dep_lib)) > EOF - $ cat > consumer/c.ml < let w = A2.v + $ cat > consumer_lib/consumer.ml < let w = Referenced_dep.v > EOF - $ cat > consumer/d.ml < consumer_lib/filler.ml < let _ = () > EOF $ dune build @check -Edit A1's interface — a module C does not reference — and record -the rebuild-target count for C: +Edit [Unread_dep_a]'s interface — a module [consumer] does not +reference — and record the rebuild targets for [consumer]: - $ cat > base/a1.mli < dep_lib/unread_dep_a.mli < val v : int > val extra : unit -> int > EOF - $ cat > base/a1.ml < dep_lib/unread_dep_a.ml < let v = 1 > let extra () = 7 > EOF $ dune build @check - $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length' - 1 + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer_lib/\\.consumer_lib\\.objs/byte/consumer\\."))]' + [ + { + "target_files": [ + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmi", + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmo", + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmt" + ] + } + ] -Same for A3: +Same for [Unread_dep_b]: - $ cat > base/a3.mli < dep_lib/unread_dep_b.mli < val v : int > val other : string > EOF - $ cat > base/a3.ml < dep_lib/unread_dep_b.ml < let v = 3 > let other = "hi" > EOF $ dune build @check - $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length' - 1 + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer_lib/\\.consumer_lib\\.objs/byte/consumer\\."))]' + [ + { + "target_files": [ + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmi", + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmo", + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmt" + ] + } + ] -Edit A2's interface — the one module C does reference — and check -that the count is positive (C must rebuild): +Edit [Referenced_dep]'s interface — the one module [consumer] does +reference — and record the rebuild targets ([consumer] must +rebuild): - $ cat > base/a2.mli < dep_lib/referenced_dep.mli < val v : int > val new_fn : int -> int > EOF - $ cat > base/a2.ml < dep_lib/referenced_dep.ml < let v = 2 > let new_fn x = x + 1 > EOF $ dune build @check - $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length > 0' - true + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer_lib/\\.consumer_lib\\.objs/byte/consumer\\."))]' + [ + { + "target_files": [ + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmi", + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmo", + "_build/default/consumer_lib/.consumer_lib.objs/byte/consumer.cmt" + ] + } + ]