Skip to content

Commit d508551

Browse files
committed
test(cross): pin that a cross build declares the artifact it produces
Adds ArtifactNaming (a (os, env) function on the target triple) plus the regression assertion for a defect that exists on HEAD today. plan.cppm's target_output() spells the artifact suffix from mcpp::platform::exe_suffix — a HOST constant. Cross-compiling Linux -> PE that yields `bin/foo` while mingw's GCC driver writes `bin/foo.exe`, so the file ninja was told to produce never appears. ninja finds the declared output missing on every run and reruns the link edge forever. The e2e asserts both the cause and its observable consequence: that the declared ninja output exists, and that an up-to-date rebuild does not change the artifact's mtime. Verified RED before the fix: FAIL: ninja declares output 'bin/relinkprobe' but that file does not exist actually produced: relinkprobe.exe => the link edge can never be satisfied, so it reruns every build The unit tests cover ArtifactNaming's own logic, including the part a single _WIN32 branch cannot express: windows-gnu uses the GNU convention (libfoo.a) while windows-msvc uses foo.lib. They pass a deliberately bogus host answer, so any assertion leaking through to the host axis fails loudly. None of the other cross tests could have caught this: they look for the REAL artifact (find -name '*.exe'), not for what ninja declared, so both of their assertions hold while the inconsistency sits underneath them. Refs .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md
1 parent ae31fa4 commit d508551

3 files changed

Lines changed: 268 additions & 0 deletions

File tree

src/toolchain/triple.cppm

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,74 @@ namespace pins {
173173
inline constexpr std::string_view kSuggestGccMingw = "gcc 16.1.0";
174174
} // namespace pins
175175

176+
// ── Artifact naming conventions ──────────────────────────────────────────────
177+
//
178+
// How a built artifact is NAMED is a property of the TARGET, never of the
179+
// machine doing the build. `mcpp::platform::{exe_suffix,lib_prefix,…}` answer a
180+
// different question — "what does THIS machine call its own binaries" — and
181+
// using them to name build outputs is wrong the moment host != target.
182+
//
183+
// It is a function of (os, env), not of os alone. The trap:
184+
//
185+
// x86_64-windows-gnu → libfoo.a (GNU/mingw convention)
186+
// x86_64-windows-msvc → foo.lib (MSVC convention)
187+
//
188+
// A single `_WIN32` branch cannot express that, which is why building a static
189+
// library with mingw ON a Windows host produces `foo.lib` today — a GNU archive
190+
// wearing an MSVC name. That is a pre-existing defect, unrelated to cross
191+
// compilation.
192+
//
193+
// See .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md.
194+
struct ArtifactNaming {
195+
std::string_view exeSuffix; // "" | ".exe"
196+
std::string_view libPrefix; // "lib" | ""
197+
std::string_view staticLibExt; // ".a" | ".lib"
198+
std::string_view sharedLibExt; // ".so" | ".dylib" | ".dll"
199+
// PE consumers link against an import library, not the .dll itself. mcpp
200+
// does not model import libraries yet, so this currently marks "shared
201+
// libraries are not supported for this target" rather than describing a
202+
// produced artifact. Shared libraries have never been verified end-to-end
203+
// on PE or Mach-O — every shared-library e2e declares `# requires: elf`.
204+
bool sharedNeedsImportLib;
205+
};
206+
207+
// Naming for an explicit target triple. An EMPTY triple means "build for this
208+
// machine", and only then is the host answer the correct one — so the caller
209+
// passes it in rather than this module reaching for mcpp::platform, which keeps
210+
// the decision testable from any host (and keeps this module dependency-free).
211+
inline ArtifactNaming artifact_naming(const Triple& t, const ArtifactNaming& hostNaming) {
212+
if (t.empty()) return hostNaming;
213+
214+
if (t.os == "windows") {
215+
// PE. The static-library convention splits on env, not on os.
216+
const bool msvc = t.is_msvc_env();
217+
return ArtifactNaming{
218+
.exeSuffix = ".exe",
219+
.libPrefix = msvc ? "" : "lib",
220+
.staticLibExt = msvc ? ".lib" : ".a",
221+
.sharedLibExt = ".dll",
222+
.sharedNeedsImportLib = true,
223+
};
224+
}
225+
if (t.os == "macos") {
226+
return ArtifactNaming{
227+
.exeSuffix = "", .libPrefix = "lib",
228+
.staticLibExt = ".a", .sharedLibExt = ".dylib",
229+
.sharedNeedsImportLib = false,
230+
};
231+
}
232+
if (t.os == "linux") {
233+
return ArtifactNaming{
234+
.exeSuffix = "", .libPrefix = "lib",
235+
.staticLibExt = ".a", .sharedLibExt = ".so",
236+
.sharedNeedsImportLib = false,
237+
};
238+
}
239+
// Outside the triple language: fall back to the host answer rather than
240+
// guessing. A wrong guess here silently misnames every artifact.
241+
return hostNaming;
242+
}
243+
176244
} // namespace mcpp::toolchain::triple
177245

