diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 8055e0d3..d46faacd 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -64,6 +64,9 @@ std::unique_ptr make_ninja_backend(); std::string emit_ninja_string(const BuildPlan& plan); std::string filter_ninja_output(std::string_view output, std::span commandPrefixes); +std::string compiler_launcher_contents(const std::filesystem::path& loader, + const std::filesystem::path& compiler, + const std::vector& dirs); // Advice appended to a failed build whose linker output names a replaceable // function nothing in the graph defines. Empty when there is nothing to add. @@ -81,6 +84,21 @@ std::string link_failure_advice(std::string_view output); namespace mcpp::build { +std::string compiler_launcher_contents(const std::filesystem::path& loader, + const std::filesystem::path& compiler, + const std::vector& dirs) { + std::string libraryPath; + for (auto const& dir : dirs) { + if (!libraryPath.empty()) libraryPath += ':'; + libraryPath += dir.string(); + } + return std::format( + "#!/bin/sh\nexec {} --library-path {} {} \"$@\"\n", + mcpp::platform::shell::quote(loader.string()), + mcpp::platform::shell::quote(libraryPath), + mcpp::platform::shell::quote(compiler.string())); +} + namespace { std::string escape_ninja_path(const std::filesystem::path& p) { @@ -281,6 +299,33 @@ void write_file(const std::filesystem::path& p, std::string_view content) { os << content; } +const std::vector& compiler_invocation_dirs(const BuildPlan& plan) { + return plan.toolchain.compilerInvocationRuntimeDirs.empty() + ? plan.toolchain.compilerRuntimeDirs + : plan.toolchain.compilerInvocationRuntimeDirs; +} + +bool needs_compiler_launcher(const BuildPlan& plan) { + return mcpp::platform::is_linux + && plan.toolchain.compiler == mcpp::toolchain::CompilerId::GCC + && !plan.toolchain.compilerInvocationLoader.empty(); +} + +std::filesystem::path compiler_launcher_path(const BuildPlan& plan, bool cxx) { + return plan.outputDir / (cxx ? "mcpp-cxx" : "mcpp-cc"); +} + +void write_compiler_launcher(const std::filesystem::path& path, + const std::filesystem::path& loader, + const std::filesystem::path& compiler, + const std::vector& dirs) { + write_file(path, compiler_launcher_contents(loader, compiler, dirs)); + + std::error_code ec; + std::filesystem::permissions(path, std::filesystem::perms::owner_exec, + std::filesystem::perm_options::add, ec); +} + bool run(const std::string& cmd, std::string& output_capture, bool capture_output = true) { output_capture.clear(); if (capture_output) { @@ -384,6 +429,10 @@ std::vector command_prefixes(const CompileFlags& flags, }; add(flags.cxxBinary); add(flags.ccBinary); + if (needs_compiler_launcher(plan)) { + add(compiler_launcher_path(plan, /*cxx=*/true)); + add(compiler_launcher_path(plan, /*cxx=*/false)); + } add(flags.arBinary); add(plan.scanDepsPath); // mcpp itself drives the dyndep and stage_file rules; its echoed command @@ -589,10 +638,19 @@ std::string emit_ninja_string(const BuildPlan& plan) { // The macOS initializer-ordering shim (#336) is a C translation unit, so // it needs the C driver bindings even in a project with no .c sources. const bool need_ios_init_shim = flags.needsStreamInitShim; - append(std::format("cxx = {}\n", escape_ninja_path(flags.cxxBinary))); + auto compiler_command = [&](const std::filesystem::path& binary, bool cxx) { + if (needs_compiler_launcher(plan)) + return shell_quote_arg(escape_ninja_chars( + compiler_launcher_path(plan, cxx).string())); + return escape_ninja_path(binary); + }; + append(std::format("cxx = {}\n", compiler_command(flags.cxxBinary, /*cxx=*/true))); + // clang-scan-deps receives the driver after `--` as argv, not as a shell + // command. Keep a raw path for that interface; `$cxx` may be a launcher. + append(std::format("cxx_driver = {}\n", escape_ninja_path(flags.cxxBinary))); append(std::format("cxxflags = {}\n", flags.cxx)); if (need_c_rule || need_asm_rule || need_ios_init_shim) { // asm_object drives the C compiler too - append(std::format("cc = {}\n", escape_ninja_path(flags.ccBinary))); + append(std::format("cc = {}\n", compiler_command(flags.ccBinary, /*cxx=*/false))); } if (need_c_rule || need_ios_init_shim) { append(std::format("cflags = {}\n", flags.cc)); @@ -1267,7 +1325,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { // overruns (#261: 48 -I entries at a deep consumer path). append(std::format( " command = $scan_deps -format=p1689 -o $out -- " - "$cxx{} $cxxflags $unit_cxxflags $unit_lang -c $in " + "$cxx_driver{} $cxxflags $unit_cxxflags $unit_lang -c $in " "-o $compile_target\n", rsp_ref(scanPayload))); } @@ -2271,10 +2329,24 @@ std::expected NinjaBackend::build(const BuildPlan& plan plan.outputDir.string(), ec.message()), plan.outputDir}); + auto flags = compute_flags(plan); + stage("compute-flags"); + auto ninja_path = plan.outputDir / "build.ninja"; auto manifest = emit_ninja_string(plan); stage("emit-ninja"); + if (needs_compiler_launcher(plan)) { + const auto& dirs = compiler_invocation_dirs(plan); + write_compiler_launcher(compiler_launcher_path(plan, /*cxx=*/true), + plan.toolchain.compilerInvocationLoader, + flags.cxxBinary, dirs); + write_compiler_launcher(compiler_launcher_path(plan, /*cxx=*/false), + plan.toolchain.compilerInvocationLoader, + flags.ccBinary, dirs); + } + stage("write-compiler-launcher"); + // Command-length backstop (see // .agents/docs/2026-08-06-command-length-architecture.md). The structural // defence is that every unbounded payload goes through a response file; @@ -2289,8 +2361,6 @@ std::expected NinjaBackend::build(const BuildPlan& plan stage("write-ninja"); // compile_commands.json — via the dedicated module. - auto flags = compute_flags(plan); - stage("compute-flags"); auto cdb = write_compile_commands(plan, flags); stage("compile-commands"); if (!cdb) { diff --git a/src/toolchain/detect.cppm b/src/toolchain/detect.cppm index 332fd5c3..76c5237a 100644 --- a/src/toolchain/detect.cppm +++ b/src/toolchain/detect.cppm @@ -59,6 +59,12 @@ detect(const std::filesystem::path& explicit_compiler, } tc.compilerRuntimeDirs = discover_compiler_runtime_dirs(tc.binaryPath); + tc.compilerInvocationRuntimeDirs = + discover_compiler_invocation_runtime_dirs(tc.binaryPath, runtimeBinding); + auto loader = discover_compiler_invocation_loader(tc.binaryPath, runtimeBinding); + if (!loader) return std::unexpected(loader.error()); + if (*loader) + tc.compilerInvocationLoader = std::move(**loader); auto envPrefix = compiler_env_prefix(tc); auto ver_r = run_capture(std::format("{}{} --version 2>&1", diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index d9b0711f..fbcd83cb 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -100,12 +100,24 @@ struct Toolchain { // zero-libc tier and on hosted targets. std::string targetSysrootPkg; std::filesystem::path targetSysrootLib; - std::vector compilerRuntimeDirs; // LD_LIBRARY_PATH for private tools + // Runtime directories inherited by Ninja and its whole process tree. Keep + // private libc out of this list: Ninja launches each edge through /bin/sh. + std::vector compilerRuntimeDirs; + // Directories required when starting a compiler driver. For native managed + // GCC on Linux, the private loader below consumes these after Ninja's + // shell has already started. + std::vector compilerInvocationRuntimeDirs; + // The private ELF loader for a native managed GCC and its bound glibc. + // Empty for every other toolchain: they retain their ordinary driver + // invocation. Keeping this separate from the runtime directories matters + // because an LD_LIBRARY_PATH export reaches GCC's host as/ld children. + std::filesystem::path compilerInvocationLoader; std::vector linkRuntimeDirs; // -L/-rpath dirs for produced binaries // Environment the toolchain's tools need when invoked (set on the ninja - // process, inherited by compiler/linker children). Empty for GCC/Clang - // (their LD_LIBRARY_PATH need goes through compilerRuntimeDirs); the - // MSVC backend fills INCLUDE/LIB/PATH here (design §5.1). + // process, inherited by compiler/linker children). Empty for GCC/Clang; + // native managed GCC uses compilerInvocationLoader instead of exporting + // its bound glibc. The MSVC backend fills INCLUDE/LIB/PATH here (design + // §5.1). // (Own struct, not std::pair — GCC 16 modules choke on a std::pair // member added to this exported class: "failed to load pendings".) std::vector envOverrides; diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index 3fe41a57..a9b47fb8 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -33,6 +33,14 @@ std::string normalize_driver_output(std::string_view s); std::vector discover_compiler_runtime_dirs(const std::filesystem::path& compilerBin); +std::vector +discover_compiler_invocation_runtime_dirs(const std::filesystem::path& compilerBin, + std::string_view runtimeBinding); + +std::expected, DetectError> +discover_compiler_invocation_loader(const std::filesystem::path& compilerBin, + std::string_view runtimeBinding); + std::vector discover_link_runtime_dirs(const std::filesystem::path& compilerBin, std::string_view targetTriple); @@ -95,8 +103,24 @@ std::string env_prefix_for_dirs(const std::vector& dirs) return mcpp::platform::linux_::build_clean_ld_library_path_prefix(dirs); } +bool is_native_managed_gcc(const std::filesystem::path& compilerBin) { + // Managed compiler paths are /xim-x-gcc//bin/g++. + // Cross compilers also accept a glibc runtime binding for their target, + // but their host-side driver must not load that private libc. + const auto packageVersion = compilerBin.parent_path().parent_path(); + return packageVersion.parent_path().filename() == "xim-x-gcc"; +} + +bool is_elf_loader_name(std::string_view name) { + return name.starts_with("ld-linux-") && name.find(".so") != std::string_view::npos; +} + } // namespace +std::optional +payload_root_for_binding(const std::filesystem::path& compilerBin, + std::string_view binding); + std::expected run_capture(const std::string& cmd) { auto r = mcpp::platform::process::capture_host_tool(cmd); if (r.exit_code != 0 && r.output.empty()) { @@ -205,9 +229,84 @@ discover_compiler_runtime_dirs(const std::filesystem::path& compilerBin) { append_existing_unique(dirs, *rt / "lib64"); append_existing_unique(dirs, *rt / "lib"); } + return dirs; } +std::vector +discover_compiler_invocation_runtime_dirs(const std::filesystem::path& compilerBin, + std::string_view runtimeBinding) { + auto dirs = discover_compiler_runtime_dirs(compilerBin); + + // A managed compiler's DT_RUNPATH reaches its bound private libc for its + // own direct dependencies only. A host /etc/ld.so.preload library can + // require libdl.so.2 itself, where that non-transitive RUNPATH cannot help. + // Use the resolved binding rather than scanning installed glibc versions: + // the selected payload is an ABI decision, not a directory-order choice. + if constexpr (mcpp::platform::is_linux) { + if (runtimeBinding.starts_with("glibc@") + && is_native_managed_gcc(compilerBin)) { + if (auto glibc = payload_root_for_binding(compilerBin, runtimeBinding)) { + append_existing_unique(dirs, *glibc / "lib64"); + append_existing_unique(dirs, *glibc / "lib"); + } + } + } + return dirs; +} + +std::expected, DetectError> +discover_compiler_invocation_loader(const std::filesystem::path& compilerBin, + std::string_view runtimeBinding) { + if constexpr (!mcpp::platform::is_linux) { + return std::optional{}; + } + + if (!runtimeBinding.starts_with("glibc@") || !is_native_managed_gcc(compilerBin)) + return std::optional{}; + + auto glibc = payload_root_for_binding(compilerBin, runtimeBinding); + if (!glibc) { + return std::unexpected(DetectError{std::format( + "native managed GCC '{}' requires bound {} but its payload is unavailable", + compilerBin.string(), std::string(runtimeBinding))}); + } + + std::vector candidates; + std::error_code ec; + for (auto const& dir : {*glibc / "lib64", *glibc / "lib"}) { + if (!std::filesystem::is_directory(dir, ec)) { + ec.clear(); + continue; + } + for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; + it.increment(ec)) { + const auto& candidate = it->path(); + if (!is_elf_loader_name(candidate.filename().string()) + || !std::filesystem::is_regular_file(candidate, ec)) { + ec.clear(); + continue; + } + auto resolved = std::filesystem::weakly_canonical(candidate, ec); + if (ec) { + ec.clear(); + resolved = std::filesystem::absolute(candidate, ec); + if (ec) resolved = candidate; + } + if (std::find(candidates.begin(), candidates.end(), resolved) == candidates.end()) + candidates.push_back(std::move(resolved)); + } + ec.clear(); + } + + if (candidates.size() != 1) { + return std::unexpected(DetectError{std::format( + "native managed GCC '{}' requires exactly one ld-linux-*.so* in bound {} payload; found {}", + compilerBin.string(), std::string(runtimeBinding), candidates.size())}); + } + return std::optional{candidates.front()}; +} + std::vector discover_link_runtime_dirs(const std::filesystem::path& compilerBin, std::string_view targetTriple) { @@ -231,7 +330,13 @@ discover_link_runtime_dirs(const std::filesystem::path& compilerBin, } std::string compiler_env_prefix(const Toolchain& tc) { - return env_prefix_for_dirs(tc.compilerRuntimeDirs); + if (!tc.compilerInvocationLoader.empty()) { + return std::format("{} --library-path {} ", + mcpp::xlings::shq(tc.compilerInvocationLoader.string()), + mcpp::xlings::shq(join_colon_paths(tc.compilerInvocationRuntimeDirs))); + } + return env_prefix_for_dirs(tc.compilerInvocationRuntimeDirs.empty() + ? tc.compilerRuntimeDirs : tc.compilerInvocationRuntimeDirs); } std::expected diff --git a/tests/e2e/188_build_actions.sh b/tests/e2e/188_build_actions.sh index 1d88f0c4..f60ef434 100755 --- a/tests/e2e/188_build_actions.sh +++ b/tests/e2e/188_build_actions.sh @@ -279,8 +279,11 @@ EOF printf 'int main() { return 0; }\n' > src/main.cpp "$MCPP" build > o0.log 2>&1 || { cat o0.log; echo "FAIL: probe build failed"; exit 1; } OBJ_NINJA=$(find target -name build.ninja | head -1) -OBJ_CXX=$(sed -n 's/^cxx *= *//p' "$OBJ_NINJA" | head -1) -[ -n "$OBJ_CXX" ] || { cat "$OBJ_NINJA"; echo "FAIL: could not read the compiler out of build.ninja"; exit 1; } +# `cxx` may be a fingerprint-scoped launcher. This action is deliberately +# retained after that build directory is removed below, so use the stable raw +# driver exported for clang-scan-deps instead. +OBJ_CXX=$(sed -n 's/^cxx_driver *= *//p' "$OBJ_NINJA" | head -1) +[ -n "$OBJ_CXX" ] || { cat "$OBJ_NINJA"; echo "FAIL: could not read the compiler driver out of build.ninja"; exit 1; } cat > src/main.cpp <<'EOF' #include diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 76a07180..22c716bc 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -791,10 +791,87 @@ TEST(NinjaBackend, ClangScanRuleWritesViaDashOWithoutShellRedirection) { auto rule = ninja.substr(scanRule, scanEnd - scanRule); EXPECT_NE(rule.find("-format=p1689 -o $out --"), std::string::npos) << rule; + EXPECT_NE(rule.find("-- $cxx_driver"), std::string::npos) << rule; + EXPECT_EQ(rule.find("-- $cxx "), std::string::npos) << rule; EXPECT_EQ(rule.find("> $out"), std::string::npos) << "scan rule must not use shell redirection: " << rule; } +TEST(NinjaBackend, ClangKeepsItsRawDriverWhenItHasRuntimeDirectories) { + auto plan = minimal_plan(); + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.toolchain.binaryPath = "/opt/xim-x-llvm/bin/clang++"; + plan.toolchain.compilerInvocationRuntimeDirs = {"/opt/xim-x-llvm/lib"}; + plan.scanDepsPath = "/opt/xim-x-llvm/bin/clang-scan-deps"; + plan.compileUnits.push_back({ + .source = "src/m.cppm", + .kind = mcpp::SourceKind::ModuleInterface, + .object = "obj/m.o", + .packageName = "objc_rule_test", + .providesModule = "m", + }); + + auto ninja = emit_ninja_string(plan); + + EXPECT_NE(ninja.find("cxx = /opt/xim-x-llvm/bin/clang++"), std::string::npos) + << ninja; + EXPECT_EQ(ninja.find("mcpp-cxx"), std::string::npos) << ninja; + EXPECT_NE(ninja.find("cxx_driver = /opt/xim-x-llvm/bin/clang++"), std::string::npos) + << ninja; + auto scanRule = ninja.find("rule cxx_scan"); + ASSERT_NE(scanRule, std::string::npos) << ninja; + auto scanEnd = ninja.find("description = SCAN", scanRule); + ASSERT_NE(scanEnd, std::string::npos) << ninja; + auto rule = ninja.substr(scanRule, scanEnd - scanRule); + EXPECT_NE(rule.find("-- $cxx_driver"), std::string::npos) << rule; + EXPECT_EQ(rule.find("LD_LIBRARY_PATH"), std::string::npos) << rule; +} + +TEST(NinjaBackend, PrivateGccLauncherUsesItsLoaderWithoutExportingLibraryPath) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "private ELF loaders are Linux-only"; + + auto script = compiler_launcher_contents( + "/opt/xim-x-glibc/2.44/lib/ld-linux-x86-64.so.2", + "/opt/xim-x-gcc/16.1.0/bin/g++", + {"/opt/xim-x-glibc/2.44/lib", "/opt/xim-x-gcc/16.1.0/lib64"}); + + EXPECT_NE(script.find("exec '/opt/xim-x-glibc/2.44/lib/ld-linux-x86-64.so.2' --library-path "), + std::string::npos) << script; + EXPECT_NE(script.find("'/opt/xim-x-glibc/2.44/lib:/opt/xim-x-gcc/16.1.0/lib64' "), + std::string::npos) << script; + EXPECT_NE(script.find("'/opt/xim-x-gcc/16.1.0/bin/g++' \"$@\""), std::string::npos) + << script; + EXPECT_EQ(script.find("LD_LIBRARY_PATH"), std::string::npos) << script; +} + +TEST(NinjaBackend, PrivateGccLauncherPathIsNinjaEscapedThenShellQuoted) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "private ELF loaders are Linux-only"; + + auto plan = minimal_plan(); + plan.outputDir = "/tmp/mcpp output$dir:with'quote"; + plan.toolchain.compilerInvocationLoader = + "/opt/xim-x-glibc/2.44/lib/ld-linux-x86-64.so.2"; + plan.toolchain.compilerInvocationRuntimeDirs = {"/opt/xim-x-glibc/2.44/lib"}; + plan.compileUnits.push_back({ + .source = "src/main.c", + .kind = mcpp::SourceKind::C, + .object = "obj/main.o", + .packageName = "objc_rule_test", + }); + + auto ninja = emit_ninja_string(plan); + auto expectedCxx = shell_quote_arg(escape_ninja_chars( + (plan.outputDir / "mcpp-cxx").string())); + auto expectedCc = shell_quote_arg(escape_ninja_chars( + (plan.outputDir / "mcpp-cc").string())); + + EXPECT_NE(ninja.find("cxx = " + expectedCxx), std::string::npos) << ninja; + EXPECT_NE(ninja.find("cc = " + expectedCc), std::string::npos) << ninja; + EXPECT_EQ(ninja.find("LD_LIBRARY_PATH"), std::string::npos) << ninja; +} + // The `cmd /c` wrapper was the only place mcpp put a shell between ninja and // a compiler invocation. Keep it gone: it is what re-imposes the 8191 ceiling. TEST(NinjaBackend, NoRuleWrapsItsCommandInCmdSlashC) { diff --git a/tests/unit/test_toolchain_detect.cpp b/tests/unit/test_toolchain_detect.cpp index eac7407f..3c171b7b 100644 --- a/tests/unit/test_toolchain_detect.cpp +++ b/tests/unit/test_toolchain_detect.cpp @@ -93,6 +93,87 @@ TEST(ToolchainDetect, IgnoresTargetRuntimeLibraryPathDuringProbe) { EXPECT_EQ(tc->compiler, CompilerId::Clang); EXPECT_EQ(tc->targetTriple, "x86_64-unknown-linux-gnu"); } + +TEST(ToolchainDetect, NativeGccUsesTheBoundGlibcLoaderForProbes) { + auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_compiler_runtime_{}", std::random_device{}()); + TempDirGuard cleanup{root}; + + auto xpkgs = root / "registry" / "data" / "xpkgs"; + auto compiler = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; + auto wanted = xpkgs / "xim-x-glibc" / "2.44" / "lib"; + auto other = xpkgs / "xim-x-glibc" / "2.39" / "lib64"; + std::filesystem::create_directories(compiler.parent_path()); + std::filesystem::create_directories(wanted); + std::filesystem::create_directories(other); + + auto loader = wanted / "ld-linux-x86-64.so.2"; + auto trace = root / "loader.trace"; + std::ofstream loaderOs(loader); + loaderOs << R"(#!/usr/bin/env bash +if [[ "$1" != "--library-path" || "$2" != ")" << wanted.string() << R"(" ]]; then + echo "bad loader invocation: $*" >&2 + exit 127 +fi +printf '%s\n' "$*" >> ")" << trace.string() << R"(" +shift 2 +exec env -u LD_LIBRARY_PATH "$@" +)"; + loaderOs.close(); + std::filesystem::permissions( + loader, + std::filesystem::perms::owner_exec + | std::filesystem::perms::owner_read + | std::filesystem::perms::owner_write); + + std::ofstream os(compiler); + os << R"(#!/usr/bin/env bash +if [[ -n "${LD_LIBRARY_PATH:-}" ]]; then + echo "probe inherited LD_LIBRARY_PATH" >&2 + exit 127 +fi +case "$1" in + --version) echo "g++ (mcpp test) 16.1.0" ;; + -dumpmachine) echo "x86_64-linux-gnu" ;; +esac +)"; + os.close(); + std::filesystem::permissions( + compiler, + std::filesystem::perms::owner_exec + | std::filesystem::perms::owner_read + | std::filesystem::perms::owner_write); + + auto tc = detect(compiler, "glibc@2.44"); + ASSERT_TRUE(tc.has_value()) << tc.error().message; + EXPECT_EQ(tc->compilerInvocationLoader, loader); + EXPECT_NE(std::find(tc->compilerInvocationRuntimeDirs.begin(), + tc->compilerInvocationRuntimeDirs.end(), wanted), + tc->compilerInvocationRuntimeDirs.end()); + EXPECT_EQ(std::find(tc->compilerInvocationRuntimeDirs.begin(), + tc->compilerInvocationRuntimeDirs.end(), other), + tc->compilerInvocationRuntimeDirs.end()); + EXPECT_EQ(std::find(tc->compilerRuntimeDirs.begin(), tc->compilerRuntimeDirs.end(), wanted), + tc->compilerRuntimeDirs.end()); + EXPECT_EQ(compiler_env_prefix(*tc).find("LD_LIBRARY_PATH"), std::string::npos); + + std::ifstream traceIn(trace); + std::string traceText((std::istreambuf_iterator(traceIn)), {}); + EXPECT_NE(traceText.find("--version"), std::string::npos) << traceText; + EXPECT_NE(traceText.find("-dumpmachine"), std::string::npos) << traceText; + EXPECT_NE(traceText.find("-print-sysroot"), std::string::npos) << traceText; + + auto mingw = xpkgs / "xim-x-mingw-cross-gcc" / "16.1.0" / "bin" + / "x86_64-w64-mingw32-g++"; + std::filesystem::create_directories(mingw.parent_path()); + std::ofstream(mingw).close(); + + auto mingwDirs = discover_compiler_invocation_runtime_dirs(mingw, "glibc@2.44"); + EXPECT_EQ(std::find(mingwDirs.begin(), mingwDirs.end(), wanted), mingwDirs.end()); + auto mingwLoader = discover_compiler_invocation_loader(mingw, "glibc@2.44"); + ASSERT_TRUE(mingwLoader.has_value()) << mingwLoader.error().message; + EXPECT_FALSE(mingwLoader->has_value()); +} #endif // defined(__linux__) // ─── normalize_driver_output: path-free semantic identity ─────────────