From f195de59c61ed3f0053d4e1aeaf5b1d215d0b1f5 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Fri, 15 May 2026 11:38:52 +0800 Subject: [PATCH] Add multi-scope regression tests for scoped-build implicit-dep detection Adds scoped_implicit_dep_multi_scope fixture (three TUs share an out-of-scope header in include/lib/shared.h, linked into a single binary) and four SCENARIOs tagged [multi-scope]: - Full initial build + scoped rebuild - Out-of-tree variant build (-B build/v1) - Scoped initial build + scoped rebuild - Full initial + noop scoped pass + scoped rebuild after edit All four assert the linked binary's bytes change after a header edit, catching both the "Nothing to do" noop mode and the "rebuilt some + linked stale" partial-rebuild mode. Verified RED: reverting the binary_search(implicit_dep_files) clause from 315b2a2 in find_changed_files_with_implicit causes all four to fail with "Nothing to do (up to date)". Refs 315b2a2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tupfile.fixture | 1 + .../Tupfile.ini | 0 .../app/Tupfile.fixture | 2 + .../app/main.c | 10 + .../include/lib/shared.h | 1 + .../src/alpha/Tupfile.fixture | 1 + .../src/alpha/alpha.c | 2 + .../src/beta/Tupfile.fixture | 1 + .../src/beta/beta.c | 2 + test/unit/test_e2e.cpp | 183 ++++++++++++++++++ 10 files changed, 203 insertions(+) create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.fixture create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.ini create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/Tupfile.fixture create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/main.c create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/include/lib/shared.h create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/Tupfile.fixture create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/alpha.c create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/Tupfile.fixture create mode 100644 test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/beta.c diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.fixture b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.fixture new file mode 100644 index 00000000..31053962 --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.fixture @@ -0,0 +1 @@ +: |> echo "root" |> diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.ini b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/Tupfile.ini new file mode 100644 index 00000000..e69de29b diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/Tupfile.fixture b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/Tupfile.fixture new file mode 100644 index 00000000..d96d64d8 --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/Tupfile.fixture @@ -0,0 +1,2 @@ +: main.c |> cc -MD -I../include -c %f -o %o |> main.o +: main.o ../src/alpha/alpha.o ../src/beta/beta.o |> cc %f -o %o |> app diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/main.c b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/main.c new file mode 100644 index 00000000..ffa2cde6 --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/app/main.c @@ -0,0 +1,10 @@ +#include +#include "lib/shared.h" +extern int alpha_version(void); +extern int beta_version(void); +int main(void) +{ + printf("main=%d alpha=%d beta=%d\n", + VERSION, alpha_version(), beta_version()); + return 0; +} diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/include/lib/shared.h b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/include/lib/shared.h new file mode 100644 index 00000000..49752f51 --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/include/lib/shared.h @@ -0,0 +1 @@ +#define VERSION 1 diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/Tupfile.fixture b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/Tupfile.fixture new file mode 100644 index 00000000..7ed34509 --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/Tupfile.fixture @@ -0,0 +1 @@ +: alpha.c |> cc -MD -I../../include -c %f -o %o |> alpha.o diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/alpha.c b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/alpha.c new file mode 100644 index 00000000..092fbae7 --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/alpha/alpha.c @@ -0,0 +1,2 @@ +#include "lib/shared.h" +int alpha_version(void) { return VERSION; } diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/Tupfile.fixture b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/Tupfile.fixture new file mode 100644 index 00000000..e697096d --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/Tupfile.fixture @@ -0,0 +1 @@ +: beta.c |> cc -MD -I../../include -c %f -o %o |> beta.o diff --git a/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/beta.c b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/beta.c new file mode 100644 index 00000000..44815c8f --- /dev/null +++ b/test/e2e/fixtures/scoped_implicit_dep_multi_scope/src/beta/beta.c @@ -0,0 +1,2 @@ +#include "lib/shared.h" +int beta_version(void) { return VERSION; } diff --git a/test/unit/test_e2e.cpp b/test/unit/test_e2e.cpp index 9feacbc0..071cfabb 100644 --- a/test/unit/test_e2e.cpp +++ b/test/unit/test_e2e.cpp @@ -5389,6 +5389,189 @@ SCENARIO("Scoped build detects header changes outside scope", "[e2e][incremental } } +// Reproducer for the real-world bug noted in CLAUDE.md: +// "Header-dep tracking can miss across variants — editing a widely-included +// SDK header may re-link with stale .o files and produce a binary newer +// than the header but functionally older." +// +// Differs from the single-scope test above in two ways: +// - multi-arg scoped invocation (mirrors `pup src/fubon ... ios` pattern) +// - multiple TUs in different scope dirs all transitively reach the same +// out-of-scope header, AND a downstream link rule consumes their .o files +// +// The strict assertion checks that ALL three .o files (alpha.o, beta.o, +// main.o) are present AND the binary's hash changes after the header bump, +// catching both the "noop" mode and the "rebuild some-but-not-all + relink +// with stale .o" mode of the bug. +SCENARIO("Scoped build with multiple scopes detects out-of-scope header changes", + "[e2e][incremental][scope][multi-scope]") +{ + GIVEN("a project where multiple scoped TUs share one header in include/") + { + auto f = E2EFixture { "scoped_implicit_dep_multi_scope" }; + REQUIRE(f.init().success()); + + auto first = f.build(); + INFO("first build stdout: " << first.stdout_output); + INFO("first build stderr: " << first.stderr_output); + REQUIRE(first.success()); + REQUIRE(f.exists("src/alpha/alpha.o")); + REQUIRE(f.exists("src/beta/beta.o")); + REQUIRE(f.exists("app/main.o")); + REQUIRE(f.exists("app/app")); + + // Capture the linked binary's content so we can detect stale-link bugs + // even if pup claims "rebuilt". + auto const binary_before = f.read_file("app/app"); + + WHEN("the shared header is modified and the same scoped build re-runs") + { + f.write_file("include/lib/shared.h", "#define VERSION 2\n"); + + // Multi-arg scoped invocation mirroring the real-world reproducer. + auto result = f.pup({ "src/alpha", "src/beta", "app" }); + + THEN("pup picks up the change and the link reflects the new VERSION") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE(result.success()); + REQUIRE_FALSE(result.is_noop()); + + // Strict check: the linked binary must differ from the pre-edit + // version. If pup recompiled only some .o files and re-linked + // with stale others, the binary would be byte-identical or only + // partially updated — either way still buggy. + auto const binary_after = f.read_file("app/app"); + REQUIRE(binary_before != binary_after); + } + } + } +} + +// Same shape as above but with the build done out-of-tree under build/, +// invoked with -B. Variants share the source tree but have independent indices +// — a fix in one variant's index must not let the other re-link with stale .o. +SCENARIO("Out-of-tree variant build picks up out-of-scope header changes", + "[e2e][incremental][scope][multi-scope][variant]") +{ + GIVEN("the multi-scope project configured under build/v1") + { + auto f = E2EFixture { "scoped_implicit_dep_multi_scope" }; + REQUIRE(f.pup({ "configure", "-B", "build/v1" }).success()); + REQUIRE(f.build({ "-B", "build/v1" }).success()); + REQUIRE(f.exists("build/v1/src/alpha/alpha.o")); + REQUIRE(f.exists("build/v1/src/beta/beta.o")); + REQUIRE(f.exists("build/v1/app/app")); + + auto const binary_before = f.read_file("build/v1/app/app"); + + WHEN("the shared header is edited and the variant rebuilds") + { + f.write_file("include/lib/shared.h", "#define VERSION 2\n"); + + auto result = f.pup({ "-B", "build/v1", + "src/alpha", "src/beta", "app" }); + + THEN("the variant's binary reflects the new VERSION") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE(result.success()); + REQUIRE_FALSE(result.is_noop()); + + auto const binary_after = f.read_file("build/v1/app/app"); + REQUIRE(binary_before != binary_after); + } + } + } +} + +// Probes the scoped-initial-build axis: when the FIRST build is already +// scoped (rather than full), is the index populated correctly enough that +// a subsequent header edit propagates through the linked binary? +SCENARIO("Scoped rebuild after a SCOPED initial multi-scope build", + "[e2e][incremental][scope][multi-scope]") +{ + GIVEN("a multi-scope project whose first build was already scoped") + { + auto f = E2EFixture { "scoped_implicit_dep_multi_scope" }; + REQUIRE(f.init().success()); + + auto scopes = std::vector { "src/alpha", "src/beta", "app" }; + auto first = f.pup(scopes); + INFO("first scoped build stdout: " << first.stdout_output); + INFO("first scoped build stderr: " << first.stderr_output); + REQUIRE(first.success()); + REQUIRE(f.exists("app/app")); + + auto const binary_before = f.read_file("app/app"); + + WHEN("the shared header changes and the same scoped command runs again") + { + f.write_file("include/lib/shared.h", "#define VERSION 2\n"); + + auto result = f.pup(scopes); + + THEN("the linked binary reflects the new VERSION") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE(result.success()); + REQUIRE_FALSE(result.is_noop()); + + auto const binary_after = f.read_file("app/app"); + REQUIRE(binary_before != binary_after); + } + } + } +} + +// Probes the repeated-scoped-invocation axis: full build, then a no-op +// scoped pass, then a real edit + scoped rebuild. Mirrors the production +// pattern where developers run the same scoped command many times across +// a session before the edit that exposes the bug. +SCENARIO("Header edit after a noop scoped pass still propagates to the linked binary", + "[e2e][incremental][scope][multi-scope]") +{ + GIVEN("a fully-built project that has gone through a noop scoped pass") + { + auto f = E2EFixture { "scoped_implicit_dep_multi_scope" }; + REQUIRE(f.init().success()); + + auto scopes = std::vector { "src/alpha", "src/beta", "app" }; + + auto first = f.build(); + INFO("first build stdout: " << first.stdout_output); + REQUIRE(first.success()); + REQUIRE(f.exists("app/app")); + + auto noop = f.pup(scopes); + INFO("noop stdout: " << noop.stdout_output); + REQUIRE(noop.success()); + + auto const binary_before = f.read_file("app/app"); + + WHEN("the shared header changes and the scoped build runs once more") + { + f.write_file("include/lib/shared.h", "#define VERSION 2\n"); + + auto result = f.pup(scopes); + + THEN("the linked binary still reflects the new VERSION") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE(result.success()); + REQUIRE_FALSE(result.is_noop()); + + auto const binary_after = f.read_file("app/app"); + REQUIRE(binary_before != binary_after); + } + } + } +} + // ============================================================================= // Target Build No-Op Tests // =============================================================================