From 45a36db3817dc58bc360589672afca87feb50c47 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:46:35 +0800 Subject: [PATCH 1/2] feat(target): Cortex-M gets a C library, and its multilib key is the TRIPLE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xim:picolibc-arm@1.8.12` carries seven multilibs — picolibc plus the compiler-rt builtins that link it, built together by one LLVM. A project opts in with one line: [target.thumbv7m-none-eabi] sysroot = "xim:picolibc-arm@1.8.12" and `libdir` is what makes that line find anything. ## ⚠️⚠️ The column is the TRIPLE here, not `/` `libdir` names the sub-directory a multilib C library uses. On riscv, `/` separates every profile because `mabi` there IS the float ABI — `lp64d` and `lp64` are different values. On ARM `mabi` names the PROCEDURE CALL STANDARD and is `aapcs` for both variants, while the float ABI lives in the triple's `eabi`/`eabihf` suffix. So `armv7e-m/aapcs` would name ONE directory for two incompatible libraries. Measured while building the payload: under the sibling convention the seven M-profile profiles collapsed into FIVE directories, and `armv7e-m/aapcs/libc.a` came out carrying `Tag_ABI_HardFP_use` and `FP_arch: VFPv4-D16` — the hard-float build, sitting exactly where a soft-float program would find it. Nothing failed at build time. The two ARMv7-A rows are filled for the same reason even though no package carries an A-profile multilib yet: the column is a CONVENTION about where a sysroot puts a profile, not a claim that one exists, and leaving it empty would leave the next payload free to choose the arrangement that just failed. ⭐ Filling it does NOT give these rows a C library by default. The column is read only once a sysroot has been resolved, and the target table still binds none: the zero-libc tier stays the default and the manifest line is the opt-in. ## The criteria are the ABI and the size, not the link * A unit test quantifies over every 32-bit ARM row and asserts that the soft and hard variants of ONE architecture name DIFFERENT directories — with a denominator, so the check cannot quietly range over nothing. * `tests/e2e/338` builds the SOFT-float row and asserts the image declares no hard-float ABI tag, then runs it and reads `$?`. * ⚠️ And it asserts the artefact is not EMPTY. Measured while writing it: with the board's crt0 selection missing, the link SUCCEEDED and mcpp reported `Size fw text 0 data 0` — a well-formed ELF containing nothing. Every check that looked only for "Finished" would have passed. 338 skips until the payload is published, and CI demands that it either passed or said why — which is what distinguishes a skip from a silent zero exit. 97/97 unit; e2e 130-131, 332, 336, 338 green. --- .github/workflows/ci-linux-e2e.yml | 12 +- CHANGELOG.md | 27 ++++ src/freestanding/target.cppm | 52 +++++-- tests/e2e/338_cortex_m_picolibc_sysroot.sh | 158 +++++++++++++++++++++ tests/unit/test_freestanding.cpp | 45 ++++++ 5 files changed, 281 insertions(+), 13 deletions(-) create mode 100755 tests/e2e/338_cortex_m_picolibc_sysroot.sh diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 52530adf..1ac95137 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -189,7 +189,8 @@ jobs: tests/e2e/132_freestanding_test_and_artifacts.sh \ tests/e2e/133_freestanding_std_subset.sh \ tests/e2e/332_cortex_m_builds_and_boots.sh \ - tests/e2e/336_armv7a_builds_and_boots.sh; do + tests/e2e/336_armv7a_builds_and_boots.sh \ + tests/e2e/338_cortex_m_picolibc_sysroot.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" rc=${PIPESTATUS[0]} @@ -231,6 +232,15 @@ jobs: a32=$(grep -c 'booted on virt' 336_armv7a_builds_and_boots.sh.log || true) [ "$a32" = "2" ] || { echo "336 booted $a32 rows, expected 2"; exit 1; } + # ⚠️ 338 SKIPS UNTIL `xim:picolibc-arm` IS PUBLISHED, and a skip here + # is legitimate rather than a defect — the payload is a separate + # release. So its PASS line is NOT demanded; what IS demanded is that + # the script either passed or said why, which is what distinguishes a + # skip from a silent zero-exit. + grep -qE 'PASS: a Cortex-M project opts into picolibc|SKIP: picolibc-arm is not installed' \ + 338_cortex_m_picolibc_sysroot.sh.log || { + cat 338_cortex_m_picolibc_sysroot.sh.log + echo "338 neither passed nor reported why it did not run"; exit 1; } # ────────────────────────────────────────────────────────────────── # Hermetic (no host toolchain): the ONLY environment class that diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c460ff..1df0325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ ## [2026.9.4.2] — 2026-09-04 +### ⭐⭐ Cortex-M 有 C 库了:`libdir` 填上,而它的键是**三元组** + +`xim:picolibc-arm@1.8.12` 带七个多库(picolibc + compiler-rt builtins,同一个 +LLVM 一起构建)。工程一行选入: + +```toml +[target.thumbv7m-none-eabi] +sysroot = "xim:picolibc-arm@1.8.12" +``` + +⚠️⚠️ **`libdir` 这一列在 ARM 上是三元组,不是 `/`,而差别是一次 +无人报告的 ABI 替换。** riscv 的 `mabi` **就是**浮点 ABI(`lp64d` 与 `lp64` 是 +两个值);ARM 的 `mabi` 是过程调用标准,两个变体都是 `aapcs`,浮点 ABI 在三元组的 +`eabi`/`eabihf` 后缀里。于是 `armv7e-m/aapcs` 会给两份互不兼容的库命名同一个目录。 + +实测(构建 `xim:picolibc-arm` 时):按 `/`,七个档位塌成五个目录, +`armv7e-m/aapcs/libc.a` 带着 `Tag_ABI_HardFP_use` —— 硬浮点的构建,坐在软浮点 +程序会去找它的位置上。**构建期什么都没报。** + +⭐ 填这一列**不**给这些行默认配 C 库:只有解析出 sysroot 之后才会读它,目标表仍然 +不绑定任何一个。零 libc 仍是默认,这只是让选入这件事能成立。 + +判据是浮点 ABI 而不是「链接通过」:`tests/e2e/338` 构建**软浮点**那一行,断言产物 +里没有硬浮点 ABI 标记,然后启动它并读退出码。⚠️ 并且断言产物**不为空** —— 实测: +缺了 crt0 的链接会成功并报 `Size fw text 0 data 0`,一个格式良好、什么都没有的 +ELF,任何只看 `Finished` 的检查都会放行。 + runner 有了名字,工具有了档位,`--locked` 成为断言,`mcpp emit sbom`。 ### ⭐⭐ 一条命令,加具名的例外 diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index 0e842213..0773972c 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -251,20 +251,38 @@ inline constexpr Spec kTable[] = { // the x86_64 row's problem does not recur here. Verified end to end: a // freestanding thumb object links under `ld.lld` and boots under QEMU. // - // `libdir` is EMPTY on every row because `sysroot` is (see the target - // table): the column is read only when a sysroot has been resolved, and a - // value here could never be checked. + // ⚠️⚠️ `libdir` IS THE TRIPLE HERE, NOT `/` LIKE EVERY OTHER + // ROW, AND THE DIFFERENCE IS AN ABI SUBSTITUTION THAT NOTHING REPORTS. + // + // The column names the sub-directory a multilib C library uses, and on + // riscv `/` separates every profile because `mabi` there IS + // the float ABI — `lp64d` and `lp64` are different values. On ARM `mabi` + // names the PROCEDURE CALL STANDARD and is `aapcs` for both variants, while + // the float ABI lives in this triple's `eabi`/`eabihf` suffix. So + // `armv7e-m/aapcs` would name ONE directory for two incompatible libraries. + // + // Measured while building `xim:picolibc-arm`: under the sibling convention + // the seven profiles collapsed into five directories and + // `armv7e-m/aapcs/libc.a` came out carrying `Tag_ABI_HardFP_use` — the + // hard-float build, sitting exactly where a soft-float program would find + // it. Nothing failed at build time. + // + // ⚠️ Filling this does NOT give these rows a C library by default: the + // column is read only once a sysroot has been resolved, and the target + // table still binds none. The zero-libc tier stays the default and + // `[target.] sysroot = "xim:picolibc-arm@1.8.12"` is the opt-in. + // This makes the opt-in work; it does not take the opt-out away. // // ⚠️ `-mabi=aapcs`, matching the aarch64 row and for the same reason: on // ARM `-mabi` names a procedure call standard, not a data model. // triple march mabi mcmodel libdir extra - { "thumbv6m-none-eabi", "armv6-m", "aapcs", "", "", kThumbSoftExtra }, - { "thumbv7m-none-eabi", "armv7-m", "aapcs", "", "", kThumbSoftExtra }, - { "thumbv7em-none-eabi", "armv7e-m", "aapcs", "", "", kThumbSoftExtra }, - { "thumbv7em-none-eabihf", "armv7e-m", "aapcs", "", "" }, - { "thumbv8m.base-none-eabi", "armv8-m.base","aapcs","", "", kThumbSoftExtra }, - { "thumbv8m.main-none-eabi", "armv8-m.main","aapcs","", "", kThumbSoftExtra }, - { "thumbv8m.main-none-eabihf","armv8-m.main","aapcs","", "" }, + { "thumbv6m-none-eabi", "armv6-m", "aapcs", "", "thumbv6m-none-eabi", kThumbSoftExtra }, + { "thumbv7m-none-eabi", "armv7-m", "aapcs", "", "thumbv7m-none-eabi", kThumbSoftExtra }, + { "thumbv7em-none-eabi", "armv7e-m", "aapcs", "", "thumbv7em-none-eabi", kThumbSoftExtra }, + { "thumbv7em-none-eabihf", "armv7e-m", "aapcs", "", "thumbv7em-none-eabihf" }, + { "thumbv8m.base-none-eabi", "armv8-m.base","aapcs","", "thumbv8m.base-none-eabi", kThumbSoftExtra }, + { "thumbv8m.main-none-eabi", "armv8-m.main","aapcs","", "thumbv8m.main-none-eabi", kThumbSoftExtra }, + { "thumbv8m.main-none-eabihf","armv8-m.main","aapcs","", "thumbv8m.main-none-eabihf" }, // ── ARMv7-A (Cortex-A, 32-bit) ────────────────────────────────────────── // // ⭐ THE FIRST 32-BIT MACHINE WITH A MEMORY MANAGEMENT UNIT, AND THAT IS @@ -295,8 +313,18 @@ inline constexpr Spec kTable[] = { // the WRONG exit status — measured: a program exiting 0 reported 1. That is // a board fact rather than a target fact, recorded here because it is where // the next person to write such a board will look. - { "armv7a-none-eabi", "armv7-a", "aapcs", "", "", kArmSoftExtra }, - { "armv7a-none-eabihf", "armv7-a", "aapcs", "", "" }, + // + // ⚠️ `libdir` IS THE TRIPLE HERE TOO, AND FOR THE SAME REASON AS THE + // M ROWS — even though no package carries an A-profile multilib yet. + // + // The column is a CONVENTION about where a sysroot puts a profile, not a + // claim that one exists: it is read only after a sysroot has been resolved, + // and an absent directory is skipped. Leaving it empty would leave the next + // A-profile payload free to choose `/` — which maps these two + // incompatible libraries onto one directory, exactly as it did for + // `armv7e-m` when `xim:picolibc-arm` was first built. + { "armv7a-none-eabi", "armv7-a", "aapcs", "", "armv7a-none-eabi", kArmSoftExtra }, + { "armv7a-none-eabihf", "armv7-a", "aapcs", "", "armv7a-none-eabihf" }, }; // The single read point. Returns nullopt for anything that is not a known diff --git a/tests/e2e/338_cortex_m_picolibc_sysroot.sh b/tests/e2e/338_cortex_m_picolibc_sysroot.sh new file mode 100755 index 00000000..7bd58213 --- /dev/null +++ b/tests/e2e/338_cortex_m_picolibc_sysroot.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell qemu-arm +# A Cortex-M project opts into a C library, and gets the RIGHT multilib. +# +# ⭐⭐ THE ZERO-LIBC TIER IS THE DEFAULT AND THIS IS THE OPT-IN. Every +# `thumb*-none-eabi*` row carries an empty C-library column, so a project +# targeting one begins with no libc unless it says otherwise. One line says +# otherwise, and `libdir` is what makes that line find anything. +# +# ⚠️⚠️ AND THE ASSERTION IS THE FLOAT ABI, NOT THAT IT LINKS. +# +# `libdir` names the sub-directory a multilib C library uses. On riscv +# `/` separates every profile because `mabi` there IS the float +# ABI; on ARM `mabi` names the procedure call standard and is `aapcs` for both +# variants, while the float ABI lives in the triple's `eabi`/`eabihf` suffix. So +# `armv7e-m/aapcs` would name ONE directory for two incompatible libraries — +# measured while building `xim:picolibc-arm`, where the soft-float row silently +# received a library carrying `Tag_ABI_HardFP_use`. +# +# A test that only linked would pass either way. This one builds the SOFT-float +# row and asserts the produced image declares no hard-float ABI. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +qemu_arm() { + local d c + for d in "${MCPP_HOME:-$HOME/.mcpp}/registry" "$HOME/.xlings"; do + c=$(ls "$d"/data/xpkgs/xim-x-qemu-arm/*/bin/qemu-system-arm 2>/dev/null | sort -V | tail -1) + [ -n "$c" ] && [ -x "$c" ] && { echo "$c"; return 0; } + done + command -v qemu-system-arm 2>/dev/null && return 0 + return 1 +} +QEMU="$(qemu_arm)" || { echo "SKIP: qemu-system-arm not installed"; exit 0; } + +llvm_tool() { + local c + c=$(ls "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-llvm/*/bin/"$1" 2>/dev/null | sort -V | tail -1) + [ -n "$c" ] && { echo "$c"; return 0; } + command -v "$1" 2>/dev/null +} +READELF="$(llvm_tool llvm-readelf)" || { echo "SKIP: llvm-readelf not found"; exit 0; } + +# ⚠️ The payload has to be present. Installing it here rather than skipping +# would make the test about the install; skipping when it is absent keeps the +# criterion about the ENGINE, which is what this suite tests. +# ⚠️ `xim-x-picolibc-arm` AND NOT `*-x-picolibc-arm`. The engine resolves the +# name `xim:picolibc-arm`, so a copy installed under any other namespace — a +# LOCAL index entry, say — satisfies a glob and not the engine. Measured: with +# the loose pattern this test proceeded against a `local-x-` payload and failed +# on `'stdio.h' file not found`, having asserted nothing about the engine. +sysroot_dir() { + local d c + for d in "${MCPP_HOME:-$HOME/.mcpp}/registry" "$HOME/.xlings"; do + for c in "$d"/data/xpkgs/xim-x-picolibc-arm/*/; do + [ -d "$c/lib/thumbv7m-none-eabi" ] && { echo "$c"; return 0; } + done + done + return 1 +} +SYSROOT="$(sysroot_dir)" || { echo "SKIP: picolibc-arm is not installed"; exit 0; } + +mkdir -p "$work/fw/src" +cd "$work/fw" +cat > mcpp.toml <<'TOML' +[package] +name = "fw" +version = "0.1.0" + +[build] +target = "thumbv7m-none-eabi" +sources = ["src/main.c"] + +[targets.fw] +kind = "bin" +main = "src/main.c" + +# The opt-in, and the whole of it. +[target.thumbv7m-none-eabi] +sysroot = "xim:picolibc-arm@1.8.12" +TOML + +# ⭐ LOCATION IS A TARGET FACT; SELECTION IS A BOARD FACT. The engine resolved +# WHERE the C library is and which multilib profile applies. WHICH startup +# object, WHICH linker script and WHICH libraries are decisions a board makes, +# and this fixture stands in for a board package. +# +# ⚠️ `target_libc_profile()` IS THE `libdir` COLUMN, AND WITHOUT IT NONE OF THIS +# CAN BE WRITTEN. It is empty when the column is, and the board is then reduced +# to guessing a directory name — which is the thing that goes wrong silently. +cat > build.mcpp <<'BUILD' +import mcpp; +import std; +int main() { + const std::string root = mcpp::sysroot_dir() ? mcpp::sysroot_dir() : ""; + const std::string prof = mcpp::target_libc_profile() + ? mcpp::target_libc_profile() : ""; + if (prof.empty()) { + std::cerr << "the target row carries no libc profile; a board cannot " + "name a file inside the sysroot\n"; + return 1; + } + const std::string lib = root + "/lib/" + prof; + mcpp::link_script((lib + "/picolibc.ld").c_str()); + mcpp::link_search(lib.c_str()); + mcpp::link_lib("crt0-semihost"); // pulled from the archive by ENTRY + mcpp::link_lib("c"); + mcpp::link_lib("semihost"); + mcpp::link_lib(mcpp::target_builtins_lib() ? mcpp::target_builtins_lib() : ""); + return 0; +} +BUILD + +cat > src/main.c <<'C' +#include +/* `volatile`, so the multiply survives constant folding and picolibc's float + formatting is really exercised — the path that needs the builtins. */ +volatile float fa = 3.0f, fb = 4.0f; +int main(void) { printf("picolibc: %.2f\n", (double)(fa * fb + 1.0f)); return 0; } +C + +# Twice, the first allowed to fail: the target world is installed during a build. +"$MCPP" build > /dev/null 2>&1 || true +"$MCPP" build 2>&1 | tail -20 || { echo "FAIL: the opt-in build did not succeed"; exit 1; } + +elf=$(find target -type f -name fw | head -1) +[ -n "$elf" ] || { echo "FAIL: no artefact"; exit 1; } + +# ⚠️⚠️ AN EMPTY IMAGE IS NOT A PASS, AND IT IS WHAT A MISSING BOARD SELECTION +# PRODUCES. Measured while writing this: without the crt0 the link SUCCEEDED and +# mcpp reported `Size fw text 0 data 0` — a well-formed ELF containing +# nothing. Every check that only looked for "Finished" would have passed. +size=$(wc -c < "$elf") +[ "$size" -gt 4096 ] || { echo "FAIL: the artefact is $size bytes — an empty link"; exit 1; } + +# ⭐ THE ASSERTION THAT CATCHES THE WRONG MULTILIB. A hard-float libc linked +# into a soft-float image leaves `Tag_ABI_HardFP_use` in the attributes. +hard=$("$READELF" -A "$elf" 2>/dev/null | grep -c 'ABI_HardFP_use' || true) +[ "$hard" = "0" ] || { + echo "FAIL: a soft-float image carries $hard hard-float ABI tags — the wrong multilib was linked" + exit 1; } +echo " ok the soft-float row linked a soft-float C library" + +# And it runs: the C library is not merely present, its printf works. +set +e +timeout 30 "$QEMU" -machine mps2-an385 -cpu cortex-m3 -nographic -semihosting \ + -no-reboot -kernel "$elf" > run.log 2>&1 +rc=$? +set -e +cat run.log +grep -q 'picolibc: 13.00' run.log \ + || { echo "FAIL: printf did not produce the expected output"; exit 1; } +[ "$rc" = "0" ] || { echo "FAIL: the image ran but exited $rc"; exit 1; } + +echo "PASS: a Cortex-M project opts into picolibc and gets the right multilib" diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index fffa3c48..79e6a844 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -559,3 +559,48 @@ TEST(FreestandingTarget, EveryRowCompilesWithPerFunctionSections) { } EXPECT_GT(rows, 0); } + +// ⚠️⚠️ THE MULTILIB KEY IS NOT `/` ON ARM, AND ASSUMING IT WAS +// PRODUCES AN ABI SUBSTITUTION THAT NOTHING REPORTS. +// +// The column names the sub-directory a multilib C library uses. On riscv, +// `/` separates every profile because `mabi` there IS the float +// ABI — `lp64d` and `lp64` are different values. On ARM `mabi` names the +// procedure call standard and is `aapcs` for both variants, while the float ABI +// lives in the triple's `eabi`/`eabihf` suffix. +// +// Measured while building `xim:picolibc-arm`: under the sibling convention the +// seven M-profile profiles collapsed into five directories, and +// `armv7e-m/aapcs/libc.a` came out carrying `Tag_ABI_HardFP_use` — the +// hard-float build, where a soft-float program would find it. Nothing failed. +// +// So the assertion is not "libdir is non-empty": it is that the soft and hard +// variants of ONE architecture name DIFFERENT directories, which is the +// property the layout exists to preserve. +TEST(FreestandingTarget, Arm32SoftAndHardVariantsNameDifferentLibdirs) { + std::map> byMarch; + int rows = 0; + for (const auto& spec : mcpp::freestanding::known()) { + if (!is_arm32(spec.triple)) continue; + ++rows; + EXPECT_FALSE(spec.libdir.empty()) + << spec.triple << " has no libdir, so an opt-in sysroot cannot be found"; + byMarch[std::string(spec.march)].push_back(std::string(spec.libdir)); + } + EXPECT_GT(rows, 0); + + int pairs = 0; + for (auto const& [march, dirs] : byMarch) { + if (dirs.size() < 2) continue; + ++pairs; + std::set distinct(dirs.begin(), dirs.end()); + EXPECT_EQ(distinct.size(), dirs.size()) + << march << " maps " << dirs.size() << " rows onto " + << distinct.size() << " directories: a soft-float program would " + "link a hard-float library, silently"; + } + // ⚠️ A denominator. If no architecture had two rows the loop above would + // assert nothing, and the property would be untested rather than held. + EXPECT_GT(pairs, 0) << "no architecture carries both float ABIs; the check " + "above quantified over nothing"; +} From d43083d2be97c26df98d14723e483212298be646 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:08:04 +0800 Subject: [PATCH 2/2] test(337): the build half reads the cache entry, not whichever file find returns first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ IT PASSED LOCALLY AND FAILED ON CI, WHICH IS THE SIGNATURE OF AN ASSERTION THAT DEPENDS ON SOMETHING NOBODY CHOSE. The block ran `find target -name featrun | head -1`. By then two output directories exist — one per feature set — and which one `find` walks first is filesystem order. What the fix actually changed is the cache ENTRY: it records the feature set its artefacts were built with, and both fast paths compare it. Entries are written most-recently-used first, so the first `features=` line belongs to the build that just ran. Reading it is exact and order-free. Measured on the same project: plain -> '', --features loud -> 'loud', plain again -> '' — the third being the reading that was 'loud' before the fix. --- .../e2e/337_run_takes_features_and_profile.sh | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/e2e/337_run_takes_features_and_profile.sh b/tests/e2e/337_run_takes_features_and_profile.sh index b73ba792..ba2869f4 100755 --- a/tests/e2e/337_run_takes_features_and_profile.sh +++ b/tests/e2e/337_run_takes_features_and_profile.sh @@ -85,19 +85,37 @@ want "$again" "dev" "a plain run inherited the previous --release" # # 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; } +# ⚠️⚠️ THE ASSERTION IS ON WHAT THE CACHE ENTRY RECORDS, NOT ON WHICH FILE A +# `find` HAPPENS TO RETURN FIRST. +# +# The first version of this block ran `find target -name featrun | head -1`. +# Two output directories exist by then — one per feature set — and which one +# `find` walks first is filesystem order. It passed locally and failed on CI, +# which is the signature of an assertion that depends on something nobody +# chose. +# +# What the fix actually changed is the ENTRY: it now records the feature set its +# artefacts were built with, and both fast paths compare it. Entries are written +# most-recently-used first, so the first `features=` line in the cache belongs +# to the build that just ran — and reading it is exact. +mru_features() { awk -F= '/^features=/{print $2; exit}' target/.build_cache; } rm -rf target "$MCPP" build >/dev/null 2>&1 -first="$(./"$(artifact)")" +[ -z "$(mru_features)" ] || { + echo "FAIL: a plain build recorded features '$(mru_features)'"; exit 1; } + "$MCPP" build --features loud >/dev/null 2>&1 +[ "$(mru_features)" = "loud" ] || { + echo "FAIL: --features loud recorded '$(mru_features)', expected loud"; exit 1; } + +# ⚠️ THE ONE THAT CAUGHT THE PRE-EXISTING DEFECT. Before the entry carried a +# feature set, this plain build matched the entry `--features loud` had written, +# reported success in 0.00s and left the loud artefact in place. Measured: three +# consecutive builds of one project printed `quiet`, `LOUD`, `LOUD`. "$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 +[ -z "$(mru_features)" ] || { + echo "FAIL: a plain build after --features matched an entry recorded as '$(mru_features)'" + exit 1; } echo "PASS: mcpp run takes --features and --profile, and the fast path honours both"