diff --git a/CHANGELOG.md b/CHANGELOG.md index e16eb16b..28c460ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,25 @@ M-profile:MPU 按基址与上限描述区域,没有页表项。A-profile 有真 而不是它要陈述的性质。新行加进来时规则适用而测试**静默跳过**了它们,每条断言依然通过。 谓词已改为「32 位 ARM」。 +### ⭐ `mcpp run` 接受 `--features` 与 `--profile` + +`build` 与 `test` 一直有这两条轴,`run` 没有 —— 于是 `run` **只能执行上一次 `build` +恰好留下的东西**:没有任何一种写法能跑一个 release 产物,或一个开了 feature 的产物。 + +⚠️ 而这正是整个设备面赖以成立的形状:板级包把「模拟器」与「真板」表达成 feature, +所以 `mcpp run --features hardware` 才是板子到手那天开发者敲的命令。**方案里唯一一个 +自己跑不起来的场景就是它。** + +### ⚠️⚠️ 顺带修掉一个既有缺陷:构建缓存不看 feature + +缓存条目按 (target, profile, cache mode) 索引,而**输出目录按含 feature 的指纹索引**。 +于是 `mcpp build --features loud` 写下的条目指向 loud 的目录,下一次**不带 feature 的** +`mcpp build` 命中它、0.00s 报成功,**把带 feature 的产物交给一个没要 feature 的请求**。 + +实测(修复前):同一个工程连续三次构建打印 `quiet`、`LOUD`、`LOUD`。 + +条目现在记录它的 feature 集合(归一化,`a,b` 与 `b a` 同一);两条快路径都比对它。 + ### 发现性 `mcpp why runners` 列出本工程提供的 runner,与其余解析结果并列; diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md index bdd94b39..f33d6cd6 100644 --- a/docs/13-baremetal.md +++ b/docs/13-baremetal.md @@ -43,14 +43,18 @@ asked what a *32-bit* machine's entry looks like — short descriptors are 32 bi wide, long (LPAE) ones 64. That question cannot be put to a machine with no entries, which is why `openarch`'s Cortex-M backend declines the capability. -**The semihosting exit call is not spelled the way M-profile spells it.** -`SYS_EXIT` (`0x18`) on AArch32 takes the reason code in `r1` *directly*; the -`{reason, code}` block a Cortex-M board passes is `SYS_EXIT_EXTENDED` (`0x20`), -which exists because a 32-bit `r1` cannot carry both. Measured: passing the -block to `0x18` prints correctly and then reports the wrong exit status, so a -board that only checks its output cannot see the difference. This is a *board* -fact rather than a target fact; it is recorded here because it is where the next -person writing such a board will look. +**The semihosting exit call has two spellings and only one of them carries a +status.** `SYS_EXIT` (`0x18`) takes its reason code in `r1` *directly*; the +`{reason, code}` block is `SYS_EXIT_EXTENDED` (`0x20`), which exists because a +32-bit `r1` cannot carry both a reason and a status. Passing the block to `0x18` +prints everything correctly and then reports the **wrong** exit status. + +⚠️ This is an *AArch32* fact and applies to M-profile as much as to A-profile. +Measured twice: an ARMv7-A image exiting 0 reported 1, and an `openarch` +Cortex-M example printed `both tasks observed preemption` and exited 1 — every +assertion on its output passed. A board that only checks what it printed cannot +see the difference, which is why `tests/e2e/332` and `336` both read `$?`, and +both take it from the emulator rather than from the tail of a pipeline. ### M-profile is seven rows rather than one diff --git a/docs/18-devices.md b/docs/18-devices.md index 03488709..a42f828b 100644 --- a/docs/18-devices.md +++ b/docs/18-devices.md @@ -21,8 +21,14 @@ mcpp run # the default runner mcpp run --runner flash # a named one mcpp run --list-runners # what this project supplies mcpp why runners # the same list, beside everything else resolved +mcpp run --features hardware # the same board, reached the other way ``` +`mcpp run` takes `--features` and `--profile`, the same axes `mcpp build` and +`mcpp test` take. That is what makes the line above a command rather than a +manifest edit: a board package expresses its two environments as features, so +selecting one is the same act as selecting any other feature. + `mcpp run` is the whole of the common case, including on real hardware. On a device, running a program means writing it, resetting, attaching to its output and reading its exit status — which is one command (`probe-rs run`, `qemu-system-* diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md index e07767f7..5d0970f8 100644 --- a/docs/zh/13-baremetal.md +++ b/docs/zh/13-baremetal.md @@ -39,11 +39,16 @@ A-profile 有真正的 MMU 与页表走查器,所以它是第一个能被问「* 什么样」的目标 —— 短描述符 32 位宽,长描述符(LPAE)64 位。这个问题无法向一台没有 页表项的机器提出,这正是 `openarch` 的 Cortex-M 后端不声明该能力的原因。 -**半主机的退出调用与 M-profile 的拼法不同。** AArch32 的 `SYS_EXIT`(`0x18`)把 -原因码**直接**放在 `r1` 里;Cortex-M 板级代码传的那个 `{reason, code}` 块是 -`SYS_EXIT_EXTENDED`(`0x20`),它存在的理由正是 32 位的 `r1` 装不下两者。实测:把 -块传给 `0x18`,打印正确而**退出状态是错的** —— 只看输出的板子看不出这个差别。这是 -**板**的事实而不是目标的事实,记在这里是因为下一个写这种板的人会来这里找。 +**半主机的退出调用有两种拼法,只有一种带得走状态。** `SYS_EXIT`(`0x18`)把原因码 +**直接**放在 `r1`;`{reason, code}` 块是 `SYS_EXIT_EXTENDED`(`0x20`),它存在的理由 +正是 32 位的 `r1` 装不下「原因」与「状态」两者。把块传给 `0x18`,一切打印都正确,而 +**退出状态是错的**。 + +⚠️ 这是 **AArch32** 的事实,对 M-profile 与 A-profile 同样成立。实测两次:一个 +ARMv7-A 程序退 0 而报回 1;`openarch` 的一个 Cortex-M 示例打印了 +`both tasks observed preemption` 然后退 1 —— 对**输出**的每一条断言都通过了。只看 +自己打印了什么的板子看不出这个差别,所以 `tests/e2e/332` 与 `336` 都读 `$?`,而且都 +从模拟器本身取,不从管道末端取。 ### M-profile 是七行而不是一行 diff --git a/docs/zh/18-devices.md b/docs/zh/18-devices.md index b1fd5e10..b495bebc 100644 --- a/docs/zh/18-devices.md +++ b/docs/zh/18-devices.md @@ -16,8 +16,13 @@ mcpp run # 默认 runner mcpp run --runner flash # 具名的 mcpp run --list-runners # 这个工程提供了哪些 mcpp why runners # 同一份清单,与其余解析结果并列 +mcpp run --features hardware # 同一块板,换一条路到达 ``` +`mcpp run` 接受 `--features` 与 `--profile`,与 `mcpp build`、`mcpp test` 同轴。 +上面那一行因此是一条**命令**而不是一次清单改动:板级包把两种环境表达成 feature, +选其中之一与选任何别的 feature 是同一个动作。 + **`mcpp run` 覆盖了常见情形的全部,真实硬件也一样。** 在设备上,「运行一个程序」 意味着写进去、复位、接上它的输出、读回退出状态 —— 这是**一条**命令 (`probe-rs run`、`qemu-system-* -kernel`),不是几条。因此板级包把它作为**默认** diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 15b4f8b2..12856037 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -139,6 +139,22 @@ struct BuildCacheEntry { // every entry such a cache could hold, because no manifest could express // the tier. bool runTierPending = false; + // ⚠️⚠️ THE FEATURE SET THIS ENTRY'S ARTEFACTS WERE BUILT WITH. + // + // The entry is keyed on (target, profile, cache mode) and was matched on + // those three alone, while the OUTPUT DIRECTORY is keyed on a fingerprint + // that includes the features. So `mcpp build --features loud` wrote an + // entry pointing at the loud output directory, and the next plain + // `mcpp build` matched it and reported success in 0.00s — serving the + // featured artefact to a request that asked for none. + // + // Measured before this field existed: three builds of one project printed + // `quiet`, `LOUD`, `LOUD`. The third had no feature on. + // + // Absent on caches written before the field: empty, which reads as "no + // features" — correct for every entry such a cache could hold whose + // request also has none, and a miss otherwise, which is the safe direction. + std::string features; }; std::vector read_build_cache(const std::filesystem::path& projectRoot) { @@ -254,6 +270,11 @@ std::vector read_build_cache(const std::filesystem::path& proje e.runTierPending = (line.substr(8) == "1"); haveNextLine = static_cast(std::getline(f, line)); } + // Optional `features=`. Absent ⇒ empty; see the field. + if (haveNextLine && line.starts_with("features=")) { + e.features = line.substr(9); + haveNextLine = static_cast(std::getline(f, line)); + } entries.push_back(std::move(e)); if (!haveNextLine || line.empty()) break; } @@ -265,6 +286,25 @@ std::vector read_build_cache(const std::filesystem::path& proje void write_build_cache_entries(const std::filesystem::path& path, const std::vector& entries); +// `a, b` and `b a` are one request. Normalised on both sides of the comparison +// — the entry stores this form and the fast path computes it — so a cache hit +// depends on the SET rather than on how it was typed. +std::string normalize_features(std::string_view raw) { + std::vector toks; + for (std::size_t i = 0; i < raw.size();) { + auto c = raw.find_first_of(", ", i); + auto t = raw.substr(i, c == std::string_view::npos ? c : c - i); + if (!t.empty()) toks.emplace_back(t); + if (c == std::string_view::npos) break; + i = c + 1; + } + std::ranges::sort(toks); + toks.erase(std::unique(toks.begin(), toks.end()), toks.end()); + std::string out; + for (auto const& t : toks) { if (!out.empty()) out += ','; out += t; } + return out; +} + void write_build_cache(const std::filesystem::path& projectRoot, const std::filesystem::path& outputDir, const std::string& ninjaProgram, @@ -280,7 +320,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, const mcpp::platform::runtime::RuntimeBinding& runtimeBinding = {}, std::vector depSourceRoots = {}, bool runnerDeclared = false, - bool runTierPending = false) { + bool runTierPending = false, + const std::string& features = {}) { auto path = projectRoot / kBuildCacheFile; auto entries = read_build_cache(projectRoot); @@ -303,6 +344,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, newEntry.depSourceRootsRecorded = true; newEntry.runnerDeclared = runnerDeclared; newEntry.runTierPending = runTierPending; + newEntry.features = features; entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -345,6 +387,7 @@ void write_build_cache_entries(const std::filesystem::path& path, for (auto& r : e.depSourceRoots) f << r << '\n'; f << "runner=" << (e.runnerDeclared ? 1 : 0) << '\n'; f << "runtier=" << (e.runTierPending ? 1 : 0) << '\n'; + f << "features=" << e.features << '\n'; } } @@ -764,7 +807,12 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, !choose_runner(ctx).tmpl.empty(), // …and one written by a build that left a run-tier // tool uninstalled, for the same reason. - ctx.runTierPending); + ctx.runTierPending, + // The feature set these artefacts were built with: + // the entry is matched on it, because the output + // directory is keyed on a fingerprint that includes + // it and the entry was not. + normalize_features(ctx.activeFeatureRequest)); } // The one place the --strict policy is settled. Degradations reported by @@ -1013,11 +1061,15 @@ struct FastPathIdentity { // the same single manifest read, and the only one that can VETO the fast // path rather than describe it — see try_fast_build. bool hooksActive = false; + // What `--features` asked for, normalised so that spelling and order + // cannot make two identical requests compare unequal. + std::string features; }; std::optional fast_path_identity(const std::filesystem::path& projectRoot, - std::string_view profileOverride = "") { + std::string_view profileOverride = "", + std::string_view featuresRequested = "") { auto m = mcpp::manifest::load(projectRoot / "mcpp.toml"); if (!m) return std::nullopt; return FastPathIdentity{ @@ -1028,6 +1080,7 @@ fast_path_identity(const std::filesystem::path& projectRoot, mcpp::extension_table_for(m->buildConfig.moduleExtensions), m->buildConfig.target, m->hooks.active(), + normalize_features(featuresRequested), }; } @@ -1040,11 +1093,19 @@ fast_path_identity(const std::filesystem::path& projectRoot, // what THIS graph supplies, which is knowable only after resolution. export int list_runners(const std::string& package_filter, const std::string& cache_mode, bool no_cache, - const std::string& target_triple) { + const std::string& target_triple, + // Which runners exist DEPENDS on the features: a board + // package supplies a different set for an emulator and + // for a probe. Reporting them without the axis that + // selects them would answer a question nobody asked. + const std::string& features = {}, + const std::string& profile = {}) { mcpp::build::BuildOverrides ov; ov.package_filter = package_filter; ov.cache_mode = no_cache ? std::string("off") : cache_mode; ov.target_triple = target_triple; + ov.features = features; + ov.profile = profile; // Reporting what `mcpp run` would do means resolving what `mcpp run` // resolves, tool tiers included — otherwise this command would list a // runner whose program it had declined to install. @@ -1113,7 +1174,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo const BuildCacheEntry* match = nullptr; for (auto& e : entries) { if (e.targetTriple == currentTarget && e.profile == want->profile - && e.cacheMode == want->cacheMode) { + && e.cacheMode == want->cacheMode && e.features == want->features) { match = &e; break; } @@ -1245,7 +1306,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, const BuildCacheEntry* match = nullptr; for (auto& e : entries) { if (e.targetTriple.empty() && e.profile == want->profile - && e.cacheMode == want->cacheMode) { + && e.cacheMode == want->cacheMode && e.features == want->features) { match = &e; break; } @@ -1407,7 +1468,24 @@ export int build_run_target(const std::optional& targetName, // Empty is the default runner — `mcpp run`. Any // other value came from `--runner ` and the // engine has never seen it before. - std::string_view runner_name = {}) { + std::string_view runner_name = {}, + // ⭐⭐ THE TWO AXES `build` AND `test` HAVE ALWAYS + // TAKEN, AND `run` DID NOT. + // + // Both change WHAT IS BUILT, so a `run` that could + // not express them could only ever execute whatever + // a previous `build` happened to leave behind — and + // there is no spelling of `mcpp run` that runs a + // release artefact, or one built with a feature on. + // + // It is the shape the whole device surface is built + // around: a board package expresses "emulator" and + // "hardware" as features, so `mcpp run --features + // hardware` is the command a developer types when + // the board arrives. Without this it was the one + // scenario the design's own example could not run. + const std::string& features = {}, + const std::string& profile = {}) { // mcpp#225 (E2): reuse the resolved build cache when it's still fresh, // skipping prepare_build's toolchain resolution + modgraph scan // entirely — mirrors cmd_build's try_fast_build fast path. The cached @@ -1424,6 +1502,11 @@ export int build_run_target(const std::optional& targetName, // manifest to print the note against. if (package_filter.empty() && cache_mode.empty() && !no_cache && target_triple.empty() && !no_runner + // ⚠️ AND NEITHER NEW AXIS IS SET. The cached entry was written for + // whichever feature set and profile the last build used; taking it + // here would silently ignore the flag, which is the same reason + // `--cache` and `--profile` bypass it in `cmd_build`. + && features.empty() && profile.empty() // ⚠️⚠️ THE FAST PATH IS `run`'s, AND ONLY `run`'s. // // It exec's the cached artefact directly — that IS its definition — so @@ -1448,6 +1531,8 @@ export int build_run_target(const std::optional& targetName, ov.package_filter = package_filter; ov.cache_mode = cache_mode; ov.target_triple = target_triple; + ov.features = features; + ov.profile = profile; // This verb executes what it builds, so the `when = "run"` tool tier is // part of what has to exist. `mcpp build` does not set it, which is the // whole of the difference the tier buys. diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index a2be39cd..0d4a027f 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -677,6 +677,11 @@ export struct BuildContext { // build cache records it so `mcpp run`'s fast path declines an entry a // plain `mcpp build` wrote — see BuildCacheEntry::runTierPending. bool runTierPending = false; + // What `--features` asked for, verbatim. Carried so the build cache entry + // can record the set its artefacts were built with — the output directory + // is keyed on a fingerprint that includes the features and the entry was + // not, which let a plain build serve a featured artefact. + std::string activeFeatureRequest; std::filesystem::path outputDir; std::filesystem::path stdBmi; std::filesystem::path stdObject; @@ -8828,6 +8833,7 @@ prepare_build(bool print_fingerprint, ctx.runtimeSelection = runtimeSelection; ctx.runtimeBinding = runtimeBindingSnapshot; ctx.profile = effectiveProfile; + ctx.activeFeatureRequest = overrides.features; ctx.compilerChoice = { std::string(tc_origin_name(tcOrigin)), graphCompilerRequiredBy, graphCompilerReplaced.empty() ? pinReplacedDefault diff --git a/src/cli.cppm b/src/cli.cppm index c5aa4e3a..9dfcca6a 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -417,6 +417,24 @@ int run(int argc, char** argv) { .help("Deprecated alias for --cache=off (also clears the build dir)")) .option(cl::Option("no-runner") .help("Execute the artifact directly, ignoring any [target.].runner (a host that runs it natively)")) + // ⭐⭐ THE TWO AXES `build` AND `test` HAVE ALWAYS TAKEN. + // + // Both decide WHAT IS BUILT, so without them `run` could only + // execute whatever a previous `build` happened to leave behind — + // there was no spelling of `mcpp run` that ran a release artefact, + // or one built with a feature on. + // + // It is the shape the device surface is built around: a board + // package expresses "emulator" and "hardware" as features, so + // `mcpp run --features hardware` is what a developer types when the + // board arrives. That was the one scenario the design could not + // actually run. + .option(cl::Option("features").takes_value().value_name("LIST") + .help("Activate features (comma/space separated), same axis as `mcpp build --features`")) + .option(cl::Option("profile").takes_value().value_name("NAME") + .help("Build profile to run (dev | release | )")) + .option(cl::Option("release").help("Shorthand for --profile release")) + .option(cl::Option("dev").help("Shorthand for --profile dev")) // ⭐⭐ THE WAY TO REACH THE ARTEFACT, BY NAME. // // `mcpp run` is universal — every domain has one. HOW the artefact diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 2d797563..a2f55ed9 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -221,12 +221,20 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, // what `mcpp run` has always meant. std::string runner_name; if (auto rn = parsed.value("runner")) runner_name = *rn; + // The same two axes `build` and `test` take, read the same way. `--release` + // and `--dev` are the shorthands the other verbs already accept. + std::string features, profile; + if (auto fs = parsed.value("features")) features = *fs; + if (auto pr = parsed.value("profile")) profile = *pr; + if (parsed.is_flag_set("release")) profile = "release"; + if (parsed.is_flag_set("dev")) profile = "dev"; if (parsed.is_flag_set("list-runners")) return mcpp::build::list_runners(package_filter, cache_mode, no_cache, - target_triple); + target_triple, features, profile); return mcpp::build::build_run_target(targetName, passthrough, package_filter, cache_mode, no_cache, target_triple, - no_runner, runner_name); + no_runner, runner_name, features, + profile); } export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, diff --git a/tests/e2e/332_cortex_m_builds_and_boots.sh b/tests/e2e/332_cortex_m_builds_and_boots.sh index 6535d107..8671debf 100755 --- a/tests/e2e/332_cortex_m_builds_and_boots.sh +++ b/tests/e2e/332_cortex_m_builds_and_boots.sh @@ -127,14 +127,28 @@ boot_row() { # triple machine cpuflag flash_org flash_len ram_org r echo "FAIL: $triple lost the vector table (KEEP not honoured)"; exit 1 fi - local out - out=$(timeout 30 "$QEMU" -machine "$machine" $cpuflag -nographic -semihosting \ - -no-reboot -kernel "$elf" 2>&1 | head -3) + local out rc + # ⚠️⚠️ NOT `qemu | head`, AND THE EXIT STATUS IS A SECOND ASSERTION. + # + # `$?` after a pipeline is the LAST command's status, so piping into `head` + # would read head's 0 and the check below would be vacuous. It is not a + # theoretical concern: `SYS_EXIT` (0x18) takes its reason in r1 directly + # while the `{reason, code}` block is `SYS_EXIT_EXTENDED` (0x20), and a + # board that confuses them prints every expected line and then reports the + # WRONG status. Measured in `mcpplibs/openarch`, where an example printed + # its success line and exited 1. + set +e + timeout 30 "$QEMU" -machine "$machine" $cpuflag -nographic -semihosting \ + -no-reboot -kernel "$elf" > qemu.log 2>&1 + rc=$? + set -e + out=$(head -3 qemu.log) case "$out" in *"cortex-m ok"*) ;; *) echo "FAIL: $triple did not boot on $machine; got: $out"; exit 1 ;; esac - echo " ok $triple booted on $machine" + [ "$rc" = "0" ] || { echo "FAIL: $triple booted but exited $rc"; exit 1; } + echo " ok $triple booted on $machine and exited 0" ran=$((ran + 1)) } diff --git a/tests/e2e/337_run_takes_features_and_profile.sh b/tests/e2e/337_run_takes_features_and_profile.sh new file mode 100755 index 00000000..b73ba792 --- /dev/null +++ b/tests/e2e/337_run_takes_features_and_profile.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# `mcpp run` takes `--features` and `--profile`, the axes `build` and `test` do. +# +# ⚠️⚠️ WITHOUT THEM `run` COULD ONLY EXECUTE WHATEVER A PREVIOUS `build` LEFT +# BEHIND. There was no spelling of `mcpp run` that ran a release artefact, or +# one built with a feature on — and a board-support package expresses its two +# environments (an emulator, a debug probe) AS features, so +# `mcpp run --features hardware` is precisely the command the device surface was +# designed around. It did not exist. +# +# ⚠️ AND THE FAST PATH HAD TO LEARN ABOUT THEM. It reuses the cached artefact, +# which was built under the previous feature set and profile; taking it here +# would accept the flag and ignore it, which is worse than refusing it. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +mkdir -p "$work/app/src" +cd "$work/app" +cat > mcpp.toml <<'TOML' +[package] +name = "featrun" +version = "0.1.0" + +[build] +sources = ["src/main.cpp"] + +[features] +loud = { defines = ["LOUD=1"] } +TOML +cat > src/main.cpp <<'CPP' +#include +int main() { +#ifdef LOUD + std::printf("LOUD\n"); +#else + std::printf("quiet\n"); +#endif +#ifdef NDEBUG + std::printf("release\n"); +#else + std::printf("dev\n"); +#endif + return 0; +} +CPP + +want() { case "$1" in *"$2"*) ;; *) echo "FAIL: $3"; echo "$1"; exit 1 ;; esac; } +lack() { case "$1" in *"$2"*) echo "FAIL: $3"; echo "$1"; exit 1 ;; *) ;; esac; } + +plain="$("$MCPP" run 2>&1)" +want "$plain" "quiet" "a plain run should not have the feature" +want "$plain" "dev" "a plain run should be the dev profile" + +# ⚠️ THE SECOND RUN IS THE ONE THAT MATTERS: the first populated the build +# cache, so a fast path that ignored --features would now answer "quiet". +loud="$("$MCPP" run --features loud 2>&1)" +want "$loud" "LOUD" "--features was accepted and ignored (the fast path took a stale entry)" +lack "$loud" "quiet" "--features did not take effect" + +rel="$("$MCPP" run --release 2>&1)" +want "$rel" "release" "--release was accepted and ignored" + +both="$("$MCPP" run --features loud --release 2>&1)" +want "$both" "LOUD" "--features lost when combined with --release" +want "$both" "release" "--release lost when combined with --features" + +# And back: the flags are not sticky. An entry written under one feature set +# must not answer for a run that asks for none. +again="$("$MCPP" run 2>&1)" +want "$again" "quiet" "a plain run inherited the previous --features" +want "$again" "dev" "a plain run inherited the previous --release" + +# ── And the same defect on `mcpp build`, which is where it came from ─────── +# +# ⚠️⚠️ THIS WAS PRE-EXISTING AND IS THE REASON THE RUN SIDE WAS BROKEN. The +# build cache entry is keyed on (target, profile, cache mode) while the OUTPUT +# DIRECTORY is keyed on a fingerprint that includes the features. So an entry +# written by `mcpp build --features loud` pointed at the loud directory, and the +# next plain `mcpp build` matched it and reported success in 0.00s — serving a +# featured artefact to a request that had no feature on. +# +# Measured before the fix: three builds of one project printed +# `quiet`, `LOUD`, `LOUD`. +artifact() { find target -type f -name featrun -newermt '-1 day' | head -1; } + +rm -rf target +"$MCPP" build >/dev/null 2>&1 +first="$(./"$(artifact)")" +"$MCPP" build --features loud >/dev/null 2>&1 +"$MCPP" build >/dev/null 2>&1 +third="$(./"$(artifact)")" +case "$first" in *quiet*) ;; *) echo "FAIL: the first build was not plain"; exit 1 ;; esac +case "$third" in + *quiet*) ;; + *) echo "FAIL: a plain build after --features served the featured artefact" + echo " first=$first third=$third"; exit 1 ;; +esac + +echo "PASS: mcpp run takes --features and --profile, and the fast path honours both"