From 8e3fd3a5730613e511cd8bc941363f0c967152d7 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 17 Jul 2026 18:37:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(build):=20build.mcpp=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=20=E2=80=94=20=E7=8E=AF=E5=A2=83=E5=A5=91=E7=BA=A6=20+=20cross?= =?UTF-8?q?=20=E4=B8=8B=E8=BF=90=E8=A1=8C=20+=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=8C=85=E6=89=A7=E8=A1=8C=20(0.0.95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计: .agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md (PR-C) G3 环境契约 + cross: - 注入 MCPP_TARGET/HOST/PROFILE/OUT_DIR/MANIFEST_DIR/MCPP_FEATURE_/ MCPP_FEATURES(仅子进程,capture_exec env);契约值无条件折入重跑键 (cache 新增 ctx 行,旧缓存缺行自动失效一次) - cross --target 不再跳过:宿主工具链按同一 spec 词汇独立解析(不注入 target 轴),懒解析且仅当存在 build.mcpp;typed `import mcpp;` 模块补 读取端 target()/host()/profile()/out_dir()/manifest_dir()/has_feature() - feature 闭包收敛为单一 feature_closure 实现(原三处重复);profile 名提升 为 effectiveProfile 供契约使用;--features 解析共享 parse_feature_request G2 依赖包 build.mcpp(Cargo build.rs 模型): - 时机:feature 激活后、modgraph 扫描前;作用域照 Cargo:cfg/flags 只进该包 自身 TU(镜像进 privateBuild,与 MCPP_FEATURE_ 宏同款),link 指令补传终链 (BFS 传播已过,追加新尾部) - 产物全部项目本地化:target/.build-mcpp/deps/@/,registry 包根 绝不写入;相对 generated= 以 OUT_DIR 为基;scanner 支持绝对路径字面源条目 - 顺修潜在盲区:generated= 只推 bc.sources,scanner 走 modules.sources 镜像 ——现在双推(此前靠默认 glob 兜住) 版本 bump 0.0.95(mcpp.toml + fingerprint.cppm 同步);docs/07 中英补契约与 依赖节;e2e 110(契约值/缓存/feature+profile 重跑)/111(dep 作用域/终链/ registry 零写入)/112(cross + wine 全环);单测 35/35,e2e 112 过(3 失败 均既有环境性 22/54/62) --- docs/07-build-mcpp.md | 41 +++- docs/zh/07-build-mcpp.md | 35 +++- mcpp.toml | 2 +- src/build/build_program.cppm | 166 +++++++++++++---- src/build/prepare.cppm | 226 ++++++++++++++++++----- src/modgraph/scanner.cppm | 8 + src/toolchain/fingerprint.cppm | 2 +- tests/e2e/110_build_mcpp_env_contract.sh | 96 ++++++++++ tests/e2e/111_dep_build_mcpp.sh | 87 +++++++++ tests/e2e/112_build_mcpp_cross.sh | 63 +++++++ 10 files changed, 635 insertions(+), 91 deletions(-) create mode 100755 tests/e2e/110_build_mcpp_env_contract.sh create mode 100755 tests/e2e/111_dep_build_mcpp.sh create mode 100755 tests/e2e/112_build_mcpp_cross.sh diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 7fdd25fa..ab3ebd42 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -92,6 +92,37 @@ If your `build.mcpp` also needs to *write* a generated file, mix in a textual stdout protocol above remains the low-level substrate; `import mcpp;` is the typed layer over it. +## Environment contract (mcpp 0.0.95+) + +The running program receives the build context as `MCPP_*` variables +(Cargo's env-family equivalent), also exposed through typed readers: + +| Variable | Typed reader | Value | +|---|---|---| +| `MCPP_TARGET` | `mcpp::target()` | resolved canonical triple (the `--target` triple under cross; the host triple natively) | +| `MCPP_HOST` | `mcpp::host()` | the host triple | +| `MCPP_PROFILE` | `mcpp::profile()` | effective profile name (`dev`/`release`/…) | +| `MCPP_OUT_DIR` | `mcpp::out_dir()` | a writable scratch/output dir owned by mcpp | +| `MCPP_MANIFEST_DIR` | `mcpp::manifest_dir()` | the package root (= CWD) | +| `MCPP_FEATURE_` | `mcpp::has_feature("name")` | set to `1` per active feature (same `` sanitization as the `MCPP_FEATURE_` compile macro) | +| `MCPP_FEATURES` | — | comma-separated active feature list | + +These values are folded into the re-run key **unconditionally** — changing the +target, profile, or feature set re-runs the program without any +`rerun-if-env-changed` declaration. + +## Dependencies' build.mcpp (mcpp 0.0.95+) + +A dependency that ships a `build.mcpp` gets it compiled and run too (the +Cargo `build.rs` model — building a package means trusting its build program), +after its features are resolved and before the source scan. Scope follows +Cargo: `cxxflag`/`cflag`/`cfg` directives color **only that package's own +TUs**; `link-lib`/`link-search` reach the final link. Its artifacts (binary, +cache, `MCPP_OUT_DIR`) live in the **consuming project's** +`target/.build-mcpp/deps/@/` — a registry package root is shared +across projects (and may be read-only), so it is never written to; relative +`generated=` paths resolve against `MCPP_OUT_DIR`, not the package root. + ## Incremental: declared inputs (no needless re-runs) mcpp does **not** re-run `build.mcpp` on every build. It caches the program's @@ -113,11 +144,11 @@ When nothing changed you'll see `build.mcpp up to date (cached)`; otherwise ## Notes & limits -- **Runs on the host.** `build.mcpp` compiles and runs with the host toolchain. - Under a cross build (`mcpp build --target `) it is **skipped with a - warning** for now (host-toolchain-for-cross is a planned follow-up). Gate - *dependencies* on the target with `[target.'cfg(...)']` tables instead — those - evaluate on the resolved target. See [05 - mcpp.toml Manifest Guide](05-mcpp-toml.md). +- **Runs on the host — including under cross** (mcpp 0.0.95+). Under + `mcpp build --target ` the program is compiled with a host-resolved + toolchain, runs on the host, and sees `MCPP_TARGET` = the cross triple. + For purely declarative target gating, `[target.'cfg(...)']` tables remain + the first choice — see [05 - mcpp.toml Manifest Guide](05-mcpp-toml.md). - **CWD is the project root**, so relative paths (`src/generated.cpp`) land where you expect. - A non-zero exit from `build.mcpp` aborts the build and prints its output. diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 4ef7614e..5b98ca99 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -86,6 +86,33 @@ int main() { 只有 `import std;` 是不必要的。上面的裸 stdout 协议仍是底层基底;`import mcpp;` 是其上的 类型化层。 +## 环境契约(mcpp 0.0.95+) + +运行中的程序以 `MCPP_*` 环境变量得到构建上下文(对应 Cargo 的环境变量族), +也有类型化读取端: + +| 变量 | 类型化读取 | 值 | +|---|---|---| +| `MCPP_TARGET` | `mcpp::target()` | 解析后的 canonical 三元组(交叉构建下是 `--target` 三元组,原生构建是宿主) | +| `MCPP_HOST` | `mcpp::host()` | 宿主三元组 | +| `MCPP_PROFILE` | `mcpp::profile()` | 生效 profile 名(`dev`/`release`/…) | +| `MCPP_OUT_DIR` | `mcpp::out_dir()` | mcpp 提供的可写输出/暂存目录 | +| `MCPP_MANIFEST_DIR` | `mcpp::manifest_dir()` | 包根(= CWD) | +| `MCPP_FEATURE_` | `mcpp::has_feature("name")` | 每个活跃 feature 置 `1`(`` 消毒规则与 `MCPP_FEATURE_` 编译宏一致) | +| `MCPP_FEATURES` | — | 活跃 feature 逗号列表 | + +这些契约值**无条件**折入重跑键——换 target、换 profile、开关 feature 都会触发重跑, +不需要任何 `rerun-if-env-changed` 声明。 + +## 依赖包的 build.mcpp(mcpp 0.0.95+) + +带 `build.mcpp` 的依赖包也会被编译并运行(Cargo `build.rs` 模型——构建一个包 +即信任其构建程序),时机在其 feature 解析之后、源扫描之前。作用域照 Cargo: +`cxxflag`/`cflag`/`cfg` 指令只染色**该包自身的 TU**;`link-lib`/`link-search` +到达终链。其产物(二进制、缓存、`MCPP_OUT_DIR`)放在**消费方工程**的 +`target/.build-mcpp/deps/@/` 下——registry 包根跨工程共享(且可能只读), +绝不写入;相对 `generated=` 路径按 `MCPP_OUT_DIR` 解析,而非包根。 + ## 增量:声明输入(避免无谓重跑) mcpp **不会**每次构建都重跑 `build.mcpp`。它会缓存程序产出的指令,只有当它依赖的东西 @@ -106,9 +133,9 @@ mcpp **不会**每次构建都重跑 `build.mcpp`。它会缓存程序产出的 ## 说明与限制 -- **在主机上运行。** `build.mcpp` 用主机工具链编译并运行。在交叉构建 - (`mcpp build --target `)下目前会**跳过并给出警告**(主机工具链交叉是计划中的 - 后续项)。要按目标平台门控*依赖*,请改用 `[target.'cfg(...)']` 表——它们按解析后的目标 - 求值。参见 [05 - mcpp.toml 工程文件指南](05-mcpp-toml.md)。 +- **在主机上运行——交叉构建下也是**(mcpp 0.0.95+)。`mcpp build --target ` + 下,程序用宿主解析的工具链编译、在宿主运行,并看到 `MCPP_TARGET` = 交叉三元组。 + 纯声明式的目标门控仍首选 `[target.'cfg(...)']` 表——参见 + [05 - mcpp.toml 工程文件指南](05-mcpp-toml.md)。 - **当前工作目录是工程根目录**,因此相对路径(`src/generated.cpp`)会落在你预期的位置。 - `build.mcpp` 非零退出会中止构建并打印其输出。 diff --git a/mcpp.toml b/mcpp.toml index 13ff08f9..a0ffae65 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.94" +version = "0.0.95" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 79353a2f..b05723a8 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -20,22 +20,41 @@ import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex) import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target import mcpp.toolchain.registry; // archive_tool +import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value) import mcpp.ui; export namespace mcpp::build { +// Build-program environment contract (G3) — what the running build.mcpp can +// see, mirroring Cargo's env family. Injected as MCPP_* variables into the +// child ONLY (never the calling process), and folded into the cache key so a +// target/profile/feature change re-runs the program. +struct BuildProgramEnv { + std::string targetTriple; // resolved canonical triple; "" = host + std::string profile; // effective profile name (dev/release/…) + std::vector features; // active feature closure of the package + // Artifact home (bin/cache/out). Empty → /target/.build-mcpp (the + // root-project default). Dependencies MUST point this into the CONSUMING + // project's tree — a registry package root is shared and may be read-only. + std::filesystem::path artifactsDir; + // Base for resolving relative `mcpp:generated=` paths. Empty → root (the + // root-project contract, unchanged). Dependencies point this at OUT_DIR so + // a shared package root is never written to. + std::filesystem::path genBase; +}; + // Compile + run `/build.mcpp` (if present) with `hostCompiler` (the resolved -// host frontend) and apply its directives to `m.buildConfig`. `tc` supplies the -// sysroot / runtime flags a fresh sandbox needs to compile + link a freestanding -// host program. No-op when the file is absent. `isCross` skips execution (a host -// build program can't run when compiled for another target). +// HOST frontend — under a cross --target the caller resolves a host toolchain; +// the program always compiles AND runs on the host) and apply its directives to +// `m.buildConfig`. `tc` supplies the sysroot / runtime flags a fresh sandbox +// needs to compile + link a freestanding host program. No-op when absent. std::expected run_build_program( mcpp::manifest::Manifest& m, const std::filesystem::path& root, const std::filesystem::path& hostCompiler, const mcpp::toolchain::Toolchain& tc, std::string_view cppStandard, - bool isCross); + const BuildProgramEnv& env); } // namespace mcpp::build @@ -200,6 +219,7 @@ std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc) { // not mistake this embedded string for build_program.cppm exporting a 2nd module. constexpr std::string_view kMcppModuleSource = R"CPP(module; #include +#include @MODULE@ mcpp; export namespace mcpp { inline void cxxflag(const char* flag) { std::printf("mcpp:cxxflag=%s\n", flag); } @@ -210,6 +230,24 @@ inline void define(const char* name) { std::printf("mcpp:cfg=%s\n", inline void generated(const char* path) { std::printf("mcpp:generated=%s\n", path); } inline void rerun_if_changed(const char* path) { std::printf("mcpp:rerun-if-changed=%s\n", path); } inline void rerun_if_env_changed(const char* var) { std::printf("mcpp:rerun-if-env-changed=%s\n", var); } +// ── environment contract (read side; values injected by the engine) ───── +inline const char* env_or(const char* n) { const char* v = std::getenv(n); return v ? v : ""; } +inline const char* target() { return env_or("MCPP_TARGET"); } +inline const char* host() { return env_or("MCPP_HOST"); } +inline const char* profile() { return env_or("MCPP_PROFILE"); } +inline const char* out_dir() { return env_or("MCPP_OUT_DIR"); } +inline const char* manifest_dir() { return env_or("MCPP_MANIFEST_DIR"); } +inline bool has_feature(const char* name) { + char buf[256] = "MCPP_FEATURE_"; + unsigned long o = 13; + for (const char* p = name; *p && o + 1 < sizeof buf; ++p, ++o) { + char c = *p; + buf[o] = (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') + : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; + } + buf[o] = 0; + return std::getenv(buf) != nullptr; +} } )CPP"; @@ -275,18 +313,64 @@ build_mcpp_module(const fs::path& bdir, const fs::path& compiler, // project: target/.build-mcpp/{build.mcpp.bin, build.mcpp.cache}. A stable subdir // (not the fingerprint-keyed one — build.mcpp runs before the fingerprint exists) // so the binary + cache survive across builds and aren't rebuilt needlessly. -fs::path build_dir(const fs::path& root) { return root / "target" / ".build-mcpp"; } +// A dependency's artifacts are redirected into the CONSUMING project's tree +// via BuildProgramEnv::artifactsDir (a registry root may be read-only). +fs::path build_dir(const fs::path& root, const BuildProgramEnv& env) { + return env.artifactsDir.empty() ? root / "target" / ".build-mcpp" + : env.artifactsDir; +} + +std::string cache_path(const fs::path& bdir) { + return (bdir / "build.mcpp.cache").string(); +} + +// MCPP_FEATURE_ spelling — same sanitizer as the compile-side +// -DMCPP_FEATURE_ macro (prepare.cppm): uppercase, non-alnum → '_'. +std::string sanitize_feature_env(std::string f) { + for (auto& c : f) + c = std::isalnum(static_cast(c)) + ? static_cast(std::toupper(static_cast(c))) : '_'; + return f; +} -std::string cache_path(const fs::path& root) { - return (build_dir(root) / "build.mcpp.cache").string(); +// The injected contract values, as (NAME, value) pairs for the child process. +std::vector> +contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv& env) { + std::vector> e; + auto hostT = mcpp::toolchain::triple::host_triple().str(); + e.emplace_back("MCPP_TARGET", env.targetTriple.empty() ? hostT : env.targetTriple); + e.emplace_back("MCPP_HOST", hostT); + e.emplace_back("MCPP_PROFILE", env.profile); + e.emplace_back("MCPP_OUT_DIR", outDir.string()); + e.emplace_back("MCPP_MANIFEST_DIR", root.string()); + std::string csv; + for (auto const& f : env.features) { + if (!csv.empty()) csv += ','; + csv += f; + e.emplace_back("MCPP_FEATURE_" + sanitize_feature_env(f), "1"); + } + e.emplace_back("MCPP_FEATURES", csv); + return e; +} + +// The contract values are part of the re-run key UNCONDITIONALLY — a target / +// profile / feature change must re-run the program; that correctness cannot +// depend on the author remembering rerun-if-env-changed. +std::string contract_hash(const std::vector>& e) { + std::string s; + for (auto const& [k, v] : e) { s += k; s += '='; s += v; s += '\n'; } + return mcpp::toolchain::hash_string(s); } -void write_cache(const fs::path& root, const std::string& programHash, - const std::string& compilerHash, const Directives& d) { - std::ofstream os(cache_path(root), std::ios::trunc); +void write_cache(const fs::path& bdir, const fs::path& root, + const std::string& programHash, + const std::string& compilerHash, const std::string& ctxHash, + const Directives& d) { + std::ofstream os(cache_path(bdir), std::ios::trunc); if (!os) return; // best-effort: a failed cache write only loses the optimization os << "program " << programHash << '\n'; os << "compiler " << compilerHash << '\n'; + os << "ctx " << ctxHash << '\n'; for (auto const& f : d.rerunFiles) os << "in " << mcpp::toolchain::hash_file(abs_against_root(root, f)) << ' ' << f << '\n'; for (auto const& e : d.rerunEnv) @@ -304,15 +388,16 @@ void write_cache(const fs::path& root, const std::string& programHash, struct CacheRecord { std::string programHash; std::string compilerHash; + std::string ctxHash; // contract env (target/profile/features/out-dir) std::vector> inputs; // (hash, path) std::vector> envs; // (hash, name) Directives directives; bool loaded = false; }; -CacheRecord read_cache(const fs::path& root) { +CacheRecord read_cache(const fs::path& bdir) { CacheRecord r; - std::ifstream is(cache_path(root)); + std::ifstream is(cache_path(bdir)); if (!is) return r; std::string line; while (std::getline(is, line)) { @@ -323,6 +408,7 @@ CacheRecord read_cache(const fs::path& root) { std::string rest = line.substr(sp + 1); if (tag == "program") r.programHash = rest; else if (tag == "compiler") r.compilerHash = rest; + else if (tag == "ctx") r.ctxHash = rest; else if (tag == "in" || tag == "env") { auto sp2 = rest.find(' '); if (sp2 == std::string::npos) continue; @@ -345,10 +431,12 @@ CacheRecord read_cache(const fs::path& root) { // Decide whether the cached run is still valid (so we can skip recompiling/running). bool cache_fresh(const fs::path& root, const CacheRecord& c, - const std::string& programHash, const std::string& compilerHash) { + const std::string& programHash, const std::string& compilerHash, + const std::string& ctxHash) { if (!c.loaded) return false; if (c.programHash != programHash) return false; if (c.compilerHash != compilerHash) return false; + if (c.ctxHash != ctxHash) return false; // pre-G3 caches (no ctx line) rerun once for (auto const& [h, path] : c.inputs) if (mcpp::toolchain::hash_file(abs_against_root(root, path)) != h) return false; for (auto const& [h, name] : c.envs) @@ -367,8 +455,13 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { // cfg defines apply to both C and C++ translation units. bc.cflags.insert(bc.cflags.end(), d.defines.begin(), d.defines.end()); bc.cxxflags.insert(bc.cxxflags.end(), d.defines.begin(), d.defines.end()); - // Generated sources join the source glob set so the modgraph scanner finds them. - for (auto const& g : d.generated) bc.sources.push_back(g); + // Generated sources join the source set. BOTH lists: the scanner walks the + // legacy modules.sources mirror — pushing only bc.sources left a generated + // file outside the base globs invisible to the scan (latent since L3). + for (auto const& g : d.generated) { + bc.sources.push_back(g); + m.modules.sources.push_back(g); + } } } // namespace @@ -379,32 +472,30 @@ std::expected run_build_program( const fs::path& hostCompiler, const mcpp::toolchain::Toolchain& tc, std::string_view cppStandard, - bool isCross) { + const BuildProgramEnv& env) { fs::path src = root / "build.mcpp"; std::error_code ec; if (!fs::exists(src, ec)) return {}; // no build program — nothing to do - if (isCross) { - mcpp::ui::warning( - "build.mcpp present but skipped under a cross --target build " - "(it compiles and runs on the host; host-toolchain-for-cross is a follow-up)"); - return {}; - } + fs::path bdir = build_dir(root, env); + fs::path outDir = bdir / "out"; + auto childEnv = contract_env(root, outDir, env); + std::string ctxHash = contract_hash(childEnv); std::string programHash = mcpp::toolchain::hash_file(src); std::string compilerHash = mcpp::toolchain::hash_string(hostCompiler.string()); - // Fast path: declared inputs unchanged → reapply cached directives, no run. - CacheRecord cache = read_cache(root); - if (cache_fresh(root, cache, programHash, compilerHash)) { + // Fast path: declared inputs + contract unchanged → reapply cached + // directives, no run. + CacheRecord cache = read_cache(bdir); + if (cache_fresh(root, cache, programHash, compilerHash, ctxHash)) { apply(m, cache.directives); mcpp::ui::info("build.mcpp", "up to date (cached)"); return {}; } - fs::path bdir = build_dir(root); - fs::create_directories(bdir, ec); + fs::create_directories(outDir, ec); // creates bdir too fs::path bin = bdir / "build.mcpp.bin"; // ── Compile build.mcpp with the host toolchain ────────────────────────── @@ -453,10 +544,11 @@ std::expected run_build_program( } // ── Run it; capture stdout(+stderr) and parse directives ──────────────── - // Run with cwd = project root so the program's relative file writes (e.g. - // mcpp:generated sources) land in the project, not in mcpp's invocation dir. + // Run with cwd = package root so the program's relative file writes (e.g. + // mcpp:generated sources) land in the project, not in mcpp's invocation + // dir. The MCPP_* contract env is injected into the CHILD only. mcpp::ui::info("build.mcpp", "running"); - auto rres = mcpp::platform::process::capture_exec({bin.string()}, {}, root.string()); + auto rres = mcpp::platform::process::capture_exec({bin.string()}, childEnv, root.string()); if (rres.exit_code != 0) { return std::unexpected(std::format( "build.mcpp exited with {} (build aborted):\n{}", rres.exit_code, rres.output)); @@ -465,6 +557,16 @@ std::expected run_build_program( Directives d; parse_output(root, rres.output, d); + // Dependency mode (genBase set): relative `generated=` paths resolve + // against OUT_DIR-style genBase, not the (possibly read-only, shared) + // package root — rewrite them to absolute before validation/apply/cache. + if (!env.genBase.empty()) { + for (auto& g : d.generated) { + fs::path gp(g); + if (gp.is_relative()) g = (env.genBase / gp).lexically_normal().string(); + } + } + // Missing declared generated outputs are a hard error (declared-output contract). for (auto const& g : d.generated) { if (!fs::exists(abs_against_root(root, g))) { @@ -474,7 +576,7 @@ std::expected run_build_program( } apply(m, d); - write_cache(root, programHash, compilerHash, d); + write_cache(bdir, root, programHash, compilerHash, ctxHash, d); return {}; } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index cd888fd2..3b08b53a 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -361,6 +361,41 @@ void merge_conditional_build(mcpp::manifest::Manifest& m, } } +// Feature-activation closure — THE single implementation (build.mcpp env +// contract, Stage 2a feature-deps, and the main feature pass all call this): +// seed = [features].default ∪ requested, expanded transitively over implies; +// the literal name "default" is never itself a feature. +std::vector feature_closure(const mcpp::manifest::Manifest& pm, + const std::vector& requested) +{ + std::vector act, q; + if (auto it = pm.featuresMap.find("default"); it != pm.featuresMap.end()) + q.insert(q.end(), it->second.begin(), it->second.end()); + q.insert(q.end(), requested.begin(), requested.end()); + std::set seen; + while (!q.empty()) { + auto f = q.back(); q.pop_back(); + if (f == "default" || !seen.insert(f).second) continue; + act.push_back(f); + if (auto it = pm.featuresMap.find(f); it != pm.featuresMap.end()) + q.insert(q.end(), it->second.begin(), it->second.end()); + } + return act; +} + +// --features value → tokens (comma/space separated). +std::vector parse_feature_request(std::string_view s) { + std::vector out; + for (std::size_t p = 0; p < s.size();) { + auto c = s.find_first_of(", ", p); + auto tok = s.substr(p, c == std::string_view::npos ? std::string_view::npos : c - p); + if (!tok.empty()) out.emplace_back(tok); + if (c == std::string_view::npos) break; + p = c + 1; + } + return out; +} + bool is_std_module(std::string_view name) { return name == "std" || name == "std.compat"; } @@ -618,8 +653,11 @@ prepare_build(bool print_fingerprint, // 2. global ~/.mcpp/config.toml [toolchain].default // 3. hard error (no system fallback) // Resolve the build profile, overlaid by any [profile.] from the - // manifest → buildConfig. + // manifest → buildConfig. `effectiveProfile` outlives the block: the + // build.mcpp env contract exposes it as MCPP_PROFILE. + std::string effectiveProfile; { + auto& pname = effectiveProfile; // Precedence: --profile / --release / --dev flag (overrides.profile) > // [build].default-profile (project default) > "dev" (global default). // The global default is "dev" (-O0 -g) to follow the dominant convention @@ -627,9 +665,9 @@ prepare_build(bool print_fingerprint, // opt-in via --release / --profile release. A project that wants its // plain `mcpp build` optimized sets [build].default-profile = "release" // (mcpp's own mcpp.toml does this, so the released binary stays -O2). - std::string pname = !overrides.profile.empty() ? overrides.profile - : !m->buildConfig.defaultProfile.empty() ? m->buildConfig.defaultProfile - : "dev"; + pname = !overrides.profile.empty() ? overrides.profile + : !m->buildConfig.defaultProfile.empty() ? m->buildConfig.defaultProfile + : "dev"; mcpp::manifest::Profile pr; if (pname == "dev" || pname == "debug") { pr.optLevel = "0"; pr.debug = true; } else if (pname == "dist") { pr.optLevel = "3"; pr.strip = true; } @@ -985,13 +1023,16 @@ prepare_build(bool print_fingerprint, // self-describing. See docs: 2026-05-21-linux-sysroot-missing-kernel-headers.md // ── L3: project-local `build.mcpp` imperative build program ───────────── - // Compiled with the (host) toolchain and run now — after target resolution + // Compiled with the HOST toolchain and run now — after target resolution // + the L1 cfg-flag merge (buildConfig flags are final) and BEFORE the // modgraph scan (so its `generated=` sources are picked up). Its stdout // directives augment buildConfig; a declared-input cache re-runs it only - // when its source/inputs/env change. Leaf-only: it cannot gate the top-level - // dependency graph. Skipped under a cross --target (host program, host run). - // See .agents/docs/2026-06-30-l3-build-mcpp-implementation-design.md. + // when its source/inputs/env/contract change. It cannot gate the top-level + // dependency graph (leaf-only rule). Under a cross --target it runs with a + // host-resolved toolchain and sees MCPP_TARGET = the cross triple (G3). + // Dependencies' build.mcpp run in a later pass (G2), after their features + // are known. See .agents/docs/2026-06-30-l3-build-mcpp-implementation-design.md + // and 2026-07-17-asm-sources-and-general-build-capabilities-design.md §2.4. // Root [generated_files]: materialize before build.mcpp and the modgraph // scan so synthesized sources are globbed like any on-disk file. (The // per-dependency call sits in the dep resolution loop below; the root @@ -1002,11 +1043,74 @@ prepare_build(bool print_fingerprint, } } - if (auto bp = mcpp::build::run_build_program( - *m, *root, explicit_compiler, *tc, m->cppStandard.canonical, - /*isCross=*/!overrides.target_triple.empty()); - !bp) { - return std::unexpected(bp.error()); + // Canonical rendering of the resolved target (for the env contract). + std::string resolvedTargetCanonical; + if (!overrides.target_triple.empty()) { + auto tt = mcpp::toolchain::triple::parse(overrides.target_triple); + resolvedTargetCanonical = tt ? tt->str() : overrides.target_triple; + } + + // Host toolchain for build.mcpp (G3): under a cross --target the resolved + // `tc` is the cross toolchain, whose products cannot run here — resolve a + // host-target toolchain from the same spec vocabulary (the spec WITHOUT + // the --target axis), lazily and only when a build.mcpp actually exists + // (root or dependency). + std::optional> hostTcCache; + auto host_tc_for_build_program = [&]() -> std::expected< + std::pair, std::string> { + if (overrides.target_triple.empty()) + return std::pair{explicit_compiler, *tc}; + if (hostTcCache) return *hostTcCache; + if (!tcSpec || *tcSpec == "system" || tcSpecIsMsvc) { + return std::unexpected(std::string( + "build.mcpp under a cross --target needs a resolvable host " + "toolchain — set one via [toolchain] or `mcpp toolchain default`")); + } + auto spec = mcpp::toolchain::parse_toolchain_spec(*tcSpec); + if (!spec || spec->version.empty()) { + return std::unexpected(std::format( + "toolchain spec '{}' is invalid for the build.mcpp host resolve", *tcSpec)); + } + // Deliberately NO target injection: the spec resolves for the host. + auto pkg = mcpp::toolchain::to_xim_package(*spec); + auto cfgH = get_cfg(); + if (!cfgH) return std::unexpected(cfgH.error()); + mcpp::fetcher::Fetcher fetcher(**cfgH); + mcpp::fetcher::InstallProgressHandler progress; + auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, &progress); + if (!payload) { + return std::unexpected(std::format( + "host toolchain for build.mcpp ('{}'): {}", *tcSpec, + payload.error().message)); + } + auto frontend = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); + if (!std::filesystem::exists(frontend)) { + return std::unexpected(std::format( + "host toolchain payload '{}' has no known C++ frontend in {}", + pkg.target(), payload->binDir.string())); + } + mcpp::toolchain::ensure_post_install_fixup(**cfgH, payload->root, pkg); + auto htc = mcpp::toolchain::detect(frontend); + if (!htc) return std::unexpected(htc.error().message); + mcpp::ui::info("Resolved", std::format( + "host toolchain for build.mcpp: {}", htc->label())); + hostTcCache = std::pair{frontend, *htc}; + return *hostTcCache; + }; + + if (std::filesystem::exists(*root / "build.mcpp")) { + auto host = host_tc_for_build_program(); + if (!host) return std::unexpected(host.error()); + mcpp::build::BuildProgramEnv bpEnv; + bpEnv.targetTriple = resolvedTargetCanonical; + bpEnv.profile = effectiveProfile; + bpEnv.features = feature_closure(*m, parse_feature_request(overrides.features)); + if (auto bp = mcpp::build::run_build_program( + *m, *root, host->first, host->second, + m->cppStandard.canonical, bpEnv); + !bp) { + return std::unexpected(bp.error()); + } } // Resolve dependencies: walk the **transitive** graph from the main @@ -1893,19 +1997,7 @@ prepare_build(bool print_fingerprint, // __normal_iterator") when other modules import std. auto activateFeatures = [](const mcpp::manifest::Manifest& pm, const std::vector& requested) { - std::vector act, q; - if (auto it = pm.featuresMap.find("default"); it != pm.featuresMap.end()) - q.insert(q.end(), it->second.begin(), it->second.end()); - q.insert(q.end(), requested.begin(), requested.end()); - std::set seen; - while (!q.empty()) { - auto f = q.back(); q.pop_back(); - if (f == "default" || !seen.insert(f).second) continue; - act.push_back(f); - if (auto it = pm.featuresMap.find(f); it != pm.featuresMap.end()) - q.insert(q.end(), it->second.begin(), it->second.end()); - } - return act; + return feature_closure(pm, requested); // single shared implementation }; // Merge a manifest's active feature-deps into its `dependencies` map so the // worklist below pulls them like any normal dep. A top-level dep of the same @@ -2467,19 +2559,7 @@ prepare_build(bool print_fingerprint, }; auto activate = [](const mcpp::manifest::Manifest& pm, const std::vector& requested) { - std::vector act, q; - if (auto it = pm.featuresMap.find("default"); it != pm.featuresMap.end()) - q.insert(q.end(), it->second.begin(), it->second.end()); - q.insert(q.end(), requested.begin(), requested.end()); - std::set seen; - while (!q.empty()) { - auto f = q.back(); q.pop_back(); - if (f == "default" || !seen.insert(f).second) continue; - act.push_back(f); - if (auto it = pm.featuresMap.find(f); it != pm.featuresMap.end()) - q.insert(q.end(), it->second.begin(), it->second.end()); - } - return act; + return feature_closure(pm, requested); // single shared implementation }; auto apply = [&](mcpp::modgraph::PackageRoot& pkg, const std::vector& requested) { @@ -2587,15 +2667,7 @@ prepare_build(bool print_fingerprint, } }; if (!packages.empty()) { - std::vector rootReq; - for (std::size_t p = 0; p < overrides.features.size();) { - auto c = overrides.features.find_first_of(", ", p); - auto tok = overrides.features.substr( - p, c == std::string::npos ? std::string::npos : c - p); - if (!tok.empty()) rootReq.push_back(tok); - if (c == std::string::npos) break; - p = c + 1; - } + auto rootReq = parse_feature_request(overrides.features); // Strict schema check: a requested feature must exist in the // target package's [features] table when one is declared (a // package with no [features] accepts any request — pure-define @@ -2638,6 +2710,64 @@ prepare_build(bool print_fingerprint, apply(packages[i], req); } + // ── G2: dependency build.mcpp (Cargo build.rs model) ──────────────── + // Runs AFTER feature activation (the env contract exposes the dep's + // active features) and BEFORE the modgraph scan (generated sources + // must be visible to the glob walk). Scope is Cargo's: flag directives + // land in the dep's own buildConfig (its TUs only); link directives + // ride the dep's ldflags to the final link. Artifacts and generated + // files live in the CONSUMING project's tree — a registry package + // root is shared across projects and may be read-only; it is never + // written to. + for (std::size_t i = 1; i < packages.size(); ++i) { + auto& pkg = packages[i]; + std::error_code bpEc; + if (!std::filesystem::exists(pkg.root / "build.mcpp", bpEc)) continue; + auto host = host_tc_for_build_program(); + if (!host) return std::unexpected(host.error()); + std::vector req; + for (auto& [dname, dspec] : m->dependencies) { + if (dname == pkg.manifest.package.name + || dspec.shortName == pkg.manifest.package.name) { + req = dspec.features; + break; + } + } + auto dirSafe = [](std::string s) { + for (auto& c : s) if (c == '/' || c == '\\' || c == ':') c = '_'; + return s; + }; + mcpp::build::BuildProgramEnv bpEnv; + bpEnv.targetTriple = resolvedTargetCanonical; + bpEnv.profile = effectiveProfile; + bpEnv.features = feature_closure(pkg.manifest, req); + bpEnv.artifactsDir = *root / "target" / ".build-mcpp" / "deps" + / (dirSafe(pkg.manifest.package.name) + "@" + pkg.manifest.package.version); + bpEnv.genBase = bpEnv.artifactsDir / "out"; + auto& bcDep = pkg.manifest.buildConfig; + const auto cN = bcDep.cflags.size(), cxN = bcDep.cxxflags.size(), + ldN = bcDep.ldflags.size(); + if (auto r = mcpp::build::run_build_program( + pkg.manifest, pkg.root, host->first, host->second, + pkg.manifest.cppStandard.canonical, bpEnv); + !r) { + return std::unexpected(std::format( + "dependency '{}': {}", pkg.manifest.package.name, r.error())); + } + // Cargo scope wiring. Compile flags: the dep's TUs read the + // usage-resolved privateBuild (not bc) — mirror the newly added + // flags there, same as the MCPP_FEATURE_ macro does. Link flags: + // dep ldflags were propagated to the root during the BFS walk, + // which ran before this pass — forward the new tail (link-search + // paths are already absolute from parse_line). + pkg.privateBuild.cflags.insert(pkg.privateBuild.cflags.end(), + bcDep.cflags.begin() + cN, bcDep.cflags.end()); + pkg.privateBuild.cxxflags.insert(pkg.privateBuild.cxxflags.end(), + bcDep.cxxflags.begin() + cxN, bcDep.cxxflags.end()); + m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), + bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); + } + // apply() may have added interface defines to packages' publicUsage // flags (a dependency's active-feature `defines`). Re-run the usage // fixpoint so those flags flow into each consumer's privateBuild — the diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index a63fc063..b4b43d14 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -506,6 +506,14 @@ void scan_one_into(ScanResult& result, excluded.insert(p); } } else { + // Literal absolute entry — e.g. a dependency build.mcpp's OUT_DIR + // generated source, which lives OUTSIDE the (possibly read-only) + // package root. No glob expansion; taken as-is when it exists. + if (std::filesystem::path gp(g); gp.is_absolute()) { + std::error_code aec; + if (std::filesystem::is_regular_file(gp, aec)) all_files.insert(gp); + continue; + } for (auto& p : expand_glob(root, g)) { all_files.insert(p); } diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 44e594a8..adec22b7 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.94"; +inline constexpr std::string_view MCPP_VERSION = "0.0.95"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/110_build_mcpp_env_contract.sh b/tests/e2e/110_build_mcpp_env_contract.sh new file mode 100755 index 00000000..b1faae9f --- /dev/null +++ b/tests/e2e/110_build_mcpp_env_contract.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# requires: gcc +# G3: build.mcpp environment contract — MCPP_TARGET/HOST/PROFILE/OUT_DIR/ +# MANIFEST_DIR/FEATURES injected into the child; contract values are part of +# the cache key (a feature change re-runs the program; an identical build +# hits the cache). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new envbp > /dev/null +cd envbp + +cat > build.mcpp <<'EOF' +#include +#include +#include +#include +static const char* env_or(const char* n) { const char* v = std::getenv(n); return v ? v : ""; } +int main() { + std::ofstream f("src/contract_gen.cpp"); + f << "extern \"C\" const char* bp_target() { return \"" << env_or("MCPP_TARGET") << "\"; }\n"; + f << "extern \"C\" const char* bp_host() { return \"" << env_or("MCPP_HOST") << "\"; }\n"; + f << "extern \"C\" const char* bp_profile() { return \"" << env_or("MCPP_PROFILE") << "\"; }\n"; + f << "extern \"C\" const char* bp_features() { return \"" << env_or("MCPP_FEATURES") << "\"; }\n"; + f << "extern \"C\" int bp_has_extra() { return " + << (std::getenv("MCPP_FEATURE_EXTRA") ? 1 : 0) << "; }\n"; + if (!f) return 1; + // OUT_DIR must exist and be writable. + const char* out = std::getenv("MCPP_OUT_DIR"); + if (!out) { std::fprintf(stderr, "MCPP_OUT_DIR unset\n"); return 1; } + std::ofstream probe(std::string(out) + "/probe.txt"); + if (!probe) { std::fprintf(stderr, "MCPP_OUT_DIR not writable\n"); return 1; } + std::printf("mcpp:generated=src/contract_gen.cpp\n"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" const char* bp_target(); +extern "C" const char* bp_host(); +extern "C" const char* bp_profile(); +extern "C" const char* bp_features(); +extern "C" int bp_has_extra(); +int main() { + std::println("target={} host={} profile={} features=[{}] extra={}", + bp_target(), bp_host(), bp_profile(), bp_features(), bp_has_extra()); + return 0; +} +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "envbp" +version = "0.1.0" + +[features] +default = [] +extra = [] +EOF + +"$MCPP" build > build1.log 2>&1 || { cat build1.log; echo "build 1 failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +host_triple_re='[a-z0-9_]+-(linux|macos|windows)(-[a-z]+)?' +[[ "$out" =~ target=$host_triple_re\ host=$host_triple_re\ profile=dev\ features=\[\]\ extra=0 ]] || { + echo "unexpected contract values: $out"; exit 1; } + +# Identical build → no re-run (either the whole-build fast path short-circuits +# before build.mcpp, or the build.mcpp cache hits — both are fine; a "running" +# line is not). +"$MCPP" build > build2.log 2>&1 +grep -q "build.mcpp running" build2.log && { + cat build2.log; echo "identical build re-ran build.mcpp"; exit 1; } || true + +# Feature change → contract hash changes → re-run, and the program observes +# the feature. +"$MCPP" build --features extra > build3.log 2>&1 || { cat build3.log; echo "build 3 failed"; exit 1; } +grep -q "build.mcpp running" build3.log || { + cat build3.log; echo "feature change did not re-run build.mcpp"; exit 1; } +bin="$(ls -t $(find target -name 'envbp' -type f) | head -1)" +out="$("./$bin" | tail -1)" +[[ "$out" == *"features=[extra] extra=1"* ]] || { + echo "feature not visible to build.mcpp: $out"; exit 1; } + +# Profile change also re-runs. +"$MCPP" build --release > build4.log 2>&1 || { cat build4.log; echo "build 4 failed"; exit 1; } +grep -q "build.mcpp running" build4.log || { + cat build4.log; echo "profile change did not re-run build.mcpp"; exit 1; } +bin="$(ls -t $(find target -name 'envbp' -type f) | head -1)" +out="$("./$bin" | tail -1)" +[[ "$out" == *"profile=release"* ]] || { echo "profile not visible: $out"; exit 1; } + +echo "OK" diff --git a/tests/e2e/111_dep_build_mcpp.sh b/tests/e2e/111_dep_build_mcpp.sh new file mode 100755 index 00000000..71c7edf8 --- /dev/null +++ b/tests/e2e/111_dep_build_mcpp.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# requires: gcc +# G2: a dependency's build.mcpp runs (Cargo build.rs model) with Cargo scope: +# its cfg define reaches ONLY the dep's own TUs; its generated source (written +# to MCPP_OUT_DIR) compiles into the dep; artifacts live in the CONSUMING +# project's tree — the dep root itself is never written to. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +mkdir -p "$TMP/deplib/src" "$TMP/app/src" + +cat > "$TMP/deplib/mcpp.toml" <<'EOF' +[package] +name = "deplib" +version = "0.1.0" + +[targets.deplib] +kind = "lib" +EOF + +cat > "$TMP/deplib/build.mcpp" <<'EOF' +#include +#include +#include +#include +int main() { + const char* out = std::getenv("MCPP_OUT_DIR"); + if (!out) { std::fprintf(stderr, "MCPP_OUT_DIR unset\n"); return 1; } + std::ofstream f(std::string(out) + "/gen_dep.cpp"); + f << "extern \"C\" int dep_gen_answer() { return 77; }\n"; + if (!f) return 1; + std::printf("mcpp:generated=gen_dep.cpp\n"); + std::printf("mcpp:cfg=DEP_HAS_BP\n"); + return 0; +} +EOF + +cat > "$TMP/deplib/src/dep.cpp" <<'EOF' +#ifndef DEP_HAS_BP +#error "dep build.mcpp define did not reach the dep's own TU" +#endif +extern "C" int dep_gen_answer(); +extern "C" int dep_answer() { return dep_gen_answer(); } +EOF + +cat > "$TMP/app/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies.deplib] +path = "../deplib" +EOF + +cat > "$TMP/app/src/main.cpp" <<'EOF' +#ifdef DEP_HAS_BP +#error "dep build.mcpp define leaked into the consumer TU" +#endif +import std; +extern "C" int dep_answer(); +int main() { + std::println("dep = {}", dep_answer()); + return 0; +} +EOF + +# Snapshot the dep root so we can prove it is never written to. +before="$(find "$TMP/deplib" | sort)" + +cd "$TMP/app" +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "dep = 77" ]] || { echo "unexpected output: $out"; exit 1; } + +# Dep root untouched (no target/, no generated files, nothing). +after="$(find "$TMP/deplib" | sort)" +[[ "$before" == "$after" ]] || { + diff <(echo "$before") <(echo "$after") || true + echo "dependency root was written to"; exit 1; } + +# Artifacts live in the consuming project. +find target/.build-mcpp/deps -name 'gen_dep.cpp' | grep -q . || { + echo "dep OUT_DIR generated file not in project tree"; exit 1; } + +echo "OK" diff --git a/tests/e2e/112_build_mcpp_cross.sh b/tests/e2e/112_build_mcpp_cross.sh new file mode 100755 index 00000000..c1bf3bd8 --- /dev/null +++ b/tests/e2e/112_build_mcpp_cross.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# requires: gcc mingw-cross +# G3 (cross): build.mcpp under `--target x86_64-windows-gnu` is no longer +# skipped — it compiles AND runs on the host (host-resolved toolchain) and +# sees MCPP_TARGET = the cross triple while MCPP_HOST stays the host. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new crossbp > /dev/null +cd crossbp + +cat > build.mcpp <<'EOF' +#include +#include +#include +static const char* env_or(const char* n) { const char* v = std::getenv(n); return v ? v : ""; } +int main() { + std::ofstream f("src/cross_gen.cpp"); + f << "extern \"C\" const char* bp_target() { return \"" << env_or("MCPP_TARGET") << "\"; }\n"; + if (!f) return 1; + std::printf("mcpp:generated=src/cross_gen.cpp\n"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" const char* bp_target(); +int main() { + std::println("bp target = {}", bp_target()); + return 0; +} +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "crossbp" +version = "0.1.0" +EOF + +"$MCPP" build --target x86_64-windows-gnu > build.log 2>&1 || { + cat build.log; echo "cross build failed"; exit 1; } +# Not skipped anymore. +grep -qi "skipped under a cross" build.log && { + echo "build.mcpp still skipped under cross"; exit 1; } || true +# The generated source baked in the CROSS triple (contract value), and the +# produced artifact is a PE binary. +grep -q 'x86_64-windows-gnu' src/cross_gen.cpp || { + cat src/cross_gen.cpp; echo "MCPP_TARGET was not the cross triple"; exit 1; } +exe="$(find target -name 'crossbp.exe' | head -1)" +[[ -n "$exe" ]] || { echo "no PE artifact produced"; exit 1; } + +# Wine run (when available) proves the full loop. +if command -v wine &>/dev/null; then + out="$(wine "$exe" 2>/dev/null | tr -d '\r' | tail -1)" + [[ "$out" == "bp target = x86_64-windows-gnu" ]] || { + echo "unexpected wine output: $out"; exit 1; } +fi + +echo "OK"