178246
namespace mcpp::toolchain::triple {

tests/e2e/183_cross_no_relink.sh

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env bash
2+
# requires: mingw-cross
3+
#
4+
# A cross build must not relink on every invocation.
5+
#
6+
# plan.cppm's target_output() used to spell the artifact suffix from
7+
# mcpp::platform::exe_suffix — a HOST constant. Cross-compiling from Linux to
8+
# a PE target, that yields `bin/foo` while mingw's GCC driver writes
9+
# `bin/foo.exe`, so the file ninja was told to produce never appears. ninja
10+
# finds the declared output missing on every run and reruns the link edge
11+
# forever: incremental builds are effectively off for PE targets.
12+
#
13+
# The symptom is invisible to the other cross tests because they look for the
14+
# REAL artifact (`find -name '*.exe'`), not for what ninja declared — both of
15+
# their assertions hold while the inconsistency sits underneath them.
16+
#
17+
# See .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md.
18+
set -euo pipefail
19+
20+
TMP="$(mktemp -d)"
21+
trap 'rm -rf "$TMP"' EXIT
22+
cd "$TMP"
23+
24+
mkdir -p src
25+
cat > mcpp.toml <<'EOF'
26+
[package]
27+
name = "relinkprobe"
28+
version = "0.1.0"
29+
EOF
30+
31+
cat > src/main.cpp <<'EOF'
32+
int main() { return 0; }
33+
EOF
34+
35+
TRIPLE=x86_64-windows-gnu
36+
37+
"$MCPP" build --target "$TRIPLE" > "$TMP/build1.log" 2>&1 || {
38+
echo "first cross build failed:"; cat "$TMP/build1.log"; exit 1; }
39+
40+
# The produced artifact — whatever it is actually called.
41+
ART="$(find "target/$TRIPLE" -type f -path '*/bin/*' -name 'relinkprobe*' | head -1)"
42+
[[ -n "$ART" ]] || { echo "no artifact produced under target/$TRIPLE"; exit 1; }
43+
44+
# ── The declared ninja output must be the file that actually gets written ────
45+
# This is the root assertion. Positive grep on purpose: `! cmd | grep` is exempt
46+
# from errexit and can never fail.
47+
NINJA="$(find "target/$TRIPLE" -name build.ninja | head -1)"
48+
[[ -n "$NINJA" ]] || { echo "no build.ninja found"; exit 1; }
49+
50+
DECLARED="$(grep -oE '^build [^:]*bin/relinkprobe[^ :]*' "$NINJA" | head -1 | sed 's/^build //' | tr -d ' ')"
51+
[[ -n "$DECLARED" ]] || { echo "no link edge for relinkprobe in build.ninja"; exit 1; }
52+
53+
BUILDDIR="$(dirname "$NINJA")"
54+
if [[ ! -f "$BUILDDIR/$DECLARED" ]]; then
55+
echo "FAIL: ninja declares output '$DECLARED' but that file does not exist"
56+
echo " actually produced: $(basename "$ART")"
57+
echo " => the link edge can never be satisfied, so it reruns every build"
58+
exit 1
59+
fi
60+
61+
# ── And the observable consequence: a second build must not relink ──────────
62+
T1="$(stat -c %Y "$ART" 2>/dev/null || stat -f %m "$ART")"
63+
sleep 1 # ensure a relink would be visible at 1s mtime granularity
64+
"$MCPP" build --target "$TRIPLE" > "$TMP/build2.log" 2>&1 || {
65+
echo "second cross build failed:"; cat "$TMP/build2.log"; exit 1; }
66+
T2="$(stat -c %Y "$ART" 2>/dev/null || stat -f %m "$ART")"
67+
68+
if [[ "$T1" != "$T2" ]]; then
69+
echo "FAIL: artifact was relinked on an up-to-date rebuild ($T1 -> $T2)"
70+
echo " ninja declared: $DECLARED"
71+
echo " produced: $(basename "$ART")"
72+
exit 1
73+
fi
74+
75+
echo "OK: cross build declares the artifact it actually produces, and does not relink"
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#include <gtest/gtest.h>
2+
3+
import std;
4+
import mcpp.platform;
5+
import mcpp.toolchain.triple;
6+
7+
// How a built artifact is NAMED is a property of the TARGET, never of the
8+
// machine doing the build.
9+
//
10+
// mcpp used to answer it with mcpp::platform::{exe_suffix,lib_prefix,
11+
// static_lib_ext,shared_lib_ext} — host constants selected by
12+
// `#if defined(_WIN32)/__APPLE__`. On a host build the two questions coincide,
13+
// which is why this survived; it only diverges once host != target.
14+
//
15+
// The host answer is threaded in as a parameter precisely so these can be
16+
// pinned from any host: the `hostNaming` argument models "what the build
17+
// machine would have said", and every assertion below that passes a deliberately
18+
// wrong one is checking that the target answer wins.
19+
20+
namespace {
21+
22+
namespace tr = mcpp::toolchain::triple;
23+
24+
// A deliberately WRONG host answer. If any assertion below leaks through to it,
25+
// the target axis is not being honoured.
26+
constexpr tr::ArtifactNaming kBogusHost{
27+
.exeSuffix = ".HOST", .libPrefix = "HOST", .staticLibExt = ".HOST",
28+
.sharedLibExt = ".HOST", .sharedNeedsImportLib = false,
29+
};
30+
31+
tr::Triple T(std::string_view s) {
32+
auto t = tr::parse(s);
33+
return t ? *t : tr::Triple{};
34+
}
35+
36+
// ── The regression: naming must not come from the build host ────────────────
37+
38+
TEST(ArtifactNaming, LinuxTargetIgnoresHostAnswer) {
39+
auto n = tr::artifact_naming(T("x86_64-linux-musl"), kBogusHost);
40+
EXPECT_EQ(n.exeSuffix, "");
41+
EXPECT_EQ(n.libPrefix, "lib");
42+
EXPECT_EQ(n.staticLibExt, ".a");
43+
EXPECT_EQ(n.sharedLibExt, ".so");
44+
EXPECT_FALSE(n.sharedNeedsImportLib);
45+
}
46+
47+
TEST(ArtifactNaming, MacosTargetIgnoresHostAnswer) {
48+
auto n = tr::artifact_naming(T("aarch64-macos"), kBogusHost);
49+
EXPECT_EQ(n.exeSuffix, "");
50+
EXPECT_EQ(n.libPrefix, "lib");
51+
EXPECT_EQ(n.staticLibExt, ".a");
52+
EXPECT_EQ(n.sharedLibExt, ".dylib");
53+
EXPECT_FALSE(n.sharedNeedsImportLib);
54+
}
55+
56+
// ── The (os, env) split — the part a single _WIN32 branch cannot express ─────
57+
//
58+
// windows-gnu uses the GNU convention. mcpp names this `foo.lib` today even on
59+
// a Windows host, so mingw's `ar` emits a GNU archive wearing an MSVC name.
60+
// That is a pre-existing defect, independent of cross compilation.
61+
TEST(ArtifactNaming, WindowsGnuUsesGnuConvention) {
62+
auto n = tr::artifact_naming(T("x86_64-windows-gnu"), kBogusHost);
63+
EXPECT_EQ(n.exeSuffix, ".exe");
64+
EXPECT_EQ(n.libPrefix, "lib"); // NOT ""
65+
EXPECT_EQ(n.staticLibExt, ".a"); // NOT ".lib"
66+
EXPECT_EQ(n.sharedLibExt, ".dll");
67+
EXPECT_TRUE(n.sharedNeedsImportLib);
68+
}
69+
70+
TEST(ArtifactNaming, WindowsMsvcUsesMsvcConvention) {
71+
auto n = tr::artifact_naming(T("x86_64-windows-msvc"), kBogusHost);
72+
EXPECT_EQ(n.exeSuffix, ".exe");
73+
EXPECT_EQ(n.libPrefix, "");
74+
EXPECT_EQ(n.staticLibExt, ".lib");
75+
EXPECT_EQ(n.sharedLibExt, ".dll");
76+
EXPECT_TRUE(n.sharedNeedsImportLib);
77+
}
78+
79+
// The legacy mingw spelling must resolve identically to the canonical one —
80+
// the triple parser is the single source of truth, not a substring match.
81+
TEST(ArtifactNaming, LegacyMingwSpellingMatchesCanonical) {
82+
auto legacy = tr::artifact_naming(T("x86_64-w64-mingw32"), kBogusHost);
83+
auto canonical = tr::artifact_naming(T("x86_64-windows-gnu"), kBogusHost);
84+
EXPECT_EQ(legacy.exeSuffix, canonical.exeSuffix);
85+
EXPECT_EQ(legacy.libPrefix, canonical.libPrefix);
86+
EXPECT_EQ(legacy.staticLibExt, canonical.staticLibExt);
87+
EXPECT_EQ(legacy.sharedLibExt, canonical.sharedLibExt);
88+
}
89+
90+
// ── Host target: the one case where the host answer IS correct ──────────────
91+
92+
TEST(ArtifactNaming, EmptyTripleFallsBackToHost) {
93+
auto n = tr::artifact_naming(tr::Triple{}, kBogusHost);
94+
EXPECT_EQ(n.exeSuffix, ".HOST");
95+
EXPECT_EQ(n.libPrefix, "HOST");
96+
}
97+
98+
// An unparseable triple must not be guessed at — a wrong guess here silently
99+
// misnames every artifact of the build.
100+
TEST(ArtifactNaming, UnknownOsFallsBackToHost) {
101+
tr::Triple wasm; wasm.arch = "wasm32"; wasm.os = "unknown";
102+
auto n = tr::artifact_naming(wasm, kBogusHost);
103+
EXPECT_EQ(n.exeSuffix, ".HOST");
104+
EXPECT_EQ(n.staticLibExt, ".HOST");
105+
}
106+
107+
// ── Host builds must be bit-for-bit unchanged ───────────────────────────────
108+
// Passing the real host constants for the host target has to reproduce exactly
109+
// what the old code produced on this machine.
110+
TEST(ArtifactNaming, RealHostConstantsRoundTrip) {
111+
const tr::ArtifactNaming host{
112+
.exeSuffix = mcpp::platform::exe_suffix,
113+
.libPrefix = mcpp::platform::lib_prefix,
114+
.staticLibExt = mcpp::platform::static_lib_ext,
115+
.sharedLibExt = mcpp::platform::shared_lib_ext,
116+
.sharedNeedsImportLib = mcpp::platform::is_windows,
117+
};
118+
auto n = tr::artifact_naming(tr::Triple{}, host);
119+
EXPECT_EQ(n.exeSuffix, mcpp::platform::exe_suffix);
120+
EXPECT_EQ(n.libPrefix, mcpp::platform::lib_prefix);
121+
EXPECT_EQ(n.staticLibExt, mcpp::platform::static_lib_ext);
122+
EXPECT_EQ(n.sharedLibExt, mcpp::platform::shared_lib_ext);
123+
}
124+
125+
} // namespace

0 commit comments

Comments
 (0)