diff --git a/.agents/docs/2026-11-12-ecosystem-architecture-optimization-design.md b/.agents/docs/2026-11-12-ecosystem-architecture-optimization-design.md index b55655d01..23d244a43 100644 --- a/.agents/docs/2026-11-12-ecosystem-architecture-optimization-design.md +++ b/.agents/docs/2026-11-12-ecosystem-architecture-optimization-design.md @@ -904,9 +904,335 @@ S1 与 S2 之间不插入新能力。S3 之后的每个新系统是对架构的 宿主一道闸;S5 的 K7、DOS 的 A 路线、UEFI 平台包与单任务环境的规范明示;S6 的新系统;xim 的第二个 PR(基座准入作业 BP3–BP7、wine、DOSBox、OVMF 的包、`-disk-image` 的正名);openkal-freebsd 的运行器改用 dirun。 +第四版(2026.11.13.1)实施了其中的:e2e 向 mtest 的 C++ 用例的迁移(各宿主的单元作业以 `mcpp test` 运行)、两个金丝雀与夜间 +矩阵(5.7,`tests/canary/`、`canaries.yml`)、UEFI 收敛(第 19 节)、xim 的第二个 PR(wine、DOSBox、OVMF、djgpp 的包)。 +用例运行的位置与所需能力由用例以 mtest 0.8.0 的属性陈述(`"x"_test[s::windows][s::msvc]`;`only{}`、`except{}` +取 mcpplibs.os 的平台与架构,`capability` 由程序在代码中回答一次),不再由脚本头的 `# requires:` 与运行器的探测表承担; +只在 Windows 上运行的 45 个脚本随之迁移(`tests/e2e/windows/`,迁移表第 24 批)。需要指定用例而本机不能构建测试的作业 +(屏蔽 Visual Studio 的 Windows、只有交叉工具链的 Linux、ci-fresh-install 的已发布 mcpp)从构建作业写出的测试归档 +运行它们(`build.yml` 的 `tests`、`.github/actions/e2e-cases`),每个用例须打印其 `ok` 行;被测的 mcpp 缺省是归档自带的构建, +`MCPP_E2E_MCPP` 另行指定。 +CI 的触发模型(5.11)随之实施:`changes` 作业给出本次运行的集合(PR 的推送为 `pr`,带 `ci:full` 标签的 PR、合入 main 与手动 +触发为 `full`),单元作业的 `--since` 与 e2e 分片的 `E2E_SET` 都取它;`e2e-plan` 作业以 `mtest ci plan` 按 main 存储中的 +测量历史给出每个宿主的分片数(`tests/e2e/mtest.toml` 的 `[ci.plan]`,每片 12 分钟,至多 8 片),集合不给工作的宿主不启动 +分片;每个宿主的构建作业在上传之后运行该宿主的闸(`e2e/basics`:本提交的 mcpp 新建、构建并运行一个程序),闸失败即该作业 +失败、其下游不启动,并取消整次运行。闸取 `e2e/basics` 而非单元测试:后者的构建要数分钟,超出 2–3 分钟的预算。 +同版并入的两处缺陷:GCC 14 之前的编译器没有 C++ 模块与 P1689,此前在第一次编译时因 `-fmodules` 失败;现在不声明、不导入 +模块的 C++ 单元以不带模块选项的翻译单元构建(图取 mcpp 自己的扫描),需要模块的第一个单元在规划时被拒绝并指出所需——8.2 的 +A 路线表"libstdc++ 12,无模块"由此成立(实测:DJGPP 的 GCC 12.2.0 构建异常、`std::vector`、`std::sort` 的程序,经 DOSBox 运行,状态 7); +中断时先请每个程序结束、给两秒,再结束其单元,各等待以 `try_wait` 轮询而不持锁(此前 `wait_for` 持锁使终止拿不到锁, +`mcpp __action-stamp` 的子进程成为孤儿)。 + 实施中观察到而本轮没有处理的四点: - lld-link 为"本地定义却按导入引用"的符号所造的槽不算作引用(LNK4217 的情形),节回收之后槽为空。引擎可以拒绝报告了该警告的链接;头文件在 Windows 上缺省为 `dllimport` 的其他静态库属于同一类。 - 在 openkal-windows 自身的目录里 `mcpp test --target x86_64-windows-musl`:测试单元与测试库的模块按两个三元组编译,模块不可用。已发布的引擎同样。 - 一个 mcpp 读索引条目时,另一个 mcpp 正在刷新索引:读的一方报告"没有可读的索引条目"(Windows 上 e2e 1071 出现一次)。 - 两条腿并行构建时出现一次"compiler-run owner refused unit admission"(Linux 上 e2e 963,一次)。 +- mingw 的 GCC 16.1 在模块单元中把内联函数的局部静态(`std::_Sp_make_shared_tag::_S_ti()::__tag`)定义在 COMDAT 之外: + 两个模块单元都用到 `std::make_shared` 时链接因重复定义失败(金丝雀 `cxx` 的 `x86_64-windows-gnu` 测试程序)。属编译器; + 矩阵以 `fails` 记录。 + +## 16. 工具链载荷即数据(2026-11-13 补充) + +### 16.1 现状(测) + +**版本已经是数据。** 发布的引擎(2026.11.10.2):`--toolchain gcc@16.1` 解析为 `xim:gcc@16.1.0`,`xim:gcc@16.1.0` 原样可用, +`gcc@16.9.0` 被拒绝并说明索引里没有该包。因此 xim-pkgindex 发布 `gcc 16.2.x` 之后,`[toolchain] default = "gcc@16.2"`(该系列在 +索引中的最新者)或 `"xim:gcc@16.2.0"` 不需要改动引擎即可使用。默认版本由内置行固定,改变默认需要发布引擎,使用则不需要。 + +**名字不是数据。** 三处把载荷写在代码里: + +| 位置 | 写死的内容 | +|---|---| +| `compat::normalize_spec` | 可接受的名字:`gcc`、`llvm`、`msvc`、`emsdk`、`android-ndk` 与别名 `clang`、`musl-gcc`、`mingw*`、`-gcc`;其余名字(含 `xim:` 前缀)被拒绝 | +| `to_xim_package` | (家族,目标,宿主)到包名的分支:gcc 到 `gcc`、`musl-gcc`、`mingw-gcc`、`mingw-cross-gcc`、`-gcc`;llvm 到 `llvm`、`emsdk`、`android-ndk`;每个分支各写前端候选与前端目录 | +| `identify_xim_payload` | 上一项的反向映射,供 `toolchain list` 与 `doctor` 枚举 | + +新增一个工具链发行版(厂商的 clang、`riscv64-elf-gcc`、icx)因此仍要改引擎,与 R1"行是数据,读行的代码不是"不一致。已经数据化的 +部分是三项:载荷的描述文件 `.mcpp-toolchain.json`(schema 1:`frontend`、`platform_floor`、`std_module_defines`、`runner`), +按路径命名的工具链(`path:` 与清单的 `[toolchain]` 路径表,家族由目录里的驱动判定),以及行的 `gcc-triple`。 + +### 16.2 原则 + +**家族是引擎的词汇,载荷是数据。** 家族回答"编译器说哪一套选项"(gcc、llvm、msvc),引擎为每一套各有一份实现,它保持封闭。 +载荷回答"哪个包、前端在哪里、带哪些工具",它是包的事实,由包陈述。 + +### 16.3 方案 + +**T1 描述文件 schema 2 即路径表。** `.mcpp-toolchain.json` 增加清单 `[toolchain]` 路径表的键:`family`、`prefix`、`sysroot`、 +`tools`(角色到程序),路径相对载荷根。一个载荷对自身的陈述与一个项目对本机目录的陈述是同一张表,读同一个结构 +(`LocalToolchain`)。schema 1 的四个键不变;没有描述文件的已发布载荷按原样工作。 + +**T2 `xim:@` 接受任何名字。** 名字在家族词汇(含别名)之外时,它指一个载荷:安装该包,读它的描述文件,然后按 +`path:<载荷根>` 加描述文件的表解析——家族由驱动判定(与表中的 `family` 不一致即拒绝),目标由驱动回答。描述文件缺失时拒绝, +指出缺少的文件与键。不带 `xim:` 的未知名字仍被拒绝:命名空间是"这是一个包"的唯一说法。平台的 `toolchain` 键因此可以直接写 +任何载荷,平台定义的行不需要新键。 + +**T3 默认映射是表。** 裸名(`gcc@16.1.0`、`llvm@23.1.3`)是简写:(家族,目标,宿主)经一张规则表得到包名、前端目录、前端候选与 +glibc 修正,规则按序匹配,取第一条。表在 toolchain-model 的数据文件里,`to_xim_package` 与 `identify_xim_payload` 都读它, +正反两个方向不再各写一遍。别名(`clang`、`musl-gcc`、`mingw*`)保持兼容提示。 + +**T4 验收:引擎零改动。** 新增一个工具链即发布一个带描述文件的 xim 包;以本轮的 gcc 16.2.0 验证版本开放,以一个词汇外名字的 +载荷验证 T2(e2e:仓库内的假载荷,带 schema 2 描述文件)。 + +**T5 机械门禁。** 统计 `src/` 中载荷名(`emsdk`、`android-ndk`、`musl-gcc`、`mingw-gcc`、`mingw-cross-gcc`)在规则表之外的出现次数, +与基线文件比较,多于基线即失败。 + +### 16.4 本轮的 gcc 16.2.0 与 17 的主干快照 + +默认仍为 gcc 16.1.0,xim 的 `latest` 仍指向正式版。xim-pkgindex 以 `tools/res/build.sh gcc` 从源码构建,两种来源: + +| 版本 | 来源 | 来源核对 | 平台 | +|---|---|---|---| +| `16.2.0` | GNU 的发布包(`ftpmirror.gnu.org`) | GNU 签名已核对(RSA 7F74F97C103468EE5D750B583AB00996FC26A641),摘要为实测 | Linux x86_64、aarch64 | +| `17.0.0-20261108` | GCC 的快照目录(`gcc.gnu.org/pub/gcc/snapshots/17-20261108/`) | 上游不签快照,摘要为加入时实测;源码自述 `BASE-VER 17.0.0`、`DATESTAMP 20261108`、`DEV-PHASE experimental` | Linux x86_64、aarch64 | + +**主干快照的版本写法是 `.0.0-`。** 它就是快照的编译器对自身的陈述(`17.0.0 20261108 (experimental)`),与上游 +快照名 `gcc-17-20261108` 一一对应。按 xpkg 版本的次序,日期段是预发布段:快照排在 `17.0.0` 与首个正式版 `17.1.0` 之前, +快照之间按日期排序。等值匹配要求预发布段相同,因此 `gcc@17` 与 `gcc@17.0` 不选中快照,只有写全 +`xim:gcc@17.0.0-20261108`(或 `gcc@17.0.0-20261108`)才使用它:前沿的使用者显式选择,其余使用者不受影响。一个日期是一份不变 +的源码,锁文件、指纹与缓存因此可复现;不设滚动版本。索引只保留一个快照(本轮为 20261108),换新快照时替换条目,xlings-res 的 +资源保留。 + +构建与正式版相同:C 程序与使用 `import std` 的 C++23 程序构建并运行,`--version` 报告所写的版本,二进制只依赖 glibc 的库; +`res-build` 的 `only` 接受 `what@version`,新版本的构建不重建已发布的版本。产物发布到 xlings-res 并以 gtc 镜像到 GitCode, +`pkgs/g/gcc.lua` 增加两个版本。 + +验证项:`gcc@16.2`、`xim:gcc@16.2.0`、`xim:gcc@17.0.0-20261108` 的解析、安装与构建(含 `import std`),`gcc@17` 不选中快照, +`[toolchain] default` 与 `--toolchain` 两种写法,`toolchain list` 与 `doctor` 的枚举,默认(16.1.0)不变。 + +## 17. 工作区的并发、升级后的重编与构建目录的体积(2026-11-13 补充) + +### 17.1 现状(测) + +**并发的契约。** 一个配置一个构建目录,一把锁(`dirlock`),只在写目录时持有:清单、解析、网络、工具链与扫描不持锁, +`mcpp test` 在第一个测试运行之前释放,`mcpp run` 在程序启动之前释放。同一配置的两个命令只在编译与链接时排队,等待方显示 +持有者;不同配置(profile、目标、选择)是不同的目录,互不等待。一个配置只有一份 `build.ninja` 与 ninja 状态,两个 ninja 同写 +即互相破坏(mcpp#839),因此锁的粒度是配置而不是成员。 + +**升级后的重编。** 两个成员的工作区(一个库成员带 mtest 测试,一个程序成员),以 2026.11.10.2 构建并测试,再以 2026.11.12.1 +构建:153 个目标文件与 BMI 中 18 个重编,即两个成员与 mtest 的全部;全局缓存供给的依赖未重编。原因不是指纹的内容: +锁文件的哈希不在指纹中(该字段为空),同一谱系的新指纹接管旧目录(2026-10-31 §8,目录改名而非复制)。原因在命令中的两个 +路径: + +- 目录的名字:编译命令含绝对的输出路径,链接命令含目录内的 specs 路径,路径含指纹,目录改名使这些命令全部改变; +- 引擎的路径:图在命令中运行引擎(`$mcpp __compiler-cap … -- <编译器>`、BMI 比较、链接包装、暂存、模块绑定), + `$mcpp` 是引擎的绝对路径,经 xlings 安装时含版本(`xim-x-mcpp/<版本>/bin/mcpp`)。只改目录名之后仍是 18 个重编(实测)。 + +ninja 按命令重编。随后的警告"fingerprint changed, full rebuild"不区分"接管并按命令重编"与"新目录"。 + +**体积。** 已有:`-ffunction-sections -fdata-sections` 与测试程序链接的 `--gc-sections`,ELF 上的 `-gz=zlib`(mcpp#874), +开发 profile 的拆分 DWARF(`.dwo`),`mcpp clean --report`(每个配置的 obj、bmi、bin),构建后的过期清扫(`[build] keep_days`) +与 `[build] target_budget`。`debug` 只有开与关。程序与测试链接目标文件,不经静态库:构建目录中的 `.a` 只是库成员的交付物 +(`bin/`),mcpp 自身的两个构建目录(638 MB、466 MB)中归档为零。 + +### 17.2 方案 + +**W1 构建目录以配置的谱系命名。** 目录名是谱系(编译器家族、目标、标准库家族、语言标准、配置的选项;2026-10-31 §8.2)的哈希, +演进的部分(编译器的版本与驱动、标准库的版本、mcpp 的版本、运行时契约)记在目录内:`toolchain` 已有,完整指纹另记于 +`fingerprint`。于是: + +- mcpp 或依赖升级不改变任何路径,ninja 只重编命令真正改变的边; +- 图经构建目录自己的引擎链接运行引擎:`<目录>/engine/epoch-/mcpp`,硬链接(不能时复制)到当前引擎,规划者在目录锁下 + 使之为当前引擎;一条命令只随编译器、选项与文件改变。链接的路径含 `kCacheEpoch`:引擎各步所写内容的含义改变的版本提升它, + 各边随之重新运行,与依赖缓存以 epoch 而不以版本号为键是同一原则。快速路径接受指向本引擎的链接。自包含的家目录由引擎的位置 + 推出,经链接运行的子命令推不出,因此此时 mcpp 为其启动的程序陈述 `MCPP_HOME`; +- 编译器的构建改变时,目录的内容在原处替换(锁文件除外),与现在"移除前任"的结果相同,状态行不变;引擎或演进部分改变时, + 编译器的在途记录(`.sched`)删除,与接管时相同; +- 以指纹命名的既有目录由现有的接管逻辑按谱系文件接管一次,此后路径稳定; +- 快速路径的记录保存完整指纹,入口检查读目录内的 `fingerprint`,不再比较目录名; +- 警告只在谱系改变(配置改变,新目录、全量构建)时出现,并说明是哪一项改变;同一目录的继续不发警告,重编的内容由 ninja 的 + 进度说明。 + +指纹本身不变,仍是键(打包的 `buildKey`、`--print-fingerprint`);改变的只是它不再兼作路径。实测(同一工作区,引擎只改版本号): +重编由 153 个中的 18 个降为 0,构建 0.22 s,测试 0.43 s;源码改动、profile 切换与 `debug = "lines"` 仍按预期重编或新开目录。 + +**W2 被取代的目录。** 不另立机制。升级本来就不复制目录(接管),W1 之后连改名也没有;配置的改变产生的新目录是开发者会切回的 +目录,按 `keep_days` 清扫。 + +**W3 时间与体积可见。** + +- 等待构建目录的时间进入 `Finished` 的分解(`waited 1m30s for the build directory`),从"plan"中扣除; +- `mcpp clean --report` 增加一列 `debug`,即拆分出的调试信息(`.dwo`)。 + +**W4 契约成文。** 17.1 的并发契约写入 `docs/07-workspace.md` 与 `docs/08-testing.md`(中英),包括"测一个成员、同时改另一个 +成员"的可行性与等待的条件。 + +**W5 调试级别 `"lines"`。** `[profile.] debug = "lines"` 只保留行表:GNU 与 LLVM 方言为 `-g1`(clang 即 +`-gline-tables-only`),回溯与 sanitizer 报告仍有文件与行号,没有变量与类型信息。cl.exe 没有只含行表的级别,`"lines"` 在其上即 +`/Z7`。其余字符串被拒绝。默认不变;配置字符串中 `debug=1` 不变,`"lines"` 为 `debug=lines`,既有目录的身份不变。 + +**W6 薄归档:测后不采纳。** 构建内的链接输入是目标文件,归档只作交付物,薄归档引用构建目录中的路径,作为交付物是错的; +可节省的体积为零。 + +### 17.3 推荐的做法(文档,不改引擎) + +写入 `docs/08-testing.md` 与 `docs/04-mcpp-toml.md` 的 profile 一节: + +1. 全量测试与 CI 使用单独的 profile,例如 `[profile.ci]` 写 `debug = "lines"`(或 `false`)与 `strip = true`。日常开发保留完整的 + 调试信息。 +2. 测试多的包使用 `[test] programs = "directory"`:一个目录一个程序,链接次数由文件数降到目录数。 +3. 模块的实现放在实现单元(`module X;`):函数体的改动不改变 BMI,导入方不重编。 +4. 成员分层:逻辑放在不依赖 UI 与渲染的成员中,多数测试只链接逻辑层。 +5. 体积以 `mcpp clean --report` 读取,以 `mcpp clean --stale` 回收。 + +### 17.4 考虑过、不采纳 + +- **测试程序默认以共享库链接成员的闭包。** 它去掉闭包的重复链接,但牵涉 Windows 的 DLL 导出语义与模块的符号导出,跨平台的 + 代价大,不能作为通用的默认。 +- **默认剥离测试程序的符号表。** 调试器、sanitizer 与回溯依赖符号表;由 profile 的 `strip` 选择。 + +## 18. 大工作区的测试反馈(mcpp-community/mcpp#927,2026-11-13 补充) + +反馈来自一个 143 个成员、339 个测试程序的工作区(2026.11.12.1、mtest 0.7.1、Linux x86_64、gcc 16.1.0、`dev`)。六项中, +项目自身的部分不在此列:测试文件的放置、成员的分层、CI profile 的采用,以及测试本身 43 分钟的运行时间。 + +### 18.1 归属与现状(代码核对) + +| 项 | 属于 mcpp 的部分 | 现状 | +|---|---|---| +| 1 `programs = "directory"` 对 `tests/` 根下的文件无效 | 语义的表达与提示 | 设计如此:固定目录下的子目录各成一个程序,直接位于固定目录中的文件仍各为程序;设置不改变任何程序时没有任何提示,`--strict` 亦然 | +| 2 `mcpp test -p` 解析两遍、汇总有一段无名 | 一次调用的规划次数与时间的归属 | 测试先以 `mcpp build` 的方式构建包的程序(`MCPP_BIN_`),其快速路径的条件要求 `package_filter` 为空,故 `-p` 必走完整的 `prepare_build`;随后带 dev 依赖再 `prepare_build` 一次。汇总只列 build 与 run | +| 3 两个进程并发时一次 `no local package index` | 索引刷新与读取的并发 | 刷新锁是非阻塞的:拿不到锁的进程跳过刷新并立即读取。mcpp 拥有的索引在暂存目录取回后交换;文件系统不支持原子交换时退化为"旧目录移开、新目录改名",其间索引不存在;provider 自己的索引原地刷新。两者都是读者可见的窗口 | +| 4 测试程序占配置体积七成 | 调试信息的级别、链接形式、程序的粒度 | §17 已有 `debug = "lines"`;`dependency_linkage = "shared"` 按 profile 可写,实测对工作区成员生效(成员依赖成为共享库,放在产品目录中);开发 profile 默认拆分并压缩 DWARF;文档未把它们组织为"开发时的测试体积"一节 | +| 5 升级后 88 分钟全量重编 | 构建目录的身份 | §17 W1 已解决:目录以谱系命名,图经目录的引擎链接运行 mcpp;实测只改版本号的引擎重编 0 个 | +| 6 `clean --stale` 需要人记得 | 被取代目录的回收 | W1 之后升级不再产生目录,编译器改变时原处替换;构建后的清扫(`keep_days`,默认 3 天)与 `target_budget` 已有。剩下的是:配置改变新开目录时,警告不说旧目录的大小 | + +### 18.2 方案 + +**P1 测试程序的粒度:`programs = "package"`,与 `"directory"` 的提示。** `[test] programs` 是测试程序的粒度:`"file"` +(默认)、`"directory"`、`"package"`。`"package"` 使一个包所发现的全部文件成为一个程序,以第一个 glob 的固定目录命名 +(`tests`),规则与 `"directory"` 相同:定义 `main` 的文件至多一个,否则拒绝并指出两者;按测试协议运行时仍是每个文件一个进程 +(`tests/`),隔离与并行不变,链接一次。`"directory"` 的语义不变,并在设置留下了直接位于固定目录中的文件时说一行: + +``` +note: [test] programs = "directory": 12 files directly in tests/ stay programs of their own; "package" makes the suite one program +``` + +设置不改变任何程序时,`--strict` 拒绝。文档写明三种粒度与固定目录的含义。它同时是第 4 项的主要手段:程序数由测试文件数降为 +目录数或包数,闭包的重复链接随之减少。 + +**P2 一次调用,每个配置规划一次;汇总给出每一段。** + +- 程序的预构建对 `-p` 与工作区选择也取快速路径:按所选成员的配置组匹配记录,与 `try_fast_workspace_build` 相同; +- 需要规划时,同一次调用内的第二次 `prepare_build` 复用第一次的依赖解析与工具链解析(以清单、锁文件与覆盖项为键的进程内结果), + 不再解析与打印第二遍。`mcpp run --format`(构建后打包)同样受益; +- 测试的汇总与 `Finished` 一致地分解:`finished in 7.77s (plan 2.10s · programs 1.73s · build 1.60s · run 2.34s)`,各段之和 + 为总时长,等待构建目录时另列 `waited`(§17 W3)。 + +**P3 索引:刷新可以跳过,读取不读替换中的树。** 刷新分两段:取回在暂存目录中进行,不阻塞任何人;交换在索引锁下进行,只持有 +毫秒。读取在索引的共享锁下进行,因此不会落在交换的窗口中(包括不支持原子交换的文件系统)。provider 自己的索引也按此取回到 +暂存目录再交换(`sync_index` 已接受目标目录),不再原地改写。拿不到刷新锁的进程仍跳过刷新,但它的读取等待交换完成。 + +**P4 开发时测试的体积:已有能力的组织与文档。** 不新增引擎键: + +- `[workspace.profile.] debug = "lines"`(§17 W5):回溯与 sanitizer 仍有文件与行号; +- `[workspace.profile.] dependency_linkage = "shared"`:一个成员的测试链接其余成员的共享库,闭包在磁盘上只有一份; + 这是可选的 profile,不是默认(§17.4 的理由不变:Windows 的 DLL 导出与模块符号导出); +- P1 的程序粒度。 + +`docs/08-testing.md` 的"测试构建的体积"一节按上述顺序给出开发与 CI 两个 profile 的写法。 + +**P5 升级。** 由 §17 W1 解决,不另立机制;`mtest` 升级只重编导入 mtest 的单元与重链接测试程序,这是必要的。 + +**P6 新目录的提示。** 配置改变新开目录时,警告给出旧目录的大小与回收它的命令: + +``` +warning: the configuration changed (debug 1 → lines): 7a72… is built in full beside ed03… (2.1 GiB); `mcpp clean --stale` drops it once no build uses it +``` + +### 18.3 实施记录(2026.11.13.1) + +| 项 | 实施 | 持有它的检查 | +|---|---|---| +| P1 | `programs = "package"`:一个包一个程序,以第一个 glob 的固定目录命名,定义 `main` 的文件至多一个;按文件运行的各部分以文件在程序目录(其文件的公共目录)之下的路径命名,不同子目录中的同名文件因此不冲突(目录模式下即文件名,与此前相同);`"directory"` 留下固定目录中的文件时给出 note,不形成任何组时为 `degraded`(`--strict` 拒绝) | mcpp `test_targets.cppm`;e2e 的 C++ 用例 | +| P2 的汇总 | `finished in X (plan · programs · build · run)`:plan 为总时长减去其余各段,programs 不足 5 ms 时不列 | mcpp `execute.cppm`;`tests/e2e/test/streams.cpp` | +| P3 | 拿不到刷新锁、且所声明的索引目录有缺失的进程,等待刷新锁(显示 `Waiting`)后再读;索引完整时仍跳过刷新、立即读取 | mcpp `xlings.cpp` | +| P4 | 文档:`docs/08-testing.md` 的"测试构建的体积"一节(中英) | — | +| P6 | 警告给出旧目录的大小与回收命令 | mcpp `execute.cppm` | + +**P3 的范围比 18.2 小。** 18.2 的方案是读取持共享锁、刷新在暂存目录中进行后交换。#927 报告的现象只在"索引目录不存在"时出现: +索引存在时,刷新以交换替换整棵树,读取者读到的是交换前或交换后的一棵完整的树。因此实施只关闭这一个窗口,读路径不加锁。 +不支持原子交换的文件系统上,"移开、改名"之间的窗口与目录不存在是同一情形,读取者同样等待。 + +**P2 的另两项不在本版。** `-p` 的预构建取快速路径、同一次调用内复用解析:二者改变 `prepare_build` 的入口契约(解析结果的生命周期 +与键),在本轮之外;当前代价是 `-p` 多一次完整规划。 + +**用户裁决。** P1、P2、P3、P6 的取舍向负责人提出过,未得到答复;以上按本文的推荐实施。 + +### 18.4 不采纳 + +- **`[test] debug = "lines"`,测试专有的调试级别。** `mcpp build` 与 `mcpp test` 是同一配置(2026.11.8.1),被测代码的目标文件 + 由两者共用。测试专有的级别要么只作用于测试自己的翻译单元,体积几乎不变;要么使测试成为另一配置,切换即全量重编。级别由 + profile 决定。 +- **默认只剥离符号表而保留调试信息。** 符号表是调试器、性能分析器与回溯在没有 DWARF 时的依据,开发构建不默认去掉;需要时由 + `strip = true` 的 profile 选择。 + +## 19. UEFI 收敛到 openkal:实施中测到的与据此的设计(2026-11-13 补充) + +本节记录"UEFI 收敛到 openkal"(第 5.12 节,用户决定:openkal-platform 接管 `x86_64-uefi` 行与载体,uefi-platform#1 关闭) +实施时测到的三处缺口,以及按 P16(归属)与"不硬编码系统名"的规则给出的解法。 + +### 19.1 测到的 + +| 项 | 测到的(2026-11-13,llvm 23.1.3) | 缺口的归属 | +|---|---|---| +| C 环境的实现 | openkal-musl 声明 `presents = "posix"`、`data-model = "arch-default"`;对 `x86_64-uefi` 的构建被拒:"this OS is outside the mapping table"。`cenv` 的映射以系统名为键(`windows`、`linux`、`macos`) | mcpp:映射的键应是行的容器格式 | +| 包对 PE 的选择 | musl 与 llvm-runtime 的 PE 部分(setjmp 的记录、`.ctors` 的遍历、compiler-rt 的 PE 源、libunwind 的 PE 分支)以 `cfg(windows)` 选择;UEFI 是 PE 而不是 Windows | mcpp 缺少按容器选择的 cfg 键;包把"PE"写成了"Windows" | +| 链接 | 以 `--target=x86_64-pc-cygwin` 编译的目标文件在 `lld-link`(MSVC 方式)下没有 `__CTOR_LIST__`;`ld.lld -m i386pep --subsystem efi_application` 链接出 EFI 应用(子系统 10);openkal-platform 的 `[platform.link]` 与 `[platform.build]` 是 ok 镜像的 | 提供者的 `link`、`layers` 已由 `provider_view` 并入(SPEC-016 §3);平台顶层的 `runner` 仍压过提供者自己的运行器 | + +### 19.2 方案 + +1. **`cenv` 以容器为键(mcpp)。** 实现"呈现 POSIX"的代价是工具链对容器的约定:PE 为 LLP64、16 位 `wchar_t`,以 Cygwin 身份的 + 替换得到 LP64 与 `__unix__`;ELF 已有 `__unix__`;Mach-O 加 `-D__unix__`。`realise` 增加容器参数(由 + `Triple::object_format()` 给出,平台定义的行亦然),系统名只用于消息。内置行的行为与命令行不变(单元测试 21 例,含新增的 + "平台定义的 PE 行取 PE 的实现"与"未知容器被拒并指名")。已实现。 +2. **`cfg(object-format = "...")`(mcpp)。** 三元组键增加 `object-format`,值为行数据的拼写(`elf`、`pe`、`mach-o`、`wasm`、 + `coff`);特异性为一个分量,低于 `os`。包需要的是同一容器的各系统共有的约定时按它选择。已实现,文档 docs/22(中英)。 +3. **一个平台按行给出约定(openkal-platform,数据;mcpp 一处)。** 提供者的 `link` 已在它服务的身份上取代平台的 + `[platform.link]`:`x86_64-uefi` 的提供者陈述 `driver = "lld"`、`flags = ["--subsystem", "efi_application", + "--gc-sections"]`、`start = "efi_main"`、`exe = "{name}.efi"`、`inputs = []`、`seal = []`,行的 + `link-emulation = "i386pep"` 选择 MinGW 方式的 COFF 链接;ok 镜像的编译约定移入 `[platform.build.'cfg(os = "openkal")']`。 + mcpp 的一处:提供者陈述了 `runner` 时,`provider_view` 以它取代平台顶层的 `runner`(顶层的运行器属于 `[platform] + targets` 这个提供者),与 `link`、`layers` 的取代一致。 +4. **包按容器选择(openkal-musl 0.29.6、openkal-llvm-runtime 0.20.3)。** PE 共有的部分改为 `cfg(object-format = "pe")`, + 私有宏为 `OKM_TARGET_PE`、`OPENKAL_TARGET_PE`;Windows 独有的部分(openkal-windows 依赖、`okw_start` 入口、`.exe` 的重试、 + Windows 的链接词)仍为 `cfg(windows)`。安装的头文件不读系统名:musl 的 PE 记录放在只在 PE 行上进入包含路径的 + `musl-generated/x86_64-pe`,llvm-runtime 以只在 PE 行上进入包含路径的 `port/include-pe/__openkal_target_pe.h` 标记容器。 + 两包的引擎下限为 2026.11.13.1。 +5. **openkal-uefi 0.10.0 的 `standalone`。** 与 openkal-windows 相同:`efi_main` 记录固件表与参数,交给 C 库的 + `__libc_start_main`,初始化由 C 库完成;无 C 库时保持 0.9.0 的行为。openkal-musl 在 `cfg(os = "uefi")` 下以 + `standalone` 依赖它,与其余系统的写法一致。 +6. **运行器。** xim 的 `ovmf` 9.2.4-1 携带 dirun 描述文件(`{profile-dir}`,dirun 0.3.2),运行器陈述只写 + `dirun --payload {} --profile {pkg:ovmf}/dirun.toml --emulator {pkg:qemu-x86}/bin/qemu-system-x86_64`。 + +7. **可选接口按实现的陈述(openkal-musl、openkal-llvm-runtime)。** 两包的 `OKM_HAS_FS/PROCESS/TASK`、`OPENKAL_HAS_TASK` + 原以目标为代理(`os = "none"` 为 0);现以 `cfg(not(interface = "openkal.fs"))` 等读实现的 `provides-interfaces` + (ok 镜像仍在运行时询问)。`pthread_getattr_np` 对 `kal_task_stack` 的直接调用改经可选接口的接缝。 +8. **行可以只陈述链接的仿真而不陈述机器(mcpp)。** `defined_spec` 原要求 `march`;`x86_64-uefi` 只陈述 + `link-emulation = "i386pep"`,处理器取 clang 三元组的默认。 + +**测到的结果(2026-11-13,本地各仓库的工作树)。** `import std` 的 C++ 程序(`std::println`、`std::ranges::sort`)以 +`--target x86_64-uefi` 构建:release 产物 1.2 MB 的 PE32+ EFI 应用(子系统 10),在 OVMF 下经 dirun 与 ovmf 的描述文件运行, +输出正确,状态 42 经调试端口回到宿主,墙钟 0.8 秒。 + +### 19.3 载体:经 okrun 运行时测到的三处缺口(2026-11-13) + +载体是一个 `x86_64-uefi` 的 EFI 应用,它在固件中运行一个 openkal 映像。经 dirun 在 OVMF 下运行时,载体先是不输出任何内容直到 +超时。逐项测得: + +| 现象 | 原因 | 修正 | +|---|---|---| +| 2 GiB 时 `_mi_page_map_register` 中的 #GP | mimalloc 3.6.1 的页映射依赖"保留而未提交的内存读为零";无区域的实现(UEFI)上,保留即分配,OVMF 以 0xAF 填充池 | openkal-musl 的 mimalloc 原语:无区域时,未提交的保留清零,`is_zero` 如实陈述 | +| 512 MiB 时中止 | mimalloc 的 arena 以 256 MiB 对齐;`kal_alloc` 以超额分配实现对齐,32 MiB 的 arena 成为 288 MiB 的请求 | openkal-uefi:一页及以上的对齐读内存映射,在对齐的空闲范围以 `AllocateAddress` 分配页;openkal-musl:无虚拟保留时 arena 的预留为 1 GiB 的四分之一 | +| 中止的消息不可见 | OVMF 的 StdErr 不写串口 | openkal-uefi 的弱钩子 `okuefi_board_errors_to_console()`:板子定义它,标准错误即 ConOut | + +载体与映像是两个分配器,各需要一个 256 MiB 对齐的 arena,因此 openkal-platform 的 UEFI 运行器给出 `--memory 2048`。测得: +映像打印、写标准错误、抛出并捕获异常,以状态 7 返回,1 GiB 与 2 GiB 下均为 0.4 秒。 + +### 19.4 不采纳 + +- **为 UEFI 在包里加 `cfg(os = "uefi")` 的 PE 分支。** 每增加一个 PE 系统就要再改一次包;容器是这些分支真正问的事实。 +- **引擎为每个构建广播容器宏。** 改变所有目标的每条编译命令(升级即全量重编),而安装的头文件可由清单选择的包含目录回答。 +- **行声明 `family = "windows"`。** UEFI 不是 Windows 家族;`cfg(windows)` 下的 Windows 依赖与入口会被错误地选中。 diff --git a/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md b/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md new file mode 100644 index 000000000..e0cd2b8c3 --- /dev/null +++ b/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md @@ -0,0 +1,222 @@ +--- +subject: ci +status: active +--- + +# CI 与发布的时间成本:按测量调度的测试、分片、并行发布 + +**日期:** 2026-11-14 +**状态:** 已实施(mcpp 2026.11.13.1,PR #930)。负责人于 2026-11-14 确认第 3 节的决定 1–4,并要求 P1–P8 中 +有效且不破坏测试的各项一并实施;第 4 节记录未采纳项及其测量。 + +## 1. 测量 + +数据取自 PR #930 的 CI 运行(43323959609、43516054606、43538214072)、各作业的 mtest 存储与发布 +v2026.11.12.1 的运行。 + +| 对象 | 测量 | +|---|---| +| PR 全量 CI | 墙钟 67–85 分钟,runner 时间 503–571 分钟,61–77 个作业 | +| macOS 作业 | 17 个、186 runner 分钟;排队合计 201 分钟,单个作业最长 36 分钟(其他主机单个最长 4.7 分钟) | +| Linux 构建作业(43538214072) | 共 35.9 分钟:构建 16.4,gate(`mcpp test e2e/basics`)18.6;全部 Linux 作业等待它 | +| Linux 单元作业(43516054606) | 45 分钟上限内只运行了 65 个程序,其工作量 3970 s;`cli/signals` 3 个用例 433 s | +| macOS 单元作业(macos-15) | 1931 s = 构建 398 + 程序 331 + 运行 1196;运行的工作量 3099 s(3 核);`openkal/trees` 956 一个用例 806 s,在运行中途才开始 | +| Windows 单元作业 | 运行 487 s(工作量 1817 s,4 个工作线程),作业 19 分钟中其余主要是测试的构建 | +| 发布 v2026.11.12.1 | 109 分钟,各作业串行:canaries 48 → Linux 构建 27(创建 release)→ aarch64 29 / macOS 14 / Windows 8 → 封存与发布 5 | + +`mcpp test` 在本文之前按发现顺序运行,部分按用例数排序;不读历史、不分片、不能只重跑失败部分。 +一次失败的重跑重复整个 45 分钟的作业。 + +## 2. 关键路径 + +1. **Linux:** 构建 16 分钟 + gate 18.6 分钟,之后单元作业再次构建测试(约 15 分钟)并在一个作业内运行全部 + C++ e2e 程序,超过 45 分钟。 +2. **macOS:** runner 并发不足,排队主导;两个镜像各运行完整的单元作业。 +3. **发布:** canaries 门住构建,三个平台构建只因 Linux 作业创建 release 而等待它。 + +## 3. 决定与实现 + +| 编号 | 决定 | 实现 | +|---|---|---| +| P1 | `mcpp test` 按存储测得的用时调度 | `mcpp.build.test_schedule`:读取存储最近 30 次运行,按用时从长到短开始;`[test] split = "auto"` / `--split auto` 把测得长于份额的部分按用例分组(SPEC-017 O10) | +| P2 | 构建一次,分片运行 | `--shard I/N`(最长处理时间优先分配);`ci-unit.yml` 的 archive 阶段由 bootstrap mcpp 构建全部测试并附上 main 的历史,与 build.yml 并行;run 阶段四个分片以本提交的 mcpp 运行 `--archive --shard`。archive 阶段在每个集合上都构建全部测试:bootstrap mcpp 的按变更选择没有 P3 的规则 | +| P3 | 按变更选择覆盖 C++ e2e | 运行本包程序的测试(其文件写出 `MCPP_BIN_`)受本包任何非测试源文件的变更影响。此前 `src/` 的变更选不中任何 e2e 程序(`--changed src/build/execute.cppm`:1 个测试 → 26 个) | +| P4 | macOS 预算 | `xcode-27` 在 PR 上运行 SDK 相关子集(`unit`、`e2e/basics`、`e2e/build*`、`e2e/macos`、`e2e/modules`、`e2e/runtime`、`e2e/toolchain`),main 与 `ci:full` 全量 | +| P5 | 发布并行 | 四个构建同时开始,产物留作 artifact,`create-release` 一次创建;canaries 只在手动发布带 `canaries` 时运行,与构建并行,门住 release 的创建 | +| P6 | 只重跑失败部分 | `--failed`;`mcpp-unit` action 在重跑的作业中取上一次尝试的存储并以 `--failed` 运行 | +| P7 | 合入前的 bench | `bench.yml`:取该提交成功 CI 运行的 Linux 二进制,只运行 `bench/` | +| — | gate 离开 Linux 构建 | gate 与 `mcpp-tests-linux-x86_64` 移入 archive 阶段;MinGW 交叉用例(198、240、248、923、1058)移入 run 阶段的 `cases` 作业 | + +分片的一致性:每个分片从同一个 artifact 取得同一份历史;`--shard` 不把本组(`GITHUB_RUN_ID`)的分片运行读作 +历史,因此在同一存储上先后运行的分片也得到相同的分配。本地验证:`e2e/test` 与 `e2e/run` 的 25 个测试分三片, +每个恰好运行一次,三片 42 / 36 / 40 s。 + +## 4. 未采纳与测量 + +- **P3 的覆盖图收窄。** 最新的覆盖图(e2e-coverage 43337527734,691 个用例)中,`src/` 文件被执行它的用例覆盖的比例 + 中位数为 99%,四分位 95%–99%。按覆盖图为 C++ e2e 收窄,几乎任何源文件的变更仍选中全部用例,不实施。 +- **P8 缓存测试构建目录。** 在已构建的树上 touch 全部源文件(等同一次新的 checkout)后,`mcpp build` 重新执行了 + 487 个边中的 487 个:恢复的 `target/` 不被复用;另外,以本提交的 mcpp 构建测试时引擎指纹每次都变。不实施。 +- **iOS 与小作业合并。** 三个 iOS 作业分属不同 runner(其中一个为 Intel Mac),不能合并;跳过的作业不占 runner。 + +## 5. 预期与核验 + +| 路径 | 之前 | 之后(估计,待 CI 核验) | +|---|---|---| +| Linux 测试 | 构建 35.9 分钟后单作业 > 45 分钟 | archive 与构建并行约 20 分钟,四个分片约 10–15 分钟 | +| macOS 单元(macos-15) | 运行 1196 s | 最长者先开始:约 max(806, 3099/3) ≈ 1033 s | +| 一次失败的重跑 | 整个作业 | 只运行失败的用例 | +| 发布 | 109 分钟 | 不带 canaries 约 35 分钟(aarch64 29 + 创建与封存 + 发布) | + +第 5 节的估计以合入前 CI 与下一次发布的实测核验,并补入本节。 + +### 5.1 首次运行(43544563640) + +| 对象 | 测量 | +|---|---| +| Linux 构建作业 | 10.4 分钟(之前 35.9) | +| archive 阶段 | 22.0 分钟,与构建并行:gate 19.8 分钟(构建 617 s),归档 26 个测试,取得 main 的 7 次运行 | +| 四个分片 | 02:35–02:39 开始;1/4 只分得 1 个测试(1.5 分钟),其余 18–26 分钟 | +| Linux 测试路径 | 自运行开始 57 分钟走完(之前:构建 35.9 分钟后单作业在 45 分钟上限处被终止) | + +分片不均的原因:main 最近一次成功运行的存储来自旧布局,只测得单元程序;C++ e2e 程序没有测量,取了单元 +程序的平均值,估计之和远小于实际,最长的单元程序独占一片。修正后,没有测量的测试取其最近目录中已测测试 +的平均值,各级目录都没有时取已测测试中最长者,未见过的测试因而按数量分散(SPEC-017 O10 第 1 条)。 +本 PR 合入后 main 的四个分片存储覆盖全部测试,此后的估计都来自测量。 + +分片首次在 Linux 上把全部 C++ e2e 程序跑完,暴露了此前被 45 分钟上限截断而从未完成的失败: + +- **共享 registry 被案例 home 改写。** registry 链接到共享 home 的案例 home 把自己的 store 写进共享 + registry 的配置,之后共享 home 的安装落入已删除的案例目录(`install path missing after fetch`, + openkal/sets 963、openkal/layers 994)。 +- **归档运行把 `MCPP_RUNTIME_FILES` 交给每个测试**(run/contract 672)。 +- **MCPP_BOOT 是 xlings shim。** shim 按工作目录的项目解析版本,在测试自己的目录中找不到(workspace/inheritance 772)。 +- **测试读取共享状态。** toolchain/layers 663 取共享缓存中最新的 std 元数据,读到并行程序的;cdb/root 781、 + build-program/hosttools 831、runtime/binding 200 与 cache/fastpath 断言构建被重放,而并行安装改写运行环境 + 描述时构建按设计重新规划。 +- **targets/graph 778** 从 mcpp 的输出中找链接行;冷构建的输出超过运行 mcpp 的 e2e 进程保留的 1 MiB + 标准错误(mtest `os::process::bounds::err_limit`),链接行被截去。 +- **openkal/trees** 在四个工作线程旁超过 1200 s。 +- **交叉构建与发布取错二进制。** `find -name mcpp` 可能取到自举构建保留的引擎副本 `engine/epoch-N/mcpp`。 +- **Windows 写入冲突。** xlings 原地写 SubOS 描述,另一进程打开该文件时保存被拒(images/1033 + `Failed to write file`);`config.toml` 的替换在读者频繁打开时两秒内未成功(unit ConfigValueIsReplacedWhole)。 +- **无 Visual Studio 的 Windows 上运行 MSVC ABI 归档。** 归档运行仍编译项目的构建程序(mingw/182)。 + +### 5.2 收敛(负责人 2026-11-14 确认) + +上述问题不逐点修补,按其所属的机制收敛: + +| 编号 | 机制 | 实现 | +|---|---|---| +| A | registry 的归属 | `GlobalConfig::registryBorrowed` 在加载配置时判定一次(`[xlings] home` 指向别处,或 `registry` 经链接到达,且已有配置);索引位置、`[index.repos]` 表的位置与上游的每个写入者都只问它 | +| B | 准备的意图 | `PrepareIntent {Build, Describe, Configure, RunBuilt}` 取代 `plan_only`、`configuration_only`、`runs_built_tests` 三个开关;各阶段读 `describes()`、`configures()`、`compiles()`;构建程序与宿主工具的阶段在驱动处按 `compiles()` 一次跳过 | +| C | 交给 runner 的内容 | `runner_handoff_env`:文件清单与运行契约(`MCPP_RUN_*`),构建的测试运行与归档运行共用;直接运行的程序两者都不得到;归档中的文件按其相对程序的位置命名 | +| D | provider 状态的写入次序 | `mcpp::home::Turn`(跨进程与线程、同一线程可重入);锁位于 provider 状态的真实目录(`Provider::state_dir`,解析链接),安装、增删索引仓库、选择运行时、初始化沙箱都取它;读者在 Windows 上以可删除共享打开文件。xlings 已以暂存文件加 `MoveFileExW` 替换,Windows 拒绝替换他人未以可删除共享打开的文件,且只试一次;重试与 POSIX 语义的替换见 openxlings/xlings#715,上游实现后本次序不需改动 | +| E | CI 与测试的约定 | `install_pinned_mcpp.sh` 导出 payload 中的发布二进制为 `MCPP_BOOT`;`.github/tools/built_artifact.sh` 取构建报告(`--message-format json`)中的产物路径,取代对 `target/` 的搜索;测试以 mtest `timeout{}` 声明自身时长,运行器取其与 `--timeout` 的较大者(trees 40 分钟、sets 30 分钟),全局时限不变;断言重放的测试持有 `machine`;778 读取构建图而非输出 | + +快速路径以共享 SubOS 描述的修改时间判定运行环境未变,是保守而正确的:并行安装可能改变程序加载时解析到的 +库。按内容判定需要知道程序用到描述中的哪些条目,不在本次范围。 + +### 5.3 第二次运行(43553119208) + +| 对象 | 测量 | +|---|---| +| Linux 构建作业 | 17.1 分钟(runner 间差异;上一次 10.4) | +| archive 阶段 | 38.5 分钟:gate 2103 s(构建 1118 s),归档 129 s;gate 先以 bootstrap 构建本包程序 mcpp,其工作与构建作业重复 | +| 四个分片 | 8.3、16.1、22.5、24.3 分钟;最长的单测为 openkal/sets 1242 s、openkal/trees 1023 s | +| 分片工作量 | 325 条记录合计 10770 s | + +### 5.4 被测程序只构建一次(负责人 2026-11-14 要求完成) + +第二次运行中 archive 阶段的 38.5 分钟里,gate 先以 bootstrap 构建本包程序 mcpp(1118 s),archive 再把这个构建 +作为 `MCPP_BIN_MCPP` 带给分片。于是该程序被构建两次(构建作业一次,archive 阶段一次),分片测试的是 archive +阶段的构建,而不是构建作业产出、其余作业都使用的二进制(规则 R1)。macOS 与 Windows 的单元作业同样在 +`mcpp test` 的程序阶段以本提交的 mcpp 再构建一次(macos-15 上 331 s)。 + +| 编号 | 机制 | 实现 | +|---|---|---| +| F | 在别处构建的程序 | `mcpp test --no-programs`:本包程序只为名字而规划,不构建;测试从环境的 `MCPP_BIN_` 取得各程序,环境未指定者在任何测试运行前被拒绝,不会从 `PATH` 取到同名程序。与 `--no-run --archive` 同用时归档不携带程序,清单以 `named_programs` 列出其变量,归档的运行从自身环境取得;归档的运行不接受该选项;环境对每个名字只指定一个程序,多腿被拒绝。`MCPP_BIN_` 的拼写收敛为 `program_env_key` | +| G | CI 中被测的 mcpp | archive 阶段以 `--no-programs` 只构建测试;gate 移到 run 阶段的第一个分片,从归档运行;分片、gate 与 MinGW 用例作业以 `MCPP_BIN_MCPP` 指定构建作业的二进制;`mcpp-unit` 在构建模式下传 `--no-programs`,macOS 与 Windows 单元作业不再构建 mcpp。archive 阶段的 bootstrap 认识该选项时才使用它:固定版本升至 2026.11.13.1 后生效,无需修改工作流;此前归档携带 bootstrap 对本提交源码的构建,与本节之前相同 | +| H | 重放构建的产物报告 | 工作区的快速路径与单包的一样重述所重放构建的记录:`--message-format json` 给出 `artifact` 事件与信封中的 `data`。此前它不写记录,`built_artifact.sh` 在已构建的工作区上取不到产物 | + +`mtest::e2e::run` 丢弃 `os::process::run` 报告的截断标志(标准错误保留 1 MiB),见 mcpp-community/mtest#22; +graph 778 已改为读取构建图,不依赖输出的完整性。 + +本地核验:`mcpp test e2e/basics --no-run --no-programs --archive` 的程序阶段 0.96 s(只规划),归档不含程序; +未指定 `MCPP_BIN_MCPP` 的运行以退出码 2 被拒绝;指定 `/bin/false` 时 basics 失败,说明测试使用的是所指定的程序。 +用例 1085(重放构建报告产物,包与工作区)与 1086(`--no-programs` 的写出、拒绝、运行与本地运行)通过。 + +### 5.5 从缓存恢复的 store 中无法启动的工具链(mtest 0.8.4 发布) + +**现象。** mtest v0.8.4 的发布运行(43556169215)在 web 行失败:`source imports std but toolchain 'clang 6.0.9 +(wasm32-emscripten)' provides no std module source`。同一提交在 PR 运行(43554279064)中通过。 + +**与此前版本的对比。** 0.8.3 及以前没有 web 行;0.8.4 首次以 emsdk 构建。三次运行的区别只在缓存:PR 运行恢复的 +store 不含 emsdk,现场安装后通过;main 运行同样现场安装、通过,并保存了含 emsdk 的 store(键 `722042…`);tag 运行与 +mtest#23 的 PR 运行恢复这份 store,emsdk 被视为已就位,均失败。失败与 mtest 的代码无关,是任何恢复含 emsdk 的 store +的运行都会遇到的。在 runner 上诊断:`bin/clang` 存在,执行时 `ENOENT`;其 `PT_INTERP` 为 +`/abi/linux-x86_64-glibc/ld-linux-x86-64.so.2`,`RUNPATH` 同样经过 `/abi`,而 `/abi` 不在缓存路径中。 + +**是否为引入的缺陷。** 是,但不是本 PR 引入,也不在 mtest: +- 本机 2026-09-01 安装的 emsdk(当时 mcpp 自行修补 payload),其 clang 的 `PT_INTERP` 直接指向 store 中的 glibc + payload,store 是自包含的;自运行库钉版本由提供者绑定(设计 2026-10-16 §7.1–7.2)起,xlings 2026.10.26.1 安装的 + 同一 payload 指向 home 的链接 `/abi`。store 因此不再自包含,链接属于 home 的状态。 +- mcpp 在取用工具链时确保运行库存在并保持钉版本(`ensure_declared_runtime` → `pin_runtime`,链接缺失则重建), + 但“哪些 payload 运行在运行库上”由一张名单回答(`runtime_payload_kind`:gcc、llvm)。emsdk 的 clang 同样运行在 + 该运行库上,却不在名单中;新安装时 xlings 安装其 glibc 依赖顺带建立链接,掩盖了这一点,从缓存恢复时便暴露。 + 缺陷自 store 改为经 `/abi` 链接时即潜伏,emsdk 首次从缓存恢复时显现。 +- `em++ --version` 由 emcc 自己回答而不启动 clang,探测因此“成功”;随后两个 std 探测失败的原因被丢弃,报告只剩 + “没有 std 模块”,指向了错误的方向。 + +**设计层面的解决。** + +| 编号 | 机制 | 实现 | +|---|---|---| +| I | 运行在运行库上的 payload 由事实回答 | `runtime_payload_kind(pkg, root)`:gcc、llvm 在安装前按配方回答;payload 就位后按其程序回答(`starts_through_its_home`:`bin/` 中第一个指名加载器的 ELF,其 `PT_INTERP` 是否位于该 payload 自身的提供者 home 内,home 由提供者的 layout 给出,两侧按文件系统解析)。不列名字,不写系统加载器路径;Mach-O 与 PE 不指名加载器,答案为否,各系统形式相同;新的工具链族无需登记。各取用点把 payload 目录传入 | +| J | 探测失败如实报告 | 带错误输出的探测失败时,错误信息附上其输出的最后一行;clang 的 std 探测未得到回答时,工具链记下原因(`stdModuleUnanswered`),`import std` 的拒绝信息随之给出“编译器未回答:…” | +| K | 提供者状态整体缓存 | mtest 的 CI 把 `data/xpkgs` 与 `abi` 一并缓存(mtest 0.8.5);mcpp 自己的 CI 缓存整个 home,不受影响。缓存路径改变后旧的缓存条目不再被恢复 | +| L | 不依赖解释器的产物查询 | hermetic 容器中没有 python,`built_artifact.sh` 改为以 grep 与 sed(`-E`,GNU 与 BSD 相同)读取 `artifact` 事件:每行一个产物,其对象只有一个 `path` 键,不依赖键序 | + +I 使从缓存恢复的 emsdk 在 mcpp 2026.11.13.1 起自动重建链接;K 使 mtest 在其固定的 mcpp 版本上即可工作。二者互不依赖。 + +### 5.6 第三次运行的 Windows 作业(43568062909) + +| 失败 | 原因 | 处理 | +|---|---|---| +| unit RuntimePayload/APayloadStartsThroughItsHome | 用例在每个宿主上期望 `elf`;Windows 上 `runtime_payload_kind` 按设计为空(I:PE 不指名加载器,不钉运行库) | 期望随宿主,Windows 上为空;`starts_through_its_home` 的判定在各宿主上相同 | +| unit Config/ConfigValueIsReplacedWhole(本分支五次完成的 Windows 运行中四次失败;下列原因经下一行更正) | 写者以重命名替换文件期间,Windows 对读者的打开与对该名字的查询都给出拒绝(被替换的副本处于待删除状态)。`read_whole` 以 `exists` 的失败判定文件不存在,在重命名期间放弃,读者得到空内容。5.1 记录的写者一侧(两秒内未替换成功)已由写者的重试解决,剩下的是读者一侧 | `read_whole` 以打开本身的错误判断:Windows 上除“不存在”以外的拒绝在两秒内重试;其他系统的重命名立即替换名字,读取失败即为最终结果 | +| e2e run/contract 1024 | 用例断言快速路径重放,却不持有 machine;并行程序向共享 home 安装、改变运行环境描述时,完整路径按设计重新规划(E 的约定)。其上方的注释称其写 config.toml,与内容不符 | 移入 contract-machine 套件,注释改为其持有 machine 的原因 | +| e2e openkal/sets 964 B(Linux 分片 2) | 本 PR 引入(5.2 B)。`RunBuilt` 不再供给宿主工具,归档的运行因此既找不到平台链接用的 `okld`,也找不到 runner `okrun`;此前的通过依赖共享 home 中别处留下的程序。本机可稳定复现:`runner 'okrun' for 'x86_64-openkal' was not found` | 两处按意图回答。不链接的准备(`compiles()` 为假或只描述)不要求链接与封装程序:`linksSomething` 一处计算,平台工具缺失与 ld.lld 缺失两个拒绝共用。平台声明为工具、本次准备未定位的 runner,取平台为本机架构构建的 runner 程序(`pack::runner_program`),与其他架构的 runner 同一机制。交给 runner 的文件清单与闭包记录写在归档的运行目录中、所描述的程序旁,不写入检出目录的输出树(用例 B 断言该树中没有测试的产物) | + +**macOS canary(cxx aarch64-macos test)。** 自 canary 加入起,该格在 macOS runner 上每次都失败(9 次),矩阵所写的 `ok` +未经测量。测试程序经 mtest 依赖 mcpplibs.os → openkal-macos。openkal-macos 的构建程序在 `MCPP_TARGET_REQUESTED` +非空时把 `port/`(只列三个名字的 `libSystem.tbd`)加入链接搜索路径,依据是 mcpp 在构建程序契约中的陈述:在 Mac 上 +`--target aarch64-macos` 时 mcpp 不把系统 SDK 放上链接(`build_program.cppm`)。而 mcpp 的链接参数在每个 macOS 链接上 +都给出 `-isysroot `(`flags.cppm`,2026-08-25 起在图供给 C 库时也保留)。`-L` 先于 SDK 被搜索,存根遮蔽了 SDK 的完整 +`libSystem`,`strlen` 等因此未定义。缺陷不由本 PR 引入:mcpp 的两处陈述自 2026-08-25 起互相矛盾,canary 首次测量了这一组合。 + +解决需要一项跨仓库的契约:mcpp 向构建程序陈述链接所用的 SDK(为空即链接中没有系统的存根),由 `flags.cppm` 的同一判定给出; +openkal-macos 仅在其为空时提供自己的存根,旧版 mcpp 不设该变量时行为不变;同时更正 `build_program.cppm` 的陈述。该契约待负责人确认; +在此之前矩阵按测量记为 `fails` 并注明原因,修复后 canary 报告该格与矩阵不符,矩阵随之改回 `ok`。 + +**自我审查(本节各提交)。** 独立审查给出以下问题,均已处理: + +| 问题 | 处理 | +|---|---| +| gate 在分片 1 中运行,其记录进入运行存储:作为最新一次运行,它改变分片 1 的测试时长,使分片 1 的分配与其他分片不同(有的测试运行两次、有的不运行);分片 1 重试时,`--failed` 读到的是通过的 gate 而不是失败的那次尝试 | gate 先于历史运行;取历史前清空本作业的存储,gate 的记录不保留 | +| runner 的回退对所有准备生效,正常的 `mcpp run`/`test` 在未找到平台工具时也会构建 carrier | `BuildContext::intent` 记录准备的意图;回退只在 `RunBuilt` 下发生,其余准备仍报告 runner 未找到 | +| 写有 `named_programs` 的归档仍为 schema 1,只读 schema 1 的旧 mcpp 会在没有被测程序的情况下运行其测试 | 此类归档写为 schema 2,读取接受 1 与 2;其余归档仍为 schema 1 | +| 归档的运行在任何腿运行前要求全部所列程序,另一台机器运行的腿也被要求 | 在腿确定于本机运行后才向环境要求该腿的程序 | +| e2e-cases 以 MSYS 形式导出 `MCPP_BIN_MCPP` | 与 mcpp-unit 相同,以 `cygpath -w` 给出系统自身的拼写 | +| MSVC ABI 的 clang 也运行 libc++ 探测,可能记下不相干的“未回答” | 不对 MSVC ABI 的驱动提出该问题,其 std 模块按 STL 查找 | + +**第四次运行(43576630955,含 bench)。** 另有两处与本 PR 改动无关、由负载暴露的用例缺陷:modules/extensions 218 断言无改动的构建被重放, +却不持有 machine(与 1024 同类,移入 extensions-machine 套件);DetachLifetime 的辅助脚本在杀死控制进程后固定等待 0.15 s 再要求其锁, +负载下内核结束该进程晚于此,改为在时限内等到锁被释放。 + +**ConfigValueIsReplacedWhole 的真实原因。** 上表的推断(读者把替换期间的拒绝当作不存在)在修正后仍失败;用例改为报告首个读错的读者 +所见,Windows 上为 `65605 bytes, at [toolchain]: [toolchain]\r\ndefault = "gcc@16.1.0"\r\n`。`write_replacing` 以文本模式写出, +Windows 把每个 `\n` 写成 `\r\n`;`write_config_value` 只认 `\n` 结尾的 `[toolchain]`,再次设值时找不到该表而追加第二个,解析器拒绝 +重复的表。这是用户可见的缺陷(Windows 上第二次 `mcpp toolchain default` 之后 config.toml 无效),此前读者偶尔未在写入期间读取时 +用例通过。处理:`write_replacing` 按给定字节写出;`write_config_value` 在两种行尾之前匹配表头与键,新增的行沿用文件自身的行尾 +(Config/ConfigValueKeepsTheFilesLineEnding)。读者对拒绝的重试保留,它对应 Windows 替换期间真实存在的拒绝。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 4f08c0b63..1fd4bdf2c 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -360 records. +361 records. ## By subject @@ -42,6 +42,10 @@ Records that declare one. Everything else is listed by date below. - [BMI publication, compiler capacity and active download budgets](2026-11-06-bmi-publication-and-compiler-cap-closeout.md) — active +### ci + +- [CI 与发布的时间成本:按测量调度的测试、分片、并行发布](2026-11-14-ci-and-release-time-cost-design.md) — active + ### compiler-scheduling - [Compiler attempt lifetime and output recovery](2026-11-06-compiler-attempt-recovery.md) — active @@ -193,6 +197,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-11 +- [CI 与发布的时间成本:按测量调度的测试、分片、并行发布](2026-11-14-ci-and-release-time-cost-design.md) — active - [The three-plane platform architecture: mcpp × openkal × xim](2026-11-12-three-plane-platform-architecture-design.md) — superseded by [2026-11-12-ecosystem-architecture-optimization-design.md](2026-11-12-ecosystem-architecture-optimization-design.md) - [生态架构优化方案:四个平面的收敛、已合入项的纠正与新系统的准入(mcpp × openkal × xlings)](2026-11-12-ecosystem-architecture-optimization-design.md) — active - [测试与依赖解析补充方案:mcpp、mcpp test、mtest、mcpp-ide](2026-11-10-test-and-resolution-supplement-design.md) — active diff --git a/.agents/skills/mcpp-release/SKILL.md b/.agents/skills/mcpp-release/SKILL.md index 1fffd2b5d..fddedcb09 100644 --- a/.agents/skills/mcpp-release/SKILL.md +++ b/.agents/skills/mcpp-release/SKILL.md @@ -115,15 +115,26 @@ Tag push 会自动触发 `release.yml` workflow。 ### 4. 监控 Release CI -Release workflow 包含**四个平台**的构建,外加一个生态发布 job: +Release workflow 的四个平台构建**同时开始**,各自把归档留作 artifact;`create-release` +在四个构建都通过后一次创建带全部归档的 GitHub release(设计 2026-11-14): | Job | 平台 | 产物 | 依赖 | |-----|------|------|------| -| `build-release` | Linux x86_64 | `mcpp-X.Y.Z-linux-x86_64.tar.gz` | 无(先执行) | -| `build-linux-aarch64` | Linux aarch64(交叉) | `mcpp-X.Y.Z-linux-aarch64.tar.gz` | 等 Linux x86_64 完成 | -| `build-macos` | macOS ARM64 | `mcpp-X.Y.Z-macosx-arm64.tar.gz` | 等 Linux 完成 | -| `build-windows` | Windows x86_64 | `mcpp-X.Y.Z-windows-x86_64.zip` | 等 Linux 完成 | -| `publish-ecosystem` | — | 镜像到 xlings-res 双端 + 开索引 bump PR | 等**全部四个**构建完成 | +| `canaries` | — | 生态项目(xlings、mcppls)用候选版本构建 | 仅手动发布且勾选 `canaries` 时运行 | +| `build-release` | Linux x86_64 | `mcpp-X.Y.Z-linux-x86_64.tar.gz`、源码包、`mcpp.lua`、`install.sh` | 无 | +| `build-linux-aarch64` | Linux aarch64(交叉) | `mcpp-X.Y.Z-linux-aarch64.tar.gz` | 无 | +| `build-macos` | macOS ARM64 | `mcpp-X.Y.Z-macosx-arm64.tar.gz` | 无 | +| `build-windows` | Windows x86_64 | `mcpp-X.Y.Z-windows-x86_64.zip` | 无 | +| `create-release` | — | GitHub release、覆盖全部归档的 `SHA256SUMS` | 四个构建(及运行时的 canaries) | +| `release-manifest` | — | `mcpp-release.json` | `create-release` | +| `publish-ecosystem` | — | 镜像到 xlings-res 双端 + 开索引 bump PR | `release-manifest` | + +canaries 默认不运行(tag push 与未勾选的手动发布):xlings 与 mcppls 的构建约 40 分钟, +由各项目在发布后以自己的 CI 固定新版本验证。需要在创建 release 前验证时,手动发布: + +```bash +gh workflow run release.yml --repo mcpp-community/mcpp -f canaries=true +``` `build-linux-aarch64` 是两段式的(bootstrap 先构出本 release 的 x86_64 mcpp,再用它交叉构建 aarch64),因为 bootstrap 装的是**上一个已发布版本**,可能不认新特性。 @@ -138,7 +149,7 @@ gh run view # 如果失败,下载日志分析 gh api repos/mcpp-community/mcpp/actions/runs//logs \ -H "Accept: application/vnd.github+json" > /tmp/release-logs.zip -unzip -p /tmp/release-logs.zip "build + upload (linux _ x86_64)/8_Smoke-test the bundled tarball.txt" +unzip -p /tmp/release-logs.zip "build (linux _ x86_64)/8_Smoke-test the bundled tarball.txt" ``` ### 5. 验证 Release 产物 @@ -189,7 +200,7 @@ release 规则验证最终资产,不要套用 Linux 的 strip 判断。 ### publish-ecosystem:镜像 + 索引(发布的后半程) -四个构建 job 全绿后自动执行,做两件事: +`release-manifest` 封存之后自动执行,做两件事: 1. **镜像到 `xlings-res/mcpp` 双端**(GitHub + GitCode),由 `.github/tools/mirror_res.sh` 完成 —— 单 leg 内资产**并发上传** diff --git a/.github/actions/e2e-cases/action.yml b/.github/actions/e2e-cases/action.yml new file mode 100644 index 000000000..4ee4bc4a3 --- /dev/null +++ b/.github/actions/e2e-cases/action.yml @@ -0,0 +1,92 @@ +name: e2e-cases +description: > + Named cases of the C++ e2e suite, each held to its `ok` line: a case that is + skipped (a capability absent, another platform) or fails fails the step, so a + job that exists to run a case cannot pass without running it. The cases run + from the test archive build.yml wrote for the host (`mcpp-tests-`, + `mcpp test --archive`, docs/08): no job that runs them builds them, and a + host with Visual Studio masked could not. + +inputs: + cases: + description: the cases, one `/` per line + required: true + mcpp: + description: the mcpp that runs the tests + required: true + host: + description: the host of the archive (linux-x86_64, windows-x86_64) + required: true + from-main: + description: > + 'true': the archive of main's last successful CI run, for a workflow that + runs outside it (ci-fresh-install); otherwise this run's. + required: false + default: 'false' + under-test: + description: > + The mcpp the cases test: MCPP_E2E_MCPP, which the cases' support reads + before the archive's program, and MCPP_BIN_MCPP, which an archive written + with --no-programs takes. Empty: the archive's own build. + required: false + default: '' + token: + description: a token that reads this repository's actions + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: The tests of this run + if: inputs.from-main != 'true' + uses: actions/download-artifact@v4 + with: + name: mcpp-tests-${{ inputs.host }} + path: ${{ runner.temp }}/mcpp-tests + + - name: The tests of main's last CI run + if: inputs.from-main == 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + HOST: ${{ inputs.host }} + run: | + set -euo pipefail + run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --branch main --status success --limit 1 \ + --json databaseId -q '.[0].databaseId') + [ -n "$run" ] || { echo "::error::no successful CI run of main holds the tests"; exit 1; } + rm -rf "$RUNNER_TEMP/mcpp-tests" + gh run download "$run" -R "$GITHUB_REPOSITORY" -n "mcpp-tests-$HOST" -D "$RUNNER_TEMP/mcpp-tests" + echo "the tests of main's run $run" + + - name: The cases + shell: bash + env: + CASES: ${{ inputs.cases }} + RUNNER_MCPP: ${{ inputs.mcpp }} + UNDER_TEST: ${{ inputs.under-test }} + run: | + set -euo pipefail + cases=() + while IFS= read -r c; do + c="${c#"${c%%[![:space:]]*}"}"; c="${c%"${c##*[![:space:]]}"}" + if [ -n "$c" ]; then cases+=("$c"); fi + done <<< "$CASES" + [ "${#cases[@]}" -gt 0 ] || { echo "::error::no case named"; exit 1; } + args=(--archive "$RUNNER_TEMP/mcpp-tests/mcpp-tests.tar.gz") + for c in "${cases[@]}"; do args+=(--case "$c"); done + # The system's own spelling of the path: mcpp reads MCPP_BIN_MCPP + # itself, and a Windows mcpp does not read an MSYS path. + if [ -n "$UNDER_TEST" ]; then + under="$UNDER_TEST" + if command -v cygpath >/dev/null 2>&1; then under="$(cygpath -w "$under")"; fi + export MCPP_E2E_MCPP="$under" MCPP_BIN_MCPP="$under" + fi + log="$RUNNER_TEMP/e2e-cases.log" + status=0 + "$RUNNER_MCPP" test "${args[@]}" 2>&1 | tee "$log" || status=$? + for c in "${cases[@]}"; do + tr -d '\r' < "$log" | grep -qxF " ok $c" || { echo "::error::$c did not pass (a skip or a failure; its line above)"; status=1; } + done + exit "$status" diff --git a/.github/actions/e2e-mtest/action.yml b/.github/actions/e2e-mtest/action.yml index d953a9596..34c95c5e4 100644 --- a/.github/actions/e2e-mtest/action.yml +++ b/.github/actions/e2e-mtest/action.yml @@ -9,7 +9,9 @@ description: > inputs: host: - description: the host's name in the stores' artifact names (linux, macos, windows) + description: > + the host's name in the stores' artifact names (linux, macos, windows), or + `*` for every host's (the plan of ci.yml) required: true token: description: a token that reads this repository's actions @@ -55,7 +57,7 @@ runs: # Another host's own sources (src/native/windows/...) run on no Linux test, # so its suite has no map and every test of it is affected. - name: The coverage map - if: inputs.host == 'linux' + if: inputs.host == 'linux' || inputs.host == '*' shell: bash env: GH_TOKEN: ${{ inputs.token }} diff --git a/.github/actions/mcpp-unit/action.yml b/.github/actions/mcpp-unit/action.yml new file mode 100644 index 000000000..0314673c5 --- /dev/null +++ b/.github/actions/mcpp-unit/action.yml @@ -0,0 +1,146 @@ +name: mcpp-unit +description: > + The unit and C++ e2e tests by `mcpp test` (docs/08 "The order of a run"; + design 2026-11-14): the measured history of main's last CI run taken into the + run store, so that the tests run longest first, a part longer than its share + runs as groups of its cases (`--split auto`) and `--shard` assigns tests by + duration; on a re-run of a failed job, only what its previous attempt failed + (`--failed`); the job's own runs kept as `mtest-unit--a`. + Run it after use-built-mcpp, from the repository's root. + +inputs: + name: + description: the job's name in the stores' artifact names (linux-1, macos-15, windows) + required: true + history: + description: > + the stores of main's last CI run to read the measured durations from + (`mtest-unit-*`); empty when the job was given its history + (the shards of one run take it from their archive job) + required: false + default: '' + archive: + description: the test archive to run (`mcpp test --archive`); empty to build the tests here + required: false + default: '' + shard: + description: I/N, this job's shard of the tests (`mcpp test --shard`) + required: false + default: '' + since: + description: the commit a pull request's change is read from (`mcpp test --since`) + required: false + default: '' + patterns: + description: the tests to run, as `mcpp test` patterns separated by spaces; empty for all + required: false + default: '' + mcpp: + description: the mcpp that runs the tests; empty for this commit's (MCPP_FRESH) + required: false + default: '' + timeout: + description: the seconds a test may run (`mcpp test --timeout`) + required: false + default: '1200' + stores: + description: the stores to keep, relative to the workspace, separated by spaces + required: false + default: target/mtest + token: + description: a token that reads this repository's actions + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: The measured history of main's last CI run + if: inputs.history != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + KEY: ${{ inputs.history }} + run: | + set -uo pipefail + py=python3; command -v python3 >/dev/null 2>&1 || py=python + run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --branch main --status success --limit 1 \ + --json databaseId -q '.[0].databaseId' 2>/dev/null || true) + if [ -z "$run" ]; then echo "no successful run of main: the tests run in the order they are listed"; exit 0; fi + d="$RUNNER_TEMP/unit-history" + rm -rf "$d" + if ! gh run download "$run" -R "$GITHUB_REPOSITORY" -p "mtest-unit-$KEY*" -D "$d" 2>/dev/null; then + echo "main's run $run kept no store for $KEY: the tests run in the order they are listed"; exit 0 + fi + "$py" .github/tools/test_history.py take target/mtest "$d" "$d"/*/ + + # A RE-RUN RUNS WHAT FAILED. The store of the attempt before this one, when + # it kept one, names what failed; a job that failed before its tests ran + # kept none and runs them all again. + - name: The attempt before this one + if: github.run_attempt != '1' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + NAME: ${{ inputs.name }} + run: | + set -uo pipefail + py=python3; command -v python3 >/dev/null 2>&1 || py=python + prev="mtest-unit-$NAME-a$((GITHUB_RUN_ATTEMPT - 1))" + d="$RUNNER_TEMP/unit-previous" + rm -rf "$d" + if gh run download "$GITHUB_RUN_ID" -R "$GITHUB_REPOSITORY" -n "$prev" -D "$d" 2>/dev/null \ + && [ -f "$d/index.ndjson" ]; then + "$py" .github/tools/test_history.py take target/mtest "$d" + echo "MCPP_UNIT_FAILED=1" >> "$GITHUB_ENV" + echo "the attempt before kept $prev: what it failed runs again" + else + echo "the attempt before kept no store: every test runs" + fi + + - name: The tests via `mcpp test` + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + SHARD: ${{ inputs.shard }} + SINCE: ${{ inputs.since }} + PATTERNS: ${{ inputs.patterns }} + TIMEOUT: ${{ inputs.timeout }} + RUNS: ${{ inputs.mcpp }} + run: | + set -euo pipefail + args=(test --timeout "$TIMEOUT" --split auto) + # THE PROGRAM THE TESTS RUN IS THIS COMMIT'S, AS BUILD.YML BUILT IT + # (rule R1): it is not built again here (`--no-programs`), and an + # archive written without it takes it from the same variable. A native + # path: on Windows the variable is read by mcpp and the tests, not bash. + fresh="$MCPP_FRESH" + if command -v cygpath >/dev/null 2>&1; then fresh="$(cygpath -w "$fresh")"; fi + export MCPP_BIN_MCPP="${MCPP_BIN_MCPP:-$fresh}" + if [ -n "$ARCHIVE" ]; then args+=(--archive "$ARCHIVE"); else args+=(--no-programs); fi + if [ -n "${MCPP_UNIT_FAILED:-}" ]; then + args+=(--failed) + else + [ -z "$SHARD" ] || args+=(--shard "$SHARD") + [ -z "$SINCE" ] || args+=(--since "$SINCE") + # shellcheck disable=SC2206 -- the patterns are words + [ -z "$PATTERNS" ] || args+=($PATTERNS) + fi + echo "mcpp ${args[*]}" + "${RUNS:-$MCPP_FRESH}" "${args[@]}" + + # The job's own runs, and not the history it was given, are kept. + - name: The runs of this job + if: always() + shell: bash + env: + STORES: ${{ inputs.stores }} + run: | + py=python3; command -v python3 >/dev/null 2>&1 || py=python + for s in $STORES; do [ ! -d "$s" ] || "$py" .github/tools/test_history.py own "$s" "$GITHUB_RUN_ID"; done + + - uses: ./.github/actions/test-report + if: always() + with: + name: mtest-unit-${{ inputs.name }}-a${{ github.run_attempt }} + stores: ${{ inputs.stores }} diff --git a/.github/actions/use-built-mcpp/use.sh b/.github/actions/use-built-mcpp/use.sh index 9fa9f7c57..5602dd018 100644 --- a/.github/actions/use-built-mcpp/use.sh +++ b/.github/actions/use-built-mcpp/use.sh @@ -88,7 +88,8 @@ if [ -n "$tc" ]; then fi { - echo "MCPP_BOOT=$boot" + # The released binary install_pinned_mcpp.sh named, when it named one. + echo "MCPP_BOOT=${MCPP_BOOT:-$boot}" echo "MCPP=$bin" echo "MCPP_FRESH=$bin" if [ -n "${XLINGS_BIN:-}" ]; then echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN"; fi diff --git a/.github/release-canaries.toml b/.github/release-canaries.toml index 495c2c3c9..f5e43d0b1 100644 --- a/.github/release-canaries.toml +++ b/.github/release-canaries.toml @@ -1,8 +1,10 @@ # Release canaries (the 2026-09-28 ecosystem design, WS10). # # Projects of this ecosystem built with the candidate mcpp before a release is -# tagged: release.yml runs .github/workflows/release-canaries.yml first, and a -# canary that fails blocks the tag. +# created: a release started by hand with `canaries` (release.yml) runs +# .github/workflows/release-canaries.yml beside its builds, and a canary that +# fails blocks the release. A tag pushed, or a release started without it, +# runs none (design 2026-11-14: their build took 48 of 109 minutes). # # Only the ecosystem's own repositories are canaries. A downstream project (a # user's application, or a fork of one) validates a release in its own pull diff --git a/.github/tools/built_artifact.sh b/.github/tools/built_artifact.sh new file mode 100755 index 000000000..3652043d0 --- /dev/null +++ b/.github/tools/built_artifact.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# THE PROGRAM A BUILD MADE, AS THE BUILD REPORTS IT. +# +# built_artifact.sh [build arguments...] +# +# Runs ` build --message-format json [arguments]` and prints the absolute +# path of the artifact named , from the build's `artifact` events +# (docs/50-machine-output.md §9): one line per product, whose object has one +# `path`. Read with grep and sed (`-E`, as GNU and BSD sed read it), so that a +# machine with no JSON reader (the hermetic container) can ask it. The build's narration stays on standard +# error. A failed build, or one that reports no such artifact, exits non-zero. +# +# Instead of a search of target/: a self-hosting build keeps the engine it +# runs under target///engine/epoch-N/mcpp, and a search +# for a file named mcpp took that copy (an x86_64 program) for the product of +# an aarch64 build (2026-11-14). +set -euo pipefail +[ $# -ge 2 ] || { echo "usage: $0 [build arguments...]" >&2; exit 2; } +mcpp=$1; name=$2; shift 2 +report=$(mktemp) +trap 'rm -f "$report"' EXIT +"$mcpp" build --message-format json "$@" > "$report" +found=$(grep -F '"event":"artifact"' "$report" | grep -F "\"name\":\"$name\"" \ + | sed -nE 's/.*"path":"(([^"\\]|\\.)*)".*/\1/p' | tail -1 | sed 's/\\\\/\\/g' || true) +[ -n "$found" ] || { echo "the build reported no artifact named $name" >&2; exit 1; } +printf '%s\n' "$found" diff --git a/.github/tools/check_payload_names.py b/.github/tools/check_payload_names.py new file mode 100644 index 000000000..b33d62764 --- /dev/null +++ b/.github/tools/check_payload_names.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""check_payload_names.py -- the engine's sources name no more toolchain payloads than the baseline. + +WHY THIS EXISTS + +Which package a family's toolchain is for a target on a host is data: the +`[[toolchain.payload]]` entries of modules/toolchain-model/data/builtin.toml +(design 2026-11-12 section 16, T3), read by mcpp.toolchain.payloads in both +directions. A payload named in code is a second statement of that fact, which +the next payload has to edit the engine to extend. This check counts. + +WHAT IS COUNTED + +The vocabulary is the table's own: every entry name without a placeholder that +is not a family name (`gcc`, `llvm` and `msvc` name the option vocabularies the +engine implements). Each is counted as a string literal in the sources that +check_system_names.py counts, outside comments, with the same reader. + +THE RATCHET + +`payload-names.baseline` records one number per name. More occurrences than the +baseline fail; fewer fail too, with the line to write, so the count only falls. + + check_payload_names.py compare with the baseline + check_payload_names.py --update rewrite the baseline to the counts found + check_payload_names.py --list N print every counted line of name N +""" + +import importlib.util +import pathlib +import re +import sys +import tomllib + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +BASELINE = HERE / "payload-names.baseline" +TABLE = ROOT / "modules/toolchain-model/data/builtin.toml" +FAMILIES = {"gcc", "llvm", "msvc"} + +_spec = importlib.util.spec_from_file_location("check_system_names", HERE / "check_system_names.py") +system_names = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(system_names) + + +def vocabulary(): + entries = tomllib.loads(TABLE.read_text(encoding="utf-8")).get("toolchain", {}).get("payload", []) + names = [] + for e in entries: + name = e["name"] + if "{" not in name and name not in FAMILIES and name not in names: + names.append(name) + return names + + +def count(names): + patterns = {n: re.compile(r'"%s"' % re.escape(n)) for n in names} + counts = {n: 0 for n in names} + lines = {n: [] for n in names} + for rel, path in system_names.sources(): + state = {"block": False} + for number, raw in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + code = system_names.code_of(raw, state) + for name, pattern in patterns.items(): + hits = len(pattern.findall(code)) + if hits: + counts[name] += hits + lines[name].append(f"{rel}:{number}: {raw.strip()}") + return counts, lines + + +def main(argv): + names = vocabulary() + counts, lines = count(names) + if len(argv) == 2 and argv[0] == "--list": + print("\n".join(lines.get(argv[1], []))) + return 0 + if argv == ["--update"]: + text = ("# One number per payload name of data/builtin.toml: its occurrences in the\n" + "# engine's sources (.github/tools/check_payload_names.py). The numbers only fall.\n") + text += "".join(f"{n} {counts[n]}\n" for n in names) + BASELINE.write_text(text) + print(f"wrote {BASELINE.relative_to(ROOT)}") + return 0 + base = {} + if BASELINE.is_file(): + for line in BASELINE.read_text().splitlines(): + line = line.split("#", 1)[0].strip() + if line: + name, number = line.split() + base[name] = int(number) + more = [f" {n}: {counts[n]} occurrences, the baseline is {base.get(n, 0)}" + for n in names if counts[n] > base.get(n, 0)] + fewer = [f" {n}: {counts[n]} occurrences, the baseline is {base.get(n, 0)}" + for n in names if counts[n] < base.get(n, 0)] + if more: + print("the engine's sources name a toolchain payload more often than the baseline:") + print("\n".join(more)) + print("Which payload serves a family is data: an entry of `[[toolchain.payload]]`\n" + "(modules/toolchain-model/data/builtin.toml), read through mcpp.toolchain.payloads.\n" + "`check_payload_names.py --list ` prints the counted lines.") + if fewer: + print("the engine's sources name a toolchain payload less often than the baseline; record it:") + print("\n".join(fewer)) + print(" python3 .github/tools/check_payload_names.py --update") + return 1 if more or fewer else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index cf3dc3802..6f560669e 100755 --- a/.github/tools/install_pinned_mcpp.sh +++ b/.github/tools/install_pinned_mcpp.sh @@ -137,4 +137,15 @@ echo "bootstrap mcpp: $MCPP ($GOT)" >&2 # a member's `os = "0.1.0"` -- missed against it with no refresh. "$MCPP" index update >&2 || echo "::warning::the bootstrap mcpp could not refresh its package index" >&2 +# THE RELEASED BINARY ITSELF, for a program that runs the bootstrap outside +# this checkout (MCPP_BOOT; e2e 772, a released client builds a published +# archive). $MCPP is xlings' shim, which takes the version from the project of +# its working directory and finds none in a directory of a test's own. The +# payload is where xlings places a package, under the home this script found; +# the release fallback above is a binary already. +boot="$MCPP" +payload="$XL_HOME/.xlings/data/xpkgs/xim-x-mcpp/$PIN/bin/mcpp${EXE}" +[ "$installed" = no ] || [ ! -f "$payload" ] || boot="$payload" +[ -z "${GITHUB_ENV:-}" ] || echo "MCPP_BOOT=$boot" >> "$GITHUB_ENV" + echo "$MCPP" diff --git a/.github/tools/payload-names.baseline b/.github/tools/payload-names.baseline new file mode 100644 index 000000000..22d3d5692 --- /dev/null +++ b/.github/tools/payload-names.baseline @@ -0,0 +1,7 @@ +# One number per payload name of data/builtin.toml: its occurrences in the +# engine's sources (.github/tools/check_payload_names.py). The numbers only fall. +emsdk 0 +android-ndk 0 +musl-gcc 0 +mingw-gcc 1 +mingw-cross-gcc 0 diff --git a/.github/tools/system-names.baseline b/.github/tools/system-names.baseline index 106788fcd..02744e5ba 100644 --- a/.github/tools/system-names.baseline +++ b/.github/tools/system-names.baseline @@ -1,18 +1,18 @@ # One number per word: its occurrences in the engine's sources # (.github/tools/check_system_names.py). The numbers only fall. -linux 66 -macos 54 -windows 81 -freebsd 2 +linux 64 +macos 51 +windows 72 +freebsd 1 android 19 -emscripten 21 -ios 25 +emscripten 18 +ios 24 darwin 10 -netbsd 1 +netbsd 0 openbsd 0 haiku 0 dos 0 uefi 0 -is_linux 30 -is_windows 130 +is_linux 26 +is_windows 126 is_macos 32 diff --git a/.github/tools/test_history.py b/.github/tools/test_history.py new file mode 100644 index 000000000..ef2cb45cb --- /dev/null +++ b/.github/tools/test_history.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""The run store `mcpp test` reads and writes (docs/08 "The order of a run"). + + test_history.py take ... the runs of each store taken + into , each run once + test_history.py own the runs of that are not of + (a workflow run) removed + +A store is the mtest store's form: `index.ndjson`, a line per run, and +`runs//`. `take` gives a job the measured history of main's last run, so +that its `mcpp test` orders the tests longest first and assigns its shards by +duration; `own` leaves the job's own runs before the store is kept, so that a +store does not carry the history it was given into the next run's. +""" +import json +import pathlib +import shutil +import sys + + +def runs(store): + index = store / "index.ndjson" + if not index.is_file(): + return [] + out = [] + for line in index.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + run = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(run, dict) and run.get("id"): + out.append((run, line)) + return out + + +def take(store, sources): + store.mkdir(parents=True, exist_ok=True) + have = {run["id"] for run, _ in runs(store)} + taken = 0 + with (store / "index.ndjson").open("a", encoding="utf-8") as index: + for source in sources: + for run, line in runs(source): + if run["id"] in have: + continue + src = source / "runs" / run["id"] + if not src.is_dir(): + continue + shutil.copytree(src, store / "runs" / run["id"], dirs_exist_ok=True) + index.write(line + "\n") + have.add(run["id"]) + taken += 1 + print(f"{taken} run(s) taken into {store}") + + +def own(store, group): + kept, dropped = [], 0 + for run, line in runs(store): + if str(run.get("group", "")) == group: + kept.append(line) + continue + shutil.rmtree(store / "runs" / run["id"], ignore_errors=True) + dropped += 1 + if (store / "index.ndjson").is_file(): + (store / "index.ndjson").write_text("".join(l + "\n" for l in kept), encoding="utf-8") + print(f"{len(kept)} run(s) of group {group} kept, {dropped} removed from {store}") + + +def main(argv): + if len(argv) >= 3 and argv[1] == "take": + take(pathlib.Path(argv[2]), [pathlib.Path(p) for p in argv[3:]]) + return 0 + if len(argv) == 4 and argv[1] == "own": + own(pathlib.Path(argv[2]), argv[3]) + return 0 + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/versions.env b/.github/versions.env index 018aaffcf..cea9579db 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,5 +1,5 @@ # The engines CI pins, read by the composite actions (.github/actions/mtest). # mtest: an xlings package (xim:mtest) whose sha256 the index states; the release # bundle's sha256 checks the fallback download. -MTEST_VERSION=0.6.2 -MTEST_SHA256=06947a06ce4803bdc9ca9038bcda07b179d9ce46ab016355e772289abb4f6e30 +MTEST_VERSION=0.8.0 +MTEST_SHA256=06555eb8dbdebdf978dc2f074a3efe8521bf71bb51844976216ec21d7fb4be94 diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 000000000..17f8c690b --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,71 @@ +name: bench + +# THE ENGINE'S OWN COSTS, MEASURED ALONE (bench/tests, mtest Bench cases; +# design 2026-11-10 §16 and 2026-11-14). The measurement a release is merged +# with, for a commit whose CI already passed: the Linux binary that run built +# (`mcpp-built-linux-x86_64`), timed on the bench fixture, instead of the whole +# CI started again with `bench`. A commit no successful CI run built is built +# here. Main's CI measures every push, which is the history kept. + +on: + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + bench: + name: the engine's costs (linux x86_64) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + MCPP_HOME: /home/runner/.mcpp + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/bootstrap-mcpp + + - name: This commit's mcpp, from its CI run + id: built + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --commit "$GITHUB_SHA" --status success \ + --limit 1 --json databaseId -q '.[0].databaseId' 2>/dev/null || true) + if [ -n "$run" ] && gh run download "$run" -R "$GITHUB_REPOSITORY" -n mcpp-built-linux-x86_64 \ + -D "$RUNNER_TEMP/mcpp-built" 2>/dev/null; then + echo "this commit's mcpp from CI run $run" + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "no successful CI run of $GITHUB_SHA kept its binary: it is built here" + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Use this commit's mcpp + if: steps.built.outputs.found == 'true' + shell: bash + run: bash .github/actions/use-built-mcpp/use.sh linux-x86_64 GLOBAL + + - name: Build this commit's mcpp + if: steps.built.outputs.found != 'true' + shell: bash + run: | + set -euo pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + built=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP" mcpp) + echo "MCPP_FRESH=$built" >> "$GITHUB_ENV" + + - name: The engine's costs (bench, mtest Bench) + shell: bash + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + cd bench && MCPP="$MCPP_FRESH" "$MCPP_FRESH" test + + - uses: ./.github/actions/test-report + if: always() + with: + name: mtest-bench-linux + stores: bench/target/mtest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7eddce0d..124433ac4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,23 @@ on: type: string required: false default: '' + gate: + description: > + The tests that must pass before this host's jobs start (design + 2026-11-12 §5.11): run with `mcpp test` once the build is uploaded; + a failure fails this job, whose dependants then do not start, and + cancels the run. + type: string + required: false + default: e2e/basics + tests: + description: > + The tests written as the artifact `mcpp-tests-` (`mcpp test + --no-run --archive`, docs/08), for the jobs that run cases on a host + that cannot build them (.github/actions/e2e-cases). + type: string + required: false + default: '' jobs: build: @@ -84,6 +101,53 @@ jobs: if-no-files-found: error retention-days: 3 + # THE HOST'S GATE: a new program scaffolded, built and run by this + # commit's mcpp (MCPP_BIN_MCPP), seconds of running. A red commit stops + # here, before the shards and the jobs that need several builds start. + - name: The host's gate + id: gate + if: ${{ inputs.gate != '' }} + shell: bash + run: | + set -euo pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + # shellcheck disable=SC2086 -- the input is a list of test paths + "$MCPP" test ${{ inputs.gate }} + + - name: A failed gate cancels the run + if: ${{ failure() && steps.gate.outcome == 'failure' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "::error::the gate of ${{ inputs.host }} failed: the run is cancelled" + gh run cancel "$GITHUB_RUN_ID" -R "$GITHUB_REPOSITORY" \ + || echo "::warning::the run could not be cancelled (the token's actions permission)" + + # The tests a host without a compiler runs (a Windows with Visual Studio + # masked, a job with only a cross toolchain, the released mcpp of + # ci-fresh-install): built here, where the package's programs already + # are, and run elsewhere from the archive with no build. The archive + # holds this build's mcpp, which its cases test (MCPP_BIN_MCPP). + - name: Write the tests other jobs run + if: ${{ inputs.tests != '' }} + shell: bash + run: | + set -euo pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + # shellcheck disable=SC2086 -- the input is a list of test paths + "$MCPP" test ${{ inputs.tests }} --no-run --archive "dist/mcpp-tests.tar.gz" + + - name: Upload the tests other jobs run + if: ${{ inputs.tests != '' }} + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: mcpp-tests-${{ inputs.host }} + path: dist/mcpp-tests.tar.gz + if-no-files-found: error + retention-days: 7 + # Only on main, and only for the caches this job restored: a pull request # reads the main lineage and adds nothing to the store. - name: Install the toolchains the consumers use diff --git a/.github/workflows/canaries.yml b/.github/workflows/canaries.yml new file mode 100644 index 000000000..ed060b30b --- /dev/null +++ b/.github/workflows/canaries.yml @@ -0,0 +1,126 @@ +name: canaries + +# THE TWO CANARIES OVER THE MATRIX, NIGHTLY (design 2026-11-12 §5.7). +# +# tests/canary/c (C89 on POSIX alone: two programs from one set of sources, a +# resource script written once, a non-local jump, files, the arguments) and +# tests/canary/cxx (C++23 modules, `import std`, exceptions, threads, a test) +# are built, linked statically, run, tested and packed for every identity the +# host's rows of tests/canary/matrix.tsv name, with this commit's mcpp. Each row +# states what the step does (ok, fails with a note, n/a); a cell that differs +# from its row, either way, fails the job, so the table is a declaration that +# a change of behaviour corrects. The report of each host is the job's +# summary and its artifact `canary-report-`; a statement such as "every +# identity builds" cites a run of this workflow. +# +# Nightly, by hand, and on a pull request labelled `ci:canaries`; no pull +# request waits for it otherwise. + +on: + schedule: + - cron: "17 19 * * *" + workflow_dispatch: + pull_request: + types: [labeled, synchronize] + +permissions: + contents: read + actions: read + +concurrency: + group: canaries-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-linux: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:canaries') + uses: ./.github/workflows/build.yml + with: + host: linux-x86_64 + runs-on: ubuntu-24.04 + + build-macos: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:canaries') + uses: ./.github/workflows/build.yml + with: + host: macos-arm64 + runs-on: macos-15 + + build-windows: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:canaries') + uses: ./.github/workflows/build.yml + with: + host: windows-x86_64 + runs-on: windows-latest + + linux: + name: canaries (linux-x86_64) + needs: build-linux + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + - uses: ./.github/actions/use-built-mcpp + with: + host: linux-x86_64 + # The machine the rows of linux-x86_64 state: an aarch64 program runs + # through QEMU's user mode, registered with binfmt_misc, as it does where + # the matrix was measured. Without it the aarch64 Linux rows' run and test + # are refused by the system (Exec format error), which is not mcpp's to state. + - name: QEMU user mode for aarch64 programs + run: | + sudo apt-get update -q + sudo apt-get install -y -q qemu-user-static binfmt-support + test -e /proc/sys/fs/binfmt_misc/qemu-aarch64 + - name: The canaries over the matrix + run: MCPP="$MCPP_FRESH" bash tests/canary/run.sh --host linux-x86_64 --report canary-report-linux-x86_64.tsv + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: canary-report-linux-x86_64 + path: canary-report-linux-x86_64.tsv + + macos: + name: canaries (macos-aarch64) + needs: build-macos + runs-on: macos-15 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-macos-llvm + with: + image: macos-15 + - uses: ./.github/actions/use-built-mcpp + with: + host: macos-arm64 + - name: The canaries over the matrix + run: MCPP="$MCPP_FRESH" bash tests/canary/run.sh --host macos-aarch64 --report canary-report-macos-aarch64.tsv + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: canary-report-macos-aarch64 + path: canary-report-macos-aarch64.tsv + + windows: + name: canaries (windows-x86_64) + needs: build-windows + runs-on: windows-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + - uses: ./.github/actions/use-built-mcpp + with: + host: windows-x86_64 + - name: The canaries over the matrix + shell: bash + run: MCPP="$MCPP_FRESH" bash tests/canary/run.sh --host windows-x86_64 --report canary-report-windows-x86_64.tsv + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: canary-report-windows-x86_64 + path: canary-report-windows-x86_64.tsv diff --git a/.github/workflows/ci-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index 5fa97a62b..c60ef4415 100644 --- a/.github/workflows/ci-aarch64-fresh-install.yml +++ b/.github/workflows/ci-aarch64-fresh-install.yml @@ -244,8 +244,7 @@ jobs: # above, same reason as install_released_mcpp.sh point 1. rm -f .xlings.json # Two compiles at a time, for the reason of the self-host step above. - MCPP_JOBS=2 mcpp build --target aarch64-linux-musl - self=$(find target/aarch64-linux-musl -type f -path '*/bin/mcpp' | head -1) + self=$(MCPP_JOBS=2 bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" mcpp mcpp --target aarch64-linux-musl) test -x "$self" self=$(realpath "$self") "$self" --version diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 97bccff54..27c6cad7e 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -27,6 +27,12 @@ on: # Run daily at 06:00 UTC to catch issues from xlings/runner updates - cron: '0 6 * * *' +permissions: + contents: read + # The bare Windows job runs a case from the test archive of main's last CI + # run (.github/actions/e2e-cases). + actions: read + concurrency: group: ci-fresh-install cancel-in-progress: false # use false to test in PRs, true to only test released mcpp @@ -821,13 +827,23 @@ jobs: bash .github/tools/install_released_mcpp.sh "${MCPP_PIN}" "$(pwd)" mcpp self config --mirror GLOBAL + - name: The released mcpp + shell: bash + run: echo "MCPP_RELEASED=$(command -v mcpp)" >> "$GITHUB_ENV" + # The self-check, the fallback, persistence, a self-contained exe, and # the refusal to overrule an explicit [toolchain] — all in e2e 182, so - # the assertions live with the tests rather than in workflow YAML. + # the assertions live with the tests rather than in workflow YAML. This + # host builds nothing with Visual Studio masked: the case comes from the + # test archive of main's last CI run, and tests the released mcpp. - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" - shell: bash - run: | - MCPP="$(command -v mcpp)" bash tests/e2e/182_windows_no_msvc_fallback.sh + uses: ./.github/actions/e2e-cases + with: + mcpp: ${{ env.MCPP_RELEASED }} + under-test: ${{ env.MCPP_RELEASED }} + host: windows-x86_64 + from-main: 'true' + cases: mingw/182 windows no msvc fallback - uses: actions/upload-artifact@v4 if: failure() diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 1195d4be2..469c53ad1 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -1,10 +1,10 @@ name: ci-linux-e2e # The e2e suite (tests/e2e/run_all.sh) on Linux, called by ci.yml beside -# ci-linux.yml after the Linux build. Eight shards, divided by mtest by the -# measured history of main's stores (design 2026-11-08 W8; rule R4 of the -# 2026-10-02 CI record): about 94 minutes of tests (2026-11-08), about 12 a -# shard one test at a time and about 6 two at a time, +# ci-linux.yml after the Linux build. As many shards as ci.yml's plan gives +# (design 2026-11-12 §5.11; `mtest ci plan` over the measured history of main's +# stores, tests/e2e/mtest.toml [ci.plan]); the scripts are divided among them +# by that history (design 2026-11-08 W8; rule R4 of the 2026-10-02 CI record), # each running the one binary build.yml produced (use-built-mcpp, rule R1). # Each shard writes a per-test report that the e2e-coverage job of ci.yml reads # (rule R5). @@ -14,24 +14,42 @@ name: ci-linux-e2e on: workflow_call: + inputs: + set: + description: the set of cases (`pr` or `full`; ci.yml's changes job) + type: string + default: full + shards: + description: the shards of this host (`mtest ci plan`), a JSON list + type: string + default: '[1,2,3]' + count: + description: how many shards the list holds + type: string + default: '3' + work: + description: whether the set gives this host any case + type: boolean + default: true jobs: e2e: - name: e2e ${{ matrix.shard }}/8 (linux x86_64, self-host) + name: e2e ${{ matrix.shard }}/${{ inputs.count }} (linux x86_64, self-host) + if: inputs.work runs-on: ubuntu-24.04 timeout-minutes: 45 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard: ${{ fromJSON(inputs.shards) }} env: - E2E_SHARD: ${{ matrix.shard }}/8 + E2E_SHARD: ${{ matrix.shard }}/${{ inputs.count }} # By mtest (design 2026-11-08 W8; .github/actions/e2e-mtest): parts by # the measured history of main's stores, two tests at a time (measured on # one Linux shard: 220 s against 424 s one at a time, three no faster), # and on a pull request the tests its change affects. E2E_JOBS: '2' - E2E_SET: ${{ github.event_name == 'pull_request' && 'pr' || '' }} + E2E_SET: ${{ inputs.set == 'pr' && 'pr' || '' }} E2E_REPORT: ${{ github.workspace }}/e2e-report-linux-${{ matrix.shard }}.tsv MCPP_HOME: /home/runner/.mcpp # NOTE: do NOT force MCPP_VERBOSE here. The e2e suite includes tests that @@ -469,8 +487,7 @@ jobs: # tests, a compiler-free container in which only sandbox payloads exist. - name: Build PR mcpp from source (sandbox gcc only) run: | - "$MCPP_BOOT" build - MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)") + MCPP=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP_BOOT" mcpp) test -x "$MCPP" "$MCPP" --version echo "MCPP=$MCPP" >> "$GITHUB_ENV" @@ -524,51 +541,25 @@ jobs: "ok: a stated self-contained program over a coupled C++ shared library is refused" \ "PASS: 700 a program over a C++ shared library has one C++ runtime" - # The compile database (#699 report, design 2026-09-26 §3): the scripts - # whose rows need llvm declare `# requires: llvm`, which no shard has, so - # they run here and each is held to the lines it prints only when the - # property it names was checked. - - name: "Compile database e2e that needs llvm (783, 784, 785, 786)" + # The compile database (#699 report, design 2026-09-26 §3): the cases whose rows need llvm + # (tests/e2e/cdb/toolchains.cpp, formerly e2e 783-786) require `llvm`, which no shard has, so + # they run here. The bootstrap drives `mcpp test` so that the test build reuses the build of + # the step above; the mcpp under test is that build's binary (MCPP_BIN_MCPP). Each case is + # held to its `ok` line: a skip or a failure fails the step. + - name: "Compile database e2e that needs llvm (e2e/cdb, suite toolchains)" run: | set -o pipefail - # The scripts read the database with python3, which this container - # lacks. It is not a toolchain: the job's property (no host compiler, - # no host C runtime) was asserted before the checkout and still holds. - if ! command -v python3 >/dev/null 2>&1; then - apt-get install -y -qq python3 >/dev/null - if command -v gcc >/dev/null 2>&1; then - echo "::error::installing python3 brought a host compiler"; exit 1 - fi - fi export PATH="$HOME/.xlings/subos/current/bin:$PATH" - export MCPP - run_and_assert() { # $1 = script; the remaining arguments are lines it must print - local script="$1"; shift - local log="$RUNNER_TEMP/$(basename "$script").log" - bash "$script" 2>&1 | tee "$log" - for line in "$@"; do - grep -qxF "$line" "$log" || { echo "::error::$script did not print: $line"; exit 1; } - done - } - run_and_assert tests/e2e/783_cdb_switches_whole_with_the_configuration.sh \ - "ok: mcpp test then mcpp build keep the test entry in one configuration" \ - "ok: switching toolchain switches the whole root file" \ - "ok: switching back restores that configuration's entries, test units included" \ - "PASS: 783 one compile database per configuration" - run_and_assert tests/e2e/784_cdb_replays_from_its_directory.sh \ - "ok: gcc row replays every entry from its directory, no gcm.cache/ in the project root" \ - "ok: llvm row replays every entry from its directory" \ - "PASS: 784 the compile database replays from its own directory" - run_and_assert tests/e2e/785_cdb_interface_flag_module_extensions.sh \ - "ok: the interface entry carries -x c++-module immediately before -c, and replays" \ - "ok: the non-module unit carries no language flag" \ - "PASS: 785 the compile database states a module interface's language explicitly" - run_and_assert tests/e2e/786_std_unit_in_the_database_and_build_id.sh \ - "ok: the std unit's directory is the shared std cache, not the project's output directory" \ - "ok: emit --spec compile-commands renders the same std entry as the build's database" \ - "ok: provides['std'] names the std-cache BMI" \ - "ok: build-id is present and stable across two runs" \ - "PASS: 786 the std unit in the database, emit/build agreement, provides and build-id" + log="$RUNNER_TEMP/cdb-toolchains.log" + "$MCPP_BOOT" test e2e/cdb --case toolchains 2>&1 | tee "$log" + for line in \ + " ok toolchains/783 cdb switches whole with the configuration" \ + " ok toolchains/784 cdb replays from its directory" \ + " ok toolchains/785 cdb interface flag module extensions" \ + " ok toolchains/786 std unit in the database and build id" \ + "4 passed, 0 failed, 0 skipped"; do + grep -qxF "$line" "$log" || { echo "::error::e2e/cdb toolchains did not print: $line"; exit 1; } + done # Last, so that the payloads the steps above installed are in it. This job # is the one writer of its cache, on main only (R3). diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index cff74a76f..0ae71c16a 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -18,7 +18,9 @@ name: ci-linux # # The checks that need no binary run in the `docs` job of ci.yml. # -# The e2e suite is ci-linux-e2e.yml, called beside this one. +# The e2e suite is ci-linux-e2e.yml, called beside this one; the root suite of +# `mcpp test` (the unit tests and the C++ e2e programs) is ci-unit.yml, built +# once and run in shards. # # Paired workflows: ci-linux-e2e.yml, ci-macos.yml, ci-windows.yml. @@ -31,21 +33,26 @@ on: bench: type: boolean default: false + # The set of cases (ci.yml's changes job): `pr` runs the tests the + # change affects (`mcpp test --since`), `full` every test. + set: + type: string + default: full env: # MCPP_HOME pinned so the cache keys below restore into the same path # mcpp resolves at runtime. MCPP_HOME: /home/runner/.mcpp # Verbose every mcpp invocation for richer CI diagnostics (src/cli.cppm). - # Safe here: this workflow no longer runs the e2e suite, which is what - # asserts mcpp's default quiet output (tests 48/53). + # The e2e cases of the unit job read mcpp's default output; the case support + # removes MCPP_VERBOSE from the mcpp a case starts (tests/support). MCPP_VERBOSE: "1" jobs: build-test: - name: unit tests (linux x86_64, self-host) + name: subsystem tests (linux x86_64, self-host) runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -59,17 +66,13 @@ jobs: # docs/08; design 2026-11-08 W8): the base's tip is fetched, and when it # cannot be, every test runs. - name: The base of the change - if: github.base_ref != '' + if: github.base_ref != '' && inputs.set == 'pr' shell: bash run: | if git fetch --no-tags --depth=1 origin "+refs/heads/$GITHUB_BASE_REF:refs/remotes/origin/$GITHUB_BASE_REF"; then echo "MCPP_TEST_SINCE=origin/$GITHUB_BASE_REF" >> "$GITHUB_ENV" fi - - name: Unit + integration tests via `mcpp test` - run: | - "$MCPP_FRESH" test ${MCPP_TEST_SINCE:+--since "$MCPP_TEST_SINCE"} - # Each package under `modules/` carries its own tests, and they are built # as that package ALONE -- a configuration the root suite never produces, # since there every module is linked together. A subsystem that has @@ -116,7 +119,7 @@ jobs: - uses: ./.github/actions/test-report if: always() with: - name: mtest-unit-linux + name: mtest-unit-linux-subsystems stores: target/mtest bench/target/mtest # A cold, from-scratch self-host build by this commit's binary with the diff --git a/.github/workflows/ci-macos-e2e.yml b/.github/workflows/ci-macos-e2e.yml index 2bbd0ccfc..9fa3e0be4 100644 --- a/.github/workflows/ci-macos-e2e.yml +++ b/.github/workflows/ci-macos-e2e.yml @@ -1,7 +1,7 @@ name: ci-macos-e2e # The e2e suite on macOS ARM64, called by ci.yml beside ci-macos.yml after the -# macOS build. Three shards, assigned by measured duration +# macOS build. Shards as ci.yml's plan gives them (design 2026-11-12 §5.11), by measured duration # (by mtest, from the measured history of main's stores; rule R4 of the 2026-10-02 CI record), each # running the one binary build.yml produced (use-built-mcpp, rule R1), and # each writing the per-test report the e2e-coverage job of ci.yml reads. @@ -16,48 +16,40 @@ name: ci-macos-e2e on: workflow_call: + inputs: + set: + description: the set of cases (`pr` or `full`; ci.yml's changes job) + type: string + default: full + shards: + description: the shards of this host (`mtest ci plan`), a JSON list + type: string + default: '[1,2,3]' + count: + description: how many shards the list holds + type: string + default: '3' + work: + description: whether the set gives this host any case + type: boolean + default: true jobs: e2e: - name: e2e suite ${{ matrix.shard }}/${{ matrix.shards }} (macOS ARM64, self-host, ${{ matrix.image }}) - # The same two images as ci-macos.yml; `xcode-27` is macOS 27 (see there). - # While the line was on LLVM 22.1.8 the xcode-27 leg was known red - # (#669: the image's SDKs ship `arm64e.x1` .tbd stubs that ld64.lld 22.1.8 - # cannot parse, llvm-project#222721) and ran as a single shard behind an - # input. The 23.1.3 line move removed the cause, so both images run the - # same sharded suite on every pull request. + name: e2e suite ${{ matrix.shard }}/${{ inputs.count }} (macOS ARM64, self-host, ${{ matrix.image }}) + if: inputs.work strategy: fail-fast: false matrix: - include: - - image: macos-15 - shard: 1 - shards: 3 - - image: macos-15 - shard: 2 - shards: 3 - - image: macos-15 - shard: 3 - shards: 3 - - image: xcode-27 - shard: 1 - shards: 3 - - image: xcode-27 - shard: 2 - shards: 3 - - image: xcode-27 - shard: 3 - shards: 3 + image: [macos-15, xcode-27] + shard: ${{ fromJSON(inputs.shards) }} runs-on: ${{ matrix.image }} timeout-minutes: 60 env: - E2E_SHARD: ${{ matrix.shard }}/${{ matrix.shards }} - # 721 installs the large NDK in its own bounded step below. Running it in - # the ordinary shard duplicates the download and can fail before linking. - E2E_EXCLUDE: 721_*.sh + E2E_SHARD: ${{ matrix.shard }}/${{ inputs.count }} # By mtest (design 2026-11-08 W8; .github/actions/e2e-mtest), as on Linux. E2E_JOBS: '2' - E2E_SET: ${{ github.event_name == 'pull_request' && 'pr' || '' }} + E2E_SET: ${{ inputs.set == 'pr' && 'pr' || '' }} E2E_REPORT: ${{ github.workspace }}/e2e-report-${{ matrix.image == 'macos-15' && 'macos' || matrix.image }}-${{ matrix.shard }}.tsv # NOTE: no MCPP_VERBOSE — the e2e suite asserts mcpp's default quiet # output (tests 48/53). @@ -153,18 +145,3 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - # #647 E3: an Android row links on a macOS host. Its own step, because the - # NDK is a large download the suite's per-test bound does not allow for, - # and the shard does not carry it (the script declares android-ndk). - - name: "Android row on a macOS host (721)" - # Once per image, not once per shard: the step installs the NDK. - if: matrix.shard == 1 # ci-lint: allow-r1: one NDK install per image; the shards share it - timeout-minutes: 30 - shell: bash - run: | - set -o pipefail - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL - "$MCPP" toolchain install android-ndk@30.0.16248370 - bash tests/e2e/721_*.sh 2>&1 | tee "$RUNNER_TEMP/721.log" - grep -q '^PASS: 721' "$RUNNER_TEMP/721.log" diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index bc7dde52e..75b51835e 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -10,6 +10,12 @@ name: ci-macos on: workflow_call: + inputs: + # The set of cases (ci.yml's changes job): `pr` runs the tests the + # change affects (`mcpp test --since`), `full` every test. + set: + type: string + default: full jobs: macos-xlings-llvm: @@ -36,7 +42,10 @@ jobs: - image: macos-15 - image: xcode-27 runs-on: ${{ matrix.image }} - timeout-minutes: 45 + # The C++ e2e programs run in this job (tests/e2e/*/, design 2026-11-12 + # section 5.9); a change to tests/support runs them all, measured at 40 + # minutes on xcode-27. + timeout-minutes: 90 # NOTE: no MCPP_VERBOSE here — keep this job's output shape identical to # ci-macos-e2e.yml, which asserts mcpp's default quiet output (48/53). steps: @@ -290,23 +299,31 @@ jobs: # docs/08; design 2026-11-08 W8): the base's tip is fetched, and when it # cannot be, every test runs. - name: The base of the change - if: github.base_ref != '' + if: github.base_ref != '' && inputs.set == 'pr' shell: bash run: | if git fetch --no-tags --depth=1 origin "+refs/heads/$GITHUB_BASE_REF:refs/remotes/origin/$GITHUB_BASE_REF"; then echo "MCPP_TEST_SINCE=origin/$GITHUB_BASE_REF" >> "$GITHUB_ENV" fi + # The unit and C++ e2e tests, longest first by main's measured history, + # and on a re-run only what the attempt before failed (mcpp-unit). + # Measured on 2026-11-13 (macos-15): one case (openkal/trees 956) ran + # 806 s of a 1196 s run, and started when the other workers had half + # the run behind them. + # + # THE NEWEST macOS ON A PULL REQUEST: the tests whose subject is the + # SDK, the system's libc++ headers and the loader (the unit tests, and the + # e2e programs that build, link and load on this host), every test on + # main and on a pull request labelled `ci:full`. The rest of the e2e + # programs state what mcpp decides, which macos-15 runs on every change. - name: Unit + integration tests via `mcpp test` - run: | - "$MCPP_FRESH" test ${MCPP_TEST_SINCE:+--since "$MCPP_TEST_SINCE"} - - # The run `mcpp test` kept (target/mtest): this job's report in its - # summary, and the store for the workflow run's report (ci.yml test-report). - - uses: ./.github/actions/test-report - if: always() + uses: ./.github/actions/mcpp-unit with: - name: mtest-unit-macos + name: ${{ matrix.image }} + history: ${{ matrix.image }} + since: ${{ env.MCPP_TEST_SINCE }} + patterns: ${{ (matrix.image == 'xcode-27' && inputs.set == 'pr') && 'unit e2e/basics e2e/build e2e/macos e2e/modules e2e/runtime e2e/toolchain' || '' }} # WS8: this host's row of docs/01 and docs/20 is checked against the # one answer the resolver gives (`self env --format json`). diff --git a/.github/workflows/ci-unit.yml b/.github/workflows/ci-unit.yml new file mode 100644 index 000000000..6aa055a26 --- /dev/null +++ b/.github/workflows/ci-unit.yml @@ -0,0 +1,273 @@ +name: ci-unit + +# THE UNIT AND C++ E2E TESTS OF ONE HOST, BUILT ONCE AND RUN IN SHARDS (design +# 2026-11-14). Called twice by ci.yml, as two stages: +# +# archive every test built once by the bootstrap mcpp (`mcpp test --no-run +# --no-programs --archive`, SPEC-015 §4), with the measured history +# of main's last run beside it: artifact `mcpp-unit-`. The +# package's program is not built here: the tests run build.yml's. +# It needs no build of this commit, so it runs beside build.yml. +# run the host's gate, then the shards: each runs its part of the +# archive (`mcpp test --archive --shard I/N --split auto`) against +# this commit's mcpp as build.yml built it (MCPP_BIN_MCPP), the tests +# assigned longest first by that one history, so the shards together +# run each test once; a re-run of a failed shard runs what it failed. +# +# Measured on 2026-11-14 (Linux x86_64, GCC): the gate held the build job 18.6 +# minutes after the binary was built, every Linux job waited for it, and the +# one job that then built and ran every test passed its 45-minute limit. In the +# archive stage the gate and the archive built the package's program again +# (1118 s of a 38.5-minute stage), and the shards tested that build. + +on: + workflow_call: + inputs: + stage: + description: archive | run + type: string + required: true + host: + description: the host of build.yml's artifact (linux-x86_64) + type: string + required: true + name: + description: the host's name in the stores' artifact names (linux) + type: string + required: true + runs-on: + type: string + required: true + gate: + description: the tests that must pass before the run goes on (build.yml `gate`) + type: string + default: e2e/basics + tests: + description: the tests written as `mcpp-tests-` for the jobs that run named cases (e2e-cases) + type: string + default: '' + shards: + description: the run stage's shards, a JSON list of 1..N + type: string + default: '[1]' + archived: + description: the number of tests the archive stage built; 0 starts no shard + type: string + default: '1' + cases: + description: the run stage's cases of `mcpp-tests-` that need the MinGW cross toolchain, one per line + type: string + default: '' + outputs: + archived: + description: the number of tests the archive holds + value: ${{ jobs.archive.outputs.archived }} + +env: + MCPP_HOME: /home/runner/.mcpp + # Verbose every mcpp invocation for richer CI diagnostics (src/cli.cppm). The + # e2e cases read mcpp's default output; the case support removes it. + MCPP_VERBOSE: "1" + +jobs: + archive: + name: unit tests, built (${{ inputs.host }}) + if: inputs.stage == 'archive' + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 60 + outputs: + archived: ${{ steps.archive.outputs.archived }} + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/bootstrap-mcpp + + # THE PACKAGE'S PROGRAM IS BUILD.YML'S. `--no-programs` (2026.11.13.1) + # plans the package's programs for their names and neither builds nor + # carries them; the run takes each from MCPP_BIN_. A bootstrap + # without the option builds and carries the program, as before it, and + # the tests run that build of this commit's sources. + - name: The archive's programs + id: programs + shell: bash + run: | + if "$MCPP" test --help 2>/dev/null | grep -q -- '--no-programs'; then + echo "flag=--no-programs" >> "$GITHUB_OUTPUT" + else + echo "the bootstrap $("$MCPP" --version 2>/dev/null | head -1) builds the package's program into the archive" + fi + + # EVERY TEST, ON EVERY SET. The bootstrap mcpp that builds them selects + # by change without the rule that a test running the package's program + # is affected by the program's sources (2026.11.13.1), so it would leave + # the e2e programs out of a pull request that changes src/; with that + # rule nearly every change selects them all. + - name: The tests, built once + id: archive + shell: bash + run: | + set -euo pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + mkdir -p "$RUNNER_TEMP/unit" + # shellcheck disable=SC2086 -- the flag is one word or none + "$MCPP" test --no-run ${{ steps.programs.outputs.flag }} --archive "$RUNNER_TEMP/unit/unit.tar.gz" 2>&1 | tee "$RUNNER_TEMP/archive.log" + n=$(sed -n 's/.*Archived \([0-9][0-9]*\) test.*/\1/p' "$RUNNER_TEMP/archive.log" | tail -1) + [ -f "$RUNNER_TEMP/unit/unit.tar.gz" ] || n=0 + echo "archived=${n:-0}" >> "$GITHUB_OUTPUT" + echo "the archive holds ${n:-0} test(s)" + + # One history for every shard: the shards of one run assign the tests + # from it, so a run of main that finishes while they start changes none + # of their assignments. + - name: The measured history of main's last CI run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + KEY: ${{ inputs.name }} + run: | + set -uo pipefail + mkdir -p "$RUNNER_TEMP/unit/history" + run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --branch main --status success --limit 1 \ + --json databaseId -q '.[0].databaseId' 2>/dev/null || true) + if [ -z "$run" ]; then echo "no successful run of main: the shards divide by count"; exit 0; fi + d="$RUNNER_TEMP/unit-stores" + if ! gh run download "$run" -R "$GITHUB_REPOSITORY" -p "mtest-unit-$KEY*" -D "$d" 2>/dev/null; then + echo "main's run $run kept no store for $KEY: the shards divide by count"; exit 0 + fi + python3 .github/tools/test_history.py take "$RUNNER_TEMP/unit/history" "$d" "$d"/*/ + + - name: Upload the tests and their history + if: steps.archive.outputs.archived != '0' + timeout-minutes: 10 + uses: actions/upload-artifact@v4 + with: + name: mcpp-unit-${{ inputs.host }} + path: ${{ runner.temp }}/unit + if-no-files-found: error + retention-days: 3 + + # The tests a job that runs named cases takes (e2e-cases), built here + # beside the others; the job names the program they test. + - name: Write the tests other jobs run + if: inputs.tests != '' + shell: bash + run: | + set -euo pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + # shellcheck disable=SC2086 -- the input is a list of test paths + "$MCPP" test ${{ inputs.tests }} --no-run ${{ steps.programs.outputs.flag }} --archive "dist/mcpp-tests.tar.gz" + + - name: Upload the tests other jobs run + if: inputs.tests != '' + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: mcpp-tests-${{ inputs.host }} + path: dist/mcpp-tests.tar.gz + if-no-files-found: error + retention-days: 7 + + shard: + name: unit tests ${{ matrix.shard }}/${{ strategy.job-total }} (${{ inputs.host }}, self-host) + if: inputs.stage == 'run' && inputs.archived != '0' + runs-on: ${{ inputs.runs-on }} + # The caller grants `actions: write`: a failed gate cancels the run. A job + # of a called workflow that asks for more than its caller grants fails the + # whole run at its start, the stage that does not run it included. + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shard: ${{ fromJson(inputs.shards) }} + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/bootstrap-mcpp + + - uses: ./.github/actions/use-built-mcpp + with: + host: ${{ inputs.host }} + + - name: The tests of this run + uses: actions/download-artifact@v4 + with: + name: mcpp-unit-${{ inputs.host }} + path: ${{ runner.temp }}/unit + + # The program the tests run is this commit's, as build.yml built it: an + # archive written with --no-programs takes it from MCPP_BIN_MCPP. + - name: The program under test + shell: bash + run: | + echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN" >> "$GITHUB_ENV" + echo "MCPP_BIN_MCPP=$MCPP" >> "$GITHUB_ENV" + + # THE HOST'S GATE (build.yml): a new program scaffolded, built and run by + # this commit's mcpp, seconds of running, from the archive. A red commit + # stops here, as the shards start, and the run is cancelled. The first + # shard runs it; the gate's tests run again in their own shard. + - name: The host's gate + id: gate + if: inputs.gate != '' && matrix.shard == 1 + shell: bash + run: | + set -euo pipefail + # shellcheck disable=SC2086 -- the input is a list of test paths + "$MCPP" test --archive "$RUNNER_TEMP/unit/unit.tar.gz" ${{ inputs.gate }} + + - name: A failed gate cancels the run + if: ${{ failure() && steps.gate.outcome == 'failure' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "::error::the gate of ${{ inputs.host }} failed: the run is cancelled" + gh run cancel "$GITHUB_RUN_ID" -R "$GITHUB_REPOSITORY" \ + || echo "::warning::the run could not be cancelled (the token's actions permission)" + + # One history for every shard, taken after the gate: the gate's run is + # not the shard's, and a record of it in the store would be the newest, + # sizing this shard's tests unlike the others' and answering a re-run's + # `--failed` in place of the attempt that failed. + - name: The history the shards share + shell: bash + run: | + rm -rf -- "$GITHUB_WORKSPACE/target/mtest" + python3 .github/tools/test_history.py take target/mtest "$RUNNER_TEMP/unit/history" + + - name: Unit + integration tests via `mcpp test` + uses: ./.github/actions/mcpp-unit + with: + name: ${{ inputs.name }}-${{ matrix.shard }} + archive: ${{ runner.temp }}/unit/unit.tar.gz + shard: ${{ matrix.shard }}/${{ strategy.job-total }} + + # The Windows cases only a job with the MinGW cross toolchain runs, from the + # tests the archive stage wrote (no build here); each case is held to its + # `ok` line, so a skip cannot pass as a run. + cases: + name: Windows cases from ${{ inputs.host }} (mingw-cross) + if: inputs.stage == 'run' && inputs.cases != '' + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + - uses: ./.github/actions/use-built-mcpp + with: + host: ${{ inputs.host }} + # llvm beside it: the case that pairs clang with the MinGW payload reads + # the program's resources with the payload's llvm-readobj (`llvm`). + - name: Install mingw-cross toolchain + shell: bash + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" toolchain install mingw-cross 16.1.0 + "$MCPP" toolchain install llvm 23.1.3 + echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN" >> "$GITHUB_ENV" + - uses: ./.github/actions/e2e-cases + with: + mcpp: ${{ env.MCPP }} + under-test: ${{ env.MCPP }} + host: ${{ inputs.host }} + cases: ${{ inputs.cases }} diff --git a/.github/workflows/ci-windows-e2e.yml b/.github/workflows/ci-windows-e2e.yml index 20a2abc18..ec73a1434 100644 --- a/.github/workflows/ci-windows-e2e.yml +++ b/.github/workflows/ci-windows-e2e.yml @@ -1,7 +1,7 @@ name: ci-windows-e2e # The e2e suite on Windows x64, called by ci.yml beside ci-windows.yml after -# the Windows build. Three shards, assigned by measured duration +# the Windows build. Shards as ci.yml's plan gives them (design 2026-11-12 §5.11), by measured duration # (by mtest, from the measured history of main's stores; rule R4 of the 2026-10-02 CI record): about # fifteen tests of over thirty seconds took 58 to 64 percent of a round-robin # shard, and the two shards differed by 3.6 minutes on average. Each shard runs @@ -12,26 +12,44 @@ name: ci-windows-e2e on: workflow_call: + inputs: + set: + description: the set of cases (`pr` or `full`; ci.yml's changes job) + type: string + default: full + shards: + description: the shards of this host (`mtest ci plan`), a JSON list + type: string + default: '[1,2,3]' + count: + description: how many shards the list holds + type: string + default: '3' + work: + description: whether the set gives this host any case + type: boolean + default: true jobs: e2e: - name: e2e ${{ matrix.shard }}/3 (windows x64, self-host) + name: e2e ${{ matrix.shard }}/${{ inputs.count }} (windows x64, self-host) + if: inputs.work runs-on: windows-latest timeout-minutes: 45 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: ${{ fromJSON(inputs.shards) }} env: MCPP_HOME: C:\Users\runneradmin\.mcpp # Round-robin slice of tests/e2e (see run_all.sh). - E2E_SHARD: ${{ matrix.shard }}/3 + E2E_SHARD: ${{ matrix.shard }}/${{ inputs.count }} # By mtest (design 2026-11-08 W8; .github/actions/e2e-mtest): parts by # the measured history of main's stores, two tests at a time (measured on # one Linux shard: 220 s against 424 s one at a time, three no faster), # and on a pull request the tests its change affects. E2E_JOBS: '2' - E2E_SET: ${{ github.event_name == 'pull_request' && 'pr' || '' }} + E2E_SET: ${{ inputs.set == 'pr' && 'pr' || '' }} E2E_REPORT: ${{ github.workspace }}/e2e-report-windows-${{ matrix.shard }}.tsv # NOTE: do NOT force MCPP_VERBOSE here. The e2e suite includes tests that # assert mcpp's DEFAULT (quiet) output — e.g. 48_build_error_output and diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 15412590c..e0cfad13b 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -12,6 +12,12 @@ name: ci-windows on: workflow_call: + inputs: + # The set of cases (ci.yml's changes job): `pr` runs the tests the + # change affects (`mcpp test --since`), `full` every test. + set: + type: string + default: full env: MCPP_HOME: C:\Users\runneradmin\.mcpp @@ -20,7 +26,10 @@ jobs: build-test: name: test + package (windows x64, self-host) runs-on: windows-latest - timeout-minutes: 45 + # The C++ e2e programs run in this job (tests/e2e/*/, design 2026-11-12 + # section 5.9); a change to tests/support runs them all, measured at 40 + # minutes on xcode-27. + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/bootstrap-mcpp @@ -43,25 +52,30 @@ jobs: # docs/08; design 2026-11-08 W8): the base's tip is fetched, and when it # cannot be, every test runs. - name: The base of the change - if: github.base_ref != '' + if: github.base_ref != '' && inputs.set == 'pr' shell: bash run: | if git fetch --no-tags --depth=1 origin "+refs/heads/$GITHUB_BASE_REF:refs/remotes/origin/$GITHUB_BASE_REF"; then echo "MCPP_TEST_SINCE=origin/$GITHUB_BASE_REF" >> "$GITHUB_ENV" fi - - name: Unit + integration tests via mcpp test + - name: The xlings the tests' mcpp uses shell: bash - run: | - export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") - "$MCPP_SELF" test ${MCPP_TEST_SINCE:+--since "$MCPP_TEST_SINCE"} - - # The run `mcpp test` kept (target/mtest): this job's report in its - # summary, and the store for the workflow run's report (ci.yml test-report). - - uses: ./.github/actions/test-report - if: always() + run: echo "MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe")" >> "$GITHUB_ENV" + + # The unit and C++ e2e tests, longest first by main's measured history, + # and on a re-run only what the attempt before failed (mcpp-unit). One + # job: measured on 2026-11-14 the run takes 8 minutes of the job's 19, the + # build of the tests most of the rest, which a shard would pay again. + # --timeout: an e2e program runs its cases in one process (`programs = + # "directory"`), up to fifteen former scripts that had 600 s each. + - name: Unit + integration tests via mcpp test + uses: ./.github/actions/mcpp-unit with: - name: mtest-unit-windows + name: windows + history: windows + mcpp: ${{ env.MCPP_SELF }} + since: ${{ env.MCPP_TEST_SINCE }} - name: Home and runner-cache paths preserve Unicode under a legacy Windows code page shell: bash @@ -190,12 +204,18 @@ jobs: shell: bash run: python .github/tools/check_default_toolchain_docs.py --mcpp "$MCPP_SELF" - - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" + # From the test archive build.yml wrote: with Visual Studio masked this + # host builds nothing, so the case is built where it can be. + - name: The xlings the cases' mcpp uses shell: bash - env: - MCPP_VENDORED_XLINGS: ${{ env.XLINGS_BIN }} - run: | - MCPP="$MCPP_SELF" bash tests/e2e/182_windows_no_msvc_fallback.sh + run: echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN" >> "$GITHUB_ENV" + + - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" + uses: ./.github/actions/e2e-cases + with: + mcpp: ${{ env.MCPP_SELF }} + host: windows-x86_64 + cases: mingw/182 windows no msvc fallback - uses: actions/upload-artifact@v4 if: failure() @@ -340,11 +360,16 @@ jobs: # winlibs mirror): install → default → modules build/run → standalone # exe. Same flow as e2e 97 but as a visible CI step. Payload is cached # via the mcpp sandbox cache after the first run. - - name: "Toolchain: MinGW — install → build → run (xim:mingw-gcc)" + - name: The xlings the cases' mcpp uses shell: bash - run: | - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - MCPP="$MCPP_SELF" bash tests/e2e/97_mingw_toolchain.sh + run: echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN" >> "$GITHUB_ENV" + + - name: "Toolchain: MinGW — install → build → run (xim:mingw-gcc)" + uses: ./.github/actions/e2e-cases + with: + mcpp: ${{ env.MCPP_SELF }} + host: windows-x86_64 + cases: mingw/97 mingw toolchain # windows-latest ships VS 2022 Enterprise with the VC workload, so # msvc@system detection MUST succeed here — a failure is a regression @@ -372,19 +397,23 @@ jobs: "$MCPP_SELF" self doctor 2>&1 | tee doctor.txt || true grep -qi "msvc" doctor.txt - # native cl.exe build: full e2e (modules, import std, incremental) - cd "$GITHUB_WORKSPACE" - MCPP="$MCPP_SELF" bash tests/e2e/99_msvc_native_build.sh - - # build.mcpp under cl.exe. `MSVC x build.mcpp` was an empty cell in - # this matrix and the feature was correspondingly at zero — the ten - # build.mcpp e2e all run under clang, whose payload path has no - # spaces in it either. Both gaps closed here. - MCPP="$MCPP_SELF" bash tests/e2e/180_msvc_build_mcpp.sh - # restore the LLVM default for the remaining steps "$MCPP_SELF" toolchain default llvm@23.1.3 + # The native cl.exe build (modules, import std, incremental), and + # build.mcpp under cl.exe: `MSVC x build.mcpp` was an empty cell in this + # matrix and the feature was correspondingly at zero — the ten build.mcpp + # e2e all run under clang, whose payload path has no spaces in it either. + # Each case restores the default it changes. + - name: "Toolchain: MSVC — native build and build.mcpp (e2e 99, 180)" + uses: ./.github/actions/e2e-cases + with: + mcpp: ${{ env.MCPP_SELF }} + host: windows-x86_64 + cases: | + msvc/99 msvc native build + msvc/180 msvc build mcpp + # mcpp#693: a project in a directory whose name is not ASCII, inside the # runner's code page 1252 and outside it, on the llvm, MSVC and MinGW rows, # plus a path carried through build.mcpp. Each row reaches a different diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0315174..85d610a66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,9 +55,25 @@ jobs: timeout-minutes: 5 outputs: code: ${{ steps.classify.outputs.code }} + set: ${{ steps.set.outputs.set }} steps: - uses: actions/checkout@v4 + # The set of cases this run runs (design 2026-11-12 §5.11): a pull + # request's push runs `pr`, the cases its change affects; a pull request + # labelled `ci:full`, a push to main and a run started by hand run `full`. + # The label is read when a push starts the run, as `ci:bench` is. + - name: The set of this run + id: set + env: + EVENT: ${{ github.event_name }} + LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} + run: | + set=full + if [ "$EVENT" = pull_request ] && [[ " $LABELS " != *" ci:full "* ]]; then set=pr; fi + echo "set=$set" >> "$GITHUB_OUTPUT" + echo "the set of this run: $set" + - name: Classify the changed paths id: classify env: @@ -94,6 +110,53 @@ jobs: esac python3 .github/tools/classify_changes.py --github-output "$GITHUB_OUTPUT" < changed.txt + # Each host's e2e shards, by the work its set gives it there (design + # 2026-11-12 §5.11): `mtest ci plan` divides the selection by the measured + # history of main's stores (tests/e2e/mtest.toml [ci.plan]), so the shard + # count follows the work, and a host the set gives nothing starts no shard. + # A plan that cannot be made gives every host three shards. + e2e-plan: + name: the e2e shards of each host + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + linux: ${{ steps.plan.outputs.linux }} + linux_work: ${{ steps.plan.outputs.linux_work }} + linux_count: ${{ steps.plan.outputs.linux_count }} + macos: ${{ steps.plan.outputs.macos }} + macos_work: ${{ steps.plan.outputs.macos_work }} + macos_count: ${{ steps.plan.outputs.macos_count }} + windows: ${{ steps.plan.outputs.windows }} + windows_work: ${{ steps.plan.outputs.windows_work }} + windows_count: ${{ steps.plan.outputs.windows_count }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/e2e-mtest + with: + host: '*' + - name: The plan + id: plan + env: + SET: ${{ needs.changes.outputs.set }} + run: | + set -uo pipefail + out="$RUNNER_TEMP/plan.out" + if (cd tests/e2e && GITHUB_OUTPUT="$out" "$E2E_MTEST" ci plan --set "$SET" --format github); then + # A host given no work keeps a list a matrix accepts; `work` skips it. + sed 's/=\[\]$/=[1]/' "$out" >> "$GITHUB_OUTPUT" + for h in linux macos windows; do + n=$(sed -n "s/^$h=\[\(.*\)\]$/\1/p" "$out" | tr ',' '\n' | grep -c . || true) + echo "${h}_count=${n:-0}" >> "$GITHUB_OUTPUT" + done + else + echo "::warning::mtest ci plan failed: every host takes three shards" + for h in linux macos windows; do + printf '%s=[1,2,3]\n%s_work=true\n%s_count=3\n' "$h" "$h" "$h" >> "$GITHUB_OUTPUT" + done + fi + docs: name: documentation and repository checks runs-on: ubuntu-24.04 @@ -110,6 +173,9 @@ jobs: - name: Check that the engine's sources name no more systems than the baseline run: python3 .github/tools/check_system_names.py + - name: Check that the engine's sources name no more toolchain payloads than the baseline + run: python3 .github/tools/check_payload_names.py + - name: Check that headers and conditional directives appear only in the system lock files run: bash .github/tools/check_include_regions.sh @@ -165,6 +231,7 @@ jobs: python3 tests/scripts/test_classify_changes.py python3 tests/scripts/test_notify_packages.py python3 tests/scripts/test_check_e2e_coverage.py + python3 tests/scripts/test_test_history.py python3 tests/scripts/test_check_default_toolchain_docs.py python3 tests/scripts/test_check_provider_boundary.py python3 tests/scripts/test_native_candidate_admission.py @@ -173,6 +240,10 @@ jobs: build-linux: needs: changes if: needs.changes.outputs.code == 'true' + # A failed gate cancels the run (build.yml). + permissions: + contents: read + actions: write uses: ./.github/workflows/build.yml with: host: linux-x86_64 @@ -180,10 +251,18 @@ jobs: # What the Linux consumers install beyond the build's own toolchain: the # e2e shards (musl, llvm, mingw-cross), the toolchain legs (musl, llvm). prewarm: gcc 16.1.0-musl; llvm 23.1.3; mingw-cross 16.1.0 + # The gate and the tests the Windows cases need are unit-linux-archive's + # (ci-unit.yml), beside this build: with GCC the gate held this job 18.6 + # minutes after the binary was built, and every Linux job waited for it. + gate: '' build-linux-arm: needs: changes if: needs.changes.outputs.code == 'true' + # A failed gate cancels the run (build.yml). + permissions: + contents: read + actions: write uses: ./.github/workflows/build.yml with: host: linux-aarch64 @@ -193,6 +272,10 @@ jobs: build-macos: needs: changes if: needs.changes.outputs.code == 'true' + # A failed gate cancels the run (build.yml). + permissions: + contents: read + actions: write uses: ./.github/workflows/build.yml with: host: macos-arm64 @@ -201,12 +284,19 @@ jobs: build-windows: needs: changes if: needs.changes.outputs.code == 'true' + # A failed gate cancels the run (build.yml). + permissions: + contents: read + actions: write uses: ./.github/workflows/build.yml with: host: windows-x86_64 runs-on: windows-latest # The MinGW payload every Windows e2e shard installs. prewarm: mingw 16.1.0 + # The Windows cases the jobs with Visual Studio masked run (no compiler + # there), and ci-fresh-install's released mcpp. + tests: e2e/windows # The mcpp .xlings.json names builds the tree as a developer's clone does, # and so does the newest release while the pin lags it (ci-bootstrap.yml). @@ -215,37 +305,105 @@ jobs: if: needs.changes.outputs.code == 'true' uses: ./.github/workflows/ci-bootstrap.yml + # THE LINUX UNIT AND C++ E2E TESTS, built once beside the build and run in + # shards (ci-unit.yml; design 2026-11-14). The archive stage needs no build + # of this commit: the bootstrap mcpp builds the tests, and not the package's + # program; the gate and the shards run them against build-linux's mcpp. + unit-linux-archive: + needs: changes + if: needs.changes.outputs.code == 'true' + # main's measured history is read from its last run. + permissions: + contents: read + actions: read + uses: ./.github/workflows/ci-unit.yml + with: + stage: archive + host: linux-x86_64 + name: linux + runs-on: ubuntu-24.04 + # The Windows cases the MinGW cross job runs (the run stage's `cases`). + tests: e2e/windows + + unit-linux: + needs: [build-linux, unit-linux-archive, changes] + # A failed gate cancels the run (ci-unit.yml). + permissions: + contents: read + actions: write + uses: ./.github/workflows/ci-unit.yml + with: + stage: run + host: linux-x86_64 + name: linux + runs-on: ubuntu-24.04 + archived: ${{ needs.unit-linux-archive.outputs.archived }} + # Four shards of four workers: the measured run of 2026-11-13 reached 65 + # of its programs, 3970 s of work, in the 45 minutes of one job. + shards: '[1,2,3,4]' + # The Windows cases only a MinGW cross toolchain runs, from Linux: + # resources (windres -O coff, since GNU ld cannot consume a .res; + # mcpp#365) on the GNU and clang + MinGW rows, a Windows program packed + # from Linux (the import table read, not the artifact run), and a + # library whose legs cross a binary format. + cases: | + rows/1058 a gnu abi windows row compiles its resources + resources/198 windows resources cross + resources/923 cross dependency images keep their resources + pack/240 pack pe zip cross + pack/248 pack library fat pe leg + linux: - needs: build-linux + needs: [build-linux, changes] uses: ./.github/workflows/ci-linux.yml with: # A pull request's label is read when a push starts its run; a run # started by hand states it as an input. bench: ${{ github.event_name == 'push' || inputs.bench == true || contains(github.event.pull_request.labels.*.name, 'ci:bench') }} + set: ${{ needs.changes.outputs.set }} linux-e2e: - needs: build-linux + needs: [build-linux, changes, e2e-plan] uses: ./.github/workflows/ci-linux-e2e.yml + with: + set: ${{ needs.changes.outputs.set }} + shards: ${{ needs.e2e-plan.outputs.linux || '[1,2,3]' }} + count: ${{ needs.e2e-plan.outputs.linux_count || '3' }} + work: ${{ needs.e2e-plan.outputs.linux_work != 'false' }} macos: - needs: build-macos + needs: [build-macos, changes] uses: ./.github/workflows/ci-macos.yml + with: + set: ${{ needs.changes.outputs.set }} macos-e2e: - needs: build-macos + needs: [build-macos, changes, e2e-plan] uses: ./.github/workflows/ci-macos-e2e.yml + with: + set: ${{ needs.changes.outputs.set }} + shards: ${{ needs.e2e-plan.outputs.macos || '[1,2,3]' }} + count: ${{ needs.e2e-plan.outputs.macos_count || '3' }} + work: ${{ needs.e2e-plan.outputs.macos_work != 'false' }} macos-ios: needs: build-macos uses: ./.github/workflows/ci-macos-ios.yml windows: - needs: build-windows + needs: [build-windows, changes] uses: ./.github/workflows/ci-windows.yml + with: + set: ${{ needs.changes.outputs.set }} windows-e2e: - needs: build-windows + needs: [build-windows, changes, e2e-plan] uses: ./.github/workflows/ci-windows-e2e.yml + with: + set: ${{ needs.changes.outputs.set }} + shards: ${{ needs.e2e-plan.outputs.windows || '[1,2,3]' }} + count: ${{ needs.e2e-plan.outputs.windows_count || '3' }} + work: ${{ needs.e2e-plan.outputs.windows_work != 'false' }} windows-msvc-xlings: needs: build-windows @@ -307,7 +465,7 @@ jobs: # summary, the failures, the slowest cases and the metrics. test-report: name: the run's tests, reported - needs: [changes, linux, macos, windows, linux-e2e, macos-e2e, windows-e2e] + needs: [changes, linux, unit-linux, macos, windows, linux-e2e, macos-e2e, windows-e2e] if: ${{ always() && needs.changes.outputs.code == 'true' && !cancelled() }} runs-on: ubuntu-24.04 timeout-minutes: 15 diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 167826eae..af5edbcca 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -34,10 +34,11 @@ name: cross-build-test # qemu-aarch64 is the CI proxy for "does this cross artefact actually execute". # # ── NOT here ─────────────────────────────────────────────────────────────── -# * The e2e scripts this job names explicitly (102, 198, 240, 248) are the +# * The e2e scripts this job names explicitly (102, 1044, ...) are the # ones the ordinary Linux shards SKIP for want of `mingw-cross`. They are # listed in the job rather than left to run_all's cap gating precisely so -# they cannot end up skipping everywhere at once. +# they cannot end up skipping everywhere at once. The C++ e2e cases of +# that kind (198, 240, 248, 923, 1058) are ci-unit.yml's `cases`. # * Same-arch builds (host arch == target arch) are NOT cross. The native musl # static build `--target x86_64-linux-musl` (x86_64 host) is exercised by # ci-linux.yml's "Toolchain: musl-gcc" step, and release.yml for the static @@ -164,8 +165,7 @@ jobs: - name: "Cross-build mcpp -> ${{ matrix.target }}" run: | export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - "$MCPP" build --target ${{ matrix.target }} - bin=$(find target/${{ matrix.target }} -type f -name mcpp | head -1) + bin=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP" mcpp --target ${{ matrix.target }}) [ -n "$bin" ] || { echo "no mcpp artefact for ${{ matrix.target }}"; exit 1; } echo "== file =="; file "$bin" file "$bin" | grep -q "${{ matrix.file_arch }}" || { echo "expected ${{ matrix.file_arch }}"; exit 1; } @@ -181,8 +181,7 @@ jobs: --depth 1 https://github.com/openxlings/xlings /tmp/xlings-src cd /tmp/xlings-src "$MCPP" self config --mirror GLOBAL 2>/dev/null || true - "$MCPP" build --target ${{ matrix.target }} - xbin=$(find target/${{ matrix.target }} -type f -name xlings | head -1) + xbin=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP" xlings --target ${{ matrix.target }}) [ -n "$xbin" ] || { echo "no xlings artefact for ${{ matrix.target }}"; exit 1; } echo "== file =="; file "$xbin" file "$xbin" | grep -q "${{ matrix.file_arch }}" || { echo "expected ${{ matrix.file_arch }}"; exit 1; } @@ -349,10 +348,14 @@ jobs: with: host: linux-x86_64 + # llvm beside it: the case that pairs clang with the MinGW payload reads + # the program's resources with the payload's llvm-readobj (`llvm`). - name: Install mingw-cross toolchain run: | export MCPP_VENDORED_XLINGS="$XLINGS_BIN" "$MCPP" toolchain install mingw-cross 16.1.0 + "$MCPP" toolchain install llvm 23.1.3 + echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN" >> "$GITHUB_ENV" - name: "e2e: cross-build + wine run" run: | @@ -367,52 +370,8 @@ jobs: export MCPP_VENDORED_XLINGS="$XLINGS_BIN" bash tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh - # E3 (design 2026-11-10): the same pairing compiles `[resources]` with the - # payload's windres, and reads a quoted "winres.h" from the payload's headers. - - name: "e2e: resources on the clang + MinGW pairing" - run: | - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - bash tests/e2e/1058_a_gnu_abi_windows_row_compiles_its_resources.sh - - # mcpp#365. This is the only job with a MinGW cross toolchain, so it is - # the only place the GNU half of resource compilation (windres -O coff, - # because GNU ld cannot consume a .res) can run at all — the Linux e2e - # shards skip it for want of the `mingw-cross` capability. Named - # explicitly for the same reason 102 is. - - name: "e2e: windows resources (windres / COFF)" - run: | - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - bash tests/e2e/198_windows_resources_cross.sh - bash tests/e2e/923_cross_dependency_images_keep_their_resources.sh - - # Packaging a Windows program FROM LINUX — and this job is the only - # place that can happen, for the same reason as the two above. - # - # Running it on a Windows runner would prove nothing: the point of - # reading the import table instead of executing the artifact - # (mcpp.pack.binfmt) is precisely that the packaging host need not be - # the target. A same-OS pack cannot tell the two implementations apart. - - name: "e2e: pack a PE from Linux (zip + DLL closure)" - run: | - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - bash tests/e2e/240_pack_pe_zip_cross.sh - - # A LIBRARY package whose legs cross an OS boundary, for the same reason - # as the three above: this is the only job with a MinGW cross toolchain. - # - # It is not redundant with 245 (which covers the fat-package mechanism - # with gnu + musl and therefore runs on every ordinary Linux shard). The - # leg added here changes BINARY FORMAT, and it is the case that proves - # `lib/` has to be keyed by triple rather than by OS: MinGW and MSVC are - # both "windows" and write `libfoo.a` and `foo.lib` respectively. - # - # Without this step the test would carry `# requires: mingw-cross` and - # skip in every job that exists — verified on a developer's machine and - # nowhere else, while the suite reported green. - - name: "e2e: pack a library across an OS boundary (PE leg)" - run: | - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - bash tests/e2e/248_pack_library_fat_pe_leg.sh + # The Windows cases of the C++ e2e suite that need this toolchain run + # from the test archive in ci-unit.yml (`cases`), beside the Linux shards. # SPEC-007 R4.3: the engine places a Windows program's runtime DLLs # beside it after its link, so a program started by hand finds them. diff --git a/.github/workflows/release-canaries.yml b/.github/workflows/release-canaries.yml index 8593394ce..e19d606ca 100644 --- a/.github/workflows/release-canaries.yml +++ b/.github/workflows/release-canaries.yml @@ -1,9 +1,11 @@ name: release canaries -# Real projects built with the candidate mcpp before a release is tagged (the -# 2026-09-28 ecosystem design, WS10). release.yml calls this workflow first and -# its tag job needs it, so a canary that fails blocks the tag. It can also be -# dispatched by hand against any branch, to read a candidate early. +# Real projects built with the candidate mcpp before a release is created (the +# 2026-09-28 ecosystem design, WS10). release.yml calls this workflow beside +# its builds when the release is started by hand with `canaries`, and the job +# that creates the release needs it, so a canary that fails blocks the +# release. It can also be dispatched by hand against any branch, to read a +# candidate early. # # The list is .github/release-canaries.toml. Each canary job builds the # candidate from this commit, checks the project out, removes the project's own diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95507d9b0..104ad5ceb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,22 +13,37 @@ on: tag: description: 'tag to (re)build — leave blank to derive `v` from mcpp.toml and create the tag automatically' required: false - + canaries: + description: 'build the ecosystem canaries (xlings, mcppls) with the candidate before the release is created' + type: boolean + default: false + +# THE BUILDS RUN AT ONCE, AND ONE JOB CREATES THE RELEASE (design 2026-11-14). +# The four platform builds start together and keep their archives as +# artifacts; `create-release` creates the GitHub release with every archive +# when all four have passed, so no release exists with a platform missing. The +# release v2026.11.12.1 took 109 minutes, its jobs one after another: the +# canaries 48, the Linux build 27 (it created the release the others waited +# for), the aarch64 build 29, the seal and the publication 5. jobs: - # A RELEASE IS GATED BY THE ECOSYSTEM'S OWN PROJECTS (the 2026-09-28 design, - # WS10): they are built with the candidate first - # (.github/release-canaries.toml), and the job that creates the tag needs - # them. A downstream project validates the release in its own pull request. + # THE ECOSYSTEM'S OWN PROJECTS, BUILT WITH THE CANDIDATE (the 2026-09-28 + # design, WS10; .github/release-canaries.toml), when the release is started + # by hand with `canaries`. They gate the creation of the release, beside the + # builds; a tag pushed creates its release without them. Each project's own + # CI validates the release after it, pinning it. canaries: + if: github.event_name == 'workflow_dispatch' && inputs.canaries uses: ./.github/workflows/release-canaries.yml build-release: - name: build + upload (linux / x86_64) - needs: canaries + name: build (linux / x86_64) runs-on: ubuntu-24.04 permissions: - contents: write # required to create releases + push tags + contents: read timeout-minutes: 60 + outputs: + tag: ${{ steps.stage.outputs.tag }} + version: ${{ steps.stage.outputs.version }} env: # mcpp resolves MCPP_HOME from the binary's location by default, # but here we want to share toolchains with the bootstrap sandbox, @@ -144,8 +159,7 @@ jobs: # Build first so we can strip the ELF before pack copies it. export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - "$MCPP" build --target x86_64-linux-musl - ARTIFACT=$(find target/x86_64-linux-musl -type f -name mcpp | head -1) + ARTIFACT=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP" mcpp --target x86_64-linux-musl) test -n "$ARTIFACT" file "$ARTIFACT" | grep -q 'statically linked' # NB: stripping here is pointless — `mcpp pack` below rebuilds the @@ -275,30 +289,27 @@ jobs: echo "--- RELEASE_NOTES.md ---" cat dist/RELEASE_NOTES.md - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - name: Keep the Linux archives for the release + uses: actions/upload-artifact@v4 with: - tag_name: ${{ steps.stage.outputs.tag }} - name: ${{ steps.stage.outputs.tag }} - body_path: dist/RELEASE_NOTES.md - draft: false - prerelease: false - files: | + name: release-linux-x86_64 + if-no-files-found: error + retention-days: 7 + path: | dist/mcpp-${{ steps.stage.outputs.version }}-linux-x86_64.tar.gz dist/mcpp-${{ steps.stage.outputs.version }}-linux-x86_64.tar.gz.sha256 dist/mcpp-linux-x86_64.tar.gz dist/mcpp-linux-x86_64.tar.gz.sha256 dist/install.sh - dist/SHA256SUMS dist/mcpp-${{ steps.stage.outputs.version }}.tar.gz dist/mcpp.lua + dist/RELEASE_NOTES.md build-linux-aarch64: name: build (linux / aarch64, cross) runs-on: ubuntu-24.04 - needs: build-release permissions: - contents: write + contents: read timeout-minutes: 70 steps: - uses: actions/checkout@v4 @@ -363,15 +374,13 @@ jobs: # (the feature landed after the last release), so it resolves the # x86_64 host musl-gcc for an aarch64 target. Two-stage instead: build # THIS release's x86_64 mcpp first, then cross-build aarch64 with it. - "$MCPP" build --target x86_64-linux-musl - FRESH=$(find target/x86_64-linux-musl -type f -name mcpp | head -1) + FRESH=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP" mcpp --target x86_64-linux-musl) test -n "$FRESH" - "$FRESH" build --target aarch64-linux-musl - BIN=$(find target/aarch64-linux-musl -type f -name mcpp | head -1) + BIN=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$FRESH" mcpp --target aarch64-linux-musl) test -n "$BIN" file "$BIN" | grep -q 'ARM aarch64' file "$BIN" | grep -q 'statically linked' - echo "MCPP_AARCH64=$GITHUB_WORKSPACE/$BIN" >> "$GITHUB_ENV" + echo "MCPP_AARCH64=$BIN" >> "$GITHUB_ENV" - name: Package aarch64 release (+ bundle aarch64 xlings) id: stage @@ -441,11 +450,13 @@ jobs: ver=$(qemu-aarch64-static "$SMOKE/$WRAPPER/bin/mcpp" --version) echo "$ver"; echo "$ver" | grep -q 'mcpp' - - name: Upload aarch64 artifacts to release - uses: softprops/action-gh-release@v2 + - name: Keep the aarch64 archives for the release + uses: actions/upload-artifact@v4 with: - tag_name: ${{ steps.resolve.outputs.tag }} - files: | + name: release-linux-aarch64 + if-no-files-found: error + retention-days: 7 + path: | dist/mcpp-${{ steps.resolve.outputs.version }}-linux-aarch64.tar.gz dist/mcpp-${{ steps.resolve.outputs.version }}-linux-aarch64.tar.gz.sha256 dist/mcpp-linux-aarch64.tar.gz @@ -454,9 +465,8 @@ jobs: build-macos: name: build (macOS / ARM64) runs-on: macos-15 - needs: build-release permissions: - contents: write + contents: read timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -535,9 +545,7 @@ jobs: # bootstrap's macOS link path predates the staticStdlib # implementation (hardcoded -lc++), so stage 1 links the system # libc++ — fine, it only needs to RUN on this runner. - "$MCPP" build - STAGE1=$(find target -path "*/bin/mcpp" | head -1) - STAGE1=$(cd "$(dirname "$STAGE1")" && pwd)/$(basename "$STAGE1") + STAGE1=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$MCPP" mcpp) # --no-cache removes target/, including the stage-1 compiler. Ninja # invokes that compiler again for dependency scanning, so keep the # bootstrap outside the tree the second stage is about to clear. @@ -548,13 +556,10 @@ jobs: # Stage 2: this release's mcpp rebuilds itself — flags.cppm's # native staticStdlib link produces the static minos-14 binary. - # NOTE: stage 2 lands in a NEW fingerprint directory (its - # fingerprint includes the deployment target; the bootstrap's - # did not) — pick the most recently modified binary, not the - # first find hit. - "$STAGE1" build --no-cache - MCPP_BIN=$(ls -t $(find target -path "*/bin/mcpp" -type f) | head -1) - MCPP_BIN=$(cd "$(dirname "$MCPP_BIN")" && pwd)/$(basename "$MCPP_BIN") + # Stage 2 lands in a NEW fingerprint directory (its fingerprint + # includes the deployment target; the bootstrap's did not): the + # build names its own product. + MCPP_BIN=$(bash "$GITHUB_WORKSPACE/.github/tools/built_artifact.sh" "$STAGE1" mcpp --no-cache) test -x "$MCPP_BIN" file "$MCPP_BIN" otool -L "$MCPP_BIN" @@ -625,11 +630,13 @@ jobs: # the paths the binary carries. bash .github/tools/check_release_paths.sh "$SMOKE/$WRAPPER/bin/mcpp" - - name: Upload macOS artifacts to release - uses: softprops/action-gh-release@v2 + - name: Keep the macOS archives for the release + uses: actions/upload-artifact@v4 with: - tag_name: ${{ steps.resolve.outputs.tag }} - files: | + name: release-macosx-arm64 + if-no-files-found: error + retention-days: 7 + path: | dist/mcpp-${{ steps.resolve.outputs.version }}-macosx-arm64.tar.gz dist/mcpp-${{ steps.resolve.outputs.version }}-macosx-arm64.tar.gz.sha256 dist/mcpp-macosx-arm64.tar.gz @@ -638,9 +645,8 @@ jobs: build-windows: name: build (Windows / x86_64) runs-on: windows-latest - needs: build-release permissions: - contents: write + contents: read timeout-minutes: 45 env: MCPP_HOME: C:\Users\runneradmin\.mcpp @@ -784,11 +790,13 @@ jobs: test -f "$SMOKE/$WRAPPER/mcpp.bat" echo "Smoke-test passed" - - name: Upload Windows artifacts to release - uses: softprops/action-gh-release@v2 + - name: Keep the Windows archives for the release + uses: actions/upload-artifact@v4 with: - tag_name: ${{ steps.resolve.outputs.tag }} - files: | + name: release-windows-x86_64 + if-no-files-found: error + retention-days: 7 + path: | dist/mcpp-${{ steps.resolve.outputs.version }}-windows-x86_64.zip dist/mcpp-${{ steps.resolve.outputs.version }}-windows-x86_64.zip.sha256 dist/mcpp-windows-x86_64.zip @@ -798,9 +806,54 @@ jobs: # uploader has finished. The manifest is desired state for downstream # reconcilers: a rerun may reproduce it byte-for-byte, but may never replace # it with different bytes for the same tag. + # THE RELEASE, CREATED ONCE WITH EVERY ARCHIVE: after the four builds and, + # when they ran, the canaries. SHA256SUMS names every archive of the + # release, the four builds and the source. + create-release: + name: create the release + needs: [canaries, build-release, build-linux-aarch64, build-macos, build-windows] + if: ${{ !cancelled() && !failure() }} + runs-on: ubuntu-24.04 + permissions: + contents: write # required to create releases + push tags + timeout-minutes: 15 + steps: + - name: The archives of the four builds + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: SHA256SUMS over every archive + run: | + set -euo pipefail + cd dist + ls mcpp-*.tar.gz mcpp-*.zip | LC_ALL=C sort | xargs sha256sum > SHA256SUMS + cat SHA256SUMS + ls -la + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.build-release.outputs.tag }} + name: ${{ needs.build-release.outputs.tag }} + target_commitish: ${{ github.sha }} + body_path: dist/RELEASE_NOTES.md + draft: false + prerelease: false + files: | + dist/mcpp-*.tar.gz + dist/mcpp-*.tar.gz.sha256 + dist/mcpp-*.zip + dist/mcpp-*.zip.sha256 + dist/install.sh + dist/SHA256SUMS + dist/mcpp.lua + release-manifest: name: validate + seal release manifest - needs: [build-release, build-linux-aarch64, build-macos, build-windows] + needs: [create-release] runs-on: ubuntu-24.04 permissions: contents: write diff --git a/AGENTS.md b/AGENTS.md index c12f83d13..bf925378f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,10 @@ MCPP= bash tests/e2e/_.sh ([`tests/e2e/mtest.toml`](tests/e2e/mtest.toml)). - A subsystem's unit tests live in `modules//tests/` and build against that package alone; cross-layer tests live in `tests/unit/`. +- Two canary projects (`tests/canary/c`, `tests/canary/cxx`) are built, linked statically, run, + tested and packed for every identity of `tests/canary/matrix.tsv`, nightly and on a pull request + labelled `ci:canaries` (`canaries.yml`). A row states `ok`, `fails` with its reason, or `n/a`; a + cell that differs either way fails, and a change of behaviour corrects its row. ## 6. Change workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 683a15f9a..096480048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,216 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.11.13.1] - 2026-11-13 + +### Added + +- `mcpp test` orders a run by the durations its store measured: the tests + start longest first. `--shard I/N` runs the I-th of N shards, each test + assigned longest first to the shard with the least measured work, so that N + machines with one store run every test once; `--failed` runs what the + store's last run failed (its failed cases, or the program whole when it + failed outside its cases). `[test] split = "auto"` (and `--split ` for + one invocation) divides a file the store measured longer than its share of + the run into groups of its cases. The store's program records state + `part_of`, and a shard's run states `shard`. +- `mcpp test --no-programs` builds none of the package's programs: the tests + take each from the environment's `MCPP_BIN_`, for a program built + elsewhere, and one the environment does not name is refused before a test + runs. With `--no-run --archive` the archive carries none of them and names + their variables (`named_programs`); its run takes each from its own + environment. +- Toolchain payloads are data. Which package serves a family for a target on a + host is the `[[toolchain.payload]]` table of the built-in data, read in both + directions; `xim:@` accepts any package of the index and + resolves it as `path:` with the table its `.mcpp-toolchain.json` + states (schema 2: `family`, `prefix`, `sysroot`, `tools`). A new + distribution of a known family needs no engine change. +- `[profile.] debug = "lines"` keeps the line tables alone (`-g1`; `/Z7` + under cl.exe): backtraces and sanitizer reports keep file and line. +- `cfg(interface = "openkal.fs")` selects by the interfaces the resolved + kernel-abi provider states it provides; `cfg(object-format = "pe")` selects + by the row's container (`elf`, `pe`, `mach-o`, `wasm`, `coff`). +- A provider entry states `link`, the base and `runner` of the identities it + serves, over the platform's, in either base. A row a platform defines states + its link emulation without its machine. +- `[test] programs = "package"`: the package's discovered files are one + program, linked once, run as one process per file (`tests/`). At most + one file defines `main`. +- The end-to-end cases are C++ test programs of mtest (`tests/e2e/**`, support + in `tests/support`); each host's unit job runs them with `mcpp test`. Shell + scripts remain where a case needs a shell. A case states where it runs and + what it needs of the machine as attributes of mtest 0.8.0 + (`"x"_test[s::windows][s::msvc]`): the program skips it elsewhere, naming the + platform or the capability, whichever runner starts it. A job that runs named + cases on a host that cannot build them (Visual Studio masked, a cross + toolchain only, a released mcpp) takes them from the test archive build.yml + writes (`.github/actions/e2e-cases`), and holds each case to its `ok` line; + `MCPP_E2E_MCPP` names the mcpp such a run tests when it is not the archive's + own build. +- Two canary projects (`tests/canary/c`, `tests/canary/cxx`) and a nightly + matrix (`canaries.yml`): every identity a host offers is built, linked + statically, run, tested and packed, and each cell is compared with + `tests/canary/matrix.tsv` in both directions. + +### Changed + +- A test that runs a program of the package (a file it is built from names + `MCPP_BIN_`) is affected, for `--since` and `--changed`, by a change of any + source of the package that is not a test's own. An end-to-end test is + built from none of the program's sources, and a change of `src/` selected + none of them. +- `mcpp test --archive` runs archived tests on a machine whose toolchain could + not compile them: a configuration that targets the MSVC ABI with no Visual + Studio is no longer refused when nothing is compiled. +- CI: the Linux unit and C++ e2e tests are built once beside the build and run + in four shards assigned by main's measured history (`ci-unit.yml`), and the + gate left the Linux build job, which it held for 18.6 minutes; the tests are + archived without the package's program and run, as every unit job's are, + against the binary the build job made, which no job builds again; every unit + job orders its tests by that history and re-runs only what a failed + attempt failed; the macOS 27 image runs the SDK-dependent subset on a pull + request; `bench.yml` measures the engine alone for a commit whose CI + passed. The release builds its four platforms at once and creates the + release with every archive in one job; the ecosystem canaries run only in a + release started with `canaries`. +- `mcpp new` scaffolds tests with mtest 0.8.5 (was 0.5.2), and `mcpp test + --guide` suggests it; mcpp's own tests, its modules' tests, the canaries and + the engine benchmark use it. Its end-to-end outcomes say when an output was + cut at its limit (mtest 0.8.5). The benchmark times each call by itself + (`bench::each`): the median as before, with the 95th percentile, the maximum + and the two slowest calls. +- A configuration's build directory is named by its lineage, which includes the + base as stated; the evolving parts (mcpp, the compiler build, the standard + library version, what the base contains) are recorded inside it, a compiler + build or base contents that differ empty it in place, and the graph runs mcpp + through the directory's link `engine/epoch-/mcpp`. An upgrade of mcpp alone compiles nothing again + (measured: 18 of 153 before, 0 after). +- `Finished` states the time spent waiting for a build directory; + `mcpp clean --report` gives split debug information its own column. +- The test summary divides its time as `Finished` does: + `finished in 7.77s (plan 2.10s · programs 1.73s · build 1.60s · run 2.34s)`, + replacing `(build Xs + run Ys)`. +- `[test] programs = "directory"` notes the files it leaves programs of their + own; a setting that forms no group is a degradation that `--strict` refuses. +- The warning for a configuration built in full beside another states the + other's size and the command that reclaims it. +- The C environment a kernel-abi implementation presents is chosen by the + row's container, not its system name; a carrier takes its section directive + and program suffix from the row. +- `--toolchain` opts a planned row in, as a `[target.]` toolchain does. +- mcpplibs.cmdline 0.0.4: every command's action returns its status. +- CI follows the event: a pull request runs the `pr` set (the cases its + change affects), and one labelled `ci:full`, a push to main and a run started + by hand run `full`. Each host's e2e shard count is `mtest ci plan` over the + measured history of main's stores (`tests/e2e/mtest.toml` `[ci.plan]`), and + a host the set gives no work starts no shard. Each build job runs the host's + gate (`e2e/basics`) before its dependants start, and a failed gate cancels + the run. + +### Fixed + +- A toolchain payload whose programs start through its home's loader runs on + the home's C runtime, whatever its name: taken from a store restored without + the home's loader links (`/abi`), it has the runtime pinned and the + links made again, as a gcc or llvm payload had. Which payloads run on the + runtime is read from their programs (`PT_INTERP` inside their provider + home), not from a list; the emsdk payload restored from a CI cache did not + start, and was reported as providing no std module. +- `import std` refused for a toolchain whose compiler did not answer the + question says why (the probe's last line of output), instead of only that + no std module source was found. +- `mcpp build --message-format json` of an unchanged workspace reports the + artifacts of the build it replays, as a package's fast path did; it + reported none, and its envelope carried no record. +- A home whose `registry` is a link to another home's borrows that registry, + as a home whose `[xlings] home` names it does; whether a home borrows its + registry is decided once, and every writer of the registry's configuration + asks it. A borrowing home writes neither the places of the indices, nor its + `[index.repos]` tables' places, nor its upstreams there. Before, such a home + recorded its own store in the shared registry, and every later install of the + shared home went to that store after the home had been removed (`install path + missing after fetch`). +- Every write of the provider's state takes one turn (`.mcpp-state.lock` in + the state's real directory, every link resolved): an install, an index + repository added or removed, the runtime chosen, the sandbox initialised. + Homes that reach one state through a link take one turn. Before, only the + installs of dependencies took a turn, in `registry/`, and on Windows a second + writer's save of the SubOS description was refused (`Failed to write file`). +- A reader of a home's files on Windows opens them shared for deletion, so a + writer's replacement is not refused for its sake; a writer retries every + 20 ms at most within its two seconds. +- `mcpp test --archive` compiles nothing: no build program or host tool of the + project is compiled or run, so the tests of an MSVC-ABI archive run on a + machine without Visual Studio. It asks for none of the platform's link and + seal programs, and a runner that is the platform's tool is the platform's + runner for this machine's architecture, made as another architecture's is + (`okrun` for an ok image). A test run through a runner receives what a + build's test run hands one (`MCPP_RUNTIME_FILES`, each file named by its + place beside the program, and `MCPP_RUN_*`); one run directly receives + neither. Before, every archived program received the list, named by file + name alone, and `MCPP_RUN_TRIPLE` and `MCPP_RUN_MODE`. +- A case or suite that states its length (mtest `timeout{...}`) raises the + limit of its process to the sum its cases state, when that is longer than + `--timeout`. +- `config.toml` is replaced whole: programs started at once on a fresh home + no longer read a part of another's write (`invalid config.toml: expected + '='`). On Windows, which refuses a rename over a file a reader holds open, + `mcpp toolchain default` and the other writers no longer fail with `cannot + write config.toml`: the writer closes its own reader first and waits up to + two seconds for the others, and a reader whose open is refused while the + file is being replaced waits for the replacement as well. The file is + written as given: on Windows a text-mode write had ended each line in + `\r\n`, after which a value set again found no `[toolchain]` and appended a + second one; a `config.toml` an editor wrote with `\r\n` has its value + replaced where it stands, and an added line ends as the file's others do. + A `config.toml` that is a symbolic link is written through, and the file + keeps its permissions. +- `mcpp test` runs a case that holds the machine (P7.1, resource `machine`) + with no other test beside it: the listed cases of a file that hold it are a + part of their own (`/ (machine)` beside the file's other + cases), queued last and run alone. + Before, the resource was recorded and not kept across processes (docs/08). +- A compiler without C++ modules (GCC before 14) builds the C++ units that + declare and import no module, without module flags, and refuses at planning + the first unit that needs modules, naming what it needs. Before, the build + failed on `-fmodules` at the first compile. +- An interrupt asks each running program to end and allows two seconds before + ending its unit; waits poll without holding the lock. Before, a program + started by `mcpp __action-stamp` outlived Ctrl-C. +- A cold build (`--cache off`) makes the graph's engine link again. +- A runner named by the environment replaces the requirements of the one it + replaces. +- A reader of the package index waits for a refresh in progress when an index + it declares is absent, instead of reporting no local index. +- A provider with `base = "none"` carries the platform's tools its own link, + seal and runner name; the platform's other rows no longer provision a tool + only such a provider names. Before, such a provider carried no tool, and its + seal's program was reported absent. +- A row whose compile triple names no system (`-unknown-none-elf`) is + compiled as a bare machine is: the C environment's identity macros are the C + library's alone. Before, the realised environment declared `__unix__` that + the compiler did not define. +- A build of a platform's own row reads the platform's view of it, which + carries the tools of the platform's conventions and not a provider's. Before, + the statement was taken whole, and an ok image's build provisioned the DOS + row's seal. +- A single file for a system a platform defines is the row the platform defines + for it, whose name may carry an environment (`dos` on i686 is + `i686-dos-musl`): its program is named with that row's suffix, and a runner's + `built-as` naming such a row builds over the platform. Before, the pack + refused the row as one this mcpp builds no program for, or looked for the + program without its suffix. +- A pack reads the runtime facts (interpreter, needed libraries, run paths) of + an ELF32 program as of an ELF64 one. Before, an i386 program or image was + refused as "not ELF64 little-endian". +- A file no strip reads is packed as its row's seal left it: a seal may wrap the + link's output in a container of its own (the DOS row's MZ stub before an ELF32 + executable). Before, the pack failed in `llvm-strip`. +- `mcpp test` takes several patterns and runs every test one of them names + (`mcpp test parse lex`); `--print-launch` refuses more than one. Before, a + pattern after the first was ignored without a word. + ## [2026.11.12.1] - 2026-11-12 ### Added diff --git a/bench/README.md b/bench/README.md index d15a1daed..09a52b7cc 100644 --- a/bench/README.md +++ b/bench/README.md @@ -141,8 +141,13 @@ performance effect.** What CI does keep is a history of mcpp's own costs. `bench/tests/engine.cpp` holds mtest Bench cases that time the mcpp under test (`MCPP`) on a small module fixture: a build with nothing to do, `mcpp why flags`, and a rebuild after one -interface edit. The Linux unit job runs them (`cd bench && mcpp test`), and each -cost enters mtest's store as a metric with its samples, so that +interface edit, each call timed by itself (`bench::each`; the edit itself is +paused out of the rebuild's time). The Linux subsystem job runs them on a push to +main, a pull request labelled `ci:bench` and a CI run started with `bench` +(`cd bench && mcpp test`); `bench.yml`, started by hand, runs them alone on the +Linux binary a successful CI run of the commit built, the measurement a release +is merged with. Each cost enters mtest's store as a metric -- the +median, with `.p95`, `.max` and the two slowest calls stated -- so that `mtest analyze trend` and the workflow run's report show it against its history. These are trends on one runner, not comparisons between engines; the standard set above remains the source of every published number. diff --git a/bench/README.zh-CN.md b/bench/README.zh-CN.md index 0a6a5e807..b24ddd48d 100644 --- a/bench/README.zh-CN.md +++ b/bench/README.zh-CN.md @@ -117,8 +117,12 @@ BENCH=$(ls -t bench/target/*/*/bin/mbench | head -1) **发版前运行一次标准集;任何声称影响性能的改动之后同样运行一次。** CI 保留的是 mcpp 自身开销的历史。`bench/tests/engine.cpp` 中的 mtest Bench 用例在一个小的模块 -工程上计时被测的 mcpp(`MCPP`):无事可做的构建、`mcpp why flags`、修改一个接口之后的重建。 -Linux 的单元测试 job 运行它们(`cd bench && mcpp test`),每项开销连同其样本作为指标进入 +工程上计时被测的 mcpp(`MCPP`):无事可做的构建、`mcpp why flags`、修改一个接口之后的重建, +每次调用单独计时(`bench::each`;修改文件本身被暂停计时,不计入重建)。 +Linux 的子系统测试 job 在推送到 main、带 `ci:bench` 标签的 PR 与带 `bench` 启动的 CI 运行中运行它们 +(`cd bench && mcpp test`);手动启动的 `bench.yml` 只运行它们,所用的是该提交一次成功 CI 运行构建的 +Linux 二进制,发版合入时的测量即取自它。每项开销作为指标(中位数,以及 +`.p95`、`.max`,并列出最慢的两次调用)进入 mtest 的存储,`mtest analyze trend` 与工作流运行的报告据此给出它的历史走势。这些是同一 runner 上的趋势,不是引擎之间的比较;公开的数字仍只出自上面的标准集。 diff --git a/bench/mcpp.toml b/bench/mcpp.toml index 7071ceffa..7a6882fef 100644 --- a/bench/mcpp.toml +++ b/bench/mcpp.toml @@ -21,4 +21,4 @@ windows = "llvm@23.1.3" # small fixture. `mcpp build` ignores [dev-dependencies], so the harness itself # still takes nothing beyond `import std;`. [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" diff --git a/bench/tests/engine.cpp b/bench/tests/engine.cpp index f5ca92181..374e6abca 100644 --- a/bench/tests/engine.cpp +++ b/bench/tests/engine.cpp @@ -7,6 +7,7 @@ import std; import mcpplibs.mtest; import mcpplibs.mtest.bench; +import mcpplibs.mtest.e2e; using namespace mcpplibs::mtest; @@ -17,13 +18,10 @@ std::string under_test() { return m ? m : ""; } -int mcpp(const std::filesystem::path& dir, std::string_view args) { -#ifdef _WIN32 - const auto cmd = std::format("cd /d \"{}\" && \"{}\" {} > NUL 2>&1", dir.string(), under_test(), args); -#else - const auto cmd = std::format("cd '{}' && '{}' {} > /dev/null 2>&1", dir.string(), under_test(), args); -#endif - return std::system(cmd.c_str()); +// The mcpp under test in `dir`, started directly (no shell): its status. +int mcpp(const std::filesystem::path& dir, std::vector args) { + args.insert(args.begin(), under_test()); + return e2e::run({.argv = std::move(args), .cwd = dir, .timeout = std::chrono::minutes{10}}).status; } void write(const std::filesystem::path& file, std::string_view text) { @@ -44,29 +42,36 @@ std::filesystem::path fixture(const std::filesystem::path& root) { } // namespace +// Each command is timed call by call (`bench::each`, mtest 0.8.1+): the median +// is the metric as before, and the p95, the maximum and the two slowest calls +// say whether a cost is steady or has outliers. A call is one command, a tenth +// of a second or more, so the count is the least: six calls, one of them warm-up. Bench engine{"engine", skip_if{under_test().empty(), "MCPP names no mcpp under test"}, timeout{std::chrono::minutes{20}}, [] { const auto dir = fixture(temp_dir()); - expect(fatal(eq(mcpp(dir, "build"), 0))) << "the first build of the fixture"; - const bench::options o{.samples = 5, .warmup = 1}; - // A sample of a command that failed is not a cost of the command. + expect(fatal(eq(mcpp(dir, {"build"}), 0))) << "the first build of the fixture"; + const bench::each_options o{.least = 5, .time = std::chrono::nanoseconds{0}, .warmup = 1, .slowest = 2}; + // A call of a command that failed is not a cost of the command. "a build with nothing to do"_test = [&] { int failed = 0; - bench::measure("noop-build", [&] { failed += mcpp(dir, "build") != 0; }, o); + bench::each("noop-build", [&](bench::lap&) { failed += mcpp(dir, {"build"}) != 0; }, o); expect(eq(failed, 0)) << "every `mcpp build` succeeded"; }; "the plan of a unit"_test = [&] { int failed = 0; - bench::measure("why-flags", [&] { failed += mcpp(dir, "why flags") != 0; }, o); + bench::each("why-flags", [&](bench::lap&) { failed += mcpp(dir, {"why", "flags"}) != 0; }, o); expect(eq(failed, 0)) << "every `mcpp why flags` succeeded"; }; "a rebuild after one edit of an interface"_test = [&] { - int n = 0, failed = 0; - bench::measure("one-interface-edit", [&] { + int failed = 0; + bench::each("one-interface-edit", [&](bench::lap& l) { + // The edit is not the rebuild's cost. + l.pause(); write(dir / "src/greet.cppm", std::format("export module timed.greet;\nimport std;\n" - "export std::string greet() {{ return \"hi {}\"; }}\n", n++)); - failed += mcpp(dir, "build") != 0; + "export std::string greet() {{ return \"hi {}\"; }}\n", l.index())); + l.resume(); + failed += mcpp(dir, {"build"}) != 0; }, o); expect(eq(failed, 0)) << "every rebuild succeeded"; }; diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index b60fcd439..2936592f7 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -1483,7 +1483,7 @@ established, repeats the first line of the reason in the summary, and exits 2: ``` warning: this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); declare [target.aarch64-linux-musl].runner, or pass --no-runner on a host that can smoke ... not run -error: test result: NOT RUN. 0 passed; 0 failed; 1 not run (this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); ...); finished in 0.41s (build 0.39s + run 0.00s) +error: test result: NOT RUN. 0 passed; 0 failed; 1 not run (this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); ...); finished in 0.41s (plan 0.02s · build 0.39s · run 0.00s) ``` Exit code 1 keeps its meaning — a test ran and failed — and 0 means every test @@ -1647,7 +1647,7 @@ where it would become a BMI path that nothing reports. ```toml [profile.dist] opt = 3 # -O level (a number, or the string "s"/"z") -debug = false # -g; /Z7 on cl.exe +debug = false # -g; /Z7 on cl.exe; "lines": the line tables alone lto = true # -flto; /GL + /LTCG on cl.exe (note: some packaged gcc builds ship without the LTO plugin) strip = true # -s at link time ndebug = true # -DNDEBUG for every unit of the graph (default: on for release/dist) @@ -1717,6 +1717,14 @@ anything compiles. build directory the skeleton names. `"off"` keeps all of it in the artifact. Mach-O (the debug map), PDB, MinGW, WebAssembly and freestanding targets are unchanged. `-gz=none` in a package's flags turns compression off. +- **`debug = "lines"` (2026.11.13.1+)** keeps the line tables alone: `-g1` with + GCC, which is `-gline-tables-only` with clang. A backtrace, a sanitizer report + and a profiler still name a file and a line; a debugger sees no variables and + no types. A test program of a large closure is a fraction of its `debug = true` + size, which is what a profile for CI and full test runs wants (see + [08 — The size of a test build](08-testing.md#the-size-of-a-test-build)). + cl.exe has no level below `/Z7`, which `"lines"` compiles with there. Any other + string is refused. - **Each profile owns its own build directory.** The resolved profile knobs participate in the fingerprint, so `target//` holds one hash directory per profile and switching between them is incremental instead of a full @@ -1724,13 +1732,15 @@ anything compiles. actually use. - **A configuration keeps one directory over time (2026.10.31.3+).** The part of the fingerprint a developer switches between -- the compiler family, the - target, the standard library's family, the standard and the configuration's - flags -- is the directory's lineage; the compiler's version, mcpp's version, - the std module and the runtime contract evolve. A new fingerprint of a lineage + target, the standard library's family, the standard, the configuration's + flags and the base as stated -- is the directory's lineage; the compiler's + version, mcpp's version, the std module and the runtime contract (what the + base contains) evolve. A new fingerprint of a lineage takes over the directory that lineage used last, and ninja rebuilds the edges whose commands changed: after an mcpp upgrade that leaves the flags as they - were, no unit is compiled again. A predecessor of another compiler build is - removed instead (its objects are not reusable). After a build or a test run, + were, no unit is compiled again. A predecessor of another compiler build, or + built against other contents of the base, is removed instead (its objects are + not reusable). After a build or a test run, at most once a day, the directories no configuration used for `[build] keep_days` days (config.toml; default 3, `0` keeps all) are removed. - **`[build] target_budget` bounds `target/` (2026.11.8.1+).** A size such as @@ -2169,7 +2179,7 @@ discover = ["tests/**/*.cpp"] # the default | `main` | `{ from = "/" }` | the entry target every test program begins in; each test file is then an ordinary translation unit of its program and defines no `main` (§2.2, `kind = "entry"`) *(2026.10.26.2+)* | | `start` | symbol | the symbol every test image is entered at, on a freestanding target (§2.2, `start`) *(2026.10.26.2+)* | | `programs` | `"file"` or `"directory"` | `"file"` (the default): every discovered file is a program; `"directory"`: the files of one directory below a glob's fixed directory are one program, named by the directory, beginning at the file that defines `main` when one does *(2026.11.8.1+)* | -| `split` | `"file"`, `"case"` or `"none"` | how a program that speaks the test protocol is run: a process per file of its cases (the default), per case, or once *(2026.11.9.1+)* | +| `split` | `"file"`, `"case"`, `"auto"` or `"none"` | how a program that speaks the test protocol is run: a process per file of its cases (the default), per case, per file with a file the run store measured longer than its share divided into groups of its cases (`"auto"`, 2026.11.13.1+), or once *(2026.11.9.1+)* | | `link` | `"used"` or `"objects"` | `"used"` (the default): a test links what it reaches (`--gc-sections`, `-dead_strip`); `"objects"`: every object whole *(2026.11.9.1+)* | | `store` | directory or `"none"` | where a run is kept in the mtest store's form, relative to the project; the default is `target/mtest`; `"none"` keeps nothing *(2026.11.9.1+)* | diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 9d826bc4f..932d7ed64 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -67,8 +67,8 @@ not deprecated: ```toml [dev-dependencies] -mcpplibs.mtest = "0.5.2" -"mcpplibs.mtest.gtest" = "0.5.2" +mcpplibs.mtest = "0.8.5" +"mcpplibs.mtest.gtest" = "0.8.5" ``` ```toml diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 5c5a85416..b5f5f841e 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -534,7 +534,7 @@ others are planned together again). Workspace built members libs/core, libs/http, ... in 120.40s; slowest: obj/libs/jsc/tests/jsc.o 88.0s Workspace testing member 'libs/core' (3/97) test_paths ... ok (0.31s) - test result ok. 7 passed; 0 failed; finished in 121.10s (build 120.40s + run 0.60s) + test result ok. 7 passed; 0 failed; finished in 121.10s (plan 0.10s · build 120.40s · run 0.60s) Workspace member 'libs/core' (3/97) ok — 7 passed, run 0.60s ... workspace result ok. 97 member(s); 412 passed; 0 failed; finished in 355.20s @@ -660,6 +660,45 @@ member that several members use is compiled once. that links both is refused. See [05 — One module per name in each program](05-dependencies.md#one-module-per-name-in-each-program-mcpp-20269302). +### 5.5 Two commands at once, and a configuration's directory over time + +A configuration's build directory has one lock, held while the directory is +written and not otherwise. Reading the manifests, resolving, fetching, finding +the toolchain and scanning hold nothing; `mcpp test` releases the lock before +its first test runs, and `mcpp run` before the program starts. So: + +- **One member is tested while another is edited and built.** The two commands + queue only while each compiles and links, and the one that waits says which + command holds the directory (`Waiting for the build directory, held by …`). + A test that runs while a source changes runs the program linked before the + change; the next build compiles the change. +- **Commands of different configurations do not wait for each other.** A + profile, a target or a selection that plans a different configuration is a + different directory. +- **The lock is the configuration's, not the member's.** A configuration has + one `build.ninja` and one ninja state, which two ninja processes cannot write + at once (mcpp#839). +- `Finished` says how long a command waited for a directory when it waited a + second or more (2026.11.13.1+). + +A configuration's directory is named by its **lineage** (2026.11.13.1+): the +compiler family, the target, the standard library's family, the C++ standard, +the configuration's flags and the base as it is stated (the default base and a +stated sysroot are two configurations). What evolves --- mcpp's version, the +compiler's version and build, the standard library's version, the runtime +contract, which holds what the base contains --- is +recorded in the directory (`toolchain`, `fingerprint`) and not in its name. The +graph runs mcpp through the directory's own link to it, `engine/epoch-/mcpp`, +which each build makes the running mcpp, so no command names a path that holds +mcpp's version. An mcpp upgrade therefore keeps every command whose compiler, +flags and files are unchanged, and ninja compiles only what the new version +changes; a compiler build that differs, or a base whose contents changed, empties +the directory in place (`Replaced`). The epoch in the link's path changes only with a release whose own +build steps write something different, and then every step runs again. Before 2026.11.13.1 the name was the whole +fingerprint: an upgrade moved the directory to a new name, which every +member's commands contain, and every member was compiled again. The first build +with 2026.11.13.1 takes such a directory over once. + ## 6. Directory Layout The recommended directory layout for a workspace: @@ -688,7 +727,7 @@ A workspace builds at its root (2026.9.29.1+): ``` myproject/ ├── mcpp.lock # one lock for the workspace -└── target/// +└── target/// # named by the configuration's lineage (§5.5) ├── build.ninja, compile_commands.json # one graph and one database per configuration ├── obj// # intermediate objects of every package ├── gcm.cache// # each package's BMIs (pcm.cache with clang) diff --git a/docs/08-testing.md b/docs/08-testing.md index 371ee073b..d4f9741e7 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -65,16 +65,66 @@ directory stays a program of its own. A test's name is the path of its program, so a name that is another's directory (`unit` and `unit/own`) is refused, naming both (2026.11.9.1+). A program of many files is linked once where a program per file is linked once per file, which is what a suite of many -small files spends its build time on. +small files spends its build time on. When the setting leaves files directly in +the fixed directory, `mcpp test` notes how many stay programs of their own; when +it forms no group at all, the note is a degradation, which `--strict` refuses +(2026.11.13.1+). + +`[test] programs = "package"` (2026.11.13.1+) makes every file the package +discovers one program, named by the first glob's fixed directory (`tests`). At +most one of its files defines `main`; two are refused, naming both. The program +still runs one process per file, named by the file's path below the +fixed directory (`tests/unit/b` for `tests/unit/b.cpp`), so isolation and +parallelism are those of a program per file, with one link for the suite. The +three values are the granularity of a test program: `"file"` (the default), +`"directory"` and `"package"`. A program that speaks the test protocol runs as **one process per file of its cases** (2026.11.9.1+): mcpp lists the cases of a program of several files, and each file's run selects its own (P4) and is scheduled across the workers as a program is, named `/` -- the isolation and the parallelism of a program per file, -with one link. `[test] split = "case"` gives each case its own process, and -`"none"` runs the program once. A pattern names a program or a part of one as +with one link. `[test] split = "case"` gives each case its own process, +`"none"` runs the program once, and `"auto"` (2026.11.13.1+) divides by file and +then divides a file the run store measured longer than its share of the run +(the run's measured work over its workers and shards) into groups of its listed +cases of about equal measured duration, named `/ (i/k)`. +`--split ` states the value for one invocation. A pattern names a program or a part of one as the run names it: `mcpp test unit/test_home` (or `home`) runs that file's part. +A case that holds the machine (P7.1, resource `machine`) runs with no other test +beside it (2026.11.13.1+). mcpp lists, on the run's workers, every program that +speaks the protocol and that it divides or that runs without a runner; a program +of one file behind a runner is not listed and runs whole, as before. In a program +the run divides, the listed cases of a file that hold `machine` (a suite that +states it, `E2E bmi_machine{"bmi-machine", resource{"machine"}, [] { ... }}`, or a +bench) are a part of their own, named `/ (machine)` when the file +has other cases; a program the run does not divide (`split = "none"`, a program of +one file) runs whole, alone when one of its cases holds `machine`. What holds +`machine` is queued after every other test and runs alone. The run's record states +`machine` among the part's resources. A resource stated on a case inside a suite +is not listed, so it holds nothing across processes. + +**The order of a run** (2026.11.13.1+). Before anything runs, mcpp reads the run +store (`[test] store`, below): of each test, the newest duration its runs +measured, of the cases it selects or of the program whole. A test no run +measured takes the mean of the measured tests of its nearest directory +(`e2e/run/x`: of `e2e/run/`, then of `e2e/`), and the longest measured test when +no run measured a test of its directories; a case no run measured takes the mean +of the measured cases beside it, or an equal part of its program. A store kept +by a run of another selection thus spreads the tests it never saw over the +shards by count. The tests start longest first, so the longest work does +not start last. `--shard I/N` runs the I-th of N shards: every test, longest +first, is assigned to the shard with the least measured work, and this command +runs its own; the assignment reads only the store and the run's list, so N +commands on N machines, each with the same store, run every test once. A shard +reads no run of a shard of its own group (`GITHUB_RUN_ID`, or none), so the +shards of one run started one after another on one store read it as the first +of them did. `--failed` runs what the store's last run failed: the programs +that failed and, when every failure names its cases, only those cases; a +program that failed outside its cases runs whole. It is not combined with a +pattern, `--case`, `--cases` or `--no-run`, and a last run that failed nothing +runs nothing and exits 0. + A test program keeps what it reaches (2026.11.9.1+): units are compiled with one section per function and per object, and a test links with `--gc-sections` (`-dead_strip` on Mach-O), so a test of one module does not carry its package's @@ -134,6 +184,7 @@ for one test, the build is refused, naming them, and `[test] main` chooses. ```bash mcpp test # build and run every test mcpp test parse # only those whose name matches +mcpp test parse lex # those whose name matches either mcpp test --list # what would run, without building or running it mcpp test -- --verbose # everything after `--` goes to each test binary ``` @@ -152,7 +203,11 @@ configuration it is meant to check rather than against the default one: `--timeout ` kills a test still running (default 300; `0` disables it) and `--build-timeout ` bounds the compile. A test that hangs is reported as a -failure with its own name, not as a job that stopped. +failure with its own name, not as a job that stopped. A case or a suite that +states its own length (mtest `timeout{...}`, listed with its cases) raises the +limit of its process to the sum its cases state, when that is longer +(2026.11.13.1+): a long test states its length, and the limit of the others +stays. The report is the command's result and is written to standard output: each test's verdict, the output of a test that failed, the `test result` line and, for @@ -161,6 +216,18 @@ test's verdict, the output of a test that failed, the `test result` line and, fo collects the report, and `mcpp test 2>&1 | tee test.log` collects both ([09 — Commands by scenario](09-commands-by-scenario.md#output-streams)). +The `test result` line closes with the run's time, divided as `Finished` +divides a build (2026.11.13.1+): + +``` +test result ok. 7 passed; 0 failed; finished in 7.77s (plan 2.10s · programs 1.73s · build 1.60s · run 2.34s) +``` + +`programs` is the build of the package's programs that the tests find by name, +shown when it took 5 ms or more; `build` is the tests' own graph; `run` is the +tests. `plan` is the rest: the manifest, resolution, the toolchain and the scan. +The parts sum to the total. + ### An unchanged project (2026.11.10.1+) The tests' graph is `test.ninja`, beside the build's `build.ninja` in the same @@ -185,6 +252,10 @@ the tests a change affects, read from the plan before anything is built: of its link that belongs to no module; - a change of a manifest, a lock, a build program, a header, or a source the plan does not hold affects every test; +- a test that runs a program of the package (a file it is built from names + `MCPP_BIN_`) is affected by a change of any source of the package that is + not a test's own (2026.11.13.1+): an end-to-end test runs the program it + tests and is built from none of its sources; - another file affects the tests whose own files name it (a fixture), and no other test. @@ -200,6 +271,14 @@ the package's programs first, as `mcpp build` does, and gives each test other than a letter or a digit written `_` (`MCPP_BIN_MY_TOOL` for `my-tool`). A test that runs the program it tests reads it from there. +`--no-programs` (2026.11.13.1+) builds none of them: the programs are planned +for their names, and each test takes each program from the environment's +`MCPP_BIN_`, for a program built elsewhere (a CI job that tests the +binary another job built). A program the environment does not name is refused +before any test runs, so that no test takes another program of that name from +`PATH`. The environment names one program for each name, so the option tests +one target. + **A platform package is tested on its targets** (2026.10.27.1+). `mcpp test` in a package with a `[platform]` section, with no `--target`, builds the tests for each of the platform's `targets` with the package itself as the platform. The @@ -224,6 +303,48 @@ The command continues past a member that fails, whether a test, its package's build or its plan failed, reports each member, and exits non-zero if any did. Discovery is scoped per member, so two members may each have a `tests/main.cpp`. +A member is tested while another is edited and built: the two commands wait for +each other only while one compiles or links, and a running test holds nothing +([07 — Two commands at once](07-workspace.md#55-two-commands-at-once-and-a-configurations-directory-over-time)). + +### The size of a test build + +Every test program carries the closure it links, with its debug information. +`mcpp clean --report` states, per configuration, the bytes of objects, of split +debug information, of BMIs and of products. A suite of many programs is kept +small by these, in the order of their effect: + +1. **A profile for the full run.** Debug information is most of a test + program's bytes. A profile for CI and for full runs states less of it and + strips the symbols, and day-to-day work keeps the `dev` profile: + + ```toml + [profile.ci] + opt = 0 + debug = "lines" # file and line in backtraces and sanitizer reports (2026.11.13.1+) + strip = true + ``` + + `mcpp test --profile ci` builds it in a directory of its own. +2. **Fewer programs.** `[test] programs = "directory"` links a directory's + files once, and `"package"` the package's (see [Where tests are](#where-tests-are)). + The count of links falls from the count of files to that of directories, or + to one. +3. **Members as shared libraries.** `dependency_linkage = "shared"` in a + workspace profile links each member a test reaches as a shared library, so + the closure is on disk once rather than once per program. It is a profile to + choose, not a default: on Windows a member's symbols reach a DLL's importers + only through its exports. +4. **Module implementation units.** A function body in `module X;` instead of + `export module X;` does not change X's BMI, and what imports X is not + compiled again when it changes. +5. **Layered members.** Logic in members that do not depend on UI or rendering + lets most tests link the logic alone. +6. **What no configuration uses** is removed by `mcpp clean --stale`, and after + a build by the sweep that `[build] keep_days` bounds. When a change of + configuration starts a directory of its own, the warning states the size of + the one beside it (2026.11.13.1+). + ## Tests that reach packages the artifact does not ```toml @@ -329,6 +450,19 @@ no test ran. Legs this machine does not reach are reported as not run name states: `.tar.gz` (or `.tgz`), `.tar.zst`, `.tar.xz` or `.tar`; another name is refused before anything is built. +The archive carries the package's programs beside the tests, and its run gives +each test `MCPP_BIN_` as the build would. Written with `--no-programs`, +the archive carries none of them and its manifest names their variables +(`named_programs`, schema 2, which an mcpp that reads only schema 1 refuses); +the run of a leg that runs here takes each from its own environment and refuses +one the environment does not name (2026.11.13.1+). The run of an archive builds +nothing, and is refused `--no-programs`. + +```bash +mcpp test --no-run --no-programs --archive tests.tar.gz # the tests only +MCPP_BIN_MCPP=/path/to/mcpp mcpp test --archive tests.tar.gz # their program, named by the run +``` + ## Tests that cannot run beside each other `mcpp test` runs test programs on a worker pool. One board on one probe, one diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index f4db148ee..56e8f80b4 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -117,6 +117,54 @@ raises the version's `floor`, and the next build replaces an installed payload below it, saying why. A replacement that fails leaves the installed payload in place; `mcpp toolchain install` then fails and names the revision still in use. +## The package behind a family's name, and any package as a toolchain (2026.11.13.1+) + +A family's name (`gcc@16.1.0`, `llvm@23.1.3`) is a shorthand. Which package of +the index it installs for a target on a host is data: the `[[toolchain.payload]]` +entries of `modules/toolchain-model/data/builtin.toml`, tried in order, the first +that serves the target on this machine taken. `gcc` for `x86_64-linux-musl` on a +Linux x86_64 machine is `musl-gcc`, on any other machine `x86_64-linux-musl-gcc`; +`gcc` for `x86_64-windows-gnu` is `mingw-gcc` on Windows and `mingw-cross-gcc` +elsewhere. A payload's own name (`musl-gcc@16.1.0`, `emsdk@6.0.9`) is accepted +where an entry states it, and `mcpp toolchain list` reads an installed payload +through the same entries. + +A version of a family is the index's, not the engine's: once the index publishes +it, a manifest or `--toolchain` names it without a new mcpp. A partial version +takes the newest release of it (`gcc@16.2` is 16.2.0). A trunk snapshot is +written as GCC reports it, `.0.0-`; the date is a prerelease +part, so `gcc@17` does not select it and only the whole version does: + +```toml +[toolchain] +default = "gcc@16.2" # the newest 16.2.x release +# default = "xim:gcc@17.0.0-20261108" # GCC's trunk of 2026-11-08 (experimental) +``` + +Any other package of the index is a toolchain when it describes itself. +`xim:@` installs it and resolves it as `path:` with the +table its `.mcpp-toolchain.json` states (schema 2), the keys of a manifest's +`{ path = ... }` table, each relative to the payload and refused if it leaves it: + +```json +{ "schema": 2, "family": "gcc", "prefix": "riscv64-elf-", + "sysroot": "riscv64-elf", "tools": { "cxx": "bin/riscv64-elf-g++" } } +``` + +`family` is the option vocabulary of its drivers (`gcc` or `llvm`); without it +the family is the C++ driver's name. A package without the file is refused, +naming the file. Without `xim:` an unknown name is refused, and the message +lists the families, the payloads of the table and this form. + +### A compiler without C++ modules (2026.11.13.1+) + +A GCC before 14 has neither `-fmodules` nor P1689 scanning (DJGPP's GCC 12.2.0, +measured). With it, mcpp builds the C++ units that declare and import no module +as translation units without module flags, from its own scan of the sources, +and the C units as with any compiler. A unit that declares a module, is a +module unit or imports one (`import std;` included) is refused at planning, +naming the file and what it needs. + ## Switching the Default Toolchain The default is a *pair* — toolchain axis + target axis (target omitted = host): diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index aedb6b4cb..9f55715ff 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -793,7 +793,9 @@ target does not determine a compiler. - A row at the `planned` tier is **registered in the vocabulary and nothing is wired yet**. Naming such a target is accepted by the parser and refused by the - build. + build, unless the build names a toolchain for it: `[target.X] toolchain`, or + `--toolchain` (or `MCPP_TOOLCHAIN`, its statement in the environment; + 2026.11.13.1+). - `host_can_serve` decides whether this machine can produce a target, and it is a different question from the tier. A target mcpp supports may still be unreachable from this host; the escape hatch is an explicit diff --git a/docs/22-target-side.md b/docs/22-target-side.md index c19ad0e48..e6018ed33 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -1192,14 +1192,23 @@ conditions and combinators. mcpp before 2026.11.10.2 reads only `linux`, `macos` - **Evaluated against the resolved target** — the `--target` triple for a cross build, otherwise the host. So a native Linux build never even *downloads* a `[target.windows]` dependency. -- **Predicate keys**: `os`, `arch`, `family`, `env` — the triple's coordinates — - and, from mcpp 2026.9.1.1, the five target-side layer names `compiler`, +- **Predicate keys**: `os`, `arch`, `family`, `env` — the triple's coordinates; + `object-format` (mcpp 2026.11.13.1), the row's container (`elf`, `pe`, + `mach-o`, `wasm`, `coff`), which a package selects by when what it needs is a + convention every system of the container shares (the PE rows: the Microsoft + x64 calling convention, COFF sections) rather than a system — and, from mcpp 2026.9.1.1, the five target-side layer names `compiler`, `compiler-runtime`, `kernel-abi`, `c-abi`, `c++-abi` ([22 — The Target Side](22-target-side.md)). `accelerator` is a key here too and is answered from this build's own `accel` — the backend names in `--accel` or `[build] accel` — so it is a membership test over a set, and `accelerator = "none"` is how a section says "this build named no backend" - without enumerating the ones it is not. Barewords `linux` / `macos` / + without enumerating the ones it is not. `interface` (mcpp 2026.11.13.1) is the kernel-abi + layer's second answer: an interface its provider states in `[kernel-abi] + provides-interfaces`, compared by membership as `accelerator` is. A provider + that states no list provides every interface; a graph without the layer + provides none. A package selects its sources by what the implementation + beneath it provides, and names no implementation: + `[target.'cfg(not(interface = "openkal.fs"))'.build] sources = [...]`. Barewords `linux` / `macos` / `windows` / `unix` are sugar for the matching `os` / `family` test. A key outside this set is reported as a schema warning and the section does not apply — it used to answer false in silence, which is indistinguishable from diff --git a/docs/25-home-indices-and-store.md b/docs/25-home-indices-and-store.md index 6b7d22f99..51e3ed7bf 100644 --- a/docs/25-home-indices-and-store.md +++ b/docs/25-home-indices-and-store.md @@ -35,11 +35,15 @@ stated on the build's own command lines. xpkgs/ the payloads they serve ``` -Processes share a home. Installing a package is one process at a time -(2026.11.10.1+): a process takes the home's install turn -(`registry/.install.lock`) before it installs, keeps it for the package's own -dependencies, and a process that waited for the turn looks for the package -again first, so a package two builds ask for at once is installed once. +Processes share a home. Writing the provider's state is one process at a time +(2026.11.10.1+): a process takes the state turn before it installs a package, +adds or removes an index repository, or chooses the runtime; it keeps the turn +for the package's own dependencies, and a process that waited for it looks for +the package again first, so a package two builds ask for at once is installed +once. The turn is the lock `.mcpp-state.lock` in the real directory of the +state (`registry/subos`, every link resolved; 2026.11.13.1+), so homes that +reach one state through a link take one turn. Releases before 2026.11.13.1 +took `registry/.install.lock`, for installs only. `/.mcpp/` is the machine's state of the project, never its sources. mcpp writes `.gitignore` with the one line `*` into it whenever it creates or @@ -261,7 +265,10 @@ not make (an `~/.xlings`) is refused there: reusing its packages is what `upstream` is for (§3). A registry that already exists keeps the places of its indices as the home that made it recorded them: a second home that names it reads those indices and -stores and records none of its own there (2026.11.10.1). +stores and records none of its own there (2026.11.10.1). A home whose +`registry` is a link to another home's borrows that registry the same way +(2026.11.13.1): it writes neither the places of the indices, nor the +upstreams, nor its `[index.repos]` tables' places into it. ## 6. Filesystems and long paths diff --git a/docs/35-platforms.md b/docs/35-platforms.md index 7c5e27566..cdded3cf3 100644 --- a/docs/35-platforms.md +++ b/docs/35-platforms.md @@ -372,16 +372,27 @@ name = "haiku" uname = "Haiku" # what `uname -s` answers there ``` -A provider's `runner` is the lowest tier of the runner statement: the -platform's `runner`, a build program's, this machine's `config.toml`, the -project's `[target.] runner` and `MCPP_TARGET__RUNNER` each -replace it. `hosts` are the machines the runner exists on (unset: any). In the +A provider's `runner` is the lowest tier of the runner statement: a build +program's, this machine's `config.toml`, the project's `[target.] +runner` and `MCPP_TARGET__RUNNER` each replace it. The platform's own +`runner` belongs to `[platform] targets` and does not replace it (2026.11.13.1): +a provider states the runner, the link (`link`) and the base (`sysroot`) of the +identities it serves, with `base = "platform"` and with `base = "none"` alike, +so one platform states rows whose conventions differ (the openkal platform's ok +images and `x86_64-uefi`). `hosts` are the machines the runner exists on (unset: any). In the template `{}` is the artifact, `{sysroot}` the base the artifact was linked against, and `{pkg:}` the root of the package of that name among `requires`; the template's program is looked for in the required packages before `PATH`. The packages of `requires` are installed by the first `mcpp run` or `mcpp test` that needs them, never by a build. +`[platform.tools]` are built for the building machine, and a build provisions +the tools its row carries (2026.11.13.1). A provider with `base = "none"` carries +those its own link, seal and runner name; every other row carries every tool +but those only such a provider names. So the openkal platform's DOS row +(`i686-dos-musl`, whose seal is `okdos`) builds `okdos` alone, and its ok images +do not build it. + One provider entry states one arrangement of payloads: a native arrangement and a cross one are two entries, each with its `hosts`, base and priority. A new entry does not change the answer of a request that was answered before it, so diff --git a/docs/40-baremetal.md b/docs/40-baremetal.md index bf7ff4143..85b5839b0 100644 --- a/docs/40-baremetal.md +++ b/docs/40-baremetal.md @@ -535,7 +535,7 @@ mcpp test boots: console boots ... ok (0.02s) - test result ok. 1 passed; 0 failed; finished in 0.58s (build 0.05s + run 0.02s) + test result ok. 1 passed; 0 failed; finished in 0.58s (plan 0.51s · build 0.05s · run 0.02s) ``` A failing case is named, and the command's exit status is non-zero: @@ -546,7 +546,7 @@ about to fail boots ... ok (0.02s) boots: console -error: test result: FAILED. 1 passed; 1 failed; finished in 0.24s (build 0.04s + run 0.02s) +error: test result: FAILED. 1 passed; 1 failed; finished in 0.24s (plan 0.18s · build 0.04s · run 0.02s) failures: deliberate_fail diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 21a7f4ee3..db6972909 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -991,7 +991,10 @@ Standard output is NDJSON. Each line but the last is an event: The last line is the `mcpp.build` envelope, on one line. Its `data` is the result record this run wrote (below); a run that failed before it entered a -build directory omits `data`. Its `diagnostics` are mcpp's own errors, each +build directory omits `data`. A build that replays the recorded build of an +unchanged tree (the fast path, of a package or of a workspace's members) +restates that build's artifacts, as events and in `data` (a workspace's, +2026.11.13.1+). Its `diagnostics` are mcpp's own errors, each located where its message says (§2), with the code `MCPP_BUILD_FAILED`, or `MCPP_EFFECT_DENIED` for a run that stopped before a denied effect. The narration goes to standard error, as for every machine-readable mode. diff --git a/docs/91-toolchain-internals.md b/docs/91-toolchain-internals.md index 40e94ac1f..cbee79e47 100644 --- a/docs/91-toolchain-internals.md +++ b/docs/91-toolchain-internals.md @@ -327,11 +327,22 @@ from an upstream home runs on that home's runtime; what it produces is the same, because the artifact's loader, run paths and sysroot come from the build's own command lines (§5). +The loader a payload names may be the home's link to it (`/abi//`) +rather than the runtime payload itself. The links are state of the home, made +by the pin; a store restored without them (a CI cache of `data/xpkgs` alone) +holds payloads that do not start. So whenever a payload that runs on the +runtime is taken -- installed, or found already placed -- mcpp asks the +provider to keep the pin (`ensure_declared_runtime`), which makes the links +again when they are missing. Which payloads run on the runtime is read from the +payload's programs, not from its name (2026.11.13.1): one of `bin/` whose +`PT_INTERP` lies in its own provider home. A Mach-O or PE program names no +loader, so the question has the same form on every system. + **What mcpp still does here** (`post_install.cppm`, all read-only): | function | answer | |---|---| -| `runtime_payload_kind` | whether a toolchain runs on a C runtime payload: `gcc`, `llvm` on Linux; nothing for musl-gcc, a system compiler or a PE toolchain | +| `runtime_payload_kind` | whether a toolchain runs on a C runtime payload: `gcc`, `llvm` on Linux by their recipes; once placed, any payload whose programs start through its home's loader (`starts_through_its_home`, the emsdk payload among them); nothing for musl-gcc, a system compiler or a PE toolchain | | `select_glibc_payload_lib` | the library directory of exactly `glibc@` (never another installed version) | | `detect_baked_loader`, `baked_runtime_binding` | the loader and runtime a payload was bound to, read from its ELF headers | | `write_clean_link_specs` | the per-build `-specs=` file in the build directory (§5) | @@ -502,8 +513,9 @@ loader coupling, so a host binary that stumbles onto them is at worst confused. 3. **Capabilities** (`src/toolchain/provider.cppm`): stdlib identity, BMI traits, and feature switches consumed by `flags.cppm`. 4. **Runtime kind** (`post_install.cppm`): whether the payload runs on a C - runtime payload (`runtime_payload_kind`); the provider binds it to the - pinned runtime at install, and mcpp writes nothing into it. + runtime payload (`runtime_payload_kind`), read from its programs once it + is placed; the provider binds it to the pinned runtime at install, and mcpp + writes nothing into it. A new family needs no entry here. 5. **e2e**: a hermetic-link test in the spirit of `86_llvm_hermetic_link.sh`, and coverage in the no-host-toolchain CI job. diff --git a/docs/92-release.md b/docs/92-release.md index 9516f6711..5c50dff77 100644 --- a/docs/92-release.md +++ b/docs/92-release.md @@ -48,17 +48,27 @@ install a version that did not exist yet. tag from `mcpp.toml`) does all of this: ``` -build ×4 (linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) - → GitHub Release v with tarballs + .sha256 sidecars +build ×4 at once (linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) + [canaries beside them, when started by hand with `canaries`] + → create-release: GitHub Release v with every archive + .sha256 sidecars → recompute every payload hash and publish immutable mcpp-release.json → mirror to xlings-res/mcpp on BOTH GitHub and GitCode → open the version-bump PR against openxlings/xim-pkgindex → workflow_run hook fires ci-fresh-install ``` +The four builds start together and keep their archives as artifacts; +`create-release` creates the release with all of them once every build has +passed, so no release exists with a platform missing (2026.11.13.1+). The +ecosystem canaries (xlings, mcppls; `.github/release-canaries.toml`) build with +the candidate only in a release started by hand with `canaries` +(`gh workflow run release.yml -f canaries=true`), and then gate the creation of +the release; their build took 48 of the 109 minutes of v2026.11.12.1, and each +project's own CI validates the release after it. + ### 2.1 The immutable release manifest -`release-manifest` waits for all four platform upload jobs. It downloads the +`release-manifest` waits for `create-release`. It downloads the final non-draft, non-prerelease GitHub Release, recomputes each versioned platform payload's SHA256, verifies the corresponding `.sha256` sidecar, and publishes `mcpp-release.json` schema 1: diff --git a/docs/specs/README.md b/docs/specs/README.md index 964e6c09e..ff84ed911 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -46,7 +46,7 @@ | [SPEC-014](packaging.md) | 打包与分发:链接、闭包、容器三个维度与构建腿,腿的合并,暂存树与 `mcpp-package.json`,平台的树、运行覆盖、单文件、polyglot、容器列表、签名、压缩、合并与分发描述 | 评审中 v2.2 | 2026-11-10 | mcpp >= 2026.10.31.1;逐条标注;v2.2 条款 mcpp >= 2026.11.10.1 | | [SPEC-015](target-sets.md) | 目标集合与腿:集合的写法与展开,命令对多条腿的语义,串行规划与一起构建,测试归档,机器输出 | 评审中 v1.0 | 2026-10-30 | mcpp >= 2026.10.30.1 | | [SPEC-016](targets-and-providers.md) | 目标与提供者:行、提供者、选择规则、平台定义的行、C 环境与执行、报告、机器输出与锁 | 评审中 v1.1 | 2026-11-10 | mcpp >= 2026.11.9.1;v1.1 条款 mcpp >= 2026.11.10.1 | -| [SPEC-017](test.md) | mcpp 测试规范:测试程序、测试协议、记录、存储、启动描述与框架声明;基础能力 B1–B6 与可选能力 O1–O9 | 评审中 v2.0 | 2026-11-10 | mcpp >= 2026.11.8.1;逐条标注;B5、B6 的新键、O3 的运行器一侧、O8 mcpp >= 2026.11.10.1;O9 mtest >= 0.6.0 | +| [SPEC-017](test.md) | mcpp 测试规范:测试程序、测试协议、记录、存储、启动描述与框架声明;基础能力 B1–B6 与可选能力 O1–O10 | 评审中 v2.2 | 2026-11-14 | mcpp >= 2026.11.8.1;逐条标注;B5、B6 的新键、O3 的运行器一侧、O8 mcpp >= 2026.11.10.1;O9 mtest >= 0.6.0;O10 mcpp >= 2026.11.13.1 | ## 文档约定 diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index df9fc4415..4a9307365 100644 --- a/docs/specs/manifest-semantics.md +++ b/docs/specs/manifest-semantics.md @@ -492,7 +492,7 @@ feature-deps feature-xlings ← 限定词是门 另一成员的,`-p` 下相同;非工作空间中依赖的共享库含自己与 profile 的标记而不含根包与兄弟 依赖的;只由根声明的搜索路径不再到达依赖的共享库,构建给出 `link/root-flags` 提示,依赖 自己声明后链接通过(`tests/e2e/884_a_shared_library_links_with_its_own_closure.sh`)。 -23. §11 的判据(`tests/e2e/887_msvc_lto_and_export_discovery.sh`,需要 cl.exe):cl.exe 上 +23. §11 的判据(`tests/e2e/windows/msvc.cpp` 的用例 `887 msvc lto and export discovery`,需要 cl.exe):cl.exe 上 `lto = true` 以 `/GL` 编译、以 `/LTCG` 链接;省略 `windows_auto_export` 的 DLL 所链接的包以 `/GL-` 编译并报告一次;陈述 `true` 与写入 `/GL` 被拒绝;陈述 `false` 时完整使用 `/GL`。 diff --git a/docs/specs/packaging.md b/docs/specs/packaging.md index 436effc2e..bd576e98c 100644 --- a/docs/specs/packaging.md +++ b/docs/specs/packaging.md @@ -4,10 +4,10 @@ |---|---| | 规范编号 | SPEC-014 | | 标题 | 打包与分发:链接、闭包、容器三个维度与构建腿,腿的合并,暂存树与包元数据,运行覆盖、单文件、容器列表、签名、压缩、合并与分发描述 | -| 状态 | 评审中 v2.3 | -| 版本 | 2.3 | -| 最后修改 | 2026-11-10 | -| 对应实现 | mcpp >= 2026.10.31.1;v2.2 条款 mcpp >= 2026.11.10.1 | +| 状态 | 评审中 v2.4 | +| 版本 | 2.4 | +| 最后修改 | 2026-11-14 | +| 对应实现 | mcpp >= 2026.10.31.1;v2.2 条款 mcpp >= 2026.11.10.1;v2.3 条款 mcpp >= 2026.11.12.1;v2.4 条款 mcpp >= 2026.11.13.1 | | 相关设计文档 | `.agents/docs/2026-10-30-ecosystem-part2-ide-and-language-service-design.md`(§19.6)、`.agents/docs/2026-10-29-ecosystem-target-sets-packaging-and-editors-design.md`(§4、§13)、`.agents/docs/2026-10-28-platforms-part2-packaging-and-distribution-design.md`(§2–§7、§14)、`.agents/docs/2026-10-28-platforms-design.md`(§4.7)、`.agents/docs/2026-10-29-openkal-run-test-pack-design.md`(§2.2、§2.6) | | 相关 issue | mcpp#847、mcpp#849、mcpp#860 | | 相关规范 | [SPEC-007](build-plugins.md)(包格式提供者、artifact action)、[SPEC-011](concurrency.md)(原子发布)、[SPEC-013](platforms.md)(平台、运行器、载体)、[SPEC-015](target-sets.md)(目标集合与腿) | @@ -236,7 +236,10 @@ openkal 目标上 `static` 为一个 `.oke`、无 `.okso`,`shared` 为 `.oke` 按惯常行构建,与本机系统和架构相同时取本机的行。本 mcpp 不构建程序的行(目标表标为 planned 或未列出)不放入包,并 写出该系统与行。引擎只对 `linux`、`macos`、`windows` 持有"该系统的程序是哪一行"的约定;其余系统的程序是平台运行器 为该宿主对陈述的身份(`runner.built-as`,SPEC-013 §10),无此陈述、平台也未为它定义行的系统没有程序 -(**mcpp >= 2026.11.12.1**)。 +(**mcpp >= 2026.11.12.1**)。平台为某系统定义的行即该系统的程序之行,行名可带环境段(i686 上的 `dos` 即 +`i686-dos-musl`):程序以该行的后缀命名;`built-as` 指向平台定义而非托管的行时,在该平台之上构建。封装把链接 +产物包进自己的容器时(DOS 行的 MZ 存根后接 ELF32 可执行文件),strip 不读的文件按封装的结果打包 +(**mcpp >= 2026.11.13.1**)。 ### 4.5 `mcpp-package.json` @@ -491,3 +494,4 @@ openkal 目标的单文件:载体携带一条腿的映像本身(映像不引用 | 2.1 | 2026-10-31 | mcpp 2026.10.31.1(设计 2026-10-30 part 2 §19.6):§5.3 载体模型 `row` 与 `polyglot`,`--pkg-format polyglot` 的范围、布局、启动、缓存、签名与其他选项;§1.3、§1.5、§5.4、§7、§8 相应条目;`mcpp run --pkg-format` 直接运行 `single-file` 与 `polyglot`、拒绝 `tar`、`zip`、`dir`;§4.3 macOS 上带 quarantine 标记的运行器;判据 14(e2e 992)。 | | 2.2 | 2026-11-10 | mcpp 2026.11.10.1(设计 2026-11-10 E7,mcpp#911 N8):§1.2 另一系统的产物从文件读取闭包,不运行本机加载器;`--mode static` 链接全静态程序并在打包前核对。 | | 2.3 | 2026-11-12 | mcpp 2026.11.12.1(设计 2026-11-12 C6、C7):§4.4 `--systems all` 取系统表,无程序可构建的对写为未覆盖;三个系统之外的程序按 `runner.built-as` 构建;单文件按系统表的 `uname` 选择程序(SPEC-016 §2.3)。 | +| 2.4 | 2026-11-14 | mcpp 2026.11.13.1:§4.4 平台定义的行即该系统的程序之行(行名可带环境段),`built-as` 指向定义的行时在平台之上构建;strip 不读的封装容器按封装结果打包。 | diff --git a/docs/specs/platforms.md b/docs/specs/platforms.md index 3f6181ddc..381c82f0d 100644 --- a/docs/specs/platforms.md +++ b/docs/specs/platforms.md @@ -4,9 +4,9 @@ |---|---| | 规范编号 | SPEC-013 | | 标题 | 平台:一类目标的可复用配置,其声明、选择、合并、对依赖图的补全、链接与封装、运行器与载体 | -| 状态 | 评审中 v1.8 | -| 版本 | 1.8 | -| 最后修改 | 2026-11-12 | +| 状态 | 评审中 v1.9 | +| 版本 | 1.9 | +| 最后修改 | 2026-11-14 | | 对应实现 | mcpp >= 2026.10.27.1;§10 的执行宿主与 §11 的载体架构 mcpp >= 2026.10.29.1;`runner.hosts`、载体列表、`descriptor_keys` 与 `universal` 的弃用 mcpp >= 2026.10.30.1;§7.5 版本组参与解析、§14 平台系列与 §15.1 系列的锁 mcpp >= 2026.11.7.1;§14 内置平台为确切版本与提示 mcpp >= 2026.11.7.2;v1.7 条款 mcpp >= 2026.11.10.1;逐条标注 | | 相关设计文档 | `.agents/docs/2026-10-28-platforms-design.md`、`.agents/docs/2026-10-28-platforms-part2-packaging-and-distribution-design.md`、`.agents/docs/2026-10-29-openkal-run-test-pack-design.md` | | 相关 issue | mcpp#846、mcpp#847、mcpp#848 | @@ -237,6 +237,10 @@ plugin = "build.mcpp" 根已经以构建依赖请求同名工具时让位;`{ program = "" }` 不构建,在声明的载荷与 `PATH` 中查找。 **已实现** +一行所带的工具(mcpp >= 2026.11.13.1):`base = "none"` 的提供者所服务的身份只带其自身 `link` 的 `inputs`、`seal` 与 `runner` +以第一个词指名的工具;其余身份带全部工具,只由 `base = "none"` 的提供者指名、且该身份自身约定不指名的工具除外。 +一行的工具因此不为另一行构建。**已实现** + ### 7.4 工作空间成员 工作空间的计划中,平台加入的层的包同时成为每个被选中成员的依赖边,使成员的链接闭包含有这些层。**已实现** @@ -555,3 +559,4 @@ openkal-platform 的 `targets` 因此不含 WebAssembly。Web 经 `wasm32-emscri | 1.6 | 2026-11-09 | mcpp 2026.11.9.1(SPEC-016,mcpp#911):§1 `[[platform.define]]` 与 `[[platform.provider]]` 为一般形式,`[platform]`、`[platform.hosted]` 为其简写;§9.2 `driver = "compiler"` 在每一行生效,平台的产物名模式不被构建程序改变;§11 平台定义的系统上的载体与 `--systems`;主机对为任意系统词。 | | 1.7 | 2026-11-10 | mcpp 2026.11.10.1(设计 2026-11-10 E8,mcpp#911 N6):§4 `link.flags` 在 `driver = "compiler"` 时给出警告。 | | 1.8 | 2026-11-12 | mcpp 2026.11.12.1(设计 2026-11-12 §5.2、§5.5):§10 运行器的 `requires` 与 `built-as`,模板占位 `{sysroot}`、`{pkg:}`,提供者的 `runner` 为陈述的最低一级;`hosts` 的系统取自系统表。 | +| 1.9 | 2026-11-14 | mcpp 2026.11.13.1:§7.3 一行所带的工具,`base = "none"` 的提供者只带其自身约定指名的工具,其余身份不带只由这类提供者指名的工具。 | diff --git a/docs/specs/test.md b/docs/specs/test.md index bdc46d7ac..5a7dceed2 100644 --- a/docs/specs/test.md +++ b/docs/specs/test.md @@ -4,16 +4,16 @@ |---|---| | 规范编号 | SPEC-017 | | 标题 | mcpp 测试规范:测试程序、测试协议、记录、存储、启动描述与框架声明;基础能力与可选能力 | -| 状态 | 评审中 v2.0 | -| 版本 | 2.0 | -| 最后修改 | 2026-11-10 | -| 对应实现 | 逐条标注。B1–B4 与 O1、O2、O4–O7 mcpp >= 2026.11.8.1 至 2026.11.10.1(逐条);B5、B6、O3 的运行器一侧、O8 mcpp >= 2026.11.10.1;O9 由框架的工具实现(mtest >= 0.6.0) | +| 状态 | 评审中 v2.2 | +| 版本 | 2.2 | +| 最后修改 | 2026-11-14 | +| 对应实现 | 逐条标注。B1–B4 与 O1、O2、O4–O7 mcpp >= 2026.11.8.1 至 2026.11.10.1(逐条);B5、B6、O3 的运行器一侧、O8 mcpp >= 2026.11.10.1;O9 由框架的工具实现(mtest >= 0.6.0);O10 mcpp >= 2026.11.13.1 | | 取代 | SPEC-017 v1.0(`test-runner.md`);mtest 测试协议版本 1(mtest `spec/protocol.md`)的规范正文;SPEC-004 中 `[test]` 的键 | | 相关规范 | [SPEC-004](manifest-semantics.md)(清单的平面与命名)、[SPEC-006](build-database.md)(构建数据库) | | 相关 issue | mcpp#914、mcpp#915、mcpp#917 | | 使用文档 | [08 —— 测试](../zh/08-testing.md)、[50 —— 机器可读输出](../zh/50-machine-output.md) §8 | -用语按 RFC 2119:**必须 / 禁止**为强制,**应当**为建议,**可以**为可选。条款编号有三类:B1–B6 为基础能力,O1–O9 为可选能力,P1–P12 为协议条款(编号与 mtest 协议版本 1 相同,原有引用不变)。 +用语按 RFC 2119:**必须 / 禁止**为强制,**应当**为建议,**可以**为可选。条款编号有三类:B1–B6 为基础能力,O1–O10 为可选能力,P1–P12 为协议条款(编号与 mtest 协议版本 1 相同,原有引用不变)。 ## 0. 范围与分层 @@ -48,7 +48,7 @@ ### B1. 发现与 `[test]` 的键 已实现(mcpp >= 2026.10.26.2,逐键标注见 docs/04 §2.17) 1. 一个包的测试程序由 `[test] discover` 的 glob 决定,缺省 `["tests/**/*.cpp"]`;以 `!` 开头的 glob 移除其匹配的文件;`[]` 不发现任何程序。 -2. `[test] programs`:`"file"`(缺省)时每个文件是一个程序;`"directory"` 时 glob 固定目录之下同一目录的文件是一个程序,以目录命名。 +2. `[test] programs`:`"file"`(缺省)时每个文件是一个程序;`"directory"` 时 glob 固定目录之下同一目录的文件是一个程序,以目录命名;`"package"` 时包所发现的全部文件是一个程序,以首个 glob 的固定目录命名(mcpp >= 2026.11.13.1)。一个程序中定义 `main` 的文件**不得**多于一个,否则被拒绝并写出两者。`"directory"` 留下直接位于固定目录中的文件时**必须**说明其数目;不形成任何组时为降级,在 `--strict` 下为错误(mcpp >= 2026.11.13.1)。 3. 程序名是其路径相对于首个匹配 glob 的固定目录、去掉扩展名的部分。同名的两个文件被拒绝,并写出两者。 4. `[test] main = { from = "<依赖>/<入口>" }` 为每个程序提供起点;`[test] start` 为每个测试镜像陈述入口符号;`windows_code_page`、`windows_long_paths` 与同名目标键含义相同。 5. `[test] split` 与 `[test] store` 分别属于 O1 与 B4;`[test] link` 规定测试链接其所达(`"used"`,缺省)或全部目标文件(`"objects"`)。 @@ -101,6 +101,8 @@ - **P3.1** 在 `MTEST_MODE=list` 下,程序**必须**先写一个 `program` 事件,再为将要运行的每个用例写一个 `case` 事件,最后写一个 `done` 事件,并以状态 0 退出,不运行任何用例。 - **P3.2** 用例标识是 UTF-8 字符串,且稳定:同一用例在每次运行、每个系统上标识相同。程序以 `/` 连接其套件与用例的名字构成它(例如 `fs/a file is read`)。同一程序中的标识互不相同。 - **P3.3** `case` 事件**可以**给出用例的位置(`file`、`line`)、种类(P8)、标签、所覆盖的条款、预期状态,以及调度提示(O5)。未给出种类的用例属于运行器赋予其程序的种类。 +- **P3.3 的位置与能力(mtest >= 0.8.0)。** `case` 事件**可以**给出用例运行的位置(`only`、`except`:平台与架构的名字)、所需能力及程序对其的回答(`capabilities`:名字到布尔值),以及程序将其列为跳过时的 `reason`(与预期状态 `skip` 同在)。位置之外或缺少能力的用例由程序自身跳过并陈述原因,与启动它的运行器无关;运行器**可以**不启动列为跳过的用例。 +- **mcpp 的实现(已实现,mcpp >= 2026.11.13.1)。** mcpp 不解释这些字段:用例由程序跳过,`mcpp test` 报告其状态与原因(`skip <标识> -- only on windows (this host: linux x86_64)`)。mcpp 自身的端到端用例以这些属性陈述位置与能力(`tests/support`)。 - **P3.4** 只在另一用例运行时才存在的用例(在用例内声明的用例)**可以**不被列出;它在运行时出现在报告中,其 `begin` 事件**可以**给出种类、标签与条款。选中它即以声明它的用例为作用域运行;后者只在其自身语句失败时被报告。 #### P4. 选择 @@ -169,7 +171,7 @@ - **P12.2** 运行标识按时间排序。同一 `group` 的运行(一次 CI 运行的各作业)是一个整体的各部分,读者合并报告。 - **P12.3** 一次运行中程序、用例、构建与夹具的时间构成其工作的有向无环图;读者的关键路径是按实测时间加权的最长路径。 -- **mcpp 的实现。** 存储位于 `[test] store`(缺省 `target/mtest`;`"none"` 不保存)。`run.json` 的 `commit` 与 `group` 在 CI 中取 `GITHUB_SHA` 与 `GITHUB_RUN_ID`。mcpp 在一次构建中构建全部测试程序,不写逐个程序的 `build_started_ms`。 +- **mcpp 的实现。** 存储位于 `[test] store`(缺省 `target/mtest`;`"none"` 不保存)。`run.json` 的 `commit` 与 `group` 在 CI 中取 `GITHUB_SHA` 与 `GITHUB_RUN_ID`;分片运行(O10)另写 `shard`(`I/N`)。程序进程一行在其为一部分时写 `part_of`(mcpp >= 2026.11.13.1)。mcpp 在一次构建中构建全部测试程序,不写逐个程序的 `build_started_ms`。 ### B5. 构建与启动描述 已实现(mcpp >= 2026.11.10.1) @@ -211,10 +213,11 @@ | O7 | `usage` | 资源用量(P10) | 框架;运行器补测 | 无用量 | | O8 | `output` | 每个用例的输出归属 | 框架写出 `output` 事件;mcpp 归属与转交 | 程序的输出整体显示 | | O9 | `session` | 测试会话:运行、加入、取消、状态 | 框架的工具 | 客户端以多个并发的 `mcpp test` 进程运行 | +| O10 | `measured-schedule` | 按存储测得的用时排序、划分与分片,重跑失败部分 | mcpp | 按列出顺序运行,不分片 | ### O1. 各部分 已实现(mcpp >= 2026.11.10.1) -- `[test] split`:`"file"`(缺省)时用例分布在多个文件中的程序每个文件一个进程;`"case"` 时每个用例一个进程;`"none"` 时一个进程。 +- `[test] split`:`"file"`(缺省)时用例分布在多个文件中的程序每个文件一个进程;`"case"` 时每个用例一个进程;`"none"` 时一个进程;`"auto"`(mcpp >= 2026.11.13.1)按文件划分,并按 O10 把测得长于份额的部分划分为用例组。`--split` 为一次调用给出该值。 - 每个进程是程序的一个部分,名为 `<程序>/<文件主名>`(`"case"` 时为 `<程序>/<用例>`)。 - `--cases` 的记录**必须**写出 `"file"` 划分的各部分(`parts`:`name`、`file`);每个部分的运行记录**必须**写出 `part_of`。 @@ -301,6 +304,14 @@ - `cancelled` 是运行器的状态,不属于 P5.3 的结论集合,不影响 P5.5 的退出状态规则。 7. **存储。** 会话的运行以 B4 的形式保存。 +### O10. 按测量调度 已实现(mcpp >= 2026.11.13.1) + +1. **估计。** 运行之前,运行器从 B4 的存储读取最近的运行(至多 30 次,按 `started` 由新到旧):每个程序记录名与每个用例标识的最近用时。一个测试的估计是其所选用例用时之和,未选用例时为程序整体的用时。没有测量的程序记录取其最近目录(按 `/` 逐级向上)中已测程序记录的平均值,各级都没有时取已测程序记录中的最大值;没有测量的用例取同一列表中已测用例的平均值,都没有时取其程序估计按用例数的等分。状态为 `built` 或 `not-run` 的记录与跳过的用例不计入。 +2. **顺序。** 测试**应当**按估计从长到短开始;占用 `machine` 的测试仍排在最后(P7.1)。 +3. **划分。** `split = "auto"` 时,份额为全部测试的估计之和除以工作线程数与分片数;估计长于份额、且含两个以上列出用例的部分**必须**划分为 ⌈估计/份额⌉ 组(不多于用例数),每组按最长处理时间优先分配用例,组内保持列出顺序,名为 `<部分> (i/k)`。 +4. **分片。** `--shard I/N` 把每个测试按估计从长到短(相同时按名字)分配给已分配估计之和最小的分片(相同时取编号最小者),只运行第 I 个分片的测试。分配**必须**只取决于存储与本次运行的测试列表。分片**禁止**把本组(`GITHUB_RUN_ID`,无则为空)的分片运行读作历史,使同一存储上先后启动的各分片读到相同的历史。 +5. **失败重跑。** `--failed` 读取存储中最近的一次运行:状态为 `fail` 或 `build-fail` 的程序记录与状态为 `fail` 或 `xpass` 的用例。所选程序为这些记录的 `part_of`(无则为其名字);每个失败的程序都有失败用例时,只运行这些用例(与 `--case` 相同);否则整体运行这些程序。最近一次运行没有失败时,运行器不运行任何测试,退出码为 0。 + ## 4. 宣告 mcpp 以 feature(docs/50 §4)宣告它实现的条款;客户端按需门控。 @@ -338,3 +349,5 @@ mcpp 以 feature(docs/50 §4)宣告它实现的条款;客户端按需门 |---|---|---| | 1.0 | 2026-11-10 | 初版(`test-runner.md`):2026.11.8.1 起的运行器侧行为、各部分、随写随读的用例记录、指标与界限、P12 存储字段(mcpp#917) | | 2.0 | 2026-11-10 | 改为 mcpp 测试规范:合并 SPEC-017 v1.0、mtest 协议版本 1 的规范正文与 SPEC-004 的 `[test]` 键;分为基础能力 B1–B6 与可选能力 O1–O9;新增 B5 启动描述、B6 的 `test-capabilities` 与 `test-session`、O3 检查点细节、O8 输出归属、O9 测试会话(mcpp#914、mcpp#915) | +| 2.1 | 2026-11-14 | P3.3 的位置与能力(`only`、`except`、`capabilities`、列为跳过时的 `reason`;mtest 0.8.0),及 mcpp 的实现 | +| 2.2 | 2026-11-14 | O10 按测量调度(顺序、`split = "auto"`、`--shard`、`--failed`);B4 的 `shard` 与 `part_of` | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 22a68b630..7d0420e40 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -1366,7 +1366,7 @@ test` 把它报告为**未运行**,在原因确定时打印一次,在摘要 ``` warning: this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); declare [target.aarch64-linux-musl].runner, or pass --no-runner on a host that can smoke ... not run -error: test result: NOT RUN. 0 passed; 0 failed; 1 not run (this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); ...); finished in 0.41s (build 0.39s + run 0.00s) +error: test result: NOT RUN. 0 passed; 0 failed; 1 not run (this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); ...); finished in 0.41s (plan 0.02s · build 0.39s · run 0.00s) ``` 退出码 1 保留原有含义——一个测试运行过并失败;0 意味着每个测试都 @@ -1515,7 +1515,7 @@ GCC 16.1 报告 `sorry, unimplemented: private module fragment`,mcpp ```toml [profile.dist] opt = 3 # -O level (a number, or the string "s"/"z") -debug = false # -g;cl.exe 上为 /Z7 +debug = false # -g;cl.exe 上为 /Z7;"lines":只含行表 lto = true # -flto; /GL + /LTCG on cl.exe (note: some packaged gcc builds ship without the LTO plugin) strip = true # -s at link time ndebug = true # 整个图的每个单元 -DNDEBUG(release/dist 默认开) @@ -1569,14 +1569,19 @@ ldflags = [] 它只在 `trim_paths` 关闭时生效,因为调试器经骨架所记的构建目录查找单元的 `.dwo`。`"off"` 把全部调试信息 留在产物中。Mach-O(debug map)、PDB、MinGW、WebAssembly 与裸机目标不变。在包的 flags 中写 `-gz=none` 可关闭压缩。 +- **`debug = "lines"`(2026.11.13.1+)** 只保留行表:GCC 为 `-g1`,clang 即 `-gline-tables-only`。回溯、sanitizer 报告与 + 性能分析器仍给出文件与行号;调试器看不到变量与类型。闭包大的测试程序只有 `debug = true` 时的一部分大小,这正是 CI 与全量测试的 + profile 所需(见 [08 —— 测试构建的体积](08-testing.md#测试构建的体积))。cl.exe 没有低于 `/Z7` 的级别,`"lines"` 在其上以 `/Z7` + 编译。其他字符串被拒绝。 - **每个 profile 拥有自己的构建目录。** 解析出的 profile 旋钮参与 指纹,所以 `target//` 下每个 profile 各有一个哈希目录,在 它们之间切换是增量的,不是一次完整重建。这也意味着磁盘开销随实际 用到的 profile 数量而增长。 - **一个配置在时间上只占一个目录(2026.10.31.3+)。** 指纹中开发者在其间切换的部分——编译器族、目标、 - 标准库族、语言标准与配置级选项——是目录的谱系;编译器版本、mcpp 版本、std 模块与运行时契约随时间演进。 + 标准库族、语言标准、配置级选项与所陈述的基座——是目录的谱系;编译器版本、mcpp 版本、std 模块与运行时契约 + (基座的内容)随时间演进。 同一谱系的新指纹接管该谱系最近使用的目录,由 ninja 重建命令变化的边:编译选项不变的 mcpp 升级之后, - 没有单元被重新编译。编译器构建不同的前驱则被删除(其中的对象不可复用)。构建或测试成功后,每天至多一次, + 没有单元被重新编译。编译器构建不同、或基于基座的其他内容构建的前驱则被删除(其中的对象不可复用)。构建或测试成功后,每天至多一次, 删除 `[build] keep_days` 天(config.toml;默认 3,`0` 全部保留)内没有配置使用过的目录。 - **`[build] target_budget` 限定 `target/` 的大小(2026.11.8.1+)。** 在 `mcpp.toml`(或 config.toml) 中写 `target_budget = "20GiB"` 这样的大小:每次构建或测试后,若 `target/` 超过该大小,没有任何配置 @@ -1981,7 +1986,7 @@ discover = ["tests/**/*.cpp"] # the default | `main` | `{ from = "<依赖>/<入口>" }` | 每个测试程序的起点所在的入口目标;测试文件随之成为程序的普通翻译单元,不定义 `main`(§2.2,`kind = "entry"`) *(2026.10.26.2+)* | | `start` | 符号 | 独立目标上每个测试镜像的入口符号(§2.2,`start`) *(2026.10.26.2+)* | | `programs` | `"file"` 或 `"directory"` | `"file"`(默认):每个被发现的文件是一个程序;`"directory"`:glob 固定目录之下同一目录的文件是一个程序,以目录命名,有文件定义 `main` 时以它为起点 *(2026.11.8.1+)* | -| `split` | `"file"`、`"case"` 或 `"none"` | 使用测试协议的程序的运行方式:按用例所在文件各一个进程(默认)、每个用例一个进程,或只运行一次 *(2026.11.9.1+)* | +| `split` | `"file"`、`"case"`、`"auto"` 或 `"none"` | 使用测试协议的程序的运行方式:按用例所在文件各一个进程(默认)、每个用例一个进程、按文件且把运行存储测得长于其份额的文件划分为用例组(`"auto"`,2026.11.13.1+),或只运行一次 *(2026.11.9.1+)* | | `link` | `"used"` 或 `"objects"` | `"used"`(默认):测试只链接它用到的部分(`--gc-sections`、`-dead_strip`);`"objects"`:保留每个目标文件的全部内容 *(2026.11.9.1+)* | | `store` | 目录或 `"none"` | 运行以 mtest 存储的形式保存的位置,相对项目;默认 `target/mtest`;`"none"` 不保存 *(2026.11.9.1+)* | diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 9bb21e85d..666ee78b4 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -62,8 +62,8 @@ mcpp 把它读作这些点所拼写的选择器,警告该拼法已弃用(自 ```toml [dev-dependencies] -mcpplibs.mtest = "0.5.2" -"mcpplibs.mtest.gtest" = "0.5.2" +mcpplibs.mtest = "0.8.5" +"mcpplibs.mtest.gtest" = "0.8.5" ``` ```toml diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 942ab2c95..aab611ac1 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -477,7 +477,7 @@ mcpp test --exclude legacy --exclude bench # at a virtual root: every member b Workspace built members libs/core, libs/http, ... in 120.40s; slowest: obj/libs/jsc/tests/jsc.o 88.0s Workspace testing member 'libs/core' (3/97) test_paths ... ok (0.31s) - test result ok. 7 passed; 0 failed; finished in 121.10s (build 120.40s + run 0.60s) + test result ok. 7 passed; 0 failed; finished in 121.10s (plan 0.10s · build 120.40s · run 0.60s) Workspace member 'libs/core' (3/97) ok — 7 passed, run 0.60s ... workspace result ok. 97 member(s); 412 passed; 0 failed; finished in 355.20s @@ -565,6 +565,25 @@ mcpp test --workspace --workspace-timeout 1800 # whole fan-out (default 0 = no 程序的两个成员可以各自提供同名模块,一条 `--workspace` 命令会把两者都构建出来;同时链接两者的 成员会被拒绝。见 [05 —— 每个程序里一个名字只对应一个模块](05-dependencies.md#每个程序里一个名字只对应一个模块mcpp-20269302)。 +### 5.5 同时的两个命令,与一个配置的目录随时间的变化 + +一个配置的构建目录有一把锁,只在写目录时持有。读取清单、解析、下载、确定工具链与扫描都不持锁;`mcpp test` 在第一个测试 +运行之前释放锁,`mcpp run` 在程序启动之前释放。因此: + +- **测试一个成员的同时可以修改并构建另一个成员。** 两个命令只在各自编译与链接时排队,等待的一方说明持有目录的命令 + (`Waiting for the build directory, held by …`)。源码改动时正在运行的测试运行的是改动前链接的程序;下一次构建编译该改动。 +- **不同配置的命令互不等待。** 规划出不同配置的 profile、目标或选择是不同的目录。 +- **锁属于配置,不属于成员。** 一个配置只有一份 `build.ninja` 与一份 ninja 状态,两个 ninja 进程不能同时写它们(mcpp#839)。 +- 等待目录一秒以上时,`Finished` 说明等待的时长(2026.11.13.1+)。 + +一个配置的目录以其**谱系**命名(2026.11.13.1+):编译器家族、目标、标准库家族、C++ 标准、配置的选项与所陈述的基座(默认基座 +与陈述的 sysroot 是两个配置)。随时间演进的部分——mcpp 的版本、编译器的版本与构建、标准库的版本、运行时契约(含基座的内容)——记在目录内(`toolchain`、`fingerprint`),不在名字中。构建图经 +目录自己的 mcpp 链接 `engine/epoch-/mcpp` 运行 mcpp,每次构建使之为正在运行的 mcpp,因此没有命令含带 mcpp 版本的路径。 +mcpp 升级后,编译器、选项与文件未变的命令保持不变,ninja 只编译新版本改变的部分;编译器的构建不同、或基座的内容改变时, +目录在原处清空(`Replaced`)。链接路径中的 epoch 只在某个版本自身的构建步骤写出不同内容时改变,此时各步重新运行。2026.11.13.1 之前 +目录名是完整的指纹:升级把目录移到新的名字,而每个成员的命令都含这个名字,于是每个成员都重新编译。2026.11.13.1 的第一次构建 +接管这样的目录一次。 + ## 6. 目录布局 工作空间推荐的目录布局: @@ -593,7 +612,7 @@ myproject/ ``` myproject/ ├── mcpp.lock # 整个工作空间一份锁文件 -└── target/// +└── target/// # 以配置的谱系命名(§5.5) ├── build.ninja, compile_commands.json # 每个配置一张图、一份数据库 ├── obj// # 各包的中间产物 ├── gcm.cache// # 各包的 BMI(clang 为 pcm.cache) diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index b86fabe9a..58aa0bb4a 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -56,14 +56,43 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 的文件的规则开始。同一目录中两个定义 `main` 的文件会被拒绝并点出两者;直接位于固定 目录中的文件仍各自是一个程序。测试的名字是其程序的路径,因此一个名字是另一个名字的目录 (`unit` 与 `unit/own`)时被拒绝并点出两者(2026.11.9.1+)。多文件的程序只链接一次,而每文件一个程序时每个文件链接 -一次,后者正是由许多小文件组成的套件在构建上花费的时间。 +一次,后者正是由许多小文件组成的套件在构建上花费的时间。该设置留下直接位于固定目录中的文件时,`mcpp test` 给出一条 +note,说明有多少文件仍各自是一个程序;它不形成任何组时,该说明是一次降级,`--strict` 拒绝(2026.11.13.1+)。 + +`[test] programs = "package"`(2026.11.13.1+)使包所发现的全部文件成为一个程序,以第一个 glob 的固定目录命名(`tests`)。 +其中定义 `main` 的文件至多一个,两个则被拒绝并点出两者。该程序仍按文件各起一个进程运行,以文件在固定目录之下的路径命名(`tests/unit/b.cpp` 为 `tests/unit/b`),隔离与并行 +与每文件一个程序相同,整个套件只链接一次。三个值是测试程序的粒度:`"file"`(默认)、`"directory"` 与 `"package"`。 使用测试协议的程序**按其用例所在文件各起一个进程**运行(2026.11.9.1+):mcpp 先列出多文件程序的用例, 每个文件的那次运行只选取自己的用例(P4),像一个程序一样在各工作线程间调度,名为 `<程序>/<文件>`——兼得每文件一个程序的隔离与并行,而只链接一次。`[test] split = "case"` -使每个用例一个进程,`"none"` 只运行一次。模式按运行所用的名字指名程序或其一部分:`mcpp test unit/test_home`(或 `home`) +使每个用例一个进程,`"none"` 只运行一次,`"auto"`(2026.11.13.1+)先按文件划分,再把运行存储测得 +长于其份额(本次运行测得的工作量除以工作线程数与分片数)的文件按其列出的用例划分为测得用时大致相等的 +若干组,名为 `<程序>/<文件> (i/k)`。`--split ` 为一次调用给出该值。模式按运行所用的名字指名程序或其一部分:`mcpp test unit/test_home`(或 `home`) 运行该文件的那一部分。 +占用整台机器的用例(P7.1,资源 `machine`)运行时旁边没有其他测试(2026.11.13.1+)。mcpp +在运行的工作线程上列出每个使用测试协议、且被划分或不经运行器运行的程序;经运行器运行的单文件 +程序不被列出,仍整体运行。在被划分的程序中,一个文件所列出的占用 `machine` 的用例(声明该资源的 +套件,`E2E bmi_machine{"bmi-machine", resource{"machine"}, [] { ... }}`,或基准)单独成为一部分, +该文件另有其他用例时名为 `<程序>/<文件> (machine)`;不被划分的程序(`split = "none"`、单文件 +程序)整体运行,其用例之一占用 `machine` 时单独运行。占用 `machine` 的部分排在其他全部测试之后 +单独运行,运行记录在该部分的资源中写明 `machine`。写在套件内部某个用例上的资源不在列出之列, +因此不跨进程占用任何东西。 + +**一次运行的顺序**(2026.11.13.1+)。运行之前,mcpp 读取运行存储(`[test] store`,见下文):每个测试 +最近一次测得的用时,按其所选用例或整个程序计算。没有测量的测试取其最近目录中已测测试的平均值 +(`e2e/run/x`:先 `e2e/run/`,再 `e2e/`),其各级目录都没有已测测试时取已测测试中最长者;没有测量的用例 +取同一程序中已测用例的平均值,都没有时取其程序估计的等分。因此由另一种选择的运行留下的存储,把它 +从未见过的测试按数量分散到各分片。各测试按用时从长到短开始, +最长的工作不会最后才开始。`--shard I/N` 运行 N 个分片中的第 I 个:每个测试按用时从长到短分配给测得 +工作量最少的分片,本命令运行属于自己的那些;分配只读取存储与本次运行的列表,因此 N 台机器上的 N 个 +命令在存储相同时恰好把每个测试运行一次。分片不读取属于本组(`GITHUB_RUN_ID`,或无)分片的运行, +因此在同一存储上先后启动的同一次运行的各分片,读到的与其中第一个读到的相同。`--failed` 运行存储中 +最近一次运行失败的部分:失败的程序,以及在每个失败都指明其用例时仅这些用例;在用例之外失败的程序 +整体运行。它不与模式、`--case`、`--cases` 或 `--no-run` 同用;最近一次运行没有失败时什么也不运行, +退出码为 0。 + 测试程序只保留它用到的部分(2026.11.9.1+):各单元以每函数、每数据对象一个节编译, 测试以 `--gc-sections` 链接(Mach-O 为 `-dead_strip`),测某一模块的测试不再带上整个包 的全部函数(mcpp 自身的单元测试:每个由 32 MB 降至 14 MB)。静态构造是回收的根。 @@ -111,6 +140,7 @@ GoogleTest 的为起点。依赖中定义 `main` 的源文件(`gtest_main.cc` ```bash mcpp test # build and run every test mcpp test parse # only those whose name matches +mcpp test parse lex # those whose name matches either mcpp test --list # what would run, without building or running it mcpp test -- --verbose # everything after `--` goes to each test binary ``` @@ -128,7 +158,9 @@ mcpp test -- --verbose # everything after `--` goes to each test binary | `--toolchain ` | 本次调用使用的工具链,例如 `llvm@23.1.3` | `--timeout ` 杀掉仍在运行的测试(默认 300;`0` 关闭),`--build-timeout ` -限制编译耗时。挂起的测试以它自己的名字被报为失败,而不是报成一个停止的任务。 +限制编译耗时。挂起的测试以它自己的名字被报为失败,而不是报成一个停止的任务。声明了自身时长的用例或套件 +(mtest `timeout{...}`,随用例列出)把其进程的时限提高到其各用例声明之和(若更长;2026.11.13.1+):长测试 +声明自己的时长,其余测试的时限不变。 报告是命令的结果,写到标准输出:每个测试的结论、失败测试的输出、`test result` 行, 以及 `--workspace` 时的 `workspace result` 行。构建的各步骤(`Compiling`、`Running`) @@ -136,6 +168,15 @@ mcpp test -- --verbose # everything after `--` goes to each test binary `mcpp test 2>&1 | tee test.log` 两者都收集 (见 [09 —— 按场景选命令](09-commands-by-scenario.md#输出流))。 +`test result` 一行以运行的时间结束,按 `Finished` 分解构建的方式分解(2026.11.13.1+): + +``` +test result ok. 7 passed; 0 failed; finished in 7.77s (plan 2.10s · programs 1.73s · build 1.60s · run 2.34s) +``` + +`programs` 是测试按名字找到的包程序的构建,不少于 5 ms 时列出;`build` 是测试自己的图;`run` 是测试的运行。`plan` 是其余 +部分:清单、解析、工具链与扫描。各段之和为总时长。 + ### 未改动的工程(2026.11.10.1+) 测试的图是 `test.ninja`,与构建的 `build.ninja` 位于同一构建目录;两者共享目标文件和 ninja 的记录, @@ -152,6 +193,8 @@ mcpp test -- --verbose # everything after `--` goes to each test binary - 测试受其构建所用文件的变更影响:它自己的文件、它导入的模块单元及这些单元再导入的 单元,以及链接中不属于任何模块的单元; - 清单、锁文件、构建程序、头文件或计划中没有的源文件的变更影响所有测试; +- 运行本包程序的测试(其构建所用的某个文件写出 `MCPP_BIN_`)受本包中任何不属于测试自身的源文件的 + 变更影响(2026.11.13.1+):端到端测试运行它所测的程序,却不由该程序的任何源文件构建; - 其他文件只影响自己的文件中写出该文件的测试(测试数据),不影响其他测试。 `--since` 从 git 读取变更:自该提交以来提交的、工作区中修改的、新增且未被忽略的路径。 @@ -162,6 +205,10 @@ mcpp test -- --verbose # everything after `--` goes to each test binary `MCPP_BIN_`:程序 `` 的路径,名字转为大写,字母与数字之外的字符写作 `_`(`my-tool` 为 `MCPP_BIN_MY_TOOL`)。运行被测程序的测试从这里读取它。 +`--no-programs`(2026.11.13.1+)不构建它们:程序只为得到名字而规划,每个测试从环境的 `MCPP_BIN_` +取得各程序,用于在别处构建的程序(一个 CI 作业测试另一个作业构建的二进制)。环境未指定的程序在任何测试运行前 +被拒绝,测试因而不会从 `PATH` 取到同名的另一个程序。环境中每个名字只指定一个程序,因此该选项只测试一个目标。 + **平台包在它的目标上测试**(2026.10.27.1+)。在带 `[platform]` 段的包中执行 `mcpp test` 而不给 `--target` 时, 以该包本身为平台,为平台的每个 `targets` 构建测试。其 runner 能在本机运行的目标被运行;其余已构建、报告为未运行 ([35](35-platforms.md))。 @@ -181,6 +228,34 @@ mcpp test --workspace --exclude legacy # every member's but legacy's 逐成员汇报,只要有一个失败就以非零退出。发现按成员隔离,因此两个成员可以各有一个 `tests/main.cpp`。 +测试一个成员的同时可以修改并构建另一个成员:两个命令只在一方编译或链接时互相等待,运行中的测试不持有任何东西 +([07 —— 同时的两个命令](07-workspace.md#55-同时的两个命令与一个配置的目录随时间的变化))。 + +### 测试构建的体积 + +每个测试程序携带它所链接的闭包及其调试信息。`mcpp clean --report` 按配置给出目标文件、拆分出的调试信息、BMI 与产物的字节数。 +程序多的测试集按效果从大到小由以下几项保持小: + +1. **全量运行用一个 profile。** 调试信息是测试程序的大部分字节。CI 与全量运行的 profile 少写调试信息并剥离符号,日常开发保留 + `dev`: + + ```toml + [profile.ci] + opt = 0 + debug = "lines" # 回溯与 sanitizer 报告中的文件与行号(2026.11.13.1+) + strip = true + ``` + + `mcpp test --profile ci` 在它自己的目录中构建。 +2. **更少的程序。** `[test] programs = "directory"` 把一个目录的文件链接一次,`"package"` 把整个包的文件链接一次(见 + [测试的位置](#测试的位置))。链接次数由文件数降为目录数或一次。 +3. **成员以共享库链接。** 工作区 profile 中的 `dependency_linkage = "shared"` 使测试所到达的每个成员以共享库链接,闭包在磁盘上 + 只有一份,而不是每个程序一份。它是可选的 profile,不是默认:Windows 上成员的符号只经导出到达 DLL 的导入方。 +4. **模块实现单元。** 函数体写在 `module X;` 而不是 `export module X;` 中时不改变 X 的 BMI,它改动时导入 X 的单元不重新编译。 +5. **成员分层。** 逻辑放在不依赖 UI 与渲染的成员中,多数测试只链接逻辑层。 +6. **没有配置使用的目录**由 `mcpp clean --stale` 删除,构建之后由 `[build] keep_days` 所限的清扫删除。配置的改变新开一个 + 目录时,警告给出旁边那个目录的大小(2026.11.13.1+)。 + ## 取到产物取不到的包的测试 ```toml @@ -267,6 +342,15 @@ mcpp test --archive tests.tar.gz # 在检出中运 ([SPEC-015 §4](../specs/target-sets.md))。归档是 tar,压缩方式由文件名决定:`.tar.gz`(或 `.tgz`)、`.tar.zst`、`.tar.xz` 或 `.tar`;其他名称在构建前即被拒绝。 +归档在测试旁携带包的程序,其运行像构建那样给每个测试 `MCPP_BIN_`。以 `--no-programs` 写出的归档 +不携带它们,清单列出其变量(`named_programs`,schema 2,只读 schema 1 的 mcpp 拒绝它);在本机运行的腿 +从自身的环境取得各程序,环境未指定者被拒绝(2026.11.13.1+)。归档的运行不构建任何东西,不接受 `--no-programs`。 + +```bash +mcpp test --no-run --no-programs --archive tests.tar.gz # 只有测试 +MCPP_BIN_MCPP=/path/to/mcpp mcpp test --archive tests.tar.gz # 其程序由运行方指定 +``` + ## 不能彼此并排运行的测试 `mcpp test` 在一个工作池上运行测试程序。一块接了一个探针的板子、一块 GPU、一个串口, diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index c52afa4fc..7af8bdd9a 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -106,6 +106,41 @@ $ mcpp toolchain install llvm 23.1.3 修订修复的是每个已装载荷都有的缺陷时,索引同时提高该版本的 `floor`,低于它的已装载荷在下一次构建时被替换, 并说明原因。替换失败时已装载荷保留;`mcpp toolchain install` 此时报错,并说明仍在使用的修订号。 +## 族名所对应的包,与作为工具链的任意包(2026.11.13.1+) + +族名(`gcc@16.1.0`、`llvm@23.1.3`)是简写。它在某台主机上为某个目标安装索引里的哪个包,是数据: +`modules/toolchain-model/data/builtin.toml` 的 `[[toolchain.payload]]` 条目,按序尝试,取第一条在本机服务该目标的。 +Linux x86_64 上为 `x86_64-linux-musl` 的 `gcc` 是 `musl-gcc`,其他主机上是 `x86_64-linux-musl-gcc`;为 +`x86_64-windows-gnu` 的 `gcc` 在 Windows 上是 `mingw-gcc`,其他主机上是 `mingw-cross-gcc`。条目陈述的载荷名 +(`musl-gcc@16.1.0`、`emsdk@6.0.9`)可直接使用,`mcpp toolchain list` 也经同一组条目识别已安装的载荷。 + +族的版本属于索引而不属于引擎:索引发布之后,清单或 `--toolchain` 直接写它,不需要新的 mcpp。部分版本取该系列最新的正式版 +(`gcc@16.2` 即 16.2.0)。主干快照按 GCC 自报的形式写作 `.0.0-`;日期是预发布段,因此 `gcc@17` 不选中它, +只有写全版本才使用: + +```toml +[toolchain] +default = "gcc@16.2" # 16.2.x 中最新的正式版 +# default = "xim:gcc@17.0.0-20261108" # GCC 2026-11-08 的主干(实验性) +``` + +索引里的其他包只要描述自身,就是工具链。`xim:@` 安装它,并按 `path:<根>` 加它的 +`.mcpp-toolchain.json`(schema 2)所陈述的表解析;键即清单 `{ path = ... }` 表的键,各自相对载荷根,越出载荷即被拒绝: + +```json +{ "schema": 2, "family": "gcc", "prefix": "riscv64-elf-", + "sysroot": "riscv64-elf", "tools": { "cxx": "bin/riscv64-elf-g++" } } +``` + +`family` 是其驱动程序的选项写法(`gcc` 或 `llvm`);未写时由 C++ 驱动程序的名字判定。没有该文件的包被拒绝,并指出文件名。 +不带 `xim:` 的未知名字被拒绝,消息列出族、表中的载荷与这种写法。 + +### 没有 C++ 模块的编译器(2026.11.13.1+) + +GCC 14 之前的版本既没有 `-fmodules`,也没有 P1689 扫描(实测 DJGPP 的 GCC 12.2.0)。使用它时,mcpp 依据自己对源码的扫描, +把不声明、不导入任何模块的 C++ 单元作为不带模块选项的翻译单元构建,C 单元与其他编译器相同。声明模块、属于模块单元或导入 +模块(含 `import std;`)的单元在规划时被拒绝,并指出文件及其所需。 + ## 切换默认工具链 默认值是一*对*——toolchain 轴 + target 轴(省略 target 即宿主): diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index f7e25f6e4..7c6daf903 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -734,7 +734,9 @@ runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic", ## 当前边界 - 处于 `planned` 档位的行,含义是**词汇表里已登记,还没有任何东西 - 接线**。点名这样一个目标会被解析器接受、被构建拒绝。 + 接线**。点名这样一个目标会被解析器接受、被构建拒绝,除非构建为它指定了 + 工具链:`[target.X] toolchain` 或 `--toolchain`(或其在环境中的陈述 + `MCPP_TOOLCHAIN`;2026.11.13.1+)。 - `host_can_serve` 判断的是「这台机器能不能产出这个目标」,这与档位 是两个不同的问题。mcpp 支持的目标仍可能从这台宿主够不着;出口是 显式的 `[target.X] toolchain = "…"`。 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index cc651c25b..54625640a 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -1001,13 +1001,19 @@ arch/env 条件与组合子用 `cfg(...)`。2026.11.10.2 之前的 mcpp 仅将 ` 因此不会与检查本身漂移。 - **按解析后的目标求值**——交叉构建取 `--target` 三元组,否则取宿主。因此 原生 Linux 构建**根本不会下载** `[target.windows]` 依赖。 -- **谓词的键**:`os`、`arch`、`family`、`env`——三元组的坐标——以及自 mcpp +- **谓词的键**:`os`、`arch`、`family`、`env`——三元组的坐标;`object-format`(mcpp 2026.11.13.1), + 行的容器格式(`elf`、`pe`、`mach-o`、`wasm`、`coff`),包需要的是同一容器的各系统共有的约定 + (PE 行:Microsoft x64 调用约定、COFF 节)而不是某个系统时按它选择——以及自 mcpp 2026.9.1.1 起的五个目标侧层名 `compiler`、`compiler-runtime`、 `kernel-abi`、`c-abi`、`c++-abi`(见 [22 —— 目标侧](22-target-side.md))。 `accelerator` 同样是这里的键,由本次构建自己的 `accel`(`--accel` 或 `[build] accel` 里的后端名)回答,因此它是对一个集合的成员判定; `accelerator = "none"` 是一种写法,用来说明「本次构建没有命名任何后端」, - 而不必枚举它不是的那些后端。裸词 `linux` / `macos` / `windows` / `unix` + 而不必枚举它不是的那些后端。`interface`(mcpp 2026.11.13.1)是 kernel-abi 层的第二个回答: + 该层的提供者在 `[kernel-abi] provides-interfaces` 里陈述的接口,与 + `accelerator` 一样按成员判定。未陈述列表的提供者提供全部接口;没有该层的 + 图不提供任何接口。包据此按其下实现提供的接口选择源文件,而不命名任何实现: + `[target.'cfg(not(interface = "openkal.fs"))'.build] sources = [...]`。裸词 `linux` / `macos` / `windows` / `unix` 是对应 `os` / `family` 判定的糖。集合之外的键会被报成一条 schema 警告, 且该段不生效——它过去静默地求值为假,而那与「这一段本就不该匹配」 读起来完全相同。 diff --git a/docs/zh/25-home-indices-and-store.md b/docs/zh/25-home-indices-and-store.md index d38a51225..d36049e9f 100644 --- a/docs/zh/25-home-indices-and-store.md +++ b/docs/zh/25-home-indices-and-store.md @@ -28,9 +28,11 @@ mcpp 经由其包提供者 xlings 安装工具链与包。payload(一个包的 xpkgs/ 它们所服务的 payload ``` -多个进程共用一个 home。安装包一次只由一个进程进行(2026.11.10.1+):进程在安装前取得 home 的安装次序 -(`registry/.install.lock`),为该包自己的依赖继续持有;等待过次序的进程先重新查找该包,所以两个构建同时 -需要的包只安装一次。 +多个进程共用一个 home。写入 provider 状态一次只由一个进程进行(2026.11.10.1+):进程在安装包、增删索引 +仓库或选择运行时之前取得状态次序,为该包自己的依赖继续持有;等待过次序的进程先重新查找该包,所以两个 +构建同时需要的包只安装一次。次序是状态真实目录(`registry/subos`,解析全部链接;2026.11.13.1+)中的锁 +`.mcpp-state.lock`,因此经链接到达同一状态的各个 home 取同一个次序。2026.11.13.1 之前的版本取 +`registry/.install.lock`,且只用于安装。 `/.mcpp/` 是本机关于该项目的状态,不是项目的源文件。mcpp 在创建或使用它时写入只含一行 `*` 的 `.gitignore`,所以即使项目自己的忽略文件没有列出它,版本控制也会忽略它。 @@ -206,7 +208,7 @@ sysroot = { path = "/opt/acme/sysroot" } # 团队约定的路径,用于仓库 `[xlings] home` 移动 registry,它指向的必须是 mcpp 建立的 home。不是 mcpp 建立的 home(例如 `~/.xlings`)在这里被拒绝: 复用它的包应使用 `upstream`(§3)。 -已经存在的 registry 保留建立它的 home 所记录的索引位置:指向它的另一个 home 读取这些索引和存储,不在其中记录自己的位置(2026.11.10.1)。 +已经存在的 registry 保留建立它的 home 所记录的索引位置:指向它的另一个 home 读取这些索引和存储,不在其中记录自己的位置(2026.11.10.1)。`registry` 是指向另一个 home 的 registry 的链接时,该 home 同样是借用者(2026.11.13.1):索引的位置、上游以及其 `[index.repos]` 表的位置都不写入其中。 ## 6. 文件系统与长路径 diff --git a/docs/zh/35-platforms.md b/docs/zh/35-platforms.md index edaa24fa0..ac444c854 100644 --- a/docs/zh/35-platforms.md +++ b/docs/zh/35-platforms.md @@ -309,11 +309,17 @@ name = "haiku" uname = "Haiku" # `uname -s` 在该系统上的回答 ``` -提供者的 `runner` 是运行器陈述的最低一级:平台的 `runner`、构建程序的、本机 `config.toml` 的、项目的 -`[target.] runner` 与 `MCPP_TARGET__RUNNER` 依次替换它。`hosts` 是该运行器所在的宿主(未写为任一宿主)。 +提供者的 `runner` 是运行器陈述的最低一级:构建程序的、本机 `config.toml` 的、项目的 +`[target.] runner` 与 `MCPP_TARGET__RUNNER` 依次替换它。平台自己的 `runner` 属于 `[platform] targets`, +不替换它(2026.11.13.1):提供者陈述它所服务的身份的运行器、链接(`link`)与基座(`sysroot`),`base = "platform"` 与 +`base = "none"` 相同,因此一个平台可以陈述约定不同的行(openkal 平台的 ok 镜像与 `x86_64-uefi`)。`hosts` 是该运行器所在的宿主(未写为任一宿主)。 模板中 `{}` 是产物,`{sysroot}` 是产物链接时所用的基座,`{pkg:}` 是 `requires` 中该名字的包的根;模板的程序先在 `requires` 的包中查找,再查 `PATH`。`requires` 的包由第一次需要它们的 `mcpp run` 或 `mcpp test` 安装,构建不安装。 +`[platform.tools]` 为构建机器构建,构建只准备它的行所带的工具(2026.11.13.1)。`base = "none"` 的提供者带它自己的链接、 +封装(seal)与运行器所指名的工具;其余的行带全部工具,只由这类提供者指名的工具除外。因此 openkal 平台的 DOS 行 +(`i686-dos-musl`,其封装为 `okdos`)只构建 `okdos`,其 ok 镜像不构建它。 + 一条提供者条目陈述一种载荷安排:原生安排与交叉安排是两条条目,各有自己的 `hosts`、基座与优先级。新增的条目不改变 此前已有回答的请求,所以它以低于现有 `-` 回答的优先级进入 ([SPEC-016 §4](../specs/targets-and-providers.md))。 diff --git a/docs/zh/40-baremetal.md b/docs/zh/40-baremetal.md index cd4307ebd..fbeb395c9 100644 --- a/docs/zh/40-baremetal.md +++ b/docs/zh/40-baremetal.md @@ -476,7 +476,7 @@ mcpp test boots: console boots ... ok (0.02s) - test result ok. 1 passed; 0 failed; finished in 0.58s (build 0.05s + run 0.02s) + test result ok. 1 passed; 0 failed; finished in 0.58s (plan 0.51s · build 0.05s · run 0.02s) ``` 失败的用例会被点名,命令的退出状态非零: @@ -487,7 +487,7 @@ about to fail boots ... ok (0.02s) boots: console -error: test result: FAILED. 1 passed; 1 failed; finished in 0.24s (build 0.04s + run 0.02s) +error: test result: FAILED. 1 passed; 1 failed; finished in 0.24s (plan 0.18s · build 0.04s · run 0.02s) failures: deliberate_fail diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 5996c755d..9c5f2fc3a 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -883,7 +883,9 @@ mcpp build [target] [-p ] [--release] --message-format json | `artifact` | `artifact`:一个产物,形状同下文记录的 `artifacts[]` | 最后一行是 `mcpp.build` 信封,占一行。它的 `data` 是本次运行写下的结果记录(见 -下文);在进入构建目录之前就失败的运行省略 `data`。它的 `diagnostics` 是 mcpp +下文);在进入构建目录之前就失败的运行省略 `data`。重放未变化的树已记录的构建(快速路径, +包的或工作区成员的)时,重述该构建的产物,既作为事件也在 `data` 中(工作区的,2026.11.13.1+)。 +它的 `diagnostics` 是 mcpp 自己的错误,每条按其消息定位(§2),代码为 `MCPP_BUILD_FAILED`;在被拒绝的副作 用之前停下的运行为 `MCPP_EFFECT_DENIED`。叙述写到标准错误,与每一种机器可读模式 相同。 diff --git a/docs/zh/91-toolchain-internals.md b/docs/zh/91-toolchain-internals.md index bb7077571..1b960027e 100644 --- a/docs/zh/91-toolchain-internals.md +++ b/docs/zh/91-toolchain-internals.md @@ -296,11 +296,17 @@ payload 由提供者(xlings)在安装时写入,此后没有任何程序写 (`glibc@2.44.3`,`xlings config --runtime`,只设一次),提供者把工具链链接到该运行库的加载器与库。从上游 home 借来的工具链运行在那个 home 的运行库上;它的产物不变,因为产物的加载器、运行路径与 sysroot 来自构建自己的命令行(§5)。 +payload 指名的加载器可能是 home 指向它的链接(`/abi//`),而不是运行库 payload 本身。这些链接是 home 的状态, +由钉版本建立;不带它们恢复的 store(只缓存 `data/xpkgs` 的 CI 缓存)中的 payload 无法启动。因此,凡取用一个运行在该运行库 +上的 payload——新安装的,或已经就位的——mcpp 都请提供者保持钉版本(`ensure_declared_runtime`),链接缺失时随之重建。 +哪些 payload 运行在运行库上,由 payload 的程序而不是其名字回答(2026.11.13.1):`bin/` 中 `PT_INTERP` 位于其自身 +提供者 home 内的程序。Mach-O 与 PE 程序不指名加载器,因此这一问题在各系统上形式相同。 + **mcpp 在这里仍做的事**(`post_install.cppm`,全部只读): | 函数 | 回答 | |---|---| -| `runtime_payload_kind` | 工具链是否运行在 C 运行库 payload 上:Linux 上的 `gcc`、`llvm`;musl-gcc、系统编译器与 PE 工具链为否 | +| `runtime_payload_kind` | 工具链是否运行在 C 运行库 payload 上:Linux 上的 `gcc`、`llvm` 按其配方;就位之后,任何程序经其 home 的加载器启动的 payload(`starts_through_its_home`,emsdk payload 即在其中);musl-gcc、系统编译器与 PE 工具链为否 | | `select_glibc_payload_lib` | 恰好 `glibc@<版本>` 的库目录(绝不取另一个已安装版本) | | `detect_baked_loader`、`baked_runtime_binding` | payload 绑定的加载器与运行库,读自其 ELF 头 | | `write_clean_link_specs` | 构建目录中每次构建的 `-specs=` 文件(§5) | @@ -459,7 +465,8 @@ loader,任何环境变量都无法覆盖它,因此一个 `popen()`/`system() 3. **能力**(`src/toolchain/provider.cppm`):stdlib 身份、BMI 特性, 以及 `flags.cppm` 消费的 feature 开关。 4. **运行库类别**(`post_install.cppm`):payload 是否运行在 C 运行库 payload 上 - (`runtime_payload_kind`);提供者在安装时把它绑定到钉住的运行库,mcpp 不往其中写入任何东西。 + (`runtime_payload_kind`),就位后由其程序回答;提供者在安装时把它绑定到钉住的运行库,mcpp 不往其中写入任何东西。 + 新的工具链族无需在此登记。 5. **e2e**:按 `86_llvm_hermetic_link.sh` 的思路写一个 hermetic 链接 测试,并纳入无宿主工具链的 CI job。 diff --git a/docs/zh/92-release.md b/docs/zh/92-release.md index 219849d79..dedea25f8 100644 --- a/docs/zh/92-release.md +++ b/docs/zh/92-release.md @@ -45,17 +45,24 @@ POSIX `sh`/dash 解析不了——`sh check_version_pins.sh` 会在第 95 行附 推导 tag)会做完这些: ``` -四平台构建(linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) - → GitHub Release v,含 tarball 与 .sha256 边车文件 +四平台同时构建(linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) + [手动发布且带 `canaries` 时,canaries 与之并行] + → create-release:GitHub Release v,含全部归档与 .sha256 边车文件 → 重新计算每个载荷的哈希并发布不可变 mcpp-release.json → 镜像到 xlings-res/mcpp 的 GitHub 与 GitCode 双端 → 向 openxlings/xim-pkgindex 开版本 bump PR → workflow_run 钩子触发 ci-fresh-install ``` +四个构建同时开始,各自把归档留作 artifact;`create-release` 在每个构建都通过后一次创建带全部归档的 +release,因此不存在缺少某个平台的 release(2026.11.13.1+)。生态 canaries(xlings、mcppls; +`.github/release-canaries.toml`)只在手动发布且带 `canaries` 时用候选版本构建 +(`gh workflow run release.yml -f canaries=true`),此时它们是创建 release 的前提;v2026.11.12.1 的 +109 分钟中它们占 48 分钟,各项目在发布后以自己的 CI 验证该版本。 + ### 2.1 不可变 release manifest -`release-manifest` 会等待四个平台上传 job 全部结束,然后下载最终的非 +`release-manifest` 等待 `create-release` 结束,然后下载最终的非 draft、非 prerelease GitHub Release,重新计算每个带版本平台载荷的 SHA256,校验对应的 `.sha256` 边车文件,并发布 schema 1 的 `mcpp-release.json`: diff --git a/mcpp.toml b/mcpp.toml index dce864943..218ded807 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.11.12.1" +version = "2026.11.13.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] @@ -60,6 +60,10 @@ ldflags = ["--coverage"] [test] windows_code_page = "utf-8" programs = "directory" +# The unit tests, and the end-to-end cases (tests/e2e//*.cpp, one program per +# subsystem); the support package, the scripts' fixtures and the canaries (projects of their own, +# tests/canary) are not tests. +discover = ["tests/**/*.cpp", "!tests/support/**", "!tests/e2e/fixtures/**", "!tests/fixtures/**", "!tests/canary/**"] # Each entry equals its row's Default (SPEC-009 §12): the LLVM line moved to # 23.1.3 — the first release carrying the macOS 27 arm64e.x1 linker fix @@ -93,11 +97,11 @@ linkage = "static" # Eat our own dog food: mcpp uses mcpplibs.cmdline for argument parsing. # TOML-native namespaced form: `mcpplibs.cmdline` is the dotted subtable -# `[dependencies.mcpplibs] cmdline = "0.0.1"` ⇒ identity (mcpplibs, cmdline). +# `[dependencies.mcpplibs] cmdline = "0.0.4"` ⇒ identity (mcpplibs, cmdline). # The bootstrap mcpp used by the self-host build (xlings install mcpp, well # past 0.0.6) understands this, so the legacy quoted form is no longer needed. [dependencies] -mcpplibs.cmdline = "0.0.1" +mcpplibs.cmdline = "0.0.4" # JSON for Modern C++, as the index's module package (no copied header). nlohmann.json = "3.12.0" # The C library's macros (`stdout`, `stderr`, `EOF`, `errno` …) as C++ names: @@ -165,7 +169,9 @@ members = [ # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" +# The support of the end-to-end cases (tests/support, design 2026-11-12 §5.9). +mcpp.test-support = { path = "tests/support" } # Everything else uses M5.0 conventions: # - target = mcpp (kind=bin inferred from src/main.cpp) diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index db89e2db3..1b3a11769 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -41,4 +41,4 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index ca92d5875..d2dcfd013 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -17,4 +17,4 @@ description = "Algorithms over index-addressed directed graphs: topological orde license = "Apache-2.0" [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index 21d52fd13..055901c37 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -30,4 +30,4 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" diff --git a/modules/manifest/src/cfg_selector.cppm b/modules/manifest/src/cfg_selector.cppm index 8ef2cdd31..8bd857b0e 100644 --- a/modules/manifest/src/cfg_selector.cppm +++ b/modules/manifest/src/cfg_selector.cppm @@ -17,7 +17,7 @@ // a bare triple (`x86_64-unknown-linux-gnu`) every component; above any cfg // `os = "…"`, a bareword naming a system the OS and its family // `family = "…"`, `unix` the family -// `arch = "…"`, `env = "…"` that component +// `arch = "…"`, `env = "…"`, `object-format = "…"` that component // `all(a, b, …)` the union of its terms // `any(…)`, `not(…)`, a layer key, `accelerator` no component // @@ -43,15 +43,20 @@ export namespace mcpp::manifest::cfg { // `platform` is answered by the platform selection (SPEC-013 §3), which is // made from the triple and the configuration before resolution, so it is read // in the same pass as the triple's own keys. +// `object-format` is the row's container (`elf`, `pe`, `mach-o`, `wasm`, +// `coff`): what a package selects by when the question is the container's +// conventions, which systems of one container share, rather than a system. inline constexpr std::string_view kCfgTripleKeys[] = { - "arch", "env", "family", "os", "platform", + "arch", "env", "family", "object-format", "os", "platform", }; inline constexpr std::string_view kCfgEarlyLayerKeys[] = { "accelerator", }; +// `interface` is the kernel-abi layer's second answer: an interface its +// provider states it provides (`[kernel-abi] provides-interfaces`). inline constexpr std::string_view kCfgLayerKeys[] = { "c++-abi", "c-abi", "compiler", "compiler-runtime", - "kernel-abi", + "kernel-abi", "interface", }; // A BAREWORD NAMES A SYSTEM, AND THE ROWS ARE ITS VOCABULARY. `unix` is the // one word that names a family (unix | windows, the row's `family`); every @@ -66,7 +71,7 @@ constexpr bool is_bareword(std::string_view word) { // The rank of a selector: the number of target-triple components it fixes, or // `kTripleRank` for a bare triple. Larger is more specific. -inline constexpr int kTripleRank = 5; +inline constexpr int kTripleRank = 6; int specificity(std::string_view selector); // Order conditional tables so that a more specific selector applies later, @@ -82,7 +87,7 @@ namespace mcpp::manifest::cfg { namespace { -constexpr unsigned kArch = 1, kOs = 2, kEnv = 4, kFamily = 8, kPlatform = 16; +constexpr unsigned kArch = 1, kOs = 2, kEnv = 4, kFamily = 8, kPlatform = 16, kFormat = 32; unsigned component_of_key(std::string_view key) { if (key == "arch") return kArch; @@ -92,6 +97,8 @@ unsigned component_of_key(std::string_view key) { // A platform names a class of targets: one component, less specific than // an operating system (two) and than a triple. if (key == "platform") return kPlatform; + // A container is shared by several systems: one component, as a family is. + if (key == "object-format") return kFormat; return 0; // a layer key, `accelerator`, or an unknown key } diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index fee29752e..ae0798d59 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -246,7 +246,7 @@ struct Hosted { // engine's table does not have, with the facts a build of it needs. struct RowSpec { std::string triple; - std::string tier = "preview", note, clangTriple, objectFormat = "elf", family, dataModel, + std::string tier = "preview", note, clangTriple, gccTriple, objectFormat = "elf", family, dataModel, exeSuffix, sharedSuffix, loader, execution; std::string march, mabi, codeModel, multilib, linkEmulation; std::vector flags, descriptorKeys; @@ -373,7 +373,7 @@ inline constexpr std::string_view kPlatformKeys[] = { "define", "provider", "system", "priority", }; inline constexpr std::string_view kDefineKeys[] = { - "triple", "tier", "note", "standard", "exact", "clang-triple", "object-format", "family", + "triple", "tier", "note", "standard", "exact", "clang-triple", "gcc-triple", "object-format", "family", "data-model", "exe-suffix", "shared-suffix", "loader", "execution", "march", "mabi", "code-model", "multilib", "flags", "link-emulation", "descriptor-keys", }; diff --git a/modules/manifest/src/platform_toml.cppm b/modules/manifest/src/platform_toml.cppm index 714de9d84..544f07b64 100644 --- a/modules/manifest/src/platform_toml.cppm +++ b/modules/manifest/src/platform_toml.cppm @@ -584,6 +584,7 @@ std::expected Reader::define(const toml::Table& t, std::st list_of(kDefineKeys))); for (auto [key, to] : std::initializer_list>{ {"triple", &r.triple}, {"tier", &r.tier}, {"note", &r.note}, {"clang-triple", &r.clangTriple}, + {"gcc-triple", &r.gccTriple}, {"object-format", &r.objectFormat}, {"family", &r.family}, {"data-model", &r.dataModel}, {"exe-suffix", &r.exeSuffix}, {"shared-suffix", &r.sharedSuffix}, {"loader", &r.loader}, {"execution", &r.execution}, {"march", &r.march}, {"mabi", &r.mabi}, @@ -611,8 +612,9 @@ std::expected Reader::define(const toml::Table& t, std::st "\"x86_64-freebsd\"", where)); if (r.tier != "verified" && r.tier != "preview" && r.tier != "planned") return std::unexpected(std::format("{}.tier is verified, preview or planned", where)); - if (r.objectFormat != "elf" && r.objectFormat != "mach-o" && r.objectFormat != "pe" && r.objectFormat != "wasm") - return std::unexpected(std::format("{}.object-format is elf, mach-o, pe or wasm", where)); + if (r.objectFormat != "elf" && r.objectFormat != "mach-o" && r.objectFormat != "pe" && r.objectFormat != "wasm" + && r.objectFormat != "coff") + return std::unexpected(std::format("{}.object-format is elf, mach-o, pe, wasm or coff", where)); return r; } diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index b235a3389..fd17bf702 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1248,6 +1248,10 @@ std::expected parse_document(const t::Document& documen if (auto it = tt.find("debug"); it != tt.end() && it->second.is_bool()) { pr.debug = it->second.as_bool(); pr.debugDeclared = true; + } else if (it != tt.end() && it->second.is_string()) { + pr.debugWritten = it->second.as_string(); + pr.debug = pr.debugLines = pr.debugWritten == "lines"; + pr.debugDeclared = true; } if (auto it = tt.find("lto"); it != tt.end() && it->second.is_bool()) { pr.lto = it->second.as_bool(); @@ -3732,10 +3736,12 @@ std::expected parse_document(const t::Document& documen continue; } if (key == "programs") { - if (!value.is_string() || (value.as_string() != "file" && value.as_string() != "directory")) + if (!value.is_string() || (value.as_string() != "file" && value.as_string() != "directory" + && value.as_string() != "package")) return std::unexpected(error(origin, - "[test] programs is \"file\" (a test program per file, the default) or " - "\"directory\" (the files of one directory are one program)")); + "[test] programs is \"file\" (a test program per file, the default), " + "\"directory\" (the files of one directory are one program) or \"package\" " + "(the files the package discovers are one program)")); m.testPrograms = value.as_string(); continue; } @@ -3748,10 +3754,12 @@ std::expected parse_document(const t::Document& documen } if (key == "split") { if (!value.is_string() || (value.as_string() != "file" && value.as_string() != "case" - && value.as_string() != "none")) + && value.as_string() != "none" && value.as_string() != "auto")) return std::unexpected(error(origin, "[test] split is \"file\" (a process per file of a program's cases, the default), " - "\"case\" (a process per case) or \"none\" (one process)")); + "\"case\" (a process per case), \"auto\" (a process per file, and a file the run " + "store measured longer than its share divided into groups of its cases) or \"none\" " + "(one process)")); m.testSplit = value.as_string(); continue; } diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 21453c223..5125dd1d6 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -1222,6 +1222,7 @@ struct BuildConfig : BuildInputs { // Resolved build-profile knobs (from [profile.] + built-in defaults). std::string optLevel = "2"; // -O level bool debug = false; // -g + bool debugLines = false; // -g1: the line tables alone bool lto = false; // -flto, /GL + /LTCG on cl.exe // LTO was requested and some objects of the plan are compiled without it: // those that feed a PE shared library whose exports are discovered, or a @@ -2147,6 +2148,11 @@ struct Hooks { struct Profile { std::string optLevel = "2"; bool debug = false; + // `debug = "lines"` (2026-11-12 §17, W5): debug information, the line + // tables alone. `debugWritten` is a string as the table wrote it, which + // prepare refuses unless it is "lines". + bool debugLines = false; + std::string debugWritten; bool lto = false; bool strip = false; // Which scalars the table WROTE: a profile inherited from the workspace diff --git a/modules/toolchain-model/build.mcpp b/modules/toolchain-model/build.mcpp index 6b79f4586..8a049bba6 100644 --- a/modules/toolchain-model/build.mcpp +++ b/modules/toolchain-model/build.mcpp @@ -16,7 +16,7 @@ namespace { constexpr std::string_view kRowKeys[] = { "triple", "tier", "note", "standard", "exact", "march", "mabi", "code-model", "multilib", "flags", - "link-emulation", "clang-triple", "family", "object-format", "data-model", "exe-suffix", + "link-emulation", "clang-triple", "gcc-triple", "family", "object-format", "data-model", "exe-suffix", "shared-suffix", "loader", "execution", }; constexpr std::string_view kProviderKeys[] = { @@ -25,6 +25,11 @@ constexpr std::string_view kProviderKeys[] = { }; constexpr std::string_view kRunnerKeys[] = { "cmd", "hosts", "requires" }; constexpr std::string_view kSystemKeys[] = { "name", "uname" }; +constexpr std::string_view kPayloadKeys[] = { + "name", "aliases", "family", "serves", "hosts", "frontend-dir", "frontends", "builds-for", "glibc-fixup", "family-version", +}; +constexpr std::string_view kOfferKeys[] = { "name", "hosts" }; +constexpr std::string_view kFamilies[] = { "gcc", "llvm", "msvc" }; constexpr std::string_view kTiers[] = { "verified", "preview", "planned" }; struct Failure { std::string text; }; @@ -117,14 +122,14 @@ std::string row_entry(const toml::Table& t, Arrays& arrays, std::set& seen) { lit(text(t, "uname", "[[platform.system]] " + name))); } +bool flag(const toml::Table& t, std::string_view key, bool fallback, std::string_view where) { + auto it = t.find(key); + if (it == t.end()) return fallback; + if (!it->second.is_bool()) throw Failure{std::format("{}: `{}` is not true or false", where, key)}; + return it->second.as_bool(); +} + +std::string payload_entry(const toml::Table& t, Arrays& arrays) { + known_keys(t, kPayloadKeys, "[[toolchain.payload]]"); + const auto name = text(t, "name", "[[toolchain.payload]]"); + if (name.empty()) throw Failure{"[[toolchain.payload]]: an entry without `name`"}; + const auto where = std::format("[[toolchain.payload]] {}", name); + const auto family = text(t, "family", where); + if (std::ranges::find(kFamilies, family) == std::end(kFamilies)) + throw Failure{std::format("{}: `family` is gcc, llvm or msvc", where)}; + const auto serves = words(t, "serves", where), hosts = words(t, "hosts", where); + if (serves.empty()) throw Failure{std::format("{}: `serves` names no target", where)}; + if (hosts.empty()) throw Failure{std::format("{}: `hosts` names no host", where)}; + return std::format( + " {{ .name = {}, .aliases = {}, .family = {}, .serves = {}, .hosts = {}, .frontendDir = {}, .frontends = {}, " + ".buildsFor = {}, .glibcFixup = {}, .familyVersion = {} }},\n", + lit(name), arrays.add(words(t, "aliases", where)), lit(family), arrays.add(serves), arrays.add(hosts), lit(text(t, "frontend-dir", where)), + arrays.add(words(t, "frontends", where)), lit(text(t, "builds-for", where)), + flag(t, "glibc-fixup", false, where), flag(t, "family-version", true, where)); +} + +std::string offer_entry(const toml::Table& t, Arrays& arrays) { + known_keys(t, kOfferKeys, "[[toolchain.offer]]"); + const auto name = text(t, "name", "[[toolchain.offer]]"); + if (name.empty()) throw Failure{"[[toolchain.offer]]: an entry without `name`"}; + return std::format(" {{ .name = {}, .hosts = {} }},\n", lit(name), + arrays.add(words(t, "hosts", "[[toolchain.offer]] " + name))); +} + const toml::Array& entries(const toml::Document& doc, std::string_view path) { static const toml::Array none; auto* v = doc.get(path); @@ -202,7 +241,7 @@ std::string generate(const toml::Document& doc) { Arrays arrays; std::set rowNames; std::set systemNames; - std::string rows, providers, systems; + std::string rows, providers, systems, payloads, offers; for (auto const& e : entries(doc, "platform.define")) { if (!e.is_table()) throw Failure{"[[platform.define]]: an entry that is not a table"}; rows += row_entry(e.as_table(), arrays, rowNames); @@ -215,6 +254,14 @@ std::string generate(const toml::Document& doc) { if (!e.is_table()) throw Failure{"[[platform.system]]: an entry that is not a table"}; systems += system_entry(e.as_table(), systemNames); } + for (auto const& e : entries(doc, "toolchain.payload")) { + if (!e.is_table()) throw Failure{"[[toolchain.payload]]: an entry that is not a table"}; + payloads += payload_entry(e.as_table(), arrays); + } + for (auto const& e : entries(doc, "toolchain.offer")) { + if (!e.is_table()) throw Failure{"[[toolchain.offer]]: an entry that is not a table"}; + offers += offer_entry(e.as_table(), arrays); + } return std::format( "// Generated by modules/toolchain-model/build.mcpp from data/builtin.toml.\n" "export module mcpp.toolchain.builtin_targets;\n\n" @@ -225,8 +272,10 @@ std::string generate(const toml::Document& doc) { "inline constexpr mcpp::toolchain::target::Row kRows[] = {{\n{}}};\n\n" "inline constexpr mcpp::toolchain::target::Provider kProviders[] = {{\n{}}};\n\n" "inline constexpr mcpp::toolchain::target::System kSystems[] = {{\n{}}};\n\n" + "inline constexpr mcpp::toolchain::target::Payload kPayloads[] = {{\n{}}};\n\n" + "inline constexpr mcpp::toolchain::target::Offer kOffers[] = {{\n{}}};\n\n" "}} // namespace mcpp::toolchain::builtin\n", - arrays.decls, rows, providers, systems); + arrays.decls, rows, providers, systems, payloads, offers); } } // namespace diff --git a/modules/toolchain-model/data/builtin.toml b/modules/toolchain-model/data/builtin.toml index 3bb419047..9161d1ec5 100644 --- a/modules/toolchain-model/data/builtin.toml +++ b/modules/toolchain-model/data/builtin.toml @@ -161,10 +161,11 @@ loader = "/lib/ld-musl-riscv64.so.1" tier = "planned" standard = true -# 32-BIT HOSTED ROWS (E11): registered intent, refused until served. No payload of -# the index targets them, and openkal has no 32-bit class yet (the image -# convention, the loader and the system calls of ELF32 are a design discussion); -# an explicit `[target.] toolchain` opts in early. +# 32-BIT ROWS (E11): registered intent, refused until served. No payload of the +# index targets them. openkal states its 32-bit class (0.25.1, clause 5.5), and +# openkal-platform 0.28.0 serves `i686-linux-musl` and `i686-openkal` with the +# LLVM toolchain; the rows stay planned here until the engine's pin of that +# platform serves them. An explicit `[target.] toolchain` opts in early. [[platform.define]] triple = "i686-linux-musl" loader = "/lib/ld-musl-i386.so.1" @@ -643,3 +644,176 @@ uname = "FreeBSD" # A Windows program is not selected by `uname`: it leads the file. [[platform.system]] name = "windows" + +# ── Toolchain payloads ─────────────────────────────────────────────────────── +# +# A toolchain named by its family (`gcc@16.1.0`, `llvm@23.1.3`) is a package of +# the ecosystem's index, and which package is data (design 2026-11-12 §16): the +# entries of a family are tried in order and the first that serves the target +# on this host is taken. A payload named outright (`xim:@`) needs +# no entry: it describes itself (`.mcpp-toolchain.json`). +# +# Payload keys +# name the package; `{triple}` is the target, `{gcc-triple}` its row's +# GCC triple, `{host-arch}` this machine's architecture. An entry +# whose placeholder has no value does not apply +# aliases other names a request may give it (`emscripten`) +# family gcc | llvm | msvc: the option vocabulary of its compiler +# serves target patterns, as a provider's (`*-linux-musl`); `*` any +# target, `host` a build for this machine +# hosts `-` patterns of the machines it is published for; +# `{arch}` the target's architecture, `!` excludes, the last +# pattern that matches stands +# frontend-dir where its C++ frontend is under the payload root (default +# `bin`); `{ndk-host}` the NDK's directory for this host +# frontends the frontend names, tried in order; `{exe}` the host's +# executable suffix, `{host-musl}` this machine's musl triple +# builds-for the target a request that names the payload builds for +# (`musl-gcc@16.1.0`), and what `toolchain list` reads it as: +# a triple, `host`, `host-musl`; empty: the request decides +# glibc-fixup the glibc payload's install-time specs fixup +# family-version the payload's version is its family's (default true): +# `musl-gcc@16.1.0` is gcc 16.1.0 for its target, and +# `toolchain list` shows it; false where the version is the +# payload's own SDK's (`emsdk@6.0.9`), which a request names +# as the payload +# +# Offer keys (`toolchain list --available`) +# name, hosts a payload and the machines on which it is offered + +[[toolchain.payload]] +name = "emsdk" +aliases = ["emscripten"] +family = "llvm" +serves = ["*-emscripten"] +hosts = ["*"] +frontend-dir = "emscripten" +frontends = ["em++", "emcc"] +builds-for = "wasm32-emscripten" +family-version = false + +[[toolchain.payload]] +name = "android-ndk" +family = "llvm" +serves = ["*-linux-android"] +hosts = ["*"] +frontend-dir = "toolchains/llvm/prebuilt/{ndk-host}/bin" +frontends = ["clang++{exe}", "clang++", "clang"] +family-version = false + +[[toolchain.payload]] +name = "llvm" +family = "llvm" +serves = ["*", "host"] +hosts = ["*"] +frontends = ["clang++{exe}"] +builds-for = "host" + +# A row that states its GCC triple (`i586-pc-msdosdjgpp`) names its cross GCC. +[[toolchain.payload]] +name = "{gcc-triple}-gcc" +family = "gcc" +serves = ["*"] +hosts = ["*"] +frontends = ["{gcc-triple}-g++{exe}", "{gcc-triple}-g++"] + +# musl for this machine's own architecture on Linux; a cross musl GCC otherwise. +[[toolchain.payload]] +name = "musl-gcc" +family = "gcc" +serves = ["*-linux-musl"] +hosts = ["linux-{arch}"] +frontends = ["{triple}-g++{exe}", "{triple}-g++", "g++{exe}", "g++"] +builds-for = "host-musl" + +[[toolchain.payload]] +name = "{triple}-gcc" +family = "gcc" +serves = ["*-linux-musl"] +hosts = ["*"] +frontends = ["{triple}-g++{exe}", "{triple}-g++", "g++{exe}", "g++"] + +# MinGW: Windows' own GCC on Windows (also for a build for the machine itself), +# the cross GCC elsewhere. +[[toolchain.payload]] +name = "mingw-gcc" +aliases = ["mingw"] +family = "gcc" +serves = ["*-windows-gnu", "host"] +hosts = ["windows-*"] +frontends = ["g++{exe}", "g++"] +builds-for = "x86_64-windows-gnu" + +[[toolchain.payload]] +name = "mingw-cross-gcc" +aliases = ["mingw-cross"] +family = "gcc" +serves = ["*-windows-gnu"] +hosts = ["*", "!windows-*"] +frontends = ["x86_64-w64-mingw32-g++"] +builds-for = "x86_64-windows-gnu" + +# The glibc GCC is published for x86_64 Linux and, from 16.1.0, aarch64 when a +# target names glibc; a build for the machine itself on another Linux +# architecture takes the musl GCC, which is published there. +[[toolchain.payload]] +name = "gcc" +family = "gcc" +serves = ["host"] +hosts = ["*", "!linux-*", "linux-x86_64"] +frontends = ["g++"] +builds-for = "host" +glibc-fixup = true + +[[toolchain.payload]] +name = "musl-gcc" +family = "gcc" +serves = ["host"] +hosts = ["linux-*"] +frontends = ["{host-musl}-g++", "g++"] +builds-for = "host-musl" + +[[toolchain.payload]] +name = "gcc" +family = "gcc" +serves = ["*"] +hosts = ["*"] +frontends = ["g++"] +builds-for = "host" +glibc-fixup = true + +[[toolchain.payload]] +name = "msvc" +family = "msvc" +serves = ["*", "host"] +hosts = ["*"] +frontends = ["cl.exe"] +builds-for = "host" + +[[toolchain.offer]] +name = "gcc" +hosts = ["*"] + +[[toolchain.offer]] +name = "musl-gcc" +hosts = ["*"] + +[[toolchain.offer]] +name = "llvm" +hosts = ["*", "!linux-*", "linux-x86_64", "linux-aarch64"] + +[[toolchain.offer]] +name = "msvc" +hosts = ["windows-*"] + +[[toolchain.offer]] +name = "mingw-gcc" +hosts = ["windows-*"] + +[[toolchain.offer]] +name = "{host-arch}-linux-musl-gcc" +hosts = ["windows-*"] + +[[toolchain.offer]] +name = "mingw-cross-gcc" +hosts = ["linux-x86_64"] diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index 097c55ace..757932182 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -29,7 +29,7 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" # build.mcpp compiles data/builtin.toml (the built-in target rows and providers) # into a module of static tables, with mcpp's own TOML parser. diff --git a/modules/toolchain-model/src/dialect.cppm b/modules/toolchain-model/src/dialect.cppm index df48a49f5..2510f0ae1 100644 --- a/modules/toolchain-model/src/dialect.cppm +++ b/modules/toolchain-model/src/dialect.cppm @@ -31,6 +31,10 @@ struct CommandDialect { std::string_view outputObjPrefix; // "-o " | "/Fo:" std::string_view optPrefix; // "-O" | "/O" std::string_view debugFlags; // "-g" | "/Z7" + // `debug = "lines"`: the line tables alone (2026-11-12 §17, W5). `-g1` is + // GCC's minimal level and clang's `-gline-tables-only`; cl.exe has no + // level below /Z7. + std::string_view lineDebugFlags; // "-g1" | "/Z7" std::string_view alwaysFlags; // "" | "/nologo /EHsc /utf-8" // Same rationale as forceCxxLangArgv: the argv consumer gets tokens, not // a string it has to split. Both spans point at static arrays below. @@ -255,6 +259,7 @@ constexpr CommandDialect kGnuDialect{ .outputObjPrefix = "-o ", .optPrefix = "-O", .debugFlags = "-g", + .lineDebugFlags = "-g1", .alwaysFlags = "", .alwaysFlagsArgv = {}, .libFlag = "-l{}", @@ -294,6 +299,7 @@ constexpr CommandDialect kMsvcDialect{ // object that carries its own debug information is also complete on its // own, which the object cache and a taken-over build directory assume. .debugFlags = "/Z7", + .lineDebugFlags = "/Z7", .alwaysFlags = "/nologo /EHsc /utf-8", .alwaysFlagsArgv = kMsvcAlwaysArgv, .libFlag = "{}.lib", diff --git a/modules/toolchain-model/src/fingerprint.cppm b/modules/toolchain-model/src/fingerprint.cppm index 2403f861c..e32fbb8d5 100644 --- a/modules/toolchain-model/src/fingerprint.cppm +++ b/modules/toolchain-model/src/fingerprint.cppm @@ -53,8 +53,40 @@ struct FingerprintInputs { struct Fingerprint { std::string hex; // 16 hex chars std::array parts; // each field's stringified form + // The configuration's lineage (see lineage_text), 16 hex chars: the name of + // its build directory. + std::string lineage; + // The base as the configuration states it: its form and canonical spelling + // (`default`, `path third_party/sysroot`, `package xim:glibc@2.39`). Part of + // the lineage; what the base holds is in parts[10] and evolves. + std::string base; }; +// A CONFIGURATION'S LINEAGE (design 2026-10-31 §8.2, 2026-11-12 §17): the +// fingerprint's parts a developer switches between and keeps side by side -- +// the compiler family, the target, the standard library's family, the language +// standard, the configuration's flags and the base as it is stated (a program +// built against the default base and against a stated sysroot are two +// configurations). The rest evolve with time: the compiler's version and +// identity, the standard library's version, mcpp's version, the std module and +// the runtime contract, which holds what the base contains -- a base rewritten +// in place is the same configuration, built again in its directory. +// +// THE LINEAGE NAMES THE BUILD DIRECTORY, the whole fingerprint does not. The +// commands of the units compiled there name it (an object's path, the link's +// specs), so a name that changed with each mcpp release changed every command +// of the workspace's members and ninja compiled all of them again, although a +// new fingerprint of the lineage already took the old directory over (measured: +// 2026.11.10.2 to 2026.11.12.1, every member and mtest). A directory named by +// its lineage keeps its paths: ninja compiles what a release really changed. +std::string lineage_text(const Fingerprint& fp); +// The compiler's part of the evolution, and the base's: objects and BMIs of +// another compiler build, or compiled against another content of the base +// (its runtime contract), are never reused, so a directory whose record differs +// here is emptied. A base rewritten in place may keep its files' times, which +// no edge of the graph would see. +std::string toolchain_text(const Fingerprint& fp); + std::string hash_file(const std::filesystem::path& p); // returns 16 hex std::string hash_string(std::string_view s); // returns 16 hex Fingerprint compute_fingerprint(const FingerprintInputs& in); @@ -135,7 +167,22 @@ Fingerprint compute_fingerprint(const FingerprintInputs& in) { h = fnv1a_64("\x1f", h); // unit separator } fp.hex = to_hex16(h); + fp.base = tc.base.text.empty() + ? std::string(base::form_name(tc.base.form)) + : std::format("{} {}", base::form_name(tc.base.form), tc.base.text); + fp.lineage = hash_string(lineage_text(fp)); return fp; } +std::string lineage_text(const Fingerprint& fp) { + const auto stdlibFamily = fp.parts[4].substr(0, fp.parts[4].find(' ')); + return std::format("compiler {}\ntarget {}\nstdlib {}\nstandard {}\nconfiguration {}\nbase {}\n", + fp.parts[0], fp.parts[3], stdlibFamily, fp.parts[5], fp.parts[6], fp.base); +} + +std::string toolchain_text(const Fingerprint& fp) { + return std::format("compiler {}\ndriver {}\nstdlib {}\nruntime {}\n", fp.parts[1], fp.parts[2], fp.parts[4], + fp.parts[10]); +} + } // namespace mcpp::toolchain diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 0a076fb77..2a4e3a748 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -240,6 +240,9 @@ struct Toolchain { // GCC/libc++ answer 20; MSVC answers 20 from cl 19.38 (VS 2022 17.8, // microsoft/STL#3977) and 23 below that. int importStdMinLevel = 0; + // Why no std module was found, when the compiler did not answer the + // question (its probe failed); empty when it answered that it has none. + std::string stdModuleUnanswered; // Something about HOW this toolchain was resolved that the user has to be // told, but which is not a failure. Non-empty ⇒ the caller MUST surface it. @@ -506,6 +509,13 @@ struct BmiTraits { BmiTraits bmi_traits(const Toolchain& tc); +// Whether the compiler has C++ named modules: every family mcpp drives does, +// except a GCC before 14, which accepts neither `-fmodules` nor P1689 (DJGPP's +// GCC 12.2.0, measured). A GCC version that does not parse is taken as current. +// Its C++ units that declare and import no module still build, as translation +// units without module flags. +bool compiles_modules(const Toolchain& tc); + } // namespace mcpp::toolchain namespace mcpp::toolchain { @@ -625,6 +635,12 @@ bool target_supports_full_static(std::string_view targetTriple, bool hostCapabil return t->os == "linux" || t->c_library_by_platform(); } +bool compiles_modules(const Toolchain& tc) { + if (tc.compiler != CompilerId::GCC) return true; + const int major = std::atoi(tc.version.c_str()); + return major == 0 || major >= 14; +} + BmiTraits bmi_traits(const Toolchain& tc) { if (tc.compiler == CompilerId::MSVC) { // Native cl.exe builds are gated off until the .ifc pipeline lands; @@ -681,8 +697,8 @@ BmiTraits bmi_traits(const Toolchain& tc) { .needsPrebuiltModulePath = false, .scanNeedsFModules = true, // GCC: -fmodules on every TU; BMIs implicit in cwd/gcm.cache, no - // std=/search flags needed. - .compileModulesFlag = " -fmodules", + // std=/search flags needed. A GCC without modules is given none. + .compileModulesFlag = compiles_modules(tc) ? " -fmodules" : "", .stdBmiUsePrefix = "", .stdCompatBmiUsePrefix = "", .moduleFileUsePrefix = "", diff --git a/modules/toolchain-model/src/payloads.cppm b/modules/toolchain-model/src/payloads.cppm new file mode 100644 index 000000000..3f44158ad --- /dev/null +++ b/modules/toolchain-model/src/payloads.cppm @@ -0,0 +1,224 @@ +// THE TOOLCHAIN PAYLOADS, READ (design 2026-11-12 §16, T3). +// +// data/builtin.toml states which package a family's toolchain is for a target +// on a host (`[[toolchain.payload]]`) and which packages a host is offered +// (`[[toolchain.offer]]`). This module answers the three questions the engine +// asks of those tables -- which payload a request takes, what an installed +// package is, and what a host is offered -- so that the forward and the reverse +// reading cannot disagree and no other code names a payload. + +export module mcpp.toolchain.payloads; + +import std; +import mcpplibs.os; +import mcpp.toolchain.platform; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; +import mcpp.toolchain.builtin_targets; +import mcpp.toolchain.triple; + +export namespace mcpp::toolchain::payloads { + +// The machine a question is asked on, as `-` names it, and the suffix +// of its programs. +struct Host { + std::string os, arch, exe; + std::string pair() const { return os + "-" + arch; } +}; +Host this_host(); + +// A placeholder value the caller supplies (a payload's legacy layout). +using Vars = std::vector>; + +// An entry with its placeholders filled for one request. +struct Resolved { + std::string name, family, frontendDir; + std::vector frontends; + bool glibcFixup = false; +}; + +// The payload a family's toolchain is for `target` (empty: the host's) on +// `host`. `named`, when the request named a payload (`emsdk@6.0.9`), takes that +// payload's first entry whatever it serves. nullopt: no entry applies. +std::optional choose(std::string_view family, std::string_view named, + const triple::Triple& target, const Host& host = this_host(), + const Vars& vars = {}); + +// An installed package read back: its family and the target it builds for +// (empty: the host's). nullopt for a package no entry of a family's version +// names (ninja, glibc, ...). +struct Identity { + std::string payload; // the entry's name + std::string family; + triple::Triple target; + bool familyVersion = true; // the payload's version is its family's +}; +std::optional identify(std::string_view package, const Host& host = this_host()); + +// The entry a request naming `package` (or one of its aliases) means, and the +// family and target it takes (`musl-gcc@16.1.0`, `mingw@16.1.0`, `emsdk@6.0.9`): +// nullopt when no entry has that name. +std::optional named(std::string_view package, const Host& host = this_host()); + +// The packages `toolchain list --available` offers on `host`. +std::vector offered(const Host& host = this_host()); + +// The names a request may give a payload of the table, in the table's order +// (those without a placeholder), for a message that has to list them. +std::vector names(); + +} // namespace mcpp::toolchain::payloads + +namespace mcpp::toolchain::payloads { + +namespace { + +namespace t = mcpp::toolchain::target; + +// `{name}` replaced by its value; nullopt when a placeholder has none, which is +// how an entry that does not apply (`{gcc-triple}` of a row without one) says so. +std::optional fill(std::string_view text, const Vars& vars) { + std::string out; + for (std::size_t i = 0; i < text.size();) { + if (text[i] != '{') { out += text[i++]; continue; } + const auto end = text.find('}', i); + if (end == std::string_view::npos) return std::nullopt; + const auto key = text.substr(i + 1, end - i - 1); + auto it = std::ranges::find_if(vars, [&](auto const& v) { return v.first == key; }); + if (it == vars.end()) return std::nullopt; + out += it->second; + i = end + 1; + } + return out; +} + +Vars vars_for(const triple::Triple& target, const Host& host, const Vars& extra) { + Vars v = extra; + v.emplace_back("exe", host.exe); + v.emplace_back("host-arch", host.arch); + v.emplace_back("host-musl", host.arch + "-linux-musl"); + if (!target.empty()) { + v.emplace_back("triple", target.str()); + if (auto const* r = t::find_row(target.str()); r && !r->gccTriple.empty()) + v.emplace_back("gcc-triple", std::string(r->gccTriple)); + } + return v; +} + +bool serves(const t::Payload& p, const triple::Triple& target) { + for (auto s : p.serves) { + if (s == "host") { if (target.empty()) return true; continue; } + if (target.empty()) continue; + if (s == "*" || t::serves_triple(s, target.str())) return true; + } + return false; +} + +std::optional resolve(const t::Payload& p, const Vars& v) { + auto name = fill(p.name, v); + if (!name) return std::nullopt; + Resolved r{ *name, std::string(p.family), "bin", {}, p.glibcFixup }; + if (!p.frontendDir.empty()) { + auto dir = fill(p.frontendDir, v); + if (!dir) return std::nullopt; + r.frontendDir = *dir; + } + for (auto f : p.frontends) + if (auto name = fill(f, v); name && std::ranges::find(r.frontends, *name) == r.frontends.end()) + r.frontends.push_back(*name); + return r; +} + +// The target an entry's `builds-for` names on `host`; empty: the host's. +std::optional builds_for(std::string_view what, const Host& host) { + if (what.empty()) return std::nullopt; + if (what == "host") return triple::Triple{}; + if (what == "host-musl") return triple::Triple{ host.arch, "linux", "musl" }; + return triple::parse(what); +} + +// `name` matched against an entry's name pattern: the value its one +// placeholder takes, or the empty string for a name without one. +std::optional capture(std::string_view pattern, std::string_view name) { + const auto open = pattern.find('{'); + if (open == std::string_view::npos) + return pattern == name ? std::optional{""} : std::nullopt; + const auto close = pattern.find('}', open); + const auto head = pattern.substr(0, open), tail = pattern.substr(close + 1); + if (name.size() <= head.size() + tail.size() || !name.starts_with(head) || !name.ends_with(tail)) + return std::nullopt; + return std::string(name.substr(head.size(), name.size() - head.size() - tail.size())); +} + +} // namespace + +Host this_host() { + return { std::string(mcpp::toolchain::platform::name), std::string(mcpp::toolchain::platform::host_arch), + std::string(mcpplibs::os::exe_suffix) }; +} + +std::optional choose(std::string_view family, std::string_view named, const triple::Triple& target, + const Host& host, const Vars& extra) { + const auto v = vars_for(target, host, extra); + for (auto const& p : mcpp::toolchain::builtin::kPayloads) { + if (p.family != family) continue; + if (!named.empty() ? p.name != named + : !(serves(p, target) && t::hosts_allow(p.hosts, host.pair(), target.arch))) + continue; + if (auto r = resolve(p, v)) return r; + } + return std::nullopt; +} + +std::optional identify(std::string_view package, const Host& host) { + // A name an entry states whole is read before a pattern's: `musl-gcc` is + // not the cross GCC of a triple called `musl`. + for (bool patterns : { false, true }) + for (auto const& p : mcpp::toolchain::builtin::kPayloads) { + if (!p.familyVersion || (p.name.find('{') != std::string_view::npos) != patterns) continue; + auto value = capture(p.name, package); + if (!value) continue; + if (!patterns) { + if (auto target = builds_for(p.buildsFor, host)) + return Identity{ std::string(p.name), std::string(p.family), *target }; + continue; + } + if (p.name.find("{gcc-triple}") != std::string_view::npos) { + for (auto const* r : t::rows()) + if (r->gccTriple == *value) + if (auto tt = triple::parse(r->triple)) + return Identity{ std::string(package), std::string(p.family), *tt }; + continue; + } + if (auto tt = triple::parse(*value)) return Identity{ std::string(package), std::string(p.family), *tt }; + } + return std::nullopt; +} + +std::optional named(std::string_view package, const Host& host) { + for (auto const& p : mcpp::toolchain::builtin::kPayloads) + if (p.name == package || std::ranges::find(p.aliases, package) != p.aliases.end()) + return Identity{ std::string(p.name), std::string(p.family), + builds_for(p.buildsFor, host).value_or(triple::Triple{}), p.familyVersion }; + return std::nullopt; +} + +std::vector names() { + std::vector out; + for (auto const& p : mcpp::toolchain::builtin::kPayloads) { + if (p.name.find('{') != std::string_view::npos || p.name == p.family) continue; + if (std::ranges::find(out, p.name) == out.end()) out.emplace_back(p.name); + } + return out; +} + +std::vector offered(const Host& host) { + const auto v = vars_for({}, host, {}); + std::vector out; + for (auto const& o : mcpp::toolchain::builtin::kOffers) + if (t::hosts_allow(o.hosts, host.pair(), host.arch)) + if (auto name = fill(o.name, v)) out.push_back(*name); + return out; +} + +} // namespace mcpp::toolchain::payloads diff --git a/modules/toolchain-model/src/target_schema.cppm b/modules/toolchain-model/src/target_schema.cppm index 928cbad1f..af9699f86 100644 --- a/modules/toolchain-model/src/target_schema.cppm +++ b/modules/toolchain-model/src/target_schema.cppm @@ -33,6 +33,11 @@ struct Row { // The facts of a defined row (a platform's). Empty on a built-in row, // whose system's conventions answer them. std::string_view clangTriple; // `{version}` / `{version:}` + // The target a cross GCC payload is built for, in GNU's spelling + // (`i586-pc-msdosdjgpp`): the payload is `-gcc` and its + // frontends are `-gcc` and `-g++`. Empty: the engine's + // conventions for the row's system name the payload. + std::string_view gccTriple; std::string_view objectFormat; // elf | mach-o | pe | wasm std::string_view family; // unix | windows | "" std::string_view dataModel; // lp64 | llp64 | ilp32 @@ -72,6 +77,37 @@ struct Provider { std::span runnerRequires; }; +// A TOOLCHAIN PAYLOAD (design 2026-11-12 §16, T3): the package a family's +// toolchain is for a target on a host, and where its compiler is. The entries of +// a family are tried in order; the first that serves the target on the host is +// taken. `{triple}`, `{gcc-triple}`, `{host-arch}`, `{host-musl}`, `{ndk-host}` +// and `{exe}` are filled where the entry is used; an entry whose placeholder has +// no value does not apply. +struct Payload { + std::string_view name; + std::span aliases; // other names a request may use + std::string_view family; // gcc | llvm | msvc + std::span serves; // `serves` patterns, `*` any, `host` + std::span hosts; // host patterns, `!` excludes, last match wins + std::string_view frontendDir; // under the payload root; empty: bin + std::span frontends; + // The target a request naming this payload builds for (`musl-gcc@16.1.0`): + // a triple, `host`, `host-musl`; empty: the request's. Also what + // `toolchain list` reads an installed payload as. + std::string_view buildsFor; + bool glibcFixup = false; // the glibc payload's install-time specs fixup + // The payload's version is its family's (`musl-gcc@16.1.0` is gcc 16.1.0): + // a request naming it is the family for its target, and `toolchain list` + // shows it. False where the version is the payload's own SDK's (emsdk). + bool familyVersion = true; +}; + +// A payload `toolchain list --available` offers on the hosts it matches. +struct Offer { + std::string_view name; + std::span hosts; +}; + // A SYSTEM (SPEC-016 §2.3): the facts of an operating system that no single // row states. `uname` is the word `uname -s` answers there, which a file that // carries one program per system selects by; a system without it joins no @@ -118,6 +154,19 @@ constexpr bool host_matches(std::string_view pattern, std::string_view host, return os && arch; } +// HOST PATTERNS WITH EXCLUSION: each pattern decides when it matches, the last +// one that matches stands, and `!` before a pattern excludes what it matches +// (`["*", "!windows-*"]`: every host but Windows). +constexpr bool hosts_allow(std::span patterns, std::string_view host, + std::string_view targetArch) { + bool allowed = false; + for (auto p : patterns) { + const bool exclude = p.starts_with('!'); + if (host_matches(exclude ? p.substr(1) : p, host, targetArch)) allowed = !exclude; + } + return allowed; +} + constexpr bool available_on(const Provider& p, std::string_view host, std::string_view targetArch) { for (auto h : p.hosts) if (host_matches(h, host, targetArch)) return true; diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index 9a5f7f1b8..0df62aa1c 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -52,7 +52,9 @@ export namespace mcpp::toolchain::triple { // an operating system to link against" are different axes: a bare-metal // RISC-V image is ELF with no OS, and a wasm module has an OS-like layer // (Emscripten's POSIX emulation) and is not ELF. -enum class ObjectFormat { Elf, MachO, Pe, Wasm }; +// COFF is the object format of a DJGPP program (go32: an MZ stub before a COFF +// image), which no other row produces; it is never a shared object. +enum class ObjectFormat { Elf, MachO, Pe, Wasm, Coff }; std::string_view to_string(ObjectFormat f) { switch (f) { @@ -60,10 +62,24 @@ std::string_view to_string(ObjectFormat f) { case ObjectFormat::MachO: return "Mach-O"; case ObjectFormat::Pe: return "PE"; case ObjectFormat::Wasm: return "wasm"; + case ObjectFormat::Coff: return "COFF"; } return "ELF"; } +// The container's word in row data and in `cfg(object-format = "...")`: the +// spelling a platform writes in `[[platform.define]] object-format`. +std::string_view object_format_word(ObjectFormat f) { + switch (f) { + case ObjectFormat::Elf: return "elf"; + case ObjectFormat::MachO: return "mach-o"; + case ObjectFormat::Pe: return "pe"; + case ObjectFormat::Wasm: return "wasm"; + case ObjectFormat::Coff: return "coff"; + } + return "elf"; +} + struct Triple { std::string arch; // "x86_64" | "aarch64" | "riscv64" | ... (GNU spelling) std::string os; // "linux" | "macos" | "windows" @@ -259,12 +275,18 @@ struct Triple { // fallback would otherwise claim ELF for it -- the silent wrong answer this // whole axis exists to remove. ObjectFormat object_format() const { - if (auto* d = defined()) { - if (d->objectFormat == "pe") return ObjectFormat::Pe; - if (d->objectFormat == "mach-o") return ObjectFormat::MachO; - if (d->objectFormat == "wasm") return ObjectFormat::Wasm; + // A row that states its format -- one a platform defines, or a built-in + // row whose data carries `object-format` -- is taken as written. + auto stated = [](std::string_view f) { + if (f == "pe") return ObjectFormat::Pe; + if (f == "mach-o") return ObjectFormat::MachO; + if (f == "wasm") return ObjectFormat::Wasm; + if (f == "coff") return ObjectFormat::Coff; return ObjectFormat::Elf; - } + }; + if (auto* d = defined()) return stated(d->objectFormat); + if (auto const* r = mcpp::toolchain::target::find_row(str()); r && !r->objectFormat.empty()) + return stated(r->objectFormat); if (os == "windows") return ObjectFormat::Pe; if (os == "macos" || os == "ios") return ObjectFormat::MachO; if (arch.starts_with("wasm")) return ObjectFormat::Wasm; @@ -363,6 +385,22 @@ struct Triple { // is its platform's (SPEC-013). bool loader_supplies_os() const { return is_openkal(); } + // WHETHER THE COMPILER IS TOLD NO OPERATING SYSTEM: a bare machine, an ok + // image, or a row whose compile triple names none (`i686-dos-musl', which + // a platform defines over `i386-unknown-none-elf'). The C environment's + // identity macros are then the C library's alone (`cenv::realise'). + bool compiler_told_no_os() const { + if (is_freestanding() || loader_supplies_os()) return true; + const std::string t = llvm_triple(); + std::size_t first = t.find('-'); + if (first == std::string::npos) return false; + std::size_t second = t.find('-', first + 1); + if (second == std::string::npos) return false; + const std::size_t third = t.find('-', second + 1); + return std::string_view(t).substr(second + 1, third == std::string::npos ? std::string::npos + : third - second - 1) == "none"; + } + // WHETHER THIS ROW'S TOOLCHAIN PIN IS A CAPABILITY RATHER THAN A // CONVENTION — the distinction that decides whether an author may override // it. diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index 64dcffe88..d240f4ffe 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -25,4 +25,4 @@ license = "Apache-2.0" # state its contract in isolation; the root `tests/unit/` states the same facts # as the engine consumes them, and the pair is the cross-check. [dev-dependencies] -mcpplibs.mtest = "0.5.2" +mcpplibs.mtest = "0.8.5" diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 769ff0148..2d95bdbf3 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.11.12.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.11.13.1"; } // namespace mcpp diff --git a/src/build/build_database.cppm b/src/build/build_database.cppm index 751950757..fdde663a0 100644 --- a/src/build/build_database.cppm +++ b/src/build/build_database.cppm @@ -611,7 +611,7 @@ Rendered render(std::span members, for (auto const& g : declared.globs) watch_glob(declared.root, g); } for (auto note : ctx.planNotes) { - // `note.path`, when set, is the absolute path a `plan_only` + // `note.path`, when set, is the absolute path a `PrepareIntent::Describe` // branch recorded (#699 item 2, E3); every other `path` in this // document is relative to the workspace root, and a note's is // rewritten to match before it reaches the command. diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 961c3a371..a521d5f60 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -342,7 +342,7 @@ struct BuildProgramEnv { // A run that asked for a payload (`mcpp:xpkg-request=`) is normally // DISCARDED: the requests are handed back in `xpkgRequests`, nothing else // is applied and nothing is cached, because the engine installs the - // payloads and runs the program again. Under `plan_only` no payload is + // payloads and runs the program again. Under `PrepareIntent::Describe` no payload is // installed, so the run is kept -- applied, reported, and still not cached, // so the next build asks again. bool keepRequestingRun = false; @@ -1478,7 +1478,7 @@ std::expected run_build_program_impl( for (auto const& a : dirs::advisories(m.package.name, d)) mcpp::ui::warning(a); report_stated_diagnostics(m.package.name, d); - // A run kept although it asked for a payload (`plan_only`) is never + // A run kept although it asked for a payload (`PrepareIntent::Describe`) is never // cached: replaying it would keep the build from ever asking again. if (cacheThisRun) { write_cache(bdir, root, programHash, compilerHash, ctxHash, d); diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index fa7324c86..dc1a82003 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -187,6 +187,7 @@ struct BuildAxes { // C std::string optLevel; bool debug = false; + bool debugLines = false; // debug = "lines" bool lto = false; bool strip = false; bool ndebug = false; @@ -364,6 +365,7 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"pic", b.pic}, {"crt", b.crt}, }; + if (b.debugLines) j["profile"]["debug"] = "lines"; j["package"] = { {"index", p.indexName}, {"name", p.packageName}, @@ -409,7 +411,7 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put(s, "minplat", b.minPlatformVersion); // C put(s, "opt", b.optLevel); - put(s, "debug", b.debug ? "1" : "0"); + put(s, "debug", b.debug ? (b.debugLines ? "lines" : "1") : "0"); put(s, "lto", b.lto ? "1" : "0"); put(s, "strip", b.strip ? "1" : "0"); // Only when on, so a key computed before these existed is unchanged. @@ -586,6 +588,7 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, b.optLevel = rootManifest.buildConfig.optLevel; b.debug = rootManifest.buildConfig.debug; + b.debugLines = rootManifest.buildConfig.debugLines; b.lto = rootManifest.buildConfig.lto; b.strip = rootManifest.buildConfig.strip; b.ndebug = rootManifest.buildConfig.ndebug; diff --git a/src/build/dirlock.cppm b/src/build/dirlock.cppm index 17de27a8f..3b1ac826a 100644 --- a/src/build/dirlock.cppm +++ b/src/build/dirlock.cppm @@ -68,6 +68,10 @@ void set_waiting_observer(std::function acquire(const std::filesystem::path& outputDir); +// The time this process waited for build directories another command held: +// part of `Finished`'s account (2026-11-12 §17, W3). +std::chrono::milliseconds waited(); + } // namespace mcpp::build::dirlock namespace mcpp::build::dirlock { @@ -81,6 +85,7 @@ std::function& observer_slot() { static std::function observer; return observer; } +std::atomic g_waitedMs{0}; long current_pid() { return mcpp::invocation::process_id(); } std::int64_t now_seconds() { return std::chrono::duration_cast( @@ -158,9 +163,11 @@ std::shared_ptr acquire(const std::filesystem::path& outpu std::lock_guard heldLock(g_heldMutex); if (auto existing = held()[key].lock()) return existing; const auto start = std::chrono::steady_clock::now(); + bool waiting = false; auto lock = mcpp::home::FileLock::acquire( outputDir, kLockName, owner_record(), std::chrono::milliseconds(300), [&](std::string_view record) { + waiting = true; const auto holder = parse_holder(record); mcpp::ui::status("Waiting", std::format("for the build directory, held by {}", describe(holder, now_seconds()))); @@ -168,10 +175,15 @@ std::shared_ptr acquire(const std::filesystem::path& outpu observe(holder, std::chrono::duration_cast( std::chrono::steady_clock::now() - start)); }); + if (waiting) + g_waitedMs += std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); if (!lock) return nullptr; auto shared = std::make_shared(std::move(*lock)); held()[key] = shared; return shared; } +std::chrono::milliseconds waited() { return std::chrono::milliseconds(g_waitedMs.load()); } + } // namespace mcpp::build::dirlock diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 05eb4894f..64073896c 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -17,6 +17,7 @@ import mcpp.pack.distribution; // its writer and reader import mcpp.build.test_targets; import mcpp.build.test_protocol; // the runner's side of the test protocol (docs/08 "Cases") import mcpp.build.test_impact; // the tests a change affects (docs/08) +import mcpp.build.test_schedule; // the order, division and shards of a run (docs/08) import mcpp.diag; import mcpp.build.plan; import mcpp.toolchain.triple; @@ -34,6 +35,7 @@ import mcpp.build.graph_shape; // #407: which mode wrote this build.ninja import mcpp.build.backend; import mcpp.build.ninja; import mcpp.build.dirlock; // the build directory's lock (mcpp#839) +import mcpp.build.graph_engine; // the engine a graph names (2026-11-12 §17) import mcpplibs.os.process; // the identity of this invocation (its protocol directory) import mcpp.build.result; // the result record and the event stream (design 2026-10-26 §5) import mcpp.wire; // the mcpp.launch envelope @@ -92,7 +94,7 @@ namespace libc = mcpplibs::mwrap::libc::macros; // legs//programs/ the package's own programs, which a // test finds as MCPP_BIN_ (L5) // -// `mcpp-tests.json` (schema 1): `package`, `cwd` (the directory the tests run +// `mcpp-tests.json` (schema 1, or 2 with `named_programs`): `package`, `cwd` (the directory the tests run // in, relative to the project root: the package's own), and `legs`, each with // its `triple`, `programs` (environment name to path) and `tests` (`name`, // `program`, the `files` beside it, the test `protocol` it implements, 0 for @@ -100,7 +102,12 @@ namespace libc = mcpplibs::mwrap::libc::macros; // an archive is run by the same runner as a build's tests). Paths are relative to the archive's directory and use `/`. export namespace mcpp::build::test_archive { +// Schema 2 is an archive whose legs name programs it does not carry +// (`named_programs`, `--no-programs`): an mcpp that reads only schema 1 refuses +// it, rather than running its tests without the programs they test. Every +// other archive is written as schema 1, and both are read. inline constexpr int kSchema = 1; +inline constexpr int kSchemaNamed = 2; inline constexpr std::string_view kManifestName = "mcpp-tests.json"; struct Test { @@ -109,11 +116,15 @@ struct Test { std::vector files; // beside it, relative to the archive's directory int protocol = 0; int ownFiles = 1; // the files that are the test's own + std::string partRoot; // the directory its parts are named below, as built }; struct Leg { std::string triple; std::vector> programs; // MCPP_BIN_ -> path + // The package's programs the archive does not carry (`--no-programs`): + // their MCPP_BIN_, which the environment of the run names. + std::vector named; std::vector tests; }; @@ -175,18 +186,23 @@ nlohmann::json to_json(const Manifest& m) { nlohmann::json tests = nlohmann::json::array(); for (auto const& t : l.tests) tests.push_back({{"name", t.name}, {"program", t.program}, {"files", t.files}, - {"protocol", t.protocol}, {"own_files", t.ownFiles}}); - legs.push_back({{"triple", l.triple}, {"programs", std::move(programs)}, - {"tests", std::move(tests)}}); + {"protocol", t.protocol}, {"own_files", t.ownFiles}, + {"part_root", t.partRoot}}); + nlohmann::json leg = {{"triple", l.triple}, {"programs", std::move(programs)}, + {"tests", std::move(tests)}}; + if (!l.named.empty()) leg["named_programs"] = l.named; + legs.push_back(std::move(leg)); } - return {{"schema", kSchema}, {"mcpp", std::string(mcpp::MCPP_VERSION)}, + const bool named = std::ranges::any_of(m.legs, [](auto const& l) { return !l.named.empty(); }); + return {{"schema", named ? kSchemaNamed : kSchema}, {"mcpp", std::string(mcpp::MCPP_VERSION)}, {"package", m.package}, {"cwd", m.cwd}, {"legs", std::move(legs)}}; } std::expected from_json(const nlohmann::json& d) { - if (!d.is_object() || d.value("schema", 0) != kSchema) - return std::unexpected(std::format("{} is not a schema-{} test archive manifest", - kManifestName, kSchema)); + const int schema = d.is_object() ? d.value("schema", 0) : 0; + if (schema != kSchema && schema != kSchemaNamed) + return std::unexpected(std::format("{} is not a schema-{} or schema-{} test archive manifest", + kManifestName, kSchema, kSchemaNamed)); Manifest m; m.package = d.value("package", ""); m.cwd = d.value("cwd", ""); @@ -198,10 +214,12 @@ std::expected from_json(const nlohmann::json& d) { if (jl.contains("programs") && jl["programs"].is_object()) for (auto it = jl["programs"].begin(); it != jl["programs"].end(); ++it) l.programs.emplace_back(it.key(), it.value().get()); + if (jl.contains("named_programs") && jl["named_programs"].is_array()) + for (auto const& n : jl["named_programs"]) l.named.push_back(n.get()); if (jl.contains("tests") && jl["tests"].is_array()) for (auto const& jt : jl["tests"]) { Test t{jt.value("name", ""), jt.value("program", ""), {}, jt.value("protocol", 0), - jt.value("own_files", 1)}; + jt.value("own_files", 1), jt.value("part_root", std::string{})}; if (jt.contains("files") && jt["files"].is_array()) for (auto const& f : jt["files"]) t.files.push_back(f.get()); l.tests.push_back(std::move(t)); @@ -1172,20 +1190,23 @@ runtime_files_for(const mcpp::build::BuildContext& ctx, return out; } -// Writes the list for `artifact` under the output tree and returns its path. -// `carried` is empty for a distributable, which holds its own files. +// Writes the list for `artifact` under the tree that holds it -- the output +// tree, or `tree` when the caller names another (the directory an archive's +// tests are run from) -- and returns its path. `carried` is empty for a +// distributable, which holds its own files. std::expected write_runtime_files_list( const mcpp::build::BuildContext& ctx, const std::filesystem::path& artifact, - const std::vector>& carried) { + const std::vector>& carried, + std::filesystem::path tree = {}) { namespace fs = std::filesystem; std::error_code ec; - auto rel = artifact.lexically_normal().lexically_relative( - ctx.outputDir.lexically_normal()); + if (tree.empty()) tree = ctx.outputDir; + auto rel = artifact.lexically_normal().lexically_relative(tree.lexically_normal()); if (rel.empty() || *rel.begin() == "..") rel = fs::path("distributable") / artifact.filename(); - auto listPath = ctx.outputDir / ".mcpp-runtime-files" / rel; + auto listPath = tree / ".mcpp-runtime-files" / rel; listPath += ".tsv"; fs::create_directories(listPath.parent_path(), ec); std::ofstream os(listPath, std::ios::binary | std::ios::trunc); @@ -1223,12 +1244,13 @@ std::expected write_run_closure(const mcpp::build::BuildContext& ctx, const std::filesystem::path& artifact, const std::vector>& carried, - std::string_view mode, std::string_view triple) { + std::string_view mode, std::string_view triple, std::filesystem::path tree = {}) { namespace fs = std::filesystem; std::error_code ec; - auto rel = artifact.lexically_normal().lexically_relative(ctx.outputDir.lexically_normal()); + if (tree.empty()) tree = ctx.outputDir; + auto rel = artifact.lexically_normal().lexically_relative(tree.lexically_normal()); if (rel.empty() || *rel.begin() == "..") rel = fs::path("distributable") / artifact.filename(); - auto path = ctx.outputDir / ".mcpp-runtime-files" / rel; + auto path = tree / ".mcpp-runtime-files" / rel; path += ".closure.json"; fs::create_directories(path.parent_path(), ec); auto quote = [](std::string_view s) { @@ -1281,6 +1303,27 @@ run_contract_env(const mcpp::build::BuildContext& ctx, const std::filesystem::pa return env; } +// THE HAND-OFF TO A RUNNER, of a test however it was built: the list of the +// files the program carries (MCPP_RUNTIME_FILES) and the run contract +// (MCPP_RUN_*, the closure written beside the list). Only a program run through +// a runner or `run_under` is handed them; one run directly receives neither, +// and passes neither on to the programs it runs. The test run of a build and +// the run of an archive's tests both take it from here: two copies of it had +// differed in which programs received the list (e2e 672, 2026-11-14). +std::expected>, std::string> +runner_handoff_env(const mcpp::build::BuildContext& ctx, const std::filesystem::path& artifact, + const std::vector>& carried, + std::string_view mode, std::string_view triple, const std::filesystem::path& tree = {}) { + std::vector> env; + auto listed = write_runtime_files_list(ctx, artifact, carried, tree); + if (!listed) return std::unexpected(listed.error()); + env.emplace_back(std::string(kRuntimeFilesEnv), listed->string()); + auto closure = write_run_closure(ctx, artifact, carried, mode, triple, tree); + if (!closure) return std::unexpected(closure.error()); + for (auto& kv : run_contract_env(ctx, artifact, mode, triple, *closure)) env.push_back(std::move(kv)); + return env; +} + // `run_under`, its program located as a runner's is (the payloads the project // declared, then PATH). An empty result with an empty error is "this // machine". @@ -1310,6 +1353,13 @@ std::optional platform_tool(const mcpp::build::BuildConte // "Invalid ELF image for this architecture". `build = false` locates it // without building it (`mcpp self env`). nullopt: the word is no platform // tool, and is looked up as any runner's program is. +// +// A preparation that provisions no tool -- the run of an archive +// (PrepareIntent::RunBuilt) -- has not located the platform's tool for this +// machine either. Its runner is then the platform's runner for this machine's +// architecture, the same program, made the way another architecture's is +// (under the checkout's target/carrier-pack/). Any other preparation that did +// not locate the tool reports the runner not found, as before. struct RunnerTool { std::filesystem::path program; std::string arch; // the architecture it is built for @@ -1318,16 +1368,22 @@ std::expected, std::string> runner_tool(const mcpp::build::BuildContext& ctx, std::string_view word, std::string_view tripleKey, bool noRunner, bool build = true) { auto tool = platform_tool(ctx, word); - if (!tool) return std::nullopt; + const bool provisionedNone = ctx.intent == mcpp::build::PrepareIntent::RunBuilt; + if (!tool && !(provisionedNone && ctx.plan.platform.fields.tools.contains(std::string(word)))) + return std::nullopt; auto t = mcpp::toolchain::triple::parse(tripleKey); + std::string arch = t ? std::string(t->arch) : std::string{}; if (!t || mcpp::platform::runner_reach(ctx.plan.platform.fields.runner, t->arch, mcpp::toolchain::platform::host_arch, !ctx.runUnder.empty(), noRunner) - != mcpp::platform::RunnerReach::TargetArchitecture) - return RunnerTool{ *tool, std::string(mcpp::toolchain::platform::host_arch) }; - auto program = mcpp::pack::runner_program(ctx.plan.platform, ctx.projectRoot, word, t->arch, build); + != mcpp::platform::RunnerReach::TargetArchitecture) { + if (tool) return RunnerTool{ *tool, std::string(mcpp::toolchain::platform::host_arch) }; + if (!t) return std::nullopt; + arch = mcpp::toolchain::platform::host_arch; + } + auto program = mcpp::pack::runner_program(ctx.plan.platform, ctx.projectRoot, word, arch, build); if (!program) return std::unexpected(program.error().message); - return RunnerTool{ std::move(*program), t->arch }; + return RunnerTool{ std::move(*program), arch }; } ExecutionHost locate_run_under(const mcpp::build::BuildContext& ctx, std::string_view tripleKey) { @@ -1867,8 +1923,26 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, // which is exactly what `--no-cache` has always done, hence the alias. const bool coldBuild = no_cache || ctx.cacheMode == CacheMode::Off; if (coldBuild) { + // Everything but the directory's records and its lock: the records + // (written by the plan, for this configuration) are what the next build + // reads to tell that the compiler or the base changed, and without them + // it would reuse what another compiler built in this directory. std::error_code ec; - std::filesystem::remove_all(ctx.outputDir, ec); + const std::string lock(mcpp::build::dirlock::kLockName); + for (auto const& e : std::filesystem::directory_iterator(ctx.outputDir, ec)) { + const auto name = e.path().filename().string(); + if (name == "lineage" || name == "toolchain" || name == "fingerprint" || name == "uses.list" + || name == lock || name == lock + ".owner") + continue; + std::error_code rec; + std::filesystem::remove_all(e.path(), rec); + } + // The graph names the directory's link to this engine (design §17, + // W1), which the line above removed with everything else: made again, + // as the link spec below is, or every step of a cold build fails on a + // `mcpp` that is not there. + if (!ctx.plan.engine.empty()) + ctx.plan.engine = mcpp::build::graph_engine::place(ctx.plan.engine); } // Said before the build rather than as a link that fails for want of // space (mcpp#874: `final link failed: No space left on device`). The @@ -1958,7 +2032,7 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, // Populate the global cache for deps that did NOT hit. mcpp::build::populate_dependency_cache(ctx); - // P1.5: warn if fingerprint changed from last build (explains full rebuild). + // P1.5: warn if the configuration changed from the last build (explains a full build). // Compared against the entry for the SAME profile: the profile is now a // fingerprint input, so a dev↔release switch always changes the fp. That is // exactly what the user asked for, and warning about it turns a useful @@ -1971,12 +2045,36 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, && e.cacheMode == cache_mode_name(ctx.cacheMode) && e.selection == ctx.workspaceRequest && e.group == ctx.workspaceGroup && !e.fingerprint.empty()) { - auto newFp = ctx.outputDir.filename().string(); - if (e.fingerprint != newFp) { + // A directory is a configuration's lineage (2026-11-12 §17): a + // new mcpp or compiler continues in it, and only a changed + // configuration builds in another. One that a build took over + // is gone, and nothing was rebuilt in full. + const std::filesystem::path was(e.outputDir); + std::error_code wec; + if (was != ctx.outputDir && std::filesystem::exists(was / "lineage", wec)) { + auto lines = [](const std::filesystem::path& f) { + std::ifstream in(f); + std::vector out; + for (std::string l; std::getline(in, l);) out.push_back(l); + return out; + }; + const auto before = lines(was / "lineage"), after = lines(ctx.outputDir / "lineage"); + std::string what; + for (std::size_t i = 0; i < before.size() && i < after.size(); ++i) + if (before[i] != after[i]) { + const auto sp = after[i].find(' '); + what += std::format("{}{} {} → {}", what.empty() ? "" : ", ", + after[i].substr(0, sp), before[i].substr(sp + 1), + after[i].substr(sp + 1)); + } + // The size of the directory left beside it (mcpp#927, 2026-11-12 + // §18 P6): what the reader decides about, with the command. mcpp::ui::warning(std::format( - "fingerprint changed ({} → {}), full rebuild; " - "`mcpp clean --stale` drops the directories no build still uses", - e.fingerprint, newFp)); + "the configuration changed{}: {} is built in full beside {} ({}); " + "`mcpp clean --stale` drops it once no build uses it", + what.empty() ? std::string{} : " (" + what + ")", + ctx.outputDir.filename().string(), was.filename().string(), + mcpp::bmi_cache::human_bytes(mcpp::bmi_cache::dir_size(was)))); } break; } @@ -2743,7 +2841,7 @@ admit_recorded_build(const BuildCacheEntry& e, const ReplayAsk& ask) { // only of the record: another engine's `--configure-only` rewrites the // graph and leaves the record that names this one. if (const auto named = mcpp::build::read_engine_binding(ninjaPath); - !named.empty() && named != mcpp_exe_path().generic_string()) + !named.empty() && !mcpp::build::graph_engine::is_this_engine(named)) return std::unexpected(std::format("build.ninja runs another engine ({})", named)); // #407. Freshness is measured against the SOURCES, which says nothing @@ -3024,12 +3122,20 @@ export std::optional try_fast_workspace_build( for (auto& r : ready) { if (ready.size() == 1) restore_root_compile_commands(wsRoot, r.outputDir, r.rootCompileDb); std::chrono::milliseconds elapsed{}; + // The group's record, restated as the single project's fast path + // restates it: `--message-format json` reports the artifacts of a + // workspace replayed as it reports those of a package (docs/50 §9). + auto record = fast_path_begin(wsRoot, r.outputDir); auto rc = run_ninja_fast(r.ninjaProgram, r.outputDir, r.outputDir / "build.ninja", verbose, r.runtimeEnvKey, r.runtimeEnvValue, &elapsed); if (!rc) return fast_path_declined("workspace", "ninja reported a stale graph"); - if (*rc != 0) return rc; + if (*rc != 0) { + fast_path_end(record, false); + return rc; + } if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged(r.validated)) return fast_path_declined("workspace", "ninja relinked an artifact, whose closure the full path validates"); + fast_path_end(record, true); mcpp::build::progress::note_sources(r.outputDir); } // The groups share the profile, and so its descriptor. @@ -3984,7 +4090,9 @@ export int build_run_target(const std::optional& targetName, export enum class TestMessageFormat { Human, Json }; export struct TestOptions { - std::string filter; // substring match on the path-based test name; empty = all + // The patterns a test is selected by: one that is a substring of its path-based name, or of a + // part of it, selects it; none selects every test. + std::vector filters; // `--since ` and `--changed `: only the tests the change // affects (docs/08 "The tests a change affects"). std::string since; @@ -4012,6 +4120,12 @@ export struct TestOptions { // `--no-run` makes the narrower claim available as its own answer: every // selected test compiled and linked for the target, and none was executed. bool noRun = false; + // `--no-programs`: the package's programs are planned for their names and + // not built; the tests take each from the environment's MCPP_BIN_, + // of this run or, with `--no-run --archive`, of the archive's run (the + // archive does not carry them). For programs built elsewhere -- a CI job + // that tests the binary another job built. + bool noPrograms = false; // A target this machine's runner cannot reach -- a `same-arch` runner on // another architecture with no execution host named -- is reported built // and not run, as `--no-run` reports, rather than not run: `mcpp test` in @@ -4055,6 +4169,13 @@ export struct TestOptions { // write a test (docs/08 "Writing a test"). Plans; builds and runs nothing. bool guide = false; std::optional seed; + // `--shard I/N`: of the run's tests, assigned to N shards longest first by + // the durations the store measured, the I-th's (docs/08 "The order of a + // run"). Every shard reads one store and one list, so the N shards run + // every test once. + std::optional> shard; + // `--split MODE`: the invocation's `[test] split` (file, case, auto, none). + std::string split; }; // What one member's `run_tests` actually did. `--workspace` fans out over @@ -4080,6 +4201,9 @@ export struct TestRunSummary { long long buildMs = 0; long long runMs = 0; // the test binaries' own execution long long elapsedMs = 0; // wall clock for the whole member + // The package's programs, built before its tests (MCPP_BIN_), whose + // planning and build the summary states apart (mcpp#927, §18 P2). + long long programsMs = 0; bool packageError = false; // Phase A failed: no test ever ran // The configuration group whose one build this member's tests waited for, // when `mcpp test` planned several members together; -1 for a member @@ -4173,6 +4297,7 @@ struct TestResult { long long startedMs = -1; int worker = -1; std::vector select; + bool alone = false; // it held the machine (P7.1) // The program and the files it is compiled from, for a test built and not // run (`--no-run`): what a runner that selects by change reads (docs/50). std::string program; @@ -4190,7 +4315,7 @@ struct TestResult { // engine's own. Written whole into `runs//` and then appended to the index. void write_mtest_store(const std::filesystem::path& dir, const std::vector& results, std::chrono::system_clock::time_point started, long long ms, int jobs, - std::string_view selection) { + std::string_view selection, std::string_view shard = {}) { namespace fs = std::filesystem; using nlohmann::json; const auto finished = std::chrono::system_clock::now(); @@ -4224,8 +4349,10 @@ void write_mtest_store(const std::filesystem::path& dir, const std::vector 0) p["build_ms"] = t.buildMs; + // The program a part is of: what `--failed` builds again. + if (!t.partOf.empty()) p["part_of"] = t.partOf; if (t.report.usage.set) { auto u = mcpp::build::test_protocol::usage_json(t.report.usage); u["source"] = "program"; @@ -4277,6 +4404,7 @@ void write_mtest_store(const std::filesystem::path& dir, const std::vector/'), as the run names them. -static bool test_matches(const LinkUnit& lu, std::string_view filter) { - if (filter.empty() || lu.targetName.find(filter) != std::string::npos) return true; - return std::ranges::any_of(lu.ownSources, [&](const std::filesystem::path& f) { - return std::format("{}/{}", lu.targetName, f.stem().generic_string()).find(filter) != std::string::npos; +// +// A PART'S NAME is its file's path below the program's directory, the common +// directory of the program's own files, without the extension: the stem in a +// program of one directory, and `unit/b` for `tests/unit/b.cpp` in a package's +// program, where two directories may each hold a `b.cpp` (2026.11.13.1+). +static std::filesystem::path part_root(const std::vector& own) { + if (own.size() < 2) return {}; + std::filesystem::path root = own.front().parent_path(); + for (auto const& f : own) { + const auto dir = f.parent_path(); + std::filesystem::path common; + for (auto a = root.begin(), b = dir.begin(); a != root.end() && b != dir.end() && *a == *b; ++a, ++b) + common /= *a; + root = std::move(common); + } + return root; +} + +static std::string part_label(const std::filesystem::path& root, const std::filesystem::path& file) { + if (!root.empty()) { + auto below = file.lexically_relative(root); + if (!below.empty() && *below.begin() != "..") return below.replace_extension().generic_string(); + } + return file.stem().generic_string(); +} + +static bool test_matches(const LinkUnit& lu, const std::vector& filters) { + if (filters.empty()) return true; + const auto root = part_root(lu.ownSources); + return std::ranges::any_of(filters, [&](const std::string& filter) { + if (lu.targetName.find(filter) != std::string::npos) return true; + return std::ranges::any_of(lu.ownSources, [&](const std::filesystem::path& f) { + return std::format("{}/{}", lu.targetName, part_label(root, f)).find(filter) != std::string::npos; + }); }); } +// Whether `name` is selected: one of the patterns is a substring of it, or there is none. +static bool selected_by(const std::vector& filters, std::string_view name) { + return filters.empty() || std::ranges::any_of(filters, [&](const std::string& f) { return name.find(f) != std::string_view::npos; }); +} + +// The patterns as a command line wrote them, for a message and the store's record. +static std::string patterns_text(const std::vector& filters) { + std::string out; + for (const auto& f : filters) out += (out.empty() ? "" : " ") + f; + return out; +} + // A TEST PROGRAM TO RUN: built by this command, or read from an archive. struct TestRunnable { std::string name; @@ -4467,8 +4637,20 @@ struct TestRunnable { // The files that are the test's own: a program of one file has no other // file to run its cases in, and is not listed to be split by file. std::size_t ownFiles = 1; - // The program a part belongs to, when the run divides it (`[test] split`). + // The program a part belongs to, when the run divides it (`[test] split`), + // and the directory its parts are named below (`part_label`). std::string partOf; + std::filesystem::path partRoot; + // Its cases hold the machine (P7.1 `machine`): it runs with no other test + // beside it. + bool alone = false; + // The duration the store's runs measured for it (-1: not yet estimated). + double estimateMs = -1; + // The seconds its cases state they may run, together (a case's + // `timeout`, listed): the limit of its process is the larger of this and + // `--timeout`, so that a test that states its length is not ended at the + // limit the rest are held to (P-limit, docs/08). + double declaredSecs = 0; }; // THE PROTOCOL A TEST PROGRAM IMPLEMENTS (P2.3): what its entry states, else @@ -4855,7 +5037,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, return lu.kind == mcpp::build::LinkUnit::TestBinary && (owner.empty() || lu.memberOf == owner) && (!tb.affected || tb.affected->contains(lu.targetName)) - && test_matches(lu, testOpts.filter); + && test_matches(lu, testOpts.filters); }; std::vector results; auto emit_json = [&](const TestResult& r) { @@ -4921,6 +5103,20 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, return j > 0 ? j : 1; }(); + // THE DURATIONS EARLIER RUNS MEASURED (docs/08 "The order of a run"): the + // run store, read once before anything runs. A run orders its tests + // longest first by them, `[test] split = "auto"` divides a part longer + // than its share, and `--shard` assigns the tests to shards. Measured on + // the e2e programs (design 2026-11-14): one file of two cases ran 807 s at + // the end of a 1931 s run while the other workers stood idle. + const auto storeDir = ctx->manifest.testStore.empty() ? ctx->projectRoot / "target" / "mtest" + : ctx->projectRoot / ctx->manifest.testStore; + const auto history = ctx->manifest.testStore == "none" + ? mcpp::build::test_schedule::History{} + : mcpp::build::test_schedule::read_history( + storeDir, 30, testOpts.shard ? std::optional(mcpp::invocation::get("GITHUB_RUN_ID").value_or("")) + : std::nullopt); + using Runnable = TestRunnable; std::vector runnable; @@ -4954,11 +5150,51 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, o.unset.emplace_back(v); for (auto& kv : stated) o.env.push_back(std::move(kv)); }; + const bool exclusiveDevice = (ctx->manifest.buildConfig.runExclusive + || ctx->plan.platform.fields.runner.exclusive) + && !runnerChoice.tmpl.empty(); auto run_tests_now = [&](std::vector& list) { if (list.empty()) return; + // Each test's measured duration: of the cases it selects, or of the + // program whole. + namespace ts = mcpp::build::test_schedule; + for (auto& r : list) { + if (r.estimateMs >= 0) continue; + std::vector ids; + if (r.select) { + std::ifstream in(*r.select); + for (std::string line; std::getline(in, line);) + if (!line.empty()) ids.push_back(line); + } + r.estimateMs = ts::estimate(history, r.name, ids); + } + // THE SHARD: every test assigned, longest first, to the shard with the + // least work; this command runs its own. The assignment reads only the + // store and the list, so each shard computes the same one. + if (testOpts.shard) { + const auto [index, count] = *testOpts.shard; + std::vector est; + std::vector names; + for (const auto& r : list) { est.push_back(r.estimateMs); names.push_back(r.name); } + const auto bin = ts::assign(est, names, static_cast(count)); + std::vector mine; + for (std::size_t i = 0; i < list.size(); ++i) + if (bin[i] == static_cast(index - 1)) mine.push_back(std::move(list[i])); + if (!json) + mcpp::ui::info("Shard", std::format("{}/{}: {} of {} test(s){}", index, count, mine.size(), + list.size(), history.empty() ? ", no measured run in the store" : "")); + list = std::move(mine); + if (list.empty()) return; + } + // The longest first, so that the longest work does not start last. + std::ranges::stable_sort(list, std::greater{}, &Runnable::estimateMs); const bool capture = json || list.size() > 1; - const auto deadline = std::chrono::milliseconds( - static_cast(testOpts.timeoutSecs) * 1000); + // The limit of one test's process: `--timeout`, or what its cases + // state when that is longer; `--timeout 0` disables both. + const auto limit_of = [&](const Runnable& r) { + if (testOpts.timeoutSecs <= 0) return 0.0; + return (std::max)(static_cast(testOpts.timeoutSecs), r.declaredSecs); + }; // A PHYSICAL BOARD IS A MUTEX, AND NOTHING ELSE THIS POOL HAS EVER // SCHEDULED WAS ONE. // @@ -4972,9 +5208,6 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // Clamped rather than made an error: an exclusive target with one test // is an ordinary run, and refusing it would turn a correct // configuration into a failure. - const bool exclusiveDevice = (ctx->manifest.buildConfig.runExclusive - || ctx->plan.platform.fields.runner.exclusive) - && !runnerChoice.tmpl.empty(); const int runJobsHere = exclusiveDevice ? 1 : runJobs; if (exclusiveDevice && runJobs > 1 && list.size() > 1) { mcpp::ui::info("note", std::format( @@ -4984,6 +5217,13 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, const int workers = capture ? std::min(runJobsHere, static_cast(list.size())) : 1; + // A test that holds the machine (P7.1) runs with no other beside it: + // those tests are queued last and take the gate whole, every other test + // shares it. Queued last, no test that shares the gate starts after one + // that waits for it. + std::ranges::stable_partition(list, [](const Runnable& r) { return !r.alone; }); + std::shared_mutex machineGate; + auto tRunPhase = std::chrono::steady_clock::now(); std::atomic next{0}; std::mutex reportMutex; @@ -4993,12 +5233,17 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, std::size_t i = next.fetch_add(1); if (i >= list.size()) return; auto& r = list[i]; + std::unique_lock holdsMachine(machineGate, std::defer_lock); + std::shared_lock besideOthers(machineGate, std::defer_lock); + if (r.alone) holdsMachine.lock(); + else besideOthers.lock(); // The schedule, recorded with the result (the mtest store). const auto stamp_schedule = [&, startedAt = std::chrono::steady_clock::now()] { auto& t = results.back(); t.startedMs = std::chrono::duration_cast(startedAt - tLoop).count(); t.worker = workerId; t.partOf = r.partOf; + t.alone = r.alone; if (r.select) { std::ifstream in(*r.select); for (std::string line; std::getline(in, line);) @@ -5044,7 +5289,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, runOpts.cwd = r.cwd.string(); runOpts.input = mcpp::run::Input::inherit; runOpts.streams = capture ? mcpp::run::Streams::merged : mcpp::run::Streams::inherit; - runOpts.total = deadline; + runOpts.total = std::chrono::milliseconds(static_cast(limit_of(r) * 1000)); runOpts.report_spawn_failure = false; if (r.protocol) { state_protocol(r, "run", runOpts); @@ -5168,7 +5413,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, } if (timedOut) { if (!json) mcpp::ui::plain(std::format( - "{} ... FAIL (timeout after {}s)", r.name, testOpts.timeoutSecs)); + "{} ... FAIL (timeout after {}s)", r.name, static_cast(limit_of(r)))); results.push_back({r.name, TestResult::St::RunFail, exitCode, {}, runOutput, ms, true, {}, r.buildMs}); } else if (exitCode == 0) { @@ -5392,15 +5637,10 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // own product directory with that group's deploy set. const std::string_view of = owner.empty() ? std::string_view(lu.memberOf) : owner; with_member(*ctx, of, [&] { carried = runtime_files_for(*ctx, exe, of); }); - if (auto listed = write_runtime_files_list(*ctx, exe, carried)) - childEnv.emplace_back(std::string(kRuntimeFilesEnv), listed->string()); - else if (invocationNotRunReason.empty()) - invocationNotRunReason = listed.error(); - if (auto closure = write_run_closure(*ctx, exe, carried, "test", runnerChoice.tripleKey)) - for (auto& kv : run_contract_env(*ctx, exe, "test", runnerChoice.tripleKey, *closure)) - childEnv.push_back(std::move(kv)); + if (auto handoff = runner_handoff_env(*ctx, exe, carried, "test", runnerChoice.tripleKey)) + for (auto& kv : *handoff) childEnv.push_back(std::move(kv)); else if (invocationNotRunReason.empty()) - invocationNotRunReason = closure.error(); + invocationNotRunReason = handoff.error(); } // Prepend the toolchain's environment's bin to the CHILD PATH so test @@ -5431,6 +5671,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, added.sources = sources_of(lu); added.guide = guide_of(lu, version); added.ownFiles = std::max(lu.ownSources.size(), 1); + added.partRoot = part_root(lu.ownSources); } // `mcpp test --print-launch` (design 2026-10-30 part 2 §7.4): the one test the pattern // names, built, described as `mcpp run --print-launch` describes a program -- the `mcpp.launch` @@ -5438,12 +5679,12 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, if (print_launch_flag()) { const Runnable* chosen = nullptr; for (auto const& r : runnable) - if (r.name == testOpts.filter) chosen = &r; + if (r.name == testOpts.filters.front()) chosen = &r; if (!chosen && runnable.size() == 1) chosen = &runnable.front(); if (!chosen) { mcpp::ui::error(runnable.empty() - ? std::format("--print-launch: no test that built is named '{}'", testOpts.filter) - : std::format("--print-launch describes one test, and '{}' names {}: name one", testOpts.filter, runnable.size())); + ? std::format("--print-launch: no test that built is named '{}'", testOpts.filters.front()) + : std::format("--print-launch describes one test, and '{}' names {}: name one", testOpts.filters.front(), runnable.size())); return 2; } if (!invocationNotRunReason.empty()) { @@ -5559,15 +5800,16 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // `[test] split = "file"` a program of several files with cases // in several of them runs as one process per file, each named // `/` as the run's records name it. - const auto split = ctx->manifest.testSplit.empty() ? std::string("file") : ctx->manifest.testSplit; - if (split == "file" && r.ownFiles >= 2) { + const auto split = !testOpts.split.empty() ? testOpts.split + : ctx->manifest.testSplit.empty() ? std::string("file") : ctx->manifest.testSplit; + if ((split == "file" || split == "auto") && r.ownFiles >= 2) { std::set files; for (const auto& c : report.listed) if (!c.file.empty()) files.insert(c.file); if (files.size() >= 2) for (const auto& f : files) results.back().parts.emplace_back( - std::format("{}/{}", r.name, std::filesystem::path(f).stem().generic_string()), f); + std::format("{}/{}", r.name, part_label(r.partRoot, f)), f); } if (!json) { mcpp::ui::plain(std::format("{}: {} case{}", r.name, report.listed.size(), @@ -5580,8 +5822,12 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, continue; } std::string lines; - for (const auto& id : mcpp::build::test_protocol::selection(report.listed, testOpts.cases)) + for (const auto& id : mcpp::build::test_protocol::selection(report.listed, testOpts.cases)) { lines += id + "\n"; + // A selected case that holds the machine (P7.1) runs the program alone. + for (const auto& c : report.listed) + if (c.id == id && std::ranges::find(c.resources, "machine") != c.resources.end()) r.alone = true; + } if (lines.empty()) { ++notSelected; continue; } r.select = r.protocolDir / "select.txt"; std::ofstream(*r.select, std::ios::binary) << lines; @@ -5605,48 +5851,141 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // (P4), scheduled across the workers as the programs are -- the // parallelism and the isolation of a program per file, with one link. // `[test] split = "case"` gives each case its own process; "none" runs - // the program once. - const auto split = ctx->manifest.testSplit.empty() ? std::string("file") : ctx->manifest.testSplit; - if (invocationNotRunReason.empty() && split != "none") { + // the program once; "auto" divides by file, and a part the store + // measured longer than its share of the run into groups of its cases. + const auto split = !testOpts.split.empty() ? testOpts.split + : ctx->manifest.testSplit.empty() ? std::string("file") : ctx->manifest.testSplit; + const bool measured = split == "auto" && !history.empty(); + if (invocationNotRunReason.empty()) { + const auto divides = [&](const Runnable& r) { + if (split == "none") return false; + return split == "case" || r.ownFiles >= 2 || measured; + }; + // A program the run does not divide is listed only for its + // resources; behind a runner (an emulator, a board) that start is + // the runner's, and the program runs whole, as it did before. + const auto listed_here = [&](const Runnable& r) { + const bool direct = !r.argv.empty() && std::filesystem::path(r.argv.front()) == r.program; + return r.protocol && (divides(r) || direct); + }; + // The listings, each a process start, run on the run's workers. + std::vector> listings(runnable.size()); + { + std::atomic nextListing{0}; + auto lister = [&] { + for (std::size_t i; (i = nextListing.fetch_add(1)) < runnable.size();) { + const auto& r = runnable[i]; + if (!listed_here(r)) continue; + mcpp::run::Options listOpts; + listOpts.env = r.env; + listOpts.cwd = r.cwd.string(); + state_protocol(r, "list", listOpts); + listOpts.streams = mcpp::run::Streams::merged; + listOpts.total = std::chrono::milliseconds(static_cast(testOpts.timeoutSecs) * 1000); + listOpts.report_spawn_failure = false; + auto listed = mcpp::run::execute(r.argv, listOpts); + auto report = mcpp::build::test_protocol::read_report(r.protocolDir / "list.ndjson", listed.exit_code); + if (listed.exit_code == 0 && report.done && !report.listed.empty()) listings[i] = std::move(report); + } + }; + std::vector listers; + const int listJobs = exclusiveDevice ? 1 : std::min(runJobs, static_cast(runnable.size())); + for (int w = 1; w < listJobs; ++w) listers.emplace_back(lister); + lister(); + } + // THE SHARE OF A PART (`split = "auto"`): the run's measured work + // over its workers, and over the shards when the run is one of + // several. A part longer than it is divided into groups of cases. + namespace ts = mcpp::build::test_schedule; + double share = 0; + if (measured) { + double total = 0; + for (std::size_t at = 0; at < runnable.size(); ++at) { + if (!listings[at]) { total += ts::estimate(history, runnable[at].name, {}); continue; } + std::vector ids; + for (auto const& c : listings[at]->listed) ids.push_back(c.id); + total += ts::estimate(history, runnable[at].name, ids); + } + share = total / (runJobs * (testOpts.shard ? testOpts.shard->second : 1)); + } std::vector expanded; - for (auto& r : runnable) { - if (!r.protocol || (split == "file" && r.ownFiles < 2)) { - expanded.push_back(std::move(r)); + for (std::size_t at = 0; at < runnable.size(); ++at) { + auto& r = runnable[at]; + const bool divide = divides(r); + if (!listings[at]) { + expanded.push_back(std::move(r)); // not listed, or its run reports what is wrong continue; } - mcpp::run::Options listOpts; - listOpts.env = r.env; - listOpts.cwd = r.cwd.string(); - state_protocol(r, "list", listOpts); - listOpts.streams = mcpp::run::Streams::merged; - listOpts.total = std::chrono::milliseconds(static_cast(testOpts.timeoutSecs) * 1000); - listOpts.report_spawn_failure = false; - auto listed = mcpp::run::execute(r.argv, listOpts); - auto report = mcpp::build::test_protocol::read_report(r.protocolDir / "list.ndjson", listed.exit_code); - if (listed.exit_code != 0 || !report.done || report.listed.empty()) { - expanded.push_back(std::move(r)); // its run reports what is wrong + const auto& report = *listings[at]; + // THE MACHINE, HELD ALONE (P7.1). A case that holds `machine` + // states a property that holds only while nothing beside it + // changes the machine (the shared home, its caches, the default + // toolchain). The cases of a process run one after another, so + // the property is the run's to keep across processes: the cases + // that hold it are a part of their own, run with no other test + // beside it, and the rest of their file runs as before; a file + // of both kinds names that part ` (machine)`. Measured on + // the e2e cases in one `mcpp test`: 234 and 1039 failed beside + // the other programs, and passed alone. + const auto holds_machine = [](const mcpp::build::test_protocol::Case& c) { + return std::ranges::find(c.resources, "machine") != c.resources.end(); + }; + // A program the run does not divide runs once, alone when one + // of its cases holds the machine. + // What the cases of `ids` (all, when none named) state they may run. + const auto declared = [&](const std::vector* ids) { + double s = 0; + for (auto const& c : report.listed) + if (!ids || std::ranges::find(*ids, c.id) != ids->end()) s += c.timeout; + return s; + }; + if (!divide) { + r.alone = std::ranges::any_of(report.listed, holds_machine); + r.declaredSecs = declared(nullptr); + expanded.push_back(std::move(r)); continue; } - std::map> groups; - for (auto const& c : report.listed) - groups[split == "case" ? c.id : (c.file.empty() ? std::string("-") : c.file)].push_back(c.id); - if (groups.size() < 2) { + std::map, std::vector> groups; + for (auto const& c : report.listed) { + const auto key = split == "case" ? c.id : (c.file.empty() ? std::string("-") : c.file); + groups[{key, holds_machine(c)}].push_back(c.id); + } + // A group the store measured longer than its share, as groups + // `(i/k)` of its cases of about equal measured duration. + struct Part { std::string where; bool machine; std::vector ids; std::size_t index = 0, of = 1; }; + std::vector parts; + for (auto& [key, ids] : groups) { + std::size_t k = 1; + if (measured && share > 0 && ids.size() > 1) + k = std::min(ids.size(), static_cast( + std::ceil(ts::estimate(history, r.name, ids) / share))); + if (k <= 1) { parts.push_back({key.first, key.second, std::move(ids)}); continue; } + auto chunks = ts::divide(history, r.name, ids, k); + for (std::size_t i = 0; i < chunks.size(); ++i) + parts.push_back({key.first, key.second, std::move(chunks[i]), i + 1, chunks.size()}); + } + if (parts.size() < 2) { + r.alone = parts.front().machine; + r.declaredSecs = declared(nullptr); expanded.push_back(std::move(r)); continue; } - std::vector>> ordered(groups.begin(), groups.end()); - // The largest first, so the longest work starts first. - std::ranges::stable_sort(ordered, std::greater{}, [](auto const& g) { return g.second.size(); }); + // The largest first; the run orders them again by measured duration. + std::ranges::stable_sort(parts, std::greater{}, [](auto const& p) { return p.ids.size(); }); std::size_t n = 0; // A pattern that named a part, not its program, keeps that part. - const bool whole = testOpts.filter.empty() || r.name.find(testOpts.filter) != std::string::npos; - for (auto& [key, ids] : ordered) { + const bool whole = selected_by(testOpts.filters, r.name); + for (auto& [where, machine, ids, index, of] : parts) { Runnable part = r; - const auto label = split == "case" ? key - : std::filesystem::path(key).stem().generic_string(); + const auto label = split == "case" ? where : part_label(r.partRoot, where); part.name = std::format("{}/{}", r.name, label); + // Named apart only when its file has cases of both kinds. + if (machine && groups.contains({where, false})) part.name += " (machine)"; + if (of > 1) part.name += std::format(" ({}/{})", index, of); part.partOf = r.name; - if (!whole && part.name.find(testOpts.filter) == std::string::npos) continue; + part.alone = machine; + part.declaredSecs = declared(&ids); + if (!whole && !selected_by(testOpts.filters, part.name)) continue; part.protocolDir = r.protocolDir / "split" / std::to_string(n++); std::error_code ec; std::filesystem::create_directories(part.protocolDir, ec); @@ -5665,11 +6004,13 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // program, and not a listing. if (const auto& st = ctx->manifest.testStore; st != "none" && !testOpts.listCases && std::ranges::any_of(results, [](const TestResult& r) { return r.status != TestResult::St::Built; })) - write_mtest_store(st.empty() ? ctx->projectRoot / "target" / "mtest" : ctx->projectRoot / st, results, + write_mtest_store(storeDir, results, testStartedWall, std::chrono::duration_cast( std::chrono::steady_clock::now() - tLoop).count(), - runJobs, testOpts.filter); + runJobs, patterns_text(testOpts.filters), + testOpts.shard ? std::format("{}/{}", testOpts.shard->first, testOpts.shard->second) + : std::string{}); summary.elapsedMs = carriedMs + std::chrono::duration_cast( std::chrono::steady_clock::now() - tLoop).count(); @@ -5696,13 +6037,20 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, summary.notRunReason = notRunReason; summary.built = built; - // "build X + run Y" rather than one merged number: on a member whose tests - // are cheap but whose link is not, those two are three orders of magnitude - // apart, and only the split says which one to go look at. - auto timing = std::format("{:.2f}s (build {:.2f}s + run {:.2f}s)", - static_cast(summary.elapsedMs) / 1000.0, - static_cast(summary.buildMs) / 1000.0, - static_cast(summary.runMs) / 1000.0); + // Each segment rather than one merged number: on a member whose tests are + // cheap but whose link is not, those are orders of magnitude apart, and + // only the split says which one to go look at. The segments add up to the + // total (mcpp#927, §18 P2): planning is what the others leave, as + // `Finished` reads its `plan`; the programs appear when there were any. + auto secs = [](long long ms) { return static_cast(ms) / 1000.0; }; + const long long planMs = std::max(0LL, summary.elapsedMs - summary.programsMs + - summary.buildMs - summary.runMs); + auto timing = std::format("{:.2f}s (plan {:.2f}s{} · build {:.2f}s · run {:.2f}s)", + secs(summary.elapsedMs), secs(planMs), + summary.programsMs >= 5 + ? std::format(" · programs {:.2f}s", secs(summary.programsMs)) + : std::string{}, + secs(summary.buildMs), secs(summary.runMs)); // 1 keeps meaning "a test ran and failed". 2 is "could not establish the // answer": the code the freestanding no-runner path already returns for @@ -5817,7 +6165,36 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // ─── the test archive (SPEC-015 §4) ───────────────────────────────────────── -// One leg's built tests, for the archive. +// The variable a test finds the package's program `name` in: MCPP_BIN_ and the +// name, upper-cased, every other character `_` (docs/08; mtest e2e::program). +std::string program_env_key(std::string_view name) { + std::string key = "MCPP_BIN_"; + for (char c : name) + key += (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') + : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; + return key; +} + +// `--no-programs`: each of the package's programs, planned and not built, is +// the one the environment's MCPP_BIN_ names -- at the run of an archive +// written so, or at this run. One the environment does not name is refused, +// before a test could take another program of that name from PATH. +std::optional +programs_from_environment(std::vector>& programEnv) { + for (auto& [key, path] : programEnv) { + if (!path.empty()) continue; + auto v = mcpp::invocation::get(key); + if (!v || v->empty()) + return std::format("the package's program {} is not built here (--no-programs): " + "name it in the environment, {}=", key, key); + std::error_code ec; + path = std::filesystem::absolute(*v, ec).lexically_normal().string(); + } + return std::nullopt; +} + +// One leg's built tests, for the archive. A program of `programEnv` with no +// path was planned and not built (`--no-programs`): the run names it. struct ArchiveLeg { std::string triple; BuildContext* ctx = nullptr; @@ -5848,12 +6225,13 @@ int write_test_archive(const TestOptions& o, std::span legs) { leg.triple = l.triple; for (auto const& lu : l.ctx->plan.linkUnits) { if (lu.kind != LinkUnit::TestBinary) continue; - if (!test_matches(lu, o.filter)) continue; + if (!test_matches(lu, o.filters)) continue; const auto exe = l.ctx->outputDir / lu.output; if (!fs::is_regular_file(exe, ec)) continue; ta::Test t{lu.targetName, ta::test_dest(l.triple, lu.targetName, exe.filename().string()), {}, test_protocol_of(*l.ctx, lu, objectMarkers), - static_cast(std::max(lu.ownSources.size(), 1))}; + static_cast(std::max(lu.ownSources.size(), 1)), + part_root(lu.ownSources).generic_string()}; copies.push_back({exe, t.program}); // The files of the test's member, placed in its product directory. std::vector> carried; @@ -5871,6 +6249,7 @@ int write_test_archive(const TestOptions& o, std::span legs) { } if (l.programEnv) for (auto const& [env, path] : *l.programEnv) { + if (path.empty()) { leg.named.push_back(env); continue; } auto dest = ta::program_dest(l.triple, fs::path(path).filename().string()); copies.push_back({path, dest}); leg.programs.emplace_back(env, dest); @@ -5912,12 +6291,14 @@ export int run_test_archive(std::span passthrough, BuildOverr auto opened = ta::open(fs::absolute(testOpts.archive, ec), into); if (!opened) { mcpp::ui::error(std::format("--archive {}: {}", testOpts.archive.string(), opened.error())); return 2; } const auto cwd = opened->manifest.cwd.empty() ? *root : *root / opened->manifest.cwd; + // THE PROGRAMS THE RUN NAMES: an archive written with `--no-programs` + // carries none of the package's programs. int passed = 0, failed = 0, notRun = 0, notReached = 0; std::vector reasons; for (auto const& leg : opened->manifest.legs) { if (json) std::println("{{\"leg\":{{\"triple\":\"{}\"}}}}", test_json_escape(leg.triple)); else mcpp::ui::info("Testing", std::format("on {} (from {})", leg.triple, testOpts.archive.string())); - auto ctx = prepare_configuration(leg.triple); + auto ctx = prepare_configuration(leg.triple, PrepareIntent::RunBuilt); if (!ctx) { reasons.push_back(std::format("{}: {}", leg.triple, ctx.error().substr(0, ctx.error().find('\n')))); notRun += static_cast(leg.tests.size()); @@ -5944,14 +6325,26 @@ export int run_test_archive(std::span passthrough, BuildOverr std::vector> legEnv; for (auto const& [env, path] : leg.programs) legEnv.emplace_back(env, (opened->root / path).lexically_normal().string()); - legEnv.emplace_back("MCPP_RUN_TRIPLE", tb.runnerChoice.tripleKey); - legEnv.emplace_back("MCPP_RUN_MODE", "test"); + // THE PROGRAMS THE LEG NAMES, from this run's environment, asked for + // once the leg is known to run here: a leg another machine runs needs + // none of them. One the environment does not name is refused before + // any of the leg's tests run. + if (!leg.named.empty()) { + std::vector> named; + for (auto const& env : leg.named) named.emplace_back(env, ""); + if (auto why = programs_from_environment(named)) { + mcpp::ui::error(std::format("--archive {}: the {} leg: {}", testOpts.archive.string(), + leg.triple, *why)); + return 2; + } + legEnv.insert(legEnv.end(), named.begin(), named.end()); + } // THE LEG'S TESTS, RUN AS A BUILD'S ARE: through the runner, the // protocol each implements, a process per file of its cases, the // workers, the store (SPEC-015 §4; docs/08). std::vector runs; for (auto const& t : leg.tests) { - if (!testOpts.filter.empty() && t.name.find(testOpts.filter) == std::string::npos) continue; + if (!selected_by(testOpts.filters, t.name)) continue; TestRunnable r; r.name = t.name; r.program = (opened->root / t.program).lexically_normal(); @@ -5968,16 +6361,29 @@ export int run_test_archive(std::span passthrough, BuildOverr std::array dir{r.program.parent_path()}; if (const auto key = mcpp::runtime::launch_env::runtime_library_path_key(); !key.empty()) r.env.emplace_back(key, mcpp::runtime::launch_env::prepend_path_list(key, dir)); - { - const auto list = r.program.parent_path() / ".mcpp-runtime-files.tsv"; - std::ofstream os(list, std::ios::binary | std::ios::trunc); - for (auto const& f : t.files) - os << fs::path(f).filename().generic_string() << '\t' - << (opened->root / f).lexically_normal().string() << '\n'; - r.env.emplace_back(std::string(kRuntimeFilesEnv), list.string()); + // ...and, through a runner, what a build's test run hands one + // (runner_handoff_env): the files the program carries, each named + // by its place beside the program, as the archive keeps it. The + // records are written in the archive's run directory, beside the + // programs they describe, not among the checkout's build outputs. + if (!tb.runnerTmpl.empty() || !tb.runUnderPrefix.empty()) { + std::vector> carried; + for (auto const& f : t.files) { + const auto source = (opened->root / f).lexically_normal(); + carried.emplace_back(source.lexically_relative(r.program.parent_path()).generic_string(), source); + } + auto handoff = runner_handoff_env(*tb.ctx, r.program, carried, "test", tb.runnerChoice.tripleKey, + opened->root); + if (!handoff) { + reasons.push_back(std::format("{}: {}", t.name, handoff.error())); + ++notRun; + continue; + } + for (auto& kv : *handoff) r.env.push_back(std::move(kv)); } r.protocol = t.protocol == mcpp::build::test_protocol::kVersion ? t.protocol : 0; r.ownFiles = static_cast(std::max(t.ownFiles, 1)); + r.partRoot = t.partRoot; r.cwd = cwd; runs.push_back(std::move(r)); } @@ -6067,7 +6473,8 @@ void write_test_plan(const TestBuild& tb, std::string_view targetOverride) { for (auto const& input : lu.implicitInputs) if (auto a = archiveOf.find(input); a != archiveOf.end()) add_objects(a->second->objects); json stems = json::array(); - for (auto const& f : lu.ownSources) stems.push_back(f.stem().generic_string()); + const auto root = part_root(lu.ownSources); + for (auto const& f : lu.ownSources) stems.push_back(part_label(root, f)); const bool collected = std::ranges::any_of(lu.linkFlags, [](std::string_view f) { return f == "-Wl,--gc-sections" || f == "-Wl,-dead_strip"; }); @@ -6087,7 +6494,8 @@ void write_test_plan(const TestBuild& tb, std::string_view targetOverride) { {"sources", std::vector(files.begin(), files.end())}, {"protocol", lu.testProtocol}, {"objects", objects}, {"guide", guide(mcpp::build::test_protocol::kVersion)}, {"guide_none", guide(0)}, - {"own_files", std::max(lu.ownSources.size(), 1)}}); + {"own_files", std::max(lu.ownSources.size(), 1)}, + {"part_root", root.generic_string()}}); } json frameworks = json::array(); for (auto const& f : ctx.plan.testFrameworks) @@ -6191,9 +6599,9 @@ fast_test_plan(const std::filesystem::path& projectRoot, const TestOptions& test std::map markers; for (auto const& t : record["tests"]) { const std::string name = t.value("name", ""); - bool selected = testOpts.filter.empty() || name.find(testOpts.filter) != std::string::npos; + bool selected = selected_by(testOpts.filters, name); for (auto const& stem : t["stems"]) - selected = selected || std::format("{}/{}", name, stem.get()).find(testOpts.filter) != std::string::npos; + selected = selected || selected_by(testOpts.filters, std::format("{}/{}", name, stem.get())); if (!selected) continue; TestRunnable r; r.name = name; @@ -6203,6 +6611,7 @@ fast_test_plan(const std::filesystem::path& projectRoot, const TestOptions& test for (auto const& a : passthrough) r.argv.push_back(a); r.sources = t.value("sources", std::vector{}); r.ownFiles = t.value("own_files", std::size_t{1}); + r.partRoot = t.value("part_root", std::string{}); r.protocol = t.value("protocol", 0); if (!r.protocol) { r.protocol = mcpp::build::test_protocol::marker_version(r.program); @@ -6336,8 +6745,7 @@ export int run_tests(std::span passthrough, if (testOpts.list) { std::size_t total = 0; for (auto& t : testTargets) { - if (!testOpts.filter.empty() - && t.name.find(testOpts.filter) == std::string::npos) continue; + if (!selected_by(testOpts.filters, t.name)) continue; ++total; auto abs = std::filesystem::absolute(testRoot / t.main) .lexically_normal().generic_string(); @@ -6383,17 +6791,21 @@ export int run_tests(std::span passthrough, // (cmd_build): a test of an unchanged project neither resolves nor // scans to learn that its programs are current. Measured on a project // of 1,500 sources: 2.65 s of a 5.8 s test of one case, before it. + const auto tPrograms = std::chrono::steady_clock::now(); + struct ProgramsTime { + TestRunSummary& summary; std::chrono::steady_clock::time_point from; + ~ProgramsTime() { + summary.programsMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - from).count(); + } + } programsTime{summary, tPrograms}; std::vector> fastPrograms; - if (hasPrograms && fastPossible) { + if (hasPrograms && fastPossible && !testOpts.noPrograms) { if (auto rc = try_fast_build(testRoot, mcpp::log::is_verbose(), false, "", &fastPrograms)) { if (*rc != 0) return *rc; hasPrograms = false; for (auto const& [name, exe] : fastPrograms) { - std::string key = "MCPP_BIN_"; - for (char c : name) - key += (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') - : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; - programEnv.emplace_back(std::move(key), exe); + programEnv.emplace_back(program_env_key(name), exe); } } } @@ -6406,20 +6818,23 @@ export int run_tests(std::span passthrough, // `mcpp run` (see `build_run_target`): without it this build // of another target was recorded as the host's, and the next // bare `mcpp run` warned "fingerprint changed" and rebuilt. - if (int rc = run_build_plan(*binCtx, mcpp::log::is_verbose(), false, - overrides.target_triple); rc != 0) - return rc; + // `--no-programs`: planned for their names, not built. + if (!testOpts.noPrograms) + if (int rc = run_build_plan(*binCtx, mcpp::log::is_verbose(), false, + overrides.target_triple); rc != 0) + return rc; for (auto const& lu : binCtx->plan.linkUnits) { if (lu.kind != LinkUnit::Binary) continue; - std::string key = "MCPP_BIN_"; - for (char c : lu.targetName) - key += (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') - : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; - programEnv.emplace_back(std::move(key), - (binCtx->outputDir / lu.output).lexically_normal().string()); + programEnv.emplace_back(program_env_key(lu.targetName), + testOpts.noPrograms ? std::string{} + : (binCtx->outputDir / lu.output).lexically_normal().string()); } } } + // Run here, the programs are the environment's; written into an + // archive, its run's. + if (testOpts.noPrograms && testOpts.archive.empty() && !testOpts.noRun) + if (auto why = programs_from_environment(programEnv)) { mcpp::ui::error(*why); return 2; } } // 3. prepare_build with dev-deps enabled + synthetic targets. @@ -6480,17 +6895,17 @@ export int run_tests(std::span passthrough, // filtered run must not clobber it down to one entry). auto filter_match = [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary - && test_matches(lu, testOpts.filter); + && test_matches(lu, testOpts.filters); }; - if (!testOpts.filter.empty()) { + if (!testOpts.filters.empty()) { bool any = false; for (auto& lu : ctx->plan.linkUnits) if (filter_match(lu)) { any = true; break; } if (!any) { if (json) std::println("{{\"error\":\"no-tests-matched\",\"filter\":\"{}\"}}", - test_json_escape(testOpts.filter)); - mcpp::ui::error(std::format("no tests match '{}'", testOpts.filter)); + test_json_escape(patterns_text(testOpts.filters))); + mcpp::ui::error(std::format("no tests match '{}'", patterns_text(testOpts.filters))); return 2; } } @@ -6576,13 +6991,19 @@ export int run_test_legs(std::span passthrough, if (!discovered) { mcpp::ui::error(discovered.error()); return 2; } // Nothing to build, a listing, or a filter that selects nothing: the // answer does not depend on the target, and is the one-leg answer. - const bool anyMatch = testOpts.filter.empty() - || std::ranges::any_of(discovered->targets, [&](auto const& t) { - return t.name.find(testOpts.filter) != std::string::npos; }); + const bool anyMatch = testOpts.filters.empty() + || std::ranges::any_of(discovered->targets, [&](auto const& t) { return selected_by(testOpts.filters, t.name); }); if (discovered->targets.empty() || testOpts.list || !anyMatch) { base.target_triple = requested.front().triple; return run_tests(passthrough, std::move(base), testOpts); } + // The environment names one program per MCPP_BIN_, and each leg + // tests a program of its own target. + if (testOpts.noPrograms && requested.size() > 1) { + mcpp::ui::error("--no-programs tests one target: the environment names each program once " + "(MCPP_BIN_), and each leg tests a program of its own target"); + return 2; + } bool hasPrograms = false; if (auto m = mcpp::manifest::load(discovered->packageRoot / "mcpp.toml")) { hasPrograms = std::ranges::any_of(m->targets, [](auto const& t) { @@ -6651,7 +7072,7 @@ export int run_test_legs(std::span passthrough, auto filter_match = [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary - && test_matches(lu, testOpts.filter); + && test_matches(lu, testOpts.filters); }; // ── built at the same time, a thread per leg ── @@ -6672,18 +7093,17 @@ export int run_test_legs(std::span passthrough, auto built = mcpp::build::legs::run_by_directory(dirs, [&](std::size_t i) -> int { auto& l = *legs[i]; if (l.programs) { - if (run_build_plan(*l.programs, mcpp::log::is_verbose(), false, l.triple) != 0) { + // `--no-programs`: planned for their names, not built. + if (!testOpts.noPrograms + && run_build_plan(*l.programs, mcpp::log::is_verbose(), false, l.triple) != 0) { l.programsFailed = true; return 1; } for (auto const& lu : l.programs->plan.linkUnits) { if (lu.kind != LinkUnit::Binary) continue; - std::string key = "MCPP_BIN_"; - for (char c : lu.targetName) - key += (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') - : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; - l.programEnv.emplace_back(std::move(key), - (l.programs->outputDir / lu.output).lexically_normal().string()); + l.programEnv.emplace_back(program_env_key(lu.targetName), + testOpts.noPrograms ? std::string{} + : (l.programs->outputDir / lu.output).lexically_normal().string()); } } if (auto a = test_phase_a(l.tb, testOpts, json)) { @@ -6728,6 +7148,12 @@ export int run_test_legs(std::span passthrough, worse(1); continue; } + if (testOpts.noPrograms && testOpts.archive.empty() && !testOpts.noRun) + if (auto why = programs_from_environment(l.programEnv)) { + mcpp::ui::error(std::format("the {} leg: {}", l.triple, *why)); + worse(2); + continue; + } l.tb.programEnv = std::move(l.programEnv); test_resolve_runner(l.tb, legOpts, json); if (l.tb.builtNotRunHere) { @@ -6823,14 +7249,16 @@ export void run_workspace_tests(std::span passthrough, if (members[i].targets.empty()) { slots[i].note = members[i].noTests; continue; } // As `test_matches' asks of a planned test: the program, or a part of it. auto names_it = [&](const mcpp::manifest::Target& t) { - if (t.name.find(testOpts.filter) != std::string::npos) return true; + if (selected_by(testOpts.filters, t.name)) return true; + std::vector own{std::filesystem::path(t.main)}; + for (auto const& f : t.testSources) own.emplace_back(f); + const auto root = part_root(own); auto part = [&](const std::string& f) { - return std::format("{}/{}", t.name, std::filesystem::path(f).stem().generic_string()) - .find(testOpts.filter) != std::string::npos; + return selected_by(testOpts.filters, std::format("{}/{}", t.name, part_label(root, f))); }; return part(t.main) || std::ranges::any_of(t.testSources, part); }; - if (!testOpts.filter.empty() && std::ranges::none_of(members[i].targets, names_it)) { + if (!testOpts.filters.empty() && std::ranges::none_of(members[i].targets, names_it)) { slots[i].noMatch = true; continue; } @@ -7000,7 +7428,9 @@ export void run_workspace_tests(std::span passthrough, slots[i].packageOutput = binCtx.error(); continue; } - if (run_build_plan(*binCtx, mcpp::log::is_verbose(), false, base.target_triple) != 0) { + // `--no-programs`: planned for their names, and the environment's. + if (!testOpts.noPrograms + && run_build_plan(*binCtx, mcpp::log::is_verbose(), false, base.target_triple) != 0) { slots[i].packageFailed = true; slots[i].packageOutput = std::format("the programs of member '{}' did not build", members[i].path); continue; @@ -7008,12 +7438,16 @@ export void run_workspace_tests(std::span passthrough, auto& env = tb.memberProgramEnv[slots[i].owner]; for (auto const& lu : binCtx->plan.linkUnits) { if (lu.kind != LinkUnit::Binary || (!lu.memberOf.empty() && lu.memberOf != slots[i].owner)) continue; - std::string key = "MCPP_BIN_"; - for (char c : lu.targetName) - key += (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') - : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; - env.emplace_back(std::move(key), (binCtx->outputDir / lu.output).lexically_normal().string()); + env.emplace_back(program_env_key(lu.targetName), + testOpts.noPrograms ? std::string{} + : (binCtx->outputDir / lu.output).lexically_normal().string()); } + if (testOpts.noPrograms && !testOpts.noRun) + if (auto why = programs_from_environment(env)) { + slots[i].packageFailed = true; + slots[i].packageOutput = *why; + continue; + } } // The test goals of the members that can run. std::set runnable; @@ -7023,7 +7457,7 @@ export void run_workspace_tests(std::span passthrough, test_bulk(tb, testOpts, [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary && runnable.contains(lu.memberOf) - && test_matches(lu, testOpts.filter); + && test_matches(lu, testOpts.filters); }); if (!runnable.empty()) test_resolve_runner(tb, testOpts, json); } @@ -7088,8 +7522,8 @@ export void run_workspace_tests(std::span passthrough, } else if (slot.noMatch) { if (json) std::println("{{\"error\":\"no-tests-matched\",\"filter\":\"{}\"}}", - test_json_escape(testOpts.filter)); - mcpp::ui::error(std::format("no tests match '{}'", testOpts.filter)); + test_json_escape(patterns_text(testOpts.filters))); + mcpp::ui::error(std::format("no tests match '{}'", patterns_text(testOpts.filters))); rc = 2; } else if (!slot.note.empty()) { if (!json) std::println("{}", slot.note); @@ -7185,7 +7619,8 @@ export int clean_project(bool wipe_bmi) { // member's `target/.build-mcpp/`, where its build program's outputs are, is // kept. // `mcpp clean --report` (mcpp#874): where target/ holds its bytes. Per -// configuration directory, its objects, its BMIs and its products; then the +// configuration directory, its objects, their split debug information, its BMIs +// and its products; then the // configuration-independent `.build-mcpp` (build programs' groups, generated // sources, dependencies' build-program copies). Removes nothing; `--stale // --dry-run` says what of it is unused. @@ -7197,7 +7632,20 @@ export int clean_report() { const fs::path target = *root / "target"; using mcpp::bmi_cache::dir_size, mcpp::bmi_cache::human_bytes; std::uintmax_t total = 0; - std::println("{:<44} {:>10} {:>10} {:>10} {:>10}", "configuration", "obj", "bmi", "bin", "total"); + // The split debug information (`.dwo`) is a column of its own and not + // counted under obj (2026-11-12 §17, W3): it is what a profile's `debug` + // decides. + auto split_debug = [](const fs::path& dir) { + std::uintmax_t n = 0; + std::error_code wec; + for (fs::recursive_directory_iterator it(dir, wec), end; !wec && it != end; it.increment(wec)) { + std::error_code sec; + if (it->path().extension() == ".dwo" && it->is_regular_file(sec)) n += it->file_size(sec); + } + return n; + }; + std::println("{:<44} {:>10} {:>10} {:>10} {:>10} {:>10}", "configuration", "obj", "debug", "bmi", "bin", + "total"); std::error_code ec; for (fs::directory_iterator t(target, ec), end; t != end; t.increment(ec)) { const auto triple = t->path().filename().string(); @@ -7205,14 +7653,15 @@ export int clean_report() { if (triple.starts_with(".") || !t->is_directory(fec)) continue; for (fs::directory_iterator f(t->path(), fec), fend; f != fend; f.increment(fec)) { if (!f->is_directory()) continue; - const auto obj = dir_size(f->path() / "obj"); + const auto debug = split_debug(f->path() / "obj"); + const auto obj = dir_size(f->path() / "obj") - debug; const auto bmi = dir_size(f->path() / "gcm.cache") + dir_size(f->path() / "pcm.cache"); const auto bin = dir_size(f->path() / "bin"); const auto all = dir_size(f->path()); total += all; - std::println("{:<44} {:>10} {:>10} {:>10} {:>10}", + std::println("{:<44} {:>10} {:>10} {:>10} {:>10} {:>10}", triple + "/" + f->path().filename().string(), human_bytes(obj), - human_bytes(bmi), human_bytes(bin), human_bytes(all)); + human_bytes(debug), human_bytes(bmi), human_bytes(bin), human_bytes(all)); } } const auto bm = target / ".build-mcpp"; diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 86cf0da13..40eaa3a73 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -660,7 +660,7 @@ bool realises_optimization(const mcpp::manifest::BuildConfig& bc) { std::string profile_descriptor(const mcpp::manifest::BuildConfig& bc) { std::string d = realises_optimization(bc) ? "optimized" : "unoptimized"; - if (bc.debug) d += " + debuginfo"; + if (bc.debug) d += bc.debugLines ? " + line tables" : " + debuginfo"; if (bc.lto) d += bc.ltoPartial ? " + lto (partial)" : " + lto"; return d; } @@ -1113,6 +1113,10 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { return LinkIntentFlavor::Wasm; case mcpp::toolchain::triple::ObjectFormat::Elf: return LinkIntentFlavor::Elf; + case mcpp::toolchain::triple::ObjectFormat::Coff: + // DJGPP links with GNU ld, which takes a search path and a + // library in ELF's spelling (`-L`, `-l`). + return LinkIntentFlavor::Elf; } } auto triple = plan.toolchain.targetTriple; @@ -1170,7 +1174,10 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { f.ldRuntimeFallback = farm_ld; std::filesystem::path binutilsBin; - if (!isMuslTc && !isMingwTc && caps.stdlib_id == "libstdc++") { + // A self-contained cross payload finds its own assembler and linker under + // its prefix: musl, MinGW, and a payload a row names by its GCC target. + if (!isMuslTc && !isMingwTc && caps.stdlib_id == "libstdc++" + && !mcpp::toolchain::row_states_gcc_target(plan.toolchain)) { auto ar = mcpp::toolchain::archive_tool(plan.toolchain); if (!ar.empty()) binutilsBin = ar.parent_path(); @@ -1193,7 +1200,7 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { std::string opt_flag = (isMsvcDialect && optLevel == "0") ? " /Od" // MSVC's no-opt spelling (there is no /O0) : std::format(" {}{}", d.optPrefix, optLevel); - if (prof.debug) opt_flag += std::format(" {}", d.debugFlags); + if (prof.debug) opt_flag += std::format(" {}", prof.debugLines ? d.lineDebugFlags : d.debugFlags); // DEBUG INFORMATION IS COMPRESSED where the linker copies it into every // artifact (mcpp#874): on ELF each program of a member, every test among // them, carries the DWARF of its whole closure, and zlib halves it (a diff --git a/src/build/graph_engine.cppm b/src/build/graph_engine.cppm new file mode 100644 index 000000000..c92c78530 --- /dev/null +++ b/src/build/graph_engine.cppm @@ -0,0 +1,118 @@ +// mcpp.build.graph_engine -- the engine a build directory's graph runs +// (design 2026-11-12 §17, W1). +// +// The graph runs this engine in its commands -- the capped compile +// (`$mcpp __compiler-cap ... -- `), BMI comparison, link wrapping, +// staging, module bindings -- and ninja compares each command with the one it +// last ran. This engine's own path changes with each release +// (`xim-x-mcpp//bin/mcpp`), so a graph that named it there changed +// every command on an upgrade, and every unit was compiled again although the +// compiler would have written the same objects (measured: every unit of a +// workspace's members). The graph names the build directory's own link to the +// engine instead, which the planner makes current under the directory's lock: +// a command then changes with its compiler, its flags and its files. The link's +// path carries the epoch of what the engine writes (kCacheEpoch, which the +// planner passes): a release that changes what the engine's steps write bumps +// it, and every edge runs again. +export module mcpp.build.graph_engine; + +import std; +import mcpplibs.os; +import mcpp.diag; +import mcpp.invocation; + +export namespace mcpp::build::graph_engine { + +// The link a build directory's graph names: `/engine/epoch-/mcpp`. +std::filesystem::path link(const std::filesystem::path& outputDir, int epoch); + +// Makes the link this engine -- a hard link where the file system makes one, +// else a copy -- and returns what the graph names: the link, or this engine's +// own path when neither could be made. Called under the directory's lock. +std::filesystem::path refresh(const std::filesystem::path& outputDir, int epoch); + +// The link at `to` (the path a plan's graph names), made again where it is +// missing: a cold build (`--cache off`) empties the directory after the plan +// named it. Returns `to`, or the running engine's own path when no link can be +// made there. +std::filesystem::path place(const std::filesystem::path& to); + +// Whether `named`, the engine a graph names, is this engine: its own path, or +// a link that is the same file or a copy of it. +bool is_this_engine(const std::filesystem::path& named); + +} // namespace mcpp::build::graph_engine + +namespace mcpp::build::graph_engine { + +namespace { +// The same file, or a copy of its size and time. +bool same_engine(const std::filesystem::path& a, const std::filesystem::path& b) { + namespace fs = std::filesystem; + std::error_code ec; + if (fs::equivalent(a, b, ec) && !ec) return true; + std::error_code s1, s2, t1, t2; + return fs::file_size(a, s1) == fs::file_size(b, s2) && !s1 && !s2 + && fs::last_write_time(a, t1) == fs::last_write_time(b, t2) && !t1 && !t2; +} +} // namespace + +std::filesystem::path link(const std::filesystem::path& outputDir, int epoch) { + return outputDir / "engine" / std::format("epoch-{}", epoch) + / (std::string("mcpp") + std::string(mcpplibs::os::exe_suffix)); +} + +bool is_this_engine(const std::filesystem::path& named) { + const auto self = mcpp::invocation::self_exe_path(); + return named == self || same_engine(named, self); +} + +namespace { +// What the link is made from: the running engine's file, or, where that file +// was replaced while it ran (Linux names it ` (deleted)`, as when an +// engine rebuilds itself in its own repository), the running image itself. +std::filesystem::path source_of(const std::filesystem::path& self) { + std::error_code ec; + if (std::filesystem::is_regular_file(self, ec)) return self; + // A system that presents the running image as a file (procfs) reads it + // there whether or not its path still names it. + const std::filesystem::path image{"/proc/self/exe"}; + if (std::filesystem::is_regular_file(image, ec)) return image; + return self; +} +} // namespace + +std::filesystem::path place(const std::filesystem::path& to) { + namespace fs = std::filesystem; + const auto self = mcpp::invocation::self_exe_path(); + if (same_engine(to, self)) return to; + const auto from = source_of(self); + std::error_code ec; + fs::create_directories(to.parent_path(), ec); + ec.clear(); + if (from == self) fs::create_hard_link(from, to, ec); + if (from != self || ec) { + ec.clear(); + fs::copy_file(from, to, fs::copy_options::overwrite_existing, ec); + if (!ec && from == self) fs::last_write_time(to, fs::last_write_time(self, ec), ec); + } + if (!ec) return to; + std::error_code rec; + fs::remove(to, rec); + mcpp::diag::degraded("build/engine", + std::format("the build directory's link to this mcpp could not be made: {}", ec.message()), + "the graph names this mcpp's own path, so the next mcpp compiles every unit again"); + return self; +} + +std::filesystem::path refresh(const std::filesystem::path& outputDir, int epoch) { + namespace fs = std::filesystem; + const auto to = link(outputDir, epoch); + if (same_engine(to, mcpp::invocation::self_exe_path())) return to; + std::error_code ec; + // A link of another epoch is named by no graph this engine writes. + fs::remove_all(to.parent_path().parent_path(), ec); + return place(to); +} + +} // namespace mcpp::build::graph_engine diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 930f47a2f..a89032736 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -348,6 +348,7 @@ std::filesystem::path mcpp_exe_path() { return mcpp::invocation::self_exe_path(); } + namespace { // U+FEFF in UTF-8. The response files of the MSVC tools begin with it (#693). @@ -1806,7 +1807,9 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, append(std::format("ld_driver = {}{}\n", toolEnv, ninja_tool_word(flags.ldDriver))); // `$mcpp` is needed by stage_file in EVERY configuration (dyndep or not), // so the binding cannot live inside the `if (dyndep)` below. - append(std::format("mcpp = {}\n", ninja_tool_word(mcpp_exe_path()))); + // The engine the planner chose for this directory (mcpp.build.graph_engine). + append(std::format("mcpp = {}\n", + ninja_tool_word(plan.engine.empty() ? mcpp_exe_path() : plan.engine))); if (dyndep) { if (!plan.scanDepsPath.empty()) { append(std::format("scan_deps = {}{}\n", toolEnv, ninja_tool_word(plan.scanDepsPath))); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index a369bbe9c..a78a550db 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -556,7 +556,10 @@ struct BuildPlan { std::string dialectFlags; std::filesystem::path projectRoot; // where mcpp.toml lives - std::filesystem::path outputDir; // target/// + std::filesystem::path outputDir; // target/// + // The engine the graph's commands run: the directory's link to this + // engine (mcpp.build.graph_engine); empty, this engine's own path. + std::filesystem::path engine; // Where compile_commands.json goes. Carried rather than derived from // projectRoot: under BuildOverrides::work_dir the package root is a shared // (possibly read-only) registry directory, and deriving the path would put @@ -2365,8 +2368,13 @@ make_plan(const mcpp::manifest::Manifest& manifest, if (!t.is_program() && t.kind != mcpp::manifest::Target::Entry && t.kind != mcpp::manifest::Target::TestBinary) continue; auto entry = projectRoot / t.main; - if (scannedSources.contains(entry)) continue; - rootBasenameCount[object_filename_for(entry, objExt)]++; + if (!scannedSources.contains(entry)) rootBasenameCount[object_filename_for(entry, objExt)]++; + // A test program's other files (`[test] programs`) are compiled as its + // entry is, and a package's program holds files of several directories + // (`tests/b.cpp`, `tests/unit/b.cpp`): they take part in the census. + for (auto const& source : t.testSources) + if (!scannedSources.contains(projectRoot / source)) + rootBasenameCount[object_filename_for(projectRoot / source, objExt)]++; } auto sanitize = [](const std::string& s) { std::string out; out.reserve(s.size()); diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b21a703de..d6022094d 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -412,12 +412,12 @@ export struct SourceDecision { export std::string source_tag(const SourceDecision& d, const std::filesystem::path& relativeTo = {}); -// A condition a planning pass reports instead of acting on (plan_only): the +// A condition a planning pass reports instead of acting on (PrepareIntent::Describe): the // code is stable and the message is for people. Emitted as diagnostics by the // command that asked for the plan, at the severity carried here — most notes // are warnings the document is still complete despite (a lock that would // change, a generated file left unmaterialized); a build program whose run -// failed under `plan_only` (#699 item 2, E3) is an error, because the sets it +// failed under `PrepareIntent::Describe` (#699 item 2, E3) is an error, because the sets it // would have shaped are described without its directives. export struct PlanNote { std::string code; @@ -448,6 +448,38 @@ export struct DepSourceRoot { bool operator==(const DepSourceRoot&) const = default; }; +// WHAT A PREPARATION IS FOR. One answer, from which each phase reads what it may +// do: whether it compiles, whether it runs the package's programs, whether it +// writes the project. Three switches that grew one per caller had answered it +// before, each phase asking the ones it knew of (2026-11-14). +export enum class PrepareIntent { + // Plan, then build. + Build, + // PLANNING TO DESCRIBE, NOT TO BUILD (`mcpp emit build-database`, publish, + // the doctor). With `work_dir` pointed outside the project, three things + // still reached it or ran a compiler, and this intent settles each: the std + // module is described (mcpp::toolchain::describe_std_module) instead of + // compiled; the root package's `[build] generated_files`, which are sources + // and live in the source tree, are compared instead of written, and a + // missing or stale one is recorded in BuildContext::planNotes; and mcpp.lock + // is READ from the project root, as the resolution input it is, while the + // lock this planning produces is written under `work_dir`. Build programs + // still run. + Describe, + // THE CONFIGURATION, NOT THE SOURCES (`prepare_configuration`, SPEC-013 + // §9): what the project's sources would be refused for -- `import std` + // with no C++ run-time in the graph -- is a note of the report, and the + // configuration is still reported. + Configure, + // THE CONFIGURATION TESTS BUILT ELSEWHERE RUN IN (`mcpp test --archive`, + // SPEC-015 §4): a configuration that compiles nothing. A toolchain this + // machine cannot compile with -- the MSVC ABI with no Visual Studio -- is + // no refusal, and no build program or host tool is compiled or run: their + // directives and outputs shape compiles, and the tests' programs and + // files are the archive's. + RunBuilt, +}; + export struct BuildContext; // A BUILD PROGRAMS' GRAPH a prepare planned (mcpp#797), for the readers that @@ -479,7 +511,7 @@ export struct TargetSelection { export struct BuildContext { // The build programs' graphs this prepare planned to describe them - // (`plan_only`; empty otherwise). + // (`PrepareIntent::Describe`; empty otherwise). std::vector buildProgramGraphs; // The root is a build plugin, planned in the build-program configuration // (mcpp#797, SPEC-007 P3). `mcpp pack` refuses it (P5). @@ -495,6 +527,10 @@ export struct BuildContext { // parser of one file, and because the two readers must not be able to // disagree about the machine. int globalDefaultJobs = 0; + // What the preparation was for: a preparation that compiles nothing + // (`PrepareIntent::RunBuilt`) provisioned no tool, and its readers find + // the programs it would have built another way. + PrepareIntent intent = PrepareIntent::Build; // The root's exact pins of packages every consumer shares (T3): what // `mcpp publish` refuses in a library, one line each. std::vector sharedExactPins; @@ -572,11 +608,11 @@ export struct BuildContext { // the finish phase starts writing `outputDir`, held through ninja, and // dropped (`reset()`) before a program or a test runs. Shared because a // context is copied; the last copy releases it. Null for a planning pass - // that describes (plan_only), which works in its own directory. + // that describes (PrepareIntent::Describe), which works in its own directory. std::shared_ptr buildLock; std::filesystem::path stdBmi; std::filesystem::path stdObject; - // plan_only: what the std module build WOULD be (sources are on the + // PrepareIntent::Describe: what the std module build WOULD be (sources are on the // toolchain), set when the graph imports std. A build compiles it instead // and leaves this empty. std::optional stdModule; @@ -810,6 +846,7 @@ export std::string profile_override_from_flags(std::string_view profileOption, export std::expected package_root(const std::string& ref, bool install); + export struct BuildOverrides { // Where the package being built LIVES (its mcpp.toml). Empty = walk up from // the process cwd, which is what every user-facing invocation does. Set by @@ -830,22 +867,12 @@ export struct BuildOverrides { // only some would be worse than moving none — a half-redirected build // writes into the shared root anyway, just less visibly. std::filesystem::path work_dir; - // PLANNING TO DESCRIBE, NOT TO BUILD (`mcpp emit build-database`). - // - // With `work_dir` pointed outside the project, three things still reached - // it or ran a compiler, and this switch settles each: the std module is - // described (mcpp::toolchain::describe_std_module) instead of compiled; the - // root package's `[build] generated_files`, which are sources and live in - // the source tree, are compared instead of written, and a missing or stale - // one is recorded in BuildContext::planNotes; and mcpp.lock is READ from the - // project root, as the resolution input it is, while the lock this planning - // produces is written under `work_dir`. Build programs still run. - bool plan_only = false; - // THE CONFIGURATION, NOT THE SOURCES (`prepare_configuration`, SPEC-013 - // §9): what the project's sources would be refused for -- `import std` - // with no C++ run-time in the graph -- is a note of the report, and the - // configuration is still reported. - bool configuration_only = false; + // What this preparation is for (PrepareIntent), and what each phase reads + // of it. + PrepareIntent intent = PrepareIntent::Build; + bool describes() const { return intent == PrepareIntent::Describe; } + bool configures() const { return intent == PrepareIntent::Configure || intent == PrepareIntent::RunBuilt; } + bool compiles() const { return intent != PrepareIntent::RunBuilt; } // #355 tool provisioning re-enters prepare_build for the tool package. A // tool package's own build.mcpp may legitimately want another tool (gRPC's // wants protoc), so the depth cannot be 1 — but an unbounded chain is a @@ -1115,7 +1142,7 @@ prepare_build(bool print_fingerprint, bool includeDevDeps = false, // `[target.]` tables apply); elsewhere an empty program in the // home's cache stands for one. Nothing is compiled but the tools. export std::expected -prepare_configuration(std::string_view targetTriple); +prepare_configuration(std::string_view targetTriple, PrepareIntent intent = PrepareIntent::Configure); // THE PLATFORM A TARGET SELECTS, ASKED WITHOUT PREPARING IT (SPEC-015 §1): the // selection the first phase makes -- `--platform`, the target's environment @@ -1136,7 +1163,7 @@ platform_selection_for(const mcpp::toolchain::triple::Triple& target, // by an earlier phase (`MCPP_BUILD_DATABASE_PROGRAM_FAILED`, say) was // therefore lost on every failure of a later phase, not only the one that // motivated this (design 2026-09-27 §4.2, mcpp#724 side finding A, fix item -// 2): under `emit build-database`'s `plan_only`, a member the caller could +// 2): under `emit build-database`'s `PrepareIntent::Describe`, a member the caller could // otherwise describe the ordinary way (R5.2) instead reported nothing but the // later phase's own message. // diff --git a/src/build/prepare/build_dir.cpp b/src/build/prepare/build_dir.cpp index 15a03ca77..27efbdb7e 100644 --- a/src/build/prepare/build_dir.cpp +++ b/src/build/prepare/build_dir.cpp @@ -1,6 +1,7 @@ // build_dir.cpp -- a configuration's build directory over time (design -// 2026-10-31 §8): the predecessor a new fingerprint of its lineage takes over, -// and what the directory records for the next build and for `mcpp clean --stale`. +// 2026-10-31 §8, 2026-11-12 §17): named by its lineage, emptied when its +// compiler changes, taking over a directory an earlier mcpp named by the whole +// fingerprint, and what it records for the next build and for `mcpp clean --stale`. module mcpp.build.prepare; import :state; @@ -8,6 +9,8 @@ import :state; import std; import mcpp.bmi_cache.maintenance; // dir_size, human_bytes import mcpp.build.dirlock; +import mcpp.build.graph_engine; +import mcpp.build.cache_key; // kCacheEpoch: the epoch the graph's engine link carries import mcpp.home.files; import mcpp.log; import mcpp.toolchain.fingerprint; @@ -17,28 +20,7 @@ namespace mcpp::build { namespace { -// A CONFIGURATION'S LINEAGE (design 2026-10-31 §8.2): the fingerprint's -// parts a developer switches between and keeps side by side -- the compiler -// family, the target, the standard library's family, the language standard and -// the configuration's flags. The rest evolve with time: the compiler's version -// and identity, the standard library's version, mcpp's version, the std -// module and the runtime contract. A new fingerprint of a lineage that has a -// directory takes that directory over instead of opening another copy of the -// build: ninja then rebuilds the edges whose commands changed and reuses the -// rest. Each mcpp upgrade used to open a full new directory (mcppls: 2.9 GB per -// configuration) and rebuild everything. -std::string lineage_text(const mcpp::toolchain::Fingerprint& fp) { - const auto stdlibFamily = fp.parts[4].substr(0, fp.parts[4].find(' ')); - return std::format("compiler {}\ntarget {}\nstdlib {}\nstandard {}\nconfiguration {}\n", - fp.parts[0], fp.parts[3], stdlibFamily, fp.parts[5], fp.parts[6]); -} - -// The toolchain's part of the evolution. Objects and BMIs of another compiler -// build are never reused, so a predecessor that differs here is removed rather -// than taken over: the build starts over in one directory instead of two. -std::string toolchain_text(const mcpp::toolchain::Fingerprint& fp) { - return std::format("compiler {}\ndriver {}\nstdlib {}\n", fp.parts[1], fp.parts[2], fp.parts[4]); -} +using mcpp::toolchain::lineage_text, mcpp::toolchain::toolchain_text; // THE COMPILER RECORDS OF THE PREDECESSOR ARE NOT TAKEN OVER. Beside each object // a split compile keeps its slot's records (`.sched.*`, compiler @@ -69,8 +51,46 @@ std::string read_small(const std::filesystem::path& file) { return {std::istreambuf_iterator(in), {}}; } +// `text` without its line ` ...`. +std::string without_line(std::string_view text, std::string_view key) { + std::string out; + for (auto const part : std::views::split(text, '\n')) { + const std::string_view line(part.begin(), part.end()); + if (line.empty() || (line.starts_with(key) && line.substr(key.size()).starts_with(' '))) continue; + out += line; + out += '\n'; + } + return out; +} + +// A RECORD AN EARLIER MCPP WROTE (before 2026.11.13.1) states fewer lines: its +// lineage no `base`, its toolchain no `runtime`. It is read on the lines it +// states, so that an upgrade takes the directory over rather than leaving it +// beside a new one (measured: the 753 MB directory of mcpp's own build an +// earlier mcpp of this release wrote, left). A base switched since is in the +// commands that name it, which ninja compiles again. +bool same_record(std::string_view was, std::string_view now, std::string_view added) { + if (was == now) return true; + const bool states = was.starts_with(std::string(added) + " ") + || was.find(std::format("\n{} ", added)) != std::string_view::npos; + return !was.empty() && !states && was == without_line(now, added); +} + } // namespace +// A DIRECTORY AN EARLIER MCPP NAMED BY THE WHOLE FINGERPRINT (before +// 2026.11.13.1), or one this configuration left under another name, is taken +// over once by the lineage it records; from then on the name holds. +// What a directory's record says changed: the base when only its runtime line +// differs (a base rewritten in place), the toolchain otherwise. +std::string_view what_changed(std::string_view was, std::string_view now) { + auto without_runtime = [](std::string_view t) { + const auto at = t.find("runtime "); + return t.substr(0, at == std::string_view::npos ? t.size() : at); + }; + return without_runtime(was) == without_runtime(now) ? "the base" : "the toolchain"; +} + void adopt_predecessor_dir(const PrepareState& state, const std::filesystem::path& outputDir) { namespace fs = std::filesystem; std::error_code ec; @@ -83,7 +103,7 @@ void adopt_predecessor_dir(const PrepareState& state, const std::filesystem::pat for (auto const& d : fs::directory_iterator(outputDir.parent_path(), ec)) { std::error_code dec; if (d.path() == outputDir || !d.is_directory(dec) - || read_small(d.path() / "lineage") != lineage) + || !same_record(read_small(d.path() / "lineage"), lineage, "base")) continue; // The one this configuration used last: build.ninja is written by every plan. const auto t = fs::last_write_time(d.path() / "build.ninja", dec); @@ -94,7 +114,8 @@ void adopt_predecessor_dir(const PrepareState& state, const std::filesystem::pat auto held = mcpp::home::FileLock::try_acquire(*best, mcpp::build::dirlock::kLockName); if (!held) return; const auto shown = best->lexically_relative(state.workRoot).generic_string(); - const bool sameToolchain = read_small(*best / "toolchain") == toolchain_text(state.fp); + const auto was_toolchain = read_small(*best / "toolchain"); + const bool sameToolchain = same_record(was_toolchain, toolchain_text(state.fp), "runtime"); if (sameToolchain) { // Its entries move in, one rename each, beside the lock this build // may already have created there. @@ -116,13 +137,41 @@ void adopt_predecessor_dir(const PrepareState& state, const std::filesystem::pat fs::remove_all(*best, ec); if (!sameToolchain && !ec) mcpp::ui::status("Replaced", std::format( - "{} ({}): the toolchain changed, and nothing compiled there is reused", shown, - mcpp::bmi_cache::human_bytes(bytes))); + "{} ({}): {} changed, and nothing compiled there is reused", shown, + mcpp::bmi_cache::human_bytes(bytes), + what_changed(was_toolchain, toolchain_text(state.fp)))); +} + +std::filesystem::path renew_build_dir(const PrepareState& state, const std::filesystem::path& outputDir) { + namespace fs = std::filesystem; + const auto was = read_small(outputDir / "toolchain"); + if (!was.empty() && was != toolchain_text(state.fp)) { + // Held by this build (dirlock): nothing else writes here. + const auto bytes = mcpp::bmi_cache::dir_size(outputDir); + const auto lock = std::string(mcpp::build::dirlock::kLockName); + std::error_code ec; + for (auto const& e : fs::directory_iterator(outputDir, ec)) { + const auto name = e.path().filename().string(); + if (name == lock || name == lock + ".owner") continue; + std::error_code rec; + fs::remove_all(e.path(), rec); + } + mcpp::ui::status("Replaced", std::format( + "{} ({}): {} changed, and nothing compiled there is reused", + outputDir.lexically_relative(state.workRoot).generic_string(), mcpp::bmi_cache::human_bytes(bytes), + what_changed(was, toolchain_text(state.fp)))); + } else if (const auto fp = read_small(outputDir / "fingerprint"); !fp.empty() && fp != state.fp.hex + "\n") { + // Another mcpp, std module or runtime contract continues here: the + // compiler records describe the attempts of the build before, as those + // of a directory taken over do. + drop_compiler_records(outputDir / "obj"); + } + return mcpp::build::graph_engine::refresh(outputDir, mcpp::build::cache_key::kCacheEpoch); } -// What this configuration uses beside its directory (`mcpp clean --stale`), and -// its lineage and toolchain, which the next fingerprint of the configuration -// reads to take the directory over. +// What this configuration uses beside its directory (`mcpp clean --stale`); its +// lineage and toolchain, which the next build of the configuration reads; and +// the whole fingerprint, which the directory's name no longer states. void record_build_dir(const PrepareState& state, const std::filesystem::path& outputDir) { std::string uses; for (auto const& h : state.usedProgramOut) uses += "out " + h + "\n"; @@ -142,7 +191,8 @@ void record_build_dir(const PrepareState& state, const std::filesystem::path& ou if (was != uses || !std::filesystem::exists(outputDir / "uses.list", uec)) std::ofstream(outputDir / "uses.list", std::ios::binary) << uses; for (auto const& [name, text] : {std::pair{"lineage", lineage_text(state.fp)}, - std::pair{"toolchain", toolchain_text(state.fp)}}) + std::pair{"toolchain", toolchain_text(state.fp)}, + std::pair{"fingerprint", state.fp.hex + "\n"}}) if (read_small(outputDir / name) != text) std::ofstream(outputDir / name, std::ios::binary) << text; } diff --git a/src/build/prepare/build_programs.cpp b/src/build/prepare/build_programs.cpp index 83c421988..8bbbe992b 100644 --- a/src/build/prepare/build_programs.cpp +++ b/src/build/prepare/build_programs.cpp @@ -155,7 +155,7 @@ std::expected step9_root_build_program(PrepareState& state) { bpEnv.programRunner = program->runner; bpEnv.programIdentity = program->identity; bpEnv.programCompile = program->compile; - } else if (!state.overrides.plan_only) { + } else if (!state.overrides.describes()) { return std::unexpected(program.error()); } else { bpEnv.programError = program.error(); @@ -247,10 +247,10 @@ std::expected step9_root_build_program(PrepareState& state) { *state.m, *state.root, host->first, host->second, state.m->cppStandard, bpEnv); }); - if (!bp && !state.overrides.plan_only) { + if (!bp && !state.overrides.describes()) { return std::unexpected(bp.error()); } - // #699 item 2 (E3): under `emit build-database` (`plan_only`), a + // #699 item 2 (E3): under `emit build-database` (`PrepareIntent::Describe`), a // failing root build program describes the package without its // directives rather than costing the whole plan. Every mirror below // reads what the program would have added to `*m`, so skipping @@ -591,7 +591,7 @@ std::expected step9_member_build_programs(PrepareState& state pkg.manifest.cppStandard, bpEnv); }); if (!bp) { - if (!state.overrides.plan_only) + if (!state.overrides.describes()) return std::unexpected(std::format( "workspace member '{}': {}", pkg.manifest.package.name, bp.error())); state.planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED", diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp index 1dd4a485f..d4032b44b 100644 --- a/src/build/prepare/config.cpp +++ b/src/build/prepare/config.cpp @@ -86,7 +86,7 @@ void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, // `stale`, when given, turns the function into a comparison: nothing is // created or written, and every declared file that is missing or differs from // its declared content is appended. A build that describes itself rather than -// running (BuildOverrides::plan_only) reads the root package's generated files +// running (PrepareIntent::Describe) reads the root package's generated files // this way, because they live in the source tree it promises not to write. std::expected materialize_generated_files(const std::filesystem::path& root, diff --git a/src/build/prepare/configuration.cpp b/src/build/prepare/configuration.cpp index ca23b6f3d..4d36f1298 100644 --- a/src/build/prepare/configuration.cpp +++ b/src/build/prepare/configuration.cpp @@ -14,10 +14,11 @@ namespace mcpp::build { namespace fs = std::filesystem; -std::expected prepare_configuration(std::string_view targetTriple) { +std::expected prepare_configuration(std::string_view targetTriple, + PrepareIntent intent) { BuildOverrides ov; ov.target_triple = std::string(targetTriple); - ov.configuration_only = true; + ov.intent = intent == PrepareIntent::RunBuilt ? intent : PrepareIntent::Configure; std::error_code ec; if (auto root = mcpp::project::find_manifest_root(fs::current_path(ec))) { ov.project_root = *root; diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index c030a8e42..a1d38a7cc 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -1563,7 +1563,7 @@ step6_resolve_tool_key(PrepareState& state, HostToolCtx& ctx) { // build program that must RUN the tool while configuring // meets the same missing file it meets when the tool fails // to build (SPEC-007 R5.3), so no new contract follows. - if (state.overrides.plan_only) { + if (state.overrides.describes()) { state.planNotes.push_back({"MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED", std::format("host tool '{}' of package '{}' is not in " "the tool store and is not built while " @@ -1953,7 +1953,7 @@ static std::expected step6_dependency_build_programs(PrepareS bpEnv.toolPaths = tit->second; // The program, as the build programs' graph compiled it (mcpp#797). auto program = build_program_binary(state, i); - if (!program && !state.overrides.plan_only) return std::unexpected(program.error()); + if (!program && !state.overrides.describes()) return std::unexpected(program.error()); if (program) { bpEnv.programBinary = program->path; bpEnv.programRunner = program->runner; @@ -1978,7 +1978,7 @@ static std::expected step6_dependency_build_programs(PrepareS pkg.manifest.cppStandard, bpEnv); }); !r) { - // #699 item 2 (E3): under `emit build-database` (`plan_only`), + // #699 item 2 (E3): under `emit build-database` (`PrepareIntent::Describe`), // a failing build program describes its package without that // program's directives, instead of costing the whole plan — // the manifest's own configuration, the toolchain and the @@ -1988,7 +1988,7 @@ static std::expected step6_dependency_build_programs(PrepareS // that follows from the missing directives (a source the // program would have added, say) fails the member under the // ordinary rule (E1). - if (state.overrides.plan_only) { + if (state.overrides.describes()) { state.planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED", std::format("dependency '{}': {}", pkg.manifest.package.name, r.error()), @@ -2334,16 +2334,19 @@ std::expected phase6_features_and_host_tools(PrepareState& st if (auto r = step6_device_extensions_and_rules(state); !r) return std::unexpected(r.error()); if (auto r = step6_xlings_workspace_from_graph(state); !r) return std::unexpected(r.error()); - auto toolRequests = step6_tool_requests(state); - if (!toolRequests) return std::unexpected(toolRequests.error()); - // The root build program's toolchain phase (mcpp#755): its host modules - // are registered, and nothing that depends on the build toolchain has - // been built. A statement ends this pass; prepare starts again with it. - if (auto r = step6_toolchain_phase(state); !r) return std::unexpected(r.error()); - if (auto r = step6_provision_host_tools(state, *toolRequests); !r) - return std::unexpected(r.error()); - - if (auto r = step6_dependency_build_programs(state); !r) return std::unexpected(r.error()); + // THE PACKAGES' PROGRAMS AND TOOLS, compiled and run here: a preparation + // that compiles nothing (PrepareIntent::RunBuilt) has none to run. + if (state.overrides.compiles()) { + auto toolRequests = step6_tool_requests(state); + if (!toolRequests) return std::unexpected(toolRequests.error()); + // The root build program's toolchain phase (mcpp#755): its host modules + // are registered, and nothing that depends on the build toolchain has + // been built. A statement ends this pass; prepare starts again with it. + if (auto r = step6_toolchain_phase(state); !r) return std::unexpected(r.error()); + if (auto r = step6_provision_host_tools(state, *toolRequests); !r) + return std::unexpected(r.error()); + if (auto r = step6_dependency_build_programs(state); !r) return std::unexpected(r.error()); + } if (auto r = step6_capability_binding(state); !r) return std::unexpected(r.error()); diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index b6ce758ab..17084c3af 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -861,35 +861,12 @@ step4a_load_version_dep_locate(PrepareState& state, LoadVersionDepCtx& ctx) { // one package at once both ran the provider, and the second was told the // install succeeded while the first was still placing it: "install path // missing after fetch" (measured in CI, two e2e workers fetching compat.gtest -// into one home). A process takes the home's install turn before it installs a -// package and keeps it for the package's own dependencies, which its install -// hooks install first in the same process. A turn that had to be waited for -// is reported, so that what its holder installed is looked for again. -namespace { -thread_local int g_installTurnDepth = 0; -thread_local std::optional g_installTurn; - -class InstallTurn { -public: - InstallTurn(const mcpp::config::GlobalConfig& cfg, const std::string& what, bool& waited) { - waited = false; - if (g_installTurnDepth++ > 0) return; - std::error_code ec; - std::filesystem::create_directories(cfg.registryDir, ec); - g_installTurn = mcpp::home::FileLock::acquire( - cfg.registryDir, ".install.lock", std::format("installing {}", what), - std::chrono::milliseconds(0), [&](std::string_view holder) { - waited = true; - mcpp::ui::status("Waiting", holder.empty() - ? std::string("for another mcpp of this home to finish an install") - : std::format("for another mcpp of this home, {}", holder)); - }); - } - ~InstallTurn() { if (--g_installTurnDepth == 0) g_installTurn.reset(); } - InstallTurn(const InstallTurn&) = delete; - InstallTurn& operator=(const InstallTurn&) = delete; -}; -} // namespace +// into one home). A process takes the provider's state turn (Provider:: +// state_dir) before it installs a package and keeps it for the package's own +// dependencies, which its install hooks install first in the same process; +// the provider's own writers take the same turn, nested. A turn that had to be +// waited for is reported, so that what its holder installed is looked for +// again. static std::expected step4a_load_version_dep_fetch(PrepareState& state, LoadVersionDepCtx& ctx) { @@ -917,10 +894,19 @@ step4a_load_version_dep_fetch(PrepareState& state, LoadVersionDepCtx& ctx) { } } - std::optional turn; + std::optional turn; if (!ctx.installed && !mcpp::effects::offline_mode()) { bool waited = false; - turn.emplace(**cfg, std::format("{}@{}", depName, version), waited); + const auto provider = mcpp::provider::open(ctx.useProjectEnv + ? mcpp::config::make_project_xlings_env(**cfg, state.projectIndexRoot()) + : mcpp::config::make_xlings_env(**cfg)); + turn.emplace(provider->state_dir(), mcpp::provider::kStateTurn, + std::format("installing {}@{}", depName, version), std::chrono::milliseconds(0), + [](std::string_view holder) { + mcpp::ui::status("Waiting", holder.empty() + ? std::string("for another mcpp of this home to finish an install") + : std::format("for another mcpp of this home, {}", holder)); + }, &waited); if (waited) if (auto again = step4a_load_version_dep_locate(state, ctx); !again) return again; } diff --git a/src/build/prepare/host_graph.cpp b/src/build/prepare/host_graph.cpp index 6c462cbaf..5053e843b 100644 --- a/src/build/prepare/host_graph.cpp +++ b/src/build/prepare/host_graph.cpp @@ -530,7 +530,7 @@ std::map resolution_pins(const PrepareState& state); // what a person or a test runs by hand with an environment of their choosing. // Placed when absent or older than the compiled program, by a build. static void place_program_copy(const PrepareState& state, std::size_t i, const fs::path& bin) { - if (state.overrides.plan_only) return; // a planning pass writes nothing in the project + if (state.overrides.describes()) return; // a planning pass writes nothing in the project const auto dest = build_program_dir(state, i) / (mcpplibs::os::is_windows ? "build.mcpp.exe" : "build.mcpp.bin"); std::error_code ec; @@ -717,7 +717,7 @@ build_group(PrepareState& state, const fs::path& dir, const std::vector const std::vector& goals, const HostToolchain& tc) { // The graph `mcpp emit build-database` describes, beside the one it builds. auto describe = [&](const BuildContext& ctx) { - if (!state.overrides.plan_only) return; + if (!state.overrides.describes()) return; BuildProgramGraph g; g.ctx = std::make_shared(ctx); g.dir = dir; @@ -1067,7 +1067,7 @@ ensure_build_programs(PrepareState& state, const std::vector& packa if (stale.empty()) { // Described, not built: `mcpp emit build-database` lists the graph // of programs that are current too. - if (state.overrides.plan_only) { + if (state.overrides.describes()) { write_group(state, dir, programs, standard, *enginePackage, tc->spec); if (auto r = build_group(state, dir, programs, {}, *tc); !r) mcpp::log::verbose("host-graph", r.error()); diff --git a/src/build/prepare/local_toolchain.cpp b/src/build/prepare/local_toolchain.cpp index 2564196fb..a1ce42728 100644 --- a/src/build/prepare/local_toolchain.cpp +++ b/src/build/prepare/local_toolchain.cpp @@ -303,7 +303,7 @@ step2_use_local_toolchain(PrepareState& state, const mcpp::toolchain::ToolchainS pkg.ximName = family; pkg.family = family == "llvm" ? mcpp::toolchain::Family::Llvm : mcpp::toolchain::Family::Gcc; pkg.needsGccPostInstallFixup = family == "gcc"; - if (auto provided = state.provide_runtime_payload(pkg); !provided) + if (auto provided = state.provide_runtime_payload(pkg, {}); !provided) return std::unexpected(provided.error()); } return {}; diff --git a/src/build/prepare/manifest.cpp b/src/build/prepare/manifest.cpp index bc804d680..ed9ea8c9c 100644 --- a/src/build/prepare/manifest.cpp +++ b/src/build/prepare/manifest.cpp @@ -656,8 +656,8 @@ std::expected phase0_manifest_and_workspace(PrepareState& sta // this function emits, both taken from the root manifest's [dependencies]. { // Read where the project keeps it. A planning pass that writes - // elsewhere (plan_only) still resolves against the project's lock. - auto lockPath = (state.overrides.plan_only ? *state.root : state.workRoot) / "mcpp.lock"; + // elsewhere (PrepareIntent::Describe) still resolves against the project's lock. + auto lockPath = (state.overrides.describes() ? *state.root : state.workRoot) / "mcpp.lock"; // A workspace keeps one lock at its root (§15). Until its first build // has written it, a selected member's own lock from before is read in // its place, so the upgrade does not move a git dependency. diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 0621cc8dd..1faa6ab73 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1021,6 +1021,7 @@ static void step13_graph_and_schedule(PrepareState& state, BuildContext& ctx) { if (auto c = state.get_cfg(/*requireBootstrap=*/false)) globalDefaultJobs = static_cast((*c)->defaultJobs); ctx.globalDefaultJobs = globalDefaultJobs; + ctx.intent = state.overrides.intent; // Resolve the module-edge schedule ONCE, here, where both the toolchain and // the manifest are in hand. The backend writes the graph in this shape, the @@ -1081,17 +1082,17 @@ static void step13_graph_and_schedule(PrepareState& state, BuildContext& ctx) { // moved in — an earlier assignment was silently overwritten by that move, // which produced a generated file that nothing ever passed to the driver. // Generated here rather than in compute_flags, which runs twice per build. - // A link input only: a plan that builds nothing (`plan_only`) neither reads + // A link input only: a plan that builds nothing (`PrepareIntent::Describe`) neither reads // it nor runs the driver to produce it, and its compile arguments are the // same without it. - if (state.tc->compiler == mcpp::toolchain::CompilerId::GCC && !state.overrides.plan_only) + if (state.tc->compiler == mcpp::toolchain::CompilerId::GCC && !state.overrides.describes()) ctx.plan.gccCleanSpecs = mcpp::toolchain::write_clean_link_specs( state.tc->binaryPath, ctx.outputDir); // THE WAY BACK FROM A TRIMMED PATH (design 2026-10-23 §15.3): a debugger // loads these and finds the sources the debug information names by // `@/…`. Written with the build that produced them, and // only when there is debug information for them to serve. - if (!ctx.plan.trimMap.empty() && state.m->buildConfig.debug && !state.overrides.plan_only) { + if (!ctx.plan.trimMap.empty() && state.m->buildConfig.debug && !state.overrides.describes()) { std::error_code ec; std::filesystem::create_directories(ctx.outputDir, ec); std::ofstream gdb(ctx.outputDir / "source-map.gdbinit", std::ios::binary | std::ios::trunc); @@ -2239,17 +2240,19 @@ std::expected phase13_finish(PrepareState& state) { // before this line -- the manifest, resolution, the network, the // toolchain, the scan -- ran holding nothing, and a second build of the // same configuration waits here, then finds ninja with little to do. A - // pass that only describes (plan_only) plans into its own directory. + // pass that only describes (PrepareIntent::Describe) plans into its own directory. // `--deny write-project` stops here, before the first write (design // 2026-10-26 §5.5). - if (!state.overrides.plan_only && mcpp::effects::effect_denied("write-project")) { + if (!state.overrides.describes() && mcpp::effects::effect_denied("write-project")) { mcpp::effects::note_effect_refused("write-project", ctx.outputDir.generic_string()); return std::unexpected(std::format( "effect denied: write-project -- the build writes {}, and the caller denied writing " "the project (--deny write-project)", ctx.outputDir.generic_string())); } - if (!state.overrides.plan_only) adopt_predecessor_dir(state, ctx.outputDir); - if (!state.overrides.plan_only) ctx.buildLock = mcpp::build::dirlock::acquire(ctx.outputDir); + if (!state.overrides.describes()) adopt_predecessor_dir(state, ctx.outputDir); + if (!state.overrides.describes()) ctx.buildLock = mcpp::build::dirlock::acquire(ctx.outputDir); + std::filesystem::path graphEngine; + if (!state.overrides.describes()) graphEngine = renew_build_dir(state, ctx.outputDir); // Each step states its duration under `build/stage` in the log file, as // the phases do (build wall-time plan, W9; see prepare_build). @@ -2276,6 +2279,7 @@ std::expected phase13_finish(PrepareState& state) { return std::unexpected(r.error()); if (auto r = timed("make plan", [&] { return step13_make_plan(state, ctx); }); !r) return std::unexpected(r.error()); + ctx.plan.engine = graphEngine; if (auto r = timed("cxx islands", [&] { return step13_cxx_islands(state, ctx); }); !r) return std::unexpected(r.error()); if (auto r = timed("cxx private runtime", [&] { return step13_cxx_private_runtime(state, ctx); }); !r) @@ -2303,7 +2307,7 @@ std::expected phase13_finish(PrepareState& state) { return std::unexpected(r.error()); timed("report packages", [&] { step13_report_packages(state, ctx); return 0; }); // Last: what the plan used is known only now (the dependency cache's hits). - if (!state.overrides.plan_only) record_build_dir(state, ctx.outputDir); + if (!state.overrides.describes()) record_build_dir(state, ctx.outputDir); ctx.planNotes = std::move(state.planNotes); return ctx; diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index fd2400c79..bb20d45de 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -204,6 +204,7 @@ void define_platform(const mp::Statement& s, std::string_view shortName, std::st r.flags = set.keep(d.flags); r.linkEmulation = set.keep(d.linkEmulation); r.clangTriple = set.keep(d.clangTriple); + r.gccTriple = set.keep(d.gccTriple); r.objectFormat = set.keep(d.objectFormat); r.family = set.keep(d.family); r.dataModel = set.keep(d.dataModel); @@ -528,10 +529,7 @@ std::expected step4b_apply_platform(PrepareState& state) { mcpp::platform::origin_text(P.selection.origin))); } P.selection.hosted = *hosted; - state.platformData = mcpp::platform::provider_of(found->manifest.platform, t.str()) - ? mcpp::platform::provider_view(found->manifest.platform, t.str()) - : *hosted ? mcpp::platform::hosted_view(found->manifest.platform) - : found->manifest.platform; + state.platformData = mcpp::platform::view_of(found->manifest.platform, t.str(), *hosted); P.fields = state.platformData; const auto ctx = platform_cfg_context(state); std::vector config; @@ -1061,6 +1059,10 @@ mp::Statement platform_statement(const PrepareState& state, std::vector step13_finish_platform(PrepareState& state, Bui auto P = state.platform; const auto t = target_of(state); + // A preparation links when it compiles and does not only describe: the + // run of an archive (RunBuilt) links nothing, and its link and seal + // programs were the machine's that wrote the archive. + const bool linksSomething = state.overrides.compiles() && !state.overrides.describes() + && std::ranges::any_of(ctx.plan.linkUnits, [](auto const& lu) { + return lu.kind != mcpp::build::LinkUnit::StaticLibrary; + }); + // ── The programs the link and the seal run (§4.4, §4.6) ─────────────── if (P.active) { std::set needed; @@ -1098,16 +1108,13 @@ std::expected step13_finish_platform(PrepareState& state, Bui for (auto const& [name, e] : P.fields.tools) if (auto found = locate_program(state, ctx, e.ref.program.empty() ? name : e.ref.program)) P.tools[name] = found->generic_string(); - const bool linksSomething = std::ranges::any_of(ctx.plan.linkUnits, [](auto const& lu) { - return lu.kind != mcpp::build::LinkUnit::StaticLibrary; - }); for (auto const& name : needed) { if (P.tools.contains(name)) continue; if (auto found = locate_program(state, ctx, name)) { P.tools[name] = found->generic_string(); continue; } - if (!linksSomething || state.overrides.plan_only) continue; + if (!linksSomething) continue; refusal::record(refusal::Code::PlatformToolAbsent); auto it = P.fields.tools.find(name); const std::string from = it == P.fields.tools.end() @@ -1127,9 +1134,7 @@ std::expected step13_finish_platform(PrepareState& state, Bui } // A platform that links with ld.lld needs one beside the compiler; its // absence is said here, not as a link line that starts with a flag. - if (P.active && P.fields.link.driver.value == "lld" && !state.overrides.plan_only - && std::ranges::any_of(ctx.plan.linkUnits, [](auto const& lu) { - return lu.kind != mcpp::build::LinkUnit::StaticLibrary; }) + if (P.active && P.fields.link.driver.value == "lld" && linksSomething && mcpp::freestanding::resolve_lld_any(state.tc->binaryPath).empty()) { refusal::record(refusal::Code::LldRequiredAbsent); return std::unexpected(std::format( @@ -1169,7 +1174,7 @@ std::expected step13_finish_platform(PrepareState& state, Bui state.hostPlatformTarget, state.hostPlatformRunner.empty() ? std::string("its runner") : std::filesystem::path(state.hostPlatformRunner.front()).filename().string())); - if (state.overrides.configuration_only) { + if (state.overrides.configures()) { if (ctx.hostPlatform.active) { ctx.hostTriple = state.hostPlatformTarget; ctx.hostCompiler = ctx.hostPlatform.fields.toolchain.value; diff --git a/src/build/prepare/retarget.cpp b/src/build/prepare/retarget.cpp index 1aaad88db..1e7b6ba5a 100644 --- a/src/build/prepare/retarget.cpp +++ b/src/build/prepare/retarget.cpp @@ -33,6 +33,7 @@ import mcpp.freestanding.linkline; // the ISA profile, for the std module comman import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; +import mcpp.toolchain.target_rows; import mcpp.toolchain.base; import mcpp.toolchain.base_probe; import mcpp.home; @@ -109,6 +110,25 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { // already names: a RETARGETABLE driver has to be told. gcc is not one — a // gcc payload IS its target — so the mingw and musl-gcc crosses keep // answering from `-dumpmachine`, which for them remains a measured fact. + // A ROW THAT STATES ITS GCC TARGET (`gcc-triple`) names the payload whose + // compiler is that target, and `-dumpmachine` answers in GCC's spelling + // (`i586-pc-msdosdjgpp`) rather than the row's (`i586-dos-djgpp`). The + // measured answer is checked against the row's statement and then named + // by the row, so the output directory, the fingerprint and every flag + // decision read the identity the build was asked for. + if (!state.overrides.target_triple.empty() + && state.tc->compiler == mcpp::toolchain::CompilerId::GCC) { + if (auto want = mcpp::toolchain::triple::parse(state.overrides.target_triple)) { + if (auto const* r = mcpp::toolchain::target::find_row(want->str()); r && !r->gccTriple.empty()) { + if (state.tc->targetTriple != r->gccTriple) + return std::unexpected(std::format( + "the compiler {} reports the target '{}', and the row '{}' states its GCC target " + "'{}' (`gcc-triple`)", + state.tc->binaryPath.string(), state.tc->targetTriple, want->str(), r->gccTriple)); + state.tc->targetTriple = want->str(); + } + } + } if (!state.overrides.target_triple.empty()) { if (auto want = mcpp::toolchain::triple::parse(state.overrides.target_triple); want && (want->is_freestanding() diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index a39c18d17..a4e460ae5 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -37,6 +37,7 @@ import mcpp.freestanding.target; // the target sysroot layout (libdir) import mcpp.freestanding.linkline; // the ISA profile, for the std module command import mcpp.toolchain.post_install; import mcpp.toolchain.abi; +import mcpp.toolchain.provider; // capabilities_for: whether C++ units can be built import mcpp.toolchain.triple; import mcpp.build.plan; import mcpp.build.schedule.policy; @@ -909,8 +910,11 @@ step11_std_module_availability_gate(PrepareState& state, bool needsStdModule) { state.tc->targetTriple, why, remedy)); } return std::unexpected(std::format( - "source imports std but toolchain '{}' provides no std module source", - state.tc->label())); + "source imports std but toolchain '{}' provides no std module source{}", + state.tc->label(), + state.tc->stdModuleUnanswered.empty() + ? std::string{} + : std::format("\n the compiler did not answer: {}", state.tc->stdModuleUnanswered))); } // `std.compat` is a second module of the same library, and not every // library has it: a package that supplies `std` alone, or a toolset without @@ -1033,7 +1037,7 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { auto stdTt = mcpp::toolchain::triple::parse(state.tc->targetTriple); return stdTt && stdTt->os == "macos"; }(); - if (state.overrides.plan_only) { + if (state.overrides.describes()) { // Described, not compiled: the paths and commands are the ones // ensure_built would use, from the one derivation in stdmod.cppm. auto described = mcpp::toolchain::describe_std_module( @@ -1193,10 +1197,44 @@ static void step11_public_module_check(PrepareState& state) { } } +// A C++ unit is built as a module unit and its dependencies are read by P1689 +// scanning; a compiler with neither can build the graph's C units and none of +// its C++ ones. Refused here, naming the compiler and the first C++ source, +// rather than at the first compile, where the compiler rejects `-fmodules`. +static std::expected +step11_cxx_compiler_gate(PrepareState& state) { + if (!state.tc) return {}; + const auto caps = mcpp::toolchain::capabilities_for(*state.tc); + if (caps.has_modules) return {}; + // A compiler without C++ modules builds the units that declare and import + // none, as translation units without module flags; the graph is mcpp's own + // scan. The first unit that needs modules is named, with what it needs. + for (auto const& u : state.scan.graph.units) { + if (u.kind != mcpp::SourceKind::Cxx && u.kind != mcpp::SourceKind::ModuleInterface) + continue; + std::string needs; + if (u.provides) needs = std::format("declares the module `{}`", u.provides->logicalName); + else if (u.declaration != mcpp::modgraph::ModuleDeclaration::None) + needs = "is a module unit"; + else if (!u.requires_.empty()) + needs = std::format("imports `{}`", u.requires_.front().logicalName); + else if (u.kind == mcpp::SourceKind::ModuleInterface) + needs = "is a module interface by its extension"; + if (needs.empty()) continue; + return std::unexpected(std::format( + "{} has no C++ modules, which GCC provides from 14: {} {}. C++ sources that declare " + "and import no module, and C sources, build with it.\n" + " help: a program of modules on {} needs a toolchain of GCC 14 or later, or Clang", + state.tc->label(), u.relPath.generic_string(), needs, state.tc->targetTriple)); + } + return {}; +} + std::expected phase11_scan(PrepareState& state) { auto needsStdModule = step11_scan_sources(state); if (!needsStdModule) return std::unexpected(needsStdModule.error()); if (auto r = refuse_plugin_units_in_target(state); !r) return std::unexpected(r.error()); + if (auto r = step11_cxx_compiler_gate(state); !r) return std::unexpected(r.error()); step11_public_module_check(state); if (auto r = step11_dependency_standard_scope_check(state); !r) @@ -1215,7 +1253,7 @@ std::expected phase11_scan(PrepareState& state) { return std::unexpected(r.error()); if (auto r = step11_std_module_availability_gate(state, *needsStdModule); !r) { - if (!state.overrides.configuration_only) return std::unexpected(r.error()); + if (!state.overrides.configures()) return std::unexpected(r.error()); // The configuration is reported; the sources' refusal travels with it. PlanNote note; // a warning: the document is complete without the sources note.code = "MCPP_CONFIGURATION_SOURCES_REFUSED"; @@ -1232,6 +1270,7 @@ std::expected phase11_scan(PrepareState& state) { if (state.print_fingerprint) { std::println("Toolchain: {}", state.tc->label()); std::println("Fingerprint: {}", state.fp.hex); + std::println("Lineage: {} (the build directory's name)", state.fp.lineage); for (std::size_t i = 0; i < state.fp.parts.size(); ++i) { std::println(" [{}] {}", i + 1, state.fp.parts[i]); } diff --git a/src/build/prepare/sources.cpp b/src/build/prepare/sources.cpp index 2b54ce5c1..da188b01e 100644 --- a/src/build/prepare/sources.cpp +++ b/src/build/prepare/sources.cpp @@ -443,7 +443,7 @@ answer_payload_requests(PrepareState& state, mcpp::manifest::Manifest& m, auto wit = state.xlingsWinner.find(key); addresses.push_back(wit == state.xlingsWinner.end() ? key : wit->second); } - if (state.overrides.plan_only) { + if (state.overrides.describes()) { std::string list; for (auto const& a : addresses) list += (list.empty() ? "" : ", ") + a; state.planNotes.push_back({"MCPP_BUILD_DATABASE_PAYLOAD_DEFERRED", @@ -485,7 +485,7 @@ run_answering_requests(PrepareState& state, mcpp::manifest::Manifest& m, // At most three rounds: a program may learn what it needs only from what // it received, but a program that asks for something new every time is a // loop, and a named refusal beats an unbounded one. - e.keepRequestingRun = state.overrides.plan_only; + e.keepRequestingRun = state.overrides.describes(); for (int round = 0;; ++round) { auto r = run(); if (!r) return r; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 66559eccd..909d99888 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -616,7 +616,8 @@ struct PrepareState { std::string runtimePayload; std::filesystem::path runtimeLibDir; bool runtimePayloadProvided = false; - std::function(const mcpp::toolchain::XimToolchainPackage&)> provide_runtime_payload; + std::function(const mcpp::toolchain::XimToolchainPackage&, + const std::filesystem::path&)> provide_runtime_payload; std::string effectiveProfile; std::vector storeRoots; std::optional tcSpec; @@ -793,7 +794,7 @@ struct PrepareState { std::map buildProgramBinaries; // The roots (package directories) planned as build plugins (P3). std::set buildPluginRoots; - // The build programs' graphs planned for description (`plan_only`). + // The build programs' graphs planned for description (`PrepareIntent::Describe`). std::vector buildProgramGraphs; // A use of a deprecated reading (mcpp.pm.compat.registry) by the manifest // of package `i`: warned where that manifest can be edited -- the root, a @@ -967,7 +968,7 @@ struct PrepareState { std::map> deviceSourcesByPackage; // Keyed like `deviceSourcesByPackage`, by `pkg.root.string()` (root // package included: `packages[0].root == *root`). Holds a package whose - // build program failed IN THIS PASS, under `plan_only` (`emit + // build program failed IN THIS PASS, under `PrepareIntent::Describe` (`emit // build-database`) — the one case a failed program does not already end // the whole call (SPEC-005 R5.2, #699 item 2, E3). A check whose premise // is that program's directives must not run for such a package: with no @@ -1216,6 +1217,11 @@ std::string with_index_cause(std::string msg); // A new fingerprint of a lineage takes over its predecessor's directory, or // removes it when the toolchain changed (design 2026-10-31 §8.2). void adopt_predecessor_dir(const PrepareState& state, const std::filesystem::path& outputDir); +// The configuration's directory, under its lock (2026-11-12 §17, W1): emptied +// when the compiler build it records is not this one's, its compiler records +// dropped when another engine wrote them; returns the engine its graph names, +// made this one. +std::filesystem::path renew_build_dir(const PrepareState& state, const std::filesystem::path& outputDir); // `uses.list`, `lineage` and `toolchain` in the directory. void record_build_dir(const PrepareState& state, const std::filesystem::path& outputDir); @@ -1263,7 +1269,7 @@ void fill_xpkg_env(PrepareState& state, mcpp::build::BuildProgramEnv& e, const mcpp::manifest::Manifest& owner, std::size_t consumer); // After a run of `who`'s build program into `m`: when it asked for payloads, // install them in one batch and refill `e`; true when the program must run -// again. Under `plan_only` nothing is installed: a note is recorded and false +// again. Under `PrepareIntent::Describe` nothing is installed: a note is recorded and false // is returned. std::expected answer_payload_requests(PrepareState& state, mcpp::manifest::Manifest& m, diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index c637a8f06..f64362682 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -497,9 +497,11 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { auto realised = mcpp::toolchain::cenv::realise( *state.resolvedTargetSide.cAbiDecl, tt ? tt->os : std::string{}, tt ? tt->arch : std::string{}, + tt ? mcpp::toolchain::triple::to_string(tt->object_format()) : std::string_view{}, // A program its loader runs is told no operating system, as a - // bare machine is: the C library's identity macros are the only ones. - tt && (tt->is_freestanding() || tt->loader_supplies_os())); + // bare machine is, and so is a row whose compile triple names + // none: the C library's identity macros are the only ones. + tt && tt->compiler_told_no_os()); if (!realised) { refusal::record(refusal::Code::CEnvUnrealisable); return std::unexpected(realised.error()); @@ -712,6 +714,27 @@ static void for_tests(PrepareState& state, const mcpp::modgraph::UsageRequiremen } } +// THE PACKAGE THAT RESOLVED AS THE KERNEL-ABI LAYER AND STATES ITS INTERFACES; +// null when none states them. The list comes from the package that resolved as +// the layer, not from the first one in the graph that stated one: a graph may +// carry more than one candidate (a workspace member beside a dependency, a +// second implementation behind a feature that did not activate), and only one +// is the provider this build uses. `impl` is `name@version`; the name is what +// precedes the separator, so `openkal` is not matched against `openkal-linux`. +static const mcpp::modgraph::PackageRoot* kernel_abi_provider(const PrepareState& state) { + for (auto const& pkg : state.packages) { + if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; + if (!state.resolvedTargetSide.kernelAbi.impl.empty()) { + auto const& impl = state.resolvedTargetSide.kernelAbi.impl; + const auto at = impl.find('@'); + const auto implName = at == std::string::npos ? impl : impl.substr(0, at); + if (implName != pkg.manifest.package.name) continue; + } + return &pkg; + } + return nullptr; +} + static std::expected step9_target_side_include_broadcast(PrepareState& state, TargetSideGather& gather) { namespace tsd = mcpp::targetside; @@ -954,22 +977,9 @@ step9_kernel_abi_interface_enumeration(PrepareState& state) { // rather than a missing one. std::vector providedInterfaces; std::string providerId; - for (auto& pkg : state.packages) { - if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; - // `impl` is `name@version`; the name is what precedes the - // separator. A substring test would match `openkal` against - // `openkal-linux@0.15.0` and read one implementation's list - // as another's. - if (!state.resolvedTargetSide.kernelAbi.impl.empty()) { - auto const& impl = state.resolvedTargetSide.kernelAbi.impl; - const auto at = impl.find('@'); - const auto implName = at == std::string::npos - ? impl : impl.substr(0, at); - if (implName != pkg.manifest.package.name) continue; - } - providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces; - providerId = pkg.manifest.package.name; - break; + if (auto const* pkg = kernel_abi_provider(state)) { + providedInterfaces = pkg->manifest.kernelAbiProvidesInterfaces; + providerId = pkg->manifest.package.name; } // A REQUIREMENT NOBODY ANSWERED IS SAID SO, because otherwise // "yes" and "never asked" are the same reading. @@ -1510,6 +1520,10 @@ static std::expected step9_layer_conditional_config(PrepareSt layerCtx.kernelAbi = state.resolvedTargetSide.kernelAbi.interfaceName; layerCtx.cAbi = state.resolvedTargetSide.cAbi.interfaceName; layerCtx.cxxAbi = state.resolvedTargetSide.cxx.interfaceName; + if (auto const* pkg = kernel_abi_provider(state)) { + layerCtx.interfaces = pkg->manifest.kernelAbiProvidesInterfaces; + layerCtx.interfacesStated = true; + } // A REQUIREMENT A LAYER DECIDES IS CHECKED NOW, WHEN THE LAYERS ARE // KNOWN. `[target.'cfg(all(windows, compiler = "llvm"))'] requires = // ["mcpp:compiler-runtime=compiler-rt"]` states what one compiler needs @@ -1881,7 +1895,7 @@ static std::expected step9_device_sources_reach_an_action(Pre // // THE PREMISE OF THIS CHECK IS THE BUILD PROGRAM'S DIRECTIVES: an action // consuming a device source is one such directive. A package whose program - // failed in this pass (`plan_only`, above) applied none of them, so every + // failed in this pass (`PrepareIntent::Describe`, above) applied none of them, so every // device source would read as an orphan -- not a second defect, only the // shape the first one takes here. Such a package already carries its one // diagnostic, `MCPP_BUILD_DATABASE_PROGRAM_FAILED`; this check does not run @@ -2096,8 +2110,11 @@ std::expected phase9_target_side(PrepareState& state) { if (auto r = step9_layer_conditional_config(state); !r) return std::unexpected(r.error()); if (auto r = step9_dependency_link_forms(state); !r) return std::unexpected(r.error()); step9_define_graph_package_entry_closure(state); - if (auto r = step9_root_build_program(state); !r) return std::unexpected(r.error()); - if (auto r = step9_member_build_programs(state); !r) return std::unexpected(r.error()); + // The root's and the members' programs (see phase 6): none where nothing is compiled. + if (state.overrides.compiles()) { + if (auto r = step9_root_build_program(state); !r) return std::unexpected(r.error()); + if (auto r = step9_member_build_programs(state); !r) return std::unexpected(r.error()); + } if (auto r = step9_device_sources_reach_an_action(state); !r) return std::unexpected(r.error()); if (auto r = step9_rerun_input_prepare_dir(state); !r) return std::unexpected(r.error()); diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index ce240a045..8e2abe869 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -136,7 +136,7 @@ static std::expected step1_define_early_toolchain_closures(Pr if (!state.cfg_opt) { auto c = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback(), {}, - state.overrides.plan_only ? mcpp::config::Initialization::ReadOnly + state.overrides.describes() ? mcpp::config::Initialization::ReadOnly : mcpp::config::Initialization::Bootstrap); if (!c) return std::unexpected(c.error().message); state.cfg_opt = std::move(*c); @@ -144,7 +144,7 @@ static std::expected step1_define_early_toolchain_closures(Pr // Execution requires the bootstrap tools. A planning pass reads the // installed toolchain without requiring an initialized provider or // installing tools it will not execute. - if (requireBootstrap && !state.overrides.plan_only && !state.bootstrap_checked) { + if (requireBootstrap && !state.overrides.describes() && !state.bootstrap_checked) { state.bootstrap_checked = true; auto problem = mcpp::config::check_base_init(*state.cfg_opt); if (!problem.empty()) { @@ -213,10 +213,11 @@ static std::expected step1_define_early_toolchain_closures(Pr // refreshed with the binding, because detection and the fingerprint read // them afterwards. state.runtimePayloadProvided = false; - state.provide_runtime_payload = [&](const mcpp::toolchain::XimToolchainPackage& pkg) + state.provide_runtime_payload = [&](const mcpp::toolchain::XimToolchainPackage& pkg, + const std::filesystem::path& payloadRoot) -> std::expected { if (state.runtimePayloadProvided) return {}; - if (mcpp::toolchain::runtime_payload_kind(pkg).empty()) return {}; + if (mcpp::toolchain::runtime_payload_kind(pkg, payloadRoot).empty()) return {}; state.runtimePayloadProvided = true; auto cfgP = state.get_cfg(true); if (!cfgP) return {}; @@ -265,7 +266,11 @@ static std::expected step1_define_early_toolchain_closures(Pr if (!state.overrides.dependency_linkage.empty()) state.m->buildConfig.dependencyLinkage = state.overrides.dependency_linkage; state.m->buildConfig.optLevel = pr.optLevel; + if (!pr.debugWritten.empty() && pr.debugWritten != "lines") + return std::unexpected(std::format( + "[profile.{}] debug = \"{}\": expected true, false or \"lines\"", pname, pr.debugWritten)); state.m->buildConfig.debug = pr.debug; + state.m->buildConfig.debugLines = pr.debugLines; state.m->buildConfig.lto = pr.lto; state.m->buildConfig.strip = pr.strip; // `ndebug` and `trim_paths` follow the industry's convention for a @@ -298,7 +303,7 @@ static std::expected step1_define_early_toolchain_closures(Pr // SAID ONCE PER HOME, the first time a shipping profile takes the new // defaults without stating them: the artifact changed, and the line // that changes it back is one key. - if (shipping && !(pr.ndebugDeclared && pr.trimPathsDeclared) && !state.overrides.plan_only + if (shipping && !(pr.ndebugDeclared && pr.trimPathsDeclared) && !state.overrides.describes() && state.overrides.tool_depth == 0 && !state.overrides.host_target) { std::error_code ec; const auto marker = mcpp::home::root() / "cache" / "notes" / "profile-ndebug-trim-paths"; @@ -759,7 +764,15 @@ step1_validate_target_tier(PrepareState& state, TargetOverrideCtx& ctx) { sug ? std::format(" — did you mean '{}'?", *sug) : "", ctx.requestedSpelling)); } - if (ctx.known && ctx.known->tier == "planned" && !ctx.hasToolchainOverride) { + // A TOOLCHAIN NAMED FOR THE BUILD IS AS EXPLICIT AS ONE THE SECTION NAMES. + // The refusal says that no toolchain is published for the row; a build that + // names one -- `[target.] toolchain`, or `--toolchain`, which the + // command line states as MCPP_TOOLCHAIN -- has one; so does a consumer's + // sub-build given its consumer's. Before 2026.11.13.1 only the section did, + // and a platform's conformance run that names its toolchain on the command + // line was refused. + const bool toolchainNamed = ctx.hasToolchainOverride || state.tcFromCommandLine || state.tcFromConsumer; + if (ctx.known && ctx.known->tier == "planned" && !toolchainNamed) { refusal::record(refusal::Code::TierPlanned); // The subject is what the user wrote. When completion filled a // segment, both are shown — otherwise the sentence is about a @@ -772,7 +785,7 @@ step1_validate_target_tier(PrepareState& state, TargetOverrideCtx& ctx) { return std::unexpected(std::format( "target {} is registered but not yet supported (planned) — " "no toolchain is published for it yet.\n" - " An explicit [target.{}] toolchain override can opt in early.", + " A toolchain named for the build opts in early: [target.{}] toolchain, or --toolchain.", subject, ctx.parsed->str())); } return {}; @@ -1715,6 +1728,55 @@ step2_resolve_explicit_spec(PrepareState& state, ToolchainResolveCtx& ctx) { "toolchain '{}': {}", *state.tcSpec, payload.error().message)); } + // A PAYLOAD NAMED OUTRIGHT (`xim:@`, design 2026-11-12 + // §16 T2) is resolved as `path:` with the table it states of + // itself (`.mcpp-toolchain.json`, schema 2): the same step, the same + // probes and refusals as a directory a manifest names. + if (spec->describedPayload) { + auto d = mcpp::toolchain::read_payload_descriptor(payload->root); + if (!d) return std::unexpected(d.error()); + if (!*d) + return std::unexpected(std::format( + "toolchain '{}': the package has no {} at its root; a payload named " + "outright states its drivers there (\"schema\": 2, with \"family\", " + "\"prefix\", \"tools\")", + *state.tcSpec, mcpp::toolchain::payload_descriptor_filename)); + auto const& desc = **d; + mcpp::manifest::LocalToolchain lt; + lt.path = payload->root.generic_string(); + lt.prefix = desc.prefix; + lt.family = desc.family; + if (!desc.sysroot.empty()) lt.sysroot = (payload->root / desc.sysroot).generic_string(); + for (auto const& [role, p] : desc.tools) + lt.tools.emplace_back(role, (payload->root / p).generic_string()); + if (!desc.frontend.empty() + && std::ranges::none_of(lt.tools, [](auto const& t) { return t.first == "cxx"; })) + lt.tools.emplace_back("cxx", (payload->root / desc.frontend).generic_string()); + if (lt.family.empty()) { + // The family is the drivers' when the payload does not say: its + // C++ driver's name, else the drivers in `bin/`. + for (auto const& [role, p] : lt.tools) + if (role == "cxx") { + auto stem = std::filesystem::path(p).stem().string(); + lt.family = stem.ends_with("clang++") ? "llvm" : stem.ends_with("g++") ? "gcc" : ""; + } + if (lt.family.empty()) + if (auto fam = mcpp::toolchain::parse_toolchain_spec("path:" + lt.path); fam) + lt.family = mcpp::toolchain::family_name(fam->family); + } + SourceDecision origin; + origin.subject = "toolchain.build"; + origin.cls = SourceClass::Pinned; + origin.originKind = "manifest"; + origin.originKey = *state.tcSpec; + state.localToolchain = std::move(lt); + state.localToolchainOrigin = std::move(origin); + auto local = *spec; + local.localRoot = payload->root; + mcpp::ui::info("Resolved", std::format("{} → {}", spec->display(), payload->root.string())); + return step2_use_local_toolchain(state, local); + } + // A pinned MSVC toolset: the payload root IS a VS-shaped root and the // package version IS the toolset directory name, so cl.exe is // derived, not searched for. Nothing here can silently pick a @@ -1752,7 +1814,7 @@ step2_resolve_explicit_spec(PrepareState& state, ToolchainResolveCtx& ctx) { // manifest [toolchain] path previously ran none, so a freshly // auto-installed payload kept its stale install-time cfg / // unpatched runtime libs. - if (auto provided = state.provide_runtime_payload(pkg); !provided) + if (auto provided = state.provide_runtime_payload(pkg, payload->root); !provided) return std::unexpected(provided.error()); // Canonical rendering, whatever spelling the manifest/config used: // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". @@ -1986,7 +2048,7 @@ step2_first_run_auto_install(PrepareState& state) { // `mcpp toolchain install` performs — without it a fresh sandbox // gcc cannot find the C library (stdlib.h: No such file or // directory) and a fresh llvm keeps its stale install-time cfg. - if (auto provided = state.provide_runtime_payload(defaultPkg); !provided) + if (auto provided = state.provide_runtime_payload(defaultPkg, payload->root); !provided) return std::unexpected(provided.error()); // Persist the default so we don't ask again next time. @@ -2161,6 +2223,11 @@ step2_msvc_abi_without_msvc_repair(PrepareState& state) { state.tc->compiler == mcpp::toolchain::CompilerId::MSVC || mcpp::toolchain::is_msvc_target(*state.tc); if (targetsMsvcAbi && !state.msvc_usable_either_origin()) { + // Tests built on another machine run here and nothing is compiled + // (`mcpp test --archive`): the toolchain is neither refused nor + // replaced. Measured on a Windows runner with Visual Studio masked: + // every archived test was "not run" for the compiler it did not use. + if (!state.overrides.compiles()) return {}; // Native cl.exe is ALWAYS a deliberate choice: mcpp never selects // msvc@system on its own — it cannot install one — so the only way it // reaches config.toml is a user typing `mcpp toolchain default msvc`. @@ -2241,7 +2308,7 @@ step2_msvc_abi_without_msvc_repair(PrepareState& state) { gnuPkg.target(), mcpp::toolchain::payload_frontend_dir(payloadR->root, gnuPkg).string())); } - if (auto provided = state.provide_runtime_payload(gnuPkg); !provided) + if (auto provided = state.provide_runtime_payload(gnuPkg, payloadR->root); !provided) return std::unexpected(provided.error()); // Persist both axes so the repair happens once, not on every build. diff --git a/src/build/prepare/xlings.cpp b/src/build/prepare/xlings.cpp index 5a58aeef3..5fb0fc322 100644 --- a/src/build/prepare/xlings.cpp +++ b/src/build/prepare/xlings.cpp @@ -88,7 +88,7 @@ step3_define_host_tc_closures_and_refresh_index(PrepareState& state) { if (!state.m->buildConfig.generatedFiles.empty()) { std::vector staleGenerated; if (auto r = materialize_generated_files( - *state.root, *state.m, state.overrides.plan_only ? &staleGenerated : nullptr); !r) { + *state.root, *state.m, state.overrides.describes() ? &staleGenerated : nullptr); !r) { return std::unexpected(r.error()); } for (auto const& path : staleGenerated) @@ -285,7 +285,7 @@ step3_define_host_tc_closures_and_refresh_index(PrepareState& state) { pkg.target(), mcpp::toolchain::payload_frontend_dir(payload->root, pkg).string())); } - if (auto provided = state.provide_runtime_payload(pkg); !provided) + if (auto provided = state.provide_runtime_payload(pkg, payload->root); !provided) return std::unexpected(provided.error()); // SAME THREE ARGUMENTS THE NATIVE CALL USES (line ~3550), not the // one-argument form. `detect()` probes `payloadPaths` — the @@ -559,12 +559,12 @@ std::expected phase3_xlings_before_graph(PrepareState& state) // environment half (`penv`: deps/subos/workspace) belongs to the // runtime's owner, `runtimeSelection.ownerRoot`: the workspace // root when a member builds (e2e 205), the project root otherwise. - // Under `plan_only` (`emit build-database`) nothing is written + // Under `PrepareIntent::Describe` (`emit build-database`) nothing is written // into the project (SPEC-005 R2.1, mcpp#724 side finding B, e2e // 817), so the owner's half goes to the planning directory too. // The build programs' graph shares the indices of the build that // asked, whose project already holds them (`project_index_root`). - const auto& runtimeRoot = state.overrides.plan_only + const auto& runtimeRoot = state.overrides.describes() ? state.workRoot : state.runtimeSelection.ownerRoot; const auto indices = state.overrides.project_index_root.empty() ? state.m->indices : decltype(state.m->indices){}; diff --git a/src/build/prepare_inputs.cppm b/src/build/prepare_inputs.cppm index 845f71991..35279ebac 100644 --- a/src/build/prepare_inputs.cppm +++ b/src/build/prepare_inputs.cppm @@ -66,6 +66,7 @@ namespace cfgpred { // `requires = ["mcpp:compiler=llvm"]` permanently unsatisfiable. struct Ctx { std::string os, arch, family, env, triple; + std::string objectFormat; // the row's container, `cfg(object-format = ...)` // The selected platform (SPEC-013 §3): the platform's name (`openkal`) // and its package's (`openkal-platform`); `cfg(platform = ...)` matches // either. Empty when the target uses none. @@ -77,6 +78,14 @@ struct Ctx { // ROCm device code in one artifact requires, so this layer holds a set // rather than the single answer the other five hold. std::vector accelerators; + // THE INTERFACES OF THE KERNEL-ABI LAYER (`cfg(interface = "")`): the + // resolved provider's `[kernel-abi] provides-interfaces`, compared by + // membership as `accelerator` is. A provider that states none provides + // every one; a graph without the layer provides none. A package selects + // its sources by what the implementation beneath it provides, and names + // no implementation. + std::vector interfaces; + bool interfacesStated = false; std::string_view layer_value(std::string_view k) const { if (k == "compiler") return compiler; @@ -128,6 +137,10 @@ struct Ctx { if (v == "none") return accelerators.empty(); return std::ranges::find(accelerators, v) != accelerators.end(); } + if (k == "interface") { + if (kernelAbi.empty()) return false; + return !interfacesStated || std::ranges::find(interfaces, v) != interfaces.end(); + } return layer_value(k) == v; } }; @@ -153,6 +166,7 @@ inline Ctx context_for(std::string_view targetTriple, c.arch = t->arch; c.env = t->env; c.family = t->family(); + c.objectFormat = std::string(triple::object_format_word(t->object_format())); // Canonical spelling on BOTH sides of the later comparison, so an // `x86_64-w64-mingw32` key and an `x86_64-windows-gnu` build agree. c.triple = t->str(); @@ -280,6 +294,7 @@ struct Parser { if (k == "arch") return c.arch == v; if (k == "family") return c.family == v; if (k == "env") return c.env == v; + if (k == "object-format") return c.objectFormat == v; if (k == "platform") return !c.platform.empty() && (c.platform == v || c.platformPackage == v); // `accelerator` is answerable whenever the context carries the build's @@ -547,7 +562,9 @@ std::filesystem::path target_dir(const mcpp::toolchain::Toolchain& tc, // outside the language keep their raw spelling. auto triple = tc.targetTriple.empty() ? std::string{"unknown"} : tc.targetTriple; if (auto t = mcpp::toolchain::triple::parse(triple)) triple = t->str(); - return root / "target" / triple / fp.hex; + // Named by the configuration's lineage, which the commands compiled there + // name (2026-11-12 §17, W1); the evolving rest is recorded inside. + return root / "target" / triple / fp.lineage; } @@ -583,7 +600,7 @@ std::string canonical_configuration_flags(const mcpp::manifest::Manifest& root, s += flag; } s += " opt="; s += root.buildConfig.optLevel; - s += " debug="; s += root.buildConfig.debug ? "1" : "0"; + s += " debug="; s += root.buildConfig.debug ? (root.buildConfig.debugLines ? "lines" : "1") : "0"; s += " lto="; s += root.buildConfig.lto ? "1" : "0"; s += " strip="; s += root.buildConfig.strip ? "1" : "0"; // Only when on: a profile that has neither keeps the identity it had. diff --git a/src/build/progress.cppm b/src/build/progress.cppm index 690299e33..ca4882c0a 100644 --- a/src/build/progress.cppm +++ b/src/build/progress.cppm @@ -32,6 +32,7 @@ export module mcpp.build.progress; import std; import mcpp.run; import mcpp.build.action_progress; +import mcpp.build.dirlock; import mcpp.ui; import mcpp.ui.dots_screen; import mcpp.log; @@ -1454,17 +1455,23 @@ void finished(std::string_view profile, std::string_view descriptor) { // The breakdown and the longest step explain a wait; a command shorter // than a minute has none worth a longer line (build output design // revision 3, §7.3). + // The time spent waiting for a build directory another command held + // is said whenever it is a second or more: it is the part of a slow + // command no build of this one explains (2026-11-12 §17, W3). + const long long waited = mcpp::build::dirlock::waited().count(); + if (waited >= 1'000) + detail = "waited " + mcpp::ui::format_duration(ms(waited)) + " for the build directory"; if (total >= 60'000) { const long long build = r.buildStart ? total - *r.buildStart : 0; const long long programs = r.programTime.count(); - const long long plan = std::max(0, total - build - programs); + const long long plan = std::max(0, total - build - programs - waited); std::vector parts; if (plan > 0) parts.push_back("plan " + mcpp::ui::format_duration(ms(plan))); if (programs > 0) parts.push_back("programs " + mcpp::ui::format_duration(ms(programs))); if (build > 0) parts.push_back("build " + mcpp::ui::format_duration(ms(build))); if (parts.size() > 1) for (std::size_t i = 0; i < parts.size(); ++i) - detail += (i ? " · " : "") + parts[i]; + detail += (detail.empty() ? "" : " · ") + parts[i]; long long longest = 0; std::string label; for (auto const& b : r.builds) diff --git a/src/build/result.cppm b/src/build/result.cppm index d96faaef7..1fabb1438 100644 --- a/src/build/result.cppm +++ b/src/build/result.cppm @@ -399,7 +399,13 @@ Record begin(const std::filesystem::path& projectRoot, const std::filesystem::pa r.status = "running"; r.outputDir = outputDir; r.projectRoot = projectRoot; - r.fingerprint = outputDir.filename().string(); + // The directory records the whole fingerprint; its name is the lineage + // (2026-11-12 §17). A directory an earlier mcpp wrote is named by it. + { + std::ifstream in(outputDir / "fingerprint"); + if (!std::getline(in, r.fingerprint) || r.fingerprint.empty()) + r.fingerprint = outputDir.filename().string(); + } if (auto c = mcpp::invocation::get("MCPP_CLIENT")) r.client = *c; return r; } diff --git a/src/build/test_impact.cppm b/src/build/test_impact.cppm index 410c6fce9..f16776c66 100644 --- a/src/build/test_impact.cppm +++ b/src/build/test_impact.cppm @@ -3,8 +3,10 @@ // file it is built from -- its own files, the module units it imports and // the units they import in turn, and every unit of its closure that is not a // module unit -- and by a change of what decides how everything is built -// (a manifest, a lock, a build program, a header). Another file affects the -// tests whose own files name it. Selection by change narrows; it never adds +// (a manifest, a lock, a build program, a header). A test that runs a program +// of the package (a file it is built from names `MCPP_BIN_`) is affected by a +// change of any source of the package that is not a test's own. Another file +// affects the tests whose own files name it. Selection by change narrows; it never adds // a test that a pattern or `-p` left out. export module mcpp.build.test_impact; @@ -131,6 +133,23 @@ Affected affected_tests(const BuildPlan& plan, const std::vector& c if (lu.kind == LinkUnit::TestBinary) tests.emplace_back(&lu, closure_of(plan, lu, units, byObject)); auto affect = [&](const std::string& test, std::string why) { out.tests.emplace(test, std::move(why)); }; + // THE TESTS THAT RUN THE PACKAGE'S PROGRAMS. An end-to-end test is built + // from its own files and the support it imports, and runs the program it + // tests from `MCPP_BIN_`: nothing of the program is in its closure, + // and a change of the program selected none of them (measured on the e2e + // programs of mcpp: a change of src/ selected the unit tests alone). + std::map namesProgram; + auto names_program = [&](const std::filesystem::path& f) { + auto [it, fresh] = namesProgram.try_emplace(f, false); + if (fresh) it->second = read_text(f).find("MCPP_BIN_") != std::string::npos; + return it->second; + }; + std::vector runners; + std::set testsOwn; + for (auto const& [lu, files] : tests) { + for (auto const& own : lu->ownSources) testsOwn.insert(own.lexically_normal()); + if (std::ranges::any_of(files, names_program)) runners.push_back(lu); + } for (auto const& rel : changed) { const auto path = (plan.projectRoot / mcpplibs::os::fs::from_utf8(rel)).lexically_normal(); if (decides_the_build(path) || is_header(path) @@ -148,6 +167,12 @@ Affected affected_tests(const BuildPlan& plan, const std::vector& c affect(lu->targetName, std::format("it is built from {}", rel)); ++hits; } + if (bySource.contains(path) && !testsOwn.contains(path)) + for (const auto* lu : runners) + if (!out.tests.contains(lu->targetName)) { + affect(lu->targetName, std::format("it runs a program of the package, and {} is a source of it", rel)); + ++hits; + } if (bySource.contains(path)) { out.notes.push_back(std::format("{}: {} test{}", rel, hits, hits == 1 ? "" : "s")); continue; diff --git a/src/build/test_protocol.cppm b/src/build/test_protocol.cppm index ead300d84..58cab055d 100644 --- a/src/build/test_protocol.cppm +++ b/src/build/test_protocol.cppm @@ -18,7 +18,7 @@ inline constexpr int kVersion = 1; // The framework mcpp scaffolds tests with and suggests where a package has // none (docs/01, docs/08): mtest, by this release, from its repository. -inline constexpr std::string_view kMtestRelease = "0.5.2"; +inline constexpr std::string_view kMtestRelease = "0.8.5"; inline constexpr std::string_view kMtestRepo = "https://github.com/mcpp-community/mtest"; // The protocol's variables, removed from the environment a program inherits @@ -69,6 +69,8 @@ struct Case { std::vector tags; std::string expect; std::vector bounds; // P6.5 + std::vector resources; // P7.1: `machine` holds the machine alone + double timeout = 0; // seconds the case states it may run; 0: none stated }; // A failure an expectation of a case reported (P5.2). @@ -289,7 +291,9 @@ take(Report& r, std::map& open, const nlohmann::json& r.framework = text(e, "framework"); } else if (kind == "case") { r.listed.push_back({id, text(e, "name"), text(e, "file"), number(e, "line"), - text(e, "kind"), strings(e, "tags"), text(e, "expect"), bounds_of(e)}); + text(e, "kind"), strings(e, "tags"), text(e, "expect"), bounds_of(e), + strings(e, "resources"), + e.contains("timeout") && e["timeout"].is_number() ? e["timeout"].get() : 0.0}); } else if (kind == "begin") { open[id] = r.cases.size(); r.cases.push_back({id, "", 0, "", {}}); diff --git a/src/build/test_schedule.cppm b/src/build/test_schedule.cppm new file mode 100644 index 000000000..00bbfbb5a --- /dev/null +++ b/src/build/test_schedule.cppm @@ -0,0 +1,272 @@ +// mcpp.build.test_schedule -- the order, the division and the shards of a +// `mcpp test` run, from the durations the run store measured (docs/08 "The +// order of a run"; design 2026-11-14). The store is the mtest store `[test] +// store` names: `index.ndjson`, and per run `runs//programs.ndjson` and +// `cases.ndjson`. Everything here is a function of the store and the run's +// list, so that every shard of one run, reading one store, computes one plan. +export module mcpp.build.test_schedule; + +import std; +import nlohmann.json; + +export namespace mcpp::build::test_schedule { + +// What earlier runs measured: the newest duration of each program record and +// of each case, by name and by case id. +struct History { + std::map programs; + std::map cases; + std::size_t runs = 0; + bool empty() const { return programs.empty() && cases.empty(); } +}; + +// The newest `maxRuns` runs of the store, by their start. With `shardsOf`, +// the runs of a shard of that group (the CI run, or none) are left out: the +// shards of one run, run one after another on one store, read the store as +// the first of them read it. +History read_history(const std::filesystem::path& store, std::size_t maxRuns = 30, + std::optional shardsOf = std::nullopt); + +// The duration a run of `cases` of the program record `name` is expected to +// take; with no cases, the program run whole. +// +// WHAT NO RUN MEASURED. A program no run measured takes the mean of the +// measured programs of its nearest directory (`e2e/run/x`: of `e2e/run/`, +// then of `e2e/`); with none measured there, the longest measured program, so +// that tests a history never saw (a store kept by a run of another selection) +// are spread over the shards by count rather than gathered in one. A case no +// run measured takes the mean of the measured cases beside it; with none, an +// equal part of its program's estimate. +double estimate(const History& h, std::string_view name, std::span cases); + +// LONGEST PROCESSING TIME FIRST: each item, longest first (ties by name), +// goes to the bin with the least work. Returns the bin of each item. +std::vector assign(std::span estimates, std::span names, + std::size_t bins); + +// The cases `ids` of the program record `name` divided into `k` groups of +// about equal measured duration, each group in the order the cases were listed. +std::vector> divide(const History& h, std::string_view name, + std::span ids, std::size_t k); + +// `I/N`, 1 <= I <= N. +std::optional> parse_shard(std::string_view text); + +// THE FAILURES OF THE LAST RUN (`mcpp test --failed`): the programs that +// failed, and the cases that failed in them. `whole` is set when a failed +// program names no failed case (it implements no protocol, or failed outside +// its cases), so its program runs whole. +struct Failures { + std::string run; + std::vector programs; + std::vector cases; + bool whole = false; +}; +std::optional last_failures(const std::filesystem::path& store); + +} // namespace mcpp::build::test_schedule + +namespace mcpp::build::test_schedule { + +namespace { + +using nlohmann::json; + +std::vector read_lines(const std::filesystem::path& file) { + std::vector out; + std::ifstream in(file, std::ios::binary); + for (std::string line; std::getline(in, line);) { + if (line.empty() || line == "\r") continue; + auto j = json::parse(line, nullptr, false); + if (j.is_object()) out.push_back(std::move(j)); + } + return out; +} + +double number(const json& j, const char* key) { + auto it = j.find(key); + return it != j.end() && it->is_number() ? it->get() : -1; +} + +std::string text(const json& j, const char* key) { + auto it = j.find(key); + return it != j.end() && it->is_string() ? it->get() : std::string{}; +} + +// The runs of the index, newest first; a run listed twice (a store imported +// into another) is one run. +std::vector runs_newest_first(const std::filesystem::path& store) { + auto runs = read_lines(store / "index.ndjson"); + std::ranges::stable_sort(runs, std::greater{}, [](const json& r) { return text(r, "started"); }); + std::set seen; + std::vector out; + for (auto& r : runs) + if (auto id = text(r, "id"); !id.empty() && seen.insert(id).second) out.push_back(std::move(r)); + return out; +} + +bool failed_case(std::string_view status) { return status == "fail" || status == "xpass"; } + +} // namespace + +History read_history(const std::filesystem::path& store, std::size_t maxRuns, + std::optional shardsOf) { + History h; + std::error_code ec; + if (!std::filesystem::exists(store / "index.ndjson", ec)) return h; + for (const auto& run : runs_newest_first(store)) { + if (h.runs == maxRuns) break; + if (shardsOf && !text(run, "shard").empty() && text(run, "group") == *shardsOf) continue; + const auto dir = store / "runs" / text(run, "id"); + if (!std::filesystem::is_directory(dir, ec)) continue; + ++h.runs; + for (const auto& p : read_lines(dir / "programs.ndjson")) { + const auto status = text(p, "status"); + const double ms = number(p, "ms"); + // A test built and not run, or not run, measured nothing. + if (ms < 0 || status == "built" || status == "not-run") continue; + h.programs.try_emplace(text(p, "name"), ms); + } + for (const auto& c : read_lines(dir / "cases.ndjson")) { + const double ms = number(c, "ms"); + if (ms >= 0 && text(c, "status") != "skip") h.cases.try_emplace(text(c, "id"), ms); + } + } + return h; +} + +namespace { + +// A program no run measured: the mean of its nearest measured directory, else +// the longest measured program (see `estimate`). +double unmeasured_program(const History& h, std::string_view name) { + if (h.programs.empty()) return 1000; + for (auto slash = name.rfind('/'); slash != std::string_view::npos && slash > 0; + slash = name.rfind('/', slash - 1)) { + const auto dir = std::string(name.substr(0, slash + 1)); + double sum = 0; + std::size_t n = 0; + for (auto it = h.programs.lower_bound(dir); it != h.programs.end() && it->first.starts_with(dir); ++it) { + sum += it->second; + ++n; + } + if (n) return sum / static_cast(n); + } + return std::ranges::max(h.programs | std::views::values); +} + +double program_estimate(const History& h, std::string_view name) { + auto it = h.programs.find(std::string(name)); + return it != h.programs.end() ? it->second : unmeasured_program(h, name); +} + +// Each case's estimate (see `estimate`). +std::vector case_estimates(const History& h, std::string_view name, std::span ids) { + std::vector est(ids.size(), -1); + double sum = 0; + std::size_t known = 0; + for (std::size_t i = 0; i < ids.size(); ++i) + if (auto it = h.cases.find(ids[i]); it != h.cases.end()) { + est[i] = it->second; + sum += it->second; + ++known; + } + if (known == ids.size()) return est; + const double each = known ? sum / static_cast(known) + : program_estimate(h, name) / static_cast(ids.size()); + for (auto& e : est) + if (e < 0) e = each; + return est; +} + +} // namespace + +double estimate(const History& h, std::string_view name, std::span cases) { + if (cases.empty()) return program_estimate(h, name); + double sum = 0; + for (double e : case_estimates(h, name, cases)) sum += e; + return sum; +} + +std::vector assign(std::span estimates, std::span names, + std::size_t bins) { + std::vector order(estimates.size()); + std::iota(order.begin(), order.end(), std::size_t{0}); + std::ranges::stable_sort(order, [&](std::size_t a, std::size_t b) { + if (estimates[a] != estimates[b]) return estimates[a] > estimates[b]; + return names[a] < names[b]; + }); + std::vector load((std::max)(bins, std::size_t{1}), 0.0); + std::vector bin(estimates.size(), 0); + for (auto i : order) { + const auto least = static_cast(std::ranges::min_element(load) - load.begin()); + bin[i] = least; + load[least] += estimates[i]; + } + return bin; +} + +std::vector> divide(const History& h, std::string_view name, + std::span ids, std::size_t k) { + k = std::clamp(k, 1, (std::max)(ids.size(), std::size_t{1})); + const auto est = case_estimates(h, name, ids); + std::vector names(ids.begin(), ids.end()); + const auto bin = assign(est, names, k); + std::vector> groups(k); + for (std::size_t i = 0; i < ids.size(); ++i) groups[bin[i]].push_back(ids[i]); + std::erase_if(groups, [](const auto& g) { return g.empty(); }); + return groups; +} + +std::optional> parse_shard(std::string_view s) { + const auto slash = s.find('/'); + if (slash == std::string_view::npos) return std::nullopt; + int i = 0, n = 0; + auto a = s.substr(0, slash), b = s.substr(slash + 1); + auto [pa, ea] = std::from_chars(a.data(), a.data() + a.size(), i); + auto [pb, eb] = std::from_chars(b.data(), b.data() + b.size(), n); + if (ea != std::errc{} || eb != std::errc{} || pa != a.data() + a.size() || pb != b.data() + b.size()) + return std::nullopt; + if (n < 1 || i < 1 || i > n) return std::nullopt; + return std::pair{i, n}; +} + +std::optional last_failures(const std::filesystem::path& store) { + std::error_code ec; + if (!std::filesystem::exists(store / "index.ndjson", ec)) return std::nullopt; + for (const auto& run : runs_newest_first(store)) { + const auto dir = store / "runs" / text(run, "id"); + if (!std::filesystem::is_directory(dir, ec)) continue; + Failures f; + f.run = text(run, "id"); + std::map rootOf; // a record's name -> its program + std::set programs, withCases; + for (const auto& p : read_lines(dir / "programs.ndjson")) { + const auto name = text(p, "name"); + auto root = text(p, "part_of"); + if (root.empty()) root = name; + rootOf[name] = root; + const auto status = text(p, "status"); + if (status == "fail" || status == "build-fail") programs.insert(name); + } + std::set cases; + for (const auto& c : read_lines(dir / "cases.ndjson")) { + if (!failed_case(text(c, "status"))) continue; + const auto program = text(c, "program"); + cases.insert(text(c, "id")); + withCases.insert(program); + programs.insert(program); + } + std::set roots; + for (const auto& p : programs) { + roots.insert(rootOf.contains(p) ? rootOf[p] : p); + if (!withCases.contains(p)) f.whole = true; + } + f.programs.assign(roots.begin(), roots.end()); + f.cases.assign(cases.begin(), cases.end()); + return f; + } + return std::nullopt; +} + +} // namespace mcpp::build::test_schedule diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index d9c428f02..8362832b9 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -8,6 +8,7 @@ import mcpp.modgraph.glob; import mcpp.modgraph.scanner; import mcpp.project; import mcpp.build.plan; // source_defines_main +import mcpp.diag; export namespace mcpp::build { @@ -94,7 +95,33 @@ discover_test_targets(const std::filesystem::path& manifestRoot, // `main` are two beginnings of one program and are refused. A file // directly in the fixed directory stays a program of its own. std::map> sourcesOf; - if (packageManifest && packageManifest->testPrograms == "directory") { + // The name of a program that is more than one file, by its main file. + std::map programNameOf; + const std::string programs = packageManifest ? packageManifest->testPrograms : std::string{}; + // `[test] programs = "package"` (mcpp#927, 2026-11-12 §18 P1): every file + // the package discovers is one program, named by the fixed directory of + // the first glob that found one (`tests`), linked once. A test is still + // its own process, as every case of a program is. + if (programs == "package" && !testFiles.empty()) { + std::vector mains; + for (auto const& [file, base] : testFiles) + if (source_defines_main(file)) mains.push_back(file); + if (mains.size() > 1) + return std::unexpected(std::format( + "[test] programs = \"package\": '{}' and '{}' both define `main`, and the files the " + "package discovers are one program with one beginning", + mains[0].lexically_relative(packageRoot).generic_string(), + mains[1].lexically_relative(packageRoot).generic_string())); + const auto base = testFiles.front().second; + const auto main = mains.empty() ? testFiles.front().first : mains.front(); + for (auto const& [file, b] : testFiles) + if (file != main) sourcesOf[main].push_back(file); + auto name = base.lexically_relative(packageRoot).generic_string(); + if (name.empty() || name == ".") name = packageRoot.filename().generic_string(); + programNameOf[main] = name; + testFiles = {{main, base}}; + } + if (programs == "directory") { std::map, std::vector> groups; for (auto const& [file, base] : testFiles) { const auto dir = file.parent_path(); @@ -122,6 +149,28 @@ discover_test_targets(const std::filesystem::path& manifestRoot, // by the directory's when the name is formed below. merged.emplace_back(main, key.second); } + // The files the setting leaves as programs of their own, said once: + // the reader of a suite that did not get one program per directory + // learns which files and what does (§18 P1). A setting that changes + // no program is a warning, which --strict refuses. + std::map direct; + for (auto const& [file, base] : testFiles) + if (!grouped.contains(file)) ++direct[base]; + for (auto const& [base, n] : direct) { + const auto where = base.lexically_relative(packageRoot).generic_string(); + if (groups.empty()) + mcpp::diag::degraded("test/programs", std::format( + "[test] programs = \"directory\" changes no program: the {} test file{} {} directly in {}/", + n, n == 1 ? "" : "s", n == 1 ? "is" : "are", where), + "each stays a program of its own, as without the setting", + "\"package\" makes the suite one program; a directory below it makes one of its files"); + else + mcpp::diag::note("test/programs", std::format( + "[test] programs = \"directory\": {} file{} directly in {}/ stay{} program{} of {} own; " + "\"package\" makes the suite one program", + n, n == 1 ? "" : "s", where, n == 1 ? "s a" : "", n == 1 ? "" : "s", + n == 1 ? "its" : "their")); + } std::erase_if(testFiles, [&](auto const& fb) { return grouped.contains(fb.first); }); testFiles.insert(testFiles.end(), merged.begin(), merged.end()); } @@ -149,9 +198,11 @@ discover_test_targets(const std::filesystem::path& manifestRoot, if (!mainRelative) return std::unexpected(mainRelative.error()); const auto merged = sourcesOf.find(file); - const bool directoryProgram = packageManifest && packageManifest->testPrograms == "directory" + const bool directoryProgram = programs == "directory" && file.parent_path().lexically_normal() != discoverBase.lexically_normal(); - auto name = directoryProgram ? testRelative->parent_path().generic_string() + const auto named = programNameOf.find(file); + auto name = named != programNameOf.end() ? named->second + : directoryProgram ? testRelative->parent_path().generic_string() : testRelative->replace_extension("").generic_string(); if (auto [it, fresh] = seenNames.emplace(name, *mainRelative); !fresh) { return std::unexpected(std::format( diff --git a/src/cli.cppm b/src/cli.cppm index 9d3fcc36f..735d040c8 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -71,7 +71,7 @@ void print_usage() { std::println(" mcpp new Create a new package skeleton"); std::println(" mcpp build [options] Build the current package"); std::println(" mcpp run [target] [-- args...] Build + run a binary target"); - std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --no-run, --timeout, --build-timeout, --message-format json, --no-runner)"); + std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --no-run, --shard, --failed, --timeout, --build-timeout, --message-format json, --no-runner)"); std::println(" mcpp clean [--stale] [--bmi-cache] Remove target/ (or, with --stale, only its non-current fingerprint dirs)"); std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); @@ -371,6 +371,12 @@ int run(int argc, char** argv) { } } + // A SELF-CONTAINED HOME IS STATED for the programs mcpp starts. It is + // derived from where this engine is (`/bin/mcpp`), and a build + // directory's graph runs the engine through its own link (2026-11-12 §17), + // from which the same home cannot be derived. + mcpp::home::state_portable_home(); + // ─── --deny (design 2026-10-26 §5.5) ─────────────────────── // Published as MCPP_DENY_EFFECTS, the union of what the caller's // environment already denies and what the flag adds, for the reason @@ -567,48 +573,9 @@ int run(int argc, char** argv) { } } - // ─── action_rc plumbing ───────────────────────────────────────────── - // cmdline's action callback returns void. Capture int return codes - // via a shared local; `wrap_rc` adapts an `int(ParsedArgs&)` lambda - // into the void-returning shape cmdline expects. - int action_rc = 0; - auto wrap_rc = [&action_rc](auto&& fn) { - return [fn = std::forward(fn), &action_rc] - (const cl::ParsedArgs& args) { - action_rc = fn(args); - }; - }; - - // ─── nested-subcommand dispatcher ─────────────────────────────────── - // cmdline's run() only dispatches one level — when a parent subcommand - // (e.g. `self`, `cache`, `index`, `emit`) has its own children but no - // parent action, the matched leaf never gets invoked. We give every - // such parent an action that switches on the parsed child name and - // forwards to the right cmd_* directly. - using cmd_fn = int(*)(const cl::ParsedArgs&); - auto dispatch_sub = [](std::string_view parent, - const cl::ParsedArgs& parsed, - std::initializer_list> table) -> int { - if (!parsed.has_subcommand()) { - std::string usage = std::format("`mcpp {}` requires a subcommand: ", parent); - bool first = true; - for (auto& [n, _] : table) { - if (!first) usage += " / "; - usage += n; - first = false; - } - mcpp::ui::error(usage); - return 2; - } - auto name = parsed.subcommand_name(); - auto sub_ref = parsed.subcommand(); - if (!sub_ref) return 2; - for (auto& [n, fn] : table) { - if (n == name) return fn(sub_ref->get()); - } - mcpp::ui::error(std::format("unknown `mcpp {} {}` subcommand", parent, name)); - return 2; - }; + // Every action returns its status, and `App::run` dispatches to the + // deepest named command at any depth (mcpplibs.cmdline 0.0.4): a parent + // such as `cache` or `self` carries no dispatch table of its own. // ─── `--` passthrough for `mcpp run` / `mcpp test` ────────────────── // cmdline natively recognises `--` and dumps everything after it @@ -683,7 +650,7 @@ int run(int argc, char** argv) { .help("bin (default) | lib | platform | [ns.]pkg[@ver][:template] — exact package template")) .option(cl::Option("list-templates").takes_value().value_name("PKG") .help("List templates from exact [ns.]pkg[@ver]")) - .action(wrap_rc(cmd_new))) + .action(cmd_new)) .subcommand(cl::App("build") .description("Build the current package") .option(cl::Option("configure-only") @@ -733,11 +700,11 @@ int run(int argc, char** argv) { .help("Play a game in the status row while it builds: --play-game=snake|stack|runner, or one at random")) .option(cl::Option("message-format").takes_value().value_name("FMT") .help("human (default) | json: NDJSON events on stdout, then the mcpp.build envelope")) - .action(wrap_rc([](const cl::ParsedArgs& p) { + .action([](const cl::ParsedArgs& p) { const int rc = cmd_build(p); if (rc == 0) mcpp::build::sweep_after_build(); return rc; - }))) + })) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") // Named `bin`, NOT `target`, and the rename is load-bearing. @@ -847,13 +814,13 @@ int run(int argc, char** argv) { .help("Build, then print how the program would be started (argv, the environment mcpp adds, cwd, runner) as the mcpp.launch envelope, instead of starting it")) .option(cl::Option("message-format").takes_value().value_name("FMT") .help("With --print-launch: human (default) | json (the build's events as NDJSON, then the envelope as the last line)")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + .action([&passthrough](const cl::ParsedArgs& p) { return cmd_run(p, std::span(passthrough)); - }))) + })) .subcommand(cl::App("test") .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") - .help("Run only tests whose name contains PATTERN (optional)")) + .help("Run only tests whose name contains a PATTERN; several select every test one of them names (optional)")) .option(cl::Option("target").takes_value().multiple().value_name("TRIPLE") .help("Cross target triple or target set (same axis as `mcpp build --target`); " "repeatable: every leg is built, and run where this host reaches it")) @@ -887,6 +854,14 @@ int run(int argc, char** argv) { .help("Build the tests for --target and stop; a build that succeeds is the result (for a target this host cannot execute)")) .option(cl::Option("archive").takes_value().value_name("FILE") .help("With --no-run: write the built tests to FILE. Without: run the tests FILE holds, building nothing (in a checkout of the same project)")) + .option(cl::Option("no-programs") + .help("Build none of the package's programs: the tests take each from the environment's MCPP_BIN_ (a program built elsewhere); with --no-run --archive, the archive does not carry them and its run's environment names them")) + .option(cl::Option("shard").takes_value().value_name("I/N") + .help("Run the I-th of N shards: the run's tests divided by the durations its store measured, longest first, so that N machines running 1/N to N/N run every test once")) + .option(cl::Option("split").takes_value().value_name("MODE") + .help("How a test program's cases are run as processes, for this invocation ([test] split): file | case | auto | none")) + .option(cl::Option("failed") + .help("Run what failed in the store's last run: its failed cases, or the program whole when it failed outside its cases")) .option(cl::Option("timeout").takes_value().value_name("SECS") .help("Kill a test still RUNNING after SECS seconds (default 300; 0 = no limit)")) .option(cl::Option("build-timeout").takes_value().value_name("SECS") @@ -921,11 +896,11 @@ int run(int argc, char** argv) { .help("With --workspace (or at a virtual workspace root), leave the named member out; repeatable, refused with -p")) .option(cl::Option("play-game") .help("Play a game in the status row while it builds: --play-game=snake|stack|runner, or one at random")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + .action([&passthrough](const cl::ParsedArgs& p) { const int rc = cmd_test(p, std::span(passthrough)); if (rc == 0) mcpp::build::sweep_after_build(); return rc; - }))) + })) .subcommand(cl::App("clean") .description("Remove target/, or with --stale only what no configuration still uses: fingerprint directories, generated sources, build-program groups") .option(cl::Option("bmi-cache").help("Also wipe the global build cache (see `mcpp cache clean`)")) @@ -934,7 +909,7 @@ int run(int argc, char** argv) { .option(cl::Option("report").help("Show where target/ holds its bytes, per configuration and area; removes nothing")) .option(cl::Option("older-than").takes_value().value_name("DURATION") .help("Keep unrecorded directories written more recently than this, e.g. 12h, 3d (default [build] keep_days, 3d; 0 keeps none; implies --stale)")) - .action(wrap_rc(cmd_clean))) + .action(cmd_clean)) .subcommand(cl::App("why") .description("Explain how the target / toolchain / runtime / deps / runners / sources / flags were resolved") .arg(cl::Arg("topic").help("target | toolchain | sysroot | runtime | deps | runners | sources | tool | payload | build-programs | compat | flags | platform (default: all but flags and platform)")) @@ -957,11 +932,11 @@ int run(int argc, char** argv) { .help("Resolve with these features, as `build --features` does (root features and /)")) .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/50-machine-output.md)")) - .action(wrap_rc(cmd_why))) + .action(cmd_why)) .subcommand(cl::App("resolve") .description("Re-resolve the build plan and explain it") .option(cl::Option("explain").help("Print resolved toolchain / runtime / deps / runners")) - .action(wrap_rc(cmd_why))) + .action(cmd_why)) .subcommand(cl::App("add") .description("Add a dependency to mcpp.toml") .arg(cl::Arg("pkg").help( @@ -969,15 +944,15 @@ int run(int argc, char** argv) { .required()) .option(cl::Option("dev").help( "Add to [dev-dependencies] (test-only, e.g. compat.gtest)")) - .action(wrap_rc(mcpp::pm::commands::cmd_add))) + .action(mcpp::pm::commands::cmd_add)) .subcommand(cl::App("remove") .description("Remove a dependency from mcpp.toml") .arg(cl::Arg("pkg").help("Exact package selector [ns.]name").required()) - .action(wrap_rc(mcpp::pm::commands::cmd_remove))) + .action(mcpp::pm::commands::cmd_remove)) .subcommand(cl::App("update") .description("Re-resolve dependencies and rewrite mcpp.lock") .arg(cl::Arg("pkg").help("If given, update only that package")) - .action(wrap_rc(mcpp::pm::commands::cmd_update))) + .action(mcpp::pm::commands::cmd_update)) .subcommand(cl::App("search") .description("Search packages in configured registries") .arg(cl::Arg("keyword").help("Search keyword (substring match); optional with --format json, which then lists every package")) @@ -985,12 +960,12 @@ int run(int argc, char** argv) { .help("List every published version instead of the latest few")) .option(cl::Option("format").takes_value().value_name("FMT") .help("json: the mcpp.search envelope (every version, licenses, repo)")) - .action(wrap_rc(cmd_search))) + .action(cmd_search)) .subcommand(cl::App("publish") .description("Publish package to default registry") .option(cl::Option("dry-run").help("Print xpkg.lua without uploading")) .option(cl::Option("allow-dirty").help("Allow uncommitted changes")) - .action(wrap_rc(cmd_publish))) + .action(cmd_publish)) .subcommand(cl::App("pack") // "archive", not "tarball": a Windows target produces a .zip, and // the help said tarball while the code had already stopped @@ -1080,7 +1055,7 @@ int run(int argc, char** argv) { .help("With --workspace, leave the named member out; repeatable, refused with -p")) .option(cl::Option("debug-symbols").takes_value().value_name("DIR") .help("Write the separated *.debug files here (default: discard)")) - .action(wrap_rc(cmd_pack))) + .action(cmd_pack)) // ─── emit (one nested subcommand: xpkg) ──────────────────────── .subcommand(cl::App("emit") @@ -1099,7 +1074,8 @@ int run(int argc, char** argv) { .help("Describe packages `mcpp pack` wrote (repeatable): an xim-pkgindex entry " "of the program, from their mcpp-package.json")) .option(cl::Option("url").takes_value().value_name("PREFIX") - .help("With --package: where the packages are published (/)"))) + .help("With --package: where the packages are published (/)")) + .action(cmd_emit_xpkg)) // `sbom` belongs HERE rather than at the top level: `emit` // already means "generate a document describing this project" and // already carries `-o`. A separate `mcpp sbom` would be a second @@ -1107,7 +1083,8 @@ int run(int argc, char** argv) { .subcommand(cl::App("sbom") .description("Write a CycloneDX bill of materials for the recorded resolution") .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") - .help("Write to file instead of stdout"))) + .help("Write to file instead of stdout")) + .action(mcpp::cli::cmd_sbom)) // The plan as an S1 build database, planned as `build // --configure-only` plans it and written into nothing; the build // selectors are `build`'s own (docs/specs/build-database.md). @@ -1149,13 +1126,8 @@ int run(int argc, char** argv) { .option(cl::Option("workspace") .help("Describe all workspace members in one document")) .option(cl::Option("exclude").takes_value().multiple().value_name("NAME") - .help("With --workspace (or at a virtual workspace root), leave the named member out; repeatable, refused with -p"))) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("emit", p, {{"xpkg", cmd_emit_xpkg}, - {"sbom", mcpp::cli::cmd_sbom}, - {"build-database", - mcpp::cli::cmd_emit_build_database}}); - }))) + .help("With --workspace (or at a virtual workspace root), leave the named member out; repeatable, refused with -p")) + .action(mcpp::cli::cmd_emit_build_database))) // ─── xpkg (descriptor tooling: parse) ────────────────────────── .subcommand(cl::App("xpkg") @@ -1175,10 +1147,8 @@ int run(int argc, char** argv) { .help("OBSOLETE (kept accepted so 0.0.105-era index CI keeps " "working): skip the package.name form check. Since " "0.0.106 the canonical form IS the short name, so " - "xlings-native descriptors pass without this flag"))) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("xpkg", p, {{"parse", cmd_xpkg_parse}}); - }))) + "xlings-native descriptors pass without this flag")) + .action(cmd_xpkg_parse))) // ─── resource management ─────────────────────────────────────── .subcommand(cl::App("toolchain") @@ -1220,30 +1190,35 @@ int run(int argc, char** argv) { .arg(cl::Arg("version").help("(optional, alternative to @-form)")) .option(cl::Option("target").takes_value().help( "Remove the payload for instead of the host one"))) - .action(wrap_rc(cmd_toolchain_any))) + .action(cmd_toolchain_any)) .subcommand(cl::App("cache") .description("Inspect and manage the global build cache") .subcommand(cl::App("dir") - .description("Print the cache root (and any pre-v1 cache)")) + .description("Print the cache root (and any pre-v1 cache)") + .action(cmd_cache_dir)) .subcommand(cl::App("list") .description("List cache entries with size + last-use") .option(cl::Option("json") .help("Emit machine-readable JSON (legacy payload, kept for ever)")) .option(cl::Option("format").takes_value().value_name("json") - .help("Machine-readable output (enveloped; see docs/50-machine-output.md)"))) + .help("Machine-readable output (enveloped; see docs/50-machine-output.md)")) + .action(cmd_cache_list)) .subcommand(cl::App("info") .description("Show details (incl. key inputs) for a cached package") - .arg(cl::Arg("pkg").help("@").required())) + .arg(cl::Arg("pkg").help("@").required()) + .action(cmd_cache_info)) .subcommand(cl::App("prune") .description("Drop entries not used within a threshold") .option(cl::Option("older-than").takes_value().value_name("N{s|m|h|d}") - .help("Age threshold (e.g. 30d)"))) + .help("Age threshold (e.g. 30d)")) + .action(cmd_cache_prune)) .subcommand(cl::App("gc") .description("LRU-collect package and host tool entries to a size and/or age budget") .option(cl::Option("max-size").takes_value().value_name("N{MiB|GiB}") .help("Keep the package cache under this size (e.g. 5GiB)")) .option(cl::Option("older-than").takes_value().value_name("N{s|m|h|d}") - .help("Also drop entries unused for longer than this"))) + .help("Also drop entries unused for longer than this")) + .action(cmd_cache_gc)) .subcommand(cl::App("clean") .description("Drop cache entries (default: package entries only)") .option(cl::Option("deps").help("Drop package entries (default)")) @@ -1251,33 +1226,27 @@ int run(int argc, char** argv) { .option(cl::Option("tools").help("Drop host tool entries")) .option(cl::Option("all").help("Drop every kind of entry")) .option(cl::Option("legacy") - .help("Remove the unused pre-v1 cache at $MCPP_HOME/bmi"))) + .help("Remove the unused pre-v1 cache at $MCPP_HOME/bmi")) + .action(cmd_cache_clean)) .subcommand(cl::App("verify") - .description("Check every entry's manifest against the files on disk")) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("cache", p, { - {"dir", cmd_cache_dir}, - {"list", cmd_cache_list}, - {"info", cmd_cache_info}, - {"prune", cmd_cache_prune}, - {"gc", cmd_cache_gc}, - {"clean", cmd_cache_clean}, - {"verify", cmd_cache_verify}, - }); - }))) + .description("Check every entry's manifest against the files on disk") + .action(cmd_cache_verify))) .subcommand(cl::App("index") .description("Manage configured package registries") .subcommand(cl::App("list") .description("List configured registries") .option(cl::Option("format").takes_value().value_name("FMT") - .help("json: the mcpp.index envelope"))) + .help("json: the mcpp.index envelope")) + .action(cmd_index_list)) .subcommand(cl::App("add") .description("Add a custom registry") .arg(cl::Arg("name").help("Registry name").required()) - .arg(cl::Arg("url").help("Registry URL").required())) + .arg(cl::Arg("url").help("Registry URL").required()) + .action(cmd_index_add)) .subcommand(cl::App("remove") .description("Remove a registry") - .arg(cl::Arg("name").help("Registry name").required())) + .arg(cl::Arg("name").help("Registry name").required()) + .action(cmd_index_remove)) .subcommand(cl::App("update") // #540: the argument selects among the PROJECT's custom // indices only. The global repos are always synced wholesale, @@ -1290,27 +1259,20 @@ int run(int argc, char** argv) { "(global repos always sync in full)") .arg(cl::Arg("name").help( "Update only this PROJECT-level custom index " - "(the global repos sync regardless)"))) + "(the global repos sync regardless)")) + .action(cmd_index_update)) .subcommand(cl::App("status") - .description("Show local index presence/freshness (offline)")) + .description("Show local index presence/freshness (offline)") + .action(cmd_index_status)) .subcommand(cl::App("pin") .description("Pin a custom index to a commit rev in mcpp.toml") .arg(cl::Arg("name").help("Index name").required()) - .arg(cl::Arg("rev").help("Commit sha (defaults to current lock rev)"))) + .arg(cl::Arg("rev").help("Commit sha (defaults to current lock rev)")) + .action(cmd_index_pin)) .subcommand(cl::App("unpin") .description("Remove rev pin from a custom index in mcpp.toml") - .arg(cl::Arg("name").help("Index name").required())) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("index", p, { - {"list", cmd_index_list}, - {"add", cmd_index_add}, - {"remove", cmd_index_remove}, - {"update", cmd_index_update}, - {"status", cmd_index_status}, - {"pin", cmd_index_pin}, - {"unpin", cmd_index_unpin}, - }); - }))) + .arg(cl::Arg("name").help("Index name").required()) + .action(cmd_index_unpin))) // ─── about mcpp itself ───────────────────────────────────────── .subcommand(cl::App("self") @@ -1320,9 +1282,11 @@ int run(int argc, char** argv) { .option(cl::Option("home").takes_value().value_name("PATH") .help("Initialize PATH and remember it from the original home (does not migrate data)")) .option(cl::Option("force") - .help("Rebuild registry/caches; with --home, replace the default without deleting data"))) + .help("Rebuild registry/caches; with --home, replace the default without deleting data")) + .action(cmd_self_init)) .subcommand(cl::App("doctor") - .description("Diagnose mcpp environment health")) + .description("Diagnose mcpp environment health") + .action(cmd_doctor)) .subcommand(cl::App("env") .description("Print mcpp paths and configuration; with --target, one configuration: toolchain, platform, tools, runner") .option(cl::Option("target").takes_value().multiple().value_name("TRIPLE") @@ -1335,7 +1299,8 @@ int run(int argc, char** argv) { .option(cl::Option("host-platform").takes_value().value_name("SPEC") .help("The host graph's platform for this invocation (= MCPP_HOST_PLATFORM)")) .option(cl::Option("format").takes_value().value_name("json") - .help("Machine-readable output (enveloped; see docs/50-machine-output.md)"))) + .help("Machine-readable output (enveloped; see docs/50-machine-output.md)")) + .action(cmd_env_any)) .subcommand(cl::App("config") .description("Show or modify mcpp's configuration: the xlings mirror and the retention of build outputs") .option(cl::Option("mirror").takes_value().value_name("auto|CN|GLOBAL") @@ -1343,28 +1308,21 @@ int run(int argc, char** argv) { .option(cl::Option("cache-unused-days").takes_value().value_name("DAYS") .help("Collect build-cache entries no build used for DAYS days (default 30; 0 keeps all)")) .option(cl::Option("keep-days").takes_value().value_name("DAYS") - .help("Sweep a project's build directories no configuration used for DAYS days (default 3; 0 keeps all)"))) + .help("Sweep a project's build directories no configuration used for DAYS days (default 3; 0 keeps all)")) + .action(cmd_self_config)) .subcommand(cl::App("version") - .description("Show mcpp version")) + .description("Show mcpp version") + .action(cmd_self_version)) .subcommand(cl::App("explain") .description("Show extended description for an error code") - .arg(cl::Arg("code").help("Error code such as E0001, or a refusal's reason token such as platform-version-group").required())) + .arg(cl::Arg("code").help("Error code such as E0001, or a refusal's reason token such as platform-version-group").required()) + .action(cmd_explain_action)) .subcommand(cl::App("store") .description("Compare this home's payloads with its upstreams") .subcommand(cl::App("dedupe") .description("List payloads an upstream has the same of; --apply links them") - .option(cl::Option("apply").help("Replace each by a link to the upstream's")))) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("self", p, { - {"store", cmd_self_store}, - {"init", cmd_self_init}, - {"doctor", cmd_doctor}, - {"env", cmd_env_any}, - {"config", cmd_self_config}, - {"version", cmd_self_version}, - {"explain", cmd_explain_action}, - }); - }))) + .option(cl::Option("apply").help("Replace each by a link to the upstream's"))) + .action(cmd_self_store))) // ─── top-level explain alias ────────────────────────────────── // Preserves `mcpp explain E0001` as a shortcut for @@ -1372,7 +1330,7 @@ int run(int argc, char** argv) { .subcommand(cl::App("explain") .description("Show extended description for an error code") .arg(cl::Arg("code").help("Error code such as E0001, or a refusal's reason token such as platform-version-group").required()) - .action(wrap_rc(cmd_explain_action))) + .action(cmd_explain_action)) // ─── bareword `version` alias ───────────────────────────────── // cmdline natively handles `--help`/`--version`/`-h`; the bareword @@ -1381,7 +1339,7 @@ int run(int argc, char** argv) { // App so it shows up in the auto-generated subcommand list. .subcommand(cl::App("version") .description("Show mcpp version") - .action(wrap_rc(cmd_self_version))) + .action(cmd_self_version)) // ─── hidden / internal ───────────────────────────────────────── .subcommand(cl::App("dyndep") @@ -1404,7 +1362,7 @@ int run(int argc, char** argv) { .help("(verification) planned imports for this TU, comma-separated")) .option(cl::Option("expect-none") .help("(verification) planner assumed no provides/imports")) - .action(wrap_rc(cmd_dyndep))) + .action(cmd_dyndep)) // Named by generated build.ninja edges and, since 2026.9.13.1, by a // build program's own actions through `${mcpp.self}` (docs/30), so // the argument shape below is a contract: `stage --verify content @@ -1418,21 +1376,21 @@ int run(int argc, char** argv) { .help("How an existing destination is judged up to date: content (default) | size")) .option(cl::Option("list").takes_value().value_name("FILE") .help("Place every `\\t` pair of FILE in one process (#734 E4); several lines naming one destination are its sources")) - .action(wrap_rc(cmd_stage))) + .action(cmd_stage)) .subcommand(cl::App("link-output") .description("(internal: invoked by ninja on Windows) Move a linked artifact aside, then run the linker command after `--` (a running program keeps its file)") .option(cl::Option("output").takes_value().value_name("PATH").help("the artifact the command links")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + .action([&passthrough](const cl::ParsedArgs& p) { return cmd_link_output(p, std::span(passthrough)); - }))) + })) .subcommand(cl::App("platform-step") .description("(internal: invoked by ninja for a platform's link) `capture --out F -- argv…` saves a program's output; `seal --steps F -- [needed…]` runs the platform's seal steps, then publishes") .arg(cl::Arg("mode").help("capture | seal")) .option(cl::Option("out").takes_value().value_name("PATH").help("capture: the file the output is saved as")) .option(cl::Option("steps-hex").takes_value().value_name("HEX").help("seal: the steps, one per line, words separated by a tab, hex-encoded (the command is the steps, so a changed step reseals)")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + .action([&passthrough](const cl::ParsedArgs& p) { return cmd_platform_step(p, std::span(passthrough)); - }))) + })) .subcommand(cl::App("place-dlls") .description("(internal: invoked by ninja) Place beside a Windows program the DLLs it imports from its runtime search directories") .option(cl::Option("output").takes_value().value_name("PATH").help("the stamp to write")) @@ -1441,7 +1399,7 @@ int run(int argc, char** argv) { .help("how the contract governs the MSVC C++ runtime's names: carry | system | static | not-applicable")) .option(cl::Option("toolset-crt").takes_value().value_name("DIR") .help("the selected toolset's C++ runtime directory")) - .action(wrap_rc(cmd_place_dlls))) + .action(cmd_place_dlls)) .subcommand(cl::App("coff-def") .description("(internal: invoked by ninja) Discover exports in COFF and LLVM bitcode objects") .option(cl::Option("output").takes_value().value_name("PATH").help("the .def to write")) @@ -1451,10 +1409,10 @@ int run(int argc, char** argv) { .option(cl::Option("llvm-target").takes_value().value_name("TRIPLE").help("selected target triple")) .option(cl::Option("exports-file").takes_value().value_name("PATH").help("the target's `exports` patterns, one per line")) .option(cl::Option("required").help("a consumer of this build links the DLL: an empty export surface is an error")) - .action(wrap_rc(cmd_coff_def))) + .action(cmd_coff_def)) .subcommand(cl::App("bmi-equal") .description("(internal: invoked by ninja) Compare two BMIs ignoring the compiler's embedded timestamp") - .action(wrap_rc(cmd_bmi_equal))) + .action(cmd_bmi_equal)) // The three edges of the detach-codegen schedule. Internal, and named as // such: they are only ever invoked by a generated build.ninja. .subcommand(cl::App("bmi-compile") @@ -1468,7 +1426,7 @@ int run(int argc, char** argv) { .option(cl::Option("command-file").takes_value().value_name("PATH").help("file holding the compiler command line")) .option(cl::Option("dep-from").takes_value().value_name("PATH").help("scanner depfile to adopt")) .option(cl::Option("dep-to").takes_value().value_name("PATH").help("where ninja expects this edge's depfile")) - .action(wrap_rc(cmd_bmi_compile))) + .action(cmd_bmi_compile)) .subcommand(cl::App("bmi-supervise") .description("(internal) Run a compiler to completion and record its status") .option(cl::Option("slot").takes_value().value_name("PATH")) @@ -1476,12 +1434,12 @@ int run(int argc, char** argv) { .option(cl::Option("cap").takes_value().value_name("N")) .option(cl::Option("object").takes_value().value_name("PATH")) .option(cl::Option("command-file").takes_value().value_name("PATH")) - .action(wrap_rc(cmd_bmi_supervise))) + .action(cmd_bmi_supervise)) .subcommand(cl::App("bmi-await") .description("(internal) Join a detached compiler and replay its diagnostics") .option(cl::Option("slot").takes_value().value_name("PATH")) .option(cl::Option("object").takes_value().value_name("PATH")) - .action(wrap_rc(cmd_bmi_await))) + .action(cmd_bmi_await)) ; // The bareword `mcpp help` and `mcpp` (no args) both print the @@ -1962,7 +1920,7 @@ int run(int argc, char** argv) { mcpp::ui::error(parsed.error().message); return 2; } - app.run(*parsed); + const int action_rc = app.run(*parsed); // The run's closing notices come last, after everything the command // printed, whatever its exit status (see mcpp::ui::add_closing_notice). mcpp::ui::print_closing_notices(action_rc != 0); diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index ccfb3e5b0..6a4ff2562 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -23,6 +23,7 @@ import mcpp.modgraph.glob; // escaped_spelling, for coff-def's warning import mcpp.build.stage; import mcpp.build.schedule.detach_codegen; import mcpp.build.test_targets; +import mcpp.build.test_schedule; // --shard, --failed import mcpp.cli.selection; import mcpp.build.build_database; import mcpp.build.build_program; @@ -901,7 +902,7 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) discovery = std::move(tests->discovery); mo.member_targets = std::move(tests->targets); } - mo.plan_only = true; + mo.intent = mcpp::build::PrepareIntent::Describe; std::string workKey = mo.package_filter; for (auto const& mp : req.group) workKey += (workKey.empty() ? "" : ",") + mp; mo.work_dir = build_database_work_dir(req.group.empty() ? *root : wsRoot, workKey); @@ -1206,7 +1207,7 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, for (auto const& l : *legs) testLegs.push_back({l.triple, l.fromSet}); mcpp::build::TestOptions to; - if (parsed.positional_count() > 0) to.filter = parsed.positional(0); + for (std::size_t i = 0; i < parsed.positional_count(); ++i) to.filters.push_back(parsed.positional(i)); to.list = parsed.is_flag_set("list"); to.noRunner = parsed.is_flag_set("no-runner"); // see cmd_run if (auto runner = parsed.value("runner")) to.runnerName = *runner; @@ -1252,6 +1253,14 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, // nothing. if (auto a = parsed.value("archive")) to.archive = *a; const bool runArchive = !to.archive.empty() && !to.noRun; + // The package's programs, built elsewhere: named by the environment + // (MCPP_BIN_) of this run, or of the run of the archive written. + to.noPrograms = parsed.is_flag_set("no-programs"); + if (to.noPrograms && runArchive) { + mcpp::ui::error("--no-programs leaves the package's programs unbuilt; the run of an archive builds " + "nothing (an archive written with --no-programs takes them from MCPP_BIN_)"); + return 2; + } // The three deadlines share one parser: they differ only in what they // bound, not in how they are spelled. 0 always means "no limit" — for // --timeout that now has to be asked for rather than being the default. @@ -1268,6 +1277,53 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, out = secs; return true; }; + if (auto sh = parsed.value("shard")) { + to.shard = mcpp::build::test_schedule::parse_shard(*sh); + if (!to.shard) { + mcpp::ui::error(std::format("invalid --shard '{}' (I/N, 1 <= I <= N)", *sh)); + return 2; + } + } + if (auto sp = parsed.value("split")) { + if (*sp != "file" && *sp != "case" && *sp != "auto" && *sp != "none") { + mcpp::ui::error(std::format("invalid --split '{}' (file | case | auto | none)", *sp)); + return 2; + } + to.split = *sp; + } + // THE FAILURES OF THE LAST RUN (`--failed`), read from the store before + // anything is built, so that only the programs that failed are built. + if (parsed.is_flag_set("failed")) { + if (!to.filters.empty() || !to.cases.empty() || to.listCases || to.noRun) { + mcpp::ui::error("--failed selects the tests itself; it is not combined with a PATTERN, --case, --cases or --no-run"); + return 2; + } + std::error_code ec; + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path(ec)); + auto m = root ? mcpp::manifest::load(*root / "mcpp.toml") + : std::expected( + std::unexpected(mcpp::manifest::ManifestError{})); + if (!m) { mcpp::ui::error("--failed reads the run store of a project: no mcpp.toml here"); return 2; } + if (m->testStore == "none") { + mcpp::ui::error("--failed reads the run store, and [test] store = \"none\" keeps no run"); + return 2; + } + const auto store = *root / (m->testStore.empty() ? std::string("target/mtest") : m->testStore); + auto f = mcpp::build::test_schedule::last_failures(store); + if (!f) { + mcpp::ui::error(std::format("--failed: the store {} holds no run", store.generic_string())); + return 2; + } + if (f->programs.empty()) { + mcpp::ui::info("Finished", std::format("the store's last run ({}) failed nothing: no test to run", f->run)); + return 0; + } + to.filters = f->programs; + if (!f->whole) to.cases = f->cases; + mcpp::ui::info("Selected", std::format("what failed in run {}: {} program(s){}", f->run, f->programs.size(), + f->whole ? std::string{} + : std::format(", {} case(s)", f->cases.size()))); + } if (!read_secs("timeout", to.timeoutSecs)) return 2; if (!read_secs("build-timeout", to.buildTimeoutSecs)) return 2; if (!read_secs("workspace-timeout", workspaceTimeoutSecs)) return 2; @@ -1290,7 +1346,8 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, // package on one target, built and not run, and nothing else on stdout. if (parsed.is_flag_set("print-launch")) { std::string refused; - if (to.filter.empty()) refused = "names no test: --print-launch describes the one test PATTERN names"; + if (to.filters.empty()) refused = "names no test: --print-launch describes the one test PATTERN names"; + else if (to.filters.size() > 1) refused = "describes one test, and the command names several PATTERNs: name one"; else if (to.list || to.noRun || !to.archive.empty()) refused = "describes a test to run; --list, --no-run and --archive do not run one"; else if (to.format == mcpp::build::TestMessageFormat::Json) refused = "prints the mcpp.launch envelope; --message-format json prints records"; else if (!testLegs.empty()) refused = "describes one test on one target, and --target names several legs"; diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index 179e6cd3e..c14e2ae9f 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -106,7 +106,7 @@ static int refuse_shared_exact_pins() { }); if (!library) return 0; mcpp::build::BuildOverrides ov; - ov.plan_only = true; + ov.intent = mcpp::build::PrepareIntent::Describe; ov.work_dir = mcpp::home::root() / "cache" / "publish-plan" / mcpp::toolchain::hash_string(root->lexically_normal().generic_string()); std::expected ctx = std::unexpected(std::string{}); @@ -335,7 +335,7 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, // accepts here -- the engine's own (`tar`, `dir`) and each one a package of // the resolved graph declares with `mcpp::provides_pack_format`, with the // packages that provide it -- as the `mcpp.pack.formats` envelope. A plan -// that describes (plan_only) in a directory of the mcpp home, as `emit +// that describes (PrepareIntent::Describe) in a directory of the mcpp home, as `emit // build-database` plans: the build programs run, because they are what // declares a format, and nothing is written into the project. The plan is // the target's the pack would build (`--target`, the last when repeated, as @@ -354,7 +354,7 @@ int cmd_pack_list_formats(const mcpplibs::cmdline::ParsedArgs& parsed) { if (auto v = parsed.value("features")) ov.features = *v; if (auto o = parsed.option("target"); o && !o->get().values.empty()) ov.target_triple = o->get().values.back(); - ov.plan_only = true; + ov.intent = mcpp::build::PrepareIntent::Describe; ov.work_dir = mcpp::home::root() / "cache" / "pack-formats" / mcpp::toolchain::hash_string(root->lexically_normal().generic_string()); std::expected ctx = std::unexpected(std::string{}); diff --git a/src/config.cppm b/src/config.cppm index 953bc1635..e3ebd5621 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -97,6 +97,13 @@ struct GlobalConfig { // From config.toml [xlings] std::string xlingsBinaryMode; // "bundled" | "system" | absolute path std::filesystem::path xlingsHomeOverride; // empty = use registryDir + // THE REGISTRY IS ANOTHER HOME'S. Its configuration -- the places of its + // indices, its upstreams -- is its owner's, and this home writes none of + // it. Decided once, where the configuration is loaded: a registry that + // `[xlings] home` names, or that `registry` reaches through a link, and + // that holds a configuration already. Every writer of the registry's + // configuration asks this, and no other test. + bool registryBorrowed = false; // From config.toml [index] std::string defaultIndex; // "mcpplibs" @@ -432,11 +439,11 @@ void print_status(std::string_view verb, std::string_view msg) { } } -void write_file(const std::filesystem::path& p, std::string_view content) { - std::error_code ec; - std::filesystem::create_directories(p.parent_path(), ec); - std::ofstream os(p); - os << content; +// config.toml and .xlings.json are read by every mcpp of the home: programs that +// start at once on a fresh home each write config.toml, and an in-place rewrite +// let one read a part of another's ("invalid config.toml: expected '='"). +bool write_file(const std::filesystem::path& p, std::string_view content) { + return mcpp::home::write_replacing(p, content); } std::string_view default_config_toml() { @@ -486,8 +493,7 @@ keep_days = 3 } bool write_default_config_toml(const std::filesystem::path& path) { - write_file(path, default_config_toml()); - return std::filesystem::exists(path); + return write_file(path, default_config_toml()); } bool write_default_xlings_json(const std::filesystem::path& path, @@ -548,16 +554,7 @@ std::optional read_json_file(const std::filesystem::path& path) } bool write_json_file(const std::filesystem::path& path, const nlohmann::json& doc) { - auto tmp = path; - tmp += ".tmp"; - { - std::ofstream out(tmp); - if (!out) return false; - out << doc.dump(2) << "\n"; - } - std::error_code ec; - std::filesystem::rename(tmp, path, ec); - return !ec; + return write_file(path, doc.dump(2) + "\n"); } nlohmann::json index_repo_entry(const IndexRepo& r) { @@ -597,7 +594,7 @@ void record_seeded_index_repos(const std::filesystem::path& xjson, void reconcile_index_repos(const std::filesystem::path& xjson, const std::vector& repos, - bool quiet) + bool quiet, bool borrowed) { auto doc = read_json_file(xjson); if (!doc || !doc->is_object()) return; @@ -624,8 +621,16 @@ void reconcile_index_repos(const std::filesystem::path& xjson, bool fileChanged = false, recordChanged = false; for (auto const& r : repos) { if (!r.fromConfig) continue; - const auto want = index_repo_entry(r); + auto want = index_repo_entry(r); auto* have = find(r.name); + // The places of the index (its data, its store) are the registry's + // owner's: a home that borrows the registry keeps the ones the owner + // recorded (GlobalConfig::registryBorrowed). + if (have && borrowed) + for (const char* place : {"dir", "store"}) { + want.erase(place); + if (have->contains(place)) want[place] = (*have)[place]; + } if (have && *have == want) continue; if (!record.contains(r.name)) record[r.name] = { {"previous", have ? *have : nlohmann::json(nullptr)} }; @@ -966,7 +971,12 @@ std::expected load_or_init( } // 4. Load config.toml - auto doc = fresh_config ? t::parse(default_config_toml()) : t::parse_file(cfg.configFile); + // Read as a writer leaves it (read_whole): another mcpp may be replacing it. + const auto configText = fresh_config ? std::optional(default_config_toml()) + : mcpp::home::read_whole(cfg.configFile); + if (!configText) + return std::unexpected(ConfigError{std::format("invalid config.toml: cannot open '{}'", cfg.configFile.string())}); + auto doc = t::parse(*configText); if (!doc) { return std::unexpected(ConfigError{ std::format("invalid config.toml: {}", doc.error().message)}); @@ -1151,15 +1161,25 @@ std::expected load_or_init( // (`[xlings] home` naming an existing one) keeps the places that home // recorded: writing this home's there sent every later user of the // registry to this home's store, which ended with it (the e2e homes under - // a temporary directory, measured on Windows and macOS). - const bool borrowedRegistry = [&] { - if (cfg.xlingsHomeOverride.empty()) return false; + // a temporary directory, measured on Windows and macOS). A home whose + // `registry` is a link to another home's borrows it the same way: its + // writes reached the other home's configuration through the link, and on + // Linux every install of the shared home went to a case's store after the + // case had removed it ("install path missing after fetch", 2026-11-14). + cfg.registryBorrowed = [&] { std::error_code ec; - if (std::filesystem::equivalent(cfg.xlingsHomeOverride, cfg.registryDir, ec)) return false; - return std::filesystem::exists(mcpp::provider::open(make_xlings_env(cfg))->config_file(), ec); + if (!std::filesystem::exists(mcpp::provider::open(make_xlings_env(cfg))->config_file(), ec)) return false; + if (!cfg.xlingsHomeOverride.empty()) + return !std::filesystem::equivalent(cfg.xlingsHomeOverride, cfg.registryDir, ec); + const auto real = std::filesystem::canonical(cfg.registryDir, ec); + if (ec) return false; + const auto home = std::filesystem::canonical(cfg.mcppHome, ec); + if (ec) return false; + const auto rel = real.lexically_relative(home); + return rel.empty() || *rel.begin() == ".."; }(); for (auto& r : cfg.indexRepos) { - if (borrowedRegistry || mcpp::provider::layout().is_provider_index(r.name)) continue; + if (cfg.registryBorrowed || mcpp::provider::layout().is_provider_index(r.name)) continue; r.dir = cfg.mcppHome / "indices" / r.name; r.store = mcpp::provider::layout().own_store(cfg.mcppHome); } @@ -1197,7 +1217,7 @@ std::expected load_or_init( record_seeded_index_repos(xjson, cfg.indexRepos, quiet); } else { mcpp::fallback::migrate_xlings_json_index_names(xjson); - reconcile_index_repos(xjson, cfg.indexRepos, quiet); + reconcile_index_repos(xjson, cfg.indexRepos, quiet, cfg.registryBorrowed); } { // A home from before: its indices' places recorded, their data moved @@ -1206,10 +1226,12 @@ std::expected load_or_init( std::vector owned; for (auto& r : cfg.indexRepos) if (!r.dir.empty()) owned.push_back({ r.name, r.url, {}, {}, {}, r.dir, r.store }); - mcpp::provider::open(make_xlings_env(cfg))->own_indices(owned); + if (!cfg.registryBorrowed) mcpp::provider::open(make_xlings_env(cfg))->own_indices(owned); } - { + // The upstreams are the registry's too: a borrowing home's `[xlings] + // upstream` would replace its owner's. + if (!cfg.registryBorrowed) { std::vector upstreams; for (auto& dir : cfg.upstreams) for (auto& u : mcpp::provider::layout().upstream_of(dir)) upstreams.push_back(std::move(u)); @@ -1443,35 +1465,42 @@ bool ensure_project_index_dir( std::expected write_config_value(const GlobalConfig& cfg, std::string_view section, std::string_view key, std::string_view literal) { - std::ifstream is(cfg.configFile); - if (!is) return std::unexpected(ConfigError{ + // The reader is closed before the file is replaced: Windows refuses to + // rename over a file this process holds open. + auto read = mcpp::home::read_whole(cfg.configFile); + if (!read) return std::unexpected(ConfigError{ std::format("cannot open '{}'", cfg.configFile.string())}); - std::stringstream ss; ss << is.rdbuf(); - std::string text = ss.str(); + std::string text = std::move(*read); - std::string line = std::format("{} = {}\n", key, literal); + // The file's own line ending: a config.toml written on Windows by an + // editor ends its lines with `\r\n`, and a line this writer adds ends as + // the others do. A header or a key is matched before either ending. + const std::string_view eol = text.find("\r\n") != std::string::npos ? "\r\n" : "\n"; + std::string line = std::format("{} = {}{}", key, literal, eol); const auto header = std::format("[{}]", section); + auto ends_line = [&](std::size_t at) { + return at == text.size() || text[at] == '\n' || (text[at] == '\r' && at + 1 < text.size() && text[at + 1] == '\n'); + }; // The header at the start of a line, so `[toolchain]` is not found inside // `[toolchain.x]` or a comment. auto sectionPos = std::string::npos; for (auto p = text.find(header); p != std::string::npos; p = text.find(header, p + 1)) - if ((p == 0 || text[p - 1] == '\n') - && (p + header.size() == text.size() || text[p + header.size()] == '\n')) { + if ((p == 0 || text[p - 1] == '\n') && ends_line(p + header.size())) { sectionPos = p; break; } if (sectionPos == std::string::npos) { // Append the section's block at end. - if (!text.empty() && text.back() != '\n') text += '\n'; - text += std::format("\n{}\n{}", header, line); + if (!text.empty() && text.back() != '\n') text += eol; + text += std::format("{}{}{}{}", eol, header, eol, line); } else { // Locate an existing ` = ...` line within the section. Match at // line starts with a word boundary after the key, so `default` never // matches the `default_target` line (and vice versa). - auto eol = text.find('\n', sectionPos); - if (eol == std::string::npos) eol = text.size(); - auto bodyStart = (eol == text.size()) ? text.size() : eol + 1; + auto headerEnd = text.find('\n', sectionPos); + if (headerEnd == std::string::npos) headerEnd = text.size(); + auto bodyStart = (headerEnd == text.size()) ? text.size() : headerEnd + 1; auto nextSec = text.find("\n[", bodyStart); auto bodyEnd = (nextSec == std::string::npos) ? text.size() : nextSec; auto body = std::string_view(text).substr(bodyStart, bodyEnd - bodyStart); @@ -1502,10 +1531,8 @@ write_config_value(const GlobalConfig& cfg, std::string_view section, std::strin } } - std::ofstream os(cfg.configFile); - if (!os) return std::unexpected(ConfigError{ + if (!write_file(cfg.configFile, text)) return std::unexpected(ConfigError{ std::format("cannot write '{}'", cfg.configFile.string())}); - os << text; return {}; } diff --git a/src/doctor.cppm b/src/doctor.cppm index 3d92d000b..b18c1bac9 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -233,7 +233,7 @@ export int doctor_report() { // // Offline for the call: nothing is downloaded, installed or replaced, so // a build that would install first is reported as exactly that. Planned - // to describe, not to build (`plan_only`, as `mcpp emit build-database` + // to describe, not to build (`PrepareIntent::Describe`, as `mcpp emit build-database` // plans): the std module is described rather than compiled, generated // files are compared rather than written, and the lock this planning // produces goes to a scratch directory, not the project. @@ -246,7 +246,7 @@ export int doctor_report() { mcpp::ui::set_quiet(true); (void)mcpp::build::refusal::take(); mcpp::build::BuildOverrides ov; - ov.plan_only = true; + ov.intent = mcpp::build::PrepareIntent::Describe; std::error_code tec; ov.work_dir = std::filesystem::temp_directory_path(tec) / std::format("mcpp-doctor-{}", std::chrono::steady_clock::now().time_since_epoch().count()); diff --git a/src/fallback/config_migration.cppm b/src/fallback/config_migration.cppm index 3732fe43a..582e6e9cd 100644 --- a/src/fallback/config_migration.cppm +++ b/src/fallback/config_migration.cppm @@ -11,6 +11,7 @@ export module mcpp.fallback.config_migration; import std; +import mcpp.home.files; export namespace mcpp::fallback { @@ -39,11 +40,9 @@ bool replace_all(std::string& text, std::string_view from, std::string_view to) return changed; } +// Whole, never in place: every mcpp started on the home migrates the same file. void write_file(const std::filesystem::path& p, std::string_view content) { - std::error_code ec; - std::filesystem::create_directories(p.parent_path(), ec); - std::ofstream os(p); - os << content; + (void)mcpp::home::write_replacing(p, content); } bool write_text_if_changed(const std::filesystem::path& path, @@ -57,11 +56,11 @@ bool write_text_if_changed(const std::filesystem::path& path, } // namespace bool migrate_config_toml_index_names(const std::filesystem::path& path) { - std::ifstream is(path); - if (!is) return false; - std::stringstream ss; - ss << is.rdbuf(); - auto original = ss.str(); + // Read whole and closed before the file is replaced: Windows refuses to + // rename over a file this process holds open. + auto text = mcpp::home::read_whole(path); + if (!text) return false; + const auto original = *text; auto updated = original; replace_all(updated, "default = \"mcpp-index\"", "default = \"mcpplibs\""); @@ -74,11 +73,9 @@ bool migrate_config_toml_index_names(const std::filesystem::path& path) { } bool migrate_xlings_json_index_names(const std::filesystem::path& path) { - std::ifstream is(path); - if (!is) return false; - std::stringstream ss; - ss << is.rdbuf(); - auto original = ss.str(); + auto text = mcpp::home::read_whole(path); + if (!text) return false; + const auto original = *text; auto updated = original; replace_all(updated, "\"name\": \"mcpp-index\"", "\"name\": \"mcpplibs\""); diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index cea9708b0..aebf9e875 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -111,9 +111,14 @@ inline constexpr auto kTable = [] { // `link-emulation = "elf_i386"` were read and then reached no command line, // because the table above is compiled from the built-in rows alone. A defined // row lives in the registry for the rest of the process, so the views stay valid. +// +// A ROW MAY STATE ITS LINK WITHOUT ITS MACHINE (2026-11-13): `x86_64-uefi` +// leaves the processor to the compiler's default for the clang triple and +// states `link-emulation = "i386pep"`, the COFF link of ld.lld's MinGW driver. +// Either column makes the description; `row_words` emits only what is stated. inline std::optional defined_spec(std::string_view triple) { auto const* d = mcpp::toolchain::target::defined_row(triple); - if (!d || d->march.empty()) return std::nullopt; + if (!d || (d->march.empty() && d->linkEmulation.empty())) return std::nullopt; return Spec{ d->triple, d->march, d->mabi, d->codeModel, d->multilib, d->flags, d->linkEmulation }; } @@ -160,7 +165,7 @@ inline std::optional resolve(std::string_view triple) { // after them is the engine's convention (`mcpp why flags` tells them apart). inline std::vector row_words(const Spec& s) { std::vector out; - out.emplace_back(std::string("-march=") + std::string(s.march)); + if (!s.march.empty()) out.emplace_back(std::string("-march=") + std::string(s.march)); if (!s.mabi.empty()) out.emplace_back(std::string("-mabi=") + std::string(s.mabi)); if (!s.mcmodel.empty()) diff --git a/src/home.cppm b/src/home.cppm index 5c08b281f..1e2e65556 100644 --- a/src/home.cppm +++ b/src/home.cppm @@ -57,6 +57,10 @@ std::string spelling(const std::filesystem::path& path); std::filesystem::path from_utf8(std::string_view value); std::filesystem::path original_root(); std::string original_source(); +// A self-contained home (`/bin/mcpp`), stated as MCPP_HOME for the programs +// this mcpp starts: a build directory's graph runs the engine through its own +// link, from which the home cannot be derived. Its source stays `portable`. +void state_portable_home(); std::expected inspect(const std::filesystem::path& original); Selection selection(); bool initialized(const std::filesystem::path& path); @@ -155,11 +159,25 @@ std::filesystem::path original_root() { return portable.empty() ? default_home() : portable; } +namespace { +// MCPP_HOME was set by state_portable_home, not by the caller. +bool portable_stated = false; +} // namespace + std::string original_source() { + if (portable_stated) return "portable"; if (mcpp::invocation::get("MCPP_HOME")) return "environment"; return portable_root().empty() ? "user" : "portable"; } +void state_portable_home() { + if (mcpp::invocation::get("MCPP_HOME")) return; + const auto portable = portable_root(); + if (portable.empty()) return; + mcpp::invocation::set("MCPP_HOME", spelling(portable)); + portable_stated = true; +} + std::string spelling(const std::filesystem::path& path) { const auto bytes = path.generic_u8string(); return {reinterpret_cast(bytes.data()), bytes.size()}; diff --git a/src/home/files.cpp b/src/home/files.cpp index bd20477e4..af4f14c31 100644 --- a/src/home/files.cpp +++ b/src/home/files.cpp @@ -31,8 +31,9 @@ FileLock& FileLock::operator=(FileLock&& o) noexcept { std::optional FileLock::acquire( const std::filesystem::path& dir, std::string_view name, std::string_view owner, std::chrono::milliseconds notice, - const std::function& waiting) { + const std::function& waiting, bool* waited) { namespace fs = std::filesystem; + if (waited) *waited = false; const auto start = std::chrono::steady_clock::now(); auto pause = std::chrono::milliseconds(20); bool told = false; @@ -55,6 +56,7 @@ std::optional FileLock::acquire( // try_acquire could not even create the file: nothing to wait for. std::error_code ec; if (!fs::exists(dir / std::string(name), ec)) return std::nullopt; + if (waited) *waited = true; if (!told && std::chrono::steady_clock::now() - start >= notice) { told = true; if (waiting) waiting(holder(dir, name)); @@ -70,26 +72,127 @@ std::string FileLock::holder(const std::filesystem::path& dir, std::string_view } +namespace { +// The turns this thread holds, by lock: how deeply, and the lock. +struct HeldTurn { int depth = 0; std::optional lock; }; +std::map& held_turns() { + thread_local std::map turns; + return turns; +} +} // namespace + +Turn::Turn(const std::filesystem::path& dir, std::string_view name, std::string_view what, + std::chrono::milliseconds notice, + const std::function& waiting, bool* waited) { + if (waited) *waited = false; + // A state not yet made has its directory made here when the directory + // above it exists (a home's first install, before the provider wrote its + // state); a relative or unrooted directory names no state, and gives no turn. + std::error_code ec; + if (!dir.is_absolute() || !std::filesystem::is_directory(dir.parent_path(), ec)) return; + std::filesystem::create_directory(dir, ec); + key_ = (dir / std::string(name)).lexically_normal().generic_string(); + auto& t = held_turns()[key_]; + if (t.depth++ > 0) return; + t.lock = FileLock::acquire(dir, name, what, notice, waiting, waited); +} + +Turn::~Turn() { + if (key_.empty()) return; + auto& turns = held_turns(); + if (auto it = turns.find(key_); it != turns.end() && --it->second.depth == 0) turns.erase(it); +} + +// A refusal another program's hold on the file explains, which a moment +// later may not be there: Windows refuses an open or a rename while another +// program holds the file, or while a rename over it leaves the replaced copy +// pending deletion (a sharing violation, or access denied). Elsewhere these +// answers are final, and cost the failing call the wait. +namespace { +bool refused_for_now(const mcpplibs::os::error& e) { + return e.code == mcpplibs::os::errc::again || e.code == mcpplibs::os::errc::permission; +} +} // namespace + bool replace_file(const std::filesystem::path& source, const std::filesystem::path& destination, std::error_code& ec) { - // An editor or a virus scanner may hold the destination for a moment - // (Windows refuses the rename with a sharing violation): a few tries with - // a growing pause, then the failure. - auto delay = std::chrono::milliseconds{50}; - for (int attempt = 0; attempt < 4; ++attempt) { + // Windows refuses the rename while another program holds the destination + // open (a sharing violation): an editor, a virus scanner, or another mcpp + // reading config.toml, which holds it for the length of one read. Tries + // with a pause that starts short and grows to 20 ms, for two seconds, then + // the failure. A longer pause lost to readers that hold the file most of + // the time: programs started at once on a loaded Windows runner each read + // config.toml, and a writer that tried a dozen times in two seconds found + // it held at each (unit Config/ConfigValueIsReplacedWhole, 2026-11-14). + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{2}; + auto delay = std::chrono::milliseconds{1}; + for (;;) { const auto r = mcpplibs::os::fs::rename(source, destination, true); if (r) { ec.clear(); return true; } - if (attempt == 3 || (r.error().code != mcpplibs::os::errc::permission - && r.error().code != mcpplibs::os::errc::again)) { + if (!refused_for_now(r.error()) || std::chrono::steady_clock::now() + delay > deadline) { ec = std::make_error_code(r.error().code == mcpplibs::os::errc::not_found ? std::errc::no_such_file_or_directory : std::errc::permission_denied); return false; } std::this_thread::sleep_for(delay); - delay *= 3; + delay = std::min(delay * 2, std::chrono::milliseconds{20}); + } +} + +bool write_replacing(const std::filesystem::path& path, std::string_view text) { + std::error_code ec; + // A link (a config.toml a dotfile manager keeps elsewhere) is written + // through: the file it names is replaced, and the link stays. + auto file = path; + if (std::filesystem::is_symlink(path, ec)) + if (auto target = std::filesystem::canonical(path, ec); !ec) file = std::move(target); + std::filesystem::create_directories(file.parent_path(), ec); + auto tmp = file; + tmp += std::format(".{:x}.tmp", std::random_device{}()); + { + // The bytes as given: a text-mode stream on Windows writes each `\n` + // as `\r\n`, and a reader that looks for `\n` (write_config_value) + // no longer finds the line it wrote. + std::ofstream os(tmp, std::ios::binary); + if (!os) return false; + os << text; + if (!os.flush()) { + os.close(); + std::filesystem::remove(tmp, ec); + return false; + } + } + // The replaced file's permissions carry over (a config.toml kept private). + if (const auto st = std::filesystem::status(file, ec); !ec && std::filesystem::exists(st)) + std::filesystem::permissions(tmp, st.permissions(), std::filesystem::perm_options::replace, ec); + if (!replace_file(tmp, file, ec)) { + std::filesystem::remove(tmp, ec); + return false; + } + return true; +} + +std::optional read_whole(const std::filesystem::path& file) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{2}; + auto delay = std::chrono::milliseconds{5}; + for (;;) { + // Through mcpplibs.os, whose Windows open shares the file for reading, + // writing and deletion: a writer's rename over it is not refused for + // this reader's sake, as it was for a std::ifstream's. + auto text = mcpplibs::os::fs::read(file); + if (text) return std::move(*text); + // The refusal is read from the open's own error, not from a second + // question: while a writer renames over the file, Windows answers the + // reader's open and the query of the name alike with a refusal (the + // copy being replaced is pending deletion), and a reader that took the + // query's failure for absence gave up within the rename (unit + // Config/ConfigValueIsReplacedWhole, 2026-11-14). + if (!refused_for_now(text.error()) || std::chrono::steady_clock::now() + delay > deadline) + return std::nullopt; + std::this_thread::sleep_for(delay); + delay = std::min(delay * 2, std::chrono::milliseconds{200}); } - return false; } std::string untrusted_because(const std::filesystem::path& dir) { diff --git a/src/home/files.cppm b/src/home/files.cppm index 8a5156b01..b3b50e2fc 100644 --- a/src/home/files.cppm +++ b/src/home/files.cppm @@ -40,10 +40,12 @@ public: // waiter killed by a signal holds nothing either way. nullopt only when // the lock file cannot be created at all (an unwritable directory, whose // build fails on its own terms). + // `waited`, when given, is set when the lock was held by another at the + // first try, however short the wait. static std::optional acquire( const std::filesystem::path& dir, std::string_view name, std::string_view owner, std::chrono::milliseconds notice, - const std::function& waiting); + const std::function& waiting, bool* waited = nullptr); // What the current holder of / published (see acquire). static std::string holder(const std::filesystem::path& dir, std::string_view name); @@ -61,12 +63,50 @@ private: explicit FileLock(std::intptr_t native) : native_(native) {} }; +// ── Turn ────────────────────────────────────────────────────────────────── +// +// ONE WRITER OF A SHARED STATE AT A TIME, ACROSS PROCESSES AND THREADS: the +// exclusive lock / (FileLock::acquire), held by one thread, and +// taken again by that thread without waiting -- a holder that calls a writer +// which takes the same turn nests in it. `dir` names the state's real +// directory, so every path that reaches the state through a link takes one +// turn. `waited` is set when another holder was waited out: what that holder +// wrote is then looked for again. `dir` is made when the directory above it +// exists; a relative `dir`, or one whose lock cannot be created, gives no turn, +// and the caller goes on as before the turn existed. +class Turn { +public: + Turn(const std::filesystem::path& dir, std::string_view name, std::string_view what, + std::chrono::milliseconds notice, + const std::function& waiting, bool* waited = nullptr); + ~Turn(); + Turn(const Turn&) = delete; + Turn& operator=(const Turn&) = delete; + +private: + std::string key_; +}; + // `source` renamed over `destination`, which is not removed first: a failed // replacement leaves the last good copy (a compilation database an editor -// reads). A destination another program holds open is retried briefly. +// reads). A destination another program holds open is retried for up to two +// seconds. bool replace_file(const std::filesystem::path& source, const std::filesystem::path& destination, std::error_code& ec); +// `text` written beside `file` under a name of this writer's own and renamed over +// it (replace_file): a process reading the file while this one writes it sees the +// previous copy or the new one, never a part. False when it could not be written, +// the previous copy then unchanged. For the files of a home that processes started +// at once each write (config.toml, .xlings.json). +bool write_replacing(const std::filesystem::path& file, std::string_view text); + +// The text of `file`, or nothing when it cannot be read. Windows refuses an +// open for a moment while another program renames over the file +// (write_replacing): an open refused for the reason another program's hold +// gives is retried, for up to two seconds, as the writer's rename is. +std::optional read_whole(const std::filesystem::path& file); + // Whether a directory another program wrote may be read and run from: it // belongs to this user or to the system (root; on Windows also Administrators // and SYSTEM), and when it is not this user's, this user cannot write it. diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 35299015c..4d2622237 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -2384,10 +2384,19 @@ ProgramRow program_row(std::string_view system, std::string_view arch, bool sing // program for that system is, and the engine holds no such table. One // static program over the platform's hosted arrangement, the same whichever // machine packs it. + // A row the platform defines rather than hosts (a provider's) is built over + // the platform itself, as the next rule builds a system's row. if (P && P->active) if (auto it = P->fields.runner.builtAs.find(std::format("{}-{}", system, arch)); - it != P->fields.runner.builtAs.end()) - return { it->second, "static", hosted_platform_for(P, it->second) }; + it != P->fields.runner.builtAs.end()) { + auto over = hosted_platform_for(P, it->second); + if (over.empty()) + for (auto const& d : P->fields.defines) + if (auto t = mcpp::toolchain::triple::parse(d.triple), b = mcpp::toolchain::triple::parse(it->second); + t && b && t->str() == b->str()) + over = platform_ref_of(*P); + return { it->second, "static", over }; + } // A SYSTEM A PLATFORM DEFINES (mcpp#911 G10): its row of that architecture, // built over the platform that defines it, as the platform's carrier and // packs of every other row are. A system nothing states a row for has no @@ -2418,14 +2427,36 @@ ProgramRow program_row(std::string_view system, std::string_view arch, bool sing return { here ? std::string() : triple, mode }; } -// The bytes of `carried` as a section of a program of `system`, with the -// symbols the carrier's entry reads; `name` and `digest` as strings. An empty -// `carried` places nothing: `mcpp_carried_begin == mcpp_carried_end`. -std::string carried_assembly(std::string_view system, std::string_view sectionName, +// THE ROW A PROGRAM OF `system` ON `arch` IS BUILT FOR, as the vocabulary reads +// `-` -- a row a platform defines included. Its object format and +// its program's suffix are that row's statement and not its system's name: a +// carrier for a system a platform defines (`x86_64-uefi`, PE) was written as +// ELF and named without its suffix when the name decided. +// A system the platform defines a row for is that row, whose name may carry an +// environment (`dos` on i686 is `i686-dos-musl`), as program_row builds it. +std::optional system_row(std::string_view system, std::string_view arch, + const mcpp::platform::Resolved* P = nullptr) { + if (P && P->active) + for (auto const& d : P->fields.defines) + if (auto t = mcpp::toolchain::triple::parse(d.triple); t && t->os == system && t->arch == arch) + return t; + return mcpp::toolchain::triple::parse(std::format("{}-{}", arch, system)); +} +std::string program_suffix(std::string_view system, std::string_view arch, + const mcpp::platform::Resolved* P = nullptr) { + const auto row = system_row(system, arch, P); + return row ? std::string(mcpp::toolchain::triple::artifact_naming(*row, {}).exeSuffix) : std::string(); +} + +// The bytes of `carried` as a section of a program of `system` on `arch`, with +// the symbols the carrier's entry reads; `name` and `digest` as strings. An +// empty `carried` places nothing: `mcpp_carried_begin == mcpp_carried_end`. +std::string carried_assembly(std::string_view system, std::string_view arch, std::string_view sectionName, const std::filesystem::path& carried, std::string_view name, - std::string_view digest) { - const bool macho = system == "macos"; - const bool coff = system == "windows"; + std::string_view digest, const mcpp::platform::Resolved* P = nullptr) { + const auto row = system_row(system, arch, P); + const bool macho = row && row->is_mach_o(); + const bool coff = row && (row->is_pe() || row->object_format() == mcpp::toolchain::triple::ObjectFormat::Coff); const std::string sym = macho ? "_" : ""; std::string bare(sectionName); while (!bare.empty() && bare.front() == '.') bare.erase(bare.begin()); @@ -2449,15 +2480,18 @@ std::string carried_assembly(std::string_view system, std::string_view sectionNa } // Where a program generated under `dir` is packed: the tree, and the program -// in it -- under the tree's `bin/`, or, for a Windows program, whose tree is -// flat, at its root. +// in it -- under the tree's `bin/`, or, for a PE program, whose tree is flat, +// at its root. std::filesystem::path packed_tree(const std::filesystem::path& dir) { return dir / "target" / "dist" / "packed"; } std::filesystem::path packed_program(const std::filesystem::path& dir, std::string_view stem, - std::string_view system) { - if (system == "windows") return packed_tree(dir) / (std::string(stem) + ".exe"); - return packed_tree(dir) / "bin" / std::string(stem); + std::string_view system, std::string_view arch, + const mcpp::platform::Resolved* P = nullptr) { + const auto file = std::string(stem) + program_suffix(system, arch, P); + const auto row = system_row(system, arch, P); + if (row && row->is_pe()) return packed_tree(dir) / file; + return packed_tree(dir) / "bin" / file; } // The generated project of the carrier named `stem` for `system` and `arch`. @@ -2643,7 +2677,7 @@ carrier_build(const Plan& plan, const CarrierBuild& b) } if (!write(dir / "mcpp.toml", manifest) || !write(dir / "src" / "carried.S", - carried_assembly(b.system, c.section, b.carried, b.carriedName, {})) + carried_assembly(b.system, b.arch, c.section, b.carried, b.carriedName, {}, &plan.platform)) || !write(dir / "src" / "main.cpp", mainText)) return std::unexpected(Error{std::format("cannot write the program's sources under {}", dir.string())}); @@ -3600,7 +3634,7 @@ runner_program(const mcpp::platform::Resolved& platform, const std::filesystem:: if (auto why = detail::unbuilt_carrier(platform, system, arch, false)) return std::unexpected(Error{*why}); const auto program = detail::packed_program(detail::carrier_dir(projectRoot, name, system, arch), - name, system); + name, system, arch, &platform); if (!build) return program; Plan plan; plan.platform = platform; @@ -3692,7 +3726,7 @@ std::expected, Error> single_file(const Plan& const auto stem = fs::path(plan.binaryName).stem().string(); auto packed = detail::carrier_build(plan, { f.carried, plan.binaryName, stem, f.system, f.arch, true }); if (!packed) return std::unexpected(packed.error()); - const auto suffix = f.system == "windows" ? std::string(".exe") : std::string(); + const auto suffix = detail::program_suffix(f.system, f.arch, &plan.platform); const auto program = *packed / (stem + suffix); if (!fs::is_regular_file(program, ec)) return std::unexpected(Error{std::format("the carrier built for {}-{} has no {}", @@ -3790,14 +3824,14 @@ std::expected, Error> single_file(const Plan& || !write(dir / "mcpp.toml", d::launcher_manifest(stem, plan.packageVersion) + detail::static_row_pin(row)) || !write(dir / "src" / "main.cpp", d::launcher_main()) || !write(dir / "src" / "carried.S", - detail::carried_assembly(system, ".mcppsf", dir / "payload.zst", entry, digest))) + detail::carried_assembly(system, arch, ".mcppsf", dir / "payload.zst", entry, digest))) return std::unexpected(Error{std::format("cannot write the launcher's sources under {}", dir.string())}); mcpp::ui::info("Packing", std::format("{} into one file for {}-{} ({} payload, sha256 {})", plan.binaryName, system, arch, mcpp::pack::compress::name(c.algorithm), digest.substr(0, 12))); auto packed = detail::pack_generated_program(dir, row); if (!packed) return std::unexpected(packed.error()); - const auto suffix = system == "windows" ? std::string(".exe") : std::string(); + const auto suffix = detail::program_suffix(system, arch, &plan.platform); const auto program = *packed / (stem + suffix); const auto out = output_for(t ? t->str() : plan.triple, suffix); fs::create_directories(out.parent_path(), ec); diff --git a/src/pack/strip.cppm b/src/pack/strip.cppm index 81c897714..b2dda182f 100644 --- a/src/pack/strip.cppm +++ b/src/pack/strip.cppm @@ -224,6 +224,16 @@ strip_artifact(const std::filesystem::path& artifact, "match this toolchain.", artifact.filename().string())); + // A FILE NO STRIP READS (2026.11.13.1): a row's seal may wrap its link's + // output in a container of its own (the DOS row's MZ stub before an ELF32 + // executable), which is packed as the seal left it; stripping it would + // undo the seal. An archive is read as one. + if (shape != ArtifactShape::StaticArchive + && mcpp::pack::binfmt::identify(artifact).format == mcpp::pack::binfmt::Format::Unknown) { + out.outcome = StripOutcome::NotApplicable; + return out; + } + out.before = size_of(artifact); // ── separate, when asked ────────────────────────────────────────── diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index d40f9bb72..009abc113 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -95,9 +95,15 @@ mcpp::manifest::platform::Statement hosted_view(const mcpp::manifest::platform:: // The statement that applies to `identity` (SPEC-016 §3): the platform's, with the // fields of the `[[platform.provider]]` that serves it in place of the platform's // own (its toolchain, layers and link), or the hosted view when that provider is -// hosted. The platform's own statement when no provider of it names the identity. +// hosted. The platform's own statement when no provider of it names the identity, +// without the tools only another row's provider names. mcpp::manifest::platform::Statement provider_view(const mcpp::manifest::platform::Statement& platform, std::string_view identity); +// The statement a build of `identity` reads, `hosted` as serves() answered: the +// hosted view where the platform hosts the identity and no provider names it, +// the provider view otherwise (a row of the platform's own conventions included). +mcpp::manifest::platform::Statement view_of(const mcpp::manifest::platform::Statement& platform, + std::string_view identity, bool hosted); // The `[[platform.provider]]` of a platform that serves `identity`; null when none. const mcpp::manifest::platform::ProviderSpec* provider_of(const mcpp::manifest::platform::Statement& platform, std::string_view identity); @@ -427,34 +433,95 @@ const mp::ProviderSpec* provider_of(const mp::Statement& p, std::string_view ide return nullptr; } +// THE PROVIDER'S OWN FIELDS, OVER WHICHEVER BASE (2026-11-13). `link`, `sysroot` +// and `runner` are what a provider states for the identities it serves; with +// `base = "none"` they were dropped with the platform's, so a provider could not +// state the link of a row whose conventions are not the platform's own (the +// openkal platform's `x86_64-uefi` beside its ok images). The platform's top-level +// `runner` belongs to `[platform] targets`; a provider that states one runs its +// identities by it. +namespace { +void take_provider_fields(mp::Statement& s, const mp::ProviderSpec& pr) { + if (!pr.sysroot.empty()) s.sysroot = mp::Text{ pr.sysroot, pr.origin, true }; + if (!pr.runner.cmd.empty()) s.runner = pr.runner; + auto const& l = pr.link; + auto text = [](mp::Text& to, const mp::Text& from) { if (from.set) to = from; }; + text(s.link.driver, l.driver); + text(s.link.start, l.start); + text(s.link.script, l.script); + text(s.link.exe, l.exe); + text(s.link.shared, l.shared); + if (l.flags.set) s.link.flags = l.flags; + if (l.inputsSet) { s.link.inputs = l.inputs; s.link.inputsOrigin = l.inputsOrigin; s.link.inputsSet = true; } + if (l.sealSet) { s.link.seal = l.seal; s.link.sealOrigin = l.sealOrigin; s.link.sealSet = true; } +} + +// THE PROGRAMS A STATEMENT'S CONVENTIONS RUN: its link's inputs, its seal's steps, +// its runner and its named runners, each by its first word. +std::set named_programs(const mp::Link& l, const mp::Runner& runner, + const std::map& runners) { + std::set out; + for (auto const& in : l.inputs) if (!in.from.empty()) out.insert(in.from.front()); + for (auto const& step : l.seal) if (!step.empty()) out.insert(step.front()); + if (!runner.cmd.empty()) out.insert(runner.cmd.front()); + for (auto const& [_, w] : runners) if (!w.value.empty()) out.insert(w.value.front()); + return out; +} + +// WHICH OF THE PLATFORM'S TOOLS A VIEW CARRIES (2026.11.13.1). `[platform.tools]` +// are programs for the building machine, and a build provisions each tool its view +// carries. A provider with `base = "none"` carries the tools its own conventions +// name (a row's seal, beside rows whose conventions are the platform's own); every +// other view carries every tool but those that only such providers name, so the +// programs of one row are not built for another. +void select_tools(mp::Statement& s, const mp::Statement& p, const mp::ProviderSpec* own) { + if (own && own->base == "none") { + const auto named = named_programs(s.link, s.runner, s.runners); + s.tools.clear(); + for (auto const& [name, e] : p.tools) + if (named.contains(name)) s.tools.emplace(name, e); + return; + } + std::set foreign; + for (auto const& pr : p.providers) + if (&pr != own && pr.base == "none") + for (auto const& n : named_programs(pr.link, pr.runner, {})) foreign.insert(n); + if (foreign.empty()) return; + const auto named = named_programs(s.link, s.runner, s.runners); + for (auto const& n : foreign) + if (!named.contains(n)) s.tools.erase(n); +} +} // namespace + mp::Statement provider_view(const mp::Statement& p, std::string_view identity) { const auto* pr = provider_of(p, identity); - if (!pr) return p; + if (!pr) { + auto s = p; + select_tools(s, p, nullptr); + return s; + } if (pr->base == "none") { auto s = hosted_view(p); s.cHosted = pr->hosted; if (!pr->toolchain.empty()) s.toolchain = mp::Text{ pr->toolchain, pr->origin, true }; if (!pr->layers.empty()) s.layers = pr->layers; + take_provider_fields(s, *pr); + select_tools(s, p, pr); return s; } auto s = p; s.cHosted = pr->hosted; if (!pr->toolchain.empty()) s.toolchain = mp::Text{ pr->toolchain, pr->origin, true }; if (!pr->layers.empty()) s.layers = pr->layers; - if (!pr->sysroot.empty()) s.sysroot = mp::Text{ pr->sysroot, pr->origin, true }; - auto const& l = pr->link; - auto text = [](mp::Text& to, const mp::Text& from) { if (from.set) to = from; }; - text(s.link.driver, l.driver); - text(s.link.start, l.start); - text(s.link.script, l.script); - text(s.link.exe, l.exe); - text(s.link.shared, l.shared); - if (l.flags.set) s.link.flags = l.flags; - if (l.inputsSet) { s.link.inputs = l.inputs; s.link.inputsOrigin = l.inputsOrigin; s.link.inputsSet = true; } - if (l.sealSet) { s.link.seal = l.seal; s.link.sealOrigin = l.sealOrigin; s.link.sealSet = true; } + take_provider_fields(s, *pr); + select_tools(s, p, pr); return s; } +mp::Statement view_of(const mp::Statement& p, std::string_view identity, bool hosted) { + return hosted && !provider_of(p, identity) ? hosted_view(p) : provider_view(p, identity); +} + std::expected serves(const mp::Statement& p, std::string_view package, const mcpp::toolchain::triple::Triple& t) { // A provider of the platform that names the identity (SPEC-016 §3). diff --git a/src/project.cppm b/src/project.cppm index e3d6c364a..2209761af 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -356,7 +356,10 @@ void merge_layer_root_position(mcpp::manifest::Manifest& member, if (inserted) continue; auto& to = it->second; if (!to.optDeclared && from.optDeclared) { to.optLevel = from.optLevel; to.optDeclared = true; } - if (!to.debugDeclared && from.debugDeclared) { to.debug = from.debug; to.debugDeclared = true; } + if (!to.debugDeclared && from.debugDeclared) { + to.debug = from.debug; to.debugLines = from.debugLines; to.debugWritten = from.debugWritten; + to.debugDeclared = true; + } if (!to.ltoDeclared && from.ltoDeclared) { to.lto = from.lto; to.ltoDeclared = true; } if (!to.stripDeclared && from.stripDeclared) { to.strip = from.strip; to.stripDeclared = true; } if (!to.ndebugDeclared && from.ndebugDeclared) { to.ndebug = from.ndebug; to.ndebugDeclared = true; } @@ -1138,7 +1141,7 @@ export std::string root_position_key( field("run_exclusive", b.runExclusive ? "1" : "0"); for (auto const& [name, pr] : m.profiles) { field("profile." + name, std::format("{}/{}/{}/{}/{}", pr.optLevel, - pr.debug ? 1 : 0, pr.lto ? 1 : 0, pr.strip ? 1 : 0, + pr.debug ? (pr.debugLines ? "lines" : "1") : "0", pr.lto ? 1 : 0, pr.strip ? 1 : 0, pr.dependencyLinkageDeclared ? pr.dependencyLinkage : std::string("-"))); list("profile." + name + ".cflags", pr.cflags); list("profile." + name + ".cxxflags", pr.cxxflags); diff --git a/src/provider/provider.cppm b/src/provider/provider.cppm index db2a4f151..69750b87e 100644 --- a/src/provider/provider.cppm +++ b/src/provider/provider.cppm @@ -412,6 +412,14 @@ struct PayloadStatus { }; // One provider at one Location. +// The lock in Provider::state_dir() that orders every writer of the provider's +// state (mcpp::home::Turn): an install, a repository added or removed, a +// runtime chosen. A provider that writes its state atomically needs none; one +// that does not refused a second writer's save on Windows ("Failed to write +// file ... subos\\default\\.xlings.json"), measured with the e2e programs +// of one home beside each other. +inline constexpr std::string_view kStateTurn = ".mcpp-state.lock"; + class Provider { public: virtual ~Provider() = default; @@ -442,6 +450,11 @@ public: virtual NetworkReport check_network(std::chrono::milliseconds budget = std::chrono::seconds(5)) const = 0; virtual std::filesystem::path data_root() const = 0; // where its indexes are virtual std::filesystem::path config_file() const = 0; // the home's own configuration + // THE REAL DIRECTORY OF THE STATE ITS INSTALLS WRITE (the environments' + // descriptions), every link resolved: the homes that reach one state -- + // a home whose `registry` or whose `registry/subos` is a link -- name one + // directory, and one turn (kStateTurn) orders their writers. + virtual std::filesystem::path state_dir() const = 0; // ── operations ── virtual std::expected diff --git a/src/provider/xlings/provider.cppm b/src/provider/xlings/provider.cppm index 693c8efe2..dd14546e9 100644 --- a/src/provider/xlings/provider.cppm +++ b/src/provider/xlings/provider.cppm @@ -302,6 +302,7 @@ public: std::filesystem::path config_file() const override { return (env_.projectDir.empty() ? env_.home : env_.projectDir) / ".xlings.json"; } + std::filesystem::path state_dir() const override { return xlings::state_dir(env_); } std::expected call(std::string_view operation, std::string_view argsJson, mcpp::provider::EventHandler* handler) const override { diff --git a/src/provider/xlings/xlings.cpp b/src/provider/xlings/xlings.cpp index 3c25e9b29..96d1e9ab1 100644 --- a/src/provider/xlings/xlings.cpp +++ b/src/provider/xlings/xlings.cpp @@ -947,7 +947,36 @@ Invocation invocation(const Env& env, std::vector args) { return inv; } +std::filesystem::path state_dir(const Env& env) { + const auto dir = env.projectDir.empty() ? env.home / "subos" : env.projectDir; + std::error_code ec; + auto real = std::filesystem::weakly_canonical(dir, ec); + return ec ? dir : real; +} + +namespace { +// THE PROVIDER'S STATE, ONE WRITER AT A TIME (kStateTurn). xlings saves a +// SubOS description by writing it in place, and on Windows a save while +// another process of the same state held the file was refused: the install +// failed after its payload was placed. Every invocation that writes the state +// takes the turn; a caller already holding it (an install in prepare) nests. +// When the provider writes atomically, the turn orders writers that no longer +// collide, and costs a lock per install. +void hold_state_turn(std::optional& t, const Env& env, std::string_view what) { + t.emplace(state_dir(env), mcpp::provider::kStateTurn, what, std::chrono::seconds(2), + [](std::string_view holder) { + mcpp::log::verbose("xlings", std::format("waiting for the provider's state turn{}{}", + holder.empty() ? "" : ", held by ", holder)); + }); +} +bool writes_state(std::string_view capability) { + return capability == "install_packages" || capability == "add_repo" || capability == "remove_repo"; +} +} // namespace + void select_runtime(const Env& env, std::string_view runtimeId) { + std::optional turn; + hold_state_turn(turn, env, "choosing the runtime"); auto inv = invocation(env, { "config", "--runtime", std::string(runtimeId) }); inv.options.streams = mcpp::run::Streams::discard; (void)mcpp::run::execute(inv.argv, inv.options); @@ -1131,6 +1160,8 @@ call(const Env& env, std::string_view capability, // Stdout is parsed line by line as NDJSON, so merging stderr into it could // split an event; discarding it lost the one line that names a rejection. // It is read only when the call fails. + std::optional turn; + if (writes_state(capability)) hold_state_turn(turn, env, std::format("xlings {}", capability)); auto inv = invocation(env, { "interface", std::string(capability), "--args", std::string(argsJson) }); const auto cmd = mcpp::build::command_line::display(inv.argv); @@ -1483,6 +1514,8 @@ int install_with_progress(const Env& env, std::string_view target, // the interface only retries the same operation and hides its first error. if (!quiet) print_status("Installing", target); mcpp::effects::note_network_access(); + std::optional turn; + hold_state_turn(turn, env, std::format("installing {}", target)); auto inv = invocation(env, { "interface", "install_packages", "--args", argsJson }); int resultExitCode = -1; @@ -1550,6 +1583,8 @@ int install_with_progress(const Env& env, std::string_view target, } int install_direct(const Env& env, std::string_view target, bool quiet) { + std::optional turn; + hold_state_turn(turn, env, std::format("installing {}", target)); auto inv = invocation(env, { "install", std::string(target), "-y" }); mcpp::effects::note_network_access(); // An empty standard input. Lines are passed through as the inherited @@ -1679,6 +1714,8 @@ void ensure_init(const Env& env, bool quiet) { // whatever the caller's project directory is. Env globalEnv = env; globalEnv.projectDir.clear(); + std::optional turn; + hold_state_turn(turn, globalEnv, "initialising the sandbox"); auto inv = invocation(globalEnv, { "self", "init" }); inv.options.streams = mcpp::run::Streams::discard; inv.options.total = std::chrono::seconds{120}; @@ -1954,8 +1991,25 @@ int update_index_unguarded(const Env& env, bool quiet) { std::filesystem::create_directories(paths::index_data(env), lockEc); auto lock = mcpp::home::FileLock::try_acquire(paths::index_data(env)); if (!lock) { - mcpp::log::verbose("index", - "another process is refreshing the index — skipping this one"); + // UNLESS THERE IS NO INDEX TO READ (mcpp#927, 2026-11-12 §18 P3). A + // process that skipped the refresh then read an index another process + // was still fetching, or exchanging on a volume without an atomic + // exchange, and reported "no local package index" for a home that had + // one a moment later. Such a reader waits for the refresh it skipped; + // one with an index skips as before. + bool absent = false; + for (const auto& d : paths::declared_indices(env)) + if (!std::filesystem::exists(d.dir, lockEc)) absent = true; + if (!absent) { + mcpp::log::verbose("index", + "another process is refreshing the index — skipping this one"); + return 0; + } + auto waited = mcpp::home::FileLock::acquire( + paths::index_data(env), ".lock", "a refresh of the package index", + std::chrono::milliseconds(500), [&](std::string_view) { + if (!quiet) print_status("Waiting", "for the package index another mcpp is refreshing"); + }); return 0; } diff --git a/src/provider/xlings/xlings.cppm b/src/provider/xlings/xlings.cppm index 9b03b8125..c679803fe 100644 --- a/src/provider/xlings/xlings.cppm +++ b/src/provider/xlings/xlings.cppm @@ -419,6 +419,10 @@ std::vector invocation_env(const Env& env); // The runtime the home's `abi` link selects (`xlings config --runtime`). void select_runtime(const Env& env, std::string_view runtimeId); +// Where the state an install writes lives, every link resolved: the project's +// home, or the home's `subos` (Provider::state_dir). +std::filesystem::path state_dir(const Env& env); + // Build full xlings interface command. // interface --args '' 2>/dev/null std::string build_interface_command(const Env& env, diff --git a/src/run/run.cpp b/src/run/run.cpp index 04d4acad2..d67115b34 100644 --- a/src/run/run.cpp +++ b/src/run/run.cpp @@ -44,11 +44,20 @@ std::map>& cleanups() { return c; } +std::atomic g_finishing{false}; + // A started program, ended with mcpp at a request for as long as this lives. +// One that ends while mcpp ends at a request (it was asked to, below) leaves +// nothing of its unit: what it started is ended with it, as it would have +// been had the program still run when the request's moment was over. struct Registered { Process* p; explicit Registered(Process* q) : p(q) { std::lock_guard g(g_live); live().insert(p); } - ~Registered() { std::lock_guard g(g_live); live().erase(p); } + ~Registered() { + if (g_finishing.load()) p->end_unit(); + std::lock_guard g(g_live); + live().erase(p); + } Registered(const Registered&) = delete; Registered& operator=(const Registered&) = delete; }; @@ -57,7 +66,6 @@ struct Registered { // A second caller returns when the first has finished: what it ends then is // the state the request left. A cleanup that reaches it again from within (a // terminal's mode restored onto an output with no reader) returns at once. -std::atomic g_finishing{false}; std::once_flag g_finished; thread_local bool t_finishing = false; void finish_once() { @@ -71,6 +79,25 @@ void finish_once() { for (auto const& [path, content] : interrupt_files()) (void)os::fs::write_atomic(path, content); } + // A REQUEST FIRST, THEN THE END. A program asked to end ends what it + // started: ninja ends each command it started, in a process group of the + // command's own, which ending ninja's unit outright does not reach, and a + // Ctrl-C left them running (a prepare action alive 3 s after mcpp ended, + // e2e 845). Each program is asked, the programs are given a moment to + // end, and what still runs is then ended with its unit. + { + std::lock_guard g(g_live); + for (auto* p : live()) p->terminate(); + } + const auto grace = std::chrono::steady_clock::now() + std::chrono::seconds(2); + for (;;) { + { + std::lock_guard g(g_live); + if (live().empty()) break; + } + if (std::chrono::steady_clock::now() >= grace) break; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } { std::lock_guard g(g_live); for (auto* p : live()) { p->end_unit(); p->kill(); } @@ -325,10 +352,13 @@ Outcome launch(const Spec& s) { || (s.shouldStop && *s.shouldStop))) { // Stream EOF is independent of child completion. Keep the same bounds // through the final wait, including inherited and discarded streams. - for (;;) { - auto waited = p->wait_for(std::chrono::milliseconds(20)); + // Polled with the wait lock free between polls (see below), from 1 ms + // growing to 20 ms: a program that ends at once is seen at once. + for (auto pause = std::chrono::milliseconds(1);; pause = std::min(pause * 2, std::chrono::milliseconds(20))) { + auto waited = p->try_wait(); if (!waited || waited->has_value()) break; if (should_stop()) { stop = true; break; } + std::this_thread::sleep_for(pause); } } if (stop) { @@ -336,7 +366,19 @@ Outcome launch(const Spec& s) { p->kill(); out.timed_out = true; } - auto st = p->wait(); + // POLLED, NOT ONE BLOCKING WAIT: a wait holds the program's wait lock for + // as long as it blocks, and the request to end it (`terminate', above) + // takes that lock, so an interrupt waited behind the program it was to + // end. Between the polls the lock is free: a timed wait that releases it + // only to take it again at once starved the request (measured: an action + // ignored SIGTERM for as long as its program ran). + std::optional st; + for (auto pause = std::chrono::milliseconds(1);; pause = std::min(pause * 2, std::chrono::milliseconds(20))) { + auto waited = p->try_wait(); + if (!waited) break; + if (waited->has_value()) { st = **waited; break; } + std::this_thread::sleep_for(pause); + } out.exit_code = st ? st->shell_code() : 127; return out; } diff --git a/src/runtime/elf.cppm b/src/runtime/elf.cppm index 12d38d139..1962c17c5 100644 --- a/src/runtime/elf.cppm +++ b/src/runtime/elf.cppm @@ -993,15 +993,24 @@ inspect_elf_runtime(const std::filesystem::path& artifact) { || reader.bytes[2] != 'L' || reader.bytes[3] != 'F') return std::unexpected(std::format( "artifact '{}' is not ELF", artifact.string())); - if (reader.bytes[4] != 2 || reader.bytes[5] != 1) + // Either class (i386's programs and images are ELF32): the header, the + // program headers and the dynamic entries differ in width and place; the + // strings and the version-need records do not. + if ((reader.bytes[4] != 1 && reader.bytes[4] != 2) || reader.bytes[5] != 1) return std::unexpected(std::format( - "artifact '{}' is not ELF64 little-endian", artifact.string())); + "artifact '{}' is not little-endian ELF", artifact.string())); + const bool wide = reader.bytes[4] == 2; + auto word = [&](std::uint64_t off) -> std::optional { + if (wide) return reader.u64(off); + if (auto v = reader.u32(off)) return *v; + return std::nullopt; + }; auto type = reader.u16(0x10); - auto phoff = reader.u64(0x20); - auto phentsize = reader.u16(0x36); - auto phnum = reader.u16(0x38); - if (!type || !phoff || !phentsize || !phnum || *phentsize < 0x38 + auto phoff = word(wide ? 0x20 : 0x1c); + auto phentsize = reader.u16(wide ? 0x36 : 0x2a); + auto phnum = reader.u16(wide ? 0x38 : 0x2c); + if (!type || !phoff || !phentsize || !phnum || *phentsize < (wide ? 0x38 : 0x20) || *phnum > 4096 || !reader.range(*phoff, static_cast(*phentsize) * *phnum)) return std::unexpected(std::format( @@ -1015,9 +1024,9 @@ inspect_elf_runtime(const std::filesystem::path& artifact) { for (std::uint16_t i = 0; i < *phnum; ++i) { auto off = *phoff + static_cast(i) * *phentsize; auto ptype = reader.u32(off); - auto poff = reader.u64(off + 0x08); - auto pvaddr = reader.u64(off + 0x10); - auto pfilesz = reader.u64(off + 0x20); + auto poff = word(off + (wide ? 0x08 : 0x04)); + auto pvaddr = word(off + (wide ? 0x10 : 0x08)); + auto pfilesz = word(off + (wide ? 0x20 : 0x10)); if (!ptype || !poff || !pvaddr || !pfilesz || !reader.range(*poff, *pfilesz)) return std::unexpected(std::format( @@ -1034,14 +1043,15 @@ inspect_elf_runtime(const std::filesystem::path& artifact) { } if (!dynamic) return out; // static ELF or relocatable object - if (dynamic->filesz % 16 != 0) return std::unexpected(std::format( + const std::uint64_t entry = wide ? 16 : 8; + if (dynamic->filesz % entry != 0) return std::unexpected(std::format( "artifact '{}' has a malformed PT_DYNAMIC", artifact.string())); std::vector> entries; for (std::uint64_t off = dynamic->offset; - off + 16 <= dynamic->offset + dynamic->filesz; off += 16) { - auto tag = reader.u64(off); - auto value = reader.u64(off + 8); + off + entry <= dynamic->offset + dynamic->filesz; off += entry) { + auto tag = word(off); + auto value = word(off + entry / 2); if (!tag || !value) return std::unexpected(std::format( "artifact '{}' has a truncated dynamic entry", artifact.string())); if (*tag == detail::kDtNull) break; diff --git a/src/toolchain/cenv.cppm b/src/toolchain/cenv.cppm index 314045162..1f98998f6 100644 --- a/src/toolchain/cenv.cppm +++ b/src/toolchain/cenv.cppm @@ -208,9 +208,9 @@ inline mcpp::targetside::CAbiDataModel arch_default_data_model( // MSVC alike; every other OS this table covers follows the architecture's // own convention. inline mcpp::targetside::CAbiDataModel triple_native_data_model( - std::string_view os, std::string_view arch) { + std::string_view container, std::string_view arch) { using mcpp::targetside::CAbiDataModel; - if (os == "windows") return CAbiDataModel::Llp64; + if (container == "PE") return CAbiDataModel::Llp64; return arch_is_64bit(arch) ? CAbiDataModel::Lp64 : CAbiDataModel::Ilp32; } @@ -230,8 +230,8 @@ inline mcpp::targetside::CAbiDataModel triple_native_data_model( // declaration asks for 32, regardless of what the toolchain would have // defaulted to — so the probe then measures the state the engine actually // produced, not the host's leak. -inline int native_wchar_bits(std::string_view os) { - return os == "windows" ? 16 : 32; +inline int native_wchar_bits(std::string_view container) { + return container == "PE" ? 16 : 32; } // `decl.declared` must be true — callers hold `TargetSide::cAbiDecl`, which is @@ -242,7 +242,7 @@ inline int native_wchar_bits(std::string_view os) { // and what is missing (§3.2: "无法满足...明确拒绝并说明缺什么,不静默降级"). inline std::expected realise( const mcpp::targetside::CAbiDecl& decl, std::string_view os, - std::string_view arch, bool freestanding) { + std::string_view arch, std::string_view container, bool freestanding) { using mcpp::targetside::CAbiPresents; using mcpp::targetside::CAbiDataModel; @@ -255,7 +255,7 @@ inline std::expected realise( " missing {}\n" " mcpp knows one realisation for a POSIX-presenting " "environment on a non-POSIX target: the Cygwin-flavoured " - "compile on Windows/x86_64 (design 2026-09-18 §3.3). Every " + "compile on a PE target, x86_64 (design 2026-09-18 §3.3). Every " "other combination this build asked for has no known mapping " "and is refused rather than silently approximated.", os, freestanding ? " (freestanding)" : "", @@ -275,6 +275,16 @@ inline std::expected realise( // than leave for the verification probe to catch after a real compile. bool cygwinIdentity = false; + // THE CONTAINER, NOT THE SYSTEM'S NAME, SELECTS THE MECHANISM (2026-11-13). + // What a compile line costs to present POSIX is a property of the + // toolchain's conventions for the container: a PE target is LLP64 with a + // sixteen-bit `wchar_t` and predefines `_WIN32` where it has a system, an + // ELF target predefines `__unix__`, a Mach-O target `__APPLE__`. A row a + // platform defines (`x86_64-uefi`, PE) is answered as the built-in rows of + // its container are, with no system name in this table; the verification + // probe below measures the result either way. `os` names the target in + // messages only. + // ── `presents`: the environment-identity macros ───────────────────────── // // THE RULE (design 2026-09-18 §3.3, coordinator revision from the @@ -310,7 +320,7 @@ inline std::expected realise( return refuse("a freestanding target has no platform identity " "macros to become `windows`"); } - } else if (os == "windows") { + } else if (container == "PE") { if (decl.presents == CAbiPresents::Posix) { if (arch != "x86_64") return refuse("the Cygwin-flavoured realisation is measured " @@ -403,7 +413,7 @@ inline std::expected realise( return refuse("no known way to suppress every environment-" "identity macro on a hosted Windows triple"); } - } else if (os == "linux" || os == "freebsd" || os == "netbsd") { + } else if (container == "ELF") { if (decl.presents == CAbiPresents::Posix) { // The default triple already presents POSIX — nothing to add. The // BSDs' ELF triples predefine `__unix__` as Linux's does, and are @@ -417,7 +427,7 @@ inline std::expected realise( "identity belongs to a different object format", os, mcpp::targetside::c_abi_presents_name(decl.presents))); } - } else if (os == "macos") { + } else if (container == "Mach-O") { if (decl.presents == CAbiPresents::Posix) { // UNLIKE LINUX: Apple's clang predefines `__APPLE__`/`__MACH__` // on its default triple, never `__unix__` (measured — this is @@ -435,7 +445,7 @@ inline std::expected realise( os, mcpp::targetside::c_abi_presents_name(decl.presents))); } } else { - return refuse("this OS is outside the mapping table §3.3 publishes"); + return refuse(std::format("the {} container is outside the mapping table §3.3 publishes", container)); } // ── `data-model` ───────────────────────────────────────────────────────── @@ -455,7 +465,7 @@ inline std::expected realise( // instead of a false "already satisfied": the identity switch that // `presents = "posix"` required already left LLP64 behind. const auto effectiveNative = cygwinIdentity ? CAbiDataModel::Lp64 - : triple_native_data_model(os, arch); + : triple_native_data_model(container, arch); if (want == effectiveNative) { // Already satisfied — the common case, and the only one that // needs no further check: `arch-default` on Linux/macOS/native @@ -510,7 +520,7 @@ inline std::expected realise( // token that makes the compile match the declaration. r.tokens.push_back(decl.wcharBits == 32 ? "-fno-short-wchar" : "-fshort-wchar"); - } else if (decl.wcharBits != native_wchar_bits(os)) { + } else if (decl.wcharBits != native_wchar_bits(container)) { // Hosted: the target's own default is what the compile sees, // and `native_wchar_bits(os)` correctly captures it // (16 on Windows because MinGW headers bake in @@ -604,15 +614,10 @@ inline std::expected realise( // idiom-triggering unit for `aarch64-macos` over the openkal stack and // asserts the symbol is absent from the object. // - // `os == "macos" || os == "ios"` IS `Triple::is_apple()`, SPELLED OUT. - // This module takes `os` as a string rather than a `Triple` on purpose --- - // it is pure, and importing `mcpp.toolchain-model` to reach one predicate - // would couple what that choice decoupled. The canonical list is - // `modules/toolchain-model/src/triple.cppm`; `ninja_backend.cppm` spells - // it out twice for the same reason. A new Apple OS in the vocabulary has - // to visit all three, and this comment is the grep target that says so. + // The Apple loop idioms belong to the Mach-O targets, whose system library + // is libSystem; the container is the row's (`Triple::object_format()`). if (decl.builtins == mcpp::targetside::CAbiBuiltins::Iso) { - if (!freestanding && (os == "macos" || os == "ios")) + if (!freestanding && container == "Mach-O") r.builtinsTokens.push_back("-fno-builtin"); } diff --git a/src/toolchain/clang.cpp b/src/toolchain/clang.cpp index 58a8142d2..129e7f659 100644 --- a/src/toolchain/clang.cpp +++ b/src/toolchain/clang.cpp @@ -147,6 +147,15 @@ void enrich_toolchain(Toolchain& tc, const mcpp::run::Env& env) { // std.cppm carries no __cplusplus guard. Verified on clang 22.1.8 + // libc++ (std and std.compat) at -std=c++20 (design §2.2). tc.importStdMinLevel = 20; + } else if (!msvTarget) { + // Not an answer: the driver failed the question the second probe + // asks (a compiler that does not start, a broken payload). Said by + // the refusal of `import std`, which otherwise names only its absence. + // A driver for the MSVC ABI is not asked: its std module is the STL's, + // looked for below. + if (auto r = mcpp::toolchain::run_capture( + { tc.binaryPath.string(), "--print-file-name=libc++.a" }, env, /*withErrors=*/true); !r) + tc.stdModuleUnanswered = r.error().message; } if constexpr (mcpplibs::os::is_windows) { diff --git a/src/toolchain/compat.cppm b/src/toolchain/compat.cppm index 421dc0db5..08b8cc680 100644 --- a/src/toolchain/compat.cppm +++ b/src/toolchain/compat.cppm @@ -28,6 +28,7 @@ import std; import mcpplibs.mwrap.libc; import mcpp.toolchain.platform; import mcpp.toolchain.triple; +import mcpp.toolchain.payloads; namespace libc = mcpplibs::mwrap::libc::macros; @@ -84,10 +85,6 @@ triple::Triple host_musl_triple() { return t; } -triple::Triple windows_gnu_triple() { - return { "x86_64", "windows", "gnu" }; -} - NormalizedSpec with_hint(NormalizedSpec s, std::string_view oldSpelling) { s.changed = true; std::string canonical = std::format("{}@{}", s.family, @@ -140,34 +137,22 @@ std::optional normalize_spec(std::string_view compilerIn, // already named the target. `with_hint` is deliberately not used: a hint // says "this spelling is old, here is the current one", and these are the // current ones. - if (compiler == "emsdk" || compiler == "emscripten") { - out.family = "llvm"; - out.payload = "emsdk"; - if (auto t = triple::parse("wasm32-emscripten")) out.target = *t; + // A PAYLOAD'S NAME (data/builtin.toml, `[[toolchain.payload]]`). One whose + // version is its family's (`musl-gcc@16.1.0`, `mingw@16.1.0`) is a spelling + // of the family for its target, and which package serves that is decided + // from the host by the payload mapping, never by the name typed. One whose + // version is its own SDK's (`emsdk@6.0.9`, `android-ndk@30.0.16248370`) + // stays that payload; the NDK serves both Android architectures, so it sets + // no target and the arch arrives from `--target` or `[target.]`. + if (auto id = mcpp::toolchain::payloads::named(compiler)) { if (muslVersionSuffix) return std::nullopt; - return out; - } - // The NDK serves BOTH Android arches from one payload, so it must NOT set - // a target: the arch arrives from `--target` or `[target.]`, and - // pinning one here would make `android-ndk@` mean aarch64 to a reader - // who typed it for x86_64. - // - // ONE SPELLING. `ndk` was accepted here as an alias and the capability gate - // refuses it, because that gate compares the declared spelling against the - // ROW'S PIN -- `android-ndk@30.0.16248370` -- and `ndk@30.0.16248370` does - // not contain it. So the alias parsed and was then rejected at the point of - // use, which reads as a defect rather than as a naming choice. - // - // Withdrawn rather than completed. Teaching the gate to compare normalised - // payload names would make two spellings work and put the comparison in a - // second mechanism; one name makes the gate correct by construction. The - // name kept is the one the index uses, so there is a single string for this - // payload across the ecosystem. - if (compiler == "android-ndk") { - out.family = "llvm"; - out.payload = "android-ndk"; - if (muslVersionSuffix) return std::nullopt; - return out; + out.family = id->family; + out.target = id->target; + if (!id->familyVersion) { + out.payload = id->payload; + return out; + } + return with_hint(std::move(out), std::format("{}@{}", compiler, version)); } // ── legacy spellings ───────────────────────────────────────────────────── @@ -175,21 +160,6 @@ std::optional normalize_spec(std::string_view compilerIn, out.family = "llvm"; return with_hint(std::move(out), std::format("clang@{}", version)); } - if (compiler == "musl-gcc") { // musl as compiler-name prefix - out.family = "gcc"; - out.target = host_musl_triple(); - return with_hint(std::move(out), std::format("musl-gcc@{}", version)); - } - if (compiler == "mingw" || compiler == "mingw-cross" - || compiler == "mingw-gcc" || compiler == "mingw-cross-gcc") { - // One concept, two host-split legacy names: GCC targeting Windows PE - // (GNU CRT). Which payload serves it (native winlibs vs Linux-hosted - // cross) is decided by registry's payload mapping from the HOST — - // never by the name the user typed. - out.family = "gcc"; - out.target = windows_gnu_triple(); - return with_hint(std::move(out), std::format("{}@{}", compiler, version)); - } if (ends_with(compiler, "-gcc")) { // triple-named: aarch64-linux-musl-gcc auto tripleStr = compiler.substr(0, compiler.size() - 4); if (auto t = triple::parse(tripleStr)) { diff --git a/src/toolchain/gcc.cppm b/src/toolchain/gcc.cppm index b711fbf64..8dcfafc08 100644 --- a/src/toolchain/gcc.cppm +++ b/src/toolchain/gcc.cppm @@ -10,6 +10,8 @@ import mcpp.toolchain.model; import mcpp.toolchain.probe; import mcpp.provider; import mcpp.provider.select; +import mcpp.toolchain.triple; +import mcpp.toolchain.target_rows; export namespace mcpp::toolchain::gcc { @@ -183,6 +185,14 @@ std::filesystem::path binutils_prefix_dir(const Toolchain& tc) { // at all, so answering with one would describe a flag nobody passes. if (tc.compiler != CompilerId::GCC) return {}; if (is_musl_target(tc) || is_mingw_target(tc)) return {}; + // A cross payload a row names by its GCC target (`gcc-triple`) is built + // with its own binutils, under the same prefix; a host `as` would read its + // assembly as the host's (measured: DJGPP's 32-bit `push` refused). + // The target is read in either spelling: the row's, once the build has + // named it, or the compiler's own `-dumpmachine`, which detection reads. + for (auto const* r : mcpp::toolchain::target::rows()) + if (!r->gccTriple.empty() && (r->gccTriple == tc.targetTriple || r->triple == tc.targetTriple)) + return {}; // A GCC named by path (mcpp#755) finds its own assembler and linker, as // every self-contained GCC does; it is pointed elsewhere only when the // project stated where `as` (or `ld`) is. diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index 30bde9149..f00b8feb0 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -1133,7 +1133,7 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, // needs it MOST, before any build has provided it. There is no project // here, so the selection is mcpp's default environment, which is what // `RuntimeSelection{}` means. - if (!mcpp::toolchain::runtime_payload_kind(pkg).empty()) { + if (!mcpp::toolchain::runtime_payload_kind(pkg, payload->root).empty()) { if (auto rb = mcpp::runtime::resolve_runtime_binding( mcpp::provider::runtime::RuntimeSelection{}, {}, cfg.xlingsHome())) if (auto provided = ensure_declared_runtime(cfg, *rb); !provided) { diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index d0d2f529e..071d081e3 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -178,17 +178,54 @@ select_glibc_payload_lib(const std::filesystem::path& glibcRoot, return lib; } -// WHICH TOOLCHAIN PAYLOADS RUN ON A C RUNTIME PAYLOAD, stated once: a GNU -// gcc and an llvm on Linux. The runtime they run on is the one the provider -// bound them to when it installed them (its home's runtime pin, design -// 2026-10-16 §7.2); mcpp makes sure that runtime is installed and writes -// nothing into the toolchain (§7.1: the patchelf, specs and cfg rewrites -// that once lived here are gone). Empty on every other payload and on -// Windows, where a PE toolchain has no C runtime payload. -export std::string_view runtime_payload_kind(const XimToolchainPackage& pkg) { +export std::string read_elf_interp(const std::filesystem::path& bin); + +// Whether the programs of the payload at `root` start through a loader its +// provider home keeps: a C runtime payload of that home, or the home's link to +// one (`/abi/...`). Asked of the provider's layout and of the programs +// themselves (PT_INTERP of the first ELF of `bin/` that names a loader; a +// payload's programs are linked alike), so no payload and no system loader is +// named here. A Mach-O or PE program names none, and the answer is no. +export bool starts_through_its_home(const std::filesystem::path& root) { + const auto& layout = mcpp::provider::layout(); + const auto store = layout.store_of(root); + if (store.empty()) return false; + // Both sides as the filesystem resolves them: a home reached through a + // link (a borrowed registry) names its loader by either spelling. + std::error_code ec; + const auto home = std::filesystem::weakly_canonical(layout.home_of_store(store), ec); + if (ec || home.empty()) return false; + for (auto it = std::filesystem::directory_iterator(root / "bin", ec); + !ec && it != std::filesystem::directory_iterator(); it.increment(ec)) { + if (it->is_symlink(ec) || !it->is_regular_file(ec)) continue; + const auto interp = read_elf_interp(it->path()); + if (interp.empty()) continue; + std::error_code iec; + const auto loader = std::filesystem::weakly_canonical(std::filesystem::path(interp), iec); + const auto rel = (iec ? std::filesystem::path(interp) : loader).lexically_relative(home); + return !rel.empty() && *rel.begin() != ".."; + } + return false; +} + +// WHICH TOOLCHAIN PAYLOADS RUN ON A C RUNTIME PAYLOAD, stated once. A GNU +// gcc and an llvm on Linux by their recipes, before they are installed; any +// payload, once its directory `root` is known, by its programs: they start +// through a loader their provider home keeps (`starts_through_its_home`; the +// emsdk payload, whose clang names the home's loader link; design 2026-11-14 +// §5.5). The +// runtime they run on is the one the provider bound them to when it installed +// them (its home's runtime pin, design 2026-10-16 §7.2); mcpp makes sure that +// runtime is installed and linked, and writes nothing into the toolchain +// (§7.1: the patchelf, specs and cfg rewrites that once lived here are gone). +// Empty on every other payload and on Windows, where a PE toolchain has no C +// runtime payload. +export std::string_view runtime_payload_kind(const XimToolchainPackage& pkg, + const std::filesystem::path& root = {}) { if constexpr (mcpplibs::os::is_windows) return {}; if (pkg.needsGccPostInstallFixup) return "gcc"; if (pkg.ximName == "llvm") return "llvm"; + if (!root.empty() && starts_through_its_home(root)) return "elf"; return {}; } @@ -198,7 +235,7 @@ export std::string_view runtime_payload_kind(const XimToolchainPackage& pkg) { // patchelf is not guaranteed to be resolved, and a dependency on an external // tool for four fields of a header is a dependency that will be missing on // exactly the machine that needs the answer. -std::string read_elf_interp(const std::filesystem::path& bin) { +export std::string read_elf_interp(const std::filesystem::path& bin) { std::ifstream is(bin, std::ios::binary); if (!is) return {}; unsigned char ident[16]{}; diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index ce59c7150..d16d59e21 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -277,8 +277,18 @@ std::expected run_capture(const std::vector 300) out.resize(300); + if (!out.empty()) why = ": " + out; + } return std::unexpected(DetectError{ - std::format("'{}' exited with status {}", cmd, r.exit_code)}); + std::format("'{}' exited with status {}{}", cmd, r.exit_code, why)}); } return r.output; } diff --git a/src/toolchain/provider.cppm b/src/toolchain/provider.cppm index 46f847433..0861b3bc9 100644 --- a/src/toolchain/provider.cppm +++ b/src/toolchain/provider.cppm @@ -38,9 +38,8 @@ struct ProviderCapabilities { // MSVC's /scanDependencies is compiler-integrated but not wired yet. bool has_builtin_p1689_scan = false; - // True when the compiler supports C++ named modules at all. - // All three supported compilers do; kept for future use when we add - // compilers that don't (e.g. old MSVC versions, ICC). + // True when the compiler supports C++ named modules at all, which is what + // every C++ unit is built through. GCC before 14 does not. bool has_modules = true; // Canonical stdlib identifier: @@ -73,12 +72,16 @@ ProviderCapabilities capabilities_for(const Toolchain& tc) { ProviderCapabilities caps; caps.has_import_std = tc.hasImportStd; - caps.has_modules = true; // all supported compilers handle modules + caps.has_modules = true; switch (tc.compiler) { case CompilerId::GCC: { caps.has_scan_deps = false; // GCC has no clang-scan-deps equivalent - caps.has_builtin_p1689_scan = true; + // P1689 (`-fdeps-format`) and `-fmodules` arrived together in GCC 14 + // (`compiles_modules`): without them the graph is built from mcpp's + // own scan, and units that declare or import a module are refused. + caps.has_builtin_p1689_scan = compiles_modules(tc); + caps.has_modules = caps.has_builtin_p1689_scan; caps.stdlib_id = "libstdc++"; caps.archive_format = "ar"; break; diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index df76d9b50..4ce43603a 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -31,6 +31,7 @@ import mcpp.toolchain.msvc; import mcpp.toolchain.triple; import mcpp.toolchain.target_schema; import mcpp.toolchain.target_rows; +import mcpp.toolchain.payloads; import mcpp.toolchain.target_select; export namespace mcpp::toolchain { @@ -108,6 +109,12 @@ struct ToolchainSpec { // `msvc@` takes an installed toolset of that version first. bool ecosystemOnly = false; + // A PAYLOAD NAMED OUTRIGHT (`xim:@`, design 2026-11-12 §16 + // T2): a package outside the family vocabulary, `payloadName`. It is + // installed and then resolved as `path:` with the table its + // `.mcpp-toolchain.json` states; its family is its drivers'. + bool describedPayload = false; + // A TOOLCHAIN NAMED BY PATH (mcpp#755): `path:`, the spelling a // `[toolchain]` table and `MCPP_TOOLCHAIN=path:` reach the resolver // as. Empty for every managed spec. The family is the drivers' (a @@ -121,7 +128,7 @@ struct ToolchainSpec { payloadName.empty() ? family_name(family) : std::string_view(payloadName), version); - return ecosystemOnly && family == Family::Msvc ? "xim:" + base : base; + return ecosystemOnly && (family == Family::Msvc || describedPayload) ? "xim:" + base : base; } // "gcc@16.1.0" or "gcc@16.1.0 → x86_64-windows-gnu" — user-facing. @@ -190,19 +197,6 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec); // makes, so the two sources of a family's version answer the same question. std::vector pinned_versions_for(const ToolchainSpec& requested); -// #367: does a GCC spec aimed at the NATIVE Linux host resolve to the -// `musl-gcc` payload rather than the glibc `gcc` one? -// -// `xim:gcc` publishes x86_64 assets only (`archs = { "x86_64" }`); the GCC the -// ecosystem ships for other Linux architectures is `musl-gcc`, which does -// publish them (aarch64 included, verified against xlings-res). Asking for -// `gcc` on aarch64 404s. -// -// A free function taking the host arch rather than reading the compile-time -// constant, so the aarch64 answer is testable on an x86_64 machine — the whole -// point being an architecture the developer is not sitting in front of. -bool gcc_native_payload_is_musl(std::string_view hostArch, bool isLinux, - const triple::Triple& target); ToolchainSpec with_resolved_xim_version(const ToolchainSpec& spec, std::string_view ximVersion); @@ -295,6 +289,15 @@ struct PayloadDescriptor { // where it is used (`payload_default_runner`), because where a store lives // is a property of the machine and not of this file. std::string runner; + // SCHEMA 2 IS THE PATH TABLE (design 2026-11-12 §16, T1): what a project's + // `[toolchain]` table states of a directory, a payload states of itself -- + // the family its drivers speak, their prefix, the sysroot and the tools by + // role, each relative to the payload root. A payload named outright + // (`xim:@`) is resolved as `path:` with this table. + std::string family; + std::string prefix; + std::string sysroot; + std::vector> tools; // The directory the descriptor was read from. Not a key: filled by the // reader, so that a relative `runner` resolves against the payload it was // written for, including when the descriptor was found by walking up from @@ -455,6 +458,11 @@ std::filesystem::path derive_c_compiler_path(const std::filesystem::path& cxxPat // missing" — see mcpp.pack.strip, which refuses only the second. std::filesystem::path binutils_tool(const Toolchain& tc, std::string_view name); +// Whether the target's row states its GCC target (`gcc-triple`), so that the +// payload is a self-contained cross GCC whose tools carry that prefix. The +// target is read in either spelling: the row's, or the compiler's own. +bool row_states_gcc_target(const Toolchain& tc); + std::filesystem::path archive_tool(const Toolchain& tc); std::filesystem::path link_tool(const Toolchain& tc); std::filesystem::path staged_std_bmi_path(const Toolchain& tc, @@ -606,11 +614,28 @@ parse_toolchain_spec(std::string compilerArg, } auto norm = compat::normalize_spec(compilerArg, versionArg); + if (!norm && ecosystemOnly) { + // Any package of the index, which describes itself (T2). The family + // is its drivers', read once the payload is installed. + if (versionArg.empty() || versionArg == "system") + return std::unexpected(std::format( + "'xim:{}' names a package and no version; write xim:{}@", + compilerArg, compilerArg)); + ToolchainSpec spec; + spec.payloadName = compilerArg; + spec.version = versionArg; + spec.ecosystemOnly = true; + spec.describedPayload = true; + return spec; + } if (!norm) { + std::string payloads; + for (auto const& n : mcpp::toolchain::payloads::names()) + payloads += (payloads.empty() ? "" : ", ") + n; return std::unexpected(std::format( - "unknown toolchain '{}' (expected gcc | llvm | msvc | emsdk | " - "android-ndk, or a supported alias like mingw / musl-gcc)", - compilerArg)); + "unknown toolchain '{0}': a family (gcc, llvm, msvc), a payload ({1}), or any " + "package of the index as xim:{0}@", + compilerArg, payloads)); } ToolchainSpec spec; @@ -693,21 +718,6 @@ void print_compat_hint(const ToolchainSpec& spec) { compat::print_hint_once(spec.compatHint); } -bool gcc_native_payload_is_musl(std::string_view hostArch, bool isLinux, - const triple::Triple& target) { - if (!isLinux) return false; - if (hostArch == "x86_64") return false; // the glibc payload exists here - // "Native" = no explicit target, or one naming this same machine. A CROSS - // target keeps its own payload rule (the `-gcc` packages above). - // - // A target that names glibc (`aarch64-linux-gnu`) asks for the glibc - // payload, which `xim:gcc` publishes for aarch64 since 16.1.0 (a Luban - // system's own gcc is that one): answering it with musl-gcc built for - // another C library than the one named. - if (target.env == "gnu") return false; - return target.empty() - || (target.os == "linux" && target.arch == hostArch); -} // The NDK's own name for the HOST it runs on, which is the directory component // under `toolchains/llvm/prebuilt/`. Upstream ships `linux-x86_64`, @@ -785,187 +795,28 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { pkg.displaySpec = spec.display(); pkg.ximVersion = spec.version; pkg.family = spec.family; - - if (spec.family == Family::Msvc) { - // `xim:msvc@`. Only reached for a VERSIONED spec — - // `msvc@system` never gets here, because nothing about it is a - // package (see is_system_toolchain). - // - // frontendCandidates is what the generic bin/-shaped resolution - // looks for, and an msvc payload is not bin/-shaped: cl.exe lives at - // VC/Tools/MSVC//bin/Hostx64/x64/. The managed path therefore - // resolves through msvc::installation_at() instead — the same code - // that describes a system install. Keeping the candidate here means - // a caller that does use the generic path gets nothing rather than - // the wrong thing. - pkg.ximName = "msvc"; - pkg.ximVersion = spec.version; - pkg.frontendCandidates = {"cl.exe"}; - return pkg; - } - if (spec.family == Family::Llvm) { - // THE TARGET DECIDES THE PAYLOAD HERE TOO, and it did not used to. - // - // This returned the generic llvm payload unconditionally, which is - // right for every target llvm itself serves and wrong for the two that - // arrive with their own clang. `em++` and the NDK's `clang++` ARE - // clang -- same family, same flag vocabulary, same `import std` path - // -- and each is a clang whose target is fixed by its payload, which - // is the shape `src/toolchain/hostflags.cppm` already describes: - // "every hosted cross this build tool could do was served by a payload - // whose driver had exactly one target". So the family stays `Llvm` and - // no fourth value is invented; what changes is which package answers. - const auto& lt = spec.target; - - // AND THE PAYLOAD THE SPEC NAMED DECIDES BEFORE THE TARGET DOES. - // - // `emsdk@6.0.9` names a payload. The target is the OTHER way to reach - // the same answer -- `--target wasm32-emscripten` with nothing - // declared -- and reading only the target meant a spec that named the - // payload and no target resolved the generic llvm shape: - // - // $ mcpp toolchain install emsdk 6.0.9 - // error: installed package has no known C++ frontend in - // '.../xim-x-emsdk/6.0.9/bin' - // - // The archive was fetched correctly and then looked for `clang++` in - // `bin/`, because `frontendSubdir` had been decided by a target that - // was not given. `payloadName` is the field `parse_toolchain_spec` - // fills from the spelling, and it is the more direct statement of the - // two: a spec that names a payload has answered this question. - const std::string_view named = spec.payloadName; - - if (named == "emsdk" || lt.os == "emscripten") { - // `em++` is a `#!/bin/sh` wrapper beside the Python it execs, in - // `emscripten/` rather than `bin/` -- `bin/` holds the raw clang, - // which would compile for wasm and then link like an ordinary - // clang, producing a `.wasm` with no JavaScript and none of - // Emscripten's own glue. Naming the wrapper is the whole point. - pkg.ximName = "emsdk"; - pkg.frontendSubdir = "emscripten"; - pkg.frontendCandidates = { "em++", "emcc" }; - return pkg; - } - - if (named == "android-ndk" || lt.is_android()) { - // One NDK payload serves every Android arch and API level: the - // arch arrives as `--target=-linux-android` on the - // command line, not as a different package. The host tuple in the - // path is the HOST's, not the target's -- a Linux x86_64 machine - // cross-compiling for aarch64 still reads - // `prebuilt/linux-x86_64/`. - pkg.ximName = "android-ndk"; - pkg.frontendSubdir = std::format("toolchains/llvm/prebuilt/{}/bin", - ndk_host_tag()); - if constexpr (mcpplibs::os::is_windows) { - pkg.frontendCandidates = { "clang++.exe", "clang++" }; - } else { - pkg.frontendCandidates = { "clang++", "clang" }; - } - return pkg; - } - - // ONE PAYLOAD for everything else. The `openkal-llvm` spelling - // normalises to this family and installs nothing of its own — it is a - // statement about where the TARGET SIDE comes from, and the compiler is - // the llvm payload either way. A user who has one has both. - pkg.ximName = mcpp::toolchain::llvm::package_name(); - pkg.frontendCandidates = mcpp::toolchain::llvm::frontend_candidates(); - return pkg; - } - - // Family::Gcc — the target decides the payload. - const auto& t = spec.target; - - // `&& t.os == "linux"` — AND THE PARAGRAPH BELOW ALREADY SAID SO. - // - // "Canonical linux-musl triples coincide with the GNU tool spelling" is a - // statement about linux-musl, and the condition asked only whether the C - // library is musl. `x86_64-windows-musl` was added later and walked in. - // Measured 2026-08-26 on a Linux x86_64 host: - // - // $ mcpp build --target x86_64-windows-musl ([toolchain] gcc@16.1.0) - // fetcher: resolve: target='xim:musl-gcc@16.1.0' - // error: toolchain payload 'xim:musl-gcc@16.1.0' has no known C++ - // frontend in …/xim-x-musl-gcc/16.1.0/bin - // - // `native` read `t.arch == host_arch` and got true, so a PE target resolved - // the host's ELF Linux payload. The message names a missing frontend, which - // is true of that payload and says nothing about the decision that reached - // for it — the same shape as the rest of this release. - if (t.is_musl() && t.os == "linux") { - // Same target, two payload shapes: the host-native `musl-gcc` package - // (XLINGS_RES picks the host-matching asset) when target arch == host - // arch, else the triple-named cross package. Canonical linux-musl - // triples coincide with the GNU tool spelling, so `-g++` is - // the frontend either way. - bool native = mcpplibs::os::is_linux - && t.arch == mcpp::toolchain::platform::host_arch; - pkg.ximName = native ? "musl-gcc" : t.str() + "-gcc"; - // Frontend candidates are resolved with filesystem::exists, so on a - // Windows host the bare name never matches — the file on disk is - // `-g++.exe`. The mingw branch below has carried the `.exe` - // spelling since it shipped; this one had not, which made a - // windows-hosted musl payload install fine and then be unusable. - // `.exe` first: on a case-insensitive filesystem both would match, and - // the executable is the one we want. - if constexpr (mcpplibs::os::is_windows) { - pkg.frontendCandidates = { t.str() + "-g++.exe", t.str() + "-g++", - "g++.exe", "g++" }; - } else { - pkg.frontendCandidates = { t.str() + "-g++", "g++" }; - } + // A PAYLOAD NAMED OUTRIGHT (`xim:@`, design §16 T2) is that + // package; it describes its own frontend (`.mcpp-toolchain.json`). + if (spec.describedPayload) { + pkg.ximName = spec.payloadName; return pkg; } - - if (t.is_windows_gnu() - || (t.empty() && mcpplibs::os::is_windows)) { - // GCC targeting Windows PE (GNU CRT) — ONE user-facing identity, - // host-split at the distribution layer only: - // Windows host → native winlibs UCRT build (PE frontend g++.exe) - // other hosts → Linux-hosted MSVCRT cross (ELF frontend, triple- - // prefixed so a cross build never silently falls - // back to a native g++) - if constexpr (mcpplibs::os::is_windows) { - pkg.ximName = "mingw-gcc"; - pkg.frontendCandidates = {"g++.exe", "g++"}; - } else { - pkg.ximName = "mingw-cross-gcc"; - pkg.frontendCandidates = {"x86_64-w64-mingw32-g++"}; - } - return pkg; - } - - // Host target (or linux-gnu): the glibc gcc package — on x86_64. - // - // #367: `xim:gcc` declares `archs = { "x86_64" }` and publishes assets for - // that arch only. The GCC the ecosystem ships for other Linux - // architectures is `musl-gcc`, which does publish them (aarch64 included). - // Asking for `gcc` on aarch64 therefore 404s — and because a `build.mcpp` - // host compile resolves the toolchain spec with NO target injection, it - // landed here and made every project whose graph contains a build program - // unbuildable on aarch64, with `--target aarch64-linux-musl` fixing only - // the target half. - // - // "Which payload backs this spec on this machine" is exactly the question - // this function exists to answer — the musl branch above already answers - // its half the same way ("same target, two payload shapes"). The knowledge - // belongs here rather than in every manifest: a user should not have to - // encode which architectures a toolchain package was built for. - if (gcc_native_payload_is_musl(mcpp::toolchain::platform::host_arch, - mcpplibs::os::is_linux, t)) { - const auto mt = host_musl_triple(); - pkg.ximName = "musl-gcc"; - pkg.frontendCandidates = { mt.str() + "-g++", "g++" }; - return pkg; // no glibc specs fixup: that payload is not glibc-linked - } - - pkg.ximName = "gcc"; - pkg.frontendCandidates = {"g++"}; - pkg.needsGccPostInstallFixup = true; + // WHICH PACKAGE IS DATA (design §16 T3): the entries of the family in + // data/builtin.toml, the first that serves the target on this machine. A + // payload the spec named (`emsdk@6.0.9`) is taken whatever the target. The + // NDK's host directory is for a released NDK without a descriptor. + auto r = mcpp::toolchain::payloads::choose(family_name(spec.family), spec.payloadName, spec.target, + mcpp::toolchain::payloads::this_host(), + {{"ndk-host", ndk_host_tag()}}); + if (!r) return pkg; // no entry: an empty name, which the install reports + pkg.ximName = r->name; + pkg.frontendSubdir = r->frontendDir; + pkg.frontendCandidates = std::move(r->frontends); + pkg.needsGccPostInstallFixup = r->glibcFixup; return pkg; } + ToolchainSpec with_resolved_xim_version(const ToolchainSpec& spec, std::string_view ximVersion) { ToolchainSpec out = spec; @@ -1058,11 +909,15 @@ read_payload_descriptor(const std::filesystem::path& payloadRoot) { // descriptor was added to prevent. The obligation is therefore on the // recipe: a schema bump comes with a floor on the mcpp version that reads // it, the same way an index descriptor's syntax does. - if (d.schema != 1) + if (d.schema != 1 && d.schema != 2) return refuse(std::format( "declares schema {}, which this mcpp does not implement " - "(expected 1); upgrade mcpp to build with this payload", + "(expected 1 or 2); upgrade mcpp to build with this payload", d.schema)); + if (d.schema == 1) + for (auto key : { "family", "prefix", "sysroot", "tools" }) + if (j.contains(key)) + return refuse(std::format("\"{}\" is a schema 2 key; write \"schema\": 2", key)); if (j.contains("frontend")) { if (!j["frontend"].is_string()) @@ -1148,6 +1003,41 @@ read_payload_descriptor(const std::filesystem::path& payloadRoot) { /*rootedOk=*/true)) return refuse(*problem); } + if (j.contains("family")) { + if (!j["family"].is_string()) return refuse("\"family\" is not a string"); + d.family = j["family"].get(); + if (d.family != "gcc" && d.family != "llvm") + return refuse("\"family\" is gcc or llvm, the option vocabulary of its drivers"); + } + if (j.contains("prefix")) { + if (!j["prefix"].is_string()) return refuse("\"prefix\" is not a string"); + d.prefix = j["prefix"].get(); + if (d.prefix.find_first_of("/\\") != std::string::npos) + return refuse("\"prefix\" names a directory; it is the drivers' name prefix " + "(`aarch64-none-elf-`)"); + } + if (j.contains("sysroot")) { + if (!j["sysroot"].is_string()) return refuse("\"sysroot\" is not a string"); + d.sysroot = j["sysroot"].get(); + if (auto problem = descriptor_path_problem("sysroot", d.sysroot, /*rootedOk=*/false)) + return refuse(*problem); + } + if (j.contains("tools")) { + if (!j["tools"].is_object()) return refuse("\"tools\" is not an object of role to program"); + for (auto it = j["tools"].begin(); it != j["tools"].end(); ++it) { + const std::string role = it.key(); + if (!it->is_string()) + return refuse(std::format("\"tools\".{} is not a string", role)); + auto p = it->get(); + if (auto problem = descriptor_path_problem("tools." + role, p, /*rootedOk=*/false)) + return refuse(*problem); + d.tools.emplace_back(role, std::move(p)); + } + } + // The frontend of a payload that states its C++ driver as a tool. + if (d.frontend.empty()) + for (auto const& [role, p] : d.tools) + if (role == "cxx") d.frontend = p; d.root = payloadRoot; return d; } @@ -1248,31 +1138,14 @@ payload_frontend(const std::filesystem::path& payloadRoot, } std::optional identify_xim_payload(std::string_view ximDirName) { - if (ximDirName == "gcc") - return PayloadIdentity{ Family::Gcc, {} }; - // A pinned toolset is an installed payload like any other, so it shows up - // in `toolchain list` under its toolset version. Without this row the - // install would succeed and then be invisible. - // - // Host-target (empty triple), like gcc and llvm: an msvc payload only - // ever targets the machine it runs on, and a spec for it carries no - // target axis either — so the two sides compare equal. - if (ximDirName == "msvc") - return PayloadIdentity{ Family::Msvc, {} }; - if (ximDirName == mcpp::toolchain::llvm::package_name()) - return PayloadIdentity{ Family::Llvm, {} }; - if (ximDirName == "musl-gcc") - return PayloadIdentity{ Family::Gcc, host_musl_triple() }; - if (ximDirName == "mingw-gcc" || ximDirName == "mingw-cross-gcc") - return PayloadIdentity{ Family::Gcc, { "x86_64", "windows", "gnu" } }; - if (ends_with(ximDirName, "-gcc")) { - auto prefix = ximDirName.substr(0, ximDirName.size() - 4); - if (auto t = triple::parse(prefix)) - return PayloadIdentity{ Family::Gcc, *t }; - } - return std::nullopt; // not a toolchain payload (ninja, glibc, …) + // The reverse reading of the same entries (design §16 T3). + auto id = mcpp::toolchain::payloads::identify(ximDirName); + if (!id) return std::nullopt; // not a toolchain payload (ninja, glibc, …) + return PayloadIdentity{ id->family == "llvm" ? Family::Llvm : id->family == "msvc" ? Family::Msvc : Family::Gcc, + id->target }; } + bool spec_matches_payload(const ToolchainSpec& def, const PayloadIdentity& id, std::string_view payloadVersion) { @@ -1321,44 +1194,26 @@ bool host_can_serve(const triple::Triple& target) { } std::vector available_toolchain_indexes() { - // LLVM payloads are published for Linux x86_64 and aarch64. Other - // Linux architectures retain the musl GCC default and omit LLVM. - const bool linuxWithoutLlvm = mcpplibs::os::is_linux - && mcpp::toolchain::platform::host_arch != "x86_64" - && mcpp::toolchain::platform::host_arch != "aarch64"; - - std::vector out{ - { "gcc", Family::Gcc }, - { "musl-gcc", Family::Gcc }, - }; - if (!linuxWithoutLlvm) - out.push_back({ mcpp::toolchain::llvm::package_name(), Family::Llvm }); - // The Windows-PE gcc payload is host-split at the distribution layer - // (§4.3); each host lists the package it would actually install. - if constexpr (mcpplibs::os::is_windows) { - // Pinned MSVC toolsets. Listing them is what makes the managed origin - // discoverable at all: without a row here `toolchain list --available` - // says gcc and llvm can be pinned and msvc cannot, which stopped being - // true. `msvc@system` is reported separately, as an installation. - out.push_back({ "msvc", Family::Msvc }); - out.push_back({ "mingw-gcc", Family::Gcc }); - // The windows-hosted canadian cross to Linux. Named by triple, exactly - // as to_xim_package() derives it (`-gcc`), so the Available - // listing and the install path cannot disagree about the package name. - out.push_back({ std::string(mcpp::toolchain::platform::host_arch) + "-linux-musl-gcc", - Family::Gcc }); - } else if constexpr (mcpplibs::os::is_linux) { - // Same gate: `mingw-cross-gcc` publishes x86_64 only. - if (mcpp::toolchain::platform::host_arch == "x86_64") - out.push_back({ "mingw-cross-gcc", Family::Gcc }); - } + // `[[toolchain.offer]]` (data/builtin.toml): what this machine is offered. + std::vector out; + for (auto const& name : mcpp::toolchain::payloads::offered()) + if (auto id = identify_xim_payload(name)) out.push_back({ name, id->family }); return out; } + std::filesystem::path derive_c_compiler(const Toolchain& tc) { return derive_c_compiler_path(tc.binaryPath); } +bool row_states_gcc_target(const Toolchain& tc) { + if (tc.compiler != CompilerId::GCC || tc.targetTriple.empty()) return false; + for (auto const* r : mcpp::toolchain::target::rows()) + if (!r->gccTriple.empty() && (r->gccTriple == tc.targetTriple || r->triple == tc.targetTriple)) + return true; + return false; +} + std::filesystem::path binutils_tool(const Toolchain& tc, std::string_view name) { // MSVC: no binutils, and none wanted — see the declaration. if (tc.compiler == CompilerId::MSVC) return {}; @@ -1389,6 +1244,21 @@ std::filesystem::path binutils_tool(const Toolchain& tc, std::string_view name) return {}; } + // A cross payload a row names by its GCC target (`gcc-triple`) carries its + // binutils beside the frontend under that prefix (`i586-pc-msdosdjgpp-ar`): + // the host's `ar` does not know the target's object format. The target is + // read in either spelling, as `gcc::binutils_prefix_dir` reads it. + for (auto const* r : mcpp::toolchain::target::rows()) { + if (r->gccTriple.empty() || (r->gccTriple != tc.targetTriple && r->triple != tc.targetTriple)) + continue; + const auto base = std::string(r->gccTriple) + "-" + std::string(name); + if constexpr (mcpplibs::os::is_windows) { + if (auto exe = dir / (base + ".exe"); std::filesystem::exists(exe, ec)) return exe; + } + if (auto p = dir / base; std::filesystem::exists(p, ec)) return p; + return {}; + } + // MinGW bundles its own binutils next to the frontend (self-contained, // like musl) — never an external binutils xpkg. Native (Windows-host) ships // `ar.exe`; the Linux-hosted cross ships the triple-prefixed ELF tool diff --git a/tests/canary/c/include/canary.h b/tests/canary/c/include/canary.h new file mode 100644 index 000000000..020ca6dc6 --- /dev/null +++ b/tests/canary/c/include/canary.h @@ -0,0 +1,5 @@ +/* What both programs share. C89 and POSIX only. */ +#ifndef CANARY_H +#define CANARY_H +int canary_run(const char *role, int argc, char **argv); +#endif diff --git a/tests/canary/c/mcpp.toml b/tests/canary/c/mcpp.toml new file mode 100644 index 000000000..c3acba236 --- /dev/null +++ b/tests/canary/c/mcpp.toml @@ -0,0 +1,25 @@ +# canary-c: a C89 program family on POSIX alone, the shape of a program the ecosystem is +# asked to carry everywhere (design 2026-11-12 §5.7, §7: V2, V3, V9, V10 and the run). +# Two programs share every source and differ by their entry and a macro; the resource +# script is written once and compiled where the row is PE. +[package] +name = "canary-c" +version = "1.0.0" + +[targets.canary-a] +kind = "bin" +main = "src/a.c" +defines = ["CANARY_ROLE=\"a\""] + +[targets.canary-b] +kind = "bin" +main = "src/b.c" +defines = ["CANARY_ROLE=\"b\""] + +[build] +sources = ["src/common.c"] +include_dirs = ["include"] +c_standard = "c89" + +[resources] +files = ["res/canary.rc"] diff --git a/tests/canary/c/res/canary.rc b/tests/canary/c/res/canary.rc new file mode 100644 index 000000000..39414fe59 --- /dev/null +++ b/tests/canary/c/res/canary.rc @@ -0,0 +1,18 @@ +/* V3: written once; compiled where the row is PE, ignored elsewhere. */ +1 VERSIONINFO +FILEVERSION 1,0,0,0 +PRODUCTVERSION 1,0,0,0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "ProductName", "canary-c" + VALUE "FileVersion", "1.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/tests/canary/c/src/a.c b/tests/canary/c/src/a.c new file mode 100644 index 000000000..f9f8d29cd --- /dev/null +++ b/tests/canary/c/src/a.c @@ -0,0 +1,3 @@ +/* Program a: its entry, its macro. */ +#include "canary.h" +int main(int argc, char **argv) { return canary_run(CANARY_ROLE, argc, argv); } diff --git a/tests/canary/c/src/b.c b/tests/canary/c/src/b.c new file mode 100644 index 000000000..f0dcf3b2e --- /dev/null +++ b/tests/canary/c/src/b.c @@ -0,0 +1,3 @@ +/* Program b: its entry, its macro. */ +#include "canary.h" +int main(int argc, char **argv) { return canary_run(CANARY_ROLE, argc, argv); } diff --git a/tests/canary/c/src/common.c b/tests/canary/c/src/common.c new file mode 100644 index 000000000..6f18eaba0 --- /dev/null +++ b/tests/canary/c/src/common.c @@ -0,0 +1,55 @@ +/* The checks every row of the matrix runs. Each prints `ok ` or `FAIL `; + * the status is the number of failures. */ +#define _POSIX_C_SOURCE 200112L +#include +#include +#include +#include +#include +#include "canary.h" + +static int failures; +static void check(int held, const char *what) +{ + printf("%s %s\n", held ? "ok " : "FAIL", what); + if (!held) ++failures; +} + +static sigjmp_buf jump; +static void leave(int depth) +{ + if (depth == 0) siglongjmp(jump, 7); + leave(depth - 1); +} + +int canary_run(const char *role, int argc, char **argv) +{ + char line[64]; + FILE *f; + int jumped; + + if (argc > 1 && strcmp(argv[1], "--child") == 0) return 3; + printf("canary-%s: %d argument(s)\n", role, argc); + + /* V9: a non-local jump out of nested calls. */ + jumped = sigsetjmp(jump, 1); + if (jumped == 0) leave(3); + check(jumped == 7, "sigsetjmp and siglongjmp"); + + /* A file written and read back. */ + f = fopen("canary.tmp", "w"); + check(f != NULL, "a file is created"); + if (f != NULL) { + fputs("canary\n", f); + fclose(f); + f = fopen("canary.tmp", "r"); + check(f != NULL && fgets(line, sizeof line, f) != NULL && strcmp(line, "canary\n") == 0, + "a file reads back what was written"); + if (f != NULL) fclose(f); + remove("canary.tmp"); + } + + /* The arguments arrive as given. */ + check(argc < 2 || strcmp(argv[argc - 1], "last") == 0, "the arguments arrive"); + return failures; +} diff --git a/tests/canary/cxx/mcpp.toml b/tests/canary/cxx/mcpp.toml new file mode 100644 index 000000000..713dc13d3 --- /dev/null +++ b/tests/canary/cxx/mcpp.toml @@ -0,0 +1,12 @@ +# canary-cxx: C++23 modules, `import std`, exceptions and threads, with a test +# (design 2026-11-12 §5.7): what every arrangement that declares a c++-abi carries. +[package] +name = "canary-cxx" +version = "1.0.0" + +[targets.canary-cxx] +kind = "bin" +main = "src/main.cpp" + +[dev-dependencies] +mcpplibs.mtest = "0.8.5" diff --git a/tests/canary/cxx/src/canary.cppm b/tests/canary/cxx/src/canary.cppm new file mode 100644 index 000000000..7eb2be550 --- /dev/null +++ b/tests/canary/cxx/src/canary.cppm @@ -0,0 +1,30 @@ +// What the program and its test share: a module of its own over `import std`. +export module canary; +import std; + +export namespace canary { + +// Threads: the sum of 1..n over four threads. +long long parallel_sum(long long n) { + std::atomic total{0}; + std::vector workers; + for (int w = 0; w < 4; ++w) + workers.emplace_back([&total, n, w] { + long long part = 0; + for (long long i = w + 1; i <= n; i += 4) part += i; + total += part; + }); + workers.clear(); + return total.load(); +} + +// Exceptions: thrown across a function and caught by type. +std::string thrown_and_caught() { + try { + throw std::runtime_error("carried"); + } catch (const std::exception& e) { + return e.what(); + } +} + +} // namespace canary diff --git a/tests/canary/cxx/src/main.cpp b/tests/canary/cxx/src/main.cpp new file mode 100644 index 000000000..74e57f066 --- /dev/null +++ b/tests/canary/cxx/src/main.cpp @@ -0,0 +1,16 @@ +import std; +import canary; + +int main(int argc, char** argv) { + int failures = 0; + auto check = [&](bool held, std::string_view what) { + std::println("{} {}", held ? "ok " : "FAIL", what); + if (!held) ++failures; + }; + std::println("canary-cxx: {} argument(s)", argc); + check(canary::parallel_sum(1000) == 500500, "four threads sum 1..1000"); + check(canary::thrown_and_caught() == "carried", "an exception is thrown and caught"); + check(std::format("{:>4}", 42) == " 42", "std::format"); + check(argc < 2 || std::string_view(argv[argc - 1]) == "last", "the arguments arrive"); + return failures; +} diff --git a/tests/canary/cxx/tests/canary.cpp b/tests/canary/cxx/tests/canary.cpp new file mode 100644 index 000000000..8fdb38532 --- /dev/null +++ b/tests/canary/cxx/tests/canary.cpp @@ -0,0 +1,7 @@ +import std; +import mcpplibs.mtest; +import canary; +using namespace mcpplibs::mtest; + +UTest sum{"Canary/ThreadsSum", [] { expect(eq(canary::parallel_sum(100), 5050LL)); }}; +UTest exc{"Canary/ExceptionsCarry", [] { expect(eq(canary::thrown_and_caught(), std::string("carried"))); }}; diff --git a/tests/canary/matrix.tsv b/tests/canary/matrix.tsv new file mode 100644 index 000000000..86f85da63 --- /dev/null +++ b/tests/canary/matrix.tsv @@ -0,0 +1,151 @@ +# canary identity host step expect note +c x86_64-linux-gnu linux-x86_64 build ok +c x86_64-linux-gnu linux-x86_64 static ok +c x86_64-linux-gnu linux-x86_64 run ok +c x86_64-linux-gnu linux-x86_64 test n/a +c x86_64-linux-gnu linux-x86_64 pack ok +c x86_64-linux-musl linux-x86_64 build ok +c x86_64-linux-musl linux-x86_64 static ok +c x86_64-linux-musl linux-x86_64 run ok +c x86_64-linux-musl linux-x86_64 test n/a +c x86_64-linux-musl linux-x86_64 pack ok +c aarch64-linux-gnu linux-x86_64 build ok +c aarch64-linux-gnu linux-x86_64 static ok +c aarch64-linux-gnu linux-x86_64 run ok +c aarch64-linux-gnu linux-x86_64 test n/a +c aarch64-linux-gnu linux-x86_64 pack ok +c aarch64-linux-musl linux-x86_64 build ok +c aarch64-linux-musl linux-x86_64 static ok +c aarch64-linux-musl linux-x86_64 run ok +c aarch64-linux-musl linux-x86_64 test n/a +c aarch64-linux-musl linux-x86_64 pack ok +c x86_64-windows-gnu linux-x86_64 build n/a a POSIX program; mingw's C library presents no sigsetjmp +c x86_64-windows-gnu linux-x86_64 static n/a a POSIX program; mingw's C library presents no sigsetjmp +c x86_64-windows-gnu linux-x86_64 run n/a a POSIX program; mingw's C library presents no sigsetjmp +c x86_64-windows-gnu linux-x86_64 test n/a +c x86_64-windows-gnu linux-x86_64 pack n/a a POSIX program; mingw's C library presents no sigsetjmp +c x86_64-windows-musl linux-x86_64 build ok +c x86_64-windows-musl linux-x86_64 static ok +c x86_64-windows-musl linux-x86_64 run fails windows artifacts on a linux host: the run names the absent runner +c x86_64-windows-musl linux-x86_64 test n/a +c x86_64-windows-musl linux-x86_64 pack ok +c x86_64-openkal linux-x86_64 build ok +c x86_64-openkal linux-x86_64 static ok +c x86_64-openkal linux-x86_64 run ok +c x86_64-openkal linux-x86_64 test n/a +c x86_64-openkal linux-x86_64 pack ok +c aarch64-openkal linux-x86_64 build ok +c aarch64-openkal linux-x86_64 static ok +c aarch64-openkal linux-x86_64 run fails okrun runs an artifact on a machine of its architecture: the run names it +c aarch64-openkal linux-x86_64 test n/a +c aarch64-openkal linux-x86_64 pack ok +cxx x86_64-linux-gnu linux-x86_64 build ok +cxx x86_64-linux-gnu linux-x86_64 static ok +cxx x86_64-linux-gnu linux-x86_64 run ok +cxx x86_64-linux-gnu linux-x86_64 test ok +cxx x86_64-linux-gnu linux-x86_64 pack ok +cxx x86_64-linux-musl linux-x86_64 build ok +cxx x86_64-linux-musl linux-x86_64 static ok +cxx x86_64-linux-musl linux-x86_64 run ok +cxx x86_64-linux-musl linux-x86_64 test ok +cxx x86_64-linux-musl linux-x86_64 pack ok +cxx aarch64-linux-gnu linux-x86_64 build ok +cxx aarch64-linux-gnu linux-x86_64 static ok +cxx aarch64-linux-gnu linux-x86_64 run ok +cxx aarch64-linux-gnu linux-x86_64 test ok +cxx aarch64-linux-gnu linux-x86_64 pack ok +cxx aarch64-linux-musl linux-x86_64 build ok +cxx aarch64-linux-musl linux-x86_64 static ok +cxx aarch64-linux-musl linux-x86_64 run ok +cxx aarch64-linux-musl linux-x86_64 test ok +cxx aarch64-linux-musl linux-x86_64 pack ok +cxx x86_64-windows-gnu linux-x86_64 build ok +cxx x86_64-windows-gnu linux-x86_64 static ok +cxx x86_64-windows-gnu linux-x86_64 run fails windows artifacts on a linux host: the run names the absent runner +cxx x86_64-windows-gnu linux-x86_64 test fails GCC 16.1 for mingw defines std::_Sp_make_shared_tag::_S_ti()::__tag in a module unit outside a COMDAT; the test program's link refuses the second definition +cxx x86_64-windows-gnu linux-x86_64 pack ok +cxx x86_64-windows-musl linux-x86_64 build ok +cxx x86_64-windows-musl linux-x86_64 static ok +cxx x86_64-windows-musl linux-x86_64 run fails windows artifacts on a linux host: the run names the absent runner +cxx x86_64-windows-musl linux-x86_64 test fails windows artifacts on a linux host: the run names the absent runner +cxx x86_64-windows-musl linux-x86_64 pack ok +cxx x86_64-openkal linux-x86_64 build ok +cxx x86_64-openkal linux-x86_64 static ok +cxx x86_64-openkal linux-x86_64 run ok +cxx x86_64-openkal linux-x86_64 test ok +cxx x86_64-openkal linux-x86_64 pack ok +cxx aarch64-openkal linux-x86_64 build ok +cxx aarch64-openkal linux-x86_64 static ok +cxx aarch64-openkal linux-x86_64 run fails okrun runs an artifact on a machine of its architecture: the run names it +cxx aarch64-openkal linux-x86_64 test fails okrun runs an artifact on a machine of its architecture: the run names it +cxx aarch64-openkal linux-x86_64 pack ok +c aarch64-macos macos-aarch64 build ok +c aarch64-macos macos-aarch64 static ok +c aarch64-macos macos-aarch64 run ok +c aarch64-macos macos-aarch64 test n/a +c aarch64-macos macos-aarch64 pack ok +c aarch64-macos-musl macos-aarch64 build ok +c aarch64-macos-musl macos-aarch64 static ok +c aarch64-macos-musl macos-aarch64 run ok +c aarch64-macos-musl macos-aarch64 test n/a +c aarch64-macos-musl macos-aarch64 pack ok +c aarch64-openkal macos-aarch64 build ok +c aarch64-openkal macos-aarch64 static ok +c aarch64-openkal macos-aarch64 run ok +c aarch64-openkal macos-aarch64 test n/a +c aarch64-openkal macos-aarch64 pack ok +c x86_64-windows-msvc windows-x86_64 build n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-msvc windows-x86_64 static n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-msvc windows-x86_64 run n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-msvc windows-x86_64 test n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-msvc windows-x86_64 pack n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-gnu windows-x86_64 build n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-gnu windows-x86_64 static n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-gnu windows-x86_64 run n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-gnu windows-x86_64 test n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-gnu windows-x86_64 pack n/a a POSIX program; the Windows C library presents no sigsetjmp +c x86_64-windows-musl windows-x86_64 build ok +c x86_64-windows-musl windows-x86_64 static ok +c x86_64-windows-musl windows-x86_64 run ok +c x86_64-windows-musl windows-x86_64 test n/a +c x86_64-windows-musl windows-x86_64 pack ok +c x86_64-openkal windows-x86_64 build ok +c x86_64-openkal windows-x86_64 static ok +c x86_64-openkal windows-x86_64 run ok +c x86_64-openkal windows-x86_64 test n/a +c x86_64-openkal windows-x86_64 pack ok +cxx aarch64-macos macos-aarch64 build ok +cxx aarch64-macos macos-aarch64 static ok +cxx aarch64-macos macos-aarch64 run ok +cxx aarch64-macos macos-aarch64 test fails the link names the SDK and openkal-macos's three-name libSystem stub ahead of it (design 2026-11-14 §5.6) +cxx aarch64-macos macos-aarch64 pack ok +cxx aarch64-macos-musl macos-aarch64 build ok +cxx aarch64-macos-musl macos-aarch64 static ok +cxx aarch64-macos-musl macos-aarch64 run ok +cxx aarch64-macos-musl macos-aarch64 test ok +cxx aarch64-macos-musl macos-aarch64 pack ok +cxx aarch64-openkal macos-aarch64 build ok +cxx aarch64-openkal macos-aarch64 static ok +cxx aarch64-openkal macos-aarch64 run ok +cxx aarch64-openkal macos-aarch64 test ok +cxx aarch64-openkal macos-aarch64 pack ok +cxx x86_64-windows-msvc windows-x86_64 build ok +cxx x86_64-windows-msvc windows-x86_64 static ok +cxx x86_64-windows-msvc windows-x86_64 run ok +cxx x86_64-windows-msvc windows-x86_64 test ok +cxx x86_64-windows-msvc windows-x86_64 pack ok +cxx x86_64-windows-gnu windows-x86_64 build ok +cxx x86_64-windows-gnu windows-x86_64 static ok +cxx x86_64-windows-gnu windows-x86_64 run ok +cxx x86_64-windows-gnu windows-x86_64 test fails GCC 16.1 for mingw defines std::_Sp_make_shared_tag::_S_ti()::__tag in a module unit outside a COMDAT; the test program's link refuses the second definition +cxx x86_64-windows-gnu windows-x86_64 pack ok +cxx x86_64-windows-musl windows-x86_64 build ok +cxx x86_64-windows-musl windows-x86_64 static ok +cxx x86_64-windows-musl windows-x86_64 run ok +cxx x86_64-windows-musl windows-x86_64 test ok +cxx x86_64-windows-musl windows-x86_64 pack ok +cxx x86_64-openkal windows-x86_64 build ok +cxx x86_64-openkal windows-x86_64 static ok +cxx x86_64-openkal windows-x86_64 run ok +cxx x86_64-openkal windows-x86_64 test ok +cxx x86_64-openkal windows-x86_64 pack ok diff --git a/tests/canary/run.sh b/tests/canary/run.sh new file mode 100755 index 000000000..d0122903b --- /dev/null +++ b/tests/canary/run.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# tests/canary/run.sh — the canaries over the matrix (design 2026-11-12 §5.7). +# +# tests/canary/run.sh [--host ] [--only ] [--report ] +# +# Every row of tests/canary/matrix.tsv whose host is this machine's is run: the +# step in the canary's directory, against the identity, with `$MCPP` (default +# `mcpp`). A row states what the step is expected to do: +# +# ok it succeeds; a program run prints no FAIL line and returns 0 +# fails it does not, today, for the reason the row's note names +# n/a the step does not apply to the identity (the row is not run) +# +# The report (TSV: canary identity host step expect got seconds) is written to +# --report, and a Markdown table to $GITHUB_STEP_SUMMARY when set. The status is +# the number of rows whose result differs from what they state, either way: a +# row that now succeeds where it stated `fails` is a change of behaviour, and the +# table is corrected with it. +set -u +here=$(cd "$(dirname "$0")" && pwd) +MCPP=${MCPP:-mcpp} +host=""; only=""; report="" +while [ $# -gt 0 ]; do + case "$1" in + --host) host=$2; shift 2 ;; + --only) only=$2; shift 2 ;; + --report) report=$2; shift 2 ;; + *) echo "usage: $0 [--host ] [--only ] [--report ]" >&2; exit 2 ;; + esac +done +if [ -z "$host" ]; then + case "$(uname -s)" in + Linux) os=linux ;; Darwin) os=macos ;; MINGW*|MSYS*|CYGWIN*) os=windows ;; FreeBSD) os=freebsd ;; *) os=unknown ;; + esac + case "$(uname -m)" in + x86_64|amd64) arch=x86_64 ;; aarch64|arm64) arch=aarch64 ;; *) arch=$(uname -m) ;; + esac + host=$os-$arch +fi +report=${report:-$PWD/canary-report-$host.tsv} +logs=$(mktemp -d) +printf 'canary\tidentity\thost\tstep\texpect\tgot\tseconds\n' > "$report" +differ=0; ran=0 +while IFS=$'\t' read -r canary identity row_host step expect note; do + case "$canary" in ''|'#'*) continue ;; esac + [ "$row_host" = "$host" ] || continue + [ -z "$only" ] || [ "$only" = "$canary" ] || continue + [ "$expect" = "n/a" ] && continue + dir=$here/$canary + # `${prog[@]+...}` below: bash 3.2 (macOS) refuses an empty array under `set -u`. + prog=(); [ "$canary" = c ] && prog=(canary-a) + case "$step" in + build) cmd=("$MCPP" build --target "$identity") ;; + static) cmd=("$MCPP" build --static --target "$identity") ;; + run) cmd=("$MCPP" run ${prog[@]+"${prog[@]}"} --target "$identity" -- x last) ;; + test) cmd=("$MCPP" test --target "$identity") ;; + pack) cmd=("$MCPP" pack --target "$identity") ;; + *) echo "matrix: unknown step '$step'" >&2; exit 2 ;; + esac + log=$logs/$canary-$identity-$step.log + start=$(date +%s) + (cd "$dir" && "${cmd[@]}") > "$log" 2>&1 + rc=$? + seconds=$(( $(date +%s) - start )) + got=ok + if [ $rc -ne 0 ] || { [ "$step" = run ] && grep -q '^FAIL' "$log"; }; then got=fails; fi + ran=$((ran + 1)) + mark="" + if [ "$got" != "$expect" ]; then + differ=$((differ + 1)); mark=" <- states $expect" + # The whole log of a short run: its head holds the resolution and the failed + # command, which a tail of linker errors pushes out. + echo "::group::$canary $identity $step: $got (the row states $expect)" + if [ "$(wc -l < "$log")" -le 300 ]; then cat "$log"; else head -150 "$log"; echo "[...]"; tail -100 "$log"; fi + # A cell that fails where its row states ok is run again with --verbose: + # the commands it ran (a link line's flags) say why. + if [ "$got" = fails ]; then + echo "--- again, with --verbose: the commands ---" + (cd "$dir" && "${cmd[0]}" "${cmd[1]}" --verbose "${cmd[@]:2}") 2>&1 | grep -E '(clang|g\+\+|gcc|ld|lld)[^ ]* .* -o ' | tail -5 | cut -c1-4000 + # ...and the link edges of the graphs the cell wrote, with the flags + # they read: a link line run through a response file names few of them. + find "$dir/target" \( -name build.ninja -o -name test.ninja \) -not -path '*/.build-mcpp/*' 2>/dev/null | + while read -r g; do + echo "--- $g ---" + grep -E '^[a-z_]*ldflags = |^rule (link|cxx_link|c_link)|^ command = .*-o \$out' "$g" | cut -c1-3000 | head -12 + awk '/^build bin\//{p=1} p&&/^$/{p=0} p' "$g" | cut -c1-3000 | head -20 + done + fi + echo "::endgroup::" + fi + printf '%-4s %-22s %-7s %-6s %3ss%s\n' "$canary" "$identity" "$step" "$got" "$seconds" "$mark" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$canary" "$identity" "$host" "$step" "$expect" "$got" "$seconds" >> "$report" +done < "$here/matrix.tsv" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Canaries on $host: $ran cells, $differ differ from the matrix" + echo + echo "| canary | identity | step | states | got | s |" + echo "|---|---|---|---|---|---|" + tail -n +2 "$report" | awk -F'\t' '{ m = ($5 == $6) ? $6 : "**" $6 "**"; printf "| %s | %s | %s | %s | %s | %s |\n", $1, $2, $4, $5, m, $7 }' + } >> "$GITHUB_STEP_SUMMARY" +fi +rm -rf "$logs" +echo "canaries on $host: $ran cells run, $differ differ from tests/canary/matrix.tsv" +exit $((differ > 0 ? 1 : 0)) diff --git a/tests/e2e/01_help_and_version.sh b/tests/e2e/01_help_and_version.sh deleted file mode 100755 index ef5e585ee..000000000 --- a/tests/e2e/01_help_and_version.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Verify --help and --version -set -e - -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" - -out=$("$MCPP" --version) -# Version must match `mcpp ` — don't pin a specific version here -# so this test doesn't break on every release bump. -[[ "$out" =~ ^mcpp\ [0-9]+\.[0-9]+\.[0-9]+ ]] \ - || { echo "Bad version output: $out"; exit 1; } -expected="$(awk -F '"' '/^version[[:space:]]*=/{print $2; exit}' "$ROOT/mcpp.toml")" -[[ -n "$expected" ]] || { echo "Failed to read mcpp.toml package version"; exit 1; } -[[ "$out" == "mcpp $expected"* ]] \ - || { echo "Version mismatch: mcpp.toml=$expected, --version='$out'"; exit 1; } - -out=$("$MCPP" --help) -[[ "$out" == *"Usage:"* ]] || { echo "--help missing 'Usage:' section"; exit 1; } -[[ "$out" == *"mcpp new"* ]] || { echo "--help missing 'mcpp new'"; exit 1; } -[[ "$out" == *"mcpp build"* ]] || { echo "--help missing 'mcpp build'"; exit 1; } -[[ "$out" == *"--configure-only"* ]] || { echo "--help missing '--configure-only'"; exit 1; } - -# Unknown command exit code (127) — must capture rc explicitly under `set -e` -rc=0 -"$MCPP" doesnotexist >/dev/null 2>&1 || rc=$? -[[ $rc -eq 127 ]] || { echo "expected exit 127 for unknown command, got $rc"; exit 1; } - -echo "OK" diff --git a/tests/e2e/02_new_build_run.sh b/tests/e2e/02_new_build_run.sh deleted file mode 100755 index 4eedb21a1..000000000 --- a/tests/e2e/02_new_build_run.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# requires: fresh-sandbox -# Single-module hello world: mcpp new → build → run -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new hello -cd hello - -# Validate generated structure -[[ -f mcpp.toml ]] || { echo "missing mcpp.toml"; exit 1; } -[[ -f src/main.cpp ]] || { echo "missing src/main.cpp"; exit 1; } -[[ -f .gitignore ]] || { echo "missing .gitignore"; exit 1; } -grep -q "import std" src/main.cpp || { echo "main.cpp missing 'import std'"; exit 1; } -grep -q "std::println" src/main.cpp || { echo "main.cpp missing 'std::println'"; exit 1; } - -# Build (capture output, show on failure) -set +e -"$MCPP" build > build.log 2>&1 -build_rc=$? -set -e -if [[ $build_rc -ne 0 ]]; then - cat build.log - echo "build failed (rc=$build_rc)" - exit 1 -fi -[[ -d target ]] || { cat build.log; echo "no target/ dir"; exit 1; } -# On Windows (MINGW/MSYS) the binary has a .exe suffix -OS="$(uname -s)" -if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then - binary="$(find target -name hello.exe -type f | head -1)" -else - binary="$(find target -name hello -type f | head -1)" -fi -[[ -n "$binary" ]] || { echo "binary not produced"; find target -type f 2>/dev/null | head -10; exit 1; } -[[ -x "$binary" ]] || { echo "binary not executable"; exit 1; } - -# Run via mcpp -out=$("$MCPP" run 2>&1) -[[ "$out" == *"Hello from hello"* ]] || { echo "unexpected run output: $out"; exit 1; } - -# Run with passthrough args -out=$("$MCPP" run -- foo bar 2>&1) -[[ "$out" == *"foo"* && "$out" == *"bar"* ]] || { echo "args passthrough failed: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/03_multi_module.sh b/tests/e2e/03_multi_module.sh deleted file mode 100755 index af23803f5..000000000 --- a/tests/e2e/03_multi_module.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# Multi-module: package with .cppm + main.cpp importing it -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new multi -cd multi - -# Add a module -cat > src/greeter.cppm <<'EOF' -export module multi.greeter; -import std; -export auto greet(std::string_view who) -> void { - std::println("Hello, {}! (from multi.greeter)", who); -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -import multi.greeter; -int main() { - greet("modular C++23"); - std::println("multi-module build OK"); - return 0; -} -EOF - -# Build & run -"$MCPP" build > /dev/null -out=$("$MCPP" run 2>&1) -[[ "$out" == *"from multi.greeter"* ]] || { echo "module greet not invoked: $out"; exit 1; } -[[ "$out" == *"multi-module build OK"* ]] || { echo "main println missing: $out"; exit 1; } - -# Verify build.ninja contents -build_ninja="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -q "gcm.cache/multi.greeter.gcm" "$build_ninja" || { echo "ninja missing greeter BMI"; exit 1; } -grep -q "gcm.cache/std.gcm" "$build_ninja" || { echo "ninja missing std BMI"; exit 1; } - -echo "OK" diff --git a/tests/e2e/04_incremental.sh b/tests/e2e/04_incremental.sh deleted file mode 100755 index 199a5ea16..000000000 --- a/tests/e2e/04_incremental.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Incremental: a no-op rebuild does no work; touching main.cpp recompiles only it -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new inc -cd inc -"$MCPP" build > /dev/null - -# 1. Empty rebuild is fast (Ninja: nothing to do) -out=$("$MCPP" build 2>&1) -[[ "$out" == *"Finished"* || "$out" == *"Build OK"* ]] || { echo "rebuild failed: $out"; exit 1; } - -# 2. Touch main.cpp; only main.o + link should rebuild -sleep 1 # ensure mtime change is observed -touch src/main.cpp -out=$("$MCPP" build --verbose 2>&1) -# Ninja prints "[N/M] /path/g++ ... -c .../main.cpp ..." -echo "$out" | grep -q "main.cpp" || { echo "main.cpp not rebuilt: $out"; exit 1; } -# std.o should NOT be re-staged -echo "$out" | grep -q "std.cc" && { echo "std module re-built when it shouldn't be"; exit 1; } - -echo "OK" diff --git a/tests/e2e/05_errors.sh b/tests/e2e/05_errors.sh deleted file mode 100755 index b5ca5a257..000000000 --- a/tests/e2e/05_errors.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Error paths: missing manifest, missing version, conditional import, header unit, naming violation -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# 1. No manifest -cd "$TMP" -mkdir -p empty -cd empty -out=$("$MCPP" build 2>&1) && { echo "expected failure but build succeeded"; exit 1; } -[[ "$out" == *"no mcpp.toml"* ]] || { echo "wrong error: $out"; exit 1; } - -# 2. Missing version -cd "$TMP" -mkdir -p bad-manifest -cd bad-manifest -cat > mcpp.toml <<'EOF' -[package] -name = "bad" -[language] -[modules] -sources = ["src/**/*.cppm"] -[targets.bad] -kind = "bin" -main = "src/main.cpp" -EOF -out=$("$MCPP" build 2>&1) && { echo "expected failure"; exit 1; } -[[ "$out" == *"package.version"* ]] || { echo "wrong error: $out"; exit 1; } - -# 3. Conditional import -cd "$TMP" -"$MCPP" new bad-cond > /dev/null -cd bad-cond -cat > src/main.cpp <<'EOF' -import std; -#ifdef WANT_FOO -import foo; -#endif -int main() {} -EOF -out=$("$MCPP" build 2>&1) && { echo "expected failure"; exit 1; } -[[ "$out" == *"conditional preprocessor"* ]] || { echo "wrong error: $out"; exit 1; } - -# 4. Header unit -cd "$TMP" -"$MCPP" new bad-header > /dev/null -cd bad-header -cat > src/main.cpp <<'EOF' -import std; -import "header.h"; -int main() {} -EOF -out=$("$MCPP" build 2>&1) && { echo "expected failure"; exit 1; } -[[ "$out" == *"header units"* ]] || { echo "wrong error: $out"; exit 1; } - -# 5. Module naming is the library author's choice (0.0.10+). -# No prefix enforcement — this test just verifies we REMOVED the check. -cd "$TMP" -"$MCPP" new naming-ok > /dev/null -cd naming-ok -sed -i.bak 's/name = "naming-ok"/name = "myorg.something"/' mcpp.toml && rm -f mcpp.toml.bak -cat > src/foo.cppm <<'EOF' -export module differentprefix; -import std; -EOF -# This should succeed now (no naming violation error). -"$MCPP" build > /dev/null 2>&1 || { echo "expected success but build failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/06_emit_xpkg.sh b/tests/e2e/06_emit_xpkg.sh deleted file mode 100755 index e72c12f98..000000000 --- a/tests/e2e/06_emit_xpkg.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# requires: -# emit xpkg: produce valid Lua entry from mcpp.toml -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new emit-test > /dev/null -cd emit-test - -cat > src/foo.cppm <<'EOF' -export module emit-test; -import std; -export int answer() { return 42; } -EOF - -# Wait — module name "emit-test" has a hyphen which is fine, but "emit-test" is treated -# as one logical module. Manifest's package name "emit-test" has no '.' so it's treated -# as local-flat. So the module need not be prefixed. Good. - -out=$("$MCPP" emit xpkg) -[[ "$out" == *"AUTO-GENERATED"* ]] || { echo "missing AUTO-GENERATED header: $out"; exit 1; } -[[ "$out" == *"name = \"emit-test\""* ]] || { echo "missing name: $out"; exit 1; } -[[ "$out" == *"\"emit-test\""* ]] || { echo "missing module entry: $out"; exit 1; } -[[ "$out" == *"manifest = \"mcpp.toml\""* ]] || { echo "missing manifest pointer"; exit 1; } -[[ "$out" == *"import_std = true"* ]] || { echo "missing import_std flag: $out"; exit 1; } - -# --output flag -"$MCPP" emit xpkg --output myout.lua > /dev/null -[[ -f myout.lua ]] || { echo "--output didn't write file"; exit 1; } - -# Bare Lua syntax check via lua interpreter if present -if command -v lua >/dev/null 2>&1; then - lua -e "loadfile('myout.lua')()" || { echo "Lua failed to load output"; exit 1; } -fi - -echo "OK" diff --git a/tests/e2e/07_static_library.sh b/tests/e2e/07_static_library.sh deleted file mode 100755 index 32eb1f0aa..000000000 --- a/tests/e2e/07_static_library.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -# Static library: kind = "lib" → libNAME.a via `ar rcs` -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new mathlib > /dev/null -cd mathlib - -cat > src/math.cppm <<'EOF' -export module mathlib; -import std; -export int add(int a, int b) { return a + b; } -export int mul(int a, int b) { return a * b; } -EOF -rm src/main.cpp - -cat > mcpp.toml <<'EOF' -[package] -name = "mathlib" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.mathlib] -kind = "lib" -EOF - -"$MCPP" build > build.log 2>&1 -archive="$(find target -name 'libmathlib.a' | head -1)" -[[ -n "$archive" ]] || { cat build.log; echo "no .a produced"; exit 1; } -[[ -s "$archive" ]] || { echo "empty .a"; exit 1; } -# Use the sandbox `ar` directly so this test doesn't depend on the host's -# PATH having an active binutils pin (xlings shim with no version on a -# fresh CI runner returns "no version set for ar"). -SANDBOX_AR="$(find "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs/xim-x-binutils" \ - -path '*/bin/ar' -type f | head -1)" -AR="${SANDBOX_AR:-ar}" -"$AR" t "$archive" | grep -q '\.o$' || { echo ".a contains no objects"; exit 1; } - -# build.ninja must use cxx_archive rule for the lib target -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*')" -grep -q 'bin/libmathlib.a : cxx_archive' "$ninja_file" || { - echo "build.ninja did not use cxx_archive for the lib target"; exit 1; } - -echo "OK" diff --git a/tests/e2e/08_shared_library.sh b/tests/e2e/08_shared_library.sh deleted file mode 100755 index e36a0f41f..000000000 --- a/tests/e2e/08_shared_library.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -# Shared library: kind = "shared" → libNAME.so via -shared -fPIC -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new mathlib > /dev/null -cd mathlib - -cat > src/math.cppm <<'EOF' -export module mathlib; -import std; -export int add(int a, int b) { return a + b; } -EOF -rm src/main.cpp - -cat > mcpp.toml <<'EOF' -[package] -name = "mathlib" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.mathlib] -kind = "shared" -EOF - -"$MCPP" build > build.log 2>&1 -so="$(find target -name 'libmathlib.so' | head -1)" -[[ -n "$so" ]] || { cat build.log; echo "no .so produced"; exit 1; } - -# Verify it's a real ELF shared object -file "$so" | grep -q 'ELF.*shared object' || { echo "not a valid .so"; exit 1; } - -# build.ninja must use cxx_shared rule -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*')" -grep -q 'bin/libmathlib.so : cxx_shared' "$ninja_file" || { - echo "build.ninja did not use cxx_shared for the shared lib target"; exit 1; } - -# cxxflags must include -fPIC when shared lib is present -grep -E '^cxxflags.*-fPIC' "$ninja_file" || { - echo "cxxflags did not include -fPIC for shared-lib build"; exit 1; } - -echo "OK" diff --git a/tests/e2e/09_path_dependency.sh b/tests/e2e/09_path_dependency.sh deleted file mode 100755 index 59f818d35..000000000 --- a/tests/e2e/09_path_dependency.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# Path-based dependency: package B imports modules from package A via -# [dependencies.A] path = "../A" -# Verifies the multi-package scanner + linker pipeline. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" - -# Package A — provides module mylibA.greet -"$MCPP" new mylibA > /dev/null -cd mylibA -cat > src/greet.cppm <<'EOF' -export module mylibA.greet; -import std; -export auto greet(std::string_view who) -> void { - std::println("Hello {} from mylibA!", who); -} -EOF -rm src/main.cpp - -cat > mcpp.toml <<'EOF' -[package] -name = "mylibA" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.mylibA] -kind = "lib" -EOF -cd .. - -# Package B — depends on A via path -"$MCPP" new myappB > /dev/null -cd myappB - -cat > src/main.cpp <<'EOF' -import std; -import mylibA.greet; -int main() { - greet("modular C++23"); - std::println("path dep build OK"); - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "myappB" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp"] -[targets.myappB] -kind = "bin" -main = "src/main.cpp" -[dependencies.mylibA] -path = "../mylibA" -EOF - -"$MCPP" build > build.log 2>&1 -out=$("$MCPP" run 2>&1) -[[ "$out" == *"Hello modular C++23 from mylibA"* ]] || { echo "dep module not invoked: $out"; exit 1; } -[[ "$out" == *"path dep build OK"* ]] || { echo "main println missing: $out"; exit 1; } - -# build.ninja should compile sources from BOTH packages -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*')" -grep -q 'mylibA.*greet.cppm' "$ninja_file" || { echo "ninja missing dep package source"; exit 1; } -# A dependency's BMI lies below its package's directory (pack drive and -# selection design 2026-10-01, B1); the root's would lie at its name. -grep -q 'gcm.cache/[^ /]*mylibA/mylibA.greet.gcm' "$ninja_file" || { - echo "ninja missing dep BMI"; exit 1; } - -# Path-resolution error reporting: declared name mismatch -TMP2=$(mktemp -d) -TMP2_HOST="$(host_path "$TMP2")" -cp -r "$TMP/mylibA" "$TMP2/wrongname" -sed -i.bak 's/name = "mylibA"/name = "differentname"/' "$TMP2/wrongname/mcpp.toml" && rm -f "$TMP2/wrongname/mcpp.toml.bak" -cat > mcpp.toml <&1) && { echo "expected name-mismatch error"; exit 1; } -[[ "$err" == *"mismatch with declared name"* ]] || { echo "wrong error: $err"; exit 1; } - -rm -rf "$TMP2" -echo "OK" diff --git a/tests/e2e/1000_a_configuration_keeps_one_directory.sh b/tests/e2e/1000_a_configuration_keeps_one_directory.sh deleted file mode 100755 index c4572b9e3..000000000 --- a/tests/e2e/1000_a_configuration_keeps_one_directory.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1000 -- a configuration keeps one build directory over time (design -# 2026-10-31 §8). A new fingerprint of a lineage -- the compiler family, the -# target, the standard library's family, the standard and the configuration's -# flags -- takes over the directory of its predecessor, so an mcpp upgrade -# rebuilds the edges whose commands changed in place instead of opening a full -# copy; a predecessor of another toolchain is removed; and the build directories -# no configuration used for `[build] keep_days` are swept after a build. -# -# Criteria: -# A. A predecessor of the lineage is taken over: the build continues in it -# and compiles nothing again. -# B. A predecessor of another toolchain is removed ("Replaced"). -# C. A directory of another lineage is kept. -# D. The sweep after a build removes an unrecorded directory written longer -# ago than `keep_days`; `keep_days = 0` (`mcpp self config --keep-days 0`) -# keeps it. -# E. The configuration names the build-cache entries it used (`uses.list`), -# and a build that replays its graph without planning still marks them -# used: an entry last used 40 days ago is used today after the sweep. -# F. In a rooted workspace (`members = ["."]`), `clean --stale` keeps the -# configuration in use: the workspace's own directory is no member's -# leftover. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -mkdir -p app/src && cd app -printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\n"compat.zlib" = "1.3.2"\n' > mcpp.toml -printf '#include \nint main() { return zlibVersion()[0] == 0; }\n' > src/main.cpp -"$MCPP" build > b0.log 2>&1 || fail "the first build failed" b0.log -dir=$(ls -d target/*/*/ | grep -E '/[0-9a-f]{16}/$' | head -1); dir=${dir%/} -triple=$(dirname "$dir") -[ -f "$dir/lineage" ] && [ -f "$dir/toolchain" ] || fail "the directory records no lineage" <(ls -a "$dir") -compiles() { grep -c 'main' "$1/.ninja_log"; } -before=$(compiles "$dir") - -# A -mv "$dir" "$triple/0000000000000000" -"$MCPP" build > a.log 2>&1 || fail "the build after the move failed" a.log -[ -d "$dir" ] && [ ! -d "$triple/0000000000000000" ] || fail "the predecessor was not taken over" <(ls "$triple") -[ "$(compiles "$dir")" = "$before" ] || fail "taking the predecessor over compiled again" "$dir/.ninja_log" - -# B -mv "$dir" "$triple/0000000000000000" -printf 'compiler another\n' > "$triple/0000000000000000/toolchain" -"$MCPP" build > b.log 2>&1 || fail "the build after a toolchain change failed" b.log -[ ! -d "$triple/0000000000000000" ] || fail "a predecessor of another toolchain was kept" <(ls "$triple") -grep -q 'Replaced' b.log || fail "the replacement was not said" b.log - -# C -mkdir -p "$triple/1111111111111111" -printf 'compiler other\n' > "$triple/1111111111111111/lineage" -touch "$triple/1111111111111111/build.ninja" -"$MCPP" build > c.log 2>&1 || fail "the build beside another lineage failed" c.log -[ -d "$triple/1111111111111111" ] || fail "a directory of another lineage was taken" <(ls "$triple") - -# D -old="$triple/2222222222222222" -mkdir -p "$old" && touch "$old/build.ninja" -touch -d '5 days ago' "$old/build.ninja" "$old" -"$MCPP" self config --keep-days 0 > dc.log 2>&1 || fail "self config --keep-days 0 failed" dc.log -rm -f target/.mcpp-swept -"$MCPP" build > d0.log 2>&1 || fail "the build with keep_days = 0 failed" d0.log -[ -d "$old" ] || fail "keep_days = 0 swept a directory" d0.log -"$MCPP" self config --keep-days 3 > dc.log 2>&1 || fail "self config --keep-days 3 failed" dc.log -rm -f target/.mcpp-swept -"$MCPP" build > d1.log 2>&1 || fail "the build that sweeps failed" d1.log -[ ! -d "$old" ] || fail "a directory no configuration used for 5 days was kept" d1.log <(ls "$triple") -grep -q 'Swept' d1.log || fail "the sweep was not said" d1.log -[ -d "$dir" ] || fail "the sweep removed the configuration in use" <(ls "$triple") - -# E -record=$(sed -n 's/^cache \(.*compat.zlib.*\)$/\1/p' "$dir/uses.list" | head -1) -[ -n "$record" ] && [ -f "$record" ] || fail "the configuration names no cache entry it used" "$dir/uses.list" -touch -d '40 days ago' "$record" -"$MCPP" build > e0.log 2>&1 || fail "the replayed build failed" e0.log -rm -f target/.mcpp-swept -"$MCPP" build > e1.log 2>&1 || fail "the build that sweeps failed" e1.log -[ -n "$(find "$record" -mtime -1)" ] || fail "a replayed build did not mark its cache entry used" <(stat "$record") - -# F -mkdir -p "$TMP/ws/src" && cd "$TMP/ws" -printf '[package]\nname = "ws"\nversion = "0.1.0"\n\n[workspace]\nmembers = ["."]\n' > mcpp.toml -printf 'int main() { return 0; }\n' > src/main.cpp -"$MCPP" build > f0.log 2>&1 || fail "the rooted workspace did not build" f0.log -current=$(ls -d target/*/*/ | grep -E '/[0-9a-f]{16}/$' | head -1) -"$MCPP" clean --stale > f1.log 2>&1 || fail "clean --stale failed" f1.log -[ -d "$current" ] || fail "clean --stale removed the rooted workspace's own build" f1.log - -echo "OK" diff --git a/tests/e2e/1001_a_dependency_carries_its_patches.sh b/tests/e2e/1001_a_dependency_carries_its_patches.sh deleted file mode 100755 index 3b20c47ff..000000000 --- a/tests/e2e/1001_a_dependency_carries_its_patches.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1001 -- a dependency edge carries patches (design 2026-11-02 part 3 §14.1). A -# patched library is another library: the verified source is copied to the -# variant's own tree under the mcpp home and patched there, in order, exactly; -# the lock names the variant and each patch with its SHA-256; and a patch that -# does not apply is refused naming the patch and its hunk. -# -# Criteria: -# A. The program sees the patched header; the package's own installed source -# is untouched, and the variant's tree is beside the other variants. -# B. mcpp.lock records the variant, and each patch by its path and SHA-256; -# `mcpp sbom` states the pedigree. -# C. A changed patch is another variant: the build uses the new tree. -# D. A patch that does not apply is refused with the patch's name and the -# hunk, and leaves no variant behind. -# E. `patches` on a `path` dependency is refused. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -mkdir -p app/src app/patches && cd app -printf '%s\n' '--- a/zlib-1.3.2/zlib.h' '+++ b/zlib-1.3.2/zlib.h' '@@ -31,4 +31,6 @@' \ - ' #ifndef ZLIB_H' ' #define ZLIB_H' ' ' '+#define MCPP_PATCHED_ZLIB 1' '+' ' #ifdef ZLIB_BUILD' \ - > patches/0001-mark.patch -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -"compat.zlib" = { version = "1.3.2", patches = ["patches/0001-mark.patch"] } -EOF -cat > src/main.cpp <<'EOF' -#include -#include -int main() { -#ifdef MCPP_PATCHED_ZLIB - std::printf("patched %d %s\n", MCPP_PATCHED_ZLIB, zlibVersion()); - return 0; -#else - std::printf("unpatched\n"); - return 1; -#endif -} -EOF - -# A -"$MCPP" run > a.log 2>&1 || fail "the patched build did not run" a.log -grep -q '^patched 1 1.3.2' a.log || fail "the program did not see the patched header" a.log -variant=$(ls -d "$MCPP_HOME"/cache/src/compat.zlib/1.3.2+p* 2>/dev/null | head -1) -[ -n "$variant" ] && [ -f "$variant/.mcpp-variant" ] || fail "no variant tree under the mcpp home" <(find "$MCPP_HOME/cache" -maxdepth 3) -grep -q 'MCPP_PATCHED_ZLIB' "$variant/zlib-1.3.2/zlib.h" || fail "the variant's header is not patched" "$variant/zlib-1.3.2/zlib.h" -installed=$(ls -d "$MCPP_HOME"/registry/data/xpkgs/compat-x-zlib/1.3.2 2>/dev/null | head -1) -if [ -n "$installed" ]; then - ! grep -q 'MCPP_PATCHED_ZLIB' "$installed"/zlib-1.3.2/zlib.h || fail "the installed source was patched in place" -fi - -# B -grep -q '^variant = ' mcpp.lock || fail "the lock names no variant" mcpp.lock -grep -q 'patches/0001-mark.patch#sha256:[0-9a-f]\{64\}' mcpp.lock || fail "the lock does not name the patch and its digest" mcpp.lock -"$MCPP" emit sbom > sbom.json 2>sbom.err || fail "sbom failed" sbom.err -grep -q '"pedigree"' sbom.json && grep -q '0001-mark.patch' sbom.json || fail "the bill of materials states no pedigree" sbom.json - -# C -sed -i 's/MCPP_PATCHED_ZLIB 1/MCPP_PATCHED_ZLIB 2/' patches/0001-mark.patch -"$MCPP" run > c.log 2>&1 || fail "the build with a changed patch failed" c.log -grep -q '^patched 2 ' c.log || fail "a changed patch did not make another variant" c.log -[ "$(ls -d "$MCPP_HOME"/cache/src/compat.zlib/1.3.2+p* | wc -l)" -ge 2 ] || fail "the changed patch did not get its own tree" <(ls "$MCPP_HOME/cache/src/compat.zlib") - -# D -cat > patches/0001-mark.patch <<'EOF' ---- a/zlib-1.3.2/zlib.h -+++ b/zlib-1.3.2/zlib.h -@@ -31,1 +31,1 @@ --#ifndef NOT_THE_LINE -+#ifndef ZLIB_H -EOF -before=$(ls -d "$MCPP_HOME"/cache/src/compat.zlib/* | wc -l) -if "$MCPP" build > d.log 2>&1; then fail "a patch that does not apply was accepted" d.log; fi -grep -q "0001-mark.patch" d.log && grep -q "hunk 1" d.log || fail "the refusal does not name the patch and the hunk" d.log -[ "$(ls -d "$MCPP_HOME"/cache/src/compat.zlib/* | wc -l)" = "$before" ] || fail "a failed patching left a tree" <(ls "$MCPP_HOME/cache/src/compat.zlib") - -# E -mkdir -p ../lib/src -printf '[package]\nname = "lib"\nversion = "0.1.0"\n' > ../lib/mcpp.toml -printf 'int lib_one() { return 1; }\n' > ../lib/src/lib.cpp -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -lib = { path = "../lib", patches = ["patches/0001-mark.patch"] } -EOF -if "$MCPP" build > e.log 2>&1; then fail "patches on a path dependency were accepted" e.log; fi -grep -q "is a path and carries patches" e.log || fail "the refusal does not say why" e.log - -echo "OK" diff --git a/tests/e2e/1002_a_dependency_is_embedded.sh b/tests/e2e/1002_a_dependency_is_embedded.sh deleted file mode 100755 index ee3967875..000000000 --- a/tests/e2e/1002_a_dependency_is_embedded.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1002 -- `embed = true` compiles a dependency into its consumer (design -# 2026-11-02 part 3 §14.1): the dependency's sources, less `exclude`, are the -# consumer's, compiled with the consumer's flags and the dependency's include -# directories and macros; the dependency builds no library of its own, and a -# program that reaches it holds its embedder or is refused. -# -# Criteria: -# A. The program links and runs with zlib embedded in its library package; -# no library of compat.zlib is built, and an excluded file is not compiled. -# B. The dependency's own compile flags reach its files (its `-include` of a -# generated header) and not the consumer's own. -# C. `exclude` without `embed` is refused at the manifest. -# D. A program that reaches the embedded package without its embedder is -# refused, naming both. -# E. An embedder with a build program builds without a warning about the -# flags `embed' gives the dependency's files: the build program's own plan -# compiles none of them, and the entry is the engine's, not a typo. -# F. The embedded package's link flags and libraries are not the program's: -# a dependency naming libraries no system has links when embedded. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -mkdir -p zip/src app/src -cat > zip/mcpp.toml <<'EOF' -[package] -name = "zip" -version = "0.1.0" - -[dependencies] -"compat.zlib" = { version = "1.3.2", embed = true, exclude = ["*/uncompr.c"] } -EOF -cat > zip/src/zip.cppm <<'EOF' -module; -#include -export module zip; -export int zip_bound(int n) { return static_cast(compressBound(static_cast(n))); } -export const char* zip_version() { return zlibVersion(); } -EOF -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -zip = { path = "../zip" } -EOF -cat > app/src/main.cpp <<'EOF' -import std; -import zip; -int main() { std::println("{} {}", zip_version(), zip_bound(100) > 100); } -EOF - -cat > zip/build.mcpp <<'EOF' -import std; -int main() { return 0; } -EOF - -# A -cd app -"$MCPP" run > a.log 2>&1 || fail "the program with an embedded zlib did not run" a.log - -# E -! grep -q 'matched no source file' a.log || fail "the flags embed gives were reported as a glob that matched nothing" a.log -grep -q '^1.3.2 true' a.log || fail "the program did not call the embedded zlib" a.log -dir=$(ls -d target/*/*/ | grep -E '/[0-9a-f]{16}/$' | head -1) -db=$(find "$dir" -name compile_commands.json | head -1) -[ -n "$db" ] || db=compile_commands.json -grep -q 'deflate.c' "$db" || fail "zlib's sources were not compiled" "$db" -! grep -q 'uncompr.c' "$db" || fail "an excluded file was compiled" "$db" -! find "$dir" -name '*zlib*.a' -o -name '*zlib*.lib' | grep -q . || fail "a library of the embedded package was built" <(find "$dir" -name '*.a') - -# B -grep -q 'mcpp_zlib_config.h' "$db" || fail "the dependency's own flags did not reach its files" "$db" -python3 - "$db" <<'PY' || fail "the dependency's flags reached the consumer's own unit" "$db" -import json, sys -for e in json.load(open(sys.argv[1])): - if e["file"].endswith("zip.cppm") and "mcpp_zlib_config.h" in " ".join(e.get("arguments", [])) + e.get("command", ""): - sys.exit(1) -PY -cd .. - -# C -mkdir -p bad/src && cd bad -printf '[package]\nname = "bad"\nversion = "0.1.0"\n\n[dependencies]\n"compat.zlib" = { version = "1.3.2", exclude = ["*/gzwrite.c"] }\n' > mcpp.toml -printf 'int main() {}\n' > src/main.cpp -if "$MCPP" build > c.log 2>&1; then fail "exclude without embed was accepted" c.log; fi -grep -q "exclude is allowed only with embed" c.log || fail "the refusal does not say why" c.log -cd .. - -# D -- one configuration of a workspace: `app` holds the embedder, `plain` -# reaches compat.zlib directly. The embedded package compiles nothing of its -# own there, so `plain` would link its declarations and no definitions. -mkdir -p ws/plain/src && cd ws -cp -r ../app ../zip . -rm -rf app/target -printf '[workspace]\nmembers = ["app", "plain", "zip"]\n' > mcpp.toml -printf '[package]\nname = "plain"\nversion = "0.1.0"\n\n[dependencies]\n"compat.zlib" = "1.3.2"\n' > plain/mcpp.toml -printf '#include \nint main() { return zlibVersion()[0] == 0; }\n' > plain/src/main.cpp -if "$MCPP" build --workspace > d.log 2>&1; then fail "a program without the embedder was accepted" d.log; fi -grep -q "which 'zip' embeds" d.log || fail "the refusal does not name the embedder" d.log -cd .. - -# F -mkdir -p lk/src lkapp/src && cd lkapp -printf '[package]\nname = "lk"\nversion = "0.1.0"\n\n[build]\nldflags = ["-lmcpp-no-such-library"]\n\n[runtime]\nlibraries = ["mcpp-no-such-runtime-library"]\n' > ../lk/mcpp.toml -printf 'int lk_answer(void) { return 42; }\n' > ../lk/src/lk.c -printf '[package]\nname = "lkapp"\nversion = "0.1.0"\n\n[dependencies]\nlk = { path = "../lk", embed = true }\n' > mcpp.toml -printf 'extern "C" int lk_answer();\nint main() { return lk_answer() == 42 ? 0 : 1; }\n' > src/main.cpp -"$MCPP" run > f.log 2>&1 || fail "the embedded package's link flags or libraries reached the program's link" f.log -cd .. - -echo "OK" diff --git a/tests/e2e/1003_one_program_one_variant.sh b/tests/e2e/1003_one_program_one_variant.sh deleted file mode 100755 index 50b6159a4..000000000 --- a/tests/e2e/1003_one_program_one_variant.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1003 -- a patched library is another library, and one program holds one -# variant of a package (design 2026-11-02 part 3 §14.1). Two programs of one -# workspace configuration may each use their own patch set of compat.zlib; a -# program that reaches two variants of it -- or a variant and the package -# without patches -- is refused, naming the variants. -# -# Criteria: -# A. `one` (through member `a`, patch P1) and `two` (patch P2) build in one -# configuration, and each sees its own patch. -# B. `three`, reaching `a`'s variant and P2's, is refused naming both. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mark() { # mark : a patch that defines MCPP_MARK as - printf '%s\n' '--- a/zlib-1.3.2/zlib.h' '+++ b/zlib-1.3.2/zlib.h' '@@ -31,4 +31,6 @@' \ - ' #ifndef ZLIB_H' ' #define ZLIB_H' ' ' "+#define MCPP_MARK $2" '+' ' #ifdef ZLIB_BUILD' > "$1" -} - -cd "$TMP" -mkdir -p ws && cd ws -mkdir -p a/src one/src two/src three/src patches -mark patches/p1.patch 1 -mark patches/p2.patch 2 -printf '[workspace]\nmembers = ["a", "one", "two"]\n' > mcpp.toml -cat > a/mcpp.toml <<'EOF' -[package] -name = "a" -version = "0.1.0" - -[dependencies] -"compat.zlib" = { version = "1.3.2", patches = ["../patches/p1.patch"] } -EOF -cat > a/src/a.cppm <<'EOF' -module; -#include -export module a; -export int a_mark() { return MCPP_MARK; } -EOF -printf '[package]\nname = "one"\nversion = "0.1.0"\n\n[dependencies]\na = { path = "../a" }\n' > one/mcpp.toml -printf 'import std;\nimport a;\nint main() { std::println("one {}", a_mark()); }\n' > one/src/main.cpp -cat > two/mcpp.toml <<'EOF' -[package] -name = "two" -version = "0.1.0" - -[dependencies] -"compat.zlib" = { version = "1.3.2", patches = ["../patches/p2.patch"] } -EOF -printf '#include \n#include \nint main() { std::printf("two %%d %%s\\n", MCPP_MARK, zlibVersion()); }\n' > two/src/main.cpp - -# A -"$MCPP" build --workspace > a.log 2>&1 || fail "two programs with two variants did not build" a.log -"$MCPP" run -p one > one.log 2>&1 || fail "one did not run" one.log -"$MCPP" run -p two > two.log 2>&1 || fail "two did not run" two.log -grep -q '^one 1' one.log || fail "one did not see its patch" one.log -grep -q '^two 2 1.3.2' two.log || fail "two did not see its patch" two.log -[ "$(ls -d "$MCPP_HOME"/cache/src/compat.zlib/1.3.2+p* | wc -l)" = 2 ] || fail "the two variants do not have a tree each" <(ls "$MCPP_HOME/cache/src/compat.zlib") - -# B -printf '[workspace]\nmembers = ["a", "one", "two", "three"]\n' > mcpp.toml -cat > three/mcpp.toml <<'EOF' -[package] -name = "three" -version = "0.1.0" - -[dependencies] -a = { path = "../a" } -"compat.zlib" = { version = "1.3.2", patches = ["../patches/p2.patch"] } -EOF -printf 'import a;\nint main() { return a_mark() == 0; }\n' > three/src/main.cpp -if "$MCPP" build -p three > b.log 2>&1; then fail "a program with two variants was accepted" b.log; fi -grep -q "reaches two variants of 'compat.zlib'" b.log || fail "the refusal does not name the package" b.log -[ "$(grep -c 'compat.zlib+p' b.log)" -ge 2 ] || fail "the refusal does not name both variants" b.log - -echo "OK" diff --git a/tests/e2e/1004_an_external_toolchain_finds_mcpps_ninja.sh b/tests/e2e/1004_an_external_toolchain_finds_mcpps_ninja.sh deleted file mode 100755 index 3194e67bc..000000000 --- a/tests/e2e/1004_an_external_toolchain_finds_mcpps_ninja.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1004 -- a toolchain named by a path outside every provider finds the ninja -# mcpp installed into its own home (mcpp#875). Before 2026.11 only the one -# beside the compiler's provider package was asked, then PATH, so a machine -# whose PATH held no ninja failed with "cannot start 'ninja'" while mcpp's own -# sat installed. -# -# Criteria: -# A. With the compiler reached through a directory outside the store and a -# PATH that holds no ninja, the build succeeds. -# B. `MCPP_NINJA`, as a build program sees it, is the same absolute ninja. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -gxx=$(ls -d "$MCPP_HOME"/registry/data/xpkgs/xim-x-gcc/*/bin/g++ 2>/dev/null | head -1) -[ -n "$gxx" ] || { echo "SKIP: no gcc payload to reach from outside the store"; exit 77; } -ninja=$(ls -d "$MCPP_HOME"/registry/data/xpkgs/xim-x-ninja/*/ninja 2>/dev/null | head -1) -[ -n "$ninja" ] || { echo "SKIP: mcpp's home holds no ninja"; exit 77; } -ln -s "$(dirname "$(dirname "$gxx")")" "$TMP/external-gcc" - -# The xlings mcpp drives, named outright: the PATH below holds no xlings. -export MCPP_VENDORED_XLINGS="${MCPP_VENDORED_XLINGS:-$(command -v xlings || true)}" -[ -n "$MCPP_VENDORED_XLINGS" ] || [ -x "$MCPP_HOME/registry/bin/xlings" ] || { echo "SKIP: no xlings to name"; exit 77; } - -# A PATH with the usual tools and no ninja. An external toolchain is complete -# where it is installed (MSYS2's GCC carries its assembler in its own `bin'); -# this one's assembler is the payload's binutils, put on PATH as such a -# toolchain's would be. -mkdir -p "$TMP/bin" -for t in sh bash env cat sed grep mkdir rm ls cp mv ln dirname basename uname tr head tail awk; do - p=$(command -v "$t" 2>/dev/null) && ln -sf "$p" "$TMP/bin/$t" -done -binutils=$(ls -d "$MCPP_HOME"/registry/data/xpkgs/xim-x-binutils/*/bin 2>/dev/null | head -1) -for t in as ld ar ranlib nm objcopy strip; do - [ -n "$binutils" ] && [ -x "$binutils/$t" ] && ln -sf "$binutils/$t" "$TMP/bin/$t" -done -[ ! -e "$TMP/bin/ninja" ] || fail "the restricted PATH holds a ninja" - -cd "$TMP" -mkdir -p app/src && cd app -printf '[package]\nname = "app"\nversion = "0.1.0"\n' > mcpp.toml -printf 'import std;\nint main() { std::println("built"); }\n' > src/main.cpp - -# A -PATH="$TMP/bin" MCPP_TOOLCHAIN="path:$TMP/external-gcc" "$MCPP" build > a.log 2>&1 \ - || fail "the build with an external toolchain and no ninja on PATH failed" a.log -! grep -q "cannot start 'ninja'" a.log || fail "the bare name reached PATH" a.log - -# B -PATH="$TMP/bin" MCPP_TOOLCHAIN="path:$TMP/external-gcc" "$MCPP" self env > b.log 2>&1 || true -if grep -q 'MCPP_NINJA' b.log; then - grep 'MCPP_NINJA' b.log | grep -q '/ninja' || fail "MCPP_NINJA is not an absolute ninja" b.log -fi -echo "OK" diff --git a/tests/e2e/1005_clean_stale_says_what_it_kept.sh b/tests/e2e/1005_clean_stale_says_what_it_kept.sh deleted file mode 100755 index e04cdc718..000000000 --- a/tests/e2e/1005_clean_stale_says_what_it_kept.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1005 -- `clean --stale` tells the truth about what it kept (mcpp#881), and -# its window of "recent" is the sweep's: `[build] keep_days`. -# -# Criteria: -# A. With an unrecorded directory written a minute ago, the summary says one -# directory was kept and within what window, and does not say every -# directory is recorded as current. -# B. The default window is `[build] keep_days` (3 days): a directory written -# two days ago is kept by default, and removed with `--older-than 1d`. -# C. With nothing unrecorded the summary is the one it always was. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -mkdir -p app/src && cd app -printf '[package]\nname = "app"\nversion = "0.1.0"\n' > mcpp.toml -printf 'int main() { return 0; }\n' > src/main.cpp -"$MCPP" build > build.log 2>&1 || fail "the build failed" build.log -triple=$(dirname "$(ls -d target/*/*/ | grep -E '/[0-9a-f]{16}/$' | head -1)") - -# C -"$MCPP" clean --stale --dry-run > c.log 2>&1 || fail "clean --stale failed" c.log -grep -q 'every fingerprint directory is recorded as current' c.log || fail "the plain summary changed" c.log - -# A -mkdir -p "$triple/deadbeefdeadbeef" && printf 'rule\n' > "$triple/deadbeefdeadbeef/build.ninja" -"$MCPP" clean --stale --dry-run > a.log 2>&1 || fail "clean --stale failed" a.log -grep -q 'kept .*deadbeefdeadbeef' a.log || fail "the kept directory is not listed" a.log -! grep -q 'every fingerprint directory is recorded as current' a.log || fail "the summary still says every directory is current" a.log -grep -q '1 directory not in use kept, written within the last 3d' a.log || fail "the summary does not say what was kept" a.log - -# B -touch -d '2 days ago' "$triple/deadbeefdeadbeef/build.ninja" "$triple/deadbeefdeadbeef" -"$MCPP" clean --stale > b0.log 2>&1 || fail "clean --stale failed" b0.log -[ -d "$triple/deadbeefdeadbeef" ] || fail "a directory written two days ago was removed under the default window" b0.log -"$MCPP" clean --stale --older-than 1d > b1.log 2>&1 || fail "clean --stale --older-than 1d failed" b1.log -[ ! -d "$triple/deadbeefdeadbeef" ] || fail "--older-than 1d kept a directory written two days ago" b1.log -echo "OK" diff --git a/tests/e2e/1006_a_response_file_and_what_it_names_are_link_inputs.sh b/tests/e2e/1006_a_response_file_and_what_it_names_are_link_inputs.sh deleted file mode 100755 index a1a05a3bf..000000000 --- a/tests/e2e/1006_a_response_file_and_what_it_names_are_link_inputs.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell -# 1006 -- a response file a link flag names (`@`), and every file it -# names, are inputs of the link. -# -# clangd's ok image lists the archives a `prepare` action makes in one -# response file, named by the build program (`link_flag("@")`). When the -# action rebuilt an archive the response file stayed as it was, and the link, -# which depended on neither, stayed up to date over the new archive. -# -# Criteria: the program runs with the archive's first contents; after the -# archive the response file names is replaced (the response file unchanged), -# `mcpp build` relinks and the program runs with the new contents; a further -# build with nothing changed links nothing; a response file that names the -# archive in quotes is read the same way. -set -e - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT - -mkdir -p "$work/val/src" "$work/prebuilt" -cat > "$work/val/mcpp.toml" <<'TOML' -[package] -name = "val" -version = "0.1.0" - -[targets.val] -kind = "lib" -TOML -make_archive() { # make_archive - printf 'extern "C" int val() { return %s; }\n' "$1" > "$work/val/src/val.cpp" - (cd "$work/val" && "$MCPP" build > "$work/val.log" 2>&1) || { cat "$work/val.log"; echo "FAIL: the library did not build"; exit 1; } - local a - a="$(find "$work/val/target" -name 'libval*.a' -type f | head -1)" - [[ -n "$a" ]] || { find "$work/val/target" -type f | head -40; echo "FAIL: no archive of the library"; exit 1; } - cp "$a" "$work/prebuilt/libval.a" -} -make_archive 41 -printf '"%s"\n' "$work/prebuilt/libval.a" > "$work/prebuilt/libs.rsp" - -mkdir -p "$work/app/src" -cat > "$work/app/mcpp.toml" < "$work/app/src/main.cpp" <<'CPP' -import std; -extern "C" int val(); -int main() { std::println("val {}", val()); } -CPP -cd "$work/app" - -"$MCPP" run > run1.log 2>&1 || { cat run1.log; echo "FAIL: the first run"; exit 1; } -grep -q 'val 41' run1.log || { cat run1.log; echo "FAIL: the first run did not print val 41"; exit 1; } - -make_archive 42 -"$MCPP" run > run2.log 2>&1 || { cat run2.log; echo "FAIL: the second run"; exit 1; } -grep -q 'val 42' run2.log || { - cat run2.log - echo "FAIL: a replaced archive a response file names did not relink the program" - exit 1 -} - -"$MCPP" build -v > build3.log 2>&1 || { cat build3.log; echo "FAIL: the third build"; exit 1; } -if grep -qE '^\[[0-9]+/[0-9]+\] .*bin/app' build3.log; then - cat build3.log - echo "FAIL: a build with nothing changed linked the program again" - exit 1 -fi - -echo "OK" diff --git a/tests/e2e/1007_a_requirement_a_layer_decides_is_checked.sh b/tests/e2e/1007_a_requirement_a_layer_decides_is_checked.sh deleted file mode 100755 index 72fd5f4fe..000000000 --- a/tests/e2e/1007_a_requirement_a_layer_decides_is_checked.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# 1007 -- a requirement stated in a table whose selector names a layer -# (`[target.'cfg(compiler = "llvm")'] requires = [...]`) is checked once the -# layers are resolved, as a requirement of `[package]` is. -# -# openkal-musl links its C library with `-nostdlib`, and on PE under clang the -# routines a compiler emits calls to are compiler-rt's, which the graph supplies -# (openkal-llvm-runtime). It states that as a requirement of the clang build -# only; the table's requirement was dropped without a word, and a graph without -# the layer reached the link with `___chkstk_ms` undefined. -# -# Criteria: A a dependency whose layer-selected table requires a layer this -# build does not resolve is refused before anything is compiled, the message -# naming the dependency, the layer and what it requires; B the same table under -# a selector this build does not match states nothing, and the build succeeds. -set -e - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT - -needs() { # needs - mkdir -p "$work/needs/src" - cat > "$work/needs/mcpp.toml" < "$work/needs/src/needs.cpp" -} - -mkdir -p "$work/app/src" -cat > "$work/app/mcpp.toml" < "$work/app/src/main.cpp" - -# A: a selector naming a layer, which every build matches. -needs 'cfg(not(compiler = "nonesuch"))' -if (cd "$work/app" && "$MCPP" build > "$work/a.log" 2>&1); then - cat "$work/a.log"; echo "FAIL: A: a requirement a layer decides was not checked"; exit 1 -fi -grep -q 'needs@0.1.0` requires the compiler-runtime to be `nonesuch`' "$work/a.log" \ - || { cat "$work/a.log"; echo "FAIL: A: the refusal does not name the dependency, the layer and what it requires"; exit 1; } -if grep -q 'Compiling' "$work/a.log"; then - cat "$work/a.log"; echo "FAIL: A: something was compiled before the refusal"; exit 1 -fi -echo "ok: A: a requirement a layer decides is checked when the layers are known" - -# B: the same table under a selector this build does not match. -needs 'cfg(compiler = "nonesuch")' -rm -rf "$work/app/target" -(cd "$work/app" && "$MCPP" build > "$work/b.log" 2>&1) \ - || { cat "$work/b.log"; echo "FAIL: B: a table this build does not match stated a requirement"; exit 1; } -echo "ok: B: a table this build does not match states nothing" diff --git a/tests/e2e/1008_a_provided_format_is_told_the_compression.sh b/tests/e2e/1008_a_provided_format_is_told_the_compression.sh deleted file mode 100755 index 3b35b7907..000000000 --- a/tests/e2e/1008_a_provided_format_is_told_the_compression.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# requires: pack -# 1008 -- a format a package provides is told the compression the command or -# `[pack] compress` stated (MCPP_PACK_COMPRESS, `mcpp::pack_compress()`), and -# decides what its container does with it (2026.11.3.1). -# -# `--compress` was refused with every provided format ("writes none"), so a -# provider whose container can be compressed --- openkal-platform's okb, which -# stores its images compressed under `gzip` --- had no way to learn it. -# -# Legs: -# A. `--pkg-format probe --compress gzip:9`: the program reads `gzip:9`. -# B. `--pkg-format probe`: it reads nothing. -# C. `[pack] compress = "zstd"`: it reads `zstd`. -# D. `--pkg-format dir --compress gzip` is still refused: a directory writes -# no container. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mkdir -p app/src -cat > app/mcpp.toml <<'EOF2' -[package] -name = "app" -version = "0.1.0" -standard = "c++20" - -[targets.app] -kind = "bin" -main = "src/main.cpp" -EOF2 -printf 'int main() { return 0; }\n' > app/src/main.cpp -cat > app/build.mcpp <<'EOF2' -import mcpp; -#include -#include -int main() { - mcpp::provides_pack_format("probe"); - if (std::string_view(mcpp::pack_format()) != "probe") return 0; - const std::string out = std::string(mcpp::out_dir()) + "/probe.txt"; - const std::string text = std::string("compress=") + mcpp::pack_compress(); - mcpp::action a; - a.id = "probe"; - a.role = "artifact"; - a.description = "probe"; - a.arg("/bin/sh").arg("-c").arg(("printf '%s' '" + text + "' > " + out).c_str()) - .input("${mcpp.target_file:app}") - .output(out.c_str()) - .submit(); - return 0; -} -EOF2 -cd app - -# ── A ─────────────────────────────────────────────────────────────────────── -"$MCPP" pack --pkg-format probe --compress gzip:9 > a.log 2>&1 || fail "A: pack failed" a.log -probe=$(find target -name probe.txt | head -1) -[ "$(cat "$probe")" = "compress=gzip:9" ] || fail "A: the program read '$(cat "$probe" 2>/dev/null)'" a.log -echo "ok: A, a provided format is told the compression the command states" - -# ── B ─────────────────────────────────────────────────────────────────────── -"$MCPP" pack --pkg-format probe > b.log 2>&1 || fail "B: pack failed" b.log -[ "$(cat "$probe")" = "compress=" ] || fail "B: with none stated the program read '$(cat "$probe")'" b.log -echo "ok: B, and nothing when none is stated" - -# ── C ─────────────────────────────────────────────────────────────────────── -printf '\n[pack]\ncompress = "zstd"\n' >> mcpp.toml -"$MCPP" pack --pkg-format probe > c.log 2>&1 || fail "C: pack failed" c.log -[ "$(cat "$probe")" = "compress=zstd" ] || fail "C: under [pack] compress the program read '$(cat "$probe")'" c.log -echo "ok: C, and the one [pack] compress states" - -# ── D ─────────────────────────────────────────────────────────────────────── -if "$MCPP" pack --pkg-format dir --compress gzip > d.log 2>&1; then fail "D: a directory was compressed" d.log; fi -grep -q "writes none" d.log || fail "D: the refusal" d.log -echo "ok: D, a directory writes no container and is refused" diff --git a/tests/e2e/1009_cache_gc_asks_the_runner.sh b/tests/e2e/1009_cache_gc_asks_the_runner.sh deleted file mode 100755 index 5f0ea31c3..000000000 --- a/tests/e2e/1009_cache_gc_asks_the_runner.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# 1009 -- `mcpp cache gc` asks the newest okrun among the host tools it built -# to reclaim the runner's own cache (`okrun cache gc`, openkal-image 0.4; -# design 2026-11-03 part 4 §16; 2026.11.3.1). -# -# The prepared copies and snapshots an ok image's runner keeps are laid out by -# the runner, so the runner reclaims them. Two openkal-tools entries stand in -# here: the older one must not be asked, the newer one is asked `cache gc` and -# what it says is reported. A home with no okrun asks nothing. -set -e -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -tools="$MCPP_HOME/build-cache/v1/tool/mcpplibs" - -# A home that built no okrun: gc collects as before and asks no runner. -mkdir -p "$MCPP_HOME/build-cache/v1/tool" -"$MCPP" cache gc --older-than 3650d > none.log 2>&1 || { cat none.log; exit 1; } -if grep -q 'Reclaimed' none.log; then - echo "FAIL: a home without okrun reported a runner's cache"; cat none.log; exit 1 -fi - -runner() { # runner - local bin="$tools/openkal-tools@$1/$2/bin" - mkdir -p "$bin" - cat > "$bin/okrun" < "$TMP/asked-$1" -echo "$3" >&2 -SH - chmod +x "$bin/okrun" -} -runner 0.3.2 aaaa "okrun: the older runner" -runner 0.4.1 bbbb "okrun: removed 7 bytes prepared for another boot" -runner 0.4.0 cccc "okrun: the middle runner" -# Numeric version order, and cache entries that cannot name a released version. -runner 0.10.1 dddd "okrun: removed 9 bytes prepared for another boot" -runner 0.99broken eeee "okrun: not a version" -runner 999999999999999999999999 ffff "okrun: overflowing version" - -"$MCPP" cache gc --older-than 3650d > gc.log 2>&1 || { cat gc.log; exit 1; } -[[ "$(cat "$TMP/asked-0.10.1" 2>/dev/null)" == "cache gc" ]] || { - echo "FAIL: the newest okrun was not asked \`cache gc\`"; cat gc.log; exit 1 -} -[[ ! -e "$TMP/asked-0.3.2" && ! -e "$TMP/asked-0.4.0" && ! -e "$TMP/asked-0.4.1" ]] || { - echo "FAIL: an older okrun was asked"; cat gc.log; exit 1 -} -[[ ! -e "$TMP/asked-0.99broken" && ! -e "$TMP/asked-999999999999999999999999" ]] || { - echo "FAIL: an invalid version was asked"; cat gc.log; exit 1 -} -grep -q 'Reclaimed.*removed 9 bytes prepared for another boot' gc.log || { - echo "FAIL: what the runner reclaimed is not reported"; cat gc.log; exit 1 -} -echo "OK" diff --git a/tests/e2e/100_cppfly_reflection.sh b/tests/e2e/100_cppfly_reflection.sh deleted file mode 100755 index 90453e142..000000000 --- a/tests/e2e/100_cppfly_reflection.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 100_cppfly_reflection.sh — standard = "c++fly" (design 2026-07-14): ONE -# manifest line gives the toolchain's latest -std= level + every experimental -# gate it supports. On gcc >= 16 that means -std=c++26 -freflection with zero -# hand-written flags, reaching every TU, the scan and the std BMI prebuild. -# Tail section: standard = "c++latest" regression — it used to reach the GNU -# driver as the invalid spelling -std=c++latest (design §11-Q5). -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# Capability probe (mirrors 98): newest gcc payload must accept -freflection. -USER_MCPP="${MCPP_HOME:-$HOME/.mcpp}" -GXX=$(find "$USER_MCPP/registry/data/xpkgs/xim-x-gcc" -name "g++" -path "*/bin/*" 2>/dev/null | sort | tail -1) -if [[ -z "$GXX" ]] || ! echo 'int main(){}' | "$GXX" -freflection -std=c++26 -x c++ - -fsyntax-only -o /dev/null 2>/dev/null; then - echo "SKIP-INLINE: no -freflection-capable gcc payload available" - exit 77 -fi -GCC_VER=$(basename "$(dirname "$(dirname "$GXX")")") - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" - -cat > mcpp.toml < src/main.cpp <<'EOF' -import std; - -void print_struct(auto &&value) { - constexpr auto info = std::meta::remove_cvref(^^decltype(value)); - constexpr auto no_check = std::meta::access_context::unchecked(); - static constexpr auto members = std::define_static_array( - std::meta::nonstatic_data_members_of(info, no_check)); - template for (constexpr auto e : members) { - auto &&member = value.[:e:]; - std::println("{} {}", identifier_of(e), member); - } -} - -struct Point { double x{}; double y{}; }; -int main() { - print_struct(Point{2, 3}); -} -EOF - -"$MCPP" build --no-cache > "$TMP/build.log" 2>&1 || { - cat "$TMP/build.log" - echo "FAIL: c++fly build failed" - exit 1 -} - -# The c++fly summary: the value's contract is saying what was enabled/skipped. -grep -q 'c++fly on gcc' "$TMP/build.log" || { - cat "$TMP/build.log" - echo "FAIL: build log missing c++fly summary line" - exit 1 -} -grep -q 'reflection (-freflection)' "$TMP/build.log" || { - cat "$TMP/build.log" - echo "FAIL: summary missing enabled reflection gate" - exit 1 -} -grep -q 'enabled: .*contracts' "$TMP/build.log" || { - cat "$TMP/build.log" - echo "FAIL: summary missing enabled contracts (on by -std=c++26 on gcc16)" - exit 1 -} - -# Latest level + gate flags reach the global cxxflags (every TU, deps incl.). -build_ninja="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE '^cxxflags = -std=c\+\+26 -freflection' "$build_ninja" || { - grep '^cxxflags' "$build_ninja" || true - echo "FAIL: build.ninja cxxflags lacks -std=c++26 -freflection" - exit 1 -} -if grep -q -- '-std=c++fly' "$build_ninja" compile_commands.json; then - echo "FAIL: raw c++fly canonical leaked into a command line" - exit 1 -fi -grep -q '"-freflection"' compile_commands.json || { - echo "FAIL: compile_commands.json missing -freflection" - exit 1 -} - -# Reflection actually works at runtime (std::meta over struct members). -binary=$(find target -type f -path '*/bin/flydemo' | head -1) -out=$("$binary") -[[ "$out" == *"x 2"* && "$out" == *"y 3"* ]] || { - echo "FAIL: reflection output: $out" - exit 1 -} - -# The std BMI prebuild carries the same dialect (scan/prebuild/compile agree). -# Recursive grep, not `find | head -1`: one MCPP_HOME can hold several std -# identities now, so "the first one" is arbitrary. -grep -rl '"std_flag": "-std=c++26 -freflection' "$MCPP_HOME/build-cache/v1/std" >/dev/null 2>&1 || { - find "$MCPP_HOME/build-cache/v1/std" -name std-module.json \ - -exec grep -H '"std_flag"' {} \; 2>/dev/null - echo "FAIL: no std-module.json records std_flag -std=c++26 -freflection" - exit 1 -} - -# ── c++latest regression (design §11-Q5) ──────────────────────────────── -# Used to emit the invalid GNU spelling -std=c++latest; must now resolve to -# the family latest level. -mkdir -p "$TMP/latest/src" -cd "$TMP/latest" -cat > mcpp.toml < src/main.cpp <<'EOF' -import std; -int main() { std::println("latest ok"); } -EOF - -"$MCPP" build --no-cache > "$TMP/latest-build.log" 2>&1 || { - cat "$TMP/latest-build.log" - echo "FAIL: c++latest build failed" - exit 1 -} -latest_ninja="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE '^cxxflags = -std=c\+\+26' "$latest_ninja" || { - grep '^cxxflags' "$latest_ninja" || true - echo "FAIL: c++latest did not resolve to -std=c++26" - exit 1 -} -if grep -q -- '-std=c++latest' "$latest_ninja" compile_commands.json; then - echo "FAIL: invalid -std=c++latest spelling reached a command line" - exit 1 -fi - -echo "PASS: c++fly = latest std + experimental gates; c++latest spelling fixed" diff --git a/tests/e2e/100_feature_sources_test_mode.sh b/tests/e2e/100_feature_sources_test_mode.sh deleted file mode 100755 index d770d1419..000000000 --- a/tests/e2e/100_feature_sources_test_mode.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 100_feature_sources_test_mode.sh — a dependency's feature-gated `sources` must -# be compiled under `mcpp test`, not just `mcpp build`. -# -# Regression: prepare_build() gated the whole feature-source resolution (drop + -# add) on `!includeDevDeps`, so `mcpp test` never added an active feature's -# sources. Descriptors that list a glob ONLY under `features` (xpkg's -# `features.X.sources` never lands in base `sources`) therefore compiled fine -# under `mcpp build` but failed to link under `mcpp test` with `undefined -# reference` — compat.cjson's `utils`, compat.spdlog's `compiled`, and -# compat.eigen's `eigen_blas` all hit this. The drop stays build-mode only (see -# 79_gtest_regular_dep_feature_main.sh); the add now runs in both modes. -# -# compat.cjson is the vehicle: plain C, small, and `utils` (cJSON_Utils.c) is -# feature-gated with no other dependency. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new app > /dev/null -cd app -rm -f src/main.cpp -# The skeleton's test is mtest's; this manifest names no dev-dependency. -rm -f tests/test_smoke.cpp -mkdir -p tests - -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies.compat] -cjson = { version = "1.7.19", features = ["utils"] } -EOF - -# Calls into BOTH the base sources (cJSON.c) and the feature-gated ones -# (cJSON_Utils.c) — a link failure on either fails the test. -cat > tests/t.cpp <<'EOF' -#include -#include -int main() { - cJSON* root = cJSON_Parse("{\"a\":1}"); - if (!root) return 1; - char* p = cJSONUtils_FindPointerFromObjectTo(root, root); - int ok = (p != 0); - cJSON_Delete(root); - return ok ? 0 : 1; -} -EOF - -# (1) THE REGRESSION: `mcpp test` must compile the feature's sources and link. -"$MCPP" test > /dev/null 2>&1 || { - echo "FAIL: feature-gated sources not compiled under \`mcpp test\`" - "$MCPP" test 2>&1 | tail -5 - exit 1 -} -nj=$(find target -name test.ninja -not -path '*/.build-mcpp/*' | xargs ls -t 2>/dev/null | head -1) -grep -q 'cJSON_Utils' "$nj" || { - echo "FAIL: cJSON_Utils.c absent from the test-mode build graph"; exit 1 -} - -# (2) `mcpp build` keeps working (the path that was always correct). -cat > src/main.cpp <<'EOF' -#include -#include -int main() { - cJSON* root = cJSON_Parse("{\"a\":1}"); - char* p = cJSONUtils_FindPointerFromObjectTo(root, root); - (void)p; cJSON_Delete(root); return 0; -} -EOF -"$MCPP" build > /dev/null || { echo "FAIL: build with feature sources failed"; exit 1; } -nj=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | xargs ls -t 2>/dev/null | head -1) -grep -q 'cJSON_Utils' "$nj" || { echo "FAIL: cJSON_Utils.c absent from build graph"; exit 1; } - -# (3) The drop still works: without the feature, the gated source stays OUT of a -# `mcpp build` graph (guards against "add everything unconditionally"). -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies.compat] -cjson = "1.7.19" -EOF -cat > src/main.cpp <<'EOF' -#include -int main() { cJSON* r = cJSON_Parse("{\"a\":1}"); cJSON_Delete(r); return 0; } -EOF -"$MCPP" build > /dev/null || { echo "FAIL: default (no feature) build failed"; exit 1; } -nj=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | xargs ls -t 2>/dev/null | head -1) -if grep -q 'cJSON_Utils' "$nj"; then - echo "FAIL: cJSON_Utils.c compiled without the \`utils\` feature"; exit 1 -fi - -echo "OK" diff --git a/tests/e2e/1010_the_std_module_takes_the_platforms_flags.sh b/tests/e2e/1010_the_std_module_takes_the_platforms_flags.sh deleted file mode 100755 index b80cf29bb..000000000 --- a/tests/e2e/1010_the_std_module_takes_the_platforms_flags.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# 1010 -- the std module is compiled with the compile words the platform states -# for every unit (SPEC-013 §2.5; 2026.11.3.1). -# -# openkal-platform 0.23.0 compiles every unit on x86_64 with -# `-fcf-protection=full`, which clang records in a BMI and checks when a unit -# imports it. The std module was compiled without the platform's words, and -# every importer was refused: -# -# error: Control-Flow Branch Protection enabled was disabled in precompiled -# file '.../pcm.cache/std.pcm' but is currently enabled -# -# A platform for this machine's own triple states the flag here; a program -# that imports std builds and runs with it, and the module's command names it. -# The flag is the x86_64 one, so the test asks it of an x86_64 machine only. -set -e -case "$(uname -m)" in x86_64|amd64) ;; *) echo "SKIP: -fcf-protection is an x86_64 flag"; exit 77 ;; esac -source "$(dirname "$0")/_toolchain_env.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mkdir -p "$TMP/plat" "$TMP/app/src" -cat > "$TMP/plat/mcpp.toml" <<'TOML' -[package] -name = "cet-platform" -version = "0.1.0" - -[platform] -targets = ["x86_64-linux-gnu"] - -[platform.build] -cxxflags = ["-fcf-protection=full"] -TOML - -cd "$TMP/app" -cat > mcpp.toml < src/main.cpp <<'CPP' -import std; -int main() { - std::vector v{1, 2, 3}; - std::println("std with the platform's words: {}", v.size()); - return 0; -} -CPP - -"$MCPP" build -v > build.log 2>&1 || fail "a program importing std did not build with the platform's flag" build.log -grep -q 'Control-Flow Branch Protection' build.log && fail "the std module was refused for the flag" build.log -grep -Eq 'std\.cppm.*-fcf-protection=full|-fcf-protection=full.*std\.cppm' build.log \ - || grep -q 'fcf-protection=full' target/*/*/build.ninja 2>/dev/null \ - || fail "the std module's command does not name the platform's flag" build.log -"$MCPP" run > run.log 2>&1 || fail "the program did not run" run.log -grep -q "std with the platform's words: 3" run.log || fail "the program's output" run.log -echo "OK" diff --git a/tests/e2e/1011_accelerator_requests_are_complete_and_canonical.sh b/tests/e2e/1011_accelerator_requests_are_complete_and_canonical.sh deleted file mode 100755 index 6fc9de4f3..000000000 --- a/tests/e2e/1011_accelerator_requests_are_complete_and_canonical.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell python3 -# Invalid accelerator syntax must not become a successful CPU build, and an -# implementation cannot occur twice with order-dependent constraint coverage. -set -e -MCPP="${MCPP:-mcpp}" -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT -export MCPP_HOME="$work/home" -source "$(dirname "$0")/_inherit_toolchain.sh" -mkdir -p "$work/app/src" -cd "$work/app" -fail() { echo "FAIL: $*"; cat out.log 2>/dev/null; exit 1; } -manifest() { - cat > mcpp.toml <<'TOML' -[package] -name = "accel-request" -version = "0.1.0" -[targets.accel-request] -kind = "bin" -main = "src/main.cpp" -TOML -} -manifest -echo 'int main(){return 0;}' > src/main.cpp - -for request in 'sycl+{sm_89' 'sycl+{sm_89}garbage' 'sycl+{sm_89,,sm_90}' \ - 'sycl+{sm_89},sycl+{sm_90}' 'sycl+{sm_90},sycl+{sm_89}'; do - if "$MCPP" build --accel "$request" > out.log 2>&1; then - fail "accepted malformed or repeated request: $request" - fi - grep -q 'invalid accelerator request at byte' out.log || fail "missing syntax location" - grep -q -- '--accel' out.log || fail "missing request source" -done - -cat >> mcpp.toml <<'TOML' -[build] -accel = "sycl+{sm_89" -TOML -if "$MCPP" build > out.log 2>&1; then fail 'accepted malformed manifest request'; fi -grep -q '\[build\] accel' out.log || fail 'missing manifest field' - -manifest -cat >> mcpp.toml <<'TOML' -[build] -sources = [{ glob = "src/main.cpp", accel = "sycl+{sm_90" }] -TOML -if "$MCPP" build --no-accel > out.log 2>&1; then fail 'ignored malformed source constraint'; fi -grep -q "sources entry 'src/main.cpp'" out.log || fail 'missing source constraint' -grep -q 'invalid accelerator request' out.log || fail 'missing constraint syntax error' - -manifest -cat >> mcpp.toml <<'TOML' -[build] -sources = [{ glob = "src/main.cpp", accel = "sycl+{sm_90}" }] -TOML -"$MCPP" build --accel 'sycl+{sm_90,sm_89},newmodel2+{opaque}' > out.log 2>&1 \ - || fail 'valid open-vocabulary request did not build' -before=$(find target -maxdepth 3 -name build.ninja | sort) -[ -n "$before" ] || fail 'no build graph was generated' -"$MCPP" build --accel 'newmodel2+{opaque},sycl+{sm_89,sm_90,sm_89}' > out.log 2>&1 \ - || fail 'equivalent request did not build' -after=$(find target -maxdepth 3 -name build.ninja | sort) -[ "$before" = "$after" ] || fail 'equivalent sets allocated different variants' -grep -q 'fingerprint changed' out.log && fail 'equivalent sets changed the fingerprint' -"$MCPP" run --accel 'sycl+{sm_89,sm_90},newmodel2+{opaque}' > out.log 2>&1 \ - || fail 'the canonical variant did not run' -echo 'PASS: accelerator requests are complete, unique and canonical' diff --git a/tests/e2e/1012_capabilities_bind_in_each_consumer_context.sh b/tests/e2e/1012_capabilities_bind_in_each_consumer_context.sh deleted file mode 100755 index c0fbdaebb..000000000 --- a/tests/e2e/1012_capabilities_bind_in_each_consumer_context.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell python3 -# Independent products may select different providers. A library requirement -# still accepts a provider supplied by its final application's sibling edge. -set -e -MCPP="${MCPP:-mcpp}" -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT -export MCPP_HOME="$work/home" -source "$(dirname "$0")/_inherit_toolchain.sh" -cd "$work" -fail() { echo "FAIL: $*"; cat out.log; exit 1; } -cat > mcpp.toml <<'TOML' -[workspace] -members = ["app-a", "app-b", "helper", "provider-a", "provider-b"] -TOML -for suffix in a b; do - mkdir -p "provider-$suffix/src" "app-$suffix/src" - cat > "provider-$suffix/mcpp.toml" < "provider-$suffix/src/anchor.c" - cat > "app-$suffix/mcpp.toml" < "app-$suffix/src/main.cpp" -done -mkdir -p helper/src -cat > helper/mcpp.toml <<'TOML' -[package] -name = "helper" -version = "0.1.0" -[features] -default = ["need-provider"] -need-provider = { requires = ["probe-interface"] } -[targets.helper] -kind = "lib" -TOML -echo 'int helper_anchor(void){return 0;}' > helper/src/anchor.c -"$MCPP" build --workspace > out.log 2>&1 || fail 'independent exclusive providers collided' -"$MCPP" run -p app-a > out.log 2>&1 || fail 'app-a did not run' -"$MCPP" run -p app-b > out.log 2>&1 || fail 'app-b did not run' - -# A standalone selection still needs its own binding. The enclosing -# applications do not supply an executable or test owned by the library. -if "$MCPP" build -p helper > out.log 2>&1; then fail 'standalone library borrowed an unselected consumer'; fi -grep -q "consumer 'helper'" out.log || fail 'standalone library lost its context' -mkdir -p helper/tests -echo 'int main(){return 0;}' > helper/tests/context.cpp -if "$MCPP" test --workspace > out.log 2>&1; then fail 'library test borrowed an application provider'; fi -grep -q "consumer 'helper'" out.log || fail 'library test lost its own context' -rm -rf helper/tests - -# Test link units and a shared provider keep the same per-consumer boundary. -for suffix in a b; do - mkdir -p "app-$suffix/tests" - echo 'int main(){return 0;}' > "app-$suffix/tests/context.cpp" -done -sed 's/kind = "lib"/kind = "shared"/' provider-a/mcpp.toml > changed.toml -mv changed.toml provider-a/mcpp.toml -"$MCPP" test --workspace > out.log 2>&1 || fail 'test/shared contexts crossed independent products' -"$MCPP" run -p app-a > out.log 2>&1 || fail 'shared provider consumer did not run' - -# A provider elsewhere in the workspace cannot satisfy a missing dependency. -cp app-a/mcpp.toml app-a/saved.toml -sed '/^provider-a =/d' app-a/saved.toml > app-a/mcpp.toml -if "$MCPP" build --workspace > out.log 2>&1; then fail 'another product supplied a missing provider'; fi -grep -q "no package provides capability 'probe-interface'" out.log || fail 'wrong missing-provider diagnosis' -grep -q "consumer 'app-a'" out.log || fail 'missing consumer context' -# Host-only providers cannot satisfy the missing target-side requirement. -cat >> app-a/mcpp.toml <<'TOML' -[build-dependencies] -provider-a = { path = "../provider-a" } -TOML -echo 'int main(){return 0;}' > app-a/build.mcpp -if "$MCPP" build --workspace > out.log 2>&1; then fail 'a host-only provider satisfied a target requirement'; fi -grep -q "no package provides capability 'probe-interface'" out.log || fail 'wrong host/target scope diagnosis' -rm app-a/build.mcpp -cp app-a/saved.toml app-a/mcpp.toml - -# A pin cannot remove the second implementation from one actual link closure. -cat >> app-a/mcpp.toml <<'TOML' -[capabilities] -probe-interface = "provider-a" -TOML -sed '/^helper =/a provider-b = { path = "../provider-b" }' app-a/mcpp.toml > changed.toml -mv changed.toml app-a/mcpp.toml -if "$MCPP" build --workspace > out.log 2>&1; then fail 'pin hid an exclusive collision'; fi -grep -qi exclusive out.log || fail 'wrong exclusive diagnosis' -grep -q "consumer 'app-a'" out.log || fail 'exclusive diagnosis omitted consumer' - -# Without exclusivity both providers may coexist; the member pin resolves its -# requirement while the other member binds implicitly to its own provider. -for suffix in a b; do - sed '/^exclusive =/d' "provider-$suffix/mcpp.toml" > changed.toml - mv changed.toml "provider-$suffix/mcpp.toml" -done -"$MCPP" build --workspace > out.log 2>&1 || fail 'member pin did not resolve local ambiguity' -"$MCPP" build --workspace --cap probe-interface=provider-b > out.log 2>&1 \ - || fail 'CLI pin did not override the member pin' -if "$MCPP" build --workspace --cap probe-interface=provider-a > out.log 2>&1; then - fail 'CLI pin escaped the second consumer closure' -fi -grep -q "consumer 'app-b'" out.log || fail 'invalid pin omitted its consumer' - -# Qualified provider identities distinguish packages sharing a short name. -mkdir -p namespaced/src -for ns in first second; do - mkdir -p "$ns/src" - cat > "$ns/mcpp.toml" < "$ns/src/anchor.c" -done -cat > namespaced/mcpp.toml <<'TOML' -[package] -name = "namespaced-consumer" -version = "0.1.0" -[features] -default = ["need"] -need = { requires = ["namespaced-interface"] } -[dependencies.first] -implementation = { path = "../first" } -[dependencies.second] -implementation = { path = "../second" } -[capabilities] -namespaced-interface = "implementation" -TOML -echo 'int main(){return 0;}' > namespaced/src/main.cpp -cd namespaced -if "$MCPP" build > out.log 2>&1; then fail 'ambiguous short provider identity was accepted'; fi -grep -q 'first:implementation' out.log || fail 'missing first qualified candidate' -grep -q 'second:implementation' out.log || fail 'missing second qualified candidate' -sed 's/= "implementation"$/= "first:implementation"/' mcpp.toml > changed.toml -mv changed.toml mcpp.toml -"$MCPP" build > out.log 2>&1 || fail 'qualified provider pin did not bind' - -echo 'PASS: capabilities bind in each consumer context' diff --git a/tests/e2e/1014_host_only_layers_do_not_select_the_target_abi.sh b/tests/e2e/1014_host_only_layers_do_not_select_the_target_abi.sh deleted file mode 100755 index c9488d0db..000000000 --- a/tests/e2e/1014_host_only_layers_do_not_select_the_target_abi.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell -# A library used by build.mcpp may supply a host layer. Its direct or -# transitive host-only instance cannot select the final artifact's ABI. -set -e -MCPP="${MCPP:-mcpp}" -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT -export MCPP_HOME="$work/home" -source "$(dirname "$0")/_inherit_toolchain.sh" -mkdir -p "$work/app/src" "$work/provider/src" "$work/adapter/src" -cd "$work" -cat > provider/mcpp.toml <<'TOML' -[package] -name = "host-provider" -version = "0.1.0" -provides = ["mcpp:kernel-abi=openkal"] -[targets.host-provider] -kind = "lib" -TOML -cat > provider/src/provider.cppm <<'CPP' -export module host.provider; -export int host_probe(){return 7;} -CPP -cat > adapter/mcpp.toml <<'TOML' -[package] -name = "host-adapter" -version = "0.1.0" -[dependencies] -host-provider = { path = "../provider" } -[targets.host-adapter] -kind = "lib" -TOML -cat > adapter/src/adapter.cppm <<'CPP' -export module host.adapter; -import host.provider; -export int adapter_probe(){return host_probe();} -CPP -cat > app/mcpp.toml <<'TOML' -[package] -name = "target-consumer" -version = "0.1.0" -[build-dependencies] -host-provider = { path = "../provider", host-module = true } -TOML -cat > app/build.mcpp <<'CPP' -import host.provider; -int main(){return host_probe()==7?0:1;} -CPP -cat > app/src/main.cpp <<'CPP' -#ifdef __OPENKAL__ -#error host_kernel_layer_leaked_to_target -#endif -int main(){return 0;} -CPP -cd app -"$MCPP" run > out.log 2>&1 || { cat out.log; echo 'FAIL: direct host layer leaked'; exit 1; } -sed 's/host-provider = { path = "..\/provider"/host-adapter = { path = "..\/adapter"/' mcpp.toml > changed.toml -mv changed.toml mcpp.toml -cat > build.mcpp <<'CPP' -import host.adapter; -int main(){return adapter_probe()==7?0:1;} -CPP -"$MCPP" run > out.log 2>&1 || { cat out.log; echo 'FAIL: transitive host layer leaked'; exit 1; } - -# The same package reached on both axes retains a genuine target instance. -cat >> mcpp.toml <<'TOML' -[dependencies] -host-provider = { path = "../provider" } -TOML -cat > src/main.cpp <<'CPP' -#ifndef __OPENKAL__ -#error target_kernel_layer_was_withdrawn -#endif -int main(){return 0;} -CPP -"$MCPP" run > out.log 2>&1 || { cat out.log; echo 'FAIL: target instance lost its layer'; exit 1; } -echo 'PASS: host-only layers do not select the target ABI' diff --git a/tests/e2e/1015_action_progress_paths_are_utf8_on_every_host.sh b/tests/e2e/1015_action_progress_paths_are_utf8_on_every_host.sh deleted file mode 100755 index 00814e6eb..000000000 --- a/tests/e2e/1015_action_progress_paths_are_utf8_on_every_host.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# requires: python3 -# Native worker/environment/file transport, including a non-ASCII project -# directory. This avoids pty and Unix signals so Windows runs it too. -set -e -MCPP="${MCPP:-mcpp}" -if command -v cygpath >/dev/null 2>&1; then - export MCPP_PROGRESS_TEST_EXE="$(cygpath -w "$(command -v "$MCPP")")" -else - export MCPP_PROGRESS_TEST_EXE="$(command -v "$MCPP")" -fi -python3 - <<'PY' -from pathlib import Path -import json,os,subprocess,sys,tempfile -with tempfile.TemporaryDirectory(prefix='mcpp-progress-') as temporary: - root=Path(temporary)/'项目-进度';(root/'src').mkdir(parents=True) - (root/'mcpp.toml').write_text('[package]\nname="portable-progress"\nversion="0.1.0"\n',encoding='utf-8') - (root/'src/main.cpp').write_text('int main(){return 0;}\n',encoding='utf-8') - worker=root/'worker.py';proof=root/'target/proof.json' - worker.write_text('''from pathlib import Path -import json,os,sys,time -file=os.environ.get('MCPP_ACTION_PROGRESS_FILE','') -assert file, 'the enabled action has no execution channel' -p=Path(file) -for seq in range(2): - state=dict(v=1,seq=seq,phase='compile',done=seq,total=1) - staged=p.with_suffix('.tmp');staged.write_text(json.dumps(state),encoding='utf-8') - os.replace(staged,p);time.sleep(.25) -Path(sys.argv[1]).write_text(json.dumps(dict(channel=file,snapshot=state)),encoding='utf-8') -''',encoding='utf-8') - quote=lambda value:json.dumps(str(value),ensure_ascii=False) - (root/'build.mcpp').write_text('''import std; -import mcpp; -int main(){ - mcpp::action action; - action.id="portable-worker";action.role=mcpp::roles::check;action.progress=true; - const auto stamp=std::string(mcpp::out_dir())+"/worker.stamp"; - action.arg(PYTHON).arg(WORKER).arg(PROOF).input(WORKER).output(stamp.c_str()); - action.submit(); -} -'''.replace('PYTHON',quote(sys.executable)).replace('WORKER',quote(worker)).replace('PROOF',quote(proof)),encoding='utf-8') - exe=os.environ['MCPP_PROGRESS_TEST_EXE'] - result=subprocess.run([exe,'build'],cwd=root,env=dict(os.environ,MCPP_PROGRESS='plain'),capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=180) - assert result.returncode==0,result.stdout+result.stderr - result=json.loads(proof.read_text(encoding='utf-8')) - assert '项目-进度' in result['channel'],result - assert result['snapshot']==dict(v=1,seq=1,phase='compile',done=1,total=1),result - assert not Path(result['channel']).exists(),'execution channel was not cleaned' -print('PASS: native action progress paths are UTF-8 and cleaned') -PY diff --git a/tests/e2e/1016_a_killed_build_does_not_race_the_next_one.sh b/tests/e2e/1016_a_killed_build_does_not_race_the_next_one.sh deleted file mode 100755 index 4d200ae55..000000000 --- a/tests/e2e/1016_a_killed_build_does_not_race_the_next_one.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# A BUILD WHOSE mcpp IS KILLED DOES NOT RACE THE NEXT ONE (2026.11.7.1, design -# 2026-11-07 §4.1.2). Killing the mcpp that holds the build directory leaves -# its compilers running. The next build waited only for the directory lock, so -# it entered while those compilers still wrote BMIs and objects: 2026.11.5.1 -# failed 3 of 75 such rounds under the default schedule and 12 of 15 under the -# split schedule. Every compiler is now a participant of its build's -# generation, and the next build enters only after they end. Both schedules, -# twelve rounds each: the waiting build succeeds and the build after it does -# nothing. -set -e -MCPP="${MCPP:-mcpp}" -TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT -make_project() { # make_project - mkdir -p "$1/src" - printf '[package]\nname = "kg"\nversion = "0.1.0"\nstandard = "c++23"\n[toolchain]\ndefault = "gcc@16.1.0"\n' > "$1/mcpp.toml" - [ "$2" = split ] && printf '[build]\nbmi_schedule = "on"\n' >> "$1/mcpp.toml" - for i in $(seq 1 24); do - printf 'export module m%d;\nimport std;\nexport int f%d(){ std::vector v(%d); return (int)v.size(); }\n' "$i" "$i" "$i" > "$1/src/m$i.cppm" - done - { echo 'import std;'; for i in $(seq 1 24); do echo "import m$i;"; done - echo 'int main(){ std::println("{}", f1()+f24()); }'; } > "$1/src/main.cpp" -} -for schedule in default split; do - dir="$TMP/$schedule"; make_project "$dir" "$schedule" - ( cd "$dir" && "$MCPP" build > "$TMP/warm.log" 2>&1 ) || { cat "$TMP/warm.log"; exit 1; } - for round in $(seq 1 12); do - rm -rf "$dir/target" - ( cd "$dir" && exec "$MCPP" build > "$TMP/a.log" 2>&1 ) & a=$! - ( sleep 0.2; cd "$dir"; rc=0; "$MCPP" build > "$TMP/b.log" 2>&1 || rc=$?; echo "$rc" > "$TMP/b.rc" ) & b=$! - sleep 0.9; kill -9 "$a" 2>/dev/null || true; wait "$a" 2>/dev/null || true - wait "$b" || true - if [ "$(cat "$TMP/b.rc")" != 0 ]; then - echo "FAIL: $schedule round $round: the build after the killed one failed"; cat "$TMP/b.log"; exit 1 - fi - ( cd "$dir" && "$MCPP" build > "$TMP/c.log" 2>&1 ) || { cat "$TMP/c.log"; exit 1; } - if grep -q "Compiling" "$TMP/c.log"; then - echo "FAIL: $schedule round $round: the following build compiled again"; cat "$TMP/c.log"; exit 1 - fi - out=$(cd "$dir" && "$MCPP" run 2>/dev/null | tail -1) - [ "$out" = 25 ] || { echo "FAIL: $schedule round $round: the program printed '$out'"; exit 1; } - done - echo "OK: $schedule: 12 killed builds, each followed by a successful build and a no-op" -done diff --git a/tests/e2e/1017_a_system_msvc_builds_the_build_program_of_a_cross_target.sh b/tests/e2e/1017_a_system_msvc_builds_the_build_program_of_a_cross_target.sh deleted file mode 100755 index d1ac40977..000000000 --- a/tests/e2e/1017_a_system_msvc_builds_the_build_program_of_a_cross_target.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# requires: windows msvc -# resources: machine -# `msvc@system` IS LOCATED FOR A BUILD PROGRAM, NEVER INSTALLED (2026.11.7.1). -# On a Windows machine whose default toolchain is `msvc@system`, a project with -# a build program built for a bare cross target resolved the build program's -# host toolchain through the package index and looked for an `msvc@system` -# package there (openkal's 32-bit layout probe, design 2026-11-07 §4.1.3). The -# build program is now compiled by the located Visual Studio, as a native build -# of the same machine is, and its directive reaches the cross build. -set -e - -CONF="${MCPP_HOME:-$HOME/.mcpp}/config.toml" -ORIG_DEFAULT="" -if [[ -f "$CONF" ]]; then - ORIG_DEFAULT=$(sed -n '/^\[toolchain\]/,/^\[/p' "$CONF" \ - | grep -E '^default[[:space:]]*=' | head -1 | cut -d'"' -f2 || true) -fi -TMP=$(mktemp -d) -restore() { - if [[ -n "$ORIG_DEFAULT" ]]; then - "$MCPP" toolchain default "$ORIG_DEFAULT" >/dev/null 2>&1 || true - fi - rm -rf "$TMP" -} -trap restore EXIT - -"$MCPP" toolchain default msvc >/dev/null 2>&1 \ - || { echo "FAIL: msvc@system not selectable"; exit 1; } - -mkdir -p "$TMP/probe/src" "$TMP/probe/inc" -cd "$TMP/probe" -cat > mcpp.toml <<'EOF2' -[package] -name = "layout" -version = "0.1.0" -EOF2 -cat > build.mcpp <<'EOF2' -#include -int main() { - std::puts("mcpp:include-dir=inc"); - std::puts("mcpp:rerun-if-changed=build.mcpp"); - return 0; -} -EOF2 -cat > inc/layout.h <<'EOF2' -struct pair_layout { unsigned size; unsigned long long stamp; }; -EOF2 -cat > src/main.cpp <<'EOF2' -#include "layout.h" -static_assert(__builtin_offsetof(pair_layout, stamp) == 8); -extern "C" void _start() { for (;;) {} } -EOF2 - -out=$("$MCPP" build --target riscv64-none-elf 2>&1) \ - || { echo "FAIL: the cross build with a build program failed:"; echo "$out"; exit 1; } -grep -q "host toolchain for build.mcpp: msvc@system" <<< "$out" \ - || { echo "FAIL: the build program was not compiled by the located msvc@system:"; echo "$out"; exit 1; } -if grep -qi "xim:msvc@system\|msvc@system.*not found in the" <<< "$out"; then - echo "FAIL: msvc@system was looked up as a package:"; echo "$out"; exit 1 -fi -echo "OK: msvc@system compiled the build program of a riscv64-none-elf build" diff --git a/tests/e2e/1018_a_package_and_a_namespaced_package_of_one_name_both_link.sh b/tests/e2e/1018_a_package_and_a_namespaced_package_of_one_name_both_link.sh deleted file mode 100755 index 59ddadbd2..000000000 --- a/tests/e2e/1018_a_package_and_a_namespaced_package_of_one_name_both_link.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# A PACKAGE AND A NAMESPACED PACKAGE OF ONE NAME BOTH LINK (mcpp#896). -# `foo` and `ns.foo` are two packages. The plan found a package by its bare -# name too, and the bare `foo` of `ns.foo` replaced `foo`'s, so a member that -# depended on both reached `ns.foo` twice and its link left `foo`'s objects -# out (`undefined reference to foo::one`). Each package is now reached by the -# edge that names it. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -MCPP="${MCPP:-mcpp}" - -cd "$TMP" -mkdir -p foo/src nsfoo/src app/src -printf '[workspace]\nmembers = ["foo", "nsfoo", "app"]\n' > mcpp.toml -printf '[package]\nname = "foo"\nversion = "0.1.0"\n[targets.foo]\nkind = "lib"\n[build]\nsources = ["src/*.cppm", "src/*.cpp"]\n' > foo/mcpp.toml -printf '[package]\nnamespace = "ns"\nname = "foo"\nversion = "0.1.0"\n[targets.ns-foo]\nkind = "lib"\n[build]\nsources = ["src/*.cppm", "src/*.cpp"]\n' > nsfoo/mcpp.toml -printf '[package]\nname = "app"\nversion = "0.1.0"\n[targets.app]\nkind = "bin"\nmain = "src/main.cpp"\n[dependencies]\nfoo = { path = "../foo" }\nns.foo = { path = "../nsfoo" }\n' > app/mcpp.toml -printf 'export module foo;\nexport namespace foo { int one(); }\n' > foo/src/foo.cppm -printf 'module foo;\nnamespace foo { int one() { return 1; } }\n' > foo/src/foo.cpp -printf 'export module ns.foo;\nexport namespace ns::foo { int two(); }\n' > nsfoo/src/foo.cppm -printf 'module ns.foo;\nnamespace ns::foo { int two() { return 2; } }\n' > nsfoo/src/foo.cpp -printf 'import std;\nimport foo;\nimport ns.foo;\nint main() { std::println("sum {}", foo::one() + ns::foo::two()); }\n' > app/src/main.cpp - -"$MCPP" run -p app > run.log 2>&1 || fail "the member reaching foo and ns.foo did not link and run" run.log -grep -q "sum 3" run.log || fail "the program's output" run.log -echo "PASS: 1018 a package and a namespaced package of one name both link" diff --git a/tests/e2e/1019_a_later_edge_walks_the_feature_deps_it_activates.sh b/tests/e2e/1019_a_later_edge_walks_the_feature_deps_it_activates.sh deleted file mode 100755 index 8270f550c..000000000 --- a/tests/e2e/1019_a_later_edge_walks_the_feature_deps_it_activates.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# A LATER EDGE WALKS THE FEATURE-DEPS IT ACTIVATES (mcpp#897). -# `v` asks `a` for `f1`, and its feature `x` asks `a` for `f2` through -# `[feature-deps.x]`; `s` asks `v` for `x`. With `v` selected beside `s`, `v` -# was walked as a member first, without `x`, and `s`'s edge later added `x` to -# it: `x`'s sources were compiled, `x`'s feature-deps never reached the graph, -# and `a` was built without `f2` (`a.f2: failed to read compiled module`). The -# feature-deps and forwards of a feature a later edge activates are walked now. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -MCPP="${MCPP:-mcpp}" - -cd "$TMP" -mkdir -p a/src/f1 a/src/f2 v/src/base v/src/x s/src -printf '[workspace]\nmembers = ["a", "v", "s"]\n' > mcpp.toml -cat > a/mcpp.toml <<'EOF' -[package] -name = "a" -version = "0.1.0" -[targets.a] -kind = "lib" -[build] -sources = [] -[features] -f1 = { sources = ["src/f1/*.cppm"] } -f2 = { sources = ["src/f2/*.cppm"] } -EOF -printf 'export module a.f1;\nexport int f1() { return 1; }\n' > a/src/f1/f1.cppm -printf 'export module a.f2;\nexport int f2() { return 2; }\n' > a/src/f2/f2.cppm -cat > v/mcpp.toml <<'EOF' -[package] -name = "v" -version = "0.1.0" -[targets.v] -kind = "lib" -[build] -sources = ["src/base/*.cppm"] -[dependencies] -a = { path = "../a", features = ["f1"] } -[features] -x = { sources = ["src/x/*.cppm"] } -[feature-deps.x] -a = { path = "../a", features = ["f2"] } -EOF -printf 'export module v;\nimport a.f1;\nexport int vv() { return f1(); }\n' > v/src/base/v.cppm -printf 'export module v.x;\nimport a.f2;\nexport int vx() { return f2(); }\n' > v/src/x/x.cppm -cat > s/mcpp.toml <<'EOF' -[package] -name = "s" -version = "0.1.0" -[targets.s] -kind = "bin" -main = "src/main.cpp" -[dependencies] -v = { path = "../v", features = ["x"] } -EOF -printf 'import std;\nimport v;\nimport v.x;\nint main() { std::println("sum {}", vv() + vx()); }\n' > s/src/main.cpp - -for order in "-p s -p v" "-p v -p s"; do - rm -rf target - "$MCPP" build $order > build.log 2>&1 || fail "mcpp build $order" build.log -done -"$MCPP" run -p s > run.log 2>&1 || fail "the program" run.log -grep -q "sum 3" run.log || fail "the program's output" run.log -echo "PASS: 1019 a later edge walks the feature-deps it activates" diff --git a/tests/e2e/101_cppfly_llvm_soft.sh b/tests/e2e/101_cppfly_llvm_soft.sh deleted file mode 100755 index b2e17936e..000000000 --- a/tests/e2e/101_cppfly_llvm_soft.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# 101_cppfly_llvm_soft.sh — standard = "c++fly" soft path on Clang (design -# §5.4): upstream Clang has no reflection/contracts, so the build must still -# succeed at the family's latest level (-std=c++2c), with the skipped gates -# reported in the summary — mcpp diagnostics instead of raw compiler errors. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -source "$(dirname "$0")/_llvm_env.sh" -if [[ ! -d "$LLVM_ROOT" ]]; then - echo "SKIP: xlings llvm@${LLVM_VERSION:-none} is not installed" - exit 77 -fi - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" - -cat > mcpp.toml < src/main.cpp <<'EOF' -import std; -int main() { std::println("fly soft ok"); } -EOF - -"$MCPP" build --no-cache > "$TMP/build.log" 2>&1 || { - cat "$TMP/build.log" - echo "FAIL: c++fly soft-path build failed on llvm" - exit 1 -} - -grep -q 'c++fly on clang' "$TMP/build.log" || { - cat "$TMP/build.log" - echo "FAIL: build log missing c++fly summary line" - exit 1 -} -grep -q 'skipped: .*reflection' "$TMP/build.log" || { - cat "$TMP/build.log" - echo "FAIL: summary does not report reflection as skipped on clang" - exit 1 -} - -build_ninja="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE '^cxxflags = -std=c\+\+2c' "$build_ninja" || { - grep '^cxxflags' "$build_ninja" || true - echo "FAIL: clang c++fly did not resolve to -std=c++2c" - exit 1 -} - -# stdlib gate: libc++ (linux/macos llvm payloads) → enabled + flag present; -# clang on MSVC STL (windows) → reported skipped + flag absent. Assert the -# summary and the emitted flags AGREE rather than hardcoding one stdlib. -if grep -q 'experimental-library (-fexperimental-library)' "$TMP/build.log"; then - grep -q -- '-fexperimental-library' "$build_ninja" || { - echo "FAIL: summary claims experimental-library but build.ninja lacks the flag" - exit 1 - } -else - grep -q 'skipped: .*experimental-library' "$TMP/build.log" || { - cat "$TMP/build.log" - echo "FAIL: experimental-library neither enabled nor reported skipped" - exit 1 - } - if grep -q -- '-fexperimental-library' "$build_ninja"; then - echo "FAIL: -fexperimental-library emitted for a non-libc++ stdlib" - exit 1 - fi -fi - -binary=$(find target -type f \( -path '*/bin/flysoft' -o -path '*/bin/flysoft.exe' \) | head -1) -out=$("$binary") -[[ "$out" == *"fly soft ok"* ]] || { - echo "FAIL: runtime output: $out" - exit 1 -} - -echo "PASS: c++fly soft path on clang — latest level + skipped summary" diff --git a/tests/e2e/1020_a_platform_version_group_chooses_the_version.sh b/tests/e2e/1020_a_platform_version_group_chooses_the_version.sh deleted file mode 100755 index 591856735..000000000 --- a/tests/e2e/1020_a_platform_version_group_chooses_the_version.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell gcc fresh-sandbox -# A PLATFORM'S VERSION GROUP CHOOSES THE VERSION (SPEC-013 §4.5, 2026.11.7.1). -# `[platform.versions]` used to be checked after the walk only: a request -# resolved to the newest version its range admits, and the build was refused -# when that was not the group's. The group now takes part in resolution. -# -# A. A range that admits the group's version resolves to it, though the index -# holds a newer one. -# B. A request the group's version does not satisfy is refused before -# anything is built, naming the request, its requester and the group. -# C. A development tree (a path dependency) is not bound by the group. -# -# The index is a local path index whose payloads are pre-seeded, so nothing is -# downloaded; the platform is a local directory for this machine's triples. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/plat" "$TMP/proj/src" "$TMP/proj/local-index/pkgs/a" "$TMP/fam-dev/src" -cat > "$TMP/plat/mcpp.toml" <<'TOML' -[package] -name = "group-platform" -version = "0.1.0" - -[platform] -targets = ["x86_64-linux-gnu", "aarch64-linux-gnu", "aarch64-macos", "x86_64-macos"] - -[platform.versions] -fam = "1.1.0" -TOML - -cd "$TMP/proj" -cat > local-index/pkgs/a/acme.fam.lua <<'EOF' -package = { - spec = "1", namespace = "acme", name = "acme.fam", - description = "version-group fixture", licenses = {"MIT"}, type = "package", - xpm = { - linux = { ["1.2.0"] = { url = "u", sha256 = "0" }, ["1.1.0"] = { url = "u", sha256 = "0" }, ["1.0.0"] = { url = "u", sha256 = "0" } }, - macosx = { ["1.2.0"] = { url = "u", sha256 = "0" }, ["1.1.0"] = { url = "u", sha256 = "0" }, ["1.0.0"] = { url = "u", sha256 = "0" } }, - }, - mcpp = { language = "c++23", import_std = false, sources = { "src/fam.cppm" }, - targets = { ["fam"] = { kind = "lib" } }, deps = {} }, -} -EOF -# Only the group's version has a payload: resolving another one fails to install. -mkdir -p .mcpp/.xlings/data/xpkgs/acme.fam/1.1.0/src -printf 'export module fam;\nexport int fam_value() { return 110; }\n' > .mcpp/.xlings/data/xpkgs/acme.fam/1.1.0/src/fam.cppm -printf 'import fam;\nint main() { return fam_value() == 110 ? 0 : 1; }\n' > src/main.cpp - -use() { # $1: the request of acme.fam - cat > mcpp.toml < a.log 2>&1 || fail "A: the group's version was not taken" a.log -grep -q "fam =1.1.0 → v1.1.0" a.log || fail "A: the request did not resolve to the group's version" a.log -"$MCPP" run > a-run.log 2>&1 || fail "A: the program" a-run.log -echo "ok: A, a range resolves to the group's version though the index holds a newer one" - -# B -use '"^1.2.0"' -rm -rf target -if "$MCPP" build > b.log 2>&1; then fail "B: a request outside the group was built" b.log; fi -grep -q "fam is requested as '^1.2.0' by proj" b.log || fail "B: the refusal does not name the request" b.log -grep -q "is a version group" b.log && grep -q "names fam 1.1.0" b.log || fail "B: the refusal does not name the group" b.log -[ -z "$(find target -name '*.o' 2>/dev/null)" ] || fail "B: something was compiled before the refusal" b.log -echo "ok: B, a request the group does not admit is refused naming both" - -# C -printf '[package]\nnamespace = "acme"\nname = "fam"\nversion = "1.2.0"\n[targets.fam]\nkind = "lib"\n[build]\nsources = ["src/fam.cppm"]\n' > "$TMP/fam-dev/mcpp.toml" -printf 'export module fam;\nexport int fam_value() { return 110; }\n' > "$TMP/fam-dev/src/fam.cppm" -use '{ path = "../fam-dev" }' -rm -rf target -"$MCPP" build > c.log 2>&1 || fail "C: a development tree was bound by the group" c.log -echo "ok: C, a path dependency is not bound by the group" -echo "PASS: 1020 a platform version group chooses the version" diff --git a/tests/e2e/1021_a_platform_series_is_locked_and_skips_newer_engines.sh b/tests/e2e/1021_a_platform_series_is_locked_and_skips_newer_engines.sh deleted file mode 100755 index 51f91c239..000000000 --- a/tests/e2e/1021_a_platform_series_is_locked_and_skips_newer_engines.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell gcc fresh-sandbox -# A PLATFORM SELECTED AS A SERIES (SPEC-013 §4.6, 2026.11.7.1). A project -# selects one (`"@^0"`); the target table's default is one release -# from 2026.11.7.2 (design 2026-11-07 §4.10): -# -# A. The series takes its newest release whose `[package] mcpp` floor this -# mcpp meets: a release made for a newer mcpp is skipped and named. -# B. mcpp.lock records the release; a newer one published later is not taken -# while the lock names one the series admits. -# C. `mcpp update` drops the record, and the next build takes the newer one. -# -# The index is a local path index whose payloads are pre-seeded; the platform -# releases are packages of it for this machine's triples. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj/src" "$TMP/proj/local-index/pkgs/a" -cd "$TMP/proj" -entries="" -release() { # $1: version, $2: the mcpp floor ('' for none) - local dir=".mcpp/.xlings/data/xpkgs/acme.plat/$1" - mkdir -p "$dir" - { - printf '[package]\nnamespace = "acme"\nname = "plat"\nversion = "%s"\n' "$1" - [ -n "$2" ] && printf 'mcpp = ">=%s"\n' "$2" - printf '\n[platform]\ntargets = ["x86_64-linux-gnu", "aarch64-linux-gnu", "aarch64-macos", "x86_64-macos"]\n' - printf '\n[platform.build]\ncxxflags = ["-DPLAT_RELEASE=\\"%s\\""]\n' "$1" - } > "$dir/mcpp.toml" - entries="[\"$1\"] = { url = \"u\", sha256 = \"0\" }, $entries" - cat > local-index/pkgs/a/acme.plat.lua < mcpp.toml <<'EOF' -[package] -name = "proj" -version = "0.1.0" - -[indices] -acme = { path = "local-index" } - -[target.'cfg(any(linux, macos))'] -platform = "acme.plat@^0" -EOF -printf '#include \nint main() { std::printf("release %%s\\n", PLAT_RELEASE); }\n' > src/main.cpp - -# A -"$MCPP" run > a.log 2>&1 || fail "A: build" a.log -grep -q "plat v0.2.0 needs mcpp >= 2099.1.1.1" a.log || fail "A: the release for a newer mcpp was not named" a.log -grep -q "release 0.1.0" a.log || fail "A: the series did not take 0.1.0" a.log -grep -A4 '\[package."acme.plat"\]' mcpp.lock | grep -q 'version = "0.1.0"' || fail "A: mcpp.lock does not record 0.1.0" mcpp.lock -echo "ok: A, the series skips a release made for a newer mcpp" - -# B -release 0.1.1 '' -rm -rf target # the graph is planned again, against the index that now holds 0.1.1 -"$MCPP" run > b.log 2>&1 || fail "B: build" b.log -grep -q "release 0.1.0" b.log || fail "B: a newer release was taken over the lock" b.log -grep -q "(mcpp.lock)" b.log || fail "B: the build does not say the lock chose" b.log -echo "ok: B, the lock keeps the recorded release" - -# C -"$MCPP" update > c-update.log 2>&1 || fail "C: mcpp update" c-update.log -rm -rf target -"$MCPP" run > c.log 2>&1 || fail "C: build" c.log -grep -q "release 0.1.1" c.log || fail "C: after mcpp update the newer release was not taken" c.log -echo "ok: C, mcpp update moves the series forward" -echo "PASS: 1021 a platform series is locked and skips newer engines" diff --git a/tests/e2e/1022_a_taken_over_directory_keeps_no_compiler_record_of_another.sh b/tests/e2e/1022_a_taken_over_directory_keeps_no_compiler_record_of_another.sh deleted file mode 100755 index 1c2ff6210..000000000 --- a/tests/e2e/1022_a_taken_over_directory_keeps_no_compiler_record_of_another.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1022 -- a build directory taken over keeps no compiler record of another -# directory (2026.11.7.1). A split compile (`bmi_schedule`, GCC's detach-codegen) -# keeps its slot's records beside each object (`.sched.*`), and the -# completion certificate names the object by its absolute path. A new -# fingerprint of a configuration's lineage takes its predecessor's directory -# over (design 2026-10-31 §8), and the certificates there named the -# predecessor's objects: the next build refused them at admission ("cached -# compiler certificate does not name its own object"), and every build of the -# configuration after an mcpp upgrade failed so, until `mcpp clean`. -# -# The predecessor is made with plain files: a dev build's directory, renamed, -# whose `lineage` is the release configuration's, so the release build takes it -# over while its certificates name the dev build's objects. -# -# Criteria: -# A. The release build takes the predecessor over and builds. -# B. No record of the predecessor is left beside the objects. -# C. A certificate of another directory found at admission -- the directory -# moved, or restored elsewhere -- is no record: the build compiles the -# object again rather than failing. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -mkdir -p app/src && cd app -printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[build]\nbmi_schedule = "on"\n' > mcpp.toml -printf 'export module greet;\nexport int answer() { return 42; }\n' > src/greet.cppm -printf 'import greet;\nint main() { return answer() == 42 ? 0 : 1; }\n' > src/main.cpp -config() { ls -d target/*/*/ | grep -E '/[0-9a-f]{16}/$' | head -1 | sed 's#/$##'; } - -"$MCPP" build --release > r0.log 2>&1 || fail "the first release build failed" r0.log -release=$(config); cp "$release/lineage" release.lineage; rm -rf "$release" -"$MCPP" build > d0.log 2>&1 || fail "the dev build failed" d0.log -dev=$(config) -ls "$dev"/obj/*.sched.completed > /dev/null 2>&1 || fail "the split compile left no certificate" <(find "$dev/obj") -pred="$(dirname "$dev")/0000000000000000" -mv "$dev" "$pred"; cp release.lineage "$pred/lineage" - -# A -printf 'import greet;\nint main() { return answer() == 42 ? 0 : 2; }\n' > src/main.cpp -"$MCPP" build --release > a.log 2>&1 || fail "the build that took the predecessor over failed" a.log -[ ! -d "$pred" ] || fail "the predecessor was not taken over" <(ls "$(dirname "$pred")") -taken=$(config) -echo "ok: the release build took the predecessor over and built" - -# B -if grep -rl "/0000000000000000/" "$taken"/obj --include='*.sched*' > /dev/null 2>&1; then - fail "a record of the predecessor was left beside the objects" <(grep -rl "/0000000000000000/" "$taken"/obj) -fi -echo "ok: no record of the predecessor is left" - -# C -moved="$TMP/moved-app"; cp -a "$TMP/app" "$moved"; cd "$moved" -printf 'export module greet;\nexport int answer() { return 41 + 1; }\n' > src/greet.cppm -"$MCPP" build --release > c.log 2>&1 || fail "the build of a moved directory failed" c.log -echo "ok: a certificate of another directory is no record" - -echo "PASS: 1022 a taken over directory keeps no compiler record of another" diff --git a/tests/e2e/1023_msvc_objects_carry_their_own_debug_information.sh b/tests/e2e/1023_msvc_objects_carry_their_own_debug_information.sh deleted file mode 100755 index b583a3ed2..000000000 --- a/tests/e2e/1023_msvc_objects_carry_their_own_debug_information.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc -# cl.exe OBJECTS CARRY THEIR OWN DEBUG INFORMATION (2026.11.7.1). -# A dev build compiled with `/Zi /FS`: every compile of a directory wrote one -# PDB through mspdbsrv.exe, which the first cl.exe started and which outlives -# it. Each compile runs in a process unit that retires with it, so retiring -# the unit that held the server ended it under the concurrent compiles, and -# one failed with C1090, PDB API error 23 (258, intermittently). Debug -# information is now `/Z7`, in each object, and a shared-PDB word in the -# manifest is refused before anything compiles. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -mkdir -p "$TMP/many/src" -cd "$TMP/many" -cat > mcpp.toml <<'EOF2' -[package] -name = "many" -version = "0.1.0" -[toolchain] -windows = "msvc@system" -EOF2 -# Enough units that compiles overlap, each with debug information. -for k in $(seq 1 16); do - printf 'int part%d(int v) { return v + %d; }\n' "$k" "$k" > "src/part$k.cpp" -done -{ - for k in $(seq 1 16); do printf 'int part%d(int);\n' "$k"; done - printf 'int main() { int s = 0;\n' - for k in $(seq 1 16); do printf ' s += part%d(0);\n' "$k"; done - printf ' return s == 136 ? 0 : 1; }\n' -} > src/main.cpp - -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "FAIL: a dev build of overlapping cl.exe compiles did not build"; exit 1; } -nj="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -q '/Z7' "$nj" || { - grep -n 'cxxflags\|/Z' "$nj" | head - echo "FAIL: the dev profile's cl.exe compile does not write /Z7 debug information"; exit 1; } -if grep -qE '(^| )/(Zi|FS)( |$)' "$nj"; then - grep -nE '/(Zi|FS)' "$nj" | head - echo "FAIL: a cl.exe compile still writes a shared PDB"; exit 1 -fi -"$MCPP" run > run.log 2>&1 || { - cat run.log; echo "FAIL: the program of the dev build did not run"; exit 1; } - -# A shared-PDB word in the manifest is refused before anything compiles. -cat >> mcpp.toml <<'EOF2' -[build] -cxxflags = ["/Zi"] -EOF2 -if "$MCPP" build > refused.log 2>&1; then - cat refused.log - echo "FAIL: [build] cxxflags = [\"/Zi\"] built on cl.exe"; exit 1 -fi -grep -q 'mspdbsrv' refused.log && grep -q '/Z7' refused.log || { - cat refused.log - echo "FAIL: the refusal does not say why /Zi is refused and what to write"; exit 1; } -if grep -q 'Compiling' refused.log; then - cat refused.log; echo "FAIL: the refusal came after compiling began"; exit 1 -fi -echo "PASS: cl.exe objects carry their own debug information; /Zi is refused" diff --git a/tests/e2e/1024_a_launch_description_takes_the_fast_path.sh b/tests/e2e/1024_a_launch_description_takes_the_fast_path.sh deleted file mode 100755 index ef85c2b95..000000000 --- a/tests/e2e/1024_a_launch_description_takes_the_fast_path.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# requires: python3 -# 1024 -- `mcpp run --print-launch` takes the fast path (design 2026-11-07 -# §4.9, D19). The full path records the launch it composes in the -# configuration's directory; with nothing changed, the fast path describes the -# launch from that record when the launch it composes itself is the recorded one. -# -# Criteria: -# A. Unchanged: the second description is the first, with this request's -# program arguments, and is made without resolving (no `Resolving` line). -# B. A changed source is compiled and the launch is described the same way. -# C. A record that is not the fast path's own composition is not used: the full -# path describes the launch and records it again. -# D. `--message-format json`: every line of standard output is a JSON document, -# a rebuild reports `progress`, and the last line is the `mcpp.launch` -# envelope. Without `--print-launch` it is refused, exit 2. -# E. `--protocol-version` states `launch-stream`. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -PY=python3 - -cd "$TMP" -"$MCPP" new app > /dev/null -cd app -"$MCPP" build > build.log 2>&1 || fail "the project does not build" build.log - -# A. Where `mcpp run` itself takes the fast path here (some rows, a stated -# target base for one, always plan), so does the description. -"$MCPP" run --print-launch > full.json 2> full.err || fail "A: the first description failed" full.err -"$MCPP" run > plain.out 2> plain.err || fail "A: mcpp run failed" plain.err -fast_here=1 -grep -q 'Resolving' plain.err && fast_here=0 -"$MCPP" run --print-launch -- one two > fast.json 2> fast.err || fail "A: the second description failed" fast.err -if [[ $fast_here -eq 1 ]] && grep -q 'Resolving' fast.err; then - "$MCPP" run --print-launch --verbose > /dev/null 2> why.err || true - grep 'fast-path' why.err > fast.why || true - fail "A: the unchanged description resolved again (the full path) where mcpp run does not" fast.why fast.err plain.err -fi -$PY - full.json fast.json <<'PY' || fail "A: the fast description is not the full path's" full.json fast.json -import json, sys -full, fast = (json.load(open(p)) for p in sys.argv[1:3]) -assert fast["kind"] == "mcpp.launch", fast -f, g = full["data"], fast["data"] -assert g["argv"][-2:] == ["one", "two"], g["argv"] -g = dict(g, argv=g["argv"][:-2]) -assert f == g, (f, g) -PY - -# B -src=$(ls src/*.cpp | head -1) -printf '\n// changed\n' >> "$src" -"$MCPP" run --print-launch > changed.json 2> changed.err || fail "B: the changed description failed" changed.err -grep -q 'Compiling' changed.err || fail "B: the changed source was not compiled" changed.err -$PY - full.json changed.json <<'PY' || fail "B: the description after a change differs" full.json changed.json -import json, sys -full, changed = (json.load(open(p))["data"] for p in sys.argv[1:3]) -assert full == changed, (full, changed) -PY - -# C -record=$(find target -path '*/launch/*.json' | head -1) -[[ -n "$record" ]] || fail "C: no launch is recorded in the configuration's directory" -$PY - "$record" <<'PY' -import json, sys -r = json.load(open(sys.argv[1])); r["env"]["MCPP_E2E_FOREIGN"] = "1" -json.dump(r, open(sys.argv[1], "w")) -PY -"$MCPP" run --print-launch > again.json 2> again.err || fail "C: the description failed" again.err -[[ $fast_here -eq 0 ]] || grep -q 'Resolving' again.err || fail "C: a foreign record was used by the fast path" again.err -grep -q MCPP_E2E_FOREIGN again.json && fail "C: the foreign field reached the description" again.json -grep -q MCPP_E2E_FOREIGN "$record" && fail "C: the full path did not record the launch again" "$record" - -# D -printf '\n// changed again\n' >> "$src" -"$MCPP" run --print-launch --message-format json > stream.ndjson 2> stream.err || fail "D: the streamed description failed" stream.err -$PY - stream.ndjson <<'PY' || fail "D: the stream is not NDJSON ending in the envelope" stream.ndjson -import json, sys -lines = [json.loads(l) for l in open(sys.argv[1]) if l.strip()] -assert any(l.get("event") == "progress" for l in lines[:-1]), lines -assert lines[-1]["kind"] == "mcpp.launch" and lines[-1]["data"]["argv"], lines[-1] -PY -set +e -"$MCPP" run --message-format json > refused.out 2> refused.err -rc=$? -set -e -[[ $rc -eq 2 ]] || fail "D: --message-format json without --print-launch exited $rc, not 2" refused.err -grep -q 'print-launch' refused.err || fail "D: the refusal does not name --print-launch" refused.err - -# E -"$MCPP" --protocol-version > e.out 2>&1 || fail "E: --protocol-version failed" e.out -grep -q '"launch-stream"' e.out || fail "E: launch-stream is not stated" e.out - -echo "PASS: 1024 a launch description takes the fast path" diff --git a/tests/e2e/1025_the_default_platform_is_one_release.sh b/tests/e2e/1025_the_default_platform_is_one_release.sh deleted file mode 100755 index 35869b3d9..000000000 --- a/tests/e2e/1025_the_default_platform_is_one_release.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm python3 -# 1025 -- THE DEFAULT PLATFORM IS ONE RELEASE (design 2026-11-07 §4.10, D20). -# The target table names the openkal platform release this mcpp was qualified -# with, so a project that states none builds the same versions at any time, -# with or without mcpp.lock; a project selects another release in its manifest. -# -# Criteria: -# A. A project that states no platform builds `x86_64-openkal` over -# openkal-platform 0.27.1, from the target table, and mcpp.lock records it. -# B. A lock that records another release of it does not decide: the build -# says which it takes and which the lock recorded, and records 0.27.1. -# C. A platform the project states is taken as it is (a series here). -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -PY=python3 - -cd "$TMP" -"$MCPP" new app > /dev/null 2>&1 -cd app -locked() { $PY - <<'PY' -import re -text = open("mcpp.lock").read() -m = re.search(r'\[package\."openkal-platform"\][^\[]*?version\s*=\s*"([^"]+)"', text) -print(m.group(1) if m else "") -PY -} - -# A -"$MCPP" build --target x86_64-openkal > a.log 2>&1 || fail "A: the openkal build failed" a.log -grep -q 'Platform *openkal-platform 0.27.1' a.log || fail "A: the build does not use openkal-platform 0.27.1" a.log -[[ "$(locked)" == "0.27.1" ]] || fail "A: mcpp.lock does not record 0.27.1" mcpp.lock - -# B -$PY - <<'PY' -import re -text = open("mcpp.lock").read() -text = re.sub(r'(\[package\."openkal-platform"\][^\[]*?version\s*=\s*")0\.27\.1(")', r'\g<1>0.24.3\2', text, count=1) -open("mcpp.lock", "w").write(text) -PY -[[ "$(locked)" == "0.24.3" ]] || fail "B: the fixture could not edit mcpp.lock" mcpp.lock -"$MCPP" build --target x86_64-openkal > b.log 2>&1 || fail "B: the openkal build failed" b.log -grep -q "default; mcpp.lock recorded v0.24.3" b.log || fail "B: the build does not say which release it takes over the lock's" b.log -[[ "$(locked)" == "0.27.1" ]] || fail "B: mcpp.lock was not rewritten to 0.27.1" mcpp.lock - -# C -cat >> mcpp.toml <<'EOT' - -[target.'cfg(os = "openkal")'] -platform = "openkal-platform@^0.24" -EOT -"$MCPP" build --target x86_64-openkal > c.log 2>&1 || fail "C: the build with a stated series failed" c.log -grep -q 'Platform *openkal-platform 0.24' c.log || fail "C: no platform line" c.log -grep -q "'s default" c.log && fail "C: a stated platform was reported as the default" c.log - -echo "PASS: 1025 the default platform is one release" diff --git a/tests/e2e/1026_an_archived_member_test_carries_its_members_files.sh b/tests/e2e/1026_an_archived_member_test_carries_its_members_files.sh deleted file mode 100755 index 492cbfef2..000000000 --- a/tests/e2e/1026_an_archived_member_test_carries_its_members_files.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# requires: python3 -# 1026 -- an archived member test carries its member's files (SPEC-015 §4). -# -# In a workspace, a member's programs and tests are placed in the member's own -# product directory with the files its `[runtime] deploy` names. `mcpp test -# --no-run --archive` copies each test with the files beside it; on -# 2026.11.7.1 the copy named the files where a plan of one package places -# them (`bin/`), not the member's directory (`bin//`), and -# the archive of a member with a deploy set was refused: "cannot copy … into -# the test archive". -# -# Criteria: -# A. `mcpp test -p tool --no-run --archive` writes the archive, and the -# member's deployed file is beside its test there. -# B. In a checkout without target/, the archived test runs and finds the -# file beside it. -set -e - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -MCPP="${MCPP:-mcpp}" - -mkdir -p "$TMP/ws/app/src" "$TMP/ws/tool/src" "$TMP/ws/tool/tests" "$TMP/ws/tool/notices" -cd "$TMP/ws" -printf '[workspace]\nmembers = ["app", "tool"]\n' > mcpp.toml -printf '[package]\nname = "app"\nversion = "0.1.0"\n' > app/mcpp.toml -printf 'int main() { return 0; }\n' > app/src/main.cpp -cat > tool/mcpp.toml <<'TOML' -[package] -name = "tool" -version = "0.1.0" - -[runtime] -deploy = [ { from = "notices/LICENSE", to = "licenses/tool" } ] -TOML -printf 'tool license\n' > tool/notices/LICENSE -printf 'int main() { return 0; }\n' > tool/src/main.cpp -cat > tool/tests/notice.cpp <<'CPP' -import std; -int main(int, char** argv) { - auto here = std::filesystem::absolute(argv[0]).parent_path(); - std::ifstream in(here / "licenses" / "tool" / "LICENSE"); - std::string line; - std::getline(in, line); - return line == "tool license" ? 0 : 1; -} -CPP - -check_archive() { # $1 archive, $2 log - rm -rf "$TMP/x" && mkdir -p "$TMP/x" && tar -xzf "$1" -C "$TMP/x" - python3 - "$TMP/x" <<'PY' || fail "the archive does not carry the member's file" "$2" -import json, pathlib, sys -m = next(pathlib.Path(sys.argv[1]).glob("*/mcpp-tests.json")) -d = json.load(open(m)) -tests = [t for leg in d["legs"] for t in leg["tests"] if t["name"] == "notice"] -assert tests, d -t = tests[0] -files = [f for f in t["files"] if f.endswith("licenses/tool/LICENSE")] -assert files, t -assert (m.parent / files[0]).read_text() == "tool license\n", files -PY -} - -# A -"$MCPP" test -p tool --no-run --archive "$TMP/a.tar.gz" > a.log 2>&1 || fail "A: -p tool --no-run --archive" a.log -check_archive "$TMP/a.tar.gz" a.log -echo "ok: a member's archived test carries the member's file" - -# B -mkdir -p "$TMP/co" && cp -R "$TMP/ws/mcpp.toml" "$TMP/ws/app" "$TMP/ws/tool" "$TMP/co/" -rm -rf "$TMP/co/target" "$TMP/co/app/target" "$TMP/co/tool/target" -cd "$TMP/co" -"$MCPP" test -p tool --archive "$TMP/a.tar.gz" > b.log 2>&1 || fail "B: mcpp test --archive" b.log -grep -q "notice ... ok" b.log || fail "B: the archived test did not find its file" b.log -echo "ok: the archived test finds the file beside it" - -echo "PASS: 1026 an archived member test carries its member's files" diff --git a/tests/e2e/1027_a_configuration_group_keeps_the_other_groups_lock_entries.sh b/tests/e2e/1027_a_configuration_group_keeps_the_other_groups_lock_entries.sh deleted file mode 100755 index 36267beaf..000000000 --- a/tests/e2e/1027_a_configuration_group_keeps_the_other_groups_lock_entries.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 1027 -- a configuration group keeps the lock entries of the other groups -# (mcpp#903). -# -# A command over a workspace whose members take two configurations plans one -# group at a time, and each plan writes the lock. A plan updates the entries it -# resolves and keeps the others; "the others" were taken from the members the -# COMMAND asked for, so the group that resolves no entry of a package took the -# package from this configuration: `mcpp test --workspace` removed the native -# target from a dependency only the other group's member uses. -# -# Criteria: after a build of the whole workspace, the entry of the package one -# group uses keeps both the configuration it was recorded for and the one this -# build resolved it in. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/ws" -cd "$TMP/ws" - -mkdir -p local-index/pkgs/w .mcpp/.xlings/data/xpkgs/mcpplibs.widget3/1.0.0/src -cat > local-index/pkgs/w/widget3.lua <<'LUA' -package = { - spec = "1", - namespace = "mcpplibs", - name = "widget3", - description = "a package of the default namespace from a local index", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["1.0.0"] = { url = "https://example.invalid/w.tar.gz" } }, - macosx = { ["1.0.0"] = { url = "https://example.invalid/w.tar.gz" } }, - windows = { ["1.0.0"] = { url = "https://example.invalid/w.zip" } }, - }, - mcpp = { - language = "c++23", - import_std = false, - sources = { "src/widget3.cppm" }, - targets = { ["widget3"] = { kind = "lib" } }, - deps = {}, - }, -} -LUA -printf 'export module widget3;\nexport int widget3_value() { return 36; }\n' \ - > .mcpp/.xlings/data/xpkgs/mcpplibs.widget3/1.0.0/src/widget3.cppm - -cat > mcpp.toml <<'TOML' -[workspace] -members = ["a", "b"] - -[workspace.indices] -mcpplibs = { path = "local-index" } -TOML - -# Two configurations: `b` is another C++ standard (833 G4). -mkdir -p a/src b/src -cat > a/mcpp.toml <<'TOML' -[package] -name = "a" -version = "0.1.0" - -[dependencies] -mcpplibs.widget3 = "1.0.0" - -[targets.a] -kind = "bin" -main = "src/main.cpp" -TOML -printf 'import widget3;\nint main() { return widget3_value() == 36 ? 0 : 1; }\n' > a/src/main.cpp -cat > b/mcpp.toml <<'TOML' -[package] -name = "b" -version = "0.1.0" -standard = "c++26" - -[targets.b] -kind = "bin" -main = "src/main.cpp" -TOML -printf 'int main() { return 0; }\n' > b/src/main.cpp - -"$MCPP" build --workspace --configure-only > first.log 2>&1 || fail "the first configure failed" first.log -grep -q 'widget3' mcpp.lock || fail "the lock records no widget3 entry" mcpp.lock - -# The entry as another configuration recorded it as well. -python3 - <<'PY' -import re -text = open("mcpp.lock").read() -head = re.search(r'(\[package\."[^"]*widget3"\]\n(?:(?!\[)[^\n]*\n)*)', text) -block = head.group(1) -block2 = re.sub(r'^targets = .*\n', '', block, flags=re.M) + 'targets = ["x86_64-openkal"]\n' -open("mcpp.lock", "w").write(text.replace(block, block2)) -PY - -"$MCPP" build --workspace --configure-only > second.log 2>&1 || fail "the second configure failed" second.log -line=$(awk '/widget3"\]/{f=1} f && /^targets/{print; exit}' mcpp.lock) -[[ "$line" == *x86_64-openkal* ]] || fail "the other configuration's target was dropped: $line" mcpp.lock second.log -[[ "$line" == *,* ]] || fail "this configuration's target is missing: $line" mcpp.lock second.log -echo "ok: $line" - -echo "PASS: 1027 a configuration group keeps the other groups' lock entries" diff --git a/tests/e2e/1028_a_projects_state_directory_ignores_itself.sh b/tests/e2e/1028_a_projects_state_directory_ignores_itself.sh deleted file mode 100755 index f9a67cbf1..000000000 --- a/tests/e2e/1028_a_projects_state_directory_ignores_itself.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# 1028 -- a project's state directory ignores itself. -# -# A build writes `/.mcpp/` (its compiler runs' locks; a project's own -# indices and SubOS live there too), the machine's state and never the -# project's sources. A project whose ignore file does not name it -- one not -# made by `mcpp new` -- showed it to version control, and a commit took the -# lock files (mcpplibs/os 0.4.0's first commit). -# -# Criterion: after a build in a repository whose .gitignore names only -# target/, `git status` lists nothing under .mcpp/, and `.mcpp/.gitignore` is `*`. -set -e -command -v git > /dev/null || { echo "SKIP: no git on this machine"; exit 77; } -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mkdir -p "$TMP/p/src" -cd "$TMP/p" -printf '[package]\nname = "p"\nversion = "0.1.0"\n' > mcpp.toml -printf 'int main() { return 0; }\n' > src/main.cpp -printf 'target/\n' > .gitignore -git init -q . -"$MCPP" build > build.log 2>&1 || fail "the build failed" build.log -[ -d .mcpp ] || { echo "SKIP: this build wrote no state directory"; exit 77; } -grep -qx '\*' .mcpp/.gitignore || fail ".mcpp/.gitignore does not ignore everything" .mcpp/.gitignore -git status --porcelain --untracked-files=all > status.txt -grep -q '^?? \.mcpp/' status.txt && fail "version control sees the state directory" status.txt -echo "PASS: 1028 a project's state directory ignores itself" diff --git a/tests/e2e/1029_an_unused_std_module_is_collected_by_age.sh b/tests/e2e/1029_an_unused_std_module_is_collected_by_age.sh deleted file mode 100755 index 4cea80e0f..000000000 --- a/tests/e2e/1029_an_unused_std_module_is_collected_by_age.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# requires: python3 -# 1029 -- an unused std module is collected by age (mcpp#874). -# -# The build cache keeps one std module per toolchain and flag set. After a build -# an entry of any kind unused for `[cache] unused_days` is collected, std -# modules included; `mcpp cache prune --older-than` and `cache gc --older-than` -# skipped them, so 159 std builds (4.9 GiB) survived a prune of two days. -# -# Criteria: -# A. `cache prune --older-than 1d` removes a std module unused for 3 days and -# keeps one used now. -# B. `cache gc --older-than 1d` does the same. -# C. `cache gc --max-size 0` keeps std modules: a size budget does not evict -# what costs half a minute to build again. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -export MCPP_HOME="$TMP/home" -std="$MCPP_HOME/build-cache/v1/std" - -seed() { # $1 name, $2 days since last use - mkdir -p "$std/$1/pcm.cache" - printf '{"compiler":"gcc","compiler_version":"16.1.0","cpp_standard":"c++23","stdlib":"libstdc++"}\n' > "$std/$1/std-module.json" - printf 'x' > "$std/$1/std.o" - python3 - "$std/$1" "$2" <<'PY' -import os, sys, time -t = time.time() - float(sys.argv[2]) * 86400 -for root, dirs, files in os.walk(sys.argv[1]): - for n in files: os.utime(os.path.join(root, n), (t, t)) - os.utime(root, (t, t)) -PY -} - -for cmd in "cache prune --older-than 1d" "cache gc --older-than 1d"; do - rm -rf "$std"; seed old 3; seed fresh 0 - # shellcheck disable=SC2086 - "$MCPP" $cmd > c.log 2>&1 || fail "mcpp $cmd" c.log - [ ! -e "$std/old" ] || fail "mcpp $cmd kept a std module unused for 3 days" c.log - [ -e "$std/fresh" ] || fail "mcpp $cmd removed a std module used now" c.log - echo "ok: mcpp $cmd" -done - -rm -rf "$std"; seed old 3 -"$MCPP" cache gc --max-size 0 > c.log 2>&1 || fail "mcpp cache gc --max-size 0" c.log -[ -e "$std/old" ] || fail "a size budget evicted a std module" c.log -echo "ok: a size budget keeps std modules" - -echo "PASS: 1029 an unused std module is collected by age" diff --git a/tests/e2e/1030_the_low_space_warning_knows_what_the_last_build_took.sh b/tests/e2e/1030_the_low_space_warning_knows_what_the_last_build_took.sh deleted file mode 100755 index 2dcaece3b..000000000 --- a/tests/e2e/1030_the_low_space_warning_knows_what_the_last_build_took.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# 1030 -- the low-space warning knows what the configuration's last build took -# (mcpp#874). -# -# A build warns before it starts when the file system it writes to has less free -# space than it needs. The threshold was a fixed 2 GiB, so a workspace whose -# build writes 20 GB went ahead and failed at its link. A build now records what -# it took (`.mcpp-written` in its configuration's directory: the free space -# before it less the free space after it), and the next build warns below that, -# a quarter more, and never below 2 GiB. -# -# Criteria: -# A. A build leaves `.mcpp-written` in its configuration's directory, a number. -# B. With the record stating more than the file system has free, the next -# build says so before it builds, naming what the last build took. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -cd "$TMP" -"$MCPP" new app > /dev/null -cd app -"$MCPP" build > a.log 2>&1 || fail "the build failed" a.log -rec=$(find target -name .mcpp-written | head -1) -[ -n "$rec" ] || { echo "SKIP: the file system reported no change in free space"; exit 77; } -grep -Eq '^[0-9]+$' "$rec" || fail "A: .mcpp-written is not a number" "$rec" -echo "ok: the build recorded what it took ($(cat "$rec") bytes)" -echo 1152921504606846976 > "$rec" # 1 EiB: more than any file system here has free -printf '\n// changed\n' >> src/main.cpp -"$MCPP" build > b.log 2>&1 || fail "the second build failed" b.log -grep -q 'free where this build writes' b.log || fail "B: no warning before a build that needs more than is free" b.log -grep -q 'last build took' b.log || fail "B: the warning does not say what the last build took" b.log -echo "ok: the next build is warned" -echo "PASS: 1030 the low-space warning knows what the last build took" diff --git a/tests/e2e/1031_target_keeps_within_its_budget.sh b/tests/e2e/1031_target_keeps_within_its_budget.sh deleted file mode 100755 index db153d9c5..000000000 --- a/tests/e2e/1031_target_keeps_within_its_budget.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# 1031 -- target/ keeps within its budget (mcpp#874). -# -# `[build] target_budget` states the bytes a project's target/ may hold. After -# every successful build over the budget, the build directories no -# configuration uses are removed whatever their age (the daily sweep keeps them -# for `[build] keep_days`), and when the ones in use still exceed it the build -# says so, naming the largest. -# -# Criteria: -# A. A directory no configuration uses, written a minute ago, is removed by -# the build after it when the budget is exceeded. -# B. The configuration in use is kept, and the build warns that target/ is -# over its budget in configurations still in use. -# C. Without a budget the same directory is kept (keep_days). -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -cd "$TMP" -"$MCPP" new app > /dev/null -cd app -"$MCPP" build > a.log 2>&1 || fail "the build failed" a.log -cfg=$(dirname "$(dirname "$(find target -path '*/bin/app' -o -path '*/bin/app.exe' | head -1)")") -[ -d "$cfg" ] || fail "no configuration directory" a.log -stale="$(dirname "$cfg")/0000000000000000" -cp -R "$cfg" "$stale" - -# C -printf '\n// c\n' >> src/main.cpp -"$MCPP" build > c.log 2>&1 || fail "C: the build failed" c.log -[ -d "$stale" ] || fail "C: without a budget a recent unused directory was removed" c.log -echo "ok: without a budget the recent directory is kept" - -# A, B -printf '\n[build]\ntarget_budget = "1KiB"\n' >> mcpp.toml -printf '\n// a\n' >> src/main.cpp -"$MCPP" build > b.log 2>&1 || fail "the build with a budget failed" b.log -[ ! -d "$stale" ] || fail "A: the unused directory survived a build over budget" b.log -[ -d "$cfg" ] || fail "B: the configuration in use was removed" b.log -grep -q 'over \[build\] target_budget' b.log || fail "B: no warning for a target/ over budget in use" b.log -grep -q 'unsupported key' b.log && fail "B: target_budget is not a known key" b.log -echo "ok: over budget, the unused directory goes and the build says what remains" -echo "PASS: 1031 target/ keeps within its budget" diff --git a/tests/e2e/1032_a_quoted_dotted_dependency_key_is_deprecated.sh b/tests/e2e/1032_a_quoted_dotted_dependency_key_is_deprecated.sh deleted file mode 100755 index 6d02a82e2..000000000 --- a/tests/e2e/1032_a_quoted_dotted_dependency_key_is_deprecated.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# 1032 -- a quoted dotted dependency key is deprecated, and the warning names the unquoted key. -# -# `"acme.lib" = ...` is one TOML key whose name contains a dot; mcpp reads it as -# the selector `acme.lib`, as it did before, and said nothing, so a project -# never learned the recommended spelling (mcpp#904). It is now the deprecated -# reading `quoted-dotted-dependency-key` (docs/51). -# -# Criterion: the quoted key still resolves and builds; the build warns, naming -# the key, the releases and the unquoted spelling; `mcpp why compat` -# lists the use; the unquoted key builds with no such warning; a quoted key that is the only -# spelling of a package beside its parent (`acme.lib` and `acme.lib.extra` in one table) builds with -# no such warning either. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mkdir -p "$TMP/lib/src" "$TMP/app/src" -cat > "$TMP/lib/mcpp.toml" <<'TOML' -[package] -namespace = "acme" -name = "lib" -version = "0.1.0" -TOML -printf 'export module acme.lib;\nexport int answer() { return 42; }\n' > "$TMP/lib/src/lib.cppm" -printf 'import acme.lib;\nint main() { return answer() == 42 ? 0 : 1; }\n' > "$TMP/app/src/main.cpp" - -cd "$TMP/app" -printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\n"acme.lib" = { path = "../lib" }\n' > mcpp.toml -"$MCPP" build > quoted.log 2>&1 || fail "the quoted key no longer builds" quoted.log -grep -q '"acme.lib" is deprecated since mcpp 2026.11.8.1 and removed from mcpp 2027.2' quoted.log \ - || fail "no deprecation warning for the quoted key" quoted.log -grep -q 'Write the key unquoted, `acme.lib = ...` instead' quoted.log || fail "the warning does not show the unquoted spelling of its own key" quoted.log -"$MCPP" why compat > why.log 2>&1 || true -grep -q 'quoted-dotted-dependency-key: \[dependencies\] "acme.lib"' why.log || fail "mcpp why compat does not list the use" why.log - -printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nacme.lib = { path = "../lib" }\n' > mcpp.toml -"$MCPP" build > unquoted.log 2>&1 || fail "the unquoted key does not build" unquoted.log -grep -q 'quoted-dotted-dependency-key\|is deprecated since' unquoted.log && fail "the unquoted key is warned about" unquoted.log -mkdir -p "$TMP/extra/src" -cat > "$TMP/extra/mcpp.toml" <<'TOML' -[package] -namespace = "acme.lib" -name = "extra" -version = "0.1.0" -TOML -printf 'export module acme.lib.extra;\nexport int more() { return 1; }\n' > "$TMP/extra/src/extra.cppm" -printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nacme.lib = { path = "../lib" }\n"acme.lib.extra" = { path = "../extra" }\n' > mcpp.toml -"$MCPP" build > pair.log 2>&1 || fail "a package beside its parent does not build" pair.log -grep -q 'quoted-dotted-dependency-key\|is deprecated since' pair.log && fail "the only spelling of the pair is warned about" pair.log -echo "PASS: 1032 a quoted dotted dependency key is deprecated" diff --git a/tests/e2e/1034_a_workspace_members_test_finds_its_programs.sh b/tests/e2e/1034_a_workspace_members_test_finds_its_programs.sh deleted file mode 100755 index d5776f334..000000000 --- a/tests/e2e/1034_a_workspace_members_test_finds_its_programs.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1034 -- a workspace member's test finds the member's programs under `--workspace` as under `-p`. -# -# `mcpp test` builds a package's programs before its tests and names each to them as MCPP_BIN_ -# (docs/08). A workspace's test plan holds a member's test programs only, so under `--workspace` the -# member's programs were not built and its tests found no MCPP_BIN_; `-p ` found them. -# (Found by mtest's conformance suite, whose tests run the member's own fixture.) -# -# Criteria: both members' tests pass under `--workspace` and under `-p`; each finds its own program -# of one shared name. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -printf '[workspace]\nmembers = ["a", "b"]\n' > mcpp.toml -for m in a b; do - mkdir -p "$m/src" "$m/tests" - cat > "$m/mcpp.toml" < "$m/src/tool.cpp" - cat > "$m/tests/finds.cpp" < ws.log 2>&1 || fail "--workspace: a member's test did not find its program" ws.log -"$MCPP" test -p a > a.log 2>&1 || fail "-p a" a.log -grep -q 'finds \.\.\. ok' ws.log || fail "the tests did not run under --workspace" ws.log -echo "PASS: 1034 a workspace member's test finds its programs" diff --git a/tests/e2e/1035_a_test_program_reports_its_cases.sh b/tests/e2e/1035_a_test_program_reports_its_cases.sh deleted file mode 100755 index f18ccc838..000000000 --- a/tests/e2e/1035_a_test_program_reports_its_cases.sh +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1035 -- a test program that implements the test protocol reports its cases (docs/08 "Cases"). -# -# mcpp runs the runner's side of the mtest test protocol, version 1: a program whose entry states -# `test-protocol = 1`, or whose file carries the marker `mtest-protocol=1`, is told the protocol's -# variables, lists its cases, runs a selection and reports each case; every other program is one -# case judged by its exit status and is told none of the variables. -# -# Criteria: -# A. A run's record names the protocol and each case's result, a failure with its place; a case a -# crash left without an end failed (P5.4); the human report names the failed cases. -# B. `--case PATTERN` runs the matching cases of a protocol program and no other program. -# C. `--cases` lists each protocol program's cases and runs none. -# D. A program that implements no protocol receives none of the variables, though mcpp's own -# environment has them (P1.2). -# E. A program found by its marker is a protocol program. -# F. `--no-run --message-format json` records each built test with its program and sources. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python - -cd "$TMP" -printf '[workspace]\nmembers = ["fw", "app", "plain"]\n' > mcpp.toml - -# fw: a framework whose entry implements the protocol; a test file defines `cases()`. -mkdir -p fw/src -cat > fw/mcpp.toml <<'TOML' -[package] -name = "fw" -version = "0.1.0" - -[targets.main] -kind = "entry" -main = "src/main.cpp" -test-protocol = 1 -TOML -cat > fw/src/main.cpp <<'CPP' -import std; -struct test_case { std::string id; int line; int (*body)(); }; -std::vector cases(); -int main() { - const char* mode = std::getenv("MTEST_MODE"); - const char* path = std::getenv("MTEST_REPORT"); - std::ofstream report; - if (path) report.open(path, std::ios::app); - auto event = [&](const std::string& body) { if (report) { report << "{" << body << "}\n"; report.flush(); } }; - event(R"("e":"program","protocol":1,"level":1,"framework":"fw","version":"0.1.0")"); - std::set selected; - bool selecting = false; - if (const char* s = std::getenv("MTEST_SELECT")) { - selecting = true; - std::ifstream in(s); - for (std::string line; std::getline(in, line);) if (!line.empty()) selected.insert(line); - } - int failed = 0; - for (const auto& c : cases()) { - if (mode && std::string(mode) == "list") { - event(std::format(R"("e":"case","id":"{}","name":"{}","file":"t.cpp","line":{})", c.id, c.id, c.line)); - continue; - } - if (selecting && !selected.contains(c.id)) continue; - event(std::format(R"("e":"begin","id":"{}","t":0)", c.id)); - const int r = c.body(); - if (r != 0) { - ++failed; - event(std::format(R"("e":"failure","id":"{}","file":"t.cpp","line":{},"message":"{} != 0")", c.id, c.line, r)); - } - event(std::format(R"("e":"end","id":"{}","status":"{}","ms":1)", c.id, r ? "fail" : "pass")); - } - event(std::format(R"("e":"done","status":"{}","counts":{{}})", failed ? "fail" : "pass")); - return failed ? 1 : 0; -} -CPP - -# app: its tests begin at fw's entry. -mkdir -p app/src app/tests -cat > app/mcpp.toml <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[dev-dependencies] -fw = { path = "../fw" } - -[test] -main = { from = "fw/main" } -TOML -printf 'export module app;\nexport int answer() { return 42; }\n' > app/src/app.cppm -cat > app/tests/math.cpp <<'CPP' -import std; -import app; -struct test_case { std::string id; int line; int (*body)(); }; -std::vector cases() { - return { - {"math/adds", 4, [] { return answer() == 42 ? 0 : 1; }}, - {"math/fails", 5, [] { return 7; }}, - {"math/crashes", 6, [] { std::exit(3); return 0; }}, - }; -} -CPP - -# plain: a program that implements no protocol, and one found by its marker. -mkdir -p plain/tests -printf '[package]\nname = "plain"\nversion = "0.1.0"\n' > plain/mcpp.toml -cat > plain/tests/bare.cpp <<'CPP' -import std; -int main() { - for (const char* v : {"MTEST_PROTOCOL", "MTEST_MODE", "MTEST_REPORT"}) - if (std::getenv(v)) { std::println("{} reached a program that implements no protocol", v); return 3; } - return 0; -} -CPP -cat > plain/tests/marked.cpp <<'CPP' -import std; -constinit const char marker[] = "mtest-protocol=1"; -int main() { - [[maybe_unused]] volatile char keep = marker[0]; - const char* path = std::getenv("MTEST_REPORT"); - if (!path) return 4; - std::ofstream report(path, std::ios::app); - const std::string mode = std::getenv("MTEST_MODE") ? std::getenv("MTEST_MODE") : "run"; - report << R"({"e":"program","protocol":1,"level":1,"framework":"marked","version":"1"})" << "\n"; - if (mode == "list") report << R"({"e":"case","id":"only","name":"only","file":"marked.cpp","line":1})" << "\n"; - else report << R"({"e":"begin","id":"only","t":0})" << "\n" << R"({"e":"end","id":"only","status":"pass","ms":0})" << "\n"; - report << R"({"e":"done","status":"pass","counts":{}})" << "\n"; - return 0; -} -CPP - -record() { # record LOG TEST: the test's record - $PY - "$1" "$2" <<'PY' -import json, sys -for line in open(sys.argv[1]): - line = line.strip() - if not line.startswith("{"): continue - try: r = json.loads(line) - except ValueError: continue - if r.get("test") == sys.argv[2]: print(json.dumps(r)); break -PY -} -field() { # field JSON EXPRESSION - $PY -c "import json,sys; r=json.loads(sys.argv[1]); print(eval(sys.argv[2]))" "$1" "$2" -} - -# A -"$MCPP" test -p app --message-format json > a.json 2> a.err && fail "A: a failing case passed the run" a.json a.err -rec=$(record a.json math) -[ -n "$rec" ] || fail "A: no record of the test" a.json a.err -[ "$(field "$rec" "r['protocol']")" = 1 ] || fail "A: the record does not name the protocol" a.json -[ "$(field "$rec" "[c['id']+':'+c['status'] for c in r['cases']]")" = "['math/adds:pass', 'math/fails:fail', 'math/crashes:fail']" ] \ - || fail "A: the cases' results" a.json -[ "$(field "$rec" "r['cases'][1]['failures'][0]['line']")" = 5 ] || fail "A: the failure's place" a.json -field "$rec" "r['cases'][2]['reason']" | grep -q "status 3 before the case ended" || fail "A: a crashed case's reason (P5.4)" a.json -"$MCPP" test -p app > a.log 2>&1 && fail "A: the human run passed" a.log -grep -qE "^ math/fails t\.cpp:5$" a.log || fail "A: the failures' index does not name the failed case at its place" a.log -grep -qE "cases: 1 passed, 2 failed, 0 skipped" a.log || fail "A: the summary does not count the cases" a.log - -# B -"$MCPP" test --case math/adds --message-format json > b.json 2> b.err || fail "B: the selected case failed" b.json b.err -rec=$(record b.json math) -[ "$(field "$rec" "[c['id'] for c in r['cases']]")" = "['math/adds']" ] || fail "B: the selection" b.json -[ -z "$(record b.json bare)" ] || fail "B: a program with no protocol ran under --case" b.json - -# C -"$MCPP" test --cases > c.log 2>&1 || fail "C: --cases" c.log -grep -q "math: 3 cases" c.log || fail "C: the listing of math" c.log -grep -q "math/fails t.cpp:5" c.log || fail "C: a listed case's place" c.log -grep -q "bare: one case" c.log || fail "C: a program with no protocol" c.log - -# D, E -MTEST_PROTOCOL=1 MTEST_MODE=list MTEST_REPORT="$TMP/outer.ndjson" "$MCPP" test -p plain --message-format json > d.json 2> d.err \ - || fail "D: the plain member's tests" d.json d.err -[ "$(field "$(record d.json bare)" "r['status']")" = pass ] || fail "D: the protocol's variables reached a program with none (P1.2)" d.json -[ "$(field "$(record d.json marked)" "r['protocol']")" = 1 ] || fail "E: the marker" d.json -[ "$(field "$(record d.json marked)" "[c['id'] for c in r['cases']]")" = "['only']" ] || fail "E: the marked program's case" d.json - -# F -"$MCPP" test -p app --no-run --message-format json > f.json 2> f.err || fail "F: --no-run" f.json f.err -rec=$(record f.json math) -[ "$(field "$rec" "r['status']")" = built ] || fail "F: the built record" f.json -field "$rec" "r['program']" | grep -q math || fail "F: the program" f.json -field "$rec" "any(s.endswith('app/tests/math.cpp') for s in r['sources']) and any(s.endswith('app/src/app.cppm') for s in r['sources'])" \ - | grep -q True || fail "F: the sources" f.json - -echo "PASS: 1035 a test program reports its cases" diff --git a/tests/e2e/1036_a_context_states_the_engine_values_in_process.sh b/tests/e2e/1036_a_context_states_the_engine_values_in_process.sh deleted file mode 100755 index f0403aebf..000000000 --- a/tests/e2e/1036_a_context_states_the_engine_values_in_process.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1036 -- a `mcpp::context` states the engine's values to the interface's accessors in-process -# (docs/30 "Testing a program's logic"). -# -# Every accessor of the build-program interface reads the engine's values through one function: -# within a context, the values it states, the innermost context first, and a name it does not -# state from the context around it or the environment; outside every context, the environment. -# -# Criteria: a build program that states contexts reads their values, nested and restored, and -# `has_feature` and `dep_dir` answer within them; the build succeeds and its program prints what the -# build program read. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -"$MCPP" new ctx > /dev/null -cd ctx -cat > build.mcpp <<'EOF' -import std; -import mcpp; -int main() { - const std::string os = mcpp::target_os(); - std::string seen; - { - mcpp::context outer({{"MCPP_TARGET_OS", "plan9"}, {"MCPP_FEATURE_FAST", "1"}}); - seen += mcpp::target_os(); - seen += mcpp::has_feature("fast") ? " fast" : " slow"; - { - mcpp::context inner({{"MCPP_PROFILE", "inner"}, {"MCPP_DEP_ZLIB_DIR", "/z"}}); - seen += std::format(" {} {} {}", mcpp::target_os(), mcpp::profile(), mcpp::dep_dir("zlib")); - } - seen += std::format(" {}", std::string(mcpp::profile()) == "inner" ? "leaked" : "restored"); - } - seen += std::string(mcpp::target_os()) == os ? " environment" : " leaked"; - std::ofstream("src/seen.cpp") << "extern \"C\" const char* seen() { return \"" << seen << "\"; }\n"; - mcpp::generated("src/seen.cpp"); - return 0; -} -EOF -cat > src/main.cpp <<'EOF' -import std; -extern "C" const char* seen(); -int main() { std::println("{}", seen()); return 0; } -EOF -"$MCPP" run > run.log 2>&1 || fail "the build or the program failed" run.log -grep -q "plan9 fast plan9 inner /z restored environment" run.log || fail "the values in scope" run.log -echo "PASS: 1036 a context states the engine's values in-process" diff --git a/tests/e2e/1037_a_skeleton_tests_with_mtest_and_a_test_needs_no_main.sh b/tests/e2e/1037_a_skeleton_tests_with_mtest_and_a_test_needs_no_main.sh deleted file mode 100755 index b6f90ceec..000000000 --- a/tests/e2e/1037_a_skeleton_tests_with_mtest_and_a_test_needs_no_main.sh +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1037 -- `mcpp new` scaffolds a program or a library whose test is mtest's, and a test file that -# defines no `main` begins at the entry a dependency offers (docs/08, docs/01). -# -# Criteria: -# A. `--template lib` writes the module, a partition it exports and the partition's implementation, -# named after the package (`acme.my-lib` is the module `acme.my_lib`); the library builds and its -# test, a file of mtest's cases with no `main`, passes as one protocol case. -# B. The program's skeleton test passes as one protocol case. -# C. An entry with `test-main = true` begins a dependent's test that defines no `main`; a test that -# defines one keeps it; the package's own entry begins its own tests; a framework that is a -# dependency as well as a dev-dependency offers its beginning once. -# D. Two offers are refused, naming both; `[test] main` chooses one. -# E. A library whose name has a part that does not begin with a letter is refused. -# F. In one package, a test that imports a framework's module begins at that framework's entry, -# and a test that imports none begins at a dev-dependency's source that defines `main`. -# G. `mcpp test --guide` names each test framework with its guide (`test-guide`), and a failed -# run points to it. -# H. A test that begins at an entry links no source that defines `main` beneath its -# dev-dependency (gtest_main.cc of compat.gtest beneath a GoogleTest adapter). -# I. A test links a dev-dependency that is a shared library (mtest's C ABI) by its library. -# J. In a workspace, a member's test begins by the member's own dev-dependencies: another -# member's dev-dependency that defines `main` is no beginning of it. -# K. mtest reaches the test units only: the skeleton's `mcpp build` and `mcpp test` share one -# build directory, switching between them compiles the program's source once, its command -# carries no `__OPENKAL__`, and the compile database holds the test. -# L. A dev-dependency's source whose `main` is compiled only under a switch no command defines -# (zlib's `#ifdef MAKECRCH`) is linked into a test that has its own `main`; one whose `main` -# is in the branch an unknown condition leaves (gtest_main.cc's `#else`) still begins a test. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python - -cd "$TMP" - -# A -"$MCPP" new acme.my-lib --template lib > a.log 2>&1 || fail "A: --template lib" a.log -for f in mcpp.toml src/my-lib.cppm src/hello.cppm src/hello.cpp tests/hello.cpp; do - [ -f "acme.my-lib/$f" ] || fail "A: no $f" a.log -done -grep -q '^export module acme.my_lib;' acme.my-lib/src/my-lib.cppm || fail "A: the module's name" acme.my-lib/src/my-lib.cppm -grep -q '^export module acme.my_lib:hello;' acme.my-lib/src/hello.cppm || fail "A: the partition" acme.my-lib/src/hello.cppm -grep -q 'namespace acme::my_lib' acme.my-lib/src/hello.cpp || fail "A: the namespace" acme.my-lib/src/hello.cpp -grep -q 'int main' acme.my-lib/tests/hello.cpp && fail "A: the test defines main" acme.my-lib/tests/hello.cpp -(cd acme.my-lib && "$MCPP" build > ../a-build.log 2>&1) || fail "A: the library does not build" a-build.log -(cd acme.my-lib && "$MCPP" test > ../a-test.log 2>&1) || fail "A: the library's test failed" a-test.log -grep -q 'hello \.\.\. ok (.*1 case)' a-test.log || fail "A: the test is not one protocol case" a-test.log - -# B -"$MCPP" new prog > b.log 2>&1 || fail "B: mcpp new" b.log -(cd prog && "$MCPP" test > ../b-test.log 2>&1) || fail "B: the program's test failed" b-test.log -grep -q 'test_smoke \.\.\. ok (.*1 case)' b-test.log || fail "B: the smoke test is not one protocol case" b-test.log - -# C: a framework whose entry offers to begin tests, and its own tests. -mkdir -p fw/src fw/tests app/tests -cat > fw/mcpp.toml <<'TOML' -[package] -name = "fw" -version = "0.1.0" - -[targets.main] -kind = "entry" -main = "src/entry.cpp" -test-main = true -TOML -printf 'import std;\nextern "C++" int run_cases();\nint main() { std::println("fw main"); return run_cases(); }\n' > fw/src/entry.cpp -printf 'extern "C++" int run_cases() { return 0; }\n' > fw/tests/own.cpp -cat > app/mcpp.toml <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[dev-dependencies] -fw = { path = "../fw" } -TOML -printf 'extern "C++" int run_cases() { return 0; }\n' > app/tests/begun.cpp -printf 'import std;\nint main() { std::println("own main"); return 0; }\n' > app/tests/own.cpp -(cd app && "$MCPP" test > ../c.log 2>&1) || fail "C: the dependent's tests" c.log -grep -q 'begun \.\.\. ok' c.log && grep -q 'own \.\.\. ok' c.log || fail "C: a test did not run" c.log -(cd fw && "$MCPP" test > ../c-own.log 2>&1) || fail "C: the framework's own test" c-own.log -cp app/mcpp.toml app/mcpp.toml.c -printf '\n[dependencies]\nfw = { path = "../fw" }\n' >> app/mcpp.toml -(cd app && "$MCPP" test > ../c-both.log 2>&1) || fail "C: a framework named in two tables offered twice" c-both.log -mv app/mcpp.toml.c app/mcpp.toml - -# D: a second framework offers too. -cp -R fw fw2 -sed -i.bak 's/name = "fw"/name = "fw2"/' fw2/mcpp.toml -printf 'fw2 = { path = "../fw2" }\n' >> app/mcpp.toml -(cd app && "$MCPP" test > ../d.log 2>&1) && fail "D: two offers were taken" d.log -grep -q "fw/main" d.log && grep -q "fw2/main" d.log || fail "D: the refusal does not name both" d.log -rm app/tests/own.cpp # under [test] main every test begins at the entry -printf '\n[test]\nmain = { from = "fw2/main" }\n' >> app/mcpp.toml -(cd app && "$MCPP" test > ../d2.log 2>&1) || fail "D: [test] main did not choose" d2.log - -# E -"$MCPP" new 3d --template lib > e.log 2>&1 && fail "E: a name beginning with a digit was taken" e.log -grep -q "does not begin with a letter" e.log || fail "E: the refusal" e.log -# F -mkdir -p fwm/src hdr/src mixed/tests -cat > fwm/mcpp.toml <<'TOML' -[package] -name = "fwm" -version = "0.1.0" - -[targets.main] -kind = "entry" -main = "src/entry.cpp" -test-main = true -TOML -printf 'export module fwm;\nexport int fwm_cases() { return 0; }\n' > fwm/src/fwm.cppm -printf 'import std;\nimport fwm;\nint main() { std::println("began at fwm"); return fwm_cases(); }\n' > fwm/src/entry.cpp -printf '[package]\nname = "hdr"\nversion = "0.1.0"\n' > hdr/mcpp.toml -printf 'import std;\nint main() { std::println("began at hdr"); return 0; }\n' > hdr/src/hdr_main.cpp -printf 'int hdr_marker() { return 0; }\n' > hdr/src/hdr.cpp -cat > mixed/mcpp.toml <<'TOML' -[package] -name = "mixed" -version = "0.1.0" - -[dev-dependencies] -fwm = { path = "../fwm" } -hdr = { path = "../hdr" } -TOML -printf 'import fwm;\nint uses_fwm() { return fwm_cases(); }\n' > mixed/tests/modular.cpp -printf 'int uses_headers() { return 0; }\n' > mixed/tests/headers.cpp -(cd mixed && "$MCPP" test > ../f.log 2>&1) || fail "F: the mixed package's tests" f.log -grep -A2 'modular \.\.\. ok' f.log | grep -q "began at fwm" || fail "F: the modular test did not begin at fwm" f.log -grep -A2 'headers \.\.\. ok' f.log | grep -q "began at hdr" || fail "F: the header test did not begin at hdr" f.log - -# G -printf 'test-guide = "GUIDE.md"\n' >> fwm/mcpp.toml -printf '# Writing a test with fwm\n' > fwm/GUIDE.md -(cd mixed && "$MCPP" test --guide > ../g.log 2>&1) || fail "G: --guide" g.log -grep -q "fwm 0.1.0" g.log && grep -q "fwm/GUIDE.md" g.log || fail "G: the framework and its guide" g.log -(cd mixed && "$MCPP" test --guide --message-format json > ../g.json 2>/dev/null) || fail "G: --guide json" g.json -grep -q '"guide":"[^"]*fwm/GUIDE.md"' g.json || fail "G: the guide in JSON" g.json -printf 'import fwm;\nint uses_fwm() { return fwm_cases(); }\nint broken = missing_name;\n' > mixed/tests/modular.cpp -(cd mixed && "$MCPP" test > ../g-fail.log 2>&1) && fail "G: a test that does not compile passed" g-fail.log -grep -q "how these tests are written: mcpp test --guide" g-fail.log || fail "G: the failure does not point to the guide" g-fail.log - -# H -mkdir -p fwh/src deep/tests -cat > fwh/mcpp.toml <<'TOML' -[package] -name = "fwh" -version = "0.1.0" - -[targets.main] -kind = "entry" -main = "src/entry.cpp" -test-main = true - -[dependencies] -hdr = { path = "../hdr" } -TOML -printf 'import std;\nint main() { std::println("began at fwh"); return 0; }\n' > fwh/src/entry.cpp -printf 'int fwh_marker() { return 0; }\n' > fwh/src/fwh.cpp -printf '[package]\nname = "deep"\nversion = "0.1.0"\n\n[dev-dependencies]\nfwh = { path = "../fwh" }\n' > deep/mcpp.toml -printf 'int uses_fwh() { return 0; }\n' > deep/tests/one.cpp -(cd deep && "$MCPP" test > ../h.log 2>&1) || fail "H: a main beneath the dev-dependency was linked" h.log -grep -q "began at fwh" h.log || fail "H: the test did not begin at fwh" h.log - -# I -mkdir -p shlib/src shapp/tests -printf '[package]\nname = "shlib"\nversion = "0.1.0"\n\n[targets.shlib]\nkind = "shared"\n' > shlib/mcpp.toml -printf 'extern "C" int shlib_answer() { return 42; }\n' > shlib/src/shlib.cpp -printf '[package]\nname = "shapp"\nversion = "0.1.0"\n\n[dev-dependencies]\nshlib = { path = "../shlib" }\n' > shapp/mcpp.toml -printf 'extern "C" int shlib_answer();\nint main() { return shlib_answer() == 42 ? 0 : 1; }\n' > shapp/tests/links.cpp -(cd shapp && "$MCPP" test > ../i.log 2>&1) || fail "I: a shared dev-dependency is not linked" i.log - -# J -mkdir -p wsj/one/tests wsj/two/tests -printf '[workspace]\nmembers = ["one", "two"]\n' > wsj/mcpp.toml -printf '[package]\nname = "one"\nversion = "0.1.0"\n\n[dev-dependencies]\nhdr = { path = "../../hdr" }\n' > wsj/one/mcpp.toml -printf 'int uses_headers() { return 0; }\n' > wsj/one/tests/plain.cpp -printf '[package]\nname = "two"\nversion = "0.1.0"\n\n[dev-dependencies]\nfw = { path = "../../fw" }\n' > wsj/two/mcpp.toml -printf 'extern "C++" int run_cases() { return 0; }\n' > wsj/two/tests/begun.cpp -(cd wsj && "$MCPP" test --workspace > ../j.log 2>&1) || fail "J: a member's test took another member's beginning" j.log -grep -q "began at hdr" j.log && grep -q "fw main" j.log || fail "J: each member's test did not begin at its own" j.log - -# K -"$MCPP" new kapp > k.log 2>&1 || fail "K: mcpp new" k.log -(cd kapp && "$MCPP" build && "$MCPP" test && "$MCPP" build && "$MCPP" test) > k-run.log 2>&1 || fail "K: build and test" k-run.log -set -- kapp/target/*/*/build.ninja -[ $# = 1 ] || fail "K: build and test are $# configurations" k-run.log -dir=$(dirname "$1") -[ "$(grep -cE 'obj/main\.(o|obj) ' "$dir/.ninja_log")" = 1 ] || fail "K: the program's source was compiled again" "$dir/.ninja_log" -grep -q 'test_smoke' "$dir/compile_commands.json" || fail "K: the compile database has no test" "$dir/compile_commands.json" -$PY - "$dir/compile_commands.json" <<'PY' || fail "K: the program's source sees the dev-dependency" "$dir/compile_commands.json" -import json, sys -for e in json.load(open(sys.argv[1])): - if e["file"].replace("\\", "/").endswith("kapp/src/main.cpp") and any("__OPENKAL__" in a or "openkal" in a for a in e.get("arguments", [])): - sys.exit(1) -PY - -# L -mkdir -p lib-tools/src lapp/tests -cat > lib-tools/mcpp.toml <<'TOML' -[package] -name = "lib-tools" -version = "0.1.0" - -[build] -sources = ["src/*.c"] -TOML -cat > lib-tools/src/table.c <<'C' -int table_value(void) { return 7; } -#ifdef MAKE_TABLE -#include -int main(void) { printf("%d\n", table_value()); return 0; } -#endif -C -cat > lapp/mcpp.toml <<'TOML' -[package] -name = "lapp" -version = "0.1.0" - -[dev-dependencies] -lib-tools = { path = "../lib-tools" } -TOML -printf 'extern "C" int table_value(void); -int main() { return table_value() == 7 ? 0 : 1; } -' > lapp/tests/own.cpp -(cd lapp && "$MCPP" test > ../l.log 2>&1) || fail "L: a source whose main no command compiles was left out of the link" l.log -grep -q 'own \.\.\. ok' l.log || fail "L: the test did not pass" l.log - -echo "PASS: 1037 a skeleton tests with mtest, and a test needs no main" diff --git a/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh b/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh deleted file mode 100644 index d40766bf9..000000000 --- a/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1038 -- the files of one directory are one test program, and `mcpp test` runs the tests a change -# affects (docs/08 "Where tests are", "The tests a change affects"). -# -# Criteria: -# A. `[test] programs = "directory"`: the files of a directory below `tests/` are one program, -# named by the directory, whose cases are all of theirs, run as a process per file of its cases -# and, under `split = "none"`, once; a directory whose file defines `main` -# begins there; two files of one directory that define `main` are refused, naming both. -# B. A program built from a file of plain `main` that links a framework's library (it carries the -# protocol's marker and speaks none) is one case, judged by its exit status, run and listed. -# C. `--changed PATH` runs the tests built from PATH (a module a test imports, transitively) and -# not the others; a file no test is built from or names affects none; a file a test names -# affects it; the manifest affects every test. `--since COMMIT` reads the change from git. -# D. Switching between `programs = "directory"` and the default builds without a stale product -# in the way. -# E. Under a shipping profile, `mcpp build` and `mcpp test` are one configuration: the test -# compiles none of the code under test again, which is trimmed as the build trims it, and a -# test program's own file keeps its path (`__FILE__` finds the test's directory). -# F. A case a listed case declares (protocol P3.4) is selected by its name alone; a pattern below -# the listed case runs it whole. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -"$MCPP" new app > /dev/null 2>&1 -cd app -mkdir -p tests/unit tests/e2e tests/data -cat > src/calc.cppm <<'CPP' -export module app.calc; -export int twice(int x) { return 2 * x; } -CPP -cat > tests/unit/a.cpp <<'CPP' -import mcpplibs.mtest; -import app.calc; -using namespace mcpplibs::mtest; -UTest a{"doubles", [] { expect(eq(twice(2), 4)); }}; -CPP -cat > tests/unit/b.cpp <<'CPP' -import std; -import mcpplibs.mtest; -using namespace mcpplibs::mtest; -UTest b{"reads its data", [] { expect(eq(std::string_view("tests/data/f.txt").size(), 16u)); }}; -CPP -printf 'import std;\nint main() { return 0; }\n' > tests/e2e/plain.cpp -printf 'data\n' > tests/data/f.txt - -# A -printf '\n[test]\nprograms = "directory"\n' >> mcpp.toml -"$MCPP" test > a.log 2>&1 || fail "A: the directory programs" a.log -# A program that speaks the protocol runs as a process per file of its cases (`split = "file"`). -grep -q 'unit/a \.\.\. ok (.*1 case)' a.log && grep -q 'unit/b \.\.\. ok (.*1 case)' a.log \ - || fail "A: unit does not run as a process per file" a.log -# A pattern names a part as the run names it. -"$MCPP" test unit/b > a-part.log 2>&1 || fail "A: a part named by its pattern" a-part.log -grep -q 'unit/b \.\.\. ok' a-part.log && ! grep -q 'unit/a \.\.\.' a-part.log \ - || fail "A: the pattern of a part did not run that part alone" a-part.log -printf 'split = "none"\n' >> mcpp.toml -"$MCPP" test > a.log 2>&1 || fail "A: the directory programs, run once" a.log -grep -q 'unit \.\.\. ok (.*2 cases)' a.log || fail "A: unit is not one program of two cases" a.log -grep -q '^e2e \.\.\. ok' a.log || fail "A: the directory of a file with main" a.log -"$MCPP" test --cases > a-cases.log 2>&1 || fail "A: --cases" a-cases.log -grep -q 'doubles' a-cases.log && grep -q 'reads its data' a-cases.log || fail "A: the program's cases" a-cases.log -# B: a file of plain main beside the library's marker. -grep -q 'e2e: one case, its exit status' a-cases.log || fail "B: a program of plain main is listed as one case" a-cases.log -printf 'int main() { return 0; }\n' > tests/e2e/second.cpp -"$MCPP" test > a2.log 2>&1 && fail "A: two files of one directory that define main were taken" a2.log -grep -q "plain.cpp" a2.log && grep -q "second.cpp" a2.log || fail "A: the refusal does not name both" a2.log -rm tests/e2e/second.cpp - -# C -changed() { # changed LOG PATH...: the tests run - local log=$1; shift - local args=() - for p in "$@"; do args+=(--changed "$p"); done - "$MCPP" test "${args[@]}" > "$log" 2>&1 || fail "C: mcpp test ${args[*]}" "$log" -} -changed c1.log src/calc.cppm -grep -q 'Affected 1 of 3 tests' c1.log && grep -q 'unit \.\.\. ok' c1.log || fail "C: the change of an imported module" c1.log -grep -q 'test_smoke \.\.\.' c1.log && fail "C: a test not built from the change ran" c1.log -changed c2.log README.md -grep -q 'Affected 0 of 3 tests' c2.log || fail "C: a file nothing reads" c2.log -changed c3.log tests/data/f.txt -grep -q 'Affected 1 of 3 tests' c3.log && grep -q 'unit \.\.\. ok' c3.log || fail "C: a file a test names" c3.log -changed c4.log mcpp.toml -grep -q 'Affected 3 of 3 tests' c4.log || fail "C: the manifest affects every test" c4.log -git init -q . && git add -A && git -c user.name=t -c user.email=t@t commit -qm base -printf '\nexport int thrice(int x) { return 3 * x; }\n' >> src/calc.cppm -"$MCPP" test --since HEAD > c5.log 2>&1 || fail "C: --since" c5.log -grep -q 'Affected 1 of 3 tests' c5.log || fail "C: --since reads the working tree's change" c5.log - -# D -sed -i.bak '/^\[test\]/,$d' mcpp.toml -"$MCPP" test > d.log 2>&1 || fail "D: back to a program per file" d.log -grep -q 'unit/a \.\.\. ok' d.log && grep -q 'e2e/plain \.\.\. ok' d.log || fail "D: the programs per file" d.log -printf '\n[test]\nprograms = "directory"\n' >> mcpp.toml -"$MCPP" test > d2.log 2>&1 || fail "D: and back to the directories" d2.log - -# E -cd "$TMP" -"$MCPP" new rel > /dev/null 2>&1 -cd rel -cat > src/where.cppm <<'CPP' -export module rel.where; -import std; -export std::string library_file() { return std::source_location::current().file_name(); } -CPP -cat > tests/where.cpp <<'CPP' -import std; -import rel.where; -int main() { - const std::string here = std::source_location::current().file_name(); - std::println("test {}", here); - std::println("library {}", library_file()); - return std::filesystem::exists(std::filesystem::path(here).parent_path() / "test_smoke.cpp") ? 0 : 1; -} -CPP -"$MCPP" build --release > e.log 2>&1 || fail "E: the release build" e.log -"$MCPP" test --profile release where > e-test.log 2>&1 || fail "E: the release test (its file did not find its directory)" e-test.log -set -- target/*/*/build.ninja -[ $# = 1 ] || fail "E: build and test are $# configurations" e-test.log -grep -q "library rel@0.1.0/src/where.cppm" e-test.log || fail "E: the code under test is not trimmed as built" e-test.log -[ "$(grep -cE 'obj/where\.m\.(o|obj) ' "$(dirname "$1")/.ninja_log")" = 1 ] || fail "E: the test compiled the library again" "$(dirname "$1")/.ninja_log" - -# F -cd "$TMP" -"$MCPP" new grp > /dev/null 2>&1 -cd grp -cat > tests/test_smoke.cpp <<'CPP' -import mcpplibs.mtest; -using namespace mcpplibs::mtest; -UTest g{"g", [] { - "one"_test = [] { expect(true); }; - "two"_test = [] { expect(true); }; -}}; -CPP -"$MCPP" test --cases > f-cases.log 2>&1 || fail "F: --cases" f-cases.log -grep -q 'test_smoke: 1 case' f-cases.log || fail "F: the object is the one listed case" f-cases.log -"$MCPP" test --case 'g/one' > f1.log 2>&1 || fail "F: --case g/one" f1.log -grep -q 'test_smoke \.\.\. ok (.*1 case)' f1.log || fail "F: a declared case is not selected alone" f1.log -"$MCPP" test --case 'g/t*' > f2.log 2>&1 || fail "F: --case g/t*" f2.log -grep -q 'test_smoke \.\.\. ok (.*3 cases)' f2.log || fail "F: a pattern below the listed case does not run it whole" f2.log - -echo "PASS: 1038 test programs of a directory, and the tests a change affects" diff --git a/tests/e2e/1039_two_builds_that_find_the_std_module_missing_compile_it_once.sh b/tests/e2e/1039_two_builds_that_find_the_std_module_missing_compile_it_once.sh deleted file mode 100644 index fa3c59e9c..000000000 --- a/tests/e2e/1039_two_builds_that_find_the_std_module_missing_compile_it_once.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# resources: machine -# 1039 -- two builds that find the std module missing compile it once (#906 self-review). -# -# The std module of a toolchain and flag set is one entry of the home's build cache, shared by every -# build. Two processes that found it missing compiled into the same files at once: the second clang -# read a std.pcm the first was rewriting (SIGBUS), and a build went on with no std.o (e2e 957 and 987, -# run side by side on one runner). The entry is now built under its own lock. -# -# Criteria: with the entry two projects use removed, the two build at once and both succeed; the entry -# is whole afterwards (its BMI, object and record). -# -# It holds the machine: it removes an entry of the shared home's cache, which no other test may be -# building from at that moment. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python - -cd "$TMP" -for p in one two; do "$MCPP" new "$p" > /dev/null 2>&1 || fail "mcpp new $p"; done -(cd one && "$MCPP" build > ../first.log 2>&1) || fail "the first build" first.log - -# The entry the first build used: the std module whose record it marked last. -home="${MCPP_HOME:-$HOME/.mcpp}" -entry="$("$PY" - "$home/build-cache/v1/std" <<'PY' -import os, sys -root = sys.argv[1] -records = [os.path.join(root, d, "std-module.json") for d in os.listdir(root)] if os.path.isdir(root) else [] -records = [r for r in records if os.path.isfile(r)] -print(os.path.dirname(max(records, key=os.path.getmtime)) if records else "") -PY -)" -[ -n "$entry" ] || fail "no std module entry under $home/build-cache/v1/std" first.log -rm -rf "$entry" - -(cd one && "$MCPP" build > ../one.log 2>&1) & a=$! -(cd two && "$MCPP" build > ../two.log 2>&1) & b=$! -sa=0; sb=0; wait $a || sa=$?; wait $b || sb=$? -[ "$sa" = 0 ] || fail "the first of two builds at once (exit $sa)" one.log two.log -[ "$sb" = 0 ] || fail "the second of two builds at once (exit $sb)" one.log two.log -[ -f "$entry/std-module.json" ] || fail "the entry has no record afterwards" one.log two.log -ls "$entry"/*.o > /dev/null 2>&1 || fail "the entry has no object afterwards" one.log two.log - -echo "PASS: 1039 two builds that find the std module missing compile it once" diff --git a/tests/e2e/103_target_vocabulary.sh b/tests/e2e/103_target_vocabulary.sh deleted file mode 100755 index 0e53f7245..000000000 --- a/tests/e2e/103_target_vocabulary.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# Target-axis vocabulary (naming-unification design §4.2/§4.5/§6.3): -# 1. --target typo → hard error + did-you-mean (never a silent host build) -# 2. planned-tier target → hard error (registered, not yet supported) -# 3. custom triple + explicit [target.X] section → escape hatch proceeds -# 4. [build] target = "" → default build target without --target -# 5. alias spelling (--target x86_64-unknown-linux-musl) → canonical dir -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new vocab > /dev/null -cd vocab - -# ── 1. typo: hard error with did-you-mean ──────────────────────────────────── -if "$MCPP" build --target x86_64-linux-mus > "$TMP/typo.log" 2>&1; then - echo "FAIL: typo'd --target built successfully (silent host fallthrough)" - cat "$TMP/typo.log"; exit 1 -fi -grep -q "unknown target 'x86_64-linux-mus'" "$TMP/typo.log" || { - echo "FAIL: missing unknown-target error"; cat "$TMP/typo.log"; exit 1; } -grep -q "did you mean 'x86_64-linux-musl'" "$TMP/typo.log" || { - echo "FAIL: missing did-you-mean suggestion"; cat "$TMP/typo.log"; exit 1; } - -# ── 2. planned tier: registered but unsupported → error, not a host build ─── -if "$MCPP" build --target riscv64-linux-musl > "$TMP/planned.log" 2>&1; then - echo "FAIL: planned-tier target built"; cat "$TMP/planned.log"; exit 1 -fi -grep -q "not yet supported" "$TMP/planned.log" || { - echo "FAIL: missing planned-tier error"; cat "$TMP/planned.log"; exit 1; } - -# ── 3. escape hatch: custom triple with an explicit [target.X] section ────── -# (pin the host gcc so resolution succeeds; the point is validation lets an -# explicitly-declared custom triple through.) -cat >> mcpp.toml <<'EOF' - -[target.myboard-custom-elf] -toolchain = "gcc@16.1.0" -EOF -"$MCPP" build --target myboard-custom-elf > "$TMP/custom.log" 2>&1 || { - echo "FAIL: explicit [target.X] escape hatch rejected"; cat "$TMP/custom.log"; exit 1; } -grep -q "unknown target" "$TMP/custom.log" && { - echo "FAIL: escape hatch still errored"; cat "$TMP/custom.log"; exit 1; } - -# ── 4. [build] target: project default build target (cargo build.target) ──── -git checkout -- mcpp.toml 2>/dev/null || sed -i '/\[target.myboard-custom-elf\]/,+1d' mcpp.toml -if [[ -d "$HOME/.xlings/data/xpkgs/xim-x-musl-gcc" ]] \ - || [[ -d "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs/xim-x-musl-gcc" ]]; then - printf '\n[build]\ntarget = "x86_64-linux-musl"\n' >> mcpp.toml - rm -rf target - "$MCPP" build > "$TMP/bt.log" 2>&1 || { - echo "FAIL: [build] target build failed"; cat "$TMP/bt.log"; exit 1; } - [[ -d target/x86_64-linux-musl ]] || { - echo "FAIL: [build] target did not select the musl target dir" - ls target/; exit 1; } - - # ── 5. alias spelling lands in the same canonical directory ───────────── - "$MCPP" build --target x86_64-unknown-linux-musl > "$TMP/alias.log" 2>&1 || { - echo "FAIL: alias-spelling build failed"; cat "$TMP/alias.log"; exit 1; } - [[ -d target/x86_64-unknown-linux-musl ]] && { - echo "FAIL: alias spelling minted its own target dir"; exit 1; } -else - echo "(musl toolchain not installed — steps 4-5 skipped)" -fi - -echo "OK" diff --git a/tests/e2e/1040_a_platform_defines_a_target.sh b/tests/e2e/1040_a_platform_defines_a_target.sh deleted file mode 100644 index b9e62ec0f..000000000 --- a/tests/e2e/1040_a_platform_defines_a_target.sh +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1040 -- a platform defines a target the engine's table does not have (mcpp#911 G1; SPEC-016 §2). -# -# A platform package states `[[platform.define]]` rows: the triple, the triple its compiler is -# given, the object format, the family and the system the artifact runs on. The parser then accepts -# the row's system, `cfg(os = ...)` answers by it, the output directory is named after it, and the -# platform's provider serves it. The row here is a toy system whose artifact is a static Linux ELF, -# so the host runs it. -# -# Criteria: `--target x86_64-toyos` with the platform builds into target/x86_64-toyos, the -# `cfg(os = "toyos")` table applies, the program runs (`mcpp run`) and exits as written; without -# the platform the same request is an unknown target. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/toy" "$t/app/src" -cat > "$t/toy/mcpp.toml" < "$t/app/mcpp.toml" <<'EOF' -[package] -name = "toyapp" -version = "0.1.0" - -[build] -sources = ["src/*.c"] -ldflags = ["-nostdlib", "-static", "-no-pie"] - -[targets.toyapp] -kind = "bin" -main = "src/main.c" - -[target.x86_64-toyos] -platform = { path = "../toy" } - -[target.'cfg(os = "toyos")'.build] -cflags = ["-DTOY_SYSTEM=1"] -EOF -cat > "$t/app/src/main.c" <<'EOF' -#ifndef TOY_SYSTEM -#error "cfg(os = \"toyos\") did not apply" -#endif -void _start(void) { - __asm__ volatile("mov $60, %eax\n mov $7, %edi\n syscall"); -} -EOF -cd "$t/app" - -out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "the platform's row did not build" "$out" -grep -q "Target x86_64-toyos → x86_64-unknown-linux-gnu" <<<"$out" || fail "the report names no clang triple of the row" "$out" -grep -q "Platform toy-platform 0.1.0" <<<"$out" || fail "the platform is not reported" "$out" -exe=$(find target/x86_64-toyos -path '*/bin/toyapp' -type f | head -1) -[ -n "$exe" ] || fail "no artifact under target/x86_64-toyos" "$(ls -R target | head -30)" -if readelf -l "$exe" | grep -q INTERP; then fail "the artifact names a program interpreter: the host's C library leaked in"; fi -set +e; "$exe"; rc=$?; set -e -[ "$rc" = 7 ] || fail "the artifact exited $rc, not 7" -echo " ok the row builds into target/x86_64-toyos, cfg(os = \"toyos\") applies, and it runs" - -set +e; run=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e -[ "$rc" = 7 ] || fail "mcpp run ended $rc, not 7" "$run" -echo " ok mcpp run runs it on the system its row names (exit 7)" - -# A row whose artifacts run on another system: the build does not judge their runtime closure by -# this machine's loader, and says so. -sed -i 's/^execution = "linux"/execution = "toyos"/' "$t/toy/mcpp.toml" -out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "a row for another system did not build" "$out" -grep -q "the artifacts are for toyos, not this machine" <<<"$out" || fail "the build judged a foreign artifact by this machine" "$out" -echo " ok an artifact for another system is built and its closure is not judged here" - -# An artifact for another system is built, not run, without a runner; a runner states how it runs -# (mcpp#911 G11). -set +e; out=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e -[ "$rc" != 0 ] || fail "an artifact for toyos was run on this host" "$out" -grep -q "x86_64-toyos artifacts run on toyos, not on this .* host: built, not run" <<<"$out" \ - || fail "the refusal does not say built, not run" "$out" -sed -i 's|^platform = { path = "../toy" }$|&\nrunner = ["env", "{}"]|' mcpp.toml -set +e; out=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e -[ "$rc" = 7 ] || fail "the runner did not run it (exit $rc)" "$out" -sed -i '/^runner = \["env", "{}"\]$/d' mcpp.toml -echo " ok without a runner it is built, not run; a runner runs it" - -# The platform names its programs; a build program that names them for the host does not change -# the platform's suffix, and the build says so (mcpp#911 B2). -sed -i 's/^driver = "compiler"$/driver = "compiler"\nexe = "{name}.toy"/' "$t/toy/mcpp.toml" -cat > build.mcpp <<'EOF' -import std; -int main() { std::println("mcpp:artifact-name=exe:{{name}}"); } -EOF -out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "the build with the program's name did not succeed" "$out" -grep -q 'the platform toy-platform 0.1.0 names it `{name}.toy`: the platform.s name stands' <<<"$out" \ - || fail "the conflict of names is not reported" "$out" -[ -n "$(find target/x86_64-toyos -path '*/bin/toyapp.toy' -type f | head -1)" ] \ - || fail "the program is not named as the platform names it" "$(find target/x86_64-toyos -path '*/bin/*' | head)" -rm build.mcpp -echo " ok the platform's name of a program stands over a build program's" - -# A row a platform defines is packed as a built-in row is (mcpp#911 G10); a static program is not -# run to learn its closure. -out=$("$MCPP" pack --target x86_64-toyos 2>&1) || fail "the platform's row was not packed" "$out" -listing=$(tar tzf target/dist/toyapp-0.1.0-x86_64-toyos.tar.gz) -grep -q 'bin/toyapp.toy$' <<<"$listing" || fail "the package holds no program" "$listing" -echo " ok the platform's row is packed" - -sed -i '/^\[target.x86_64-toyos\]/,/^platform/d' mcpp.toml -out=$("$MCPP" build --target x86_64-toyos 2>&1 || true) -grep -q "unknown target 'x86_64-toyos'" <<<"$out" || fail "without the platform the request was not an unknown target" "$out" -echo " ok without the platform the request is an unknown target" -echo "PASS: 1040 a platform defines a target" diff --git a/tests/e2e/1041_a_32_bit_x86_bare_target.sh b/tests/e2e/1041_a_32_bit_x86_bare_target.sh deleted file mode 100755 index 22ac4be01..000000000 --- a/tests/e2e/1041_a_32_bit_x86_bare_target.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1041 -- 32-bit x86 without an operating system (mcpp#911 G12, G4; SPEC-016 §3). -# -# The row `i686-none-elf` compiles for the i686 instruction set and links with ld.lld directly -# under the emulation `elf_i386`. A platform package that states `link.driver = "compiler"` -# replaces the row's direct link: the compiler driver links, with the row's flags and ld.lld named -# by path. The program enters at `_start` and ends with the 32-bit system call `exit`, so an x86_64 -# Linux machine with IA-32 emulation runs it as it is. -# -# Criteria: the artifact is an ELF32 Intel 80386 executable; the link line is ld.lld's (`-m -# elf_i386`) without a platform and the compiler's (`--target=i686-none-elf ... -fuse-ld=`) with one; -# where the machine runs 32-bit programs, both artifacts exit 7. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/bsp" "$t/app/src" -cat > "$t/app/mcpp.toml" <<'EOF' -[package] -name = "boot32" -version = "0.1.0" - -[build] -sources = ["src/*.c"] - -[targets.boot32] -kind = "bin" -main = "src/main.c" -EOF -cat > "$t/app/src/main.c" <<'EOF' -#if !defined(__i386__) || defined(__x86_64__) -#error "not a 32-bit x86 compilation" -#endif -_Static_assert(sizeof(void*) == 4, "a 32-bit pointer"); -void _start(void) { - __asm__ volatile("mov $1, %eax\n mov $7, %ebx\n int $0x80"); -} -EOF -cd "$t/app" - -# A 32-bit program runs here when the kernel has IA-32 emulation; elsewhere the run is skipped. -runs32() { - [ "$(uname -m)" = x86_64 ] || return 1 - set +e; "$1"; local rc=$?; set -e - [ "$rc" = 7 ] && return 0 - [ "$rc" = 126 ] && return 1 - fail "the artifact exited $rc, not 7" -} -check() { # $1: what links; $2: the pattern of its link line - local exe ninja - exe=$(find target/i686-none-elf -path '*/bin/boot32' -type f | head -1) - [ -n "$exe" ] || fail "no artifact under target/i686-none-elf" - readelf -h "$exe" | grep -q 'ELF32' || fail "the artifact is not ELF32" "$(readelf -h "$exe")" - readelf -h "$exe" | grep -q 'Intel 80386' || fail "the artifact is not for i386" "$(readelf -h "$exe")" - ninja=$(find target/i686-none-elf -name build.ninja | head -1) - grep -qE "^c_ldflags += $2" "$ninja" || fail "$1 does not link" "$(grep -E '^c?_?ldflags' "$ninja")" - if runs32 "$exe"; then echo " ok $1 links an ELF32 i386 program that exits 7" - else echo " ok $1 links an ELF32 i386 program (this machine runs no 32-bit program)"; fi -} - -out=$("$MCPP" build --target i686-none-elf 2>&1) || fail "i686-none-elf did not build" "$out" -check "ld.lld under elf_i386" '-m elf_i386' - -cat > "$t/bsp/mcpp.toml" <> mcpp.toml <<'EOF' - -[target.i686-none-elf] -platform = { path = "../bsp" } -EOF -out=$("$MCPP" build --target i686-none-elf 2>&1) || fail "i686-none-elf with the platform did not build" "$out" -grep -q "Platform bsp32 0.1.0" <<<"$out" || fail "the platform is not reported" "$out" -check "the platform's compiler driver" '.*--target=i686-none-elf .*-fuse-ld=' -echo "PASS: 1041 a 32-bit x86 bare target" diff --git a/tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh b/tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh deleted file mode 100755 index 2063f83b4..000000000 --- a/tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1042 -- every flag names its layer, and a request its selection (mcpp#911 G2; SPEC-016 §4.5). -# -# `mcpp why flags ` lists each word of the unit's command line and of the link line with the -# layer that gave it: the row's machine description, the platform, the toolchain, the package, the -# engine. `mcpp why target` lists the candidates of a request in the order the selection took them. -# -# Criteria: `why flags` attributes `-march=x86-64` and `-mno-red-zone` to the row, `-ffreestanding` -# to the engine and the package's define to the package; `why target --format json` names the -# request, the identity and the candidates in order. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'EOF' -[package] -name = "bare" -version = "0.1.0" - -[build] -sources = ["src/*.c"] -cflags = ["-DBARE_APP=1"] - -[targets.bare] -kind = "bin" -main = "src/main.c" -EOF -cat > "$t/src/main.c" <<'EOF' -void _start(void) { __asm__ volatile("mov $60, %eax\n mov $7, %edi\n syscall"); } -EOF -cd "$t" - -why=$("$MCPP" why flags main.c --target x86_64-none-elf 2>/dev/null) || fail "why flags failed" "$why" -grep -Eq '^ row +-march=x86-64 +\[x86_64-none-elf\]' <<<"$why" || fail "the row's word does not name the row" "$why" -grep -Eq '^ row +-mno-red-zone +\[x86_64-none-elf\]' <<<"$why" || fail "the row's flag does not name the row" "$why" -grep -Eq '^ engine +-ffreestanding' <<<"$why" || fail "the engine's convention does not name the engine" "$why" -grep -Eq '^ package +-DBARE_APP=1 +\[bare\]' <<<"$why" || fail "the package's define does not name the package" "$why" -grep -Eq '^ row +-m elf_x86_64' <<<"$why" || fail "the link's emulation does not name the row" "$why" -echo " ok why flags names the layer of each word" - -json=$("$MCPP" why target --target x86_64-windows --format json 2>/dev/null) || fail "why target failed" "$json" -python3 - "$json" <<'PY' || fail "why target --format json does not state the selection" "$json" -import json, sys -t = json.loads(sys.argv[1])["data"]["target"] -assert t["request"] == "x86_64-windows", t -assert t["identity"] in ("x86_64-windows-msvc", "x86_64-windows-musl", "x86_64-windows-gnu"), t -ids = [c["identity"] for c in t["candidates"]] -assert ids[0] == "x86_64-windows-msvc", ids -assert any(c["identity"] == t["identity"] and c["provider"] == t["provider"] for c in t["candidates"]), t -PY -echo " ok why target states the request, the identity and the candidates in order" -echo "PASS: 1042 each flag names its layer and a request its selection" diff --git a/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh b/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh deleted file mode 100755 index 5f9eff268..000000000 --- a/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# requires: elf unix-shell -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1043 -- several programs are packed as stated, and a program whose name has a dot is packed -# (mcpp#911 B1, B3). -# -# A package with several programs is not packed without a word: the one named after the package, -# else the first by name, is packed, and the others are said with the two ways to choose -# otherwise, `mcpp pack ` and `mcpp pack --all-programs`. A manifest `mcpp pack` -# generates quotes a target name with a dot (`COMMAND.COM`), so the generated project parses. -# -# Criteria: with none named after the package the first by name is packed and the others are said; -# `--all-programs` ships both under bin/; a single-file -# pack of `COMMAND.COM` builds its launcher and the file runs with the program's exit status. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'EOF' -[package] -name = "multi" -version = "0.1.0" - -[targets.alpha] -kind = "bin" -main = "src/alpha.cpp" - -[targets."COMMAND.COM"] -kind = "bin" -main = "src/command.cpp" -EOF -echo 'int main() { return 0; }' > "$t/src/alpha.cpp" -echo 'int main() { return 3; }' > "$t/src/command.cpp" -cd "$t" - -out=$("$MCPP" pack 2>&1) || fail "several programs, none named after the package, were not packed" "$out" -grep -q "packing COMMAND.COM, the first by name, as none is named after the package" <<<"$out" \ - || fail "the program packed is not said" "$out" -grep -q "(alpha)" <<<"$out" && grep -q "mcpp pack --all-programs" <<<"$out" \ - || fail "the others and the ways to choose are not said" "$out" -rm -rf target/dist -echo " ok several programs, none named after the package: the first by name, the others said" - -out=$("$MCPP" pack --all-programs 2>&1) || fail "--all-programs did not pack" "$out" -listing=$(tar tzf target/dist/multi-0.1.0-*.tar.gz) -grep -q '/bin/alpha$' <<<"$listing" && grep -q '/bin/COMMAND.COM$' <<<"$listing" \ - || fail "--all-programs did not ship both programs" "$listing" -echo " ok --all-programs ships every program" - -rm -rf target/dist -out=$("$MCPP" pack COMMAND.COM --pkg-format single-file 2>&1) || fail "the single file of COMMAND.COM was not made" "$out" -file=$(find target/dist -maxdepth 1 -type f -name 'multi-0.1.0-*' | head -1) -[ -n "$file" ] || fail "no single file under target/dist" "$(ls target/dist)" -set +e; "$file"; rc=$?; set -e -[ "$rc" = 3 ] || fail "the single file exited $rc, not 3" -echo " ok a program named with a dot is packed into one file and runs" - -sed -i 's/^name = "multi"/name = "alpha"/' mcpp.toml -out=$("$MCPP" pack 2>&1) || fail "the program named after the package was not packed" "$out" -grep -q "packing alpha, the program named after the package; its other programs are not packed (COMMAND.COM)" <<<"$out" \ - || fail "the programs left out are not said" "$out" -echo " ok the program named after the package is packed and the others are said" -echo "PASS: 1043 several programs and a dotted name are packed as stated" diff --git a/tests/e2e/1045_a_required_capability_takes_its_default_provider.sh b/tests/e2e/1045_a_required_capability_takes_its_default_provider.sh deleted file mode 100755 index 4ffb9c00f..000000000 --- a/tests/e2e/1045_a_required_capability_takes_its_default_provider.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1045 -- a required capability takes its package's default provider (mcpp#911 G7; SPEC-002 §3.6). -# -# A package requires a capability (`requires = ["acme:greeter"]`) and states its default providers -# per target (`[defaults."acme:greeter"] linux = ..., "*" = ...`). With no provider in the graph, -# the default for the target is the package's dependency; a selector that names the target wins -# over `*`. A provider the consumer brings is used instead, and no default is added. -# -# Criteria: the build reports the default; the program returns the default's value (1, Linux); -# with the consumer depending on the other provider it returns that one's (2) and no default is -# reported. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/greet-a/src" "$t/greet-b/src" "$t/lib/src" "$t/app/src" -for p in a b; do -cat > "$t/greet-$p/mcpp.toml" < "$t/greet-a/src/a.cpp" -echo 'extern "C" int greet() { return 2; }' > "$t/greet-b/src/b.cpp" -cat > "$t/lib/mcpp.toml" <<'EOF' -[package] -name = "lib" -version = "0.1.0" -requires = ["acme:greeter"] - -[targets.lib] -kind = "lib" - -[defaults."acme:greeter"] -linux = { path = "../greet-a" } -"*" = { path = "../greet-b" } -EOF -echo 'extern "C" int greet(); int lib_greet() { return greet(); }' > "$t/lib/src/lib.cpp" -cat > "$t/app/mcpp.toml" <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -lib = { path = "../lib" } -EOF -echo 'int lib_greet(); int main() { return lib_greet(); }' > "$t/app/src/main.cpp" -cd "$t/app" - -out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" -grep -q "Default .*greet-a for acme:greeter of lib (linux)" <<<"$out" || fail "the default is not reported" "$out" -exe=$(find target -path '*/bin/app' -type f | head -1) -set +e; "$exe"; rc=$?; set -e -[ "$rc" = 1 ] || fail "the program returned $rc, not the Linux default's 1" -echo " ok the target's default provides the capability, over \`*\`" - -printf 'greet-b = { path = "../greet-b" }\n' >> mcpp.toml -out=$("$MCPP" build 2>&1) || fail "the build with the consumer's provider failed" "$out" -grep -q "Default " <<<"$out" && fail "a default was added though the graph provides the capability" "$out" -set +e; "$exe"; rc=$?; set -e -[ "$rc" = 2 ] || fail "the program returned $rc, not the consumer's provider's 2" -echo " ok the consumer's provider is used, and no default is added" -echo "PASS: 1045 a required capability takes its default provider" diff --git a/tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh b/tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh deleted file mode 100755 index 019f5a1b1..000000000 --- a/tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1046 -- a patch made with `git diff` from the package's root is applied (mcpp#911 G8; docs/05). -# -# A patch names files from the package's root, the directory of its resolved manifest, which -# `mcpp why deps` reports. A copy of that tree under git, edited, gives the patch with -# `git diff`; the edge's `patches` lists it, and the build reads the patched variant, whose root -# `mcpp why deps` then reports. -# -# Criteria: `mcpp why deps` reports the dependency's root; the patch git writes from a copy of it -# applies; the variant holds the change and is the root reported afterwards. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/app/src" -cd "$t/app" -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -mcpplibs.cmdline = "0.0.1" -EOF -echo 'int main() { return 0; }' > src/main.cpp - -out=$("$MCPP" why deps 2>&1) || fail "why deps failed" "$out" -root=$(sed -n '/^ mcpplibs.cmdline[@+]/,/^ [^ ]/s/^ root: //p' <<<"$out" | head -1) -[ -n "$root" ] && [ -d "$root" ] || fail "the dependency's root is not reported" "$out" -echo " ok why deps reports the root" - -cp -R "$root" "$t/cmdline" -file=$(cd "$t/cmdline" && find . -name parse.cppm | head -1); file=${file#./} -[ -n "$file" ] || fail "the root holds no parse.cppm" "$(ls -R "$t/cmdline" | head)" -(cd "$t/cmdline" && git init -q && git add -A && git -c user.name=t -c user.email=t@t commit -qm base \ - && echo '// changed for the patch' >> "$file") -mkdir -p patches -(cd "$t/cmdline" && git diff) > patches/0001-change.patch -grep -q -- "--- a/$file" patches/0001-change.patch || fail "git did not name the file from the root" "$(head -3 patches/0001-change.patch)" -sed -i.bak 's|^mcpplibs.cmdline = "0.0.1"$|mcpplibs.cmdline = { version = "0.0.1", patches = ["patches/0001-change.patch"] }|' mcpp.toml - -out=$("$MCPP" build 2>&1) || fail "the patched build failed" "$out" -out=$("$MCPP" why deps 2>&1) || fail "why deps failed after the patch" "$out" -variant=$(sed -n '/^ mcpplibs.cmdline[@+]/,/^ [^ ]/s/^ root: //p' <<<"$out" | head -1) -[ -n "$variant" ] && [ "$variant" != "$root" ] || fail "the variant is not the reported root" "$out" -grep -q 'changed for the patch' "$variant/$file" || fail "the variant does not hold the change" -echo " ok the patch applies and the variant is the root the build reads" -echo "PASS: 1046 a patch made with git diff from the package root is applied" diff --git a/tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh b/tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh deleted file mode 100755 index 4e1c01e78..000000000 --- a/tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1047 -- an artifact compiles the shared sources it links with its own flags (mcpp#911 G9). -# -# `[targets..build]` states defines and flags for every translation unit of the package the -# artifact links: its entry and the shared implementation sources, compiled as the artifact's own -# variant under obj/_variant// where they differ. The other artifacts link the package's -# objects as before; a module interface stays one per package. -# -# Criteria: the program with the define returns the shared source's defined value (5), the other -# returns the plain one (4); a variant object exists for the varied artifact only; an -# implementation unit that imports the package's module builds in both. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'EOF' -[package] -name = "shell" -version = "0.1.0" - -[targets.cmd] -kind = "bin" -main = "src/cmd.cpp" - -[targets.COMMAND] -kind = "bin" -main = "src/command.cpp" - -[targets.COMMAND.build] -defines = ["ENABLE_AUTOEXEC=1"] -EOF -cat > "$t/src/base.cppm" <<'EOF' -export module shell.base; -export int base_value() { return 4; } -EOF -cat > "$t/src/shell.cpp" <<'EOF' -import shell.base; -int autoexec() { -#ifdef ENABLE_AUTOEXEC - return base_value() + 1; -#else - return base_value(); -#endif -} -EOF -echo 'int autoexec(); int main() { return autoexec(); }' > "$t/src/cmd.cpp" -echo 'int autoexec(); int main() { return autoexec(); }' > "$t/src/command.cpp" -cd "$t" - -out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" -dir=$(dirname "$(find target -path '*/bin/cmd' -type f | head -1)") -set +e; "$dir/cmd"; a=$?; "$dir/COMMAND"; b=$?; set -e -[ "$a" = 4 ] || fail "cmd returned $a, not the shared source's plain 4" -[ "$b" = 5 ] || fail "COMMAND returned $b, not 5 from its own define" -[ -n "$(find target -path '*/obj/_variant/COMMAND/*' -name 'shell.o' | head -1)" ] || fail "no variant object of COMMAND" -[ -z "$(find target -path '*/obj/_variant/cmd*' | head -1)" ] || fail "an artifact without flags of its own has a variant" -echo " ok COMMAND compiles the shared source with its define; cmd links the package's object" -echo "PASS: 1047 an artifact compiles its shared sources with its own flags" diff --git a/tests/e2e/1048_a_git_reference_follows_the_default_branch.sh b/tests/e2e/1048_a_git_reference_follows_the_default_branch.sh deleted file mode 100755 index 30319b0a5..000000000 --- a/tests/e2e/1048_a_git_reference_follows_the_default_branch.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1048 -- a git reference with no tag, rev or branch follows the remote's default branch; a -# reference on the command line takes the same forms (SPEC-001 §5; SPEC-016 §8). -# -# The default branch is the one the remote announces (`git ls-remote --symref`), here `trunk`; the -# lock records it and its commit, and an offline build reads them. A dependency that names two of -# rev / tag / branch is refused. `--platform git+#tag=` selects a platform from a -# repository as a manifest's `{ git = ..., tag = ... }` does. -# -# Criteria: the build reports the default branch and runs; mcpp.lock names `#branch=trunk@`; -# an offline rebuild succeeds; two refs are refused; the platform from git builds its row. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -g() { git -c user.name=e2e -c user.email=e2e@example.invalid -c init.defaultBranch=trunk "$@"; } -mkdir -p "$t/lib/src" "$t/app/src" "$t/toy-platform" "$t/bare/src" -cat > "$t/lib/mcpp.toml" <<'EOF' -[package] -name = "gitlib" -version = "0.1.0" - -[targets.gitlib] -kind = "lib" -EOF -echo 'int gitlib_value() { return 9; }' > "$t/lib/src/lib.cpp" -(cd "$t/lib" && g init -q . && g add -A && g commit -qm init) -cat > "$t/app/mcpp.toml" < "$t/app/src/main.cpp" -cd "$t/app" - -out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" -grep -q "the default branch of .* is trunk" <<<"$out" || fail "the default branch is not reported" "$out" -set +e; "$(find target -path '*/bin/app' -type f | head -1)"; rc=$?; set -e -[ "$rc" = 9 ] || fail "the program returned $rc, not 9" -grep -Eq 'source = "git\+.*#branch=trunk@[0-9a-f]{40}"' mcpp.lock || fail "the lock does not record the branch and its commit" "$(cat mcpp.lock)" -rm -rf target -out=$("$MCPP" build --offline 2>&1) || fail "the offline build did not read the lock" "$out" -echo " ok the default branch is followed, locked, and read offline" - -sed -i "s|gitlib = { git = \"$t/lib\" }|gitlib = { git = \"$t/lib\", tag = \"v1\", branch = \"trunk\" }|" mcpp.toml -out=$("$MCPP" build 2>&1 || true) -grep -q "names 2 of rev / tag / branch; a git dependency names at most one" <<<"$out" || fail "two refs were not refused" "$out" -echo " ok a dependency that names two refs is refused" - -cat > "$t/toy-platform/mcpp.toml" < "$t/bare/mcpp.toml" <<'EOF' -[package] -name = "bare" -version = "0.1.0" - -[build] -sources = ["src/*.c"] -ldflags = ["-nostdlib", "-static", "-no-pie"] - -[targets.bare] -kind = "bin" -main = "src/main.c" -EOF -printf 'void _start(void) { __asm__ volatile("mov $60, %%eax\\n mov $7, %%edi\\n syscall"); }\n' > "$t/bare/src/main.c" -cd "$t/bare" -out=$("$MCPP" build --target x86_64-toyos --platform "git+$t/toy-platform#tag=v1" 2>&1) || fail "the platform from git did not build its row" "$out" -grep -q "Platform toy-platform 0.1.0" <<<"$out" || fail "the platform is not reported" "$out" -echo " ok --platform git+#tag= selects the platform from its repository" -echo "PASS: 1048 a git reference follows the default branch" diff --git a/tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh b/tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh deleted file mode 100755 index 7594296b7..000000000 --- a/tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1049 -- a platform skeleton is made, and its statement is read as the engine reads it -# (SPEC-016 §5). -# -# `mcpp new --template platform` writes a platform package that defines `-` and -# the provider that serves it; `mcpp why platform` lists the identities it serves. Reading the -# platform's manifest checks the statement against the rows: a definition of a built-in row is an -# error wherever the platform is read, and a provider that serves no row is a warning of the -# platform's own build. -# -# Criteria: `mcpp why platform` lists - with its provider and priority, in the platform -# and in a project that selects it; a program builds for the row; a provider serving nothing is a -# warning of the platform's build; a redefined built-in row fails the project's build and names it. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -cd "$t" -arch=$(uname -m); [ "$arch" = arm64 ] && arch=aarch64 -out=$("$MCPP" new myos --template platform 2>&1) || fail "the skeleton was not made" "$out" -grep -q "mcpp why platform" <<<"$out" || fail "the next step is not named" "$out" -out=$(cd myos && "$MCPP" why platform 2>&1) || fail "the skeleton is not read" "$out" -grep -Eq "serves +$arch-myos +myos +priority 100" <<<"$out" || fail "the served row is not listed" "$out" -echo " ok the skeleton serves $arch-myos" - -mkdir -p app/src -cat > app/mcpp.toml < app/src/main.c -out=$(cd app && "$MCPP" build --target "$arch-myos" 2>&1) || fail "a program did not build for the row" "$out" -out=$(cd app && "$MCPP" why platform --target "$arch-myos" 2>&1) || fail "the selected platform is not read" "$out" -grep -Eq "serves +$arch-myos +myos +priority 100" <<<"$out" || fail "the project's platform is not listed" "$out" -out=$(cd app && "$MCPP" why platform --target "$arch-myos" --format json 2>&1) || fail "no JSON" "$out" -grep -Eq '"identity": *"'"$arch"'-myos"' <<<"$out" || fail "the JSON lacks the served identity" "$out" -echo " ok a program builds for the row; the project reports the platform it selects" - -cat >> myos/mcpp.toml <<'EOF' - -[[platform.provider]] -name = "nothing" -serves = ["riscv64-nosuch"] -EOF -out=$(cd myos && "$MCPP" build 2>&1) || fail "a warning ended the platform's build" "$out" -grep -q "\[\[platform.provider\]\] 'nothing' serves no row" <<<"$out" || fail "a provider serving nothing is not said" "$out" - -cat >> myos/mcpp.toml <<'EOF' - -[[platform.define]] -triple = "x86_64-linux-gnu" -EOF -set +e; out=$(cd app && "$MCPP" build --target "$arch-myos" 2>&1); rc=$?; set -e -[ "$rc" != 0 ] || fail "a redefined built-in row did not fail the build" "$out" -grep -q "'x86_64-linux-gnu' is a built-in row" <<<"$out" || fail "the redefinition is not named" "$out" -echo " ok a provider serving nothing is a warning; a redefined built-in row fails the build" -echo "PASS: 1049 a platform skeleton is made and its statement read as the engine reads it" diff --git a/tests/e2e/104_asm_sources_gas.sh b/tests/e2e/104_asm_sources_gas.sh deleted file mode 100755 index 4eeadc948..000000000 --- a/tests/e2e/104_asm_sources_gas.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# GAS assembly sources (.S) as first-class citizens: globbed by the DEFAULT -# sources glob, routed to the asm_object rule (C driver, asm-safe flags), -# preprocessed (cpp macros work), linked into the binary, and covered on -# BOTH build and test paths (the 0.0.94 feature-sources lesson: always -# compare the two). -set -e - -# x86_64 assembly — host-aware skip elsewhere (aarch64 self-host e2e). -[[ "$(uname -m)" == "x86_64" ]] || { echo "SKIP: x86_64-only asm"; exit 77; } - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new gasmix > /dev/null -cd gasmix - -# .S is preprocessed: ASM_BIAS proves cpp ran before the assembler. -cat > src/add3.S <<'EOF' -#define ASM_BIAS 7 - .text - .globl asm_add3 -#if defined(__ELF__) - .type asm_add3, @function -#endif -asm_add3: - leaq (%rdi,%rsi), %rax - addq %rdx, %rax - addq $ASM_BIAS, %rax - ret -#if defined(__ELF__) - .section .note.GNU-stack,"",@progbits -#endif -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" long asm_add3(long a, long b, long c); -int main() { - std::println("asm add3 = {}", asm_add3(1, 2, 3)); - return asm_add3(1, 2, 3) == 13 ? 0 : 1; -} -EOF - -# NOTE: no [build].sources — the default glob must pick up the .S file. -cat > mcpp.toml <<'EOF' -[package] -name = "gasmix" -version = "0.1.0" -EOF -# The skeleton's test is mtest's; this manifest names no dev-dependency. -rm -f tests/test_smoke.cpp - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } - -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } - -grep -q '^rule asm_object' "$ninja_file" || { - cat "$ninja_file"; echo "missing asm_object rule"; exit 1; } -# Routed to asm_object with the extension-qualified object name (.S.o). -grep -qE 'build obj/add3\.S\.o : asm_object .*add3\.S' "$ninja_file" || { - cat "$ninja_file"; echo "add3.S not routed to asm_object"; exit 1; } -# The global asm flag string must not smuggle in a C/C++ standard. -grep -E '^asmflags =' "$ninja_file" | grep -q -- '-std=' && { - echo "asmflags carries a -std flag"; exit 1; } || true - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "asm add3 = 13" ]] || { echo "unexpected output: $out"; exit 1; } - -# Incremental: touching the .S recompiles and relinks successfully. -touch src/add3.S -"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "rebuild failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "asm add3 = 13" ]] || { echo "post-rebuild output: $out"; exit 1; } - -# Test path: the asm object must link into test binaries too. -mkdir -p tests -cat > tests/test_asm.cpp <<'EOF' -extern "C" long asm_add3(long a, long b, long c); -int main() { return asm_add3(2, 3, 4) == 16 ? 0 : 1; } -EOF -"$MCPP" test > test.log 2>&1 || { cat test.log; echo "mcpp test failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh b/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh deleted file mode 100755 index 4cfa6c801..000000000 --- a/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc python3 -# 1050 -- a test run is kept in the mtest store's form (docs/08; mtest spec P12). -# -# `mcpp test` writes `target/mtest/runs//` -- `run.json`, a line per process in -# `programs.ndjson`, a line per case in `cases.ndjson` -- and appends the run to -# `target/mtest/index.ndjson`. `[test] store` names another directory; `"none"` keeps nothing. -# -# Criteria: a run is one record of the index with its counts; each process states its status, -# duration, start and worker; each case its program and status; a second run is a second record; -# the tests of an archive run as the build's do -- their cases reported, the run stored; -# `store = "none"` adds none; `store = ""` writes there. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python - -cd "$TMP" -"$MCPP" new prog > new.log 2>&1 || fail "mcpp new" new.log -cd prog -"$MCPP" test > ../t1.log 2>&1 || fail "the first run failed" ../t1.log -[ -f target/mtest/index.ndjson ] || fail "no index" ../t1.log -"$PY" - <<'EOF' || fail "the stored run is not as stated" target/mtest/index.ndjson -import json, pathlib -store = pathlib.Path("target/mtest") -runs = [json.loads(l) for l in (store / "index.ndjson").read_text().splitlines() if l.strip()] -assert len(runs) == 1, runs -run = runs[0] -assert run["schema"] == "mtest.run/1" and run["status"] == "pass", run -assert run["counts"].get("pass", 0) >= 1, run -d = store / "runs" / run["id"] -assert json.loads((d / "run.json").read_text())["id"] == run["id"] -programs = [json.loads(l) for l in (d / "programs.ndjson").read_text().splitlines() if l.strip()] -assert programs and all(p["status"] == "pass" and p["ms"] >= 0 and p["started_ms"] >= 0 - and p["worker"] >= 0 for p in programs), programs -cases = [json.loads(l) for l in (d / "cases.ndjson").read_text().splitlines() if l.strip()] -assert cases and all(c["status"] == "pass" and c["program"] for c in cases), cases -EOF -echo " ok a run is kept with its processes and cases" - -"$MCPP" test > ../t2.log 2>&1 || fail "the second run failed" ../t2.log -[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 2 ] || fail "a second run is not a second record" target/mtest/index.ndjson -"$MCPP" test --no-run --archive ../tests.tar.gz > ../a1.log 2>&1 || fail "the archive was not written" ../a1.log -"$MCPP" test --archive ../tests.tar.gz > ../a2.log 2>&1 || fail "the archive's tests failed" ../a2.log -grep -q 'test_smoke \.\.\. ok (.*1 case)' ../a2.log || fail "an archived protocol program does not report its case" ../a2.log -[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 3 ] || fail "the archive's run is not stored" target/mtest/index.ndjson -echo " ok an archive's tests run as the build's: their case reported, the run stored" -printf '\n[test]\nstore = "none"\n' >> mcpp.toml -"$MCPP" test > ../t3.log 2>&1 || fail "the third run failed" ../t3.log -[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 3 ] || fail "store = none kept a run" target/mtest/index.ndjson -sed -i.bak 's|^store = "none"$|store = "out/test-runs"|' mcpp.toml -"$MCPP" test > ../t4.log 2>&1 || fail "the fourth run failed" ../t4.log -[ -f out/test-runs/index.ndjson ] || fail "store = did not write there" ../t4.log -echo " ok each run is a record; store = none keeps nothing; store = writes there" -echo "PASS: 1050 a test run is kept in the mtest store" diff --git a/tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh b/tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh deleted file mode 100755 index 1e7b747be..000000000 --- a/tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm gcc musl -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1051 -- a cross target's `[target.] toolchain` names the target's compiler, not the one the -# build program is compiled with. -# -# A build program is compiled and run on this machine. `[target.x86_64-linux-musl] toolchain = -# "gcc@16.1.0"` replaces the project's `[toolchain]` for the target; the build program takes the -# spec it replaced, as it takes the one a row's pin replaced (657). Measured on mcpp's own tree, -# built on Windows for x86_64-linux-musl: the build program was compiled by the musl cross gcc and -# the host could not start it (error 8, executable format error). -# -# Criteria: under `--target x86_64-linux-musl` the build program's compiler is the `[toolchain]` -# one (llvm), the target's is the section's (gcc), and the build program runs. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -[ "$(uname -m)" = x86_64 ] || { echo "SKIP: an x86_64 host"; exit 77; } -mkdir -p "$t/src" && cd "$t" -cat > mcpp.toml < build.mcpp <<'EOF' -import std; -import mcpp; -int main() { - std::ofstream(std::string(mcpp::out_dir()) + "/ran.txt") << "ran\n"; -} -EOF -echo 'int main() { return 0; }' > src/main.cpp -out=$("$MCPP" build --target x86_64-linux-musl 2>&1) || fail "the cross build failed" "$out" -grep -q 'host toolchain for build.mcpp: llvm' <<<"$out" \ - || grep -Eq 'Resolved host toolchain for build.mcpp: clang' <<<"$out" \ - || fail "the build program is not compiled by the [toolchain] compiler" "$out" -grep -q 'x86_64-linux-musl-g++' <<<"$out" || fail "the target is not compiled by the section's gcc" "$out" -find target -name ran.txt | grep -q . || fail "the build program did not run" "$(find target -maxdepth 4 | head)" -echo " ok the build program takes the [toolchain] compiler; the target takes its section's" -echo "PASS: 1051 a cross target's toolchain section is not the build program's" diff --git a/tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh b/tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh deleted file mode 100755 index 3151f4e8e..000000000 --- a/tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1052 -- a test framework that itself uses the package under test is built against that package. -# -# mcpplibs.os is tested with mtest, and mtest uses mcpplibs.os. A second copy of the package from -# the framework's own edge would provide every module of the package twice in one program; the -# package being built stands for itself in its own graph (docs/05), whatever version the edge asked -# for, and its tests reach the framework without a cycle of packages. -# -# Criteria: the root's test, written with a framework whose library imports the root's module, -# builds and passes; the root's module is compiled once; a version the edge did not ask for is said. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/core/src" "$t/core/tests" "$t/tf/src" -cat > "$t/core/mcpp.toml" <<'EOF' -[package] -namespace = "acme" -name = "core" -version = "0.2.0" - -[targets.core] -kind = "lib" - -[dev-dependencies] -acme.tf = { path = "../tf" } -EOF -cat > "$t/core/src/core.cppm" <<'EOF' -export module acme.core; -export int answer() { return 42; } -EOF -cat > "$t/core/tests/uses_tf.cpp" <<'EOF' -import acme.core; -import acme.tf; -int main() { return tf::check(answer() == 42) ? 0 : 1; } -EOF -cat > "$t/tf/mcpp.toml" <<'EOF' -[package] -namespace = "acme" -name = "tf" -version = "0.1.0" - -[targets.tf] -kind = "lib" - -[dependencies] -acme.core = { path = "../core", version = "0.1.0" } -EOF -cat > "$t/tf/src/tf.cppm" <<'EOF' -export module acme.tf; -import acme.core; -export namespace tf { bool check(bool held) { return held && answer() == 42; } } -EOF -cd "$t/core" -out=$("$MCPP" test 2>&1) || fail "the test of the package under its own framework failed" "$out" -grep -q 'uses_tf \.\.\. ok' <<<"$out" || fail "the test did not pass" "$out" -grep -q 'it is the package being built, 0.2.0, which stands for itself' <<<"$out" \ - || fail "the version the edge did not ask for is not said" "$out" -n=$(find target -name 'core.m.o' -o -name 'core.o' | grep -c 'acme_core\|/core' || true) -[ "$n" -le 1 ] || fail "the root's module was compiled more than once ($n)" "$(find target -name 'core*.o')" -echo " ok the framework is built against the package under test, and the test passes" -echo "PASS: 1052 a framework that uses the package under test is built against it" diff --git a/tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh b/tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh deleted file mode 100755 index fe9eb4a7e..000000000 --- a/tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# 1053 -- a Mach-O image's C library is up before the program's initialisers -# (2026.11.9.1). -# -# The loader of this format runs an image's initialisers before its entry, in -# the order the linker placed their objects. A C library of the graph -# (`mcpp:c-abi`: openkal-musl, in openkal-platform's hosted arrangement) is -# brought up by an initialiser of its own; the program's objects came first on -# the link line, and a global constructor that allocated -- the registration of -# an mtest case -- stopped in an allocator not yet started (mcpplibs/os#22, -# hosted macOS, EXC_BAD_ACCESS in mimalloc). The C library's objects now lead -# the link, as a C runtime's start objects do on ELF. -# -# Criteria: -# A. In the image built for `aarch64-macos-musl` over the hosted arrangement, the -# C library's initialiser (`okm_early_init`, openkal-musl's -# port/src/mach/early_init.c) precedes the program's own. -# B. On an arm64 macOS host the program runs, its global constructor -# allocating before `main`. -set -e -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mkdir -p "$TMP/app/src" -cd "$TMP/app" -printf '[package]\nname = "early"\nversion = "0.1.0"\n' > mcpp.toml -cat > src/main.cpp <<'CPP' -import std; -// A global constructor that allocates, as a test framework's registration does. -std::vector* registered = new std::vector{std::string(64, 'x')}; -int main() { - std::println("registered {}", registered->front().size()); - return 0; -} -CPP - -"$MCPP" build --target aarch64-macos-musl > build.log 2>&1 || fail "the hosted macOS build" build.log -grep -q "openkal-platform" build.log || { - echo "SKIP: aarch64-macos-musl is not served by openkal-platform's hosted arrangement here" - exit 77 -} -bin=$(find target -path '*/bin/early' -type f | head -1) -[ -n "$bin" ] || fail "no image was produced" build.log - -# A -objdump="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-objdump" -nm="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-nm" -[ -x "$objdump" ] && [ -x "$nm" ] || { echo "SKIP(A): no llvm@${LLVM_VERSION} payload to read the image with"; objdump=""; } -if [ -n "$objdump" ]; then - python3 - "$bin" "$objdump" "$nm" > a.log 2>&1 <<'PY' || fail "A: the order of the image's initialisers" a.log build.log -import subprocess, sys -image, objdump, nm = sys.argv[1:4] -dump = subprocess.run([objdump, "--macho", "-s", "-j", "__init_offsets", image], - capture_output=True, text=True, check=True).stdout -words = [] -for line in dump.splitlines(): - parts = line.split() - if len(parts) < 2 or not all(c in "0123456789abcdef" for c in parts[0]): - continue - for w in parts[1:5]: - if len(w) == 8 and all(c in "0123456789abcdef" for c in w): - words.append(int.from_bytes(bytes.fromhex(w), "little")) -symbols = [] -for line in subprocess.run([nm, "-n", image], capture_output=True, text=True, check=True).stdout.splitlines(): - parts = line.split() - if len(parts) == 3: - symbols.append((int(parts[0], 16), parts[2])) -def name_at(offset): - address = 0x100000000 + offset - best = None - for value, name in symbols: - if value > address: - break - best = name - return best or "?" -names = [name_at(o) for o in words] -print("initialisers:", names[:8]) -assert any("okm_early_init" in n for n in names), "the C library's initialiser is not in the image" -early = next(i for i, n in enumerate(names) if "okm_early_init" in n) -program = [i for i, n in enumerate(names) if "main.cpp" in n] -assert program, "the program's initialiser is not in the image" -assert early < program[0], f"the program's initialiser runs at {program[0]}, before the C library's at {early}" -PY - echo " ok the C library's initialiser precedes the program's" -fi - -# B -if [ "$(uname -s)-$(uname -m)" = Darwin-arm64 ]; then - "$bin" > b.log 2>&1 || fail "B: the image ended with $?" b.log - grep -q "registered 64" b.log || fail "B: the program's output" b.log - echo " ok the program runs, its constructor allocating before main" -fi - -echo "OK: a Mach-O image's C library is up before its initialisers" diff --git a/tests/e2e/1054_programs_that_share_an_entry_keep_their_own_flags.sh b/tests/e2e/1054_programs_that_share_an_entry_keep_their_own_flags.sh deleted file mode 100755 index f7eb26283..000000000 --- a/tests/e2e/1054_programs_that_share_an_entry_keep_their_own_flags.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1054 -- programs that begin at one file keep their own flags (design 2026-11-10 E2; mcpp#911 N1). -# -# Three programs name the same `main`. One states no flags, one states `defines` for its entry, one -# states `[targets..build]` for every unit it links. Each program is compiled with its own -# flags: the entry is one object for the programs that give it none, and one more under -# `obj/_entry//` for each program that does. Before, the first program's flags were -# compiled into the one object and linked into every program, without a word. -# -# Criteria: the programs print 1 10, 2 10 and 3 30; the shared object carries no FLAVOUR define. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'TOML' -[package] -name = "twin" -version = "0.1.0" - -[build] -sources = ["src/*.c", "!src/main.c"] - -[targets.plain] -kind = "bin" -main = "src/main.c" - -[targets.flagged] -kind = "bin" -main = "src/main.c" -defines = ["FLAVOUR=2"] - -[targets.variant] -kind = "bin" -main = "src/main.c" - -[targets.variant.build] -defines = ["FLAVOUR=3"] -TOML -cat > "$t/src/main.c" <<'C' -#include -#ifndef FLAVOUR -#define FLAVOUR 1 -#endif -int helper(void); -int main(void) { printf("%d %d\n", FLAVOUR, helper()); return 0; } -C -cat > "$t/src/helper.c" <<'C' -#ifndef FLAVOUR -#define FLAVOUR 1 -#endif -int helper(void) { return FLAVOUR * 10; } -C -cd "$t" -out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" -for pair in "plain:1 10" "flagged:2 10" "variant:3 30"; do - name=${pair%%:*}; want=${pair#*:} - exe=$(find target -path "*/bin/$name" -type f | head -1) - [ -n "$exe" ] || fail "no program $name" - got=$("$exe") - [ "$got" = "$want" ] || fail "$name printed '$got', not '$want'" -done -ninja=$(find target -name build.ninja | head -1) -stanza=$(awk '/^build obj\/twin\/src\/main.o /{on=1; print; next} on && /^build /{exit} on{print}' "$ninja") -[ -n "$stanza" ] || fail "no shared entry object in build.ninja" -grep -q 'FLAVOUR' <<<"$stanza" && fail "the shared entry object carries a program's define" "$stanza" -echo "PASS: 1054 programs that share an entry keep their own flags" diff --git a/tests/e2e/1055_layer_suppliers_that_require_one_another_are_refused.sh b/tests/e2e/1055_layer_suppliers_that_require_one_another_are_refused.sh deleted file mode 100755 index 3ebf9aa5a..000000000 --- a/tests/e2e/1055_layer_suppliers_that_require_one_another_are_refused.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1055 -- the suppliers of the target side may not require one another in a circle (design -# 2026-11-10 E14; SPEC-002 §3.2). -# -# A C library built on the kernel interface requires it; an implementation of the kernel interface -# written above a C library requires the C library. When both supply the graph's layers, neither is -# beneath the other: the composition used to build, and the program called itself at run time. -# -# Criteria: the build is refused at resolution, naming both suppliers and what each requires, with -# the code [layer-cycle] in its message, as refusals a measurement reads carry it; without the C library's requirement it resolves. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/kimpl/src" "$t/clib/src" "$t/app/src" -cat > "$t/kimpl/mcpp.toml" <<'TOML' -[package] -name = "kimpl" -version = "0.1.0" -provides = ["mcpp:kernel-abi=toykernel"] -requires = ["mcpp:c-abi"] - -[targets.kimpl] -kind = "lib" -TOML -echo 'int toy_kernel(void) { return 1; }' > "$t/kimpl/src/k.c" -cat > "$t/clib/mcpp.toml" <<'TOML' -[package] -name = "clib" -version = "0.1.0" -provides = ["mcpp:c-abi=toylibc"] -requires = ["mcpp:kernel-abi=toykernel"] - -[targets.clib] -kind = "lib" -TOML -echo 'int toy_libc(void) { return 2; }' > "$t/clib/src/c.c" -cat > "$t/app/mcpp.toml" <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -kimpl = { path = "../kimpl" } -clib = { path = "../clib" } - -[targets.app] -kind = "bin" -main = "src/main.c" -TOML -echo 'int main(void) { return 0; }' > "$t/app/src/main.c" -cd "$t/app" -set +e; out=$("$MCPP" build --message-format json 2>&1); rc=$?; set -e -[ "$rc" != 0 ] || fail "a circle of layer requirements built" "$out" -grep -q 'require one another in a circle' <<<"$out" || fail "the refusal names no circle" "$out" -grep -q '`kimpl@0.1.0` supplies the kernel-abi and requires the c-abi' <<<"$out" \ - || fail "the kernel interface's supplier is not named" "$out" -grep -q '`clib@0.1.0` supplies the c-abi and requires the kernel-abi' <<<"$out" \ - || fail "the C library's supplier is not named" "$out" -grep -q '\[layer-cycle\]' <<<"$out" || fail "the refusal carries no layer-cycle code" "$out" -echo " ok the circle is refused, naming both suppliers" - -sed -i '/^requires = \["mcpp:kernel-abi=toykernel"\]$/d' "$t/clib/mcpp.toml" -out=$("$MCPP" build 2>&1 || true) -grep -q 'require one another in a circle' <<<"$out" && fail "a one-way requirement was refused" "$out" -echo " ok a requirement in one direction is no circle" -echo "PASS: 1055 layer suppliers that require one another are refused" diff --git a/tests/e2e/1056_a_runner_reads_the_interfaces_its_implementation_provides.sh b/tests/e2e/1056_a_runner_reads_the_interfaces_its_implementation_provides.sh deleted file mode 100755 index 88a850c45..000000000 --- a/tests/e2e/1056_a_runner_reads_the_interfaces_its_implementation_provides.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1056 -- a package that requires the kernel-abi layer reads the interfaces its provider states -# (design 2026-11-10 E13; docs/30). -# -# The resolved kernel-abi provider states `[kernel-abi] provides-interfaces`. The build program of a -# package that requires the layer receives the list as MCPP_KERNEL_ABI_INTERFACES, joined by ';' -# (`mcpp::kernel_abi_interfaces()`), and a runner of images binds exactly those. A package that does -# not require the layer receives the variable empty, as it does when the provider states no list. -# -# Criteria: the requiring package's program reads `openkal.abort;openkal.fs`; the other package's -# program reads an empty value; with the provider's list removed the requiring program reads empty. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/impl/src" "$t/runner/src" "$t/other/src" "$t/app/src" -cat > "$t/impl/mcpp.toml" <<'TOML' -[package] -name = "impl" -version = "0.1.0" -provides = ["mcpp:kernel-abi=toykernel"] - -[kernel-abi] -provides-interfaces = ["openkal.abort", "openkal.fs"] - -[targets.impl] -kind = "lib" -TOML -echo 'int toy_kernel(void) { return 1; }' > "$t/impl/src/k.c" -program() { # $1: package, $2: requires line or empty -cat > "$t/$1/mcpp.toml" < "$t/$1/src/$1.c" -cat > "$t/$1/build.mcpp" < "$t/app/mcpp.toml" <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -impl = { path = "../impl" } -runner = { path = "../runner" } -other = { path = "../other" } - -[targets.app] -kind = "bin" -main = "src/main.c" -TOML -echo 'int main(void) { return 0; }' > "$t/app/src/main.c" -cd "$t/app" -out=$("$MCPP" build 2>&1 || true) -grep -q 'runner sees \[openkal.abort;openkal.fs\]' <<<"$out" || fail "the requiring package did not read the list" "$out" -grep -q 'other sees \[\]' <<<"$out" || fail "a package that does not require the layer read something" "$out" -echo " ok the requiring package reads the provider's interfaces; another package reads nothing" - -sed -i '/^\[kernel-abi\]$/,/^provides-interfaces/d' "$t/impl/mcpp.toml" -out=$("$MCPP" build 2>&1 || true) -grep -q 'runner sees \[\]' <<<"$out" || fail "an unstated list was not empty" "$out" -echo " ok a provider that states no list gives an empty value (unknown)" -echo "PASS: 1056 a runner reads the interfaces its implementation provides" diff --git a/tests/e2e/1057_a_platform_row_takes_nothing_of_the_payloads_host.sh b/tests/e2e/1057_a_platform_row_takes_nothing_of_the_payloads_host.sh deleted file mode 100755 index cabcfc077..000000000 --- a/tests/e2e/1057_a_platform_row_takes_nothing_of_the_payloads_host.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1057 -- a row a platform defines takes its machine description and nothing of the toolchain -# payload's own target side (design 2026-11-10 E1; mcpp#911 N2, N3, N7; SPEC-016 §2.1, §5). -# -# The engine's predicates read the rows a platform defines as they read the built-in ones: the -# row's `march` and `link-emulation` reach the compile and an `ld.lld` link, and the payload's libc++ -# headers and runtime directories -- built for the payload's own host -- reach neither the compile -# nor the link of a row whose C library the platform describes. -# -# Criteria: `mcpp why flags` names `-march=i386` and `-m elf_i386` as the row's; no word of the -# compile or the link names the payload's `include/c++/v1`, a `lib/` directory or the -# gcc runtime; the build succeeds and its program is ELF32. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/plat" "$t/app/src" -cat > "$t/plat/mcpp.toml" < "$t/app/mcpp.toml" <<'TOML' -[package] -name = "toy32" -version = "0.1.0" - -[targets.toy32] -kind = "bin" -main = "src/main.c" - -[target.i386-toy32] -platform = { path = "../plat" } -TOML -cat > "$t/app/src/main.c" <<'C' -_Static_assert(sizeof(void*) == 4, "a 32-bit pointer"); -void _start(void) { for (;;) {} } -C -cd "$t/app" -out=$("$MCPP" build --target i386-toy32 2>&1) || fail "the row did not build" "$out" -flags=$("$MCPP" why flags --target i386-toy32 2>&1) || fail "why flags failed" "$flags" -grep -qE '^ *row +-march=i386 ' <<<"$flags" || fail "the row's march is not the row's word" "$flags" -grep -qE '^ *row +-m elf_i386 ' <<<"$flags" || fail "the row's emulation is not on the link" "$flags" -echo " ok the row's machine description reaches the compile and the link" -if grep -E 'include/c\+\+/v1|/lib/[a-z0-9_]+-unknown-linux-gnu|gcc-runtime' <<<"$flags" | grep -v '^ *compiler '; then - fail "the payload's own target side reached a platform row" "$flags" -fi -echo " ok nothing of the payload's host side reaches the row" -exe=$(find target/i386-toy32 -path '*/bin/toy32' -type f | head -1) -[ -n "$exe" ] || fail "no program" -readelf -h "$exe" | grep -q ELF32 || fail "the program is not ELF32" "$(readelf -h "$exe")" -echo "PASS: 1057 a platform row takes nothing of the payload's host" diff --git a/tests/e2e/1058_a_gnu_abi_windows_row_compiles_its_resources.sh b/tests/e2e/1058_a_gnu_abi_windows_row_compiles_its_resources.sh deleted file mode 100755 index 1356d4330..000000000 --- a/tests/e2e/1058_a_gnu_abi_windows_row_compiles_its_resources.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# requires: mingw-cross -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1058 -- a GNU-ABI Windows row compiles its resources with the tool its toolchain carries (design -# 2026-11-10 E3; mcpp#911 N9). -# -# clang paired with the MinGW-w64 payload looked for a windres beside clang only and refused -# `[resources]`, while the payload's `bin/` held one. The search now includes the paired payload; -# a clang with no windres anywhere compiles with the `llvm-rc` beside it, whose `.res` `ld.lld` -# links on this row. A script that includes the toolchain's `"winres.h"` reads it from the -# payload's headers, not from the project: the paired payload's under llvm, and under the cross -# gcc of the `mingw` provider, which is its own payload, `/x86_64-w64-mingw32/include`. -# -# Criteria: the program builds with `[resources]` under llvm paired with MinGW-w64 and under the -# cross gcc, and carries a VERSIONINFO resource each time. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'TOML' -[package] -name = "verinfo" -version = "0.1.0" - -[resources] -files = ["app.rc"] - -[targets.verinfo] -kind = "bin" -main = "src/main.c" -TOML -echo 'int main(void) { return 0; }' > "$t/src/main.c" -cat > "$t/app.rc" <<'RC' -#include "winres.h" -1 VERSIONINFO -FILEVERSION 1,2,3,4 -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904b0" - BEGIN - VALUE "ProductName", "verinfo" - END - END -END -RC -cd "$t" -out=$("$MCPP" build --target x86_64-windows-gnu --toolchain "llvm@${LLVM_VERSION}" 2>&1) \ - || fail "[resources] did not build on x86_64-windows-gnu with llvm" "$out" -exe=$(find target/x86_64-windows-gnu -path '*/bin/verinfo.exe' -type f | head -1) -[ -n "$exe" ] || fail "no program" -# The llvm payload of the build, not the host's tools. -readobj="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-readobj" -[ -x "$readobj" ] || fail "no llvm-readobj in the llvm payload ($readobj)" -"$readobj" --coff-resources "$exe" | grep -q 'VERSIONINFO' || fail "the program carries no VERSIONINFO" "$("$readobj" --coff-resources "$exe" | head -20)" -# The cross gcc: before, its `"winres.h"` was taken for a file of the project that no action -# produces (`ninja: error: '/winres.h' ... missing`). -rm -rf target -out=$("$MCPP" build --target x86_64-windows-gnu 2>&1) \ - || fail "[resources] did not build on x86_64-windows-gnu with the cross gcc" "$out" -exe=$(find target/x86_64-windows-gnu -path '*/bin/verinfo.exe' -type f | head -1) -[ -n "$exe" ] || fail "no program from the cross gcc" -"$readobj" --coff-resources "$exe" | grep -q 'VERSIONINFO' || fail "the cross gcc's program carries no VERSIONINFO" -echo "PASS: 1058 a GNU-ABI Windows row compiles its resources" diff --git a/tests/e2e/1059_a_base_package_states_the_layers_beside_its_c_library.sh b/tests/e2e/1059_a_base_package_states_the_layers_beside_its_c_library.sh deleted file mode 100755 index b05206aef..000000000 --- a/tests/e2e/1059_a_base_package_states_the_layers_beside_its_c_library.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1059 -- a base package states the layers it holds beside its C library (design 2026-11-10 E5; -# SPEC-002 §2.5). -# -# A sysroot whose root carries a package manifest is a base package, read as any package is: its -# `provides` answers its layers and its `[build]` keys state them -- `include_dirs` the C++ -# library's headers, `ldflags` its libraries, `std-module` and `std-module-flags` its std module -- -# with paths relative to the package root, which is the sysroot. One package serves one identity; -# a platform gives each identity its own base through each provider's `sysroot`. -# -# The bases here are synthetic (a C++ header and a std module file, no C library to link), so the -# test reads the plan rather than a program: the report, the words of a unit and of the link. -# -# Criteria: the report names the c++-abi and compiler-runtime layers as the base's; a C++ unit -# carries the base's C++ headers and not the payload's; the link names the base's libraries, with -# -nostdlib++; each architecture's build takes the base its provider names. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/plat" "$t/app/src" -for arch in x86_64 aarch64; do - b="$t/$arch-toy-sysroot" - mkdir -p "$b/usr/include/c++/v1" "$b/usr/share/libc++/v1" "$b/usr/lib" - echo '#pragma once' > "$b/usr/include/c++/v1/toy_vector" - echo 'export module std;' > "$b/usr/share/libc++/v1/std.cppm" - cat > "$b/mcpp.toml" < "$t/plat/mcpp.toml" < "$t/app/mcpp.toml" <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[targets.app] -kind = "bin" -main = "src/main.cpp" - -[target.'cfg(os = "freebsd")'] -platform = { path = "../plat" } -TOML -echo 'int main() { return 0; }' > "$t/app/src/main.cpp" -cd "$t/app" -out=$("$MCPP" why flags --target x86_64-freebsd 2>&1) || fail "the plan over the base package failed" "$out" -grep -qE 'c\+\+-abi +libc\+\+ +\(x86_64-toy-sysroot@1.0, prebuilt' <<<"$out" || fail "the C++ layer is not the base's" "$out" -grep -qE 'compiler-runtime +libgcc +\(x86_64-toy-sysroot@1.0, prebuilt' <<<"$out" || fail "the compiler run-time is not the base's" "$out" -echo " ok the report names the base's layers" -grep -q -- "-isystem$t/x86_64-toy-sysroot/usr/include/c++/v1" <<<"$out" || fail "a unit lacks the base's C++ headers" "$out" -grep -E 'xim-x-llvm/[^ ]*/include/c\+\+/v1' <<<"$out" && fail "a unit carries the payload's C++ headers" "$out" -grep -q -- "--sysroot=$t/x86_64-toy-sysroot" <<<"$out" || fail "the sysroot is not the base's root" "$out" -echo " ok the base's headers and root stand where the payload's would" -grep -qE '(^| )-nostdlib\+\+( |$)' <<<"$out" && grep -qE '(^| )-lc\+\+( |$)' <<<"$out" \ - && grep -qE '(^| )-lcxxrt( |$)' <<<"$out" || fail "the link does not name the base's libraries" "$out" -echo " ok the link names the base's libraries" -out=$("$MCPP" why flags --target aarch64-freebsd 2>&1) || fail "the aarch64 plan failed" "$out" -grep -q -- "--sysroot=$t/aarch64-toy-sysroot" <<<"$out" || fail "aarch64 does not take its own base" "$out" -grep -qE 'c\+\+-abi +libc\+\+ +\(aarch64-toy-sysroot@1.0, prebuilt' <<<"$out" || fail "aarch64's C++ layer is not its base's" "$out" -echo " ok each identity takes the base its provider names" -out=$("$MCPP" why flags --target x86_64-freebsd-musl 2>&1) && fail "the musl row was served by a platform that does not serve it" "$out" -grep -q 'serves x86_64-freebsd, aarch64-freebsd and not x86_64-freebsd-musl' <<<"$out" \ - || fail "the refusal does not name what the platform serves" "$out" -echo " ok a row the chosen platform does not serve is refused, naming the rows it serves" -echo "PASS: 1059 a base package states the layers beside its C library" diff --git a/tests/e2e/105_asm_sources_nasm.sh b/tests/e2e/105_asm_sources_nasm.sh deleted file mode 100755 index ed731098d..000000000 --- a/tests/e2e/105_asm_sources_nasm.sh +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc nasm -# NASM assembly sources (.asm) as first-class citizens: default-globbed, -# routed to the nasm_object rule with `-f` derived from the target triple, -# include dirs + %include tracked through the depfile (-MD), excluded from -# compile_commands.json, and linked into the binary. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -[[ "$(uname -m)" == "x86_64" ]] || { echo "SKIP: x86-only NASM"; exit 77; } - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -TMP=$(mktemp -d) -COLD=$(mktemp -d) -WARM=$(mktemp -d) -trap "rm -rf '$TMP' '$COLD' '$WARM'" EXIT - -cd "$TMP" -"$MCPP" new nasmix > /dev/null -cd nasmix - -mkdir -p inc -cat > inc/consts.inc <<'EOF' -%define MUL_BIAS 5 -EOF - -cat > src/mul2.asm <<'EOF' -%include "consts.inc" -section .text -global asm_mul2 -asm_mul2: - lea rax, [rdi*2 + MUL_BIAS] - ret -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" long asm_mul2(long a); -int main() { - std::println("asm mul2 = {}", asm_mul2(10)); - return asm_mul2(10) == 25 ? 0 : 1; -} -EOF - -# No [build].sources — the default glob must pick up the .asm file. -cat > mcpp.toml <<'EOF' -[package] -name = "nasmix" -version = "0.1.0" - -[build] -include_dirs = ["inc"] -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } - -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } - -grep -q '^rule nasm_object' "$ninja_file" || { - cat "$ninja_file"; echo "missing nasm_object rule"; exit 1; } -grep -q '^nasmfmt = elf64' "$ninja_file" || { - cat "$ninja_file"; echo "nasmfmt not derived as elf64"; exit 1; } -grep -qE 'build obj/mul2\.asm\.o : nasm_object .*mul2\.asm' "$ninja_file" || { - cat "$ninja_file"; echo "mul2.asm not routed to nasm_object"; exit 1; } - -# NASM units must be excluded from the CDB (clangd can't read them). -if [[ -f compile_commands.json ]]; then - grep -q 'mul2\.asm' compile_commands.json && { - echo "mul2.asm leaked into compile_commands.json"; exit 1; } || true -fi - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "asm mul2 = 25" ]] || { echo "unexpected output: $out"; exit 1; } - -# %include dependency tracking: changing the .inc must recompile the .asm -# (via nasm -MD depfile) and change the observable result. -cat > inc/consts.inc <<'EOF' -%define MUL_BIAS 6 -EOF -"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "rebuild failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "asm mul2 = 26" ]] || { - echo "stale after %include change: $out (depfile tracking broken)"; exit 1; } - -echo "OK (warm PATH nasm)" - -# ─── #232 cold-env variant A: config-bootstrap error is not swallowed ── -# -# Root cause (issue #232): nasm used a bespoke, weaker provisioning path. -# An `if (cfgNasm)` guard silently swallowed a `get_cfg()` bootstrap -# failure, misreporting it as the generic "no usable nasm" message instead -# of the real cause. The fix surfaces `get_cfg()`'s error directly. -# -# To isolate THIS guard (as opposed to the toolchain-resolution gate, -# which already correctly hard-errors on a broken config), toolchain -# resolution must not itself need `get_cfg()`: `[toolchain] linux = -# "system"` is the escape hatch that resolves the compiler straight off -# $CXX/PATH, with no xlings/config involvement. A project that doesn't -# `import std;` also skips the (unrelated) std-module precompile cache, -# whose own cache-dir resolution is independent of this gate. That way -# the FIRST config-bootstrap failure the build ever surfaces is the one -# inside the nasm gate itself — deterministic, no network required. -HOSTCXX="" -for c in /usr/bin/clang++ /usr/bin/g++ clang++ g++ c++; do - p="$(command -v "$c" 2>/dev/null)" && { HOSTCXX="$p"; break; } -done - -if [[ -z "$HOSTCXX" ]]; then - echo "SKIP: no host C++ compiler found for the cold-env (#232) variant" -else - cd "$COLD" - "$MCPP" new nasmix-cold > /dev/null - cd nasmix-cold - - mkdir -p inc - cat > inc/consts.inc <<'EOF' -%define MUL_BIAS 5 -EOF - cat > src/mul2.asm <<'EOF' -%include "consts.inc" -section .text -global asm_mul2 -asm_mul2: - lea rax, [rdi*2 + MUL_BIAS] - ret -EOF - cat > src/main.cpp <<'EOF' -#include -extern "C" long asm_mul2(long a); -int main() { - printf("asm mul2 = %ld\n", asm_mul2(10)); - return asm_mul2(10) == 25 ? 0 : 1; -} -EOF - cat > mcpp.toml <<'EOF' -[package] -name = "nasmix-cold" -version = "0.1.0" - -[toolchain] -linux = "system" - -[build] -include_dirs = ["inc"] -EOF - - # A regular FILE where $MCPP_HOME must be a directory: config - # bootstrap's directory-creation loop fails deterministically, no - # network involved. - : > "$COLD/brokenhome" - - if out=$(MCPP_HOME="$COLD/brokenhome" CXX="$HOSTCXX" "$MCPP" build 2>&1); then - echo "cold-env (#232) build unexpectedly succeeded:" - echo "$out" - exit 1 - fi - echo "$out" | grep -qi "cannot create" || { - echo "cold-env build failed, but not with the real bootstrap error:" - echo "$out"; exit 1; } - if echo "$out" | grep -qi "no usable nasm"; then - echo "REGRESSION (#232): the guard swallowed the config-bootstrap" - echo "error and fell back to the generic 'no usable nasm' message:" - echo "$out" - exit 1 - fi - - echo "OK (cold-env: real bootstrap error surfaced, not swallowed)" -fi - -# ─── #232 cold-env variant B: nasm absent everywhere routes through the ── -# ─── synchronous fetcher gate (never the retired bespoke message) ─────── -# -# nasm hidden from both PATH (decoy, too-old version) and the sandbox -# forces the provisioning branch: `Fetcher::resolve_xpkg_path("xim:nasm@…", -# autoInstall=true, …)` — the SAME gate the compiler toolchain uses. This -# sandbox has network, but package-index/mirror conditions can be flaky in -# CI (rate limiting, mirror selection), so the assertion is deliberately -# robust to either outcome: a successful synchronous install (the common -# case) OR a real, specific failure. What #232 guarantees either way is -# that the OLD bespoke path's generic "no usable nasm ... was found or -# installable" message (which fires even when the real cause is a broken -# index/network) never appears again. -export MCPP_HOME="$WARM/home" -mkdir -p "$MCPP_HOME" -source "$SCRIPT_DIR/_inherit_toolchain.sh" -# Force a cold nasm even if the host machine already has it installed. -rm -rf "$MCPP_HOME/registry/data/xpkgs/xim-x-nasm" - -DECOY="$WARM/decoy" -mkdir -p "$DECOY" -cat > "$DECOY/nasm" <<'EOF' -#!/usr/bin/env bash -# Deliberately too old (< 2.16): the version gate must reject it so the -# PATH probe falls through to sandbox lookup / provisioning, never a -# false "found". -[[ "$1" == "-v" ]] && echo "NASM version 2.10.00 compiled on Jan 1 2020" -exit 0 -EOF -chmod +x "$DECOY/nasm" -export PATH="$DECOY:$PATH" - -mkdir -p "$WARM/proj" -cd "$WARM/proj" -"$MCPP" new nasmix-warm > /dev/null -cd nasmix-warm - -mkdir -p inc -cat > inc/consts.inc <<'EOF' -%define MUL_BIAS 5 -EOF -cat > src/mul2.asm <<'EOF' -%include "consts.inc" -section .text -global asm_mul2 -asm_mul2: - lea rax, [rdi*2 + MUL_BIAS] - ret -EOF -cat > src/main.cpp <<'EOF' -import std; -extern "C" long asm_mul2(long a); -int main() { - std::println("asm mul2 = {}", asm_mul2(10)); - return asm_mul2(10) == 25 ? 0 : 1; -} -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "nasmix-warm" -version = "0.1.0" - -[build] -include_dirs = ["inc"] -EOF - -if out=$("$MCPP" build 2>&1); then - # Synchronous provisioning succeeded: nasm must have actually landed - # in the sandbox (proves the fetcher gate — not the decoy — supplied - # the binary) and the program must run correctly. - find "$MCPP_HOME/registry/data/xpkgs/xim-x-nasm" -type f -name 'nasm*' 2>/dev/null | grep -q . || { - echo "build succeeded but nasm never landed in the sandbox (#232 gate not exercised):" - echo "$out"; exit 1; } - run_out="$("$MCPP" run 2>&1 | tail -1)" - [[ "$run_out" == "asm mul2 = 25" ]] || { - echo "cold-provisioned build produced wrong output: $run_out"; exit 1; } - echo "OK (cold-env: nasm synchronously provisioned via the fetcher gate)" -else - if echo "$out" | grep -qi "no usable nasm"; then - echo "REGRESSION (#232): fell back to the old generic 'no usable" - echo "nasm' message instead of the real provisioning failure:" - echo "$out" - exit 1 - fi - echo "$out" | grep -Eqi "provisioning failed|xlings install" || { - echo "cold-env build failed without a real provisioning error message:" - echo "$out"; exit 1; } - echo "NOTE: nasm cold-provisioning hit an environment/network condition" - echo "in this sandbox (not a #232 regression) — the real error surfaced" - echo "correctly instead of the old generic message:" - echo "$out" | grep -i "provisioning failed" -fi - -echo "OK" diff --git a/tests/e2e/1060_a_patched_member_of_a_repository_keeps_its_siblings.sh b/tests/e2e/1060_a_patched_member_of_a_repository_keeps_its_siblings.sh deleted file mode 100755 index e31b9c1d5..000000000 --- a/tests/e2e/1060_a_patched_member_of_a_repository_keeps_its_siblings.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1060 -- a patched member of a git repository keeps its siblings (design 2026-11-10 E6; mcpp#911 -# N5; docs/05). -# -# A repository's root manifest lists members; one member reaches another by `path = "../core"`. -# Patching the first through a git dependency used to copy the member alone, so its `path` edge -# named a directory the copy did not have. The variant is now a copy of the repository, the patch -# names files from the member's root, and the sibling edge holds. -# -# Criteria: the build with the patch succeeds; the program prints the patched value (2) beside -# the sibling's (40). -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -repo="$t/repo"; mkdir -p "$repo/core/src" "$repo/front/src" "$t/app/src" "$t/app/patches" -cat > "$repo/mcpp.toml" <<'TOML' -[workspace] -members = ["core", "front"] -TOML -cat > "$repo/core/mcpp.toml" <<'TOML' -[package] -name = "toy-core" -version = "0.1.0" - -[targets.toy-core] -kind = "lib" -TOML -echo 'int core_value(void) { return 40; }' > "$repo/core/src/core.c" -cat > "$repo/front/mcpp.toml" <<'TOML' -[package] -name = "toy-front" -version = "0.1.0" - -[dependencies] -toy-core = { path = "../core" } - -[targets.toy-front] -kind = "lib" -TOML -echo 'int front_value(void) { return 1; }' > "$repo/front/src/front.c" -(cd "$repo" && git init -q && git add -A && git -c user.name=t -c user.email=t@t commit -qm base && git tag v1) -# The patch, from the member's root: front_value returns 2. -cp -R "$repo/front" "$t/front-edit" -(cd "$t/front-edit" && rm -rf .git && git init -q && git add -A && git -c user.name=t -c user.email=t@t commit -qm b \ - && sed -i 's/return 1;/return 2;/' src/front.c && git diff) > "$t/app/patches/0001-front.patch" -grep -q -- '--- a/src/front.c' "$t/app/patches/0001-front.patch" || fail "git wrote no member-rooted patch" -cat > "$t/app/mcpp.toml" < "$t/app/src/main.c" <<'C' -#include -int front_value(void); int core_value(void); -int main(void) { printf("%d %d\n", front_value(), core_value()); return 0; } -C -cd "$t/app" -out=$("$MCPP" build 2>&1) || fail "the patched member did not build" "$out" -got=$("$(find target -path '*/bin/app' -type f | head -1)") -[ "$got" = "2 40" ] || fail "the program printed '$got', not '2 40'" -echo "PASS: 1060 a patched member of a repository keeps its siblings" diff --git a/tests/e2e/1061_an_artifact_for_another_system_is_packed_without_running_it.sh b/tests/e2e/1061_an_artifact_for_another_system_is_packed_without_running_it.sh deleted file mode 100755 index a0fcf583b..000000000 --- a/tests/e2e/1061_an_artifact_for_another_system_is_packed_without_running_it.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1061 -- an artifact for another system is packed without running it (design 2026-11-10 E7; -# mcpp#911 N8; docs/10). -# -# A row whose C library a platform describes and whose programs run on another system: the -# vendored closure is read from the program's DT_NEEDED rather than from the host's loader (`ldd` -# of a FreeBSD program exited 127). A library this build produced travels with the program; every -# other name is the target system's. `--mode static` links the program statically on a named row -# too, and the file is asked before it is packed under that name. -# -# Criteria: `mcpp pack` (vendored) packs the program and its library and runs no host loader; -# `--mode static` packs a program that names neither a loader nor a shared library. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/plat" "$t/app/src" "$t/awaylib/src" -cat > "$t/plat/mcpp.toml" < "$t/awaylib/mcpp.toml" <<'TOML' -[package] -name = "awaylib" -version = "0.1.0" - -[targets.awaylib] -kind = "shared" -TOML -echo 'int away_value(void) { return 7; }' > "$t/awaylib/src/away.c" -cat > "$t/app/mcpp.toml" <<'TOML' -[package] -name = "away" -version = "0.1.0" - -[dependencies] -awaylib = { path = "../awaylib" } - -[targets.away] -kind = "bin" -main = "src/main.c" - -[target.x86_64-awayos] -platform = { path = "../plat" } -TOML -cat > "$t/app/src/main.c" <<'C' -int away_value(void); -void _start(void) { (void)away_value(); for (;;) {} } -C -cd "$t/app" -out=$("$MCPP" build --target x86_64-awayos 2>&1) || fail "the row did not build" "$out" -exe=$(find target/x86_64-awayos -path '*/bin/away' -type f | head -1) -[ -n "$exe" ] || fail "no program" "$out" -readelf -d "$exe" | grep -q NEEDED || fail "the program names no shared library, so this test tests nothing" "$(readelf -d "$exe")" -out=$("$MCPP" pack --target x86_64-awayos --all-programs 2>&1) || fail "the vendored pack failed" "$out" -grep -qi 'ldd failed' <<<"$out" && fail "the host's ldd was run on the artifact" "$out" -listing=$(tar tzf target/dist/away-0.1.0-x86_64-awayos.tar.gz) -grep -q '/bin/away$' <<<"$listing" || fail "the program is not packed" "$listing" -grep -qE 'libawaylib[^/]*\.so' <<<"$listing" || fail "the library the build produced does not travel" "$listing" -echo " ok the closure is read from the file, and the build's library travels" -# `--mode static` is a static program on a named row as well (N8: the program was packed dynamic -# under the `-static` name); the library, a static archive here, links into it. -sed -i 's/^kind = "shared"$/kind = "lib"/' "$t/awaylib/mcpp.toml" -out=$("$MCPP" pack --target x86_64-awayos --mode static 2>&1) || fail "the static pack failed" "$out" -static_exe=$(tar tzf target/dist/away-0.1.0-x86_64-awayos-static.tar.gz | grep '/bin/away$' | head -1) -tar xzf target/dist/away-0.1.0-x86_64-awayos-static.tar.gz -C "$t" "$static_exe" -if readelf -d "$t/$static_exe" 2>/dev/null | grep -q NEEDED || readelf -l "$t/$static_exe" | grep -q INTERP; then - fail "--mode static packed a dynamic program" "$(readelf -dl "$t/$static_exe" | head -20)" -fi -echo " ok --mode static packs a static program" -echo "PASS: 1061 an artifact for another system is packed without running it" diff --git a/tests/e2e/1062_link_flags_on_a_compiler_link_are_named_not_dropped.sh b/tests/e2e/1062_link_flags_on_a_compiler_link_are_named_not_dropped.sh deleted file mode 100755 index 76d8c69be..000000000 --- a/tests/e2e/1062_link_flags_on_a_compiler_link_are_named_not_dropped.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1062 -- `[platform.link] flags` on a platform that links through the compiler are named, not -# dropped in silence (design 2026-11-10 E8; mcpp#911 N6; SPEC-013 §4). -# -# `link.flags` are words for ld.lld itself. A platform whose `driver` is "compiler" gives them to -# no program; the build says so and names `[platform.build] ldflags`, whose words do reach the -# compiler's link. -# -# Criteria: the build of such a platform warns, naming `[platform.link] flags` and -# `[platform.build] ldflags`, and succeeds; the same platform with `driver = "lld"` does not warn. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/plat" "$t/app/src" -cat > "$t/plat/mcpp.toml" < "$t/app/mcpp.toml" <<'TOML' -[package] -name = "words" -version = "0.1.0" - -[targets.words] -kind = "bin" -main = "src/main.c" - -[target.x86_64-wordsos] -platform = { path = "../plat" } -TOML -echo 'void _start(void) { for (;;) {} }' > "$t/app/src/main.c" -cd "$t/app" -out=$("$MCPP" build --target x86_64-wordsos 2>&1) || fail "the build failed" "$out" -grep -q 'states `\[platform.link\] flags`' <<<"$out" || fail "the dropped words were not named" "$out" -grep -q '`\[platform.build\] ldflags`' <<<"$out" || fail "the remedy was not named" "$out" -echo " ok link words a compiler link does not use are named" -sed -i 's/^driver = "compiler"$/driver = "lld"/; s/^ldflags = .*$//' "$t/plat/mcpp.toml" -rm -rf target -out=$("$MCPP" build --target x86_64-wordsos 2>&1) || fail "the lld build failed" "$out" -if grep -q 'platform.link\] flags' <<<"$out"; then fail "an lld link was warned" "$out"; fi -echo " ok an ld.lld link takes them without a word" -echo "PASS: 1062 link flags on a compiler link are named, not dropped" diff --git a/tests/e2e/1064_a_scan_override_of_a_file_this_build_excludes_is_kept.sh b/tests/e2e/1064_a_scan_override_of_a_file_this_build_excludes_is_kept.sh deleted file mode 100755 index 58a61eda3..000000000 --- a/tests/e2e/1064_a_scan_override_of_a_file_this_build_excludes_is_kept.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1064 -- a scan override of a file this build excludes is kept for the builds that compile it -# (design 2026-11-10 E10; mcpp#902; docs/04 §2.8.3). -# -# A `[scan_overrides.""]` whose files exist and take no part in this build -- a feature that -# is off, a `!` rule of a `[target..build]` -- describes them for the builds that do -# compile them. A glob that matches no file of the package is still a typo. -# -# Criteria: with the feature off and with the file excluded by a target rule, the build succeeds; -# with the feature on, the overridden unit (an `import std` under `#ifdef`, which the scanner -# refuses) builds; a glob naming no file is refused. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'TOML' -[package] -name = "sorepro" -version = "0.1.0" - -[build] -sources = ["src/core.cppm", "src/extra.cppm", "src/only_elsewhere.cppm"] - -[features] -default = [] -[features.extra] -sources = ["src/extra.cppm"] - -[target.'cfg(unix)'.build] -sources = ["!src/only_elsewhere.cppm"] - -[scan_overrides."src/extra.cppm"] -provides = ["sorepro.extra"] -imports = [] - -[scan_overrides."src/only_elsewhere.cppm"] -provides = ["sorepro.elsewhere"] -imports = [] - -[lib] -path = "src/core.cppm" -TOML -echo 'export module sorepro; export int core() { return 1; }' > "$t/src/core.cppm" -cat > "$t/src/extra.cppm" <<'CPP' -module; -#ifdef SOREPRO_USE_STD_MODULE -import std; -#endif -export module sorepro.extra; -export int extra() { return 2; } -CPP -cat > "$t/src/only_elsewhere.cppm" <<'CPP' -module; -#ifdef SOREPRO_USE_STD_MODULE -import std; -#endif -export module sorepro.elsewhere; -export int elsewhere() { return 3; } -CPP -cd "$t" -out=$("$MCPP" build --toolchain "llvm@${LLVM_VERSION}" 2>&1) || fail "the build with the feature off failed" "$out" -echo " ok overrides of a file a feature leaves out and of one a target rule removes are kept" -out=$("$MCPP" build --toolchain "llvm@${LLVM_VERSION}" --features extra 2>&1) || fail "the build with the feature on failed" "$out" -echo " ok with the feature on, the overridden unit builds" -cat >> mcpp.toml <<'TOML' - -[scan_overrides."src/nowhere.cppm"] -provides = ["sorepro.nowhere"] -imports = [] -TOML -if out=$("$MCPP" build --toolchain "llvm@${LLVM_VERSION}" 2>&1); then fail "a glob naming no file was accepted" "$out"; fi -grep -q "scan_overrides glob 'src/nowhere.cppm' matches no file of the package" <<<"$out" \ - || fail "the refusal does not name the glob" "$out" -echo " ok a glob that names no file is refused" -echo "PASS: 1064 a scan override of a file this build excludes is kept" diff --git a/tests/e2e/1065_test_records_carry_parts_live_cases_and_metrics.sh b/tests/e2e/1065_test_records_carry_parts_live_cases_and_metrics.sh deleted file mode 100755 index 0e0e12150..000000000 --- a/tests/e2e/1065_test_records_carry_parts_live_cases_and_metrics.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc python3 -# 1065 -- `mcpp test` states a program's parts, each case as it happens, and the metrics, bounds, -# kind and tags of its cases, in its records and in the run it keeps (mcpp#917; docs/50 §8; mtest -# spec P5.1, P6, P10.2, P12). -# -# Criteria: -# A. `--cases --message-format json`: a program of several files with cases in two of them has -# `parts`, each `{name, file}` named as the run names it (`unit/a`, `unit/b`). -# B. `--message-format json`: a `case` record for each case's beginning and end comes before -# its program's record; the end carries the case's status, metrics and bounds; a part's record -# states `part_of`; a case in the record's `cases` has its kind, metrics and bounds. -# C. The run kept under target/mtest has, per case, `kind`, `started_ms`, `metrics` and `bounds` -# (P12). -# D. `self env` names the features `test-parts`, `test-case-stream` and `test-metrics`. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; tail -40 "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -"$MCPP" new app > /dev/null 2>&1 -cd app -mkdir -p tests/unit -cat > tests/unit/a.cpp <<'CPP' -import mcpplibs.mtest; -import mcpplibs.mtest.bench; -using namespace mcpplibs::mtest; -Bench timed{"timed", at_most{"spin", 10.0}, [] { - bench::measure("spin", [] { int x = 0; for (int i = 0; i < 100; ++i) bench::keep(x += i); }, - {.samples = 3, .warmup = 0}); -}}; -CPP -cat > tests/unit/b.cpp <<'CPP' -import mcpplibs.mtest; -using namespace mcpplibs::mtest; -UTest plain{"plain", tag{"quick"}, [] { expect(eq(1 + 1, 2)); }}; -CPP -printf '\n[test]\nprograms = "directory"\n' >> mcpp.toml - -# A -"$MCPP" test --cases --message-format json > a.json 2> a.err || fail "A: --cases" a.json a.err -python3 - a.json <<'PY' || fail "A: the parts" a.json -import json, sys -recs = [json.loads(l) for l in open(sys.argv[1]) if l.startswith("{")] -unit = next(r for r in recs if r.get("test") == "unit") -names = sorted(p["name"] for p in unit["parts"]) -assert names == ["unit/a", "unit/b"], names -assert all(p["file"].endswith(("a.cpp", "b.cpp")) for p in unit["parts"]), unit["parts"] -PY -echo " ok the listing names the parts a run divides the program into" - -# B -"$MCPP" test --message-format json > b.json 2> b.err || fail "B: the run" b.json b.err -python3 - b.json <<'PY' || fail "B: the records" b.json -import json, sys -recs = [json.loads(l) for l in open(sys.argv[1]) if l.startswith("{")] -order = [("case", r["case"]["test"], r["case"]["event"], r["case"]["id"]) if "case" in r - else ("test", r.get("test")) for r in recs if "case" in r or "test" in r] -for part in ("unit/a", "unit/b"): - at = order.index(("test", part)) - events = [o for o in order[:at] if o[0] == "case" and o[1] == part] - assert any(e[2] == "begin" for e in events) and any(e[2] == "end" for e in events), (part, order) -end = next(r["case"] for r in recs if "case" in r and r["case"]["event"] == "end" and r["case"]["id"] == "timed") -assert end["status"] == "pass", end -assert end["metrics"][0]["name"] == "spin" and end["metrics"][0]["unit"] == "s", end -assert end["bounds"] == [{"metric": "spin", "max": 10.0}], end -a = next(r for r in recs if r.get("test") == "unit/a") -assert a["part_of"] == "unit", a -case = next(c for c in a["cases"] if c["id"] == "timed") -assert case["kind"] == "bench" and case["metrics"] and case["bounds"], case -b = next(r for r in recs if r.get("test") == "unit/b") -plain = next(c for c in b["cases"] if c["id"] == "plain") -assert plain.get("tags") == ["quick"], plain -PY -echo " ok cases are records as they happen; parts, metrics, bounds, kind and tags are stated" - -# C -python3 - target/mtest <<'PY' || fail "C: the store" target/mtest/index.ndjson -import json, os, sys -store = sys.argv[1] -run = [json.loads(l) for l in open(os.path.join(store, "index.ndjson"))][-1] -cases = [json.loads(l) for l in open(os.path.join(store, "runs", run["id"], "cases.ndjson"))] -timed = next(c for c in cases if c["id"] == "timed") -assert timed["kind"] == "bench" and timed["started_ms"] >= 0, timed -assert timed["metrics"][0]["name"] == "spin" and timed["bounds"][0]["metric"] == "spin", timed -PY -echo " ok the kept run holds kind, start, metrics and bounds per case" - -# D -"$MCPP" self env --format json > d.json 2>&1 || fail "D: self env" d.json -for f in test-parts test-case-stream test-metrics; do - grep -q "\"$f\"" d.json || fail "D: no feature $f" d.json -done -echo " ok the features are announced" -echo "PASS: 1065 test records carry parts, live cases and metrics" diff --git a/tests/e2e/1066_a_merged_version_withdraws_its_requests.sh b/tests/e2e/1066_a_merged_version_withdraws_its_requests.sh deleted file mode 100755 index 7d60c6dc7..000000000 --- a/tests/e2e/1066_a_merged_version_withdraws_its_requests.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 1066 — a version a merge replaces takes its requests out of the graph. -# -# The app asks for mcpplibs.os ^0.4.1, which resolves to the newest release -# (0.4.4, over the openkal 0.25 family); a path library pins os = 0.4.1, over -# the openkal 0.24 family. The merge takes os down to 0.4.1, and the requests -# 0.4.4 made -- openkal-linux 0.26.0, and through it openkal 0.25.0 -- leave -# with it (on Windows and macOS, the 0.25 family's implementation of that -# system). The library is named to be walked first, so that the pin is met -# before the newer release's own requests are. Before, they stayed: the graph -# was refused with "irreconcilable versions" between openkal 0.24.0 and a -# request of openkal-linux 0.26.0, a version no longer in it (the mtest -# workspace, measured). -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -TMP_HOST="$(host_path "$TMP")" -trap "rm -rf $TMP" EXIT - -mkdir -p "$TMP/anchor/src" "$TMP/app/src" -cat > "$TMP/anchor/mcpp.toml" <<'EOF' -[package] -name = "anchor" -version = "0.1.0" -[targets.anchor] -kind = "lib" - -[dependencies] -mcpplibs.os = "0.4.1" -EOF -cat > "$TMP/anchor/src/anchor.cppm" <<'EOF' -export module anchor; -export int anchor_answer() { return 1; } -EOF - -cat > "$TMP/app/mcpp.toml" < "$TMP/app/src/main.cpp" <<'EOF' -import anchor; -int main() { return anchor_answer() == 1 ? 0 : 1; } -EOF - -cd "$TMP/app" -"$MCPP" build --configure-only > plan.log 2>&1 || { - cat plan.log; echo "FAIL: the merged graph was refused"; exit 1; } -if grep -q 'irreconcilable' plan.log; then - cat plan.log; echo "FAIL: a request of a replaced version was kept"; exit 1 -fi -grep -qE 'Merged mcpplibs\.os 0\.4\.[2-9] ⨯ 0\.4\.1 → v0\.4\.1' plan.log || { - cat plan.log; echo "FAIL: os was not merged down to the pinned version"; exit 1; } -grep -A2 '^\[package\."openkal"\]' mcpp.lock | grep -q 'version = "0.24.0"' || { - cat mcpp.lock; echo "FAIL: openkal is not the 0.24 the pinned os asks for"; exit 1; } -# The system's implementation is the one of the 0.24 family, whichever system -# this is: none of the 0.25 family's (linux 0.26, macos 0.23, windows 0.21). -for member in 'openkal-linux" 0.26' 'openkal-macos" 0.23' 'openkal-windows" 0.21'; do - name=${member%% *}; series=${member##* } - if grep -A2 "^\[package\.\"$name\]" mcpp.lock | grep -q "version = \"$series\."; then - cat mcpp.lock; echo "FAIL: ${name%\"} $series of the replaced version is in the graph"; exit 1 - fi -done - -echo "PASS: 1066 a merged version withdraws its requests" diff --git a/tests/e2e/1067_a_borrowed_registry_keeps_its_owners_places.sh b/tests/e2e/1067_a_borrowed_registry_keeps_its_owners_places.sh deleted file mode 100755 index 741d384a4..000000000 --- a/tests/e2e/1067_a_borrowed_registry_keeps_its_owners_places.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 1067 — a home that borrows another's registry (`[xlings] home`) leaves the -# places of the registry's indices as their owner recorded them. -# -# Before, every home that started over a registry wrote its own index -# directory and store into the registry's `.xlings.json`. The e2e homes under -# a temporary directory did, and a later build over the shared registry was -# sent to a store that had been deleted with its home ("install path missing -# after fetch", measured on Windows and macOS). -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -export MCPP_HOME="$TMP/owner" -"$MCPP" index list > "$TMP/owner.log" 2>&1 || { cat "$TMP/owner.log"; echo "FAIL: the owner home did not start"; exit 1; } -REGISTRY="$TMP/owner/registry" -test -f "$REGISTRY/.xlings.json" || { echo "FAIL: the owner home wrote no registry configuration"; exit 1; } -grep -q '"store"' "$REGISTRY/.xlings.json" || { - cat "$REGISTRY/.xlings.json"; echo "FAIL: the owner recorded no store for its index"; exit 1; } -cp "$REGISTRY/.xlings.json" "$TMP/before.json" - -export MCPP_HOME="$TMP/borrower" -mkdir -p "$MCPP_HOME" -cat > "$MCPP_HOME/config.toml" < "$TMP/borrower.log" 2>&1 || { - cat "$TMP/borrower.log"; echo "FAIL: the borrowing home did not start"; exit 1; } - -cmp -s "$TMP/before.json" "$REGISTRY/.xlings.json" || { - diff "$TMP/before.json" "$REGISTRY/.xlings.json" || true - echo "FAIL: the borrowing home rewrote the registry's index places"; exit 1; } -if grep -q 'borrower' "$REGISTRY/.xlings.json"; then - cat "$REGISTRY/.xlings.json"; echo "FAIL: the registry names the borrowing home"; exit 1 -fi - -echo "PASS: 1067 a borrowed registry keeps its owner's places" diff --git a/tests/e2e/1068_a_running_test_holds_no_build_directory.sh b/tests/e2e/1068_a_running_test_holds_no_build_directory.sh deleted file mode 100755 index a273cdc2f..000000000 --- a/tests/e2e/1068_a_running_test_holds_no_build_directory.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 1068 — a running test holds no build directory (mcpp#915). -# -# `mcpp test` releases the build directory's lock once its tests are built: a -# `mcpp build` and a second `mcpp test` of the same configuration proceed while -# a test runs, and the second test's protocol files are its own. Before, the -# lock was held until the run ended, and an editor's second test waited for -# the first ("Waiting for the build directory, held by pid ... mcpp test"). -# -# The first test waits for a file that only the commands started beside it -# create; while the lock was held, they waited for it, and it for them, until -# the test's own limit. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -# `timeout` is not on every host (macOS): the command, ended after SECS. -bounded() { - local secs=$1; shift - "$@" & local pid=$! - ( sleep "$secs"; kill "$pid" 2>/dev/null ) & local guard=$! - local rc=0 - wait "$pid" || rc=$? - kill "$guard" 2>/dev/null; wait "$guard" 2>/dev/null || true - return $rc -} -cd "$TMP" -"$MCPP" new app > /dev/null -cd app -rm -f tests/*.cpp -GATE="$(host_path "$TMP")/gate" -cat > tests/waits.cpp < tests/quick.cpp <<'EOF' -int main() { return 0; } -EOF -"$MCPP" test > "$TMP/warm.log" 2>&1 & -warm=$! -sleep 1; touch "$TMP/gate"; wait $warm || { cat "$TMP/warm.log"; echo "FAIL: the tests did not build"; exit 1; } -rm -f "$TMP/gate" - -"$MCPP" test waits > "$TMP/first.log" 2>&1 & -first=$! -# The first test is running once its program has started; give it the time to -# be built (it is built already) and to start. -for _ in $(seq 1 100); do - grep -q 'Running\|waits' "$TMP/first.log" 2>/dev/null && break - sleep 0.1 -done -sleep 1 - -# Beside it: a build of the same configuration, and a second test. -bounded 60 "$MCPP" build > "$TMP/build.log" 2>&1 || { - touch "$TMP/gate"; wait $first || true - cat "$TMP/build.log"; echo "FAIL: mcpp build waited for a running test"; exit 1; } -bounded 60 "$MCPP" test quick > "$TMP/second.log" 2>&1 || { - touch "$TMP/gate"; wait $first || true - cat "$TMP/second.log"; echo "FAIL: a second mcpp test waited for a running one"; exit 1; } -if grep -q 'Waiting for the build directory' "$TMP/build.log" "$TMP/second.log"; then - touch "$TMP/gate"; wait $first || true - cat "$TMP/build.log" "$TMP/second.log"; echo "FAIL: a command waited for the build directory"; exit 1 -fi - -touch "$TMP/gate" -wait $first || { cat "$TMP/first.log"; echo "FAIL: the first test failed"; exit 1; } -grep -q '1 passed; 0 failed' "$TMP/first.log" || { cat "$TMP/first.log"; echo "FAIL: the first test's summary"; exit 1; } -grep -q '1 passed; 0 failed' "$TMP/second.log" || { cat "$TMP/second.log"; echo "FAIL: the second test's summary"; exit 1; } - -# The protocol files were the invocations', and went with them. -if ls -d target/*/*/test-protocol/*/ 2>/dev/null | grep -q .; then - ls -R target/*/*/test-protocol; echo "FAIL: an ended invocation left its protocol directory"; exit 1 -fi - -# The issue's own shape: a test that builds its own project with mcpp. The -# inner build has the test's environment (the subos's `bin` first on PATH); it -# finds the directory free, the configuration the outer command built, and its -# graph current, so it neither waits nor plans. -MCPP_HOST="$(host_path "$(command -v "$MCPP" || echo "$MCPP")")" -INNER_LOG="$(host_path "$TMP")/inner.log" -cat > tests/inner.cpp < -#ifdef _WIN32 -#define WHOLE(s) "\"" s "\"" // cmd /c drops the outer pair of quotes -#else -#define WHOLE(s) s -#endif -int main() { return std::system(WHOLE("\"$MCPP_HOST\" build > \"$INNER_LOG\" 2>&1")) == 0 ? 0 : 1; } -EOF -"$MCPP" test inner > "$TMP/prime.log" 2>&1 || { cat "$TMP/prime.log" "$TMP/inner.log"; echo "FAIL: a test that builds its project failed"; exit 1; } -bounded 120 "$MCPP" test inner > "$TMP/inner-test.log" 2>&1 || { - cat "$TMP/inner-test.log" "$TMP/inner.log"; echo "FAIL: a test that builds its project failed"; exit 1; } -if grep -qE 'Waiting for the build directory|Resolving' "$TMP/inner.log"; then - cat "$TMP/inner.log"; echo "FAIL: the inner build waited or planned"; exit 1 -fi - -echo "PASS: 1068 a running test holds no build directory" diff --git a/tests/e2e/1069_an_unchanged_project_tests_without_planning.sh b/tests/e2e/1069_an_unchanged_project_tests_without_planning.sh deleted file mode 100755 index be7c98206..000000000 --- a/tests/e2e/1069_an_unchanged_project_tests_without_planning.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# requires: -# resources: machine -# 1069 — the tests of an unchanged project run without planning (the test fast -# path, design 2026-11-10 supplement T7). -# -# Alone on the machine: a fast path declines when the run-time environment's -# description is newer than its graph, and the description is the home's, which -# any mcpp beside this one rewrites when it installs a package. On the day new -# versions of packages many scripts name entered the index, a script that ran -# beside this one installed one between two runs here, and the second was -# planned again (xcode-27, three runs; "the runtime environment's description -# is newer than test.ninja"). -# -# A full `mcpp test` records what it planned for every test of the package -# beside `test.ninja`. The next one admits that record as `mcpp build` admits -# its own and runs the selected tests from it: it resolves no dev-dependency, -# so it prints no `Resolved mcpplibs.` line (the package's programs are built -# as `mcpp build` builds them, which on some hosts plans each time). An edited -# test, a test added or removed, and an edited source each take the full path -# again, and the results are theirs. -set -e - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -cd "$TMP" -"$MCPP" new app > /dev/null -cd app -rm -f tests/*.cpp -cat >> mcpp.toml <<'EOF' - -[dev-dependencies] -mcpplibs.mtest = "0.5.2" -EOF -write_cases() { - cat > tests/cases.cpp < full.log 2>&1 || fail "the first run failed" full.log -grep -q 'Resolved mcpplibs\.' full.log || fail "the first run did not plan" full.log -"$MCPP" test > fast.log 2>&1 || fail "the second run failed" fast.log -grep -q 'Resolved mcpplibs\.' fast.log && fail "an unchanged project was planned again" fast.log -grep -q 'cases: 2 passed, 0 failed' fast.log || fail "the second run did not run both cases" fast.log - -# A selection runs from the record. -"$MCPP" test cases --case two > case.log 2>&1 || fail "--case two failed" case.log -grep -q 'Resolved mcpplibs\.' case.log && fail "a selection was planned again" case.log -grep -q 'cases: 1 passed, 0 failed' case.log || fail "--case two did not run one case" case.log - -# An edited test is built and its result is the edit's. -sleep 1 -write_cases 3 -"$MCPP" test > edited.log 2>&1 && fail "the edited test passed" edited.log -grep -q 'cases: 1 passed, 1 failed' edited.log || fail "the edit's failure is not reported" edited.log -"$MCPP" test > again.log 2>&1 && fail "the failing test passed from the record" again.log -grep -q 'Resolved mcpplibs\.' again.log && fail "an unchanged failing project was planned again" again.log -grep -q 'cases: 1 passed, 1 failed' again.log || fail "the record's run lost the failure" again.log - -# A test added is planned and run; one removed is no longer run. -sleep 1 -write_cases 2 -printf 'int main() { return 0; }\n' > tests/extra.cpp -"$MCPP" test > added.log 2>&1 || fail "the run with an added test failed" added.log -grep -q 'extra ... ok' added.log || fail "the added test did not run" added.log -rm tests/extra.cpp -"$MCPP" test > removed.log 2>&1 || fail "the run after removing a test failed" removed.log -grep -q 'extra' removed.log && fail "a removed test ran" removed.log - -# A build after the tests keeps its own graph. -"$MCPP" build > build.log 2>&1 || fail "mcpp build failed" build.log -grep -q 'Compiling' build.log && fail "mcpp build after mcpp test compiled again" build.log - -echo "PASS: 1069 an unchanged project tests without planning" diff --git a/tests/e2e/106_feature_gated_sources_toml.sh b/tests/e2e/106_feature_gated_sources_toml.sh deleted file mode 100755 index 57893ea18..000000000 --- a/tests/e2e/106_feature_gated_sources_toml.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# G5: mcpp.toml [features] `sources` key — feature-gated source globs, at -# parity with index descriptors. Four quadrants: feature off/on × build/test -# (the 0.0.94 lesson: feature-source behavior MUST match on both paths). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new featsrc > /dev/null -cd featsrc - -cat > src/extra.cpp <<'EOF' -extern "C" int extra_answer() { return 42; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int extra_answer(); -int main() { -#ifdef HAS_EXTRA - std::println("extra = {}", extra_answer()); -#else - std::println("extra = none"); -#endif - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "featsrc" -version = "0.1.0" - -[features] -default = [] -extra = { sources = ["src/extra.cpp"], defines = ["HAS_EXTRA"] } -EOF -# The skeleton's test is mtest's; this manifest names no dev-dependency. -rm -f tests/test_smoke.cpp - -# Quadrant 1: feature OFF, build — extra.cpp must NOT compile (main has no -# reference so the link succeeds; the object must be absent). -"$MCPP" build > build_off.log 2>&1 || { cat build_off.log; echo "build (off) failed"; exit 1; } -find target -name 'extra.o' | grep -q . && { echo "extra.cpp compiled with feature off"; exit 1; } || true -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "extra = none" ]] || { echo "unexpected (off): $out"; exit 1; } - -# Quadrant 2: feature ON, build (`run` has no --features; exec the binary). -"$MCPP" build --features extra > build_on.log 2>&1 || { cat build_on.log; echo "build (on) failed"; exit 1; } -# Feature defines change the fingerprint → a second output dir; take the -# newest binary. -bin="$(ls -t $(find target -name 'featsrc' -type f) | head -1)" -[[ -n "$bin" ]] || { echo "binary not found"; exit 1; } -out="$("./$bin" | tail -1)" -[[ "$out" == "extra = 42" ]] || { echo "unexpected (on): $out"; exit 1; } - -# Quadrants 3+4: test path, feature off then on. The test binary references -# the gated symbol only when the feature is active. -mkdir -p tests -cat > tests/test_featsrc.cpp <<'EOF' -#ifdef HAS_EXTRA -extern "C" int extra_answer(); -int main() { return extra_answer() == 42 ? 0 : 1; } -#else -int main() { return 0; } -#endif -EOF -"$MCPP" test > test_off.log 2>&1 || { cat test_off.log; echo "mcpp test (off) failed"; exit 1; } -"$MCPP" test --features extra > test_on.log 2>&1 || { cat test_on.log; echo "mcpp test (on) failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/1070_the_test_spec_launches_frameworks_checks_and_output.sh b/tests/e2e/1070_the_test_spec_launches_frameworks_checks_and_output.sh deleted file mode 100755 index ee7e9583a..000000000 --- a/tests/e2e/1070_the_test_spec_launches_frameworks_checks_and_output.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 1070 — the mcpp test spec's B5, B6, O3 and O8 (docs/specs/test.md). -# -# B5 a record of a program built and not run carries how it starts -# (`launch`: argv, env, cwd, via), the arguments after `--` included; -# B6 a framework's entry states the capabilities it implements and its -# session's command, and `mcpp test --guide` reports both; -# O3 a failure's `source` and `fatal`, and a case's `checks`, reach the -# record and the kept run as the program reported them; -# O8 a case's own output: the program's `output` events, or all that a -# program which ran one case wrote; several cases are not divided. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -TMP_HOST="$(host_path "$TMP")" -trap 'rm -rf "$TMP"' EXIT -cd "$TMP" -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f"; cat "$f"; done; exit 1; } - -# The framework: an entry that begins a test and states the spec's keys. -mkdir -p fw/src app/tests -cat > fw/mcpp.toml <<'EOF' -[package] -name = "fw" -version = "0.1.0" - -[targets.main] -kind = "entry" -main = "src/main.cpp" -test-protocol = 1 -test-main = true -test-capabilities = ["check-details", "output"] -test-session = ["fw-serve", "--stdio"] -EOF -cat > fw/src/main.cpp <<'CPP' -import std; -extern bool cases(std::ofstream& report, bool list, const std::set& selected); -int main() { - const char* path = std::getenv("MTEST_REPORT"); - if (!path) return 4; - std::ofstream report(path, std::ios::app); - const std::string mode = std::getenv("MTEST_MODE") ? std::getenv("MTEST_MODE") : "run"; - std::set selected; - if (const char* s = std::getenv("MTEST_SELECT")) { - std::ifstream in(s); - for (std::string line; std::getline(in, line);) selected.insert(line); - } - report << R"({"e":"program","protocol":1,"level":1,"framework":"fw","version":"0.1.0"})" << "\n"; - const bool failed = cases(report, mode == "list", selected); - report << (failed ? R"({"e":"done","status":"fail","counts":{}})" : R"({"e":"done","status":"pass","counts":{}})") << "\n"; - report.flush(); - return failed ? 1 : 0; -} -CPP - -cat > app/mcpp.toml < app/tests/t.cpp <<'CPP' -import std; -bool cases(std::ofstream& r, bool list, const std::set& selected) { - auto on = [&](std::string_view id) { return selected.empty() || selected.contains(std::string(id)); }; - if (list) { - r << R"({"e":"case","id":"stated","name":"stated","file":"t.cpp","line":3})" << "\n"; - r << R"({"e":"case","id":"plain","name":"plain","file":"t.cpp","line":4})" << "\n"; - return false; - } - if (on("stated")) { - r << R"({"e":"begin","id":"stated","t":0})" << "\n"; - r << R"({"e":"output","id":"stated","stream":"out","text":"said by the case\n"})" << "\n"; - r << R"({"e":"failure","id":"stated","file":"t.cpp","line":3,"message":"a == b","expected":"2","actual":"3","source":"expect(eq(a, b));","fatal":true})" << "\n"; - r << R"({"e":"end","id":"stated","status":"fail","ms":0,"checks":{"held":4,"failed":1}})" << "\n"; - } - if (on("plain")) { - std::println("written by plain"); - r << R"({"e":"begin","id":"plain","t":0})" << "\n"; - r << R"({"e":"end","id":"plain","status":"pass","ms":0})" << "\n"; - } - return on("stated"); -} -CPP -cd app - -record() { # record LOG TEST - $PY - "$1" "$2" <<'PY' -import json, sys -for line in open(sys.argv[1]): - line = line.strip() - if not line.startswith("{"): continue - try: r = json.loads(line) - except ValueError: continue - if r.get("test") == sys.argv[2]: print(json.dumps(r)); break -PY -} -field() { $PY -c "import json,sys; r=json.loads(sys.argv[1]); print(eval(sys.argv[2]))" "$1" "$2"; } - -# B6 -"$MCPP" test --guide --message-format json > guide.json 2> guide.err || fail "B6: --guide" guide.json guide.err -$PY - guide.json <<'PY' || fail "B6: the framework's capabilities and session" guide.json -import json, sys -g = json.loads([l for l in open(sys.argv[1]) if l.startswith("{")][0])["guide"] -f = [x for x in g["frameworks"] if x["entry"] == "fw/main"][0] -assert f["capabilities"] == ["check-details", "output"], f -assert f["session"] == ["fw-serve", "--stdio"], f -PY -"$MCPP" test --guide > guide.log 2>&1 || fail "B6: --guide (human)" guide.log -grep -q 'implements check-details, output' guide.log || fail "B6: the human guide does not name the capabilities" guide.log -grep -q 'session fw-serve --stdio' guide.log || fail "B6: the human guide does not name the session" guide.log - -# B5 -"$MCPP" test --no-run --message-format json -- --flag > norun.json 2> norun.err || fail "B5: --no-run" norun.json norun.err -rec=$(record norun.json t) -[ "$(field "$rec" "r['status']")" = built ] || fail "B5: the program is not reported built" norun.json -[ "$(field "$rec" "r['launch']['argv'][-1]")" = --flag ] || fail "B5: the launch omits the arguments after --" norun.json -[ "$(field "$rec" "r['launch']['argv'][0] == r['program'] or r['launch']['argv'][0].replace('\\\\', '/') == r['program']")" = True ] \ - || fail "B5: the launch does not start the program" norun.json -[ "$(field "$rec" "'MTEST_PROTOCOL' in r['launch']['env']")" = False ] || fail "B5: the launch carries the protocol's variables" norun.json -[ -n "$(field "$rec" "r['launch']['cwd']")" ] || fail "B5: the launch names no directory" norun.json - -# O3, O8: both cases -"$MCPP" test --message-format json > all.json 2> all.err && fail "O3: the failing case passed" all.json all.err -rec=$(record all.json t) -[ "$(field "$rec" "r['cases'][0]['failures'][0]['source']")" = 'expect(eq(a, b));' ] || fail "O3: the failure's source" all.json -[ "$(field "$rec" "r['cases'][0]['failures'][0]['fatal']")" = True ] || fail "O3: the failure's fatal" all.json -[ "$(field "$rec" "(r['cases'][0]['checks']['held'], r['cases'][0]['checks']['failed'])")" = "(4, 1)" ] || fail "O3: the case's checks" all.json -[ "$(field "$rec" "r['cases'][0]['output']")" = 'said by the case' ] || fail "O8: the case's stated output" all.json -[ "$(field "$rec" "'output' in r['cases'][1]")" = False ] || fail "O8: the output of a program of two cases was divided" all.json -grep -q '"checks":{"failed":1,"held":4}\|"checks":{"held":4,"failed":1}' target/mtest/runs/*/cases.ndjson \ - || fail "O3: the kept run has no checks" target/mtest/runs/*/cases.ndjson - -# O8: one case run alone -"$MCPP" test --case plain --message-format json > one.json 2> one.err || fail "O8: --case plain" one.json one.err -rec=$(record one.json t) -field "$rec" "r['cases'][0]['output']" | grep -q 'written by plain' || fail "O8: the output of a program that ran one case" one.json - -echo "PASS: 1070 the test spec launches, frameworks, checks and output" diff --git a/tests/e2e/1071_the_root_overrides_a_dependency_for_the_whole_graph.sh b/tests/e2e/1071_the_root_overrides_a_dependency_for_the_whole_graph.sh deleted file mode 100755 index 7bfe20be7..000000000 --- a/tests/e2e/1071_the_root_overrides_a_dependency_for_the_whole_graph.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -# requires: python3 -# 1071 — the root overrides a dependency for the whole graph (design -# 2026-11-10 supplement, T2 and T3; docs/04 `override`). -# -# A. Two requests no merge reconciles (a library pins mcpplibs.os 0.4.1, the -# root asks for ^0.4.4) are refused, and the refusal names the line that -# chooses one version for the whole graph. -# B. With `override = true` on the root's declaration, every request takes -# the root's version; the replaced request is named once ("Overrode"), -# and mcpp.lock records the override. -# C. `--locked` reads the override as part of the recorded resolution: the -# same version reached without it is drift. -# D. A library's `override` is ignored, and said to be. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -TMP_HOST="$(host_path "$TMP")" -trap 'rm -rf "$TMP"' EXIT -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f"; cat "$f"; done; exit 1; } - -mkdir -p "$TMP/anchor/src" "$TMP/app/src" -cat > "$TMP/anchor/mcpp.toml" <<'EOF' -[package] -name = "anchor" -version = "0.1.0" -[targets.anchor] -kind = "lib" - -[dependencies] -mcpplibs.os = "0.4.1" -EOF -cat > "$TMP/anchor/src/anchor.cppm" <<'EOF' -export module anchor; -export int anchor_answer() { return 1; } -EOF -write_app() { # write_app OS-DECLARATION - cat > "$TMP/app/mcpp.toml" < "$TMP/app/src/main.cpp" <<'EOF' -import anchor; -int main() { return anchor_answer() == 1 ? 0 : 1; } -EOF -locked_os() { $PY - <<'PY' -import re -text = open("mcpp.lock").read() -m = re.search(r'\[package\."mcpplibs\.os"\]([^\[]*)', text) -body = m.group(1) if m else "" -v = re.search(r'version\s*=\s*"([^"]+)"', body) -print((v.group(1) if v else "") + (" override" if re.search(r'^override\s*=\s*true', body, re.M) else "")) -PY -} -cd "$TMP/app" - -# A -write_app '"^0.4.4"' -"$MCPP" build --configure-only > a.log 2>&1 && fail "A: two requests no merge reconciles were accepted" a.log -grep -q 'irreconcilable versions' a.log || fail "A: the refusal does not name the conflict" a.log -grep -q 'mcpplibs.os = { version = "[0-9.]*", override = true }' a.log || fail "A: the refusal does not name the override" a.log - -# B -write_app '{ version = "0.4.4", override = true }' -"$MCPP" build --configure-only > b.log 2>&1 || fail "B: the override was refused" b.log -grep -qE 'Overrode +mcpplibs\.os 0\.4\.1 \(requested by [^)]*anchor[^)]*\) -> 0\.4\.4 \(\[dependencies\] of app\)' b.log \ - || fail "B: the replaced request is not named" b.log -[ "$(grep -c 'Overrode' b.log)" = 1 ] || fail "B: the replaced request is named more than once" b.log -[ "$(locked_os)" = "0.4.4 override" ] || fail "B: mcpp.lock does not record the override ($(locked_os))" mcpp.lock - -# C -write_app '{ version = "0.4.4", override = true }' -"$MCPP" build --configure-only > c2.log 2>&1 || fail "C: the override was refused" c2.log -sed -i.bak 's/^override = true$//' mcpp.lock -"$MCPP" build --configure-only --locked > c3.log 2>&1 && fail "C: --locked accepted a lock without the override" c3.log -grep -q 'mcpplibs.os 0.4.4 -> 0.4.4 (override)' c3.log || fail "C: the drift does not name the override" c3.log - -# D -sed -i.bak 's/^mcpplibs.os = "0.4.1"$/mcpplibs.os = { version = "0.4.1", override = true }/' "$TMP/anchor/mcpp.toml" -"$MCPP" build --configure-only > d.log 2>&1 || fail "D: the build failed" d.log -grep -q "\`override\` on 'mcpplibs.os' is ignored" d.log || fail "D: a library's override is not reported" d.log -sed -i.bak 's/^mcpplibs.os = { version = "0.4.1", override = true }$/mcpplibs.os = "0.4.1"/' "$TMP/anchor/mcpp.toml" - -echo "PASS: 1071 the root overrides a dependency for the whole graph" diff --git a/tests/e2e/1072_a_resource_include_nothing_provides_is_the_scripts_own.sh b/tests/e2e/1072_a_resource_include_nothing_provides_is_the_scripts_own.sh deleted file mode 100755 index 7ee5b64ce..000000000 --- a/tests/e2e/1072_a_resource_include_nothing_provides_is_the_scripts_own.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# requires: mingw-cross -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1072 -- a resource script's quoted include that no directory holds and no build action writes is -# the script's own matter, not an input of the build graph. -# -# The scan of a script records each quoted include it finds in the script's directories, else -# against the script's directory, where a file a build action produces is ordered before the -# compile. A name nothing produces became a graph input no rule makes, and the build failed in -# ninja without the script's name (`ninja: error: '/absent.h', needed by 'res/app.o', -# missing and no known rule to make it`), even when the script includes it only -# `#if __has_include`. Such a name is now left to the script's preprocessing and to the resource -# compiler, which names the script and its line; a name a build action writes stays an input. -# -# Criteria: a script that includes an absent header under `__has_include` builds; one that -# includes it unconditionally fails with the resource compiler's message naming the script. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'TOML' -[package] -name = "rcinc" -version = "0.1.0" - -[resources] -files = ["app.rc"] - -[targets.rcinc] -kind = "bin" -main = "src/main.c" -TOML -echo 'int main(void) { return 0; }' > "$t/src/main.c" -cat > "$t/app.rc" <<'RC' -#if __has_include("absent.h") -#include "absent.h" -#endif -1 VERSIONINFO -FILEVERSION 1,0,0,0 -BEGIN -END -RC -cd "$t" -out=$("$MCPP" build --target x86_64-windows-gnu 2>&1) \ - || fail "a script that includes an absent header under __has_include did not build" "$out" -find target/x86_64-windows-gnu -path '*/bin/rcinc.exe' -type f | grep -q . || fail "no program" -# Unconditional: the resource compiler's error, naming the script. -sed -i '1d;3d' app.rc -if out=$("$MCPP" build --target x86_64-windows-gnu 2>&1); then fail "an absent header included unconditionally built" "$out"; fi -grep -q 'ninja: error' <<<"$out" && fail "the build failed in ninja, not in the resource compiler" "$out" -grep -q 'app.rc' <<<"$out" || fail "the failure does not name the script" "$out" -echo "PASS: 1072 a resource include nothing provides is the script's own" diff --git a/tests/e2e/1073_the_freebsd_musl_identity_is_served_by_openkal.sh b/tests/e2e/1073_the_freebsd_musl_identity_is_served_by_openkal.sh deleted file mode 100755 index 132adc7b7..000000000 --- a/tests/e2e/1073_the_freebsd_musl_identity_is_served_by_openkal.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm elf -# 1073 -- FreeBSD's musl identity is served by openkal (design 2026-11-10 E4, K8, -# K9; docs/21 'Build hosts and the targets they serve'). -# -# `x86_64-freebsd-musl` is openkal-platform's hosted part (0.26.0 on): openkal-musl -# over openkal-freebsd in its standalone arrangement, linked as one static -# program with no C library of the system beneath. A request of `x86_64-freebsd` -# with no platform selected for it takes that identity, as `aarch64-macos` -# takes the musl identity on a host that is not a Mac; a project that selects a -# platform for `x86_64-freebsd` keeps the system's own ABI (e2e 1059). -# -# Criteria: `--target x86_64-freebsd-musl` builds a static FreeBSD ELF over -# openkal-musl and openkal-freebsd; `--target x86_64-freebsd` with no platform -# selected builds the same identity and says so. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; tail -30 "$f" 2>/dev/null; done; exit 1; } - -cd "$TMP" -"$MCPP" new app > /dev/null 2>&1 -cd app -"$MCPP" build --target x86_64-freebsd-musl > a.log 2>&1 || fail "A: x86_64-freebsd-musl did not build" a.log -grep -q 'openkal-musl@' a.log || fail "A: the C library is not openkal-musl" a.log -grep -q 'openkal-freebsd@' a.log || fail "A: the kernel layer is not openkal-freebsd" a.log -exe=$(find target/x86_64-freebsd-musl -path '*/bin/app' -type f | head -1) -[ -n "$exe" ] || fail "A: no program" a.log -info=$(file "$exe") -grep -q 'FreeBSD' <<<"$info" || fail "A: not a FreeBSD program: $info" -grep -q 'statically linked' <<<"$info" || fail "A: not static: $info" - -"$MCPP" build --target x86_64-freebsd > b.log 2>&1 || fail "B: x86_64-freebsd with no platform did not build" b.log -grep -q 'x86_64-freebsd-musl' b.log || fail "B: the request does not say it took the musl identity" b.log -echo "PASS: 1073 the FreeBSD musl identity is served by openkal" diff --git a/tests/e2e/1074_a_namespace_package_named_like_a_spec_field.sh b/tests/e2e/1074_a_namespace_package_named_like_a_spec_field.sh deleted file mode 100755 index cb6fcf126..000000000 --- a/tests/e2e/1074_a_namespace_package_named_like_a_spec_field.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# 1074 -- a namespace's package may be named like a dependency specification's -# field (#918; docs/06). -# -# `gpp.version = { path = "../version" }` is TOML for the table `gpp` holding the -# key `version`, whose value is a table: the package `version` in the namespace -# `gpp`. A specification's `path`, `version` and `git` are strings and its -# `workspace` a boolean, so only such a value makes a table one specification. -# -# Criteria: a project depending on `gpp.version` by path builds and runs it, and -# `mcpp emit build-database` describes it. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; tail -30 "$f" 2>/dev/null; done; exit 1; } - -mkdir -p "$TMP/version/src" "$TMP/app/src" -cat > "$TMP/version/mcpp.toml" <<'T' -[package] -namespace = "gpp" -name = "version" -version = "0.1.0" -T -cat > "$TMP/version/src/version.cppm" <<'T' -export module gpp.version; -export int gpp_version() { return 7; } -T -cat > "$TMP/app/mcpp.toml" <<'T' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -gpp.version = { path = "../version" } -T -cat > "$TMP/app/src/main.cpp" <<'T' -import gpp.version; -int main() { return gpp_version() == 7 ? 0 : 1; } -T - -cd "$TMP/app" -"$MCPP" run > run.log 2>&1 || fail "the package gpp.version was not read as a package" run.log -grep -q 'must specify' run.log && fail "the namespace was read as one specification" run.log -"$MCPP" emit build-database --format json > db.json 2> db.err || fail "the build database was not described" db.err -grep -q 'version.cppm' db.json || fail "the build database does not hold gpp.version" db.json -echo "OK" diff --git a/tests/e2e/1075_a_graph_supplied_windows_row_finds_its_resource_headers.sh b/tests/e2e/1075_a_graph_supplied_windows_row_finds_its_resource_headers.sh deleted file mode 100755 index 06f578b08..000000000 --- a/tests/e2e/1075_a_graph_supplied_windows_row_finds_its_resource_headers.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1075 -- a Windows row whose system the graph supplies compiles its resources -# with the Windows headers the platform's rows declare (2026.11.11.1, G3; -# design 2026-11-10 E3's third source). -# -# `x86_64-windows-musl` takes its system from the graph (openkal-platform's -# hosted part), so its toolchain has no paired payload, and its llvm binary -# carries no Windows headers of its own: neither include source -# `system_include_dirs` read existed, and a script that includes -# `"winres.h"` failed in llvm-rc with `winres.h: No such file or directory`. -# The MinGW-w64 sysroot payload a platform row declares for the -# architecture's GNU-ABI identity holds them, under one `include/` for every -# architecture; every PE row shares that set, and the two existing sources -# keep their precedence. -# -# Criteria: the project builds with `[resources]` on x86_64-windows-musl and -# the program carries a VERSIONINFO resource; a GNU-ABI row (1058) is not -# re-tested here. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -25; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'TOML' -[package] -name = "verinfo" -version = "0.1.0" - -[resources] -files = ["app.rc"] - -[targets.verinfo] -kind = "bin" -main = "src/main.c" -TOML -echo 'int main(void) { return 0; }' > "$t/src/main.c" -cat > "$t/app.rc" <<'RC' -#include "winres.h" -1 VERSIONINFO -FILEVERSION 1,2,3,4 -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904b0" - BEGIN - VALUE "ProductName", "verinfo" - END - END -END -RC -cd "$t" -out=$("$MCPP" build --target x86_64-windows-musl --toolchain "llvm@${LLVM_VERSION}" 2>&1) \ - || fail "[resources] did not build on x86_64-windows-musl" "$out" -exe=$(find target/x86_64-windows-musl -path '*/bin/verinfo.exe' -type f | head -1) -[ -n "$exe" ] || fail "no program" "$out" -# The llvm payload of the build, not the host's tools. -readobj="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-readobj" -[ -x "$readobj" ] || fail "no llvm-readobj in the llvm payload ($readobj)" -"$readobj" --coff-resources "$exe" | grep -q 'VERSIONINFO' \ - || fail "the program carries no VERSIONINFO" "$("$readobj" --coff-resources "$exe" | head -20)" -echo "PASS: 1075 a graph-supplied Windows row finds its resource headers" diff --git a/tests/e2e/1076_an_aarch64_linux_gnu_cross_comes_from_a_sysroot_payload.sh b/tests/e2e/1076_an_aarch64_linux_gnu_cross_comes_from_a_sysroot_payload.sh deleted file mode 100755 index 5d5db3abb..000000000 --- a/tests/e2e/1076_an_aarch64_linux_gnu_cross_comes_from_a_sysroot_payload.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm elf x86_64-host -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1076 -- the cross arrangement of aarch64-linux-gnu: its C library, C++ -# library and compiler run-time come from a base package, its artifacts name -# the row's loader, and they run through the provider's runner (SPEC-016 §3, -# §6; design 2026-11-12 C2, C3, C5). -# -# The row has two provider entries: `glibc`, the host's payload on an aarch64 -# host, and `glibc-sysroot`, a base package on linux-x86_64 at a priority -# below the musl entries. The second is what this script holds: -# -# 1. `aarch64-linux` is answered as before the entry existed (musl); the -# whole identity reaches the cross entry. -# 2. A C program, a C++ program over the headers and a program that imports -# std each build, dynamically; the last one statically as well. -# 3. Each dynamic artifact is an aarch64 ELF whose interpreter is the row's -# `loader`, with no RUNPATH and no path of this machine in it. -# 4. `mcpp run` starts each through the provider's runner, which this run -# installs, and the program's exit status and output arrive. -# -# The entry's hosts are linux-x86_64, which the capability line states: on -# another host the script is excluded, and the run reports it as such. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -25; exit 1; } - -readelf="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-readelf" - -T=aarch64-linux-gnu -LOADER=/lib/ld-linux-aarch64.so.1 - -project() { #
- mkdir -p "$t/$1/src" - printf '[package]\nname = "cross"\nversion = "0.1.0"\n\n[targets.cross]\nkind = "bin"\nmain = "src/%s"\n' "$2" \ - > "$t/$1/mcpp.toml" - printf '%b' "$3" > "$t/$1/src/$2" -} -project c main.c '#include \nint main(void) { printf("cross-c\\n"); return 42; }\n' -project cxx main.cpp '#include \nint main() { std::println("cross-cxx"); return 43; }\n' -project std main.cpp 'import std;\nint main() { std::println("cross-std"); return 44; }\n' - -# 1. The request `aarch64-linux` keeps its answer. -cd "$t/c" -out=$("$MCPP" why target --target aarch64-linux 2>&1) || fail "why target refused" "$out" -grep -q "aarch64-linux-musl" <<<"$out" || fail "aarch64-linux is no longer answered by the musl identity" "$out" - -check() { # - cd "$t/$1" - out=$("$MCPP" build --target "$T" 2>&1) || fail "$1: $T did not build over the base package" "$out" - grep -q "glibc-sysroot\|aarch64-linux-gnu-sysroot" <<<"$out" || fail "$1: the base package is not what served it" "$out" - exe=$(find "target/$T" -path '*/bin/cross' -type f | head -1) - [ -n "$exe" ] || fail "$1: no program" "$out" - [ -x "$readelf" ] || fail "no llvm-readelf in the llvm payload ($readelf)" - hdr=$("$readelf" -h "$exe") - grep -q "Machine:.*AArch64" <<<"$hdr" || fail "$1: the artifact is not an aarch64 ELF" "$hdr" - interp=$("$readelf" -l "$exe" | sed -n 's/.*Requesting program interpreter: \(.*\)\]/\1/p') - [ "$interp" = "$LOADER" ] || fail "$1: the interpreter is '$interp', the row's loader is $LOADER" - dyn=$("$readelf" -d "$exe") - if grep -qiE "RPATH|RUNPATH" <<<"$dyn"; then fail "$1: the artifact carries a run-time path of this machine" "$dyn"; fi - if grep -aq "${MCPP_HOME:-$HOME/.mcpp}" "$exe"; then fail "$1: a path of this machine's store is in the artifact"; fi - rc=0 - run=$("$MCPP" run --target "$T" 2>&1) || rc=$? - [ "$rc" -eq "$3" ] || fail "$1: mcpp run exited $rc, the program returns $3" "$run" - grep -q "^$2\$" <<<"$run" || fail "$1: the program's output did not arrive" "$run" - grep -q "Running .*qemu-aarch64" <<<"$run" || fail "$1: the run did not go through the provider's runner" "$run" -} -check c cross-c 42 -check cxx cross-cxx 43 -check std cross-std 44 - -# 2. Statically: no interpreter, and the same exit status through the runner. -cd "$t/std" -printf '\n[target.%s]\nlinkage = "static"\n' "$T" >> mcpp.toml -out=$("$MCPP" build --target "$T" 2>&1) || fail "the static import-std program did not build" "$out" -exe=$(find "target/$T" -path '*/bin/cross' -type f -newer mcpp.toml | head -1) -[ -n "$exe" ] || fail "no static program" "$out" -if "$readelf" -l "$exe" | grep -q "program interpreter"; then fail "a static program names an interpreter"; fi -rc=0 -run=$("$MCPP" run --target "$T" 2>&1) || rc=$? -[ "$rc" -eq 44 ] || fail "static: mcpp run exited $rc, the program returns 44" "$run" - -echo "PASS: 1076 an aarch64-linux-gnu cross comes from a sysroot payload" diff --git a/tests/e2e/1077_a_program_of_another_system_is_built_not_run.sh b/tests/e2e/1077_a_program_of_another_system_is_built_not_run.sh deleted file mode 100755 index 7d7756844..000000000 --- a/tests/e2e/1077_a_program_of_another_system_is_built_not_run.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm -# 1077 -- a program of another system is built, not run: `mcpp run` starts -# nothing it cannot observe (SPEC-016 §6; design 2026-11-12 C9, D25). -# -# A row's artifacts run on its execution system. Where that is not this -# machine's system and nothing states a runner, the artifact is not started: -# this machine's kernel maps a static program of its own architecture without -# reading the system it names, and gives that system's call numbers its own -# meaning. The answer is given before anything is spawned, by one predicate for -# built-in rows and for rows a platform defines. -# -# Criteria: on a host whose system is not FreeBSD, `mcpp run --target -# -freebsd-musl` builds the program, exits 2 without running it, states -# "built, not run", names both systems and prints the `[target.]` -# runner declaration for the row; `mcpp test` of the same target reports the -# tests as built, not run. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; tail -30 "$f" 2>/dev/null; done; exit 1; } - -case "$(uname -m)" in x86_64|amd64) ARCH=x86_64 ;; *) ARCH=aarch64 ;; esac -T="$ARCH-freebsd-musl" - -cd "$TMP" -"$MCPP" new app > /dev/null 2>&1 -cd app -"$MCPP" build --target "$T" > b.log 2>&1 || fail "the static FreeBSD program did not build" b.log -exe=$(find "target/$T" -path '*/bin/app*' -type f | head -1) -[ -n "$exe" ] || fail "no program" b.log - -# A marker the program would write if it were started: the program of `mcpp -# new` prints, so its output in the log is the evidence of a launch. -set +e -"$MCPP" run --target "$T" > run.log 2>&1 -rc=$? -set -e -[ "$rc" -eq 2 ] || fail "run exited $rc, expected the refusal's 2" run.log -grep -q "built, not run" run.log || fail "the run does not say built, not run" run.log -grep -q "artifacts run on freebsd" run.log || fail "the artifact's system is not named" run.log -grep -q "\[target.$T\] runner = " run.log || fail "the runner declaration does not name the row" run.log -grep -q "Running" run.log && fail "the artifact was started" run.log - -# `--no-runner` states that the triple is native here, and is believed: the -# launch is then the caller's, and the refusal above does not apply. -set +e -"$MCPP" run --target "$T" --no-runner > direct.log 2>&1 -set -e -grep -q "built, not run" direct.log && fail "--no-runner was not believed" direct.log - -echo "PASS: 1077 a program of another system is built, not run" diff --git a/tests/e2e/1078_systems_all_carries_the_runners_the_platform_declares.sh b/tests/e2e/1078_systems_all_carries_the_runners_the_platform_declares.sh deleted file mode 100755 index 23de90eec..000000000 --- a/tests/e2e/1078_systems_all_carries_the_runners_the_platform_declares.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# 1078 -- `--systems all` carries the runners the platform's own data names -# (2026.11.11.1, G5): this machine's system, the rows a platform defines, and -# the systems its runner states a host of -- FreeBSD's among them, whose -# runner is built as -freebsd-musl over the platform's hosted -# arrangement. -# -# The default is unchanged: a pack without `--systems` carries the runners -# of the legs' own systems -- this machine's system for every architecture -# the package has a leg of -- and no other system's. -set -e -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; tail -30 "$f" 2>/dev/null; done; exit 1; } - -case "$(uname -m)" in - x86_64|amd64) ARCH=x86_64 ;; - aarch64|arm64) ARCH=aarch64 ;; - *) echo "SKIP: no ok-image row for $(uname -m)"; exit 77 ;; -esac -case "$(uname -s)" in Linux) OS=linux ;; *) echo "SKIP: a Linux host"; exit 77 ;; esac - -cd "$TMP" -"$MCPP" new app > /dev/null 2>&1 -cd app - -# The default: this machine's system for each leg's architecture, nothing -# else (`mcpp new app` carries an x86_64 and an aarch64 leg). A `dir` pack's -# tree lands under target/dist whatever `-o` says (design 2026-10-29 §4.6), -# so the pack is located, not assumed. -"$MCPP" pack --target openkal --mode self-contained --pkg-format dir > d.log 2>&1 \ - || fail "the default pack did not build" d.log -one=$(find target/dist -mindepth 1 -maxdepth 1 -type d -newer mcpp.toml | head -1) -[ -n "$one" ] || fail "no pack tree under target/dist" d.log -[ -d "$one/libexec/okrun/${OS}-${ARCH}" ] || fail "the default pack carries no runner of its own system" d.log -others=$(ls "$one/libexec/okrun" 2>/dev/null | grep -v "^${OS}-" || true) -[ -z "$others" ] || fail "the default pack carries a system other than this machine's: $others" d.log - -# `--systems all`: the data's answer, FreeBSD included. The same tree name -# replaces the default's, so dist is cleared first and the tree that lands -# is the all-pack's own. -rm -rf target/dist -"$MCPP" pack --target openkal --mode self-contained --systems all --pkg-format dir > a.log 2>&1 \ - || fail "--systems all did not pack" a.log -all_=$(find target/dist -mindepth 1 -maxdepth 1 -type d | head -1) -[ -n "$all_" ] || fail "no --systems all tree under target/dist" a.log -for pair in "linux-x86_64" "linux-aarch64" "freebsd-x86_64" "freebsd-aarch64"; do - [ -d "$all_/libexec/okrun/$pair" ] || fail "no runner carried for $pair" a.log -done -# The Windows row builds wherever this mcpp builds it; where it does not, the -# warning says so instead. -if [ ! -d "$all_/libexec/okrun/windows-x86_64" ]; then - grep -q "no runner for windows-x86_64" a.log \ - || fail "the Windows runner is neither carried nor said" a.log -fi -echo "PASS: 1078 --systems all carries the runners the platform declares" diff --git a/tests/e2e/107_generated_files_toml.sh b/tests/e2e/107_generated_files_toml.sh deleted file mode 100755 index ef8f961cb..000000000 --- a/tests/e2e/107_generated_files_toml.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# G6: mcpp.toml [generated_files] — path = "contents" entries materialize -# into the project root before the scan (multiline TOML strings), enter the -# fingerprint (content change → rebuild), and escaping paths hard-error. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new genf > /dev/null -cd genf -rm -f src/main.cpp - -cat > mcpp.toml <<'EOF' -[package] -name = "genf" -version = "0.1.0" - -[generated_files] -"src/gen/answer.cpp" = """ -extern "C" int gen_answer() { return 41; } -""" -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int gen_answer(); -int main() { - std::println("gen = {}", gen_answer()); - return 0; -} -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -[[ -f src/gen/answer.cpp ]] || { echo "generated file not materialized"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "gen = 41" ]] || { echo "unexpected output: $out"; exit 1; } - -# Content change re-materializes and rebuilds (content is in the fingerprint). -sed -i.bak 's/return 41/return 42/' mcpp.toml -"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "rebuild failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "gen = 42" ]] || { echo "stale after content change: $out"; exit 1; } - -# Escaping path must be rejected at parse time. -cat > mcpp.toml <<'EOF' -[package] -name = "genf" -version = "0.1.0" -[generated_files] -"../evil.cpp" = "int x;" -EOF -"$MCPP" build > escape.log 2>&1 && { echo "escaping path was accepted"; exit 1; } -grep -qi "generated_files" escape.log || { cat escape.log; echo "wrong error"; exit 1; } - -echo "OK" diff --git a/tests/e2e/1080_gcc_over_the_hosts_payload_links_from_the_payload.sh b/tests/e2e/1080_gcc_over_the_hosts_payload_links_from_the_payload.sh deleted file mode 100644 index df856f589..000000000 --- a/tests/e2e/1080_gcc_over_the_hosts_payload_links_from_the_payload.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf -# 1080 -- gcc over the host's C library payload names the payload on its link: -# the crt search prefix, the library directory and the loader (design -# 2026-11-12 C1; SPEC-006). -# -# A gcc whose recorded sysroot does not exist on this machine is linked -# "payload first": the link names the glibc payload's directory for the crt -# objects (`-B`), for the libraries (`-L`, with its run-time path) and for the -# loader. A rewrite of that branch of the flag renderer once dropped the whole -# group, and the crt objects then resolved from the host's /lib -- on a machine -# that had them; the hermetic link check refused the build. No test of the tree -# built with such a gcc, so the first report was a failed build elsewhere. -# -# gcc 15.1.0 records the sysroot of the machine that built it, which exists on -# no other; the engine's own gcc line (16.x) records the subos root and is -# linked through `--sysroot`. Criteria: a project built with gcc@15.1.0 links, -# its link line names the payload three times as above, and the program runs. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -25; exit 1; } - -cd "$t" -"$MCPP" new app > /dev/null 2>&1 -cd app -out=$("$MCPP" build --toolchain gcc@15.1.0 2>&1) || fail "the project did not build with gcc@15.1.0" "$out" -ninja=$(find target -name build.ninja | head -1) -[ -n "$ninja" ] || fail "no build.ninja" "$out" -line=$(grep -m1 -E '^ *ldflags *=' "$ninja") || fail "no link flags in build.ninja" "$(head -40 "$ninja")" -grep -q -- '-B[^ ]*xim-x-glibc' <<<"$line" || fail "the link names no crt search prefix in the payload" "$line" -grep -q -- '-L[^ ]*xim-x-glibc' <<<"$line" || fail "the link names no library directory in the payload" "$line" -grep -q -- '--dynamic-linker=[^ ]*xim-x-glibc' <<<"$line" || fail "the link names no loader in the payload" "$line" -if grep -q -- '--sysroot=' <<<"$line"; then - fail "this gcc was linked through a sysroot: the script holds the payload-first form" "$line" -fi -run=$("$MCPP" run --toolchain gcc@15.1.0 2>&1) || fail "the program did not run" "$run" -grep -q "Hello" <<<"$run" || fail "the program's output did not arrive" "$run" -echo "PASS: 1080 gcc over the host's payload links from the payload" diff --git a/tests/e2e/108_cfg_conditional_sources.sh b/tests/e2e/108_cfg_conditional_sources.sh deleted file mode 100755 index 0d8fe517a..000000000 --- a/tests/e2e/108_cfg_conditional_sources.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# G1b: [target.'cfg(...)'.build] sources — target-conditional source globs, -# evaluated against the RESOLVED target. Host-aware: asserts the host-arch -# gated file is compiled and the other-arch file is not. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -HOST_ARCH="$(uname -m)" -case "$HOST_ARCH" in - x86_64) THIS=x86_64; OTHER=aarch64 ;; - aarch64|arm64) THIS=aarch64; OTHER=x86_64 ;; - *) echo "SKIP: unmapped arch $HOST_ARCH"; exit 77 ;; -esac - -cd "$TMP" -"$MCPP" new condsrc > /dev/null -cd condsrc - -mkdir -p arch -cat > arch/this.cpp <<'EOF' -extern "C" int arch_answer() { return 7; } -EOF -cat > arch/other.cpp <<'EOF' -extern "C" int arch_answer() { return 8; } // would double-define if both compiled -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int arch_answer(); -int main() { - std::println("arch = {}", arch_answer()); - return 0; -} -EOF - -cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "arch = 7" ]] || { echo "unexpected output: $out"; exit 1; } -# The other-arch file must not have been compiled. -find target -name 'other.o' | grep -q . && { echo "other-arch source compiled"; exit 1; } || true - -echo "OK" diff --git a/tests/e2e/109_per_glob_flags.sh b/tests/e2e/109_per_glob_flags.sh deleted file mode 100755 index d7118d240..000000000 --- a/tests/e2e/109_per_glob_flags.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# G4: [build] flags = [{ glob = "...", ... }] — per-glob compile flags. -# Matched TUs get the flags (probed via -D), unmatched TUs don't, later -# entries override earlier ones ("last flag wins"), zero-hit globs warn. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new globf > /dev/null -cd globf - -cat > src/tagged.cpp <<'EOF' -#ifndef TAG -#error "per-glob define did not reach tagged.cpp" -#endif -extern "C" int tag_value() { return TAG; } -EOF - -cat > src/plain.cpp <<'EOF' -#ifdef TAG -#error "per-glob define leaked into plain.cpp" -#endif -extern "C" int plain_ok() { return 1; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int tag_value(); -extern "C" int plain_ok(); -int main() { - std::println("tag = {}", tag_value() + plain_ok()); - return 0; -} -EOF - -# Two overlapping globs: the later, more specific entry must win (-DTAG=2 -# appended after -DTAG=1; GNU last-wins → 2). Plus a zero-hit glob → warning. -cat > mcpp.toml <<'EOF' -[package] -name = "globf" -version = "0.1.0" - -[build] -flags = [ - { glob = "src/tag*.cpp", cxxflags = ["-DTAG=1"] }, - { glob = "src/tagged.cpp", cxxflags = ["-DTAG=2"] }, - { glob = "src/never/**", cxxflags = ["-DNOPE"] }, -] -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -grep -q "matched no source file" build.log || { - cat build.log; echo "missing zero-hit glob warning"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "tag = 3" ]] || { echo "unexpected output: $out (want later-entry override → 2+1)"; exit 1; } - -echo "OK" diff --git a/tests/e2e/10_env_command.sh b/tests/e2e/10_env_command.sh deleted file mode 100755 index 906e4cfa9..000000000 --- a/tests/e2e/10_env_command.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# requires: -# `self env` reads a cold home; explicit offline init creates local layout. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" - -# The first query reports paths without creating the home. -out=$("$MCPP" self env 2>&1) -[[ ! -e "$MCPP_HOME" ]] || { echo "self env initialized a cold home"; exit 1; } - -set +e -"$MCPP" --offline self init > "$TMP/init.log" 2>&1 -init_rc=$? -set -e -[[ "$init_rc" == 0 || "$init_rc" == 1 ]] || { cat "$TMP/init.log"; exit 1; } - - -# Verify directory tree -[[ -d "$MCPP_HOME/bin" ]] || { echo "missing bin/"; exit 1; } -[[ -d "$MCPP_HOME/registry" ]] || { echo "missing registry/"; exit 1; } -[[ -d "$MCPP_HOME/build-cache/v1" ]] || { echo "missing build-cache/v1/"; exit 1; } -[[ -d "$MCPP_HOME/cache" ]] || { echo "missing cache/"; exit 1; } -[[ -f "$MCPP_HOME/config.toml" ]] || { echo "missing config.toml"; exit 1; } -[[ -f "$MCPP_HOME/registry/.xlings.json" ]] || { echo "missing seeded .xlings.json"; exit 1; } -[[ -x "$MCPP_HOME/registry/bin/xlings" ]] || { echo "xlings binary not acquired"; exit 1; } - -# Verify seeded .xlings.json contains mcpplibs and NOT awesome -grep -q '"name": "mcpplibs"' "$MCPP_HOME/registry/.xlings.json" || { - echo "seeded .xlings.json missing mcpplibs"; exit 1; } -if grep -q '"name": "awesome"' "$MCPP_HOME/registry/.xlings.json"; then - echo "seeded .xlings.json should NOT contain awesome"; exit 1 -fi - -# Output should mention key paths (use grep — [[ pattern match struggles with multi-line + parens) -echo "$out" | grep -q 'MCPP_HOME' || { echo "no MCPP_HOME in env output"; exit 1; } -echo "$out" | grep -q 'xlings binary' || { echo "no xlings binary in env output"; exit 1; } -echo "$out" | grep -q 'mcpplibs' || { echo "no mcpplibs in env output"; exit 1; } - -echo "OK" diff --git a/tests/e2e/110_build_mcpp_env_contract.sh b/tests/e2e/110_build_mcpp_env_contract.sh deleted file mode 100755 index b31a60eae..000000000 --- a/tests/e2e/110_build_mcpp_env_contract.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/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_tos() { return \"" << env_or("MCPP_TARGET_OS") << "\"; }\n"; - f << "extern \"C\" const char* bp_tarch() { return \"" << env_or("MCPP_TARGET_ARCH") << "\"; }\n"; - // ENV may legitimately be empty (macOS) — assert set-ness, not content. - f << "extern \"C\" int bp_tenv_set() { return " - << (std::getenv("MCPP_TARGET_ENV") ? 1 : 0) << "; }\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(); -extern "C" const char* bp_tos(); -extern "C" const char* bp_tarch(); -extern "C" int bp_tenv_set(); -int main() { - std::println("target={} host={} profile={} features=[{}] extra={} tos={} tarch={} tenvset={}", - bp_target(), bp_host(), bp_profile(), bp_features(), bp_has_extra(), - bp_tos(), bp_tarch(), bp_tenv_set()); - 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; } -# MCPP_TARGET_OS/ARCH/ENV splits: OS is one of the canonical spellings, ARCH is -# non-empty, ENV is always SET (may be "" — no env segment on macOS) and must -# agree with the full triple's segments. -[[ "$out" =~ tos=(linux|macos|windows)\ tarch=([a-z0-9_]+)\ tenvset=1 ]] || { - echo "target split vars missing/odd: $out"; exit 1; } -tos="${BASH_REMATCH[1]}"; tarch="${BASH_REMATCH[2]}" -[[ "$out" == *"target=${tarch}-${tos}"* ]] || { - echo "target split disagrees with MCPP_TARGET: $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 -qE "^ *build\.mcpp .* ran [0-9]" 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 -qE "^ *build\.mcpp .* ran [0-9]" 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 -qE "^ *build\.mcpp .* ran [0-9]" 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 deleted file mode 100755 index 01f247432..000000000 --- a/tests/e2e/111_dep_build_mcpp.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/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: the program in .build-mcpp/deps, -# its output in this configuration's directory (mcpp 2026.10.10.1+). -find target/.build-mcpp/out -path '*deplib/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 deleted file mode 100755 index c99ec03ef..000000000 --- a/tests/e2e/112_build_mcpp_cross.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/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 - -# ── host≠target for `import std;` in build.mcpp ──────────────────────────── -# The std module staged for a build.mcpp must be the HOST one. Feeding it the -# target's would produce a helper that cannot execute here, and the failure is -# silent until exec time — the same class of mistake the mingw-cross work had -# to fix in four separate places. A cross build is the only configuration -# where host and target BMIs differ, so this is the one place it can be -# caught. -cat > build.mcpp <<'EOF' -import std; -int main() { - std::ofstream f("src/cross_gen.cpp"); - f << "extern \"C\" const char* bp_target() { return \"" - << (std::getenv("MCPP_TARGET") ? std::getenv("MCPP_TARGET") : "") - << "\"; }\n"; - if (!f) return 1; - std::println("mcpp:generated=src/cross_gen.cpp"); - return 0; -} -EOF - -rm -f src/cross_gen.cpp -"$MCPP" build --target x86_64-windows-gnu > build-std.log 2>&1 || { - cat build-std.log; echo "cross build with import std in build.mcpp failed"; exit 1; } -# The helper actually RAN on the host — proven by the file it was asked to -# write, not by the compiler's exit code. -[[ -f src/cross_gen.cpp ]] || { - cat build-std.log; echo "import-std build.mcpp did not run on the host"; exit 1; } -grep -q 'x86_64-windows-gnu' src/cross_gen.cpp || { - cat src/cross_gen.cpp; echo "MCPP_TARGET wrong under import std"; exit 1; } - -echo "OK" diff --git a/tests/e2e/113_scanner_mcpp_dir_prune.sh b/tests/e2e/113_scanner_mcpp_dir_prune.sh deleted file mode 100755 index 3a8af3f1f..000000000 --- a/tests/e2e/113_scanner_mcpp_dir_prune.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# mcpp#230: the source scanner follows directory symlinks (0.0.95), and the -# project-local `.mcpp/.xlings/data/` entry is a SYMLINK back to the -# index root — following it walks that entire checkout. In CI that tree held -# a CJK-named file, whose wide→narrow spelling throws on MSVC (bare -# 0xC0000409 / exit 127). Assert both fixes: -# 1. `.mcpp` is pruned from glob walks — an out-of-tree file reachable only -# through the symlink must NOT be compiled (it double-defines main). -# 2. A file whose name cannot be narrowed never aborts the build (covered -# on MSVC by path_matches_glob's catch; here it just rides along). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p outside/sub proj/src - -cat > outside/sub/extra.cpp <<'EOF' -int main() { return 9; } // double-defines main if the walk escapes -EOF -# CJK filename — on MSVC hosts with a non-CJK ANSI codepage this name has no -# narrow spelling; the scanner must skip it, not tear down the build. -cat > "outside/sub/问题反馈.cpp" <<'EOF' -int cjk_never_compiled() { return 2; } -EOF - -cat > proj/src/main.cpp <<'EOF' -int main() { return 0; } -EOF -cat > proj/mcpp.toml <<'EOF' -[package] -name = "prunetest" -version = "0.1.0" - -[build] -sources = ["**/*.cpp"] -EOF - -# Simulate the path-dep index link mcpp creates under the project data dir. -mkdir -p proj/.mcpp/.xlings/data -ln -s "$TMP/outside" proj/.mcpp/.xlings/data/compat - -cd proj -"$MCPP" build -"$MCPP" run - -echo "PASS: .mcpp symlink escape pruned; build unaffected by unmatchable names" diff --git a/tests/e2e/114_run_scan_scope.sh b/tests/e2e/114_run_scan_scope.sh deleted file mode 100755 index 0e337cc28..000000000 --- a/tests/e2e/114_run_scan_scope.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# mcpp#225 (cluster E — E1 bounded glob walk + E2 run-cache reuse): -# E1: expand_glob must bound its walk to the glob's literal directory -# prefix ("src" for "src/**/*.{...}") instead of always walking the -# whole project root. A large, unrelated top-level sibling directory -# (`blob/`, ~1500 files) exercises exactly the tree an unbounded walk -# would otherwise have to stat/canonicalize on every scan. -# E2: `mcpp run` must reuse the build cache `mcpp build` already resolved -# (fingerprint + ninja freshness, same gate as `mcpp build`'s own -# fast path) instead of unconditionally calling prepare_build (full -# toolchain resolution + modgraph scan) on every invocation. -# -# Not asserted via wall-clock (brittle across CI hosts) — asserted via the -# absence of the scan phase's observability marker: prepare_build logs -# `mcpp::log::verbose("scan", "scanning module sources")` right before it -# invokes the scanner (see prepare.cppm). Under MCPP_VERBOSE=1 that line is -# grep-able on stderr. `mcpp run` only reaches prepare_build when its own -# fast path (try_fast_run in execute.cppm) misses — so if the line is -# absent, the run never re-scanned. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p proj/src proj/blob - -cat > proj/mcpp.toml <<'EOF' -[package] -name = "runscope" -version = "0.1.0" -EOF - -cat > proj/src/main.cpp <<'EOF' -#include -int main() { std::puts("hello from runscope"); return 0; } -EOF - -# Large unrelated sibling tree, NOT under src/ and not matched by the -# default "src/**/*.{...}" source glob — present purely to make an -# unbounded (root-wide) walk expensive/observable, and to prove the bounded -# walk (E1) never has a reason to touch it. -for i in $(seq 1 1500); do - : > "proj/blob/file_$i.txt" -done - -cd proj -"$MCPP" build - -# `mcpp build` already resolves + writes a complete cache (incl. -# BuildCacheEntry::runTargets) via run_build_plan, so even the FIRST -# `mcpp run` after a build should hit the fast path — there is no separate -# "warm-up" run required. -MCPP_VERBOSE=1 "$MCPP" run > "$TMP/run1.out" 2>&1 -if grep -q 'scan: scanning module sources' "$TMP/run1.out"; then - echo "FAIL: first mcpp run (post-build) re-scanned module sources instead of reusing the build cache" - cat "$TMP/run1.out" - exit 1 -fi -grep -q "hello from runscope" "$TMP/run1.out" || { - echo "FAIL: mcpp run did not produce expected program output" - cat "$TMP/run1.out" - exit 1 -} - -# Second run: same assertion, now also covering that the fast path is -# stable across repeated invocations (not a one-shot fluke). -MCPP_VERBOSE=1 "$MCPP" run > "$TMP/run2.out" 2>&1 -if grep -q 'scan: scanning module sources' "$TMP/run2.out"; then - echo "FAIL: second mcpp run re-scanned module sources instead of reusing the build cache" - cat "$TMP/run2.out" - exit 1 -fi -grep -q "hello from runscope" "$TMP/run2.out" || { - echo "FAIL: mcpp run did not produce expected program output" - cat "$TMP/run2.out" - exit 1 -} - -# Sanity: touching a source file must still force a real rebuild (the fast -# path's freshness gate, not a permanent bypass). -sleep 1.1 -touch src/main.cpp -MCPP_VERBOSE=1 "$MCPP" run > "$TMP/run3.out" 2>&1 -grep -q 'scan: scanning module sources' "$TMP/run3.out" || { - echo "FAIL: mcpp run did not re-scan after a source change (fast path should have missed)" - cat "$TMP/run3.out" - exit 1 -} -grep -q "hello from runscope" "$TMP/run3.out" || { - echo "FAIL: mcpp run did not produce expected program output after rebuild" - cat "$TMP/run3.out" - exit 1 -} - -echo "PASS: mcpp run reuses the resolved build cache (no re-scan) with a large unrelated sibling tree present, and still rebuilds on source changes" diff --git a/tests/e2e/115_manifest_syntax_aot_brace.sh b/tests/e2e/115_manifest_syntax_aot_brace.sh deleted file mode 100755 index 580af7d38..000000000 --- a/tests/e2e/115_manifest_syntax_aot_brace.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# #227 + #228: [[build.flags]] array-of-tables and glob brace alternation -# {a,b} are both pure-syntax additions to the manifest grammar — this test -# exercises them TOGETHER, the way a real "codec backends" manifest would: -# - two source directories selected by ONE glob with brace alternation -# (p/{aac,bsf}/**), a third sibling directory (p/opus) excluded from it -# - per-glob flags declared via the NEW [[build.flags]] array-of-tables -# syntax (rather than the existing flags = [{...}] inline-table array), -# with a brace glob on the flags entry itself -# Assert: build succeeds, and the per-glob define only reaches units under -# the brace-matched directories (not the sibling excluded from both the -# sources glob and the flags glob). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new aotbrace > /dev/null -cd aotbrace - -mkdir -p src/p/aac src/p/bsf src/p/opus - -cat > src/p/aac/aac.cpp <<'EOF' -#ifndef CODEC -#error "per-glob define (via [[build.flags]] AOT) did not reach aac.cpp" -#endif -extern "C" int aac_id() { return 1; } -EOF - -cat > src/p/bsf/bsf.cpp <<'EOF' -#ifndef CODEC -#error "per-glob define (via [[build.flags]] AOT) did not reach bsf.cpp" -#endif -extern "C" int bsf_id() { return 2; } -EOF - -cat > src/p/opus/opus.cpp <<'EOF' -#ifdef CODEC -#error "brace glob over-matched: opus.cpp is outside {aac,bsf}" -#endif -extern "C" int opus_id() { return 3; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int aac_id(); -extern "C" int bsf_id(); -extern "C" int opus_id(); -int main() { - std::println("ids = {}", aac_id() + bsf_id() + opus_id()); - return 0; -} -EOF - -# sources: brace alternation selects p/aac and p/bsf, plus opus explicitly -# (so it's compiled but must NOT receive the CODEC define) and main.cpp. -# [build].flags: NEW `[[build.flags]]` array-of-tables syntax (issue #227), -# with a brace glob (issue #228) on the entry itself. -cat > mcpp.toml <<'EOF' -[package] -name = "aotbrace" -version = "0.1.0" - -[build] -sources = ["src/p/{aac,bsf}/**/*.cpp", "src/p/opus/**/*.cpp", "src/main.cpp"] - -[[build.flags]] -glob = "src/p/{aac,bsf}/**" -defines = ["CODEC"] -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "ids = 6" ]] || { echo "unexpected output: $out (want ids = 6)"; exit 1; } - -echo "OK" diff --git a/tests/e2e/116_include_family_and_define_spaces.sh b/tests/e2e/116_include_family_and_define_spaces.sh deleted file mode 100755 index edc24da93..000000000 --- a/tests/e2e/116_include_family_and_define_spaces.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# mcpp#226 + mcpp#234: the full include-family flag spelling (-I/-iquote/ -# -isystem/-idirafter/-iprefix/-L) must root-relativize, not just -I; and any -# flag-vector token that contains a space (e.g. `-DT=long long`, one manifest -# `defines` entry) must survive as ONE shell argument through emission, not -# silently split into two. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new incfam > /dev/null -cd incfam - -# --- Part 1: #226 — -iquote (joined spelling) resolves root-relative ------- -# hdr/magic.h is NOT next to the including .c file, so plain quote-include -# lookup (current-dir-of-includer, then quote search path) only finds it if -# -iquotehdr got rewritten to an absolute, root-relative "-iquote/hdr". -mkdir -p hdr -cat > hdr/magic.h <<'EOF' -#define MAGIC 42 -EOF - -cat > src/magic_user.c <<'EOF' -#include "magic.h" -int get_magic(void) { return MAGIC; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int get_magic(); -extern long long get_big(); - -int main() { - std::println("magic={} big={}", get_magic(), get_big()); - return 0; -} -EOF - -# --- Part 2: #234 — a flag-vector token containing a space must reach the -# compiler as ONE argv token, not split on the space. T is injected via a -# per-glob `defines = ["T=long long"]` targeting this file specifically. If -# the space split the token, the compiler would see a bogus positional -# "long" argument and this translation unit would fail to build. -cat > src/typed_user.cpp <<'EOF' -typedef T MyLong; -long long get_big() { - MyLong big = 123456789012LL; - return static_cast(big); -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "incfam" -version = "0.1.0" - -[build] -cflags = ["-iquotehdr"] -flags = [ - { glob = "src/typed_user.cpp", defines = ["T=long long"] }, -] -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "magic=42 big=123456789012" ]] || { - echo "unexpected output: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/117_object_path_collision.sh b/tests/e2e/117_object_path_collision.sh deleted file mode 100755 index 56716975e..000000000 --- a/tests/e2e/117_object_path_collision.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# requires: -# mcpp#233: object paths must mirror the source's relative directory, not -# fold onto `obj/_/`. Two sources with -# the SAME basename under DIFFERENT subtrees that happen to share the same -# immediate parent directory NAME — a/src/util.cpp and b/src/util.cpp, both -# under a literal `src/` — used to fold onto the same object output -# (`obj/_src/util.o`), and ninja rejected the generated plan with -# "multiple rules generate obj/...". This must now build successfully, with -# each source compiling to its own, distinct object. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new objcollide > /dev/null -cd objcollide - -mkdir -p a/src b/src - -cat > a/src/util.cpp <<'EOF' -int fa() { return 1; } -EOF - -cat > b/src/util.cpp <<'EOF' -int fb() { return 2; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern int fa(); -extern int fb(); -int main() { - int sum = fa() + fb(); - std::println("sum={}", sum); - return sum == 3 ? 0 : 1; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "objcollide" -version = "0.1.0" -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp", "a/src/**/*.cpp", "b/src/**/*.cpp"] -[targets.objcollide] -kind = "bin" -main = "src/main.cpp" -EOF - -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "FAIL: build failed (expected: colliding basenames must not collapse to the same object path)" - exit 1 -} - -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } - -# Both util.cpp compile edges must be present, each with a DISTINCT object -# output (mirroring a/src vs b/src rather than folding both onto the same -# "..._src/util.o"). -util_objects="$(grep -oE 'build [^ ]*util[^ ]*\.o : cxx_object' "$ninja_file" | awk '{print $2}' | sort -u)" -util_count="$(echo "$util_objects" | grep -c . || true)" -[[ "$util_count" == "2" ]] || { - echo "FAIL: expected 2 distinct util.cpp object outputs, got $util_count:" - echo "$util_objects" - cat "$ninja_file" - exit 1 -} - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "sum=3" ]] || { echo "unexpected output: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/118_purview_include_rebuild.sh b/tests/e2e/118_purview_include_rebuild.sh deleted file mode 100755 index 473848fdd..000000000 --- a/tests/e2e/118_purview_include_rebuild.sh +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env bash -# mcpp#235 / mcpp#257: compile edges must track header/purview/GMF -# `#include`s via a depfile. Before #235, `cxx_module`/`cxx_object` had NO -# depfile on non-MSVC (only the msvcDeps branch added `deps=msvc`) — the -# P1689 scan's `$out.dep` was generated then discarded. So editing a file -# `#include`d inside a module's purview (or a plain header included by a -# .cpp) did NOT invalidate the compile edge: `mcpp run` kept printing stale -# output. -# -# #257: the 0.0.97 fix was scoped to GCC because GCC's `-fmodules -MMD` -# depfile needs an awk filter and Clang's shape was unverified. It turned -# out Clang emits a single plain rule with nothing to filter, so this test -# no longer declares `# requires: gcc` — the contract holds on every POSIX -# toolchain, and running it under Clang is the point. -set -e - -# Resolved before any cd: the script changes directory below. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Portable in-place edit. This test lost its `# requires: gcc` gate in #257, -# so it now runs on macOS too, where BSD sed reads `-i`'s next argument as a -# backup suffix and swallows the script. -subst() { # subst - sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2" -} - -# EVERY HOST TRACKS INCLUDES (the 2026-09-28 design, WS2). Until 2026.9.28.2 -# a Windows host with a GNU-dialect toolchain -- the default LLVM row's clang++ -# since #718 -- emitted no depfile, because the gate read the HOST instead of -# the compiler, and this test asserted only that the engine said so. Clang -# writes a plain depfile on Windows as it does elsewhere, and GCC's filtered -# form arrives through `mcpp depfile-filter` there, so the rebuild is asserted -# on every host, and the old degradation must be gone. On 2026.9.28.1 this -# fails on the Windows row: the edit below leaves the output at 41. -DEGRADED_MSG="emits no GNU depfile" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new purviewinc > /dev/null -cd purviewinc - -cat > src/vals.inc <<'EOF' -export inline int answer() { return 41; } -EOF - -cat > src/m.cppm <<'EOF' -export module m; -#include "vals.inc" -EOF - -cat > src/main.cpp <<'EOF' -import std; -import m; -int main() { - std::println("{}", answer()); - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "purviewinc" -version = "0.1.0" -EOF - -run_log=$("$MCPP" run 2>&1) -out="$(echo "$run_log" | tail -1)" -[[ "$out" == "41" ]] || { echo "unexpected initial output: $out"; exit 1; } -if echo "$run_log" | grep -q "$DEGRADED_MSG"; then - echo "$run_log" - echo "FAIL: the build still reports that it emits no GNU depfile" - exit 1 -fi - -subst 's/41/42/' src/vals.inc - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "42" ]] || { - echo "FAIL: editing a purview #include'd file did not trigger a rebuild" - echo "got: $out (expected 42)" - exit 1 -} - -# --- Second assertion: a plain .h included by a .cpp must also rebuild ---- -cat > src/helper.h <<'EOF' -inline int helper_val() { return 100; } -EOF - -cat > src/helper_user.cpp <<'EOF' -#include "helper.h" -int use_helper() { return helper_val(); } -EOF - -cat > src/main.cpp <<'EOF' -import std; -import m; -extern int use_helper(); -int main() { - std::println("{} {}", answer(), use_helper()); - return 0; -} -EOF - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "42 100" ]] || { echo "unexpected output before header edit: $out"; exit 1; } - -subst 's/100/200/' src/helper.h - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "42 200" ]] || { - echo "FAIL: editing a plain .h included by a .cpp did not trigger a rebuild" - echo "got: $out (expected 42 200)" - exit 1 -} - - -# --- #257: the same contract under Clang ----------------------------------- -# 0.0.97 shipped the depfile for GCC only, so this exact scenario silently -# served a stale BMI on every Clang platform (macOS default, Windows hosted). -# Re-run both assertions with an LLVM toolchain when one is installed. -source "$SCRIPT_DIR/_llvm_env.sh" -if [[ -d "$LLVM_ROOT" ]]; then - cd "$TMP" - "$MCPP" new purviewinc_clang > /dev/null - cd purviewinc_clang - - cat > src/vals.inc <<'EOF' -export inline int answer() { return 41; } -EOF - cat > src/m.cppm <<'EOF' -export module m; -#include "vals.inc" -EOF - cat > src/main.cpp <<'EOF' -import std; -import m; -int main() { - std::println("{}", answer()); - return 0; -} -EOF - cat > mcpp.toml <&1 | tail -1)" - [[ "$out" == "41" ]] || { echo "clang: unexpected initial output: $out"; exit 1; } - - subst 's/41/42/' src/vals.inc - - out="$("$MCPP" run 2>&1 | tail -1)" - [[ "$out" == "42" ]] || { - echo "FAIL (#257, clang): editing a purview #include'd file did not trigger a rebuild" - echo "got: $out (expected 42)" - exit 1 - } - - cat > src/helper.h <<'EOF' -inline int helper_val() { return 100; } -EOF - cat > src/helper_user.cpp <<'EOF' -#include "helper.h" -int use_helper() { return helper_val(); } -EOF - cat > src/main.cpp <<'EOF' -import std; -import m; -extern int use_helper(); -int main() { - std::println("{} {}", answer(), use_helper()); - return 0; -} -EOF - out="$("$MCPP" run 2>&1 | tail -1)" - [[ "$out" == "42 100" ]] || { echo "clang: unexpected output before header edit: $out"; exit 1; } - - subst 's/100/200/' src/helper.h - - out="$("$MCPP" run 2>&1 | tail -1)" - [[ "$out" == "42 200" ]] || { - echo "FAIL (#257, clang): editing a plain .h included by a .cpp did not trigger a rebuild" - echo "got: $out (expected 42 200)" - exit 1 - } - echo " clang leg (llvm@${LLVM_VERSION}) ok" -else - echo " clang leg skipped (no llvm payload installed)" -fi - -echo "OK" diff --git a/tests/e2e/119_dep_cfg_sources.sh b/tests/e2e/119_dep_cfg_sources.sh deleted file mode 100755 index 517c0b51f..000000000 --- a/tests/e2e/119_dep_cfg_sources.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 119_dep_cfg_sources.sh — #229: a PATH DEPENDENCY's `[target.'cfg(...)'.build]` -# conditional `sources` must be compiled, in BOTH `mcpp build` and `mcpp test`. -# -# Regression: `merge_conditional_build` (the L1b cfg-sources merge) was only -# ever invoked for the root package (prepare.cppm root call site) and for -# version/registry dependencies (loadVersionDep). Path/git dependencies load -# their manifest directly and skipped the merge entirely — a path dep's -# cfg-conditional `sources` were parsed into `conditionalConfigs` but never -# folded into `buildConfig.sources` / `modules.sources`, so the modgraph scan -# never saw the file and the final link failed with `undefined reference`. -# -# `mylib` is a path dependency whose `mylib.cppm` module forward-declares -# `impl_value()` (extern "C", to sidestep name mangling) but the DEFINITION -# lives in `src/impl.cpp`, which is reachable ONLY through the cfg-conditional -# `[target.'cfg(...)'.build] sources` — never through the unconditional -# `[build] sources` glob. Host-aware: both `cfg(unix)` and `cfg(windows)` -# blocks point at the same file, so exactly one always applies (mirrors -# 86_target_cfg_dependencies.sh's host-aware dual-predicate style) and the -# test runs identically on all 3 CI platforms. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mylib/src -cat > mylib/mcpp.toml <<'EOF' -[package] -name = "mylib" -version = "0.1.0" - -[targets.mylib] -kind = "lib" - -[build] -sources = ["src/**/*.cppm"] - -# impl.cpp is reachable ONLY through these conditional blocks — never through -# the unconditional [build] sources glob above. Exactly one of unix/windows -# always matches the resolved host target. -[target.'cfg(unix)'.build] -sources = ["src/impl.cpp"] -[target.'cfg(windows)'.build] -sources = ["src/impl.cpp"] -EOF -cat > mylib/src/mylib.cppm <<'EOF' -export module mylib; - -// Defined in src/impl.cpp, which is pulled in ONLY via cfg-conditional -// [target.'cfg(...)'.build] sources — extern "C" so the module boundary -// doesn't affect the link name. -extern "C" int impl_value(); - -export int mylib_answer() { return impl_value(); } -EOF -cat > mylib/src/impl.cpp <<'EOF' -extern "C" int impl_value() { return 42; } -EOF - -mkdir -p app/src app/tests -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -mylib = { path = "../mylib" } -EOF -cat > app/src/main.cpp <<'EOF' -import mylib; -import std; -int main() { - std::println("value = {}", mylib_answer()); - return mylib_answer() == 42 ? 0 : 1; -} -EOF -cat > app/tests/t.cpp <<'EOF' -import mylib; -int main() { - return mylib_answer() == 42 ? 0 : 1; -} -EOF - -cd app - -# (1) `mcpp build` must compile+link mylib's cfg-conditional source. -"$MCPP" build > b.log 2>&1 || { - cat b.log - echo "FAIL: build did not compile the path dep's cfg-conditional sources (undefined reference expected pre-fix)" - exit 1 -} -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "value = 42" ]] || { echo "FAIL: unexpected run output: $out"; exit 1; } - -# (2) `mcpp test` must ALSO compile+link it (the per-package funnel must fire -# in both modes, mirroring 100_feature_sources_test_mode.sh's build/test -# parity check). -"$MCPP" test > t.log 2>&1 || { - cat t.log - echo "FAIL: test mode did not compile the path dep's cfg-conditional sources" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/11_index_list.sh b/tests/e2e/11_index_list.sh deleted file mode 100755 index 4bbe38980..000000000 --- a/tests/e2e/11_index_list.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# requires: -# `mcpp index list` shows configured registries (after init). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" - -# init -"$MCPP" self config --mirror auto > /dev/null - -# list should at minimum show mcpplibs (from seeded config) -out=$("$MCPP" index list 2>&1) -[[ "$out" == *"mcpplibs"* ]] || { echo "index list missing mcpplibs: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/120_ws_root_indices.sh b/tests/e2e/120_ws_root_indices.sh deleted file mode 100755 index de19eb898..000000000 --- a/tests/e2e/120_ws_root_indices.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# #224: root-anchored inheritance. -# 1. A workspace ROOT declares `[indices] x = { path = "..." }` (a -# relative path) ONCE. A member with NO [indices] section of its own -# inherits it and must resolve the relative path against the -# WORKSPACE ROOT, not `//...` — otherwise every member -# would need its own `../`-prefixed re-declaration. -# 2. `[workspace.dependencies] ylib = { path = "..." }` (also relative to -# the workspace root) is usable by a member via `ylib.workspace = true` -# — previously `merge_workspace_deps` only propagated `version`, so a -# path-form workspace dependency was silently dropped and failed to -# resolve. -# 3. A member that declares its own `[indices]` with a relative path wrote -# the path in ITS directory. The plan's root is the workspace root, so -# the member's path is anchored at the member (2026.9.29.1 resolved it -# against the workspace root and found no index). The two members name -# one tree, so they are one configuration. -# 4. COMPAT(workspace-position): the root's own `[indices]` reaches the -# members by POSITION, with a warning naming `[workspace.indices]`, until -# mcpp 1.0.0 (SPEC-004 §9.10). Kept in this spelling as that coverage; -# tests/e2e/893 covers `[workspace.indices]`. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/ws" -cd "$TMP/ws" - -# ── A project-local index, declared relative to the WORKSPACE ROOT ────── -mkdir -p local-index/pkgs/x -cat > local-index/pkgs/x/x.widget2.lua <<'EOF' -package = { - spec = "1", - namespace = "x", - name = "x.widget2", - description = "Namespaced package reachable only via the workspace-root-anchored index", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["1.0.0"] = { - url = "https://example.invalid/widget2-1.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = false, - sources = { "src/widget2.cppm" }, - targets = { ["widget2"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -# ── A path-form workspace dependency, also relative to the WORKSPACE ROOT ── -mkdir -p ylib/src -cat > ylib/mcpp.toml <<'EOF' -[package] -name = "ylib" -version = "0.1.0" - -[targets.ylib] -kind = "lib" -EOF -cat > ylib/src/ylib.cppm <<'EOF' -export module ylib; - -export int ylib_value() { - return 7; -} -EOF - -# ── Workspace root: virtual, one member, no re-declaration required ───── -cat > mcpp.toml < .mcpp/.xlings/data/xpkgs/x.widget2/1.0.0/src/widget2.cppm <<'EOF' -export module widget2; - -export int widget2_value() { - return 35; -} -EOF - -# Deliberately NO [indices] section here — must inherit from the root and -# resolve "local-index" against the WORKSPACE ROOT, not member-a/local-index. -cat > member-a/mcpp.toml <<'EOF' -[package] -name = "member-a" -version = "0.1.0" - -[dependencies.x] -widget2 = "1.0.0" - -[dependencies] -ylib = { workspace = true } - -[targets.member-a] -kind = "bin" -main = "src/main.cpp" -EOF - -cat > member-a/src/main.cpp <<'EOF' -import widget2; -import ylib; - -int main() { - return (widget2_value() + ylib_value() == 42) ? 0 : 1; -} -EOF - -mkdir -p member-b/src -cat > member-b/mcpp.toml <<'EOF' -[indices] -x = { path = "../local-index" } - -[package] -name = "member-b" -version = "0.1.0" - -[dependencies.x] -widget2 = "1.0.0" -EOF -cat > member-b/src/main.cpp <<'EOF' -import widget2; - -int main() { - return widget2_value() == 35 ? 0 : 1; -} -EOF - -"$MCPP" build -p member-a > build.log 2>&1 || { - cat build.log - echo "FAIL: member did not resolve root-anchored [indices]/[workspace.dependencies] path" - exit 1 -} - -grep -q '\[indices\] on the workspace root reaches every member by its position' build.log || { - cat build.log - echo "FAIL: the position reading of [indices] says nothing (COMPAT(workspace-position))" - exit 1 -} -grep -q '\[workspace.indices\]' build.log || { - cat build.log - echo "FAIL: the warning does not name the canonical spelling" - exit 1 -} - -"$MCPP" run -p member-a > run.log 2>&1 || { - cat run.log - echo "FAIL: run failed" - exit 1 -} - -"$MCPP" build -p member-b > build-b.log 2>&1 || { - cat build-b.log - echo "FAIL: a member's own relative [indices].path is not anchored at the member" - exit 1 -} -"$MCPP" run -p member-b > run-b.log 2>&1 || { - cat run-b.log - echo "FAIL: member-b's program failed" - exit 1 -} -"$MCPP" build --workspace > build-ws.log 2>&1 || { - cat build-ws.log - echo "FAIL: --workspace" - exit 1 -} -n=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | wc -l) -[[ "$n" == 1 ]] || { - find target -name build.ninja -not -path '*/.build-mcpp/*' - echo "FAIL: members naming one index tree are $n configurations, expected 1" - exit 1 -} - -grep -q '\[package\."x.widget2"\]' mcpp.lock || { - cat mcpp.lock 2>/dev/null || true - echo "FAIL: expected x.widget2 lock entry" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/121_default_ns_redirect.sh b/tests/e2e/121_default_ns_redirect.sh deleted file mode 100755 index 99b76cbb9..000000000 --- a/tests/e2e/121_default_ns_redirect.sh +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# R6: `[indices] default = { path = ... }` (also spellable `"" = {...}`) -# redirects the DEFAULT namespace — bare `gizmo = "1.0.0"` deps with no -# namespace prefix — to a local checkout, instead of the two hardcoded -# short-circuits (prepare.cppm usesBuiltinIndex / findIndexForNs) that used -# to route the default namespace straight to the builtin index regardless -# of [indices]. The package is declared ONLY in a project-local index dir -# that lives outside $MCPP_HOME (never seeded into the global/builtin -# registry location), so a build that still fell through to the builtin -# short-circuit would fail to resolve it. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -# A local index directory, deliberately NOT under $MCPP_HOME — it must be -# reached only via the [indices] default = {...} redirect. -INDEX_DIR="$TMP/local-index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/g" -cat > "$INDEX_DIR/pkgs/g/gizmo.lua" <<'EOF' -package = { - spec = "1", - name = "gizmo", - description = "Default-namespace package served only via a redirected index", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["1.0.0"] = { - url = "https://example.invalid/gizmo-1.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = false, - sources = { "src/gizmo.cppm" }, - targets = { ["gizmo"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -mkdir -p "$TMP/project/app/src" \ - "$TMP/project/app/.mcpp/.xlings/data/xpkgs/gizmo/1.0.0/src" -cd "$TMP/project/app" - -cat > .mcpp/.xlings/data/xpkgs/gizmo/1.0.0/src/gizmo.cppm <<'EOF' -export module gizmo; - -export int gizmo_value() { - return 42; -} -EOF - -cat > src/main.cpp <<'EOF' -import gizmo; - -int main() { - return gizmo_value() == 42 ? 0 : 1; -} -EOF - -cat > mcpp.toml < build.log 2>&1 || { - cat build.log - echo "FAIL: build did not resolve default-namespace dep from the redirected local index" - exit 1 -} - -"$MCPP" run > run.log 2>&1 || { - cat run.log - echo "FAIL: run failed" - exit 1 -} - -grep -q '\[package\."gizmo"\]' mcpp.lock || { - cat mcpp.lock - echo "FAIL: expected gizmo package lock entry" - exit 1 -} - -# ── "" as an alternate spelling of the redirect key ───────────────────── -mkdir -p "$TMP/project/app2/src" -cd "$TMP/project/app2" - -cat > src/main.cpp <<'EOF' -import gizmo; - -int main() { - return gizmo_value() == 42 ? 0 : 1; -} -EOF - -cat > mcpp.toml < .mcpp/.xlings/data/xpkgs/gizmo/1.0.0/src/gizmo.cppm <<'EOF' -export module gizmo; - -export int gizmo_value() { - return 42; -} -EOF - -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo 'FAIL: [indices] "" = {...} did not redirect the default namespace' - exit 1 -} - -echo "OK" diff --git a/tests/e2e/122_run_member.sh b/tests/e2e/122_run_member.sh deleted file mode 100755 index 5c8b7d85a..000000000 --- a/tests/e2e/122_run_member.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# requires: -# run-member-selection: `mcpp run` gains -p/--package, matching `build -p` / -# `test -p`. `mcpp run -p memberB` in a workspace with two binary members -# must build+run memberB's binary specifically (not memberA's, not an -# auto-picked one). -set -euo pipefail - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p memberA/src memberB/src - -cat > mcpp.toml <<'EOF' -[workspace] -members = ["memberA", "memberB"] -EOF - -cat > memberA/mcpp.toml <<'EOF' -[package] -name = "memberA" -version = "0.1.0" - -[targets.memberA] -kind = "bin" -main = "src/main.cpp" -EOF -cat > memberA/src/main.cpp <<'EOF' -import std; -int main() { std::println("hello from memberA"); return 0; } -EOF - -cat > memberB/mcpp.toml <<'EOF' -[package] -name = "memberB" -version = "0.1.0" - -[targets.memberB] -kind = "bin" -main = "src/main.cpp" -EOF -cat > memberB/src/main.cpp <<'EOF' -import std; -int main() { std::println("hello from memberB"); return 0; } -EOF - -# `-p memberB` must run memberB's binary — assert its DISTINCT output, not -# memberA's (and not "no binary target found"). -# Standard output is the program's alone: the status lines are on standard -# error (output streams plan 2026-10-01, R3), so the capture is exact. -OUT=$("$MCPP" run -p memberB 2>run_b.log) || { cat run_b.log; echo "FAIL: run -p memberB failed"; exit 1; } -echo "$OUT" -[[ "$OUT" == "hello from memberB" ]] || { - echo "FAIL: expected memberB's output, got: $OUT" - exit 1 -} -[[ "$OUT" != *"hello from memberA"* ]] || { - echo "FAIL: memberA's output leaked into -p memberB run" - exit 1 -} - -# `-p memberA` runs the other one. -OUT=$("$MCPP" run -p memberA 2>run_a.log) || { cat run_a.log; echo "FAIL: run -p memberA failed"; exit 1; } -echo "$OUT" -[[ "$OUT" == "hello from memberA" ]] || { - echo "FAIL: expected memberA's output, got: $OUT" - exit 1 -} - -# Unknown member name errors clearly instead of silently picking one. -if "$MCPP" run -p nope > run_bad.log 2>&1; then - echo "FAIL: run -p nope should have failed" - exit 1 -fi -grep -qi "nope" run_bad.log || { - cat run_bad.log - echo "FAIL: expected an error mentioning the unknown member 'nope'" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/123_same_named_main_across_dep.sh b/tests/e2e/123_same_named_main_across_dep.sh deleted file mode 100755 index 878f0d9ef..000000000 --- a/tests/e2e/123_same_named_main_across_dep.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# mcpp#240 (follow-up to #233): when a dependency package and the consumer -# ship a SAME-NAMED source (the near-universal case: both have src/main.cpp — -# e.g. OpenCV's sample main.cpp's vs the consumer's own), #233's object-path -# disambiguation renames the scanned consumer main to obj//src/main.o, -# but the LINK step kept referencing the pre-disambiguation flat obj/main.o: -# ninja: error: 'obj/main.o', needed by 'bin/', missing and no known rule -# The entry-main link input must follow the same disambiguation as its -# compile edge. Member-scoped tests dodge this (distinct filenames), so only -# a real dep+consumer collision exercises it. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# Dependency (kind=lib) that ships its OWN src/main.cpp (an impl file here, -# same basename as the consumer's entry). -mkdir -p mydep/src -cat > mydep/src/main.cpp <<'EOF' -int dep_helper() { return 41; } -EOF -cat > mydep/mcpp.toml <<'EOF' -[package] -name = "mydep" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[targets.mydep] -kind = "lib" -EOF - -# Consumer with its OWN src/main.cpp (globbed into sources, so it is scanned -# and subject to disambiguation). -mkdir -p consumer/src -cat > consumer/src/main.cpp <<'EOF' -import std; -extern int dep_helper(); -int main() { - std::println("val={}", dep_helper()); - return dep_helper() == 41 ? 0 : 1; -} -EOF -cat > consumer/mcpp.toml <<'EOF' -[package] -name = "consumer" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -mydep = { path = "../mydep" } -[targets.consumer] -kind = "bin" -main = "src/main.cpp" -EOF - -cd consumer -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "FAIL: build failed (expected: same-named main across dep must link the disambiguated object)" - exit 1 -} - -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } - -# THE invariant: every object the link edge names must be PRODUCED by an edge in -# the same graph. #240's bug was a link input that no edge produced. -# -# This deliberately does not assert *which* path the consumer's main lands on. -# The original assertion ("must not be the flat obj/main.o") encoded the shape of -# the first fix rather than the property: back then the consumer's main WAS -# renamed, because disambiguation was decided by a census over the whole build -# dir, so a dependency's same-named file dragged the consumer along with it. -# Since mcpp#344 a dependency's objects live under -# obj/_/, cross-package collisions cannot happen, and the -# consumer's own main correctly stays flat. The namespace component is part -# of exact package identity and prevents same-short packages from colliding. -# Both layouts satisfy #240; only "produced by some edge" distinguishes a fixed -# tree from a broken one. -link_line="$(grep -E '^build bin/consumer *:' "$ninja_file")" -[[ -n "$link_line" ]] || { echo "FAIL: no link edge for bin/consumer"; cat "$ninja_file"; exit 1; } - -for obj in $(echo "$link_line" | sed -E 's/^build bin\/consumer *: *cxx_link //' | tr ' ' '\n' | grep -E '\.o$'); do - grep -qE "^build ${obj//\//\\/} *:" "$ninja_file" || { - echo "FAIL: link input '$obj' is not produced by any edge" - echo "$link_line" - cat "$ninja_file" - exit 1 - } -done - -# And the dependency's same-named source must have gotten its own object rather -# than silently overwriting the consumer's. -grep -qE '^build obj/mcpplibs_mydep/.*main\.o *:' "$ninja_file" || { - echo "FAIL: the dependency's src/main.cpp has no object of its own" - grep -n 'main\.o' "$ninja_file" - exit 1 -} - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "val=41" ]] || { echo "unexpected output: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/124_gen_source_out_of_root_object_path.sh b/tests/e2e/124_gen_source_out_of_root_object_path.sh deleted file mode 100755 index 717c1b3b0..000000000 --- a/tests/e2e/124_gen_source_out_of_root_object_path.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# mcpp#239 (follow-up to #233): a dependency's build.mcpp can emit generated -# sources into OUT_DIR (/target/.build-mcpp/deps/@/out), -# which live OUTSIDE the dependency's package root. Their scanner relPath is -# `relative(, )` and therefore carries `..` -# components. #233's collision-disambiguation pasted that relPath straight -# into the object path: -# cc1plus: fatal error: opening output file -# obj//..//target/.../out/gen.o: No such file or directory -# i.e. `obj//../…` climbs out of obj/ (and with enough `..`, mirrors the -# whole absolute tree under CWD). The collision prefix must be sanitized so -# every object stays under obj/. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# Dependency whose build.mcpp writes gen.cpp into OUT_DIR (absolute path, -# outside the package root). -mkdir -p gdep/src -cat > gdep/build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - std::string p = std::string(mcpp::out_dir()) + "/gen.cpp"; - std::FILE* f = std::fopen(p.c_str(), "w"); - std::fputs("int gen_val() { return 7; }\n", f); - std::fclose(f); - mcpp::generated("gen.cpp"); - return 0; -} -EOF -cat > gdep/src/lib.cpp <<'EOF' -extern int gen_val(); -int gdep_use() { return gen_val(); } -EOF -cat > gdep/mcpp.toml <<'EOF' -[package] -name = "gdep" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[targets.gdep] -kind = "lib" -EOF - -# Consumer with its OWN src/gen.cpp — same basename as gdep's generated -# gen.cpp, forcing the disambiguation branch on the out-of-root abs source. -mkdir -p consumer/src -cat > consumer/src/gen.cpp <<'EOF' -int consumer_gen() { return 100; } -EOF -cat > consumer/src/main.cpp <<'EOF' -import std; -extern int gdep_use(); -extern int consumer_gen(); -int main() { - std::println("sum={}", gdep_use() + consumer_gen()); - return (gdep_use() == 7 && consumer_gen() == 100) ? 0 : 1; -} -EOF -cat > consumer/mcpp.toml <<'EOF' -[package] -name = "consumer" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -gdep = { path = "../gdep" } -[targets.consumer] -kind = "bin" -main = "src/main.cpp" -EOF - -cd consumer -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "FAIL: build failed (expected: out-of-root generated source must map under obj/)" - exit 1 -} - -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } - -# No object output may escape obj/ via `..` or an absolute root. -bad="$(grep -oE 'build [^ ]+\.o : cxx_object' "$ninja_file" | awk '{print $2}' \ - | grep -E '(^|/)\.\.(/|$)|^/' || true)" -if [[ -n "$bad" ]]; then - echo "FAIL: object path escapes obj/:" - echo "$bad" - exit 1 -fi - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "sum=107" ]] || { echo "unexpected output: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/125_build_mcpp_dep_dir.sh b/tests/e2e/125_build_mcpp_dep_dir.sh deleted file mode 100755 index 8e24ae84e..000000000 --- a/tests/e2e/125_build_mcpp_dep_dir.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# mcpp#241: build.mcpp env contract exposes each resolved dependency's install -# dir as MCPP_DEP__DIR (read via mcpp::dep_dir("")), so a -# package's build.mcpp can locate a dependency's payload (e.g. a data-asset -# package) by name instead of reverse-engineering the store layout. Scenario: -# consumer -> pkgp (has build.mcpp) -> datad (data package). pkgp's build.mcpp -# reads dep_dir("datad") and generates a source returning it; consumer prints it. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# data-asset dependency -mkdir -p datad/src -echo 'int datad_touch() { return 1; }' > datad/src/d.cpp -cat > datad/mcpp.toml <<'EOF' -[package] -name = "datad" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[targets.datad] -kind = "lib" -EOF - -# middle package with a build.mcpp that locates datad's dir -mkdir -p pkgp/src -cat > pkgp/src/lib.cpp <<'EOF' -extern const char* dep_datad_dir(); -const char* pkgp_datad_dir() { return dep_datad_dir(); } -EOF -cat > pkgp/build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const char* d = mcpp::dep_dir("datad"); - if (d == nullptr || d[0] == '\0') { - std::fprintf(stderr, "build.mcpp: MCPP_DEP_DATAD_DIR not set\n"); - return 1; // fail loudly if the contract var is missing - } - std::string out = std::string(mcpp::out_dir()) + "/depdir.cpp"; - std::FILE* f = std::fopen(out.c_str(), "w"); - std::fprintf(f, "extern const char* dep_datad_dir();\n"); - std::fprintf(f, "const char* dep_datad_dir() { return \"%s\"; }\n", d); - std::fclose(f); - mcpp::generated("depdir.cpp"); - return 0; -} -EOF -cat > pkgp/mcpp.toml <<'EOF' -[package] -name = "pkgp" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -datad = { path = "../datad" } -[targets.pkgp] -kind = "lib" -EOF - -# root consumer -mkdir -p consumer/src -cat > consumer/src/main.cpp <<'EOF' -import std; -extern const char* pkgp_datad_dir(); -int main() { - std::println("DEPDIR={}", pkgp_datad_dir()); - return 0; -} -EOF -cat > consumer/mcpp.toml <<'EOF' -[package] -name = "consumer" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -pkgp = { path = "../pkgp" } -[targets.consumer] -kind = "bin" -main = "src/main.cpp" -EOF - -cd consumer -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "FAIL: build failed"; exit 1; } - -out="$("$MCPP" run 2>&1 | grep '^DEPDIR=' | tail -1)" -dir="${out#DEPDIR=}" -[[ -n "$dir" ]] || { echo "FAIL: empty dep dir (contract var missing)"; exit 1; } -[[ -d "$dir" ]] || { echo "FAIL: dep dir does not exist: $dir"; exit 1; } -[[ "$(basename "$dir")" == "datad" ]] || { echo "FAIL: dep dir is not datad's root: $dir"; exit 1; } - -echo "OK" diff --git a/tests/e2e/126_default_features_opt_out.sh b/tests/e2e/126_default_features_opt_out.sh deleted file mode 100755 index 22a8e7278..000000000 --- a/tests/e2e/126_default_features_opt_out.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -# 126_default_features_opt_out.sh — consumer-side `default-features = false` -# (#242, Cargo parity). A dependency declares a DEFAULT feature set -# (`[features] default = ["turbo"]`) that carries a package-owned define. A -# consumer must be able to OPT OUT of that default set via -# `default-features = false`, so the dependency's default feature (and its -# define) does NOT activate. Explicitly requesting `features = [...]` still -# activates those, even alongside the opt-out. -# -# Root-cause funnel: the feature-activation closure (prepare.cppm -# feature_closure) no longer unconditionally seeds `[features].default` for a -# dependency — it seeds it only when the consumer's dep spec keeps default -# features (seedDefault). See the #242 commit. -# -# No `requires:` capability → runs on all three CI platforms. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# Dependency whose DEFAULT feature set enables `turbo`, which carries a -# package-owned define that propagates to consumers (header-only case). -mkdir -p widget/include/widget widget/src -cat > widget/mcpp.toml <<'EOF' -[package] -name = "widget" -version = "0.1.0" - -[features] -default = ["turbo"] -turbo = { defines = ["WIDGET_TURBO=1"] } - -[build] -include_dirs = ["include"] - -[targets.widget] -kind = "lib" -EOF -cat > widget/include/widget/widget.hpp <<'EOF' -#pragma once -inline int widget_mode() { -#ifdef WIDGET_TURBO - return 1; -#else - return 0; -#endif -} -EOF -cat > widget/src/widget.cppm <<'EOF' -export module widget; -export int widget_anchor() { return 0; } -EOF - -# ── Case A: default-features = false → the dep's default `turbo` is SUPPRESSED. -# The consumer TU must NOT see WIDGET_TURBO; if it did, the #error below trips -# and the build fails. -mkdir -p appoff/src -cat > appoff/mcpp.toml <<'EOF' -[package] -name = "appoff" -version = "0.1.0" - -[dependencies] -widget = { path = "../widget", default-features = false } -EOF -cat > appoff/src/main.cpp <<'EOF' -#include -#ifdef WIDGET_TURBO -#error "WIDGET_TURBO leaked despite default-features = false" -#endif -int main() { return widget_mode() == 0 ? 0 : 2; } -EOF - -( cd appoff - "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(A): opt-out build failed"; exit 1; } - if grep -q 'WIDGET_TURBO' compile_commands.json; then - echo "FAIL(A): WIDGET_TURBO present despite default-features = false"; cat compile_commands.json; exit 1 - fi - BIN=$(find target -type f \( -name appoff -o -name appoff.exe \) | head -1) - [ -n "$BIN" ] || { echo "FAIL(A): built binary not found"; exit 1; } - "$BIN"; rc=$? - [ "$rc" -eq 0 ] || { echo "FAIL(A): binary observed turbo despite opt-out (exit $rc)"; exit 1; } -) - -# ── Case B: default features ON (omitted) → the dep's default `turbo` activates -# and its define reaches the consumer, exactly as before this change. -mkdir -p appon/src -cat > appon/mcpp.toml <<'EOF' -[package] -name = "appon" -version = "0.1.0" - -[dependencies] -widget = { path = "../widget" } -EOF -cat > appon/src/main.cpp <<'EOF' -#include -#ifndef WIDGET_TURBO -#error "WIDGET_TURBO missing though default features are on" -#endif -int main() { return widget_mode() == 1 ? 0 : 2; } -EOF - -( cd appon - "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(B): default-on build failed"; exit 1; } - grep -q 'WIDGET_TURBO' compile_commands.json || { - echo "FAIL(B): WIDGET_TURBO missing with default features on"; cat compile_commands.json; exit 1; } -) - -# ── Case C: default-features = false BUT explicitly re-request `turbo` → -# the explicit request still activates it (opt-out suppresses only the default -# seed, not explicit `features = [...]`). -mkdir -p appre/src -cat > appre/mcpp.toml <<'EOF' -[package] -name = "appre" -version = "0.1.0" - -[dependencies] -widget = { path = "../widget", default-features = false, features = ["turbo"] } -EOF -cat > appre/src/main.cpp <<'EOF' -#include -#ifndef WIDGET_TURBO -#error "explicit features = [turbo] did not activate alongside default-features = false" -#endif -int main() { return widget_mode() == 1 ? 0 : 2; } -EOF - -( cd appre - "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(C): opt-out + explicit build failed"; exit 1; } - grep -q 'WIDGET_TURBO' compile_commands.json || { - echo "FAIL(C): explicit feature did not activate alongside opt-out"; cat compile_commands.json; exit 1; } -) - -echo "OK" diff --git a/tests/e2e/127_transitive_default_features_optout.sh b/tests/e2e/127_transitive_default_features_optout.sh deleted file mode 100755 index 44421251e..000000000 --- a/tests/e2e/127_transitive_default_features_optout.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# mcpp#242 (transitive edge, found in the 0.0.99 architecture review): a -# consumer's `default-features = false` must be honored even when the consumer -# is itself a DEPENDENCY (transitive edge), not only for the root's direct deps. -# -# Before the edge-graph convergence, feature *resolution* honored the per-edge -# opt-out (mergeActiveFeatureDeps) but feature *activation* re-derived the flag -# by scanning only the ROOT manifest's direct deps — so a transitive dep's -# opt-out was silently dropped: activation still seeded the dep's default -# feature (defining its macro / keeping its default-gated sources) that -# resolution had already skipped. Now both consume the authoritative -# consumer→dep edge graph, so they agree. -# -# Layout: root(bin) -> A(lib) -> B(lib, `default = ["heavy"]`, heavy defines -# B_HEAVY). A depends on B with `default-features = false`. B's heavy macro must -# therefore be OFF in the transitive build. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# B: a library with a default feature `heavy` that defines B_HEAVY. -mkdir -p B/src -cat > B/mcpp.toml <<'EOF' -[package] -name = "B" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[features] -default = ["heavy"] -heavy = { defines = ["B_HEAVY=1"] } -[targets.B] -kind = "lib" -EOF -cat > B/src/b.cpp <<'EOF' -int b_heavy() { -#ifdef B_HEAVY - return 1; -#else - return 0; -#endif -} -EOF - -# A: depends on B, opting OUT of B's default features. -mkdir -p A/src -cat > A/mcpp.toml <<'EOF' -[package] -name = "A" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -B = { path = "../B", default-features = false } -[targets.A] -kind = "lib" -EOF -cat > A/src/a.cpp <<'EOF' -extern int b_heavy(); -int a_val() { return b_heavy(); } -EOF - -# root consumer -> A (which transitively pulls B). -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -A = { path = "../A" } -[targets.app] -kind = "bin" -main = "src/main.cpp" -EOF -cat > app/src/main.cpp <<'EOF' -import std; -extern int a_val(); -int main() { std::println("heavy={}", a_val()); return 0; } -EOF - -cd app -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "FAIL: build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^heavy=' | tail -1)" -[[ "$out" == "heavy=0" ]] || { - echo "FAIL: transitive default-features opt-out not honored (got '$out', want heavy=0)" - exit 1 -} - -# Control: flip A to KEEP B's defaults -> heavy must come back on. Clean first: -# changing a transitive dep's active feature set across an in-place rebuild is a -# separate fingerprint concern; this test isolates the activation logic with a -# fresh build. -cat > ../A/mcpp.toml <<'EOF' -[package] -name = "A" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -B = { path = "../B" } -[targets.A] -kind = "lib" -EOF -"$MCPP" clean > /dev/null 2>&1 || true -"$MCPP" build > build2.log 2>&1 || { cat build2.log; echo "FAIL: control build failed"; exit 1; } -out2="$("$MCPP" run 2>&1 | grep '^heavy=' | tail -1)" -[[ "$out2" == "heavy=1" ]] || { - echo "FAIL: control (defaults kept) expected heavy=1, got '$out2'" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/128_feature_forwarding.sh b/tests/e2e/128_feature_forwarding.sh deleted file mode 100755 index 216964628..000000000 --- a/tests/e2e/128_feature_forwarding.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash -# 128_feature_forwarding.sh — feature dep/feat forwarding (#243, Cargo parity). -# A package feature may open a feature OF a dependency: -# -# [features] -# withextra = ["leaf/extra"] # when `withextra` is active, request -# # `extra` from dependency `leaf` -# -# This is what unblocks a module package's optional module interface (the -# opencv.dnn case): a single feature both pulls a source set locally AND opens -# the heavy feature of the compat dependency, without forcing every consumer to -# pay for it. -# -# Graph: app -> mid -> leaf. `leaf` has a feature `extra` (NOT default) that -# carries a package-owned define LEAF_EXTRA. `mid` forwards `leaf/extra` behind -# its own `withextra` feature and pulls leaf with default-features = false (so -# leaf's default set never brings `extra` on its own). The forward must: -# * activate leaf's `extra` ONLY when the consumer turns on mid/withextra, -# * travel TRANSITIVELY (root app -> mid -> leaf), -# * hold on BOTH `mcpp build` and `mcpp test` (the 0.0.97 double-path -# invariant — activation must not diverge between the two). -# -# No `requires:` capability → runs on all three CI platforms. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── leaf: a lib whose non-default feature `extra` carries a define ─────────── -mkdir -p leaf/include/leaf leaf/src -cat > leaf/mcpp.toml <<'EOF' -[package] -name = "leaf" -version = "0.1.0" - -[features] -default = [] -extra = { defines = ["LEAF_EXTRA=1"] } - -[build] -include_dirs = ["include"] - -[targets.leaf] -kind = "lib" -EOF -cat > leaf/include/leaf/leaf.hpp <<'EOF' -#pragma once -inline int leaf_mode() { -#ifdef LEAF_EXTRA - return 1; -#else - return 0; -#endif -} -EOF -cat > leaf/src/leaf.cppm <<'EOF' -export module leaf; -export int leaf_anchor() { return 0; } -EOF - -# ── mid: depends on leaf (default-features = false), forwards leaf/extra ───── -mkdir -p mid/src -cat > mid/mcpp.toml <<'EOF' -[package] -name = "mid" -version = "0.1.0" - -[features] -default = [] -withextra = ["leaf/extra"] - -[dependencies] -leaf = { path = "../leaf", default-features = false } - -[targets.mid] -kind = "lib" -EOF -cat > mid/src/mid.cppm <<'EOF' -export module mid; -export int mid_anchor() { return 0; } -EOF - -# helper: does leaf's compile command carry the extra-feature define? -leaf_has_extra() { # $1 = compile_commands.json path - grep -q 'LEAF_EXTRA' "$1" -} - -# ── Case A: app turns on mid/withextra → forward opens leaf/extra ──────────── -mkdir -p appon/src -cat > appon/mcpp.toml <<'EOF' -[package] -name = "appon" -version = "0.1.0" - -[dependencies] -mid = { path = "../mid", features = ["withextra"] } -EOF -cat > appon/src/main.cpp <<'EOF' -int main() { return 0; } -EOF - -( cd appon - "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(A): build failed"; exit 1; } - leaf_has_extra compile_commands.json || { - echo "FAIL(A): leaf/extra was NOT activated by the forward"; cat compile_commands.json; exit 1; } - # double-path: the same forward must hold under `mcpp test`. - "$MCPP" test > t.log 2>&1 || { cat t.log; echo "FAIL(A): test build failed"; exit 1; } - leaf_has_extra compile_commands.json || { - echo "FAIL(A/test): forward did not hold on the test path"; cat compile_commands.json; exit 1; } -) - -# ── Case B: app does NOT turn on withextra → leaf/extra stays off ──────────── -mkdir -p appoff/src -cat > appoff/mcpp.toml <<'EOF' -[package] -name = "appoff" -version = "0.1.0" - -[dependencies] -mid = { path = "../mid" } -EOF -cat > appoff/src/main.cpp <<'EOF' -int main() { return 0; } -EOF - -( cd appoff - "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(B): build failed"; exit 1; } - if leaf_has_extra compile_commands.json; then - echo "FAIL(B): leaf/extra leaked though no consumer requested mid/withextra" - cat compile_commands.json; exit 1 - fi - "$MCPP" test > t.log 2>&1 || { cat t.log; echo "FAIL(B): test build failed"; exit 1; } - if leaf_has_extra compile_commands.json; then - echo "FAIL(B/test): leaf/extra leaked on the test path"; cat compile_commands.json; exit 1 - fi -) - -echo "OK" diff --git a/tests/e2e/12_add_command.sh b/tests/e2e/12_add_command.sh deleted file mode 100755 index 231347718..000000000 --- a/tests/e2e/12_add_command.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env bash -# requires: -# `mcpp add` modifies mcpp.toml [dependencies]. Default-namespace packages land -# as bare keys under [dependencies]; canonical `ns.name` inputs use a subtable; -# the deprecated `ns:name` spelling is accepted only as a migration alias. A -# package no readable index can serve is -# refused before mcpp.toml is touched (#305) — but only where absence is -# provable, so the gate resolves the same candidates the manifest parser derives -# and reads through the same index routing `mcpp build` resolves dependencies -# with. -# -# Every package here is served by a project-local `[indices] path = …`, so the -# assertions depend on neither the network nor the state of the shared registry. -# MCPP_HOME is deliberately NOT isolated: an empty home would force a full -# sandbox bootstrap (xlings, index fetch, patchelf, ninja) on a test that is -# otherwise pure local file manipulation. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -TMP_HOST="$(host_path "$TMP")" -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp - -# A local index carrying five packages: two in the DEFAULT namespace, one under -# `acme`, and a same-short-name pair under `capi` / `mcpplibs.capi` that proves -# exact dotted selection cannot be stolen by the old prefixed candidate. -# Descriptors are filed under the first letter of their FILENAME, and publish -# every OS so the version assertions below hold on all three CI platforms. -mkdir -p index/pkgs/w index/pkgs/g index/pkgs/a index/pkgs/c index/pkgs/m -cat > index/pkgs/w/widget.lua <<'EOF' -package = { - spec = "1", - namespace = "mcpplibs", - name = "widget", - description = "default-namespace package served by a local index", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["0.1.0"] = { url = "https://example.invalid/w.tar.gz" } }, - macosx = { ["0.1.0"] = { url = "https://example.invalid/w.tar.gz" } }, - windows = { ["0.1.0"] = { url = "https://example.invalid/w.zip" } }, - }, -} -EOF -cat > index/pkgs/g/gadget.lua <<'EOF' -package = { - spec = "1", - namespace = "mcpplibs", - name = "gadget", - description = "second default-namespace package", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["0.2.0"] = { url = "https://example.invalid/g.tar.gz" } }, - macosx = { ["0.2.0"] = { url = "https://example.invalid/g.tar.gz" } }, - windows = { ["0.2.0"] = { url = "https://example.invalid/g.zip" } }, - }, -} -EOF -cat > index/pkgs/a/acme.util.lua <<'EOF' -package = { - spec = "1", - namespace = "acme", - name = "util", - description = "nested-namespace package served by a local index", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["2.0.0"] = { url = "https://example.invalid/u.tar.gz" } }, - macosx = { ["2.0.0"] = { url = "https://example.invalid/u.tar.gz" } }, - windows = { ["2.0.0"] = { url = "https://example.invalid/u.zip" } }, - }, -} -EOF -cat > index/pkgs/c/capi.lua.lua <<'EOF' -package = { - spec = "1", - namespace = "capi", - name = "lua", - description = "new exact dotted identity", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["5.4.7"] = { url = "https://example.invalid/lua.tar.gz" } }, - macosx = { ["5.4.7"] = { url = "https://example.invalid/lua.tar.gz" } }, - windows = { ["5.4.7"] = { url = "https://example.invalid/lua.zip" } }, - }, -} -EOF -cat > index/pkgs/m/mcpplibs.capi.lua.lua <<'EOF' -package = { - spec = "1", - namespace = "mcpplibs.capi", - name = "lua", - description = "old prefixed dotted candidate", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["9.9.9"] = { url = "https://example.invalid/old.tar.gz" } }, - macosx = { ["9.9.9"] = { url = "https://example.invalid/old.tar.gz" } }, - windows = { ["9.9.9"] = { url = "https://example.invalid/old.zip" } }, - }, -} -EOF -cat >> mcpp.toml <<'EOF' - -[indices] -default = { path = "index" } -acme = { path = "index" } -capi = { path = "index" } -EOF - -# (1) Default-namespace dep: bare name → unquoted key under [dependencies]. -"$MCPP" add widget@0.1.0 > /dev/null -grep -qE '^\[dependencies\]' mcpp.toml || { cat mcpp.toml; echo "no [dependencies] section"; exit 1; } -grep -qE '^widget = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "widget entry missing or quoted"; exit 1; } -grep -qE '^"widget"' mcpp.toml && { cat mcpp.toml; echo "default-ns key should not be quoted"; exit 1; } - -# (2) `.` where ns IS the default (mcpplibs) — still a bare key under -# [dependencies], NOT [dependencies.mcpplibs]. Appends without duplicating the -# section header. -"$MCPP" add mcpplibs.gadget@0.2.0 > /dev/null -header_count=$(grep -cE '^\[dependencies\]$' mcpp.toml) -[[ "$header_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies] header duplicated"; exit 1; } -grep -qE '^gadget = "0\.2\.0"$' mcpp.toml || { cat mcpp.toml; echo "gadget not set"; exit 1; } - -# (3) A dotted selector is one exact identity and is stored canonically as a -# namespace subtable. It must never become an ordered fallback search. -"$MCPP" add acme.util@2.0.0 > /dev/null -grep -qE '^\[dependencies\.acme\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.acme] section"; exit 1; } -grep -qE '^util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "util entry missing"; exit 1; } -! grep -qE '^acme\.util = ' mcpp.toml || { cat mcpp.toml; echo "dotted selector must not be stored as an ambiguous flat key"; exit 1; } - -# (4) The old colon spelling remains a one-release migration alias, gives a -# copyable canonical replacement, and writes the same canonical shape. -alias_out=$("$MCPP" add acme:util@2.0.0 2>&1) -[[ "$alias_out" == *"deprecated"* ]] || { echo "missing migration warning: $alias_out"; exit 1; } -[[ "$alias_out" == *"acme.util@2.0.0"* ]] || { echo "warning lacks canonical replacement: $alias_out"; exit 1; } - -# (5) `capi.lua` is exactly (capi,lua), even though the old first candidate -# (mcpplibs.capi,lua) exists with the requested 9.9.9 version. The version -# warning must come from capi:lua and the migration warning shows both complete -# selectors without silently selecting the sibling. -exact_out=$("$MCPP" add capi.lua@9.9.9 2>&1) -[[ "$exact_out" == *"available: 5.4.7"* ]] || { echo "sibling stole exact selector: $exact_out"; exit 1; } -[[ "$exact_out" == *"mcpplibs.capi.lua"* ]] || { echo "migration warning lacks old selector: $exact_out"; exit 1; } -[[ "$exact_out" == *"capi.lua"* ]] || { echo "migration warning lacks new selector: $exact_out"; exit 1; } -"$MCPP" add capi.lua@5.4.7 > /dev/null -grep -qE '^\[dependencies\.capi\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.capi] section"; exit 1; } -grep -qE '^lua = "5\.4\.7"$' mcpp.toml || { cat mcpp.toml; echo "exact capi.lua entry missing"; exit 1; } - -# Nested namespaces use the same parser for add and remove. -"$MCPP" add mcpplibs.capi.lua@9.9.9 > /dev/null -grep -qE '^\[dependencies\.mcpplibs\.capi\]$' mcpp.toml || { cat mcpp.toml; echo "nested namespace section missing"; exit 1; } -"$MCPP" remove mcpplibs.capi.lua > /dev/null -! grep -qE '^lua = "9\.9\.9"$' mcpp.toml || { cat mcpp.toml; echo "nested exact remove failed"; exit 1; } - -# (6) Dotted remove can still clean the old subtable shape for compatibility. -cat >> mcpp.toml <<'EOF' - -[dependencies.legacy] -old = "0.1.0" -EOF -"$MCPP" remove legacy.old > /dev/null -! grep -qE '^old = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "legacy.old was not removed"; exit 1; } - -# (7) Reject missing version. -err=$("$MCPP" add bareword 2>&1) && { echo "expected error for missing version"; exit 1; } -[[ "$err" == *"version required"* ]] || { echo "wrong error: $err"; exit 1; } - -# (8) Reject empty package name (e.g. `mcpp add :@1.0`). -err=$("$MCPP" add ":@1.0" 2>&1) && { echo "expected error for empty package name"; exit 1; } - -# (9) Reject malformed/unsafe selectors before any index lookup or manifest -# mutation. These spellings must not be normalized into a different identity. -cp mcpp.toml "$TMP/before-invalid" -for invalid in 'ocornut..imgui@1.0.0' 'acme/util@1.0.0' 'acme\util@1.0.0' 'acme.util@' 'acme.util@1.0@2.0' 'widget@1.0"bad'; do - err=$("$MCPP" add "$invalid" 2>&1) && { echo "expected error for invalid selector: $invalid"; exit 1; } - [[ "$err" == *"invalid"* || "$err" == *"version required"* ]] || { echo "wrong error for $invalid: $err"; exit 1; } - diff -q "$TMP/before-invalid" mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for invalid selector: $invalid"; exit 1; } -done - -# (10) A package the index does not carry is refused, and mcpp.toml is left -# exactly as it was. The error names the identities that were tried. -cp mcpp.toml "$TMP/before" -err=$("$MCPP" add definitely-not-a-real-package@9.9.9 2>&1) && { echo "expected error for missing package"; exit 1; } -[[ "$err" == *"not found"* ]] || { echo "wrong error: $err"; exit 1; } -[[ "$err" == *"tried:"* ]] || { echo "error should list the identities tried: $err"; exit 1; } -diff -q "$TMP/before" mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for missing package"; exit 1; } - -# (11) Same for an explicitly-namespaced miss in a readable index. -err=$("$MCPP" add acme.nope@1.0.0 2>&1) && { echo "expected error for missing acme package"; exit 1; } -[[ "$err" == *"(acme, nope)"* ]] || { echo "wrong error: $err"; exit 1; } -[[ "$err" == *"route: local index 'acme': root present, pkgs present"* ]] || { - echo "missing privacy-safe local-index route state: $err" - exit 1 -} -diff -q "$TMP/before" mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for missing package"; exit 1; } - -# (12) A namespace no readable index covers cannot be refuted, so the add goes -# through unverified rather than failing. Refusing here would be worse than the -# bug being fixed — `mcpp build` resolves such dependencies fine. -"$MCPP" add unknownidx.thing@1.0.0 > /dev/null -grep -qE '^\[dependencies\.unknownidx\]$' mcpp.toml || { cat mcpp.toml; echo "unverifiable namespace should still be added"; exit 1; } - -# (13) A real package asked for at a version it does not publish is a warning, -# not a refusal — version tables are per-OS, so "absent here" is not "absent". -out=$("$MCPP" add widget@9.9.9 2>&1) -[[ "$out" == *"available: 0.1.0"* ]] || { echo "expected available-versions warning: $out"; exit 1; } -grep -qE '^widget = "9\.9\.9"$' mcpp.toml || { cat mcpp.toml; echo "widget@9.9.9 should still be written"; exit 1; } - -# (14) A workspace MEMBER inherits the root's [indices], so adding inside it -# sees the same packages `mcpp build` would resolve from there (#224). -mkdir -p "$TMP/ws" -cd "$TMP/ws" -cp -r "$TMP/myapp/index" index -[[ -f index/pkgs/a/acme.util.lua ]] || { - echo "workspace index fixture copy is incomplete" - exit 1 -} -cat > mcpp.toml <<'EOF' -[workspace] -members = ["m1"] - -[workspace.indices] -acme = { path = "index" } -EOF -"$MCPP" new m1 > /dev/null -cd m1 -workspace_add=$("$MCPP" add acme.util@2.0.0 2>&1) || { - echo "$workspace_add" - echo "workspace member could not read its root-owned local index" - exit 1 -} -grep -qE '^util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "member should inherit workspace [indices]"; exit 1; } -err=$("$MCPP" add acme.nope@1.0.0 2>&1) && { echo "expected error inside workspace member"; exit 1; } -[[ "$err" == *"(acme, nope)"* ]] || { echo "wrong error: $err"; exit 1; } - -# (15) Adding an exact selector upserts an equivalent legacy flat spelling -# instead of leaving two source forms for one PackageId. -mkdir -p "$TMP/upsert" -cat > "$TMP/upsert/mcpp.toml" < /dev/null -! grep -qE '^"acme\.util" = ' mcpp.toml || { - cat mcpp.toml - echo "legacy flat selector was duplicated instead of migrated" - exit 1 -} -[[ $(grep -cE '^util = "2\.0\.0"$' mcpp.toml) == 1 ]] || { - cat mcpp.toml - echo "canonical exact selector was not upserted once" - exit 1 -} - -# (16) Exact removal is scoped to [dependencies]. A same-name dev dependency -# that appears earlier in the file must not be removed by a global text match. -cat > mcpp.toml <<'EOF' -[package] -name = "remove-scope" -version = "0.1.0" - -[dev-dependencies] -widget = "0.2.0" - -[dependencies] -widget = "0.1.0" -EOF -"$MCPP" remove widget > /dev/null -grep -qE '^widget = "0\.2\.0"$' mcpp.toml || { - cat mcpp.toml - echo "remove escaped [dependencies] and deleted the dev dependency" - exit 1 -} -! grep -qE '^widget = "0\.1\.0"$' mcpp.toml || { - cat mcpp.toml - echo "regular dependency was not removed" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/134_target_sysroot_and_queries.sh b/tests/e2e/134_target_sysroot_and_queries.sh deleted file mode 100755 index 0626764fb..000000000 --- a/tests/e2e/134_target_sysroot_and_queries.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# `[target.].sysroot` and the three target queries a board package asks. -# -# WHY THIS TEST IS TWO-SIDED THROUGHOUT -# -# Both features here are of the kind whose presence and absence look identical -# on a machine that is already configured. A sysroot override that silently did -# nothing would still build, because the target row's picolibc is what would be -# used either way; a query that returned a stale value would still compile, -# because there is only one toolchain and one C library installed. So every -# assertion below pins BOTH states — with the override and without it, with a C -# library and without one. -# -# The queries exist to remove a coupling that no manifest shows. `riscv-virt-rt` -# declares no dependency on LLVM and none on picolibc, and still could not serve -# a second toolchain or a second C library, because it had written -# `clang_rt.builtins-riscv64` and `rv64gc/lp64d` into its build program. -# -# No emulator is needed: everything here is decided at configure and link time, -# which is why `requires:` asks only for llvm. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new probe > /dev/null -cd probe -rm -f tests/*.cpp 2>/dev/null || true - -# A build program that reports what the engine told it. It returns non-zero so -# that mcpp surfaces the output: a build program's stderr is otherwise only -# shown when it fails, and a probe nobody can read proves nothing. -cat > build.mcpp <<'EOF' -import mcpp; -import std; -int main() { - std::cerr << "builtins=[" << (mcpp::target_builtins_lib() ?: "") << "]\n" - << "profile=[" << (mcpp::target_libc_profile() ?: "") << "]\n" - << "libc=[" << (mcpp::target_libc() ?: "") << "]\n"; - return 1; -} -EOF -cat > src/main.cpp <<'EOF' -extern "C" void _start() { for (;;) {} } -EOF - -manifest() { - cat > mcpp.toml < q64.log 2>&1 || true -grep -q 'builtins=\[clang_rt.builtins-riscv64\]' q64.log || { - cat q64.log; echo "rv64 builtins query wrong"; exit 1; } -grep -q 'profile=\[rv64gc/lp64d\]' q64.log || { - cat q64.log; echo "rv64 libc profile query wrong"; exit 1; } -grep -q 'libc=\[picolibc-riscv\]' q64.log || { - cat q64.log; echo "libc name query wrong"; exit 1; } - -# ── 2. The SAME manifest at the other width ───────────────────────────────── -# This is what makes the queries worth having: one board description, two ISA -# profiles, and the varying parts come from the engine rather than from a -# branch in the package. -"$MCPP" build --target riscv32-none-elf > q32.log 2>&1 || true -grep -q 'builtins=\[clang_rt.builtins-riscv32\]' q32.log || { - cat q32.log; echo "rv32 builtins query did not follow the target"; exit 1; } -grep -q 'profile=\[rv32imac/ilp32\]' q32.log || { - cat q32.log; echo "rv32 libc profile query did not follow the target"; exit 1; } - -# ── 3. The C library is really reachable without the override ─────────────── -# Establishes the control for step 4: `` resolves here. -cat > src/main.cpp <<'EOF' -#include -extern "C" void _start() { (void)sizeof(FILE); for (;;) {} } -EOF -cat > build.mcpp <<'EOF' -import mcpp; -int main() { return 0; } -EOF -manifest "" -"$MCPP" build > with_libc.log 2>&1 || { - cat with_libc.log; echo "the target row's C library should have been usable"; exit 1; } - -# ── 4. `sysroot = ""` really removes it ───────────────────────────────────── -# THE LOAD-BEARING ASSERTION. Without it, an override that parsed and did -# nothing would pass every other check in this file. -manifest ' -[target.riscv64-none-elf] -sysroot = ""' -if "$MCPP" build > no_libc.log 2>&1; then - cat no_libc.log - echo "sysroot = \"\" did not remove the C library — still resolved" - exit 1 -fi -grep -q "stdio.h" no_libc.log || { - cat no_libc.log; echo "build failed, but not because the C library was gone"; exit 1; } - -# ── 5. The zero-libc tier still produces an image ─────────────────────────── -# Removing the C library must leave a usable target, not a broken one. -cat > src/main.cpp <<'EOF' -static volatile unsigned char* const UART = - reinterpret_cast(0x10000000); -extern "C" void kmain() { - for (const char* p = "zero-libc\n"; *p; ++p) - *UART = static_cast(*p); - *reinterpret_cast(0x100000) = 0x5555; - for (;;) {} -} -asm(".section .text.entry\n.globl _start\n_start:\n" - " la sp, __stack_top\n call kmain\n1: j 1b\n"); -EOF -cat > link.ld <<'EOF' -ENTRY(_start) -SECTIONS { - . = 0x80000000; - .text : { *(.text.entry) *(.text*) } - .rodata : { *(.rodata*) } - .data : { *(.data*) } - .bss : { *(.bss*) *(COMMON) } - . = ALIGN(16); . = . + 0x1000; __stack_top = .; -} -EOF -manifest "ldflags = [\"-T\", \"$PWD/link.ld\"] - -[target.riscv64-none-elf] -sysroot = \"\"" -"$MCPP" build > zero.log 2>&1 || { - cat zero.log; echo "a self-contained zero-libc image should still build"; exit 1; } -grep -q 'Size probe' zero.log || { - cat zero.log; echo "no size summary — the freestanding link path was not taken"; exit 1; } - -# ── 6. On the zero-libc tier the libc-facing queries are empty ────────────── -# All three answers move together: a kernel must not be handed a path into a -# C library that is not there, while `builtins` is a COMPILER fact and stays. -cat > build.mcpp <<'EOF' -import mcpp; -import std; -int main() { - std::cerr << "builtins=[" << (mcpp::target_builtins_lib() ?: "") << "]\n" - << "profile=[" << (mcpp::target_libc_profile() ?: "") << "]\n" - << "libc=[" << (mcpp::target_libc() ?: "") << "]\n"; - return 1; -} -EOF -"$MCPP" build > zeroq.log 2>&1 || true -grep -q 'profile=\[\]' zeroq.log || { - cat zeroq.log; echo "libc profile should be empty with no C library"; exit 1; } -grep -q 'libc=\[\]' zeroq.log || { - cat zeroq.log; echo "libc name should be empty with no C library"; exit 1; } -grep -q 'builtins=\[clang_rt.builtins-riscv64\]' zeroq.log || { - cat zeroq.log; echo "builtins is a compiler fact and must survive"; exit 1; } - -# ── 7. A bare name is rejected at parse time ──────────────────────────────── -# Accepting it would install nothing and fail much later naming a missing libc. -manifest ' -[target.riscv64-none-elf] -sysroot = "newlib"' -if "$MCPP" build > badref.log 2>&1; then - cat badref.log; echo "a bare sysroot name should have been rejected"; exit 1; fi -grep -q 'is not a sysroot. A sysroot is a package' badref.log || { - cat badref.log; echo "rejection did not explain the expected form"; exit 1; } - -echo "PASS: [target.X].sysroot override, zero-libc tier, and the three target queries" diff --git a/tests/e2e/135_diagnostic_promise_resolves.sh b/tests/e2e/135_diagnostic_promise_resolves.sh deleted file mode 100755 index f0797cc76..000000000 --- a/tests/e2e/135_diagnostic_promise_resolves.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# A diagnostic that prints a copy-pasteable dependency line must print one that -# resolves. -# -# THIS TEST EXISTS BECAUSE THE SAME DEFECT SHIPPED TWICE. -# -# The freestanding `import std;` message ends in a `[dependencies]` block the -# reader is meant to paste. It has been wrong twice, in two different ways: -# -# 1. it named `mcpplibs.std.freestanding` before any such package existed — -# pasting it failed at the very next command with "package not found"; -# 2. it named version `0.1.0` after `0.2.0` superseded it in the index — -# pasting it produced `E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' -# not found in the synced index`. -# -# Both times the repair was to edit the literal and add a comment saying the -# literal must be kept current. A comment cannot enforce a cross-repository -# invariant — this repository already learned that for its version pins, which -# is why `check_version_pins.sh` exists — and the second occurrence is the -# proof: the comment from the first repair was sitting right above the line -# that broke. -# -# THE TEST MUST NOT SPELL THE VERSION. Asserting `std-freestanding = "0.3.0"` -# would copy the literal into a second place and check that the two copies -# agree, which is true even when both are wrong. What is checked instead is the -# property that matters: whatever the diagnostic prints, RESOLVES. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new promise > /dev/null -cd promise -rm -f tests/*.cpp 2>/dev/null || true - -cat > mcpp.toml <<'EOF' -[package] -name = "promise" -version = "0.1.0" - -[build] -target = "riscv64-none-elf" -EOF - -# `import std;` on a freestanding target is the trigger. -cat > src/main.cpp <<'EOF' -import std; -extern "C" int main() { return 0; } -EOF - -if "$MCPP" build > diag.log 2>&1; then - cat diag.log - echo "\`import std;\` on a freestanding target should have been rejected" - exit 1 -fi -grep -q 'is not available on' diag.log || { - cat diag.log; echo "the freestanding import-std diagnostic did not appear"; exit 1; } - -# ── Extract the advice, rather than restating it ───────────────────────────── -# The line is indented inside the message; take it verbatim, minus the padding. -ADVICE=$(grep -oE '[a-z0-9.-]+[[:space:]]*=[[:space:]]*"[^"]+"' diag.log | head -1) -[[ -n "$ADVICE" ]] || { - cat diag.log - echo "the diagnostic printed no dependency line to paste — if the advice was" - echo "removed on purpose, remove this test with it" - exit 1; } -echo "advice: $ADVICE" - -# ── Paste it, exactly as a reader would ───────────────────────────────────── -cat > mcpp.toml < src/main.cpp <<'EOF' -import mcpplibs.std.freestanding; -extern "C" int main() { return static_cast(std::array{}.size()); } -EOF - -# The build is expected to get as far as the LINK. It may well fail there — this -# project has no board package, so there is no crt0 and nothing to start it — -# and that failure is not what this test is about. What must not happen is a -# RESOLUTION failure: that is what a stale name or a stale version produces, and -# it is what both historical occurrences looked like. -"$MCPP" build -v > paste.log 2>&1 || true - -if grep -qE 'not found in the synced index|package not found|E_NOT_FOUND' paste.log; then - cat paste.log - echo - echo "the diagnostic's copy-pasteable line does not resolve." - echo "advice was: $ADVICE" - echo "update the literal in prepare.cppm's freestanding import-std message" - echo "to a version the index actually carries, in the same change that" - echo "publishes it." - exit 1 -fi - -# ...and the package must actually have entered the graph, so that a build which -# silently ignored the dependency cannot pass. One of the three verbs mcpp uses -# for a dependency it resolved has to name it. -grep -qE '(Downloading|Compiling).*std-freestanding' paste.log || { - cat paste.log - echo "the pasted dependency never entered the build graph" - exit 1; } - -echo "PASS: the import-std diagnostic's advice resolves and enters the graph" diff --git a/tests/e2e/136_aarch64_zero_libc_target.sh b/tests/e2e/136_aarch64_zero_libc_target.sh deleted file mode 100755 index 934033e2b..000000000 --- a/tests/e2e/136_aarch64_zero_libc_target.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# `aarch64-none-elf`: the second bare-metal architecture, on the zero-libc tier. -# -# WHY A TARGET ROW NEEDS A TEST AT ALL -# -# A row in the target table is four strings, and three of them can be wrong in -# ways that still produce a build. A wrong `-mabi` is rejected by the driver, -# which is the loud case; a wrong code model links and faults only when the -# image is placed somewhere the model cannot address; a sysroot column filled in -# "for symmetry" resolves a payload that does not exist and silently adds no -# paths, so the failure surfaces much later as a missing header inside somebody -# else's package. -# -# So this asserts what the row PRODUCES rather than what it says: an image whose -# sections are where the linker script asked, with no undefined symbols, from a -# project that names no C library. -# -# No emulator is needed. Whether the image boots is asserted by openarch's own -# CI, which runs one probe on this machine and on riscv64; what belongs here is -# that the engine can produce it at all. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new a64probe > /dev/null -cd a64probe -rm -f tests/*.cpp 2>/dev/null || true - -# NO `[target.*]` SECTION, AND THAT IS THE ASSERTION. The zero-libc tier is -# this row's own property, not something the project asked for — which is the -# difference from `riscv64-none-elf`, where declining the C library takes an -# explicit `sysroot = ""`. -cat > mcpp.toml <<'EOF' -[package] -name = "a64probe" -version = "0.1.0" - -[build] -target = "aarch64-none-elf" -EOF - -cat > src/main.cpp <<'EOF' -namespace { -volatile unsigned int* const kUart = reinterpret_cast(0x09000000); -} -extern "C" void kmain() { - for (const char* p = "a64 ok\n"; *p; ++p) *kUart = static_cast(*p); - for (;;) { asm volatile("wfi"); } -} -asm(".section .text.entry,\"ax\",@progbits\n" - ".globl _start\n" - "_start:\n" - " ldr x30, =__stack_top\n" - " mov sp, x30\n" - " bl kmain\n" - "1: b 1b\n"); -EOF - -cat > link.ld <<'EOF' -ENTRY(_start) -SECTIONS { - . = 0x40000000; - .text : { *(.text.entry) *(.text*) } - .rodata : { *(.rodata*) } - .data : { *(.data*) } - .bss : { *(.bss*) *(COMMON) } - . = ALIGN(16); . = . + 0x4000; __stack_top = .; -} -EOF - -cat > build.mcpp <<'EOF' -import mcpp; -int main() { mcpp::link_script("link.ld"); return 0; } -EOF - -# ── 1. The target builds with no C library anywhere in the project ────────── -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "aarch64-none-elf did not build"; exit 1; } - -ELF=$(find target/aarch64-none-elf -type f -name a64probe | head -1) -[ -n "$ELF" ] || { cat build.log; echo "no image was produced"; exit 1; } - -READELF=$(find "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" -name llvm-readelf 2>/dev/null | head -1) -NM=$(find "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" -name llvm-nm 2>/dev/null | head -1) -[ -x "$READELF" ] && [ -x "$NM" ] || { echo "the LLVM payload has no readelf/nm to check with"; exit 1; } - -# ── 2. It is aarch64, and it starts where the script says ─────────────────── -"$READELF" -h "$ELF" | grep -q "AArch64" || { - "$READELF" -h "$ELF" | head -12; echo "the image is not aarch64"; exit 1; } -"$READELF" -S "$ELF" | grep -E '\.text' | grep -q '0000000040000000' || { - "$READELF" -S "$ELF" | head -8 - echo "the linker script's load address did not reach the image"; exit 1; } - -# ── 3. Nothing is left undefined ──────────────────────────────────────────── -# The load-bearing one. A row whose sysroot column named a payload that does -# not exist would add no paths and produce exactly this symptom later, inside -# whichever package first included a C header — so the absence of a C library is -# asserted as an absence of undefined symbols rather than as a missing key. -UNDEF=$("$NM" -u "$ELF" | wc -l) -[ "$UNDEF" -eq 0 ] || { - "$NM" -u "$ELF"; echo "the image expects $UNDEF symbols nothing provides"; exit 1; } - -# ── 4. The ISA profile is the measured one, not the analogous one ─────────── -# `-mabi=lp64` is what the RISC-V rows' spelling suggests and what clang -# rejects with `unknown target ABI`. Asserting the flag rather than the outcome, -# because a build that had succeeded with the wrong ABI would be the more -# dangerous result. -rm -rf target -"$MCPP" build --verbose > verbose.log 2>&1 || true -grep -q -- "-mabi=aapcs" verbose.log || { - grep -o -- '-mabi=[a-z0-9]*' verbose.log | sort -u - echo "the aarch64 ABI flag is not aapcs"; exit 1; } -if grep -q -- "-mabi=lp64" verbose.log; then - grep -o -- '-mabi=[a-z0-9]*' verbose.log | sort -u - echo "an ABI clang rejects reached the command line"; exit 1 -fi - -echo "PASS: aarch64-none-elf builds on the zero-libc tier with the measured ISA profile" diff --git a/tests/e2e/137_x86_64_zero_libc_target.sh b/tests/e2e/137_x86_64_zero_libc_target.sh deleted file mode 100755 index 949359b48..000000000 --- a/tests/e2e/137_x86_64_zero_libc_target.sh +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bash -# requires: llvm unix-shell -# `x86_64-none-elf`: the third bare-metal architecture, and the first whose -# LINK the compiler driver refuses to perform. -# -# WHAT THIS ROW ADDED THAT THE OTHER TWO DID NOT NEED -# -# `riscv64-none-elf` and `aarch64-none-elf` are four strings in a table and -# nothing else: clang has a BareMetal toolchain for both, so it drives their -# links itself and reaches `ld.lld`. It has none for x86_64, so that triple -# falls through to the generic GCC toolchain — whose linker is the HOST'S `g++`: -# -# g++: error: unrecognized command-line option -# '-fuse-ld=/…/llvm/22.1.8/bin/ld.lld' -# -# Measured for every spelling of a bare x86_64 triple, and unfixable by any flag -# (`-fuse-ld=lld`, `--ld-path=`, `--gcc-toolchain=`, `-B` were each tried). Only -# putting `linux` in the OS position makes clang link directly, and that brings -# eight host `-L` paths onto a bare-metal link. -# -# So this row carries an `lldEmulation` and the engine links it with `ld.lld` -# itself. Three of the checks below exist for that path specifically: that the -# host toolchain is not consulted, that driver-only flags do not survive onto a -# linker's command line, and that the map is still produced. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new x86probe > /dev/null -cd x86probe -rm -f tests/*.cpp 2>/dev/null || true - -# NO `[target.*]` SECTION. Like `aarch64-none-elf`, the zero-libc tier is -# this row's own property rather than something the project asks for. -cat > mcpp.toml <<'EOF' -[package] -name = "x86probe" -version = "0.1.0" - -[build] -target = "x86_64-none-elf" -EOF - -# The console on this machine is not memory: x86 reaches a serial port through -# `out`, which is why the other two probes' `volatile unsigned*` has no -# counterpart here. -cat > src/main.cpp <<'EOF' -namespace { -inline void outb(unsigned short port, unsigned char v) { - asm volatile("outb %0, %1" :: "a"(v), "Nd"(port)); -} -} -extern "C" void kmain() { - for (const char* p = "x86 ok\n"; *p; ++p) outb(0x3F8, static_cast(*p)); - for (;;) { asm volatile("hlt"); } -} -asm(".section .text.entry,\"ax\",@progbits\n" - ".globl _start\n" - "_start:\n" - " movq $__stack_top, %rsp\n" - " call kmain\n" - "1: hlt\n" - " jmp 1b\n"); -EOF - -cat > link.ld <<'EOF' -ENTRY(_start) -SECTIONS { - . = 0x100000; - .text : { *(.text.entry) *(.text*) } - .rodata : { *(.rodata*) } - .data : { *(.data*) } - .bss : { *(.bss*) *(COMMON) } - . = ALIGN(16); . = . + 0x4000; __stack_top = .; -} -EOF - -cat > build.mcpp <<'EOF' -import mcpp; -int main() { mcpp::link_script("link.ld"); return 0; } -EOF - -# ── 1. It builds, which for this row means the link did not go through gcc ── -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "x86_64-none-elf did not build"; exit 1; } - -ELF=$(find target/x86_64-none-elf -type f -name x86probe | head -1) -[ -n "$ELF" ] || { cat build.log; echo "no image was produced"; exit 1; } - -READELF=$(find "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" -name llvm-readelf 2>/dev/null | head -1) -NM=$(find "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" -name llvm-nm 2>/dev/null | head -1) -[ -x "$READELF" ] && [ -x "$NM" ] || { echo "the LLVM payload has no readelf/nm to check with"; exit 1; } - -# ── 2. It is x86-64, and it starts where the script says ──────────────────── -"$READELF" -h "$ELF" | grep -q "X86-64" || { - "$READELF" -h "$ELF" | head -12; echo "the image is not x86-64"; exit 1; } -"$READELF" -S "$ELF" | grep -E '\.text' | grep -q '0000000000100000' || { - "$READELF" -S "$ELF" | head -8 - echo "the linker script's load address did not reach the image"; exit 1; } - -# ── 3. Nothing is left undefined, and nothing names a loader ──────────────── -UNDEF=$("$NM" -u "$ELF" | wc -l) -[ "$UNDEF" -eq 0 ] || { - "$NM" -u "$ELF"; echo "the image expects $UNDEF symbols nothing provides"; exit 1; } - -# An image with no operating system under it must not name an interpreter. -# The direct-linker path says `--no-dynamic-linker` where the driver path said -# `-static`; a PT_INTERP here would be that substitution having been dropped, -# and it is exactly the defect the `--no-default-config` comment in flags.cppm -# records for the riscv row: it links clean and reports success. -if "$READELF" -l "$ELF" | grep -q "INTERP"; then - "$READELF" -l "$ELF" | head -12 - echo "a bare-metal image names a dynamic loader"; exit 1 -fi - -# ── 4. The link map was produced, through a linker-native flag ────────────── -# `-Wl,-Map=` is how a DRIVER is asked for a map. Handed to `ld.lld` it is an -# unknown option, so the map's existence is evidence that the flag was rendered -# for the tool actually being invoked. -[ -f "$ELF.map" ] || { ls -la "$(dirname "$ELF")"; echo "no link map beside the image"; exit 1; } - -# ── 5. The host toolchain was not consulted ───────────────────────────────── -# The load-bearing assertion for this row. A link that had fallen back to the -# driver would name `g++` — and would then work on this machine and on no -# other, which is the failure this row exists to prevent. -rm -rf target -"$MCPP" build --verbose > verbose.log 2>&1 || { cat verbose.log; exit 1; } -grep -q "ld.lld" verbose.log || { - grep -iE "link|ld" verbose.log | tail -5 - echo "the link did not go through ld.lld"; exit 1; } - -# ── 6. The ISA profile is the measured one ────────────────────────────────── -# `-march=x86-64` with a HYPHEN, where the triple segment has an underscore; -# substituting one for the other produces `unknown target CPU`. And -# `-mno-red-zone`, without which any trap handler on this target corrupts the -# leaf function it interrupted, with no diagnostic. -grep -q -- "-march=x86-64" verbose.log || { - grep -o -- '-march=[a-z0-9_-]*' verbose.log | sort -u - echo "the x86_64 ISA profile is not the measured one"; exit 1; } -grep -q -- "-mno-red-zone" verbose.log || { - echo "the red zone was left enabled on a target that takes interrupts"; exit 1; } - -# ── 7. A shared library on this target is refused in words ───────────────── -# -# THE DIRECT-LINKER PATH DEFINES NO SHARED-LIBRARY RULE, AND WITHOUT A CHECK -# THE FAILURE NAMES AN INTERNAL ONE: -# -# ninja: error: build.ninja:88: unknown build rule 'cxx_shared' -# -# to somebody who wrote `kind = "shared"` in a manifest. Defining the rule would -# be worse than omitting it: `ld.lld -shared` succeeds and produces an object -# nothing on a machine with no loader can load. -cd "$TMP" -mkdir -p shared/src && cd shared -cat > mcpp.toml <<'EOF' -[package] -name = "shr" -version = "0.1.0" - -[build] -target = "x86_64-none-elf" - -[targets.shr] -kind = "shared" -EOF -echo 'int f() { return 1; }' > src/lib.cpp -if "$MCPP" build > shared.log 2>&1; then - cat shared.log; echo "a shared library was built for a target with no loader"; exit 1 -fi -grep -q "no dynamic loader" shared.log || { - cat shared.log - echo "the refusal does not name the situation"; exit 1; } -if grep -q "unknown build rule" shared.log; then - cat shared.log - echo "the failure names an internal ninja rule instead of the cause"; exit 1 -fi - -echo "PASS: x86_64-none-elf builds on the zero-libc tier, linked by ld.lld directly" diff --git a/tests/e2e/138_feature_sources_gate_vs_provider.sh b/tests/e2e/138_feature_sources_gate_vs_provider.sh deleted file mode 100755 index e7bb24103..000000000 --- a/tests/e2e/138_feature_sources_gate_vs_provider.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# A feature that GATES a source and a feature that PROVIDES one, under both -# `mcpp build` and `mcpp test`. -# -# THIS DEFECT SURVIVED FOR MONTHS BECAUSE NO TEST COVERED BOTH FAMILIES, AND -# THREE ATTEMPTED FIXES EACH BROKE THE ONE THEY DID NOT COVER. -# -# Two shapes of package reach the same code in `prepare_build`, and under -# `mcpp test` they want opposite things: -# -# the GATE family gtest lists `*/googletest/src/gtest_main.cc` in base -# `sources` AND under `features.main`. The package -# provides the file unconditionally; the feature is a -# switch over it. The dev-dependency track's per-test -# main detection has to SEE it to prune it per test, so -# an inactive gate must not make it disappear. -# -# the PROVIDER family riscv-virt-rt names `src/kal/**` under -# `features.openkal` and nowhere else. Those files are -# not part of the package without the feature — the -# headers they include arrive through that feature's -# `[feature-deps]` — so compiling them fails on a header -# that was never meant to be there. -# -# The engine gated the whole exclusion on "is this a test build", which is -# right for the first family and wrong for the second. Fixing it by removing -# the gate broke gtest; fixing it by removing only the glob string was a no-op, -# because a provider's glob is not in base `sources` at all and its files are -# matched by the inferred `src/**`. -# -# Both families are asserted here so that the next change to this code cannot -# satisfy one at the other's expense. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── The PROVIDER family ───────────────────────────────────────────────────── -# -# `src/gated/**` is named only by the feature, and the file includes a header -# that does not exist. With the feature inactive both commands must ignore it; -# a command that compiles it fails on the missing header, which is exactly the -# symptom the real package produced. -mkdir -p provider/src/gated provider/tests -cat > provider/mcpp.toml <<'EOF' -[package] -name = "provider" -version = "0.1.0" - -# NO `[build] sources`, WHICH IS THE POINT. The base set is the inferred -# `src/**`, which matches `src/gated/only_with_feature.cpp` — so removing the -# feature's glob STRING from a list it was never in gates nothing. -[features] -default = [] -gated = { sources = ["src/gated/**"] } -EOF -cat > provider/src/lib.cpp <<'EOF' -int provider_value() { return 7; } -EOF -cat > provider/src/gated/only_with_feature.cpp <<'EOF' -// The header arrives with the feature's dependencies and does not exist -// without it. Compiling this file without the feature is the defect. -#include -int gated_value() { return 1; } -EOF -cat > provider/tests/basic.cpp <<'EOF' -extern int provider_value(); -int main() { return provider_value() == 7 ? 0 : 1; } -EOF - -cd provider -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "provider: build compiled a feature-only source"; exit 1; } -"$MCPP" test > test.log 2>&1 || { cat test.log; echo "provider: TEST compiled a feature-only source — the defect this file exists for"; exit 1; } - -if find target -name '*only_with_feature*' | grep -q .; then - find target -name '*only_with_feature*' - echo "provider: an object was produced for a source the feature did not activate" - exit 1 -fi - -# With the feature ON it must be reached — otherwise the check above would pass -# for a build that simply ignores feature sources entirely. -if "$MCPP" build --features gated > gated.log 2>&1; then - cat gated.log - echo "provider: the feature did not bring its own source in" - exit 1 -fi -grep -q "a_header_the_feature_would_have_brought.h" gated.log || { - cat gated.log - echo "provider: activating the feature failed for some other reason"; exit 1; } -cd .. - -# ── The GATE family ───────────────────────────────────────────────────────── -# -# The same glob in base `sources` and under a feature. `mcpp build` excludes it; -# `mcpp test` keeps it visible, which is what the dev-dependency track's -# per-test main detection depends on. -mkdir -p gate/src gate/tests -cat > gate/mcpp.toml <<'EOF' -[package] -name = "gate" -version = "0.1.0" - -# `src/gated_main.cpp` IS IN BOTH LISTS, EXACTLY AS gtest'S DESCRIPTOR HAS IT. -# The package provides the file; the feature is a switch over it. -[build] -sources = ["src/lib.cpp", "src/gated_main.cpp"] - -[features] -default = [] -main = { sources = ["src/gated_main.cpp"] } -EOF -cat > gate/src/lib.cpp <<'EOF' -int gate_value() { return 11; } -EOF -cat > gate/src/gated_main.cpp <<'EOF' -int gated_main_marker() { return 42; } -EOF -cat > gate/tests/basic.cpp <<'EOF' -extern int gate_value(); -int main() { return gate_value() == 11 ? 0 : 1; } -EOF - -cd gate -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "gate: build failed"; exit 1; } -if find target -name '*gated_main*' | grep -q .; then - find target -name '*gated_main*' - echo "gate: build compiled a gated source with the feature inactive" - exit 1 -fi - -rm -rf target -"$MCPP" test > test.log 2>&1 || { cat test.log; echo "gate: test failed"; exit 1; } -# THE ASSERTION IS THAT IT IS STILL THERE. A fix that excluded every -# inactive feature glob under `mcpp test` would remove it — and would then -# break gtest, whose per-test main detection needs to see the file in order to -# prune it. Measured once as `ld returned 1 exit status` across mcpp's own -# unit tests. -find target -name '*gated_main*' | grep -q . || { - echo "gate: a source the package provides unconditionally vanished under \`mcpp test\`" - echo " (this is the shape that broke gtest)" - exit 1; } -cd .. - -echo "PASS: a feature that gates a source and a feature that provides one behave correctly under both commands" diff --git a/tests/e2e/139_build_program_advisory.sh b/tests/e2e/139_build_program_advisory.sh deleted file mode 100755 index c4b96e1e4..000000000 --- a/tests/e2e/139_build_program_advisory.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# resources: machine -# A build program that succeeds and still says something. -# -# THE SECOND BUILD IS THE TEST, NOT THE FIRST. -# -# mcpp prints what it captured from a build program only when that program -# EXITS NON-ZERO, which is why `mcpp:warning=` had to exist at all. But the -# result of a build program is CACHED, and a cache hit does not re-run it — so -# an implementation that emitted the advisory only where the program was -# actually executed would print it on a project's first build and never again. -# -# That failure mode is worse than having no channel: an advisory that stops -# appearing reads as "the condition was resolved". It is also invisible to any -# test that builds once, which is what a test written the obvious way does. -# -# So the assertions below are ordered: first build, then a SECOND build that -# must be a cache hit and must still carry the same line. -# -# This test was confirmed to FAIL before the fix — the cache-hit emission -# was removed and the second-build assertion went red while the first stayed -# green. A test for a replay path that has never been observed failing cannot -# distinguish "replay works" from "the cache never hit". -# `"$MCPP"`, NEVER A BARE `mcpp`. The harness passes the binary under test; -# a bare name resolves through PATH to whichever engine happens to be installed, -# and this test would then pass against an engine that does not have the feature. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p p/src -cd p - -cat > mcpp.toml <<'EOF' -[package] -name = "advisory" -version = "0.1.0" -EOF - -cat > src/main.cpp <<'EOF' -int main() { return 0; } -EOF - -# The realistic shape: the program looked for something optional, did not find -# it, configured nothing that depends on it, and says so. It exits 0 — the -# build is correct, the user is merely missing a capability. -cat > build.mcpp <<'EOF' -import mcpp; -import std; -int main() { - mcpp::warning("no emulator found; `xlings install qemu-riscv -y` enables `mcpp run`"); - mcpp::define("ADVISORY_RAN"); - return 0; -} -EOF - -# ── 1. The advisory reaches the user on a build that SUCCEEDED ────────────── -"$MCPP" build > first.log 2>&1 -grep -q "no emulator found" first.log \ - || { cat first.log; echo "FAIL: the advisory did not reach the output"; exit 1; } - -# Prefixed with the package it came from. A workspace has several build -# programs and an unattributed sentence sends the reader to the wrong manifest. -grep -q "advisory: no emulator found" first.log \ - || { cat first.log; echo "FAIL: the advisory was not attributed to its package"; exit 1; } - -# The build succeeded. An advisory is not an error. -grep -qi "Finished" first.log \ - || { cat first.log; echo "FAIL: the build did not finish"; exit 1; } - -# ── 2. THE SECOND BUILD. The program does not run; the advisory must ───── -# -# `touch` FIRST, AND NOT AS A SUPERSTITION. A build with nothing at all to -# do takes a WHOLE-PROJECT fast path that prints one line and never reaches the -# build.mcpp stage — so an unmodified second build exercises neither the run -# path nor the replay path, and asserting against it would test the fast path -# instead. This repository has been bitten by that masking before. -# -# Touching a source makes the project stale while leaving the build program's -# own declared inputs unchanged, which is exactly the state the replay serves. -# -# The boundary this leaves is deliberate and is documented in docs/07: an -# advisory appears whenever the build.mcpp stage is reached. A no-op build -# reaches nothing and prints nothing — including this. It is not asserted here, -# because it is a fact about the fast path rather than about this feature. -touch src/main.cpp -# A reused program is stated under --verbose only (build output design revision 3, §7.1). -"$MCPP" build -v > second.log 2>&1 - -# First establish that this build really was a cache hit. Without this the next -# assertion could pass for the wrong reason — a re-run would also print the -# line, and the replay path would go untested. -grep -qE "^ *build\.mcpp .* cached" second.log \ - || { cat second.log; echo "FAIL: the second build re-ran the program; the replay path was not exercised"; exit 1; } - -grep -q "advisory: no emulator found" second.log \ - || { cat second.log; echo "FAIL: the advisory was lost on a cache hit — it would appear once and never again"; exit 1; } - -# ── 3. Two members, two advisories, each named ───────────────────────────── -# -# Attribution is the reason the package name is passed in rather than printed -# by the program: in a workspace the program cannot reliably spell which member -# it is. -cd "$TMP" -mkdir -p ws/a/src ws/b/src -cd ws - -cat > mcpp.toml <<'EOF' -[workspace] -members = ["a", "b"] -EOF - -for m in a b; do - cat > $m/mcpp.toml < $m/src/main.cpp <<'EOF' -int main() { return 0; } -EOF - cat > $m/build.mcpp < ws.log 2>&1 -grep -q "a: advisory from a" ws.log \ - || { cat ws.log; echo "FAIL: member a's advisory is missing or unattributed"; exit 1; } -grep -q "b: advisory from b" ws.log \ - || { cat ws.log; echo "FAIL: member b's advisory is missing or unattributed"; exit 1; } - -echo "OK 139_build_program_advisory" diff --git a/tests/e2e/13_toolchain_pin.sh b/tests/e2e/13_toolchain_pin.sh deleted file mode 100755 index 7ad33bcb0..000000000 --- a/tests/e2e/13_toolchain_pin.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Project pins [toolchain] → mcpp resolves to xpkg absolute path. -# -# This test verifies the resolve_xpkg_path branch is exercised when a -# pinned toolchain spec maps to an existing xpkg payload. We seed the -# sandbox xpkgs from the user's system xlings cache via symlink to -# avoid network-bound install delay. We DO NOT run the full compile -# (that would require glibc/sysroot env activation, which is xlings' -# subos shim concern, not mcpp's toolchain-resolve feature). -set -e -source "$(dirname "$0")/_toolchain_env.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -SANDBOX_HOME="$TMP/mcpp-home" -mkdir -p "$SANDBOX_HOME/registry/data/xpkgs" -# #273: this symlink seed was once the incident vector — post-install fixup -# walked through it and rewrote the REAL ~/.xlings gcc against the throwaway -# sandbox's loader paths. Every rewrite is now fenced by the containment -# predicate (physical location must resolve inside cfg.registryDir), so the -# seed is deliberately KEPT: it doubles as a live canary — if the fence ever -# regresses, the machine's gcc breaks loudly and immediately. -# The payload is the one this machine has: in the home mcpp installs into -# (`mcpp toolchain install gcc `, which is where CI's runners have it) -# or in the system xlings. The probe named the system xlings and 16.1.0 alone -# until 2026-11-12, and the script skipped itself on every runner. -GCC_SEED="" -for store in "$(dirname "$MCPP_E2E_GCC_ROOT")" "$HOME/.xlings/data/xpkgs/xim-x-gcc"; do - if [[ -d "$store/$GCC_VERSION" ]]; then - GCC_SEED="$store" - break - fi -done -if [[ -n "$GCC_SEED" ]]; then - ln -s "$GCC_SEED" "$SANDBOX_HOME/registry/data/xpkgs/xim-x-gcc" -else - echo "SKIP: neither mcpp's home nor the system xlings has gcc $GCC_VERSION" - echo "OK" - exit 77 -fi - -export MCPP_HOME="$SANDBOX_HOME" - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp - -cat > mcpp.toml <&1 || true) - -# 1. "Resolving" + "toolchain" verb appeared -echo "$out" | grep -q 'Resolving' || { echo "missing Resolving: $out"; exit 1; } -echo "$out" | grep -q 'toolchain' || { echo "missing toolchain: $out"; exit 1; } - -# 2. Resolved line points at the xpkg payload under our sandbox. -# The display path is now shortened — paths under MCPP_HOME -# render as `@mcpp/...` — so we match the shortened form -# rather than the absolute one. The trailing path components are -# what actually prove the toolchain came from mcpp's sandbox. -echo "$out" | grep -q "Resolved.*gcc@${GCC_VERSION}" || { - echo "missing Resolved gcc@${GCC_VERSION}: $out"; exit 1; } -echo "$out" | grep -q "@mcpp/registry/data/xpkgs/xim-x-gcc/${GCC_VERSION}/bin/g++" || { - echo "Resolved path doesn't reference the mcpp sandbox xpkg: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/141_include_dirs_after.sh b/tests/e2e/141_include_dirs_after.sh deleted file mode 100755 index ee85dbf54..000000000 --- a/tests/e2e/141_include_dirs_after.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# #249: `[build] include_dirs_after` → -idirafter. A path dependency whose -# include root contains BOTH a file named like a standard header (stdlib.h, -# poisoned) AND a real header (mylib.h) must not break consumers: -idirafter -# dirs are searched AFTER the toolchain's system dirs, so the system stdlib.h -# wins while mylib.h is still found. Control: the same dep exposed via plain -# include_dirs (-I, highest priority) picks the poisoned stdlib.h and the -# build fails. This is the Linux encoding of the macOS case-insensitive -# problem where a tarball root's VERSION file shadows libc++'s . -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" - -# Dependency — a header-only-ish C++ lib whose include root is poisoned. -"$MCPP" new poisonlib > /dev/null -cd poisonlib -rm -f src/main.cpp -cat > src/lib.cppm <<'EOF' -export module poisonlib.lib; -export int poisonlib_anchor() { return 1; } -EOF -mkdir -p inc -cat > inc/stdlib.h <<'EOF' -#error poisoned -EOF -cat > inc/mylib.h <<'EOF' -#pragma once -inline int mylib_answer() { return 42; } -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "poisonlib" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[build] -include_dirs_after = ["inc"] -[targets.poisonlib] -kind = "lib" -EOF -cd .. - -# Consumer — includes BOTH the standard header the dep poisons and the -# dep's real header. -"$MCPP" new consumer > /dev/null -cd consumer -cat > src/main.cpp <<'EOF' -#include -#include -import std; -int main() { - std::println("answer = {}", mylib_answer()); - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "consumer" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp"] -[targets.consumer] -kind = "bin" -main = "src/main.cpp" -[dependencies.poisonlib] -path = "../poisonlib" -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "include_dirs_after build failed"; exit 1; } -out=$("$MCPP" run 2>&1) -[[ "$out" == *"answer = 42"* ]] || { echo "dep header not found via -idirafter: $out"; exit 1; } - -# The -idirafter flag (not -I) must carry the dep's include root. -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*')" -grep -q -- '-idirafter[^ ]*poisonlib[^ ]*inc' "$ninja_file" || { - echo "ninja missing -idirafter for dep include root"; exit 1; } - -# Control: the same dir via plain include_dirs (-I) precedes system dirs — -# the poisoned stdlib.h is picked and the build MUST fail. -sed -i.bak 's/include_dirs_after = \["inc"\]/include_dirs = ["inc"]/' ../poisonlib/mcpp.toml -rm -f ../poisonlib/mcpp.toml.bak -rm -rf target -"$MCPP" build > poisoned.log 2>&1 && { echo "expected poisoned build to fail"; exit 1; } -grep -q "poisoned" poisoned.log || { cat poisoned.log; echo "wrong failure (not the poison)"; exit 1; } - -echo "OK" diff --git a/tests/e2e/142_generated_files_incremental.sh b/tests/e2e/142_generated_files_incremental.sh deleted file mode 100755 index 3c00c2e55..000000000 --- a/tests/e2e/142_generated_files_incremental.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# Incremental x generated_files: an unchanged [generated_files] entry must NOT -# be rewritten when prepare_build runs — ninja is mtime-driven, so a gratuitous -# rewrite of a materialized header recompiles every TU that #includes it (via -# depfiles). A truly no-op rebuild is masked by the P0 fast-path (prepare is -# skipped), so the load-bearing case is: touch an UNRELATED source, which -# abandons the fast-path and re-runs prepare (and materialization). Assert the -# generated header keeps its mtime and the including TU is not recompiled; -# a content change must still rewrite and rebuild (no false-fresh). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1"; } - -cd "$TMP" -"$MCPP" new genincr > /dev/null -cd genincr -rm -f src/main.cpp - -cat > mcpp.toml <<'EOF' -[package] -name = "genincr" -version = "0.1.0" - -[generated_files] -"src/gen/config.h" = """ -#define GEN_ANSWER 41 -""" -EOF - -cat > src/main.cpp <<'EOF' -#include "gen/config.h" -#include -extern int other(); -int main() { - std::printf("gen = %d\n", GEN_ANSWER + other()); - return 0; -} -EOF - -cat > src/other.cpp <<'EOF' -int other() { return 0; } -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -[[ -f src/gen/config.h ]] || { echo "generated file not materialized"; exit 1; } - -main_obj=$(find target -name 'main*.o' | head -1) -other_obj=$(find target -name 'other*.o' | head -1) -[[ -n "$main_obj" && -n "$other_obj" ]] || { echo "object files not found under target/"; exit 1; } - -gen_before=$(mtime src/gen/config.h) -main_before=$(mtime "$main_obj") - -sleep 1 # make a rewrite observable at 1s mtime granularity - -# Touch an unrelated TU: the fast-path is abandoned, prepare_build re-runs, -# and generated_files are re-materialized. -touch src/other.cpp -# (c) the incremental rebuild still succeeds ... -"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "incremental rebuild failed"; exit 1; } -# ... and other.o really was recompiled (prepare + compile did run). -other_after=$(mtime "$other_obj") -[[ "$other_after" -gt "$gen_before" ]] || { echo "other.o was not recompiled — test premise broken"; exit 1; } -# (a) the generated header was NOT rewritten (mtime preserved) ... -gen_after=$(mtime src/gen/config.h) -[[ "$gen_before" == "$gen_after" ]] || { echo "generated file rewritten on incremental rebuild ($gen_before -> $gen_after)"; exit 1; } -# (b) ... so main.o (which #includes it, untouched) was NOT recompiled. -main_after=$(mtime "$main_obj") -[[ "$main_before" == "$main_after" ]] || { echo "main.o recompiled on incremental rebuild ($main_before -> $main_after)"; exit 1; } - -# Content change: the file must be rewritten and the TU recompiled (no -# false-fresh — change detection lives in the fingerprint). -sed -i.bak 's/GEN_ANSWER 41/GEN_ANSWER 42/' mcpp.toml -sleep 1 -"$MCPP" build > change.log 2>&1 || { cat change.log; echo "rebuild after content change failed"; exit 1; } -gen_changed=$(mtime src/gen/config.h) -[[ "$gen_after" != "$gen_changed" ]] || { echo "generated file not rewritten after content change"; exit 1; } -grep -q "GEN_ANSWER 42" src/gen/config.h || { echo "generated file has stale content"; exit 1; } -# The content change alters the fingerprint, which may relocate the build -# dir — assert on the NEWEST main*.o, wherever it lives. -main_changed=$(find target -name 'main*.o' | while read -r f; do mtime "$f"; done | sort -n | tail -1) -[[ "$main_changed" -ge "$gen_changed" ]] || { echo "main.o not recompiled after content change"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "gen = 42" ]] || { echo "stale binary after content change: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/143_build_mcpp_source_directive.sh b/tests/e2e/143_build_mcpp_source_directive.sh deleted file mode 100755 index 3e916d6b3..000000000 --- a/tests/e2e/143_build_mcpp_source_directive.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# P1 (large-source-pkg design §3.1): `mcpp:source=` selects a PRE-EXISTING -# source file into the compile set — the proper name for the old `generated=` -# absolute-path grey usage. The file lives in the package tree, is NOT matched -# by any [build]/[modules] sources glob, and the program did not write it. -# Also covers: the typed `mcpp::source(...)` emitter, and the `d source` cache -# record round-trip (a rebuild that replays the cache must still compile it). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p srcsel/src srcsel/extra -cd srcsel - -# Pre-existing payload file OUTSIDE the sources globs (extra/, not src/). -cat > extra/impl.cpp <<'EOF' -extern "C" int selected_value() { return 42; } -EOF - -# build.mcpp selects it via the typed API (relative to the package root). -cat > build.mcpp <<'EOF' -import mcpp; -int main() { - mcpp::source("extra/impl.cpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int selected_value(); -int main() { - std::println("SELECTED={}", selected_value()); - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "srcsel" -version = "0.1.0" - -[modules] -sources = ["src/**/*.cpp"] - -[targets.srcsel] -kind = "bin" -main = "src/main.cpp" -EOF - -"$MCPP" build > build1.log 2>&1 || { cat build1.log; echo "FAIL: build failed"; exit 1; } -grep -q "ignoring unknown directive" build1.log && { - cat build1.log; echo "FAIL: mcpp:source= not recognized"; exit 1; } || true - -out="$("$MCPP" run 2>&1 | grep '^SELECTED=' | tail -1)" -[[ "$out" == "SELECTED=42" ]] || { echo "FAIL: selected source not linked: $out"; exit 1; } - -# Cache round-trip: touch main.cpp (invalidates the ninja build, not the -# build.mcpp inputs) → the cached `d source` record must reapply so the -# selected file stays in the compile set without a re-run. -cat > src/main.cpp <<'EOF' -import std; -extern "C" int selected_value(); -int main() { - std::println("SELECTED2={}", selected_value()); - return 0; -} -EOF -"$MCPP" build > build2.log 2>&1 || { cat build2.log; echo "FAIL: rebuild failed"; exit 1; } -grep -qE "^ *build\.mcpp .* ran [0-9]" build2.log && { - cat build2.log; echo "FAIL: unchanged build.mcpp re-ran"; exit 1; } || true -out="$("$MCPP" run 2>&1 | grep '^SELECTED2=' | tail -1)" -[[ "$out" == "SELECTED2=42" ]] || { echo "FAIL: cached source record lost: $out"; exit 1; } - -# A source= pointing at a missing file is a hard, actionable error. -cat > build.mcpp <<'EOF' -import mcpp; -int main() { - mcpp::source("extra/nope.cpp"); - return 0; -} -EOF -"$MCPP" build > build3.log 2>&1 && { cat build3.log; echo "FAIL: missing source did not fail"; exit 1; } -grep -q "selected source" build3.log || { - cat build3.log; echo "FAIL: missing-source error not surfaced"; exit 1; } - -echo "OK" diff --git a/tests/e2e/144_build_mcpp_include_dir.sh b/tests/e2e/144_build_mcpp_include_dir.sh deleted file mode 100755 index 3da15f7b9..000000000 --- a/tests/e2e/144_build_mcpp_include_dir.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# P1 (large-source-pkg design §3.1): `mcpp:include-dir=` adds a private include -# directory for the package's own TUs — replacing the old double-emission hack -# (`mcpp:cxxflag=-I…` + `mcpp:cflag=-I…`, unnormalized). Scenario: build.mcpp -# writes a header under MCPP_OUT_DIR and points include-dir at it (absolute); -# a source #includes it; the build succeeds with NO -I flag emitted manually. -# Also: `mcpp:include-dir-after=` rides the typed #249 channel end-to-end — -# the emitted build.ninja must carry -idirafter for the directive dir — and -# both directives round-trip the directive cache. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p incdir/src -cd incdir - -cat > build.mcpp <<'EOF' -#include -#include -#include -#include -#include -import mcpp; -int main() { - std::string inc = std::string(mcpp::out_dir()) + "/inc"; - std::filesystem::create_directories(inc); - std::ofstream(inc + "/bp_config.h") << "#define BP_ANSWER 42\n"; - mcpp::include_dir(inc.c_str()); // absolute - mcpp::include_dir_after("vendor/sysinc"); // relative -> package root; accepted - return 0; -} -EOF -mkdir -p vendor/sysinc - -cat > src/main.cpp <<'EOF' -import std; -#include -int main() { - std::println("ANSWER={}", BP_ANSWER); - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "incdir" -version = "0.1.0" - -[modules] -sources = ["src/**/*.cpp"] - -[targets.incdir] -kind = "bin" -main = "src/main.cpp" -EOF - -"$MCPP" build > build1.log 2>&1 || { cat build1.log; echo "FAIL: build failed"; exit 1; } -grep -q "ignoring unknown directive" build1.log && { - cat build1.log; echo "FAIL: include-dir directive not recognized"; exit 1; } || true - -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { echo "FAIL: include dir not on the -I chain: $out"; exit 1; } - -# include-dir-after reaches the compile edges as -idirafter (typed #249 -# channel: privateBuild.includeDirsAfter → localIncludeDirsAfter). -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -grep -q -- "-idirafter.*vendor/sysinc" "$ninja_file" || { - echo "FAIL: include-dir-after not emitted as -idirafter in build.ninja"; - grep -n "local_includes" "$ninja_file" | head; exit 1; } - -# Cache round-trip: touch a source (defeats the whole-build fast path, keeps -# build.mcpp inputs unchanged) → the cached include-dir record must reapply -# without a re-run, and the header must still resolve. -cat > src/main.cpp <<'EOF' -import std; -#include -int main() { - std::println("ANSWER2={}", BP_ANSWER); - return 0; -} -EOF -"$MCPP" build > build2.log 2>&1 || { cat build2.log; echo "FAIL: rebuild failed"; exit 1; } -grep -qE "^ *build\.mcpp .* ran [0-9]" build2.log && { - cat build2.log; echo "FAIL: unchanged build.mcpp re-ran"; exit 1; } || true -out="$("$MCPP" run 2>&1 | grep '^ANSWER2=' | tail -1)" -[[ "$out" == "ANSWER2=42" ]] || { echo "FAIL: cached include-dir record lost: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/145_root_build_mcpp_dep_dirs.sh b/tests/e2e/145_root_build_mcpp_dep_dirs.sh deleted file mode 100755 index eae79b07e..000000000 --- a/tests/e2e/145_root_build_mcpp_dep_dirs.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# P1 (large-source-pkg design §3.1 item 4): the ROOT project's build.mcpp now -# runs AFTER dependency resolution and receives MCPP_DEP__DIR exactly -# like a dependency's build.mcpp does (mcpp#241 contract, previously dep-only: -# the root ran before resolution and saw none). Scenario mirrors -# 125_build_mcpp_dep_dir.sh, but the build.mcpp asserting dep_dir() is the -# ROOT's own: root -> datad (path dep); root build.mcpp reads -# dep_dir("datad"), fails loudly if unset, and generates a source returning -# it; main prints it; the test asserts it is datad's real root. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# data-asset dependency -mkdir -p datad/src -echo 'int datad_touch() { return 1; }' > datad/src/d.cpp -cat > datad/mcpp.toml <<'EOF' -[package] -name = "datad" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[targets.datad] -kind = "lib" -EOF - -# ROOT project whose own build.mcpp locates datad's dir -mkdir -p rootp/src -cat > rootp/build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const char* d = mcpp::dep_dir("datad"); - if (d == nullptr || d[0] == '\0') { - std::fprintf(stderr, "build.mcpp: MCPP_DEP_DATAD_DIR not set for the ROOT\n"); - return 1; // the moved call point must expose the contract var - } - std::string out = "src/depdir.cpp"; - std::FILE* f = std::fopen(out.c_str(), "w"); - std::fprintf(f, "const char* root_datad_dir() { return \"%s\"; }\n", d); - std::fclose(f); - mcpp::generated("src/depdir.cpp"); - return 0; -} -EOF -cat > rootp/src/main.cpp <<'EOF' -import std; -extern const char* root_datad_dir(); -int main() { - std::println("DEPDIR={}", root_datad_dir()); - return 0; -} -EOF -cat > rootp/mcpp.toml <<'EOF' -[package] -name = "rootp" -version = "0.1.0" -[modules] -sources = ["src/**/*.cpp"] -[dependencies] -datad = { path = "../datad" } -[targets.rootp] -kind = "bin" -main = "src/main.cpp" -EOF - -cd rootp -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "FAIL: build failed"; exit 1; } - -out="$("$MCPP" run 2>&1 | grep '^DEPDIR=' | tail -1)" -dir="${out#DEPDIR=}" -[[ -n "$dir" ]] || { echo "FAIL: empty dep dir (root contract var missing)"; exit 1; } -[[ -d "$dir" ]] || { echo "FAIL: dep dir does not exist: $dir"; exit 1; } -[[ "$(basename "$dir")" == "datad" ]] || { echo "FAIL: dep dir is not datad's root: $dir"; exit 1; } - -echo "OK" diff --git a/tests/e2e/146_feature_flags.sh b/tests/e2e/146_feature_flags.sh deleted file mode 100755 index d4b6b26fa..000000000 --- a/tests/e2e/146_feature_flags.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# #253: [features]..flags — per-feature per-glob compile flags. -# Quadrants: feature off/on × build/test (0.0.94 dual-path invariant), plus: -# - feature OFF: the rule does not exist → NO dead-glob warning (the issue's -# opencv mlas noise), and the define is absent; -# - feature ON: the define reaches EXACTLY the glob-matched TU (not the -# package's other TUs — contrast feature `defines`, which are package-wide); -# - feature ON with a dead glob: warning fires, naming the owning feature; -# - flags do NOT propagate to a consumer (contrast feature `defines`, which -# interface-propagate) — asserted in the dep half below. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new featflags > /dev/null -cd featflags - -mkdir -p src/simd -cat > src/simd/kernel.cpp <<'EOF' -#ifndef KERNEL_TAG -#error "feature flags did not reach the glob-matched TU" -#endif -extern "C" int kernel_tag() { return KERNEL_TAG; } -EOF - -cat > src/plain.cpp <<'EOF' -#ifdef KERNEL_TAG -#error "feature flags leaked outside their glob (must be per-TU, not package-wide)" -#endif -extern "C" int plain_ok() { return 1; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int plain_ok(); -#ifdef MCPP_FEATURE_SIMD -extern "C" int kernel_tag(); -#endif -int main() { -#ifdef MCPP_FEATURE_SIMD - std::println("tag = {}", kernel_tag() + plain_ok()); -#else - std::println("tag = none"); -#endif - return 0; -} -EOF - -# Base sources deliberately do NOT cover src/simd/** (the opencv shape: a -# feature's sources live only under the feature) — so the gated TU is absent -# in every feature-off quadrant, including `mcpp test`, whose drop-skip only -# applies to gated globs that base sources also cover. -cat > mcpp.toml <<'EOF' -[package] -name = "featflags" -version = "0.1.0" - -[build] -sources = ["src/*.cpp"] - -[features] -default = [] -simd = { sources = ["src/simd/**"], flags = [ - { glob = "src/simd/**", defines = ["KERNEL_TAG=6"] } ] } -dead = { flags = [ { glob = "src/never/**", defines = ["NOPE"] } ] } -EOF -# The skeleton's test is mtest's; this manifest names no dev-dependency. -rm -f tests/test_smoke.cpp - -# Quadrant 1: feature OFF, build — rule doesn't exist, so no dead-glob warning. -"$MCPP" build > build_off.log 2>&1 || { cat build_off.log; echo "build (off) failed"; exit 1; } -grep -q "matched no source file" build_off.log && { - cat build_off.log; echo "feature-off build must not warn about feature flag globs"; exit 1; } || true -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "tag = none" ]] || { echo "unexpected (off): $out"; exit 1; } - -# Quadrant 2: feature ON, build — define lands on the matched TU only. -"$MCPP" build --features simd > build_on.log 2>&1 || { cat build_on.log; echo "build (on) failed"; exit 1; } -bin="$(ls -t $(find target -name 'featflags' -type f) | head -1)" -[[ -n "$bin" ]] || { echo "binary not found"; exit 1; } -out="$("./$bin" | tail -1)" -[[ "$out" == "tag = 7" ]] || { echo "unexpected (on): $out (want 6+1)"; exit 1; } - -# Active feature with a glob that matches nothing → warning naming the feature. -"$MCPP" build --features simd,dead > build_dead.log 2>&1 || { - cat build_dead.log; echo "build (dead) failed"; exit 1; } -grep -q "features.dead.flags glob 'src/never/\*\*' matched no source file" build_dead.log || { - cat build_dead.log; echo "missing feature-scoped dead-glob warning"; exit 1; } - -# Quadrants 3+4: test path — flags behavior must match build (dual-path). -mkdir -p tests -cat > tests/test_featflags.cpp <<'EOF' -#ifdef MCPP_FEATURE_SIMD -extern "C" int kernel_tag(); -int main() { return kernel_tag() == 6 ? 0 : 1; } -#else -int main() { return 0; } -#endif -EOF -"$MCPP" test > test_off.log 2>&1 || { cat test_off.log; echo "mcpp test (off) failed"; exit 1; } -grep -q "matched no source file" test_off.log && { - cat test_off.log; echo "feature-off test must not warn about feature flag globs"; exit 1; } || true -"$MCPP" test --features simd > test_on.log 2>&1 || { cat test_on.log; echo "mcpp test (on) failed"; exit 1; } - -# --- Dep half: feature flags are PRIVATE to the owning package. --- -# The dep's feature carries both an interface define (propagates) and a -# per-glob flag define (must NOT propagate to the consumer's TUs). -cd "$TMP" -mkdir -p widget/src app/src -cat > widget/mcpp.toml <<'EOF' -[package] -name = "widget" -version = "0.1.0" - -[features] -default = [] -turbo = { defines = ["WIDGET_IFACE=1"], flags = [ - { glob = "src/**", defines = ["WIDGET_PRIVATE=1"] } ] } - -[targets.widget] -kind = "lib" -EOF -cat > widget/src/widget.cppm <<'EOF' -export module widget; -#ifndef WIDGET_PRIVATE -#error "feature flags did not reach the dep's own matched TU" -#endif -export int widget_anchor() { return WIDGET_PRIVATE; } -EOF -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -widget = { path = "../widget", features = ["turbo"] } -EOF -cat > app/src/main.cpp <<'EOF' -import widget; -#ifndef WIDGET_IFACE -#error "feature defines must interface-propagate to the consumer" -#endif -#ifdef WIDGET_PRIVATE -#error "feature flags must NOT propagate to the consumer" -#endif -int main() { return widget_anchor() == 1 ? 0 : 1; } -EOF -cd app -"$MCPP" build > build_dep.log 2>&1 || { cat build_dep.log; echo "dep build failed"; exit 1; } -"$MCPP" run > /dev/null 2>&1 || { echo "dep run failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/147_per_os_features_xpkg.sh b/tests/e2e/147_per_os_features_xpkg.sh deleted file mode 100755 index bab0bda62..000000000 --- a/tests/e2e/147_per_os_features_xpkg.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# #253: per-OS `features` in xpkg descriptors — the `mcpp.` additive -# overlay applies to `features` like every other mcpp-segment key: a same-named -# feature merges per-subkey by APPEND (neutral body first, host-OS body after), -# and the non-host OS section stays invisible. This is the common/delta shape -# compat.opencv's `dnn` uses (neutral common payload + per-OS kernels/flags). -# Locked end-to-end here; per-leg merge semantics are unit-tested via -# osOverride (test_manifest.cpp PerOsFeaturesAdditiveMerge). -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -# Host OS key in xpkg vocabulary; the "other" OS carries a poison section that -# must never leak into this build. -case "$(uname -s)" in - Darwin) OSKEY=macosx; OTHER=linux ;; - MINGW*|MSYS*|CYGWIN*) OSKEY=windows; OTHER=linux ;; - *) OSKEY=linux; OTHER=macosx ;; -esac - -INDEX_DIR="$TMP/local-index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/o" -cat > "$INDEX_DIR/pkgs/o/osfeat.lua" < "$PKGSRC/base.c" <<'EOF' -int osfeat_base(void) { return 1; } -EOF -cat > "$PKGSRC/accel_common.c" <<'EOF' -#ifndef OSFEAT_ACCEL -#error "neutral feature define missing" -#endif -int osfeat_common(void) { return 2; } -EOF -cat > "$PKGSRC/accel_os.c" <<'EOF' -/* Compiled only via the host-OS features overlay; its per-OS feature flag - * must land here (and only here). */ -#ifndef OSFEAT_OS_KERNEL -#error "per-OS feature flags did not reach the per-OS feature source" -#endif -int osfeat_os_kernel(void) { return OSFEAT_OS_KERNEL; } -EOF -# Poison: if the OTHER-OS section leaks, this file compiles and breaks the build. -cat > "$PKGSRC/other_os_only.c" <<'EOF' -#error "non-host OS features section must be invisible" -EOF - -cd "$TMP/project/app" -cat > src/main.cpp <<'EOF' -extern "C" int osfeat_base(void); -extern "C" int osfeat_common(void); -extern "C" int osfeat_os_kernel(void); -int main() { - // 1 + 2 + 5 == 8: base + neutral feature payload + per-OS feature delta. - return (osfeat_base() + osfeat_common() + osfeat_os_kernel()) == 8 ? 0 : 1; -} -EOF - -cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -grep -q "matched no source file" build.log && { - cat build.log; echo "per-OS feature flags glob must match its per-OS source"; exit 1; } || true -"$MCPP" run > /dev/null 2>&1 || { echo "run failed (payload sum wrong or link failed)"; exit 1; } - -# Feature OFF: every accel source (neutral + per-OS) must stay out. -cat > src/main.cpp <<'EOF' -extern "C" int osfeat_base(void); -int main() { return osfeat_base() == 1 ? 0 : 1; } -EOF -cat > mcpp.toml < build_off.log 2>&1 || { cat build_off.log; echo "build (off) failed"; exit 1; } -find . -name 'accel_*.o' | grep -q . && { echo "accel sources compiled with feature off"; exit 1; } || true -"$MCPP" run > /dev/null 2>&1 || { echo "run (off) failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/148_wide_include_list.sh b/tests/e2e/148_wide_include_list.sh deleted file mode 100755 index 7c558e1e4..000000000 --- a/tests/e2e/148_wide_include_list.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# #261: a package with a wide include-dir list must scan, compile and link. -# -# The failure this guards against is Windows-only in its symptom but not in -# its cause: every -I lands on the scan AND compile command lines, which have -# a hard ceiling on Windows (8191 through cmd.exe before #261, 32767 through -# CreateProcess after). The regression was found from the CONSUMER side — -# the package's own CI was green because its paths were short, and only a -# deep dependency path pushed the same command over the limit. -# -# Running it on every platform keeps the shape honest (POSIX proves the -# inline form still works, Windows proves the response-file form does). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new wideinc > /dev/null -cd wideinc - -# 64 include dirs, each with a header that only that dir provides, so the -# build genuinely depends on every entry being present on the command line. -N=64 -INCS="" -for i in $(seq 1 $N); do - d="vendor/dir_with_a_deliberately_long_name_$i/include" - mkdir -p "$d" - echo "#define WIDE_INC_$i $i" > "$d/wide_$i.h" - INCS="$INCS\"$d\", " -done - -python3 - "$N" <<'PY' > src/wide.cpp -import sys -n = int(sys.argv[1]) -for i in range(1, n + 1): - print(f'#include ') -print('extern "C" int wide_sum() { return 0') -for i in range(1, n + 1): - print(f' + WIDE_INC_{i}') -print(' ; }') -PY - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int wide_sum(); -int main() { - std::println("wide_sum = {}", wide_sum()); - return wide_sum() == (64 * 65) / 2 ? 0 : 1; -} -EOF - -python3 - "$INCS" <<'PY' -import sys, re, pathlib -incs = sys.argv[1].rstrip(', ') -p = pathlib.Path('mcpp.toml') -s = p.read_text() -s = re.sub(r'(?m)^\[build\]$', f'[build]\ninclude_dirs = [{incs}]', s, count=1) -if 'include_dirs' not in s: - s += f'\n[build]\ninclude_dirs = [{incs}]\n' -p.write_text(s) -PY - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "wide include build failed"; exit 1; } -out=$("$MCPP" run 2>&1) || { echo "$out"; echo "wide include run failed"; exit 1; } -echo "$out" | grep -q "wide_sum = 2080" || { - echo "$out"; echo "expected wide_sum = 2080 (every -I must be present)"; exit 1; } - -echo "PASS: 148_wide_include_list" diff --git a/tests/e2e/149_conditional_glob_flags.sh b/tests/e2e/149_conditional_glob_flags.sh deleted file mode 100755 index d931e1d55..000000000 --- a/tests/e2e/149_conditional_glob_flags.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# #258: per-glob `flags` inside `[target.'cfg()'.build]`. -# -# What this unblocks, in the reporter's words: `sources` is OS-conditional -# but `flags` was not, so one manifest covering three OSes had to present -# every OS with the other two OSes' flag entries — each matching zero sources -# by construction. The vendored-opencv port worked around it with 703 -# committed stub files whose only purpose was to give windows TUs OS-unique -# PATHS that a global flag table could key on, plus 32 globs pointing at -# them, plus ~23 structurally-guaranteed dead-glob warnings on every build. -# -# Three assertions, matching the three costs: -# 1. a matching-OS conditional flag entry reaches the TU; -# 2. it can OVERRIDE the unconditional base table (the removal case: -# unix defines HAVE_UNISTD_H, windows must not); -# 3. an off-OS entry produces NO dead-glob warning — the entry simply does -# not exist on this OS, the same way #253 made a feature-off entry not -# exist. No suppression mechanism, no `optional = true` escape hatch. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new condflags > /dev/null -cd condflags - -case "$(uname -s)" in - Linux) THIS_OS=linux; OTHER_OS=windows ;; - Darwin) THIS_OS=macos; OTHER_OS=windows ;; - *) THIS_OS=windows; OTHER_OS=linux ;; -esac - -mkdir -p src/zlib - -# Probes the three-way outcome: the base define must be overridden by the -# conditional entry that lands after it, and the conditional-only define -# must be present. -cat > src/zlib/probe.cpp <<'EOF' -#ifndef BASE_DEFINE -#error "base [build].flags entry did not reach the TU" -#endif -#ifndef COND_DEFINE -#error "conditional [target.cfg(os)] flags entry did not reach the TU" -#endif -#ifdef REMOVED_DEFINE -#error "conditional entry failed to override the base table (removal case)" -#endif -extern "C" int probe_ok() { return 1; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int probe_ok(); -int main() { std::println("probe = {}", probe_ok()); return 0; } -EOF - -# The base table defines REMOVED_DEFINE for everyone; the matching-OS -# conditional entry appends -U for it (last flag wins) and adds its own. -# The off-OS entry points at a glob that matches nothing on this host and -# must stay silent. -cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "conditional glob flags build failed"; exit 1; } - -out=$("$MCPP" run 2>&1 | tail -1) -[[ "$out" == "probe = 1" ]] || { echo "unexpected output: $out"; cat build.log; exit 1; } - -# Assertion 3 — the whole point of the "23 warnings" half of the report. -if grep -q "matched no source file" build.log; then - cat build.log - echo "FAIL: an off-OS conditional flags entry produced a dead-glob warning;" - echo " off-OS entries must not exist on this OS at all" - exit 1 -fi - -# Control: a genuinely dead glob in the UNCONDITIONAL table must still warn. -# The fix removes structural noise, not real signal. -cat >> mcpp.toml <<'EOF' - -[[build.flags]] -glob = "src/nonexistent_dir/**" -defines = ["NEVER=1"] -EOF -"$MCPP" build > build2.log 2>&1 || { cat build2.log; exit 1; } -grep -q "matched no source file" build2.log || { - cat build2.log - echo "FAIL: a real dead glob in [build].flags must still warn" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/14_toolchain_fallback.sh b/tests/e2e/14_toolchain_fallback.sh deleted file mode 100755 index fde7562da..000000000 --- a/tests/e2e/14_toolchain_fallback.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 14_toolchain_fallback.sh — M5.5: when no toolchain is configured at all -# (no project [toolchain], no global default), `mcpp build` hard-errors with -# a helpful message instead of falling back to system PATH. -# -# As of the first-run UX, mcpp DEFAULT auto-installs musl-gcc -# on the first build (covered by tests/e2e/29). Here we opt out via -# MCPP_NO_AUTO_INSTALL to assert the hard-error path still exists for -# CI / offline scenarios. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" # fresh = no global default -export MCPP_NO_AUTO_INSTALL=1 # opt out of first-run auto-install - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp - -rc=0 -out=$("$MCPP" build 2>&1) || rc=$? -[[ "$rc" -ne 0 ]] || { echo "FAIL: build should hard-error without toolchain"; exit 1; } - -# Helpful error message. -echo "$out" | grep -q 'no toolchain configured' || { - echo "FAIL: error message not helpful: $out"; exit 1; } -echo "$out" | grep -q 'mcpp toolchain install' || { - echo "FAIL: error doesn't suggest install: $out"; exit 1; } - -# A project-level [toolchain] = "system" is REFUSED, and refused on its own -# terms rather than falling through to "no toolchain configured". -# -# THIS USED TO ASSERT THE OPPOSITE — that `system` "still works as escape -# hatch" — and the assertion was only ever `grep -q 'no toolchain configured'` -# being absent. That predicate stays satisfied by ANY other error, so when the -# escape hatch became a refusal the test went on passing while its stated -# intent had inverted. A negative-only assertion cannot tell "it worked" from -# "it failed differently"; both halves are checked now. -# -# The policy: mcpp builds only with toolchains it manages. A compiler taken -# from PATH cannot be identified or reproduced, so `import std` availability -# and "the same build on another machine" stop being things mcpp can promise. -# `msvc@system` is the single exception and is not this spelling. Host -# LIBRARIES are a different axis and remain the project's own choice. -cat >> mcpp.toml <<'EOF' - -[toolchain] -default = "system" -EOF -out=$("$MCPP" build 2>&1) || true -echo "$out" | grep -q 'no toolchain configured' && { - echo "FAIL: 'system' fell through to the unconfigured path: $out"; exit 1; } -echo "$out" | grep -q 'toolchains it manages' || { - echo "FAIL: 'system' was not refused on its own terms: $out"; exit 1; } -echo "$out" | grep -q 'msvc@system' || { - echo "FAIL: the refusal does not name the one supported exception: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/151_index_url_artifact_migration.sh b/tests/e2e/151_index_url_artifact_migration.sh deleted file mode 100755 index f8453cb45..000000000 --- a/tests/e2e/151_index_url_artifact_migration.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# requires: python3 -# #267/#269: fresh init seeds the mcpplibs-org index URL + artifact source; -# legacy config.toml / .xlings.json (old org URL, no artifact) are healed in -# place, idempotently, in both pretty (mcpp writer) and compact (xlings -# writer) JSON spacings. Unrelated .xlings.json state must survive. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" - -NEW_URL='https://github.com/mcpplibs/mcpp-index.git' -OLD_URL='https://github.com/mcpp-community/mcpp-index.git' -ART='https://github.com/xlings-res/mcpp-index' -ART_CN='https://gitcode.com/xlings-res/mcpp-index' -# The default artifact is a region object since 2026.9.16.1 (#648 A6): the -# GitHub base for every mirror and the GitCode base a CN mirror asks first. -REGION="{ \"GLOBAL\": \"$ART\", \"CN\": \"$ART_CN\" }" -CFG="$MCPP_HOME/config.toml" -XJ="$MCPP_HOME/registry/.xlings.json" - -# The mcpplibs entry of .xlings.json has these fields (a JSON object), however -# the file is laid out. -entry_has() { - python3 - "$XJ" "$1" <<'PYEOF2' -import json, sys -doc = json.load(open(sys.argv[1])) -want = json.loads(sys.argv[2]) -e = next(r for r in doc["index_repos"] if r["name"] == "mcpplibs") -sys.exit(0 if all(e.get(k) == v for k, v in want.items()) else 1) -PYEOF2 -} - -# 1. Fresh init: new org URL + artifact declaration in both files. -# (xlings' own bootstrap may re-serialize .xlings.json with its writer, so -# assert on the key/value pairs, not on mcpp's seed line layout.) -"$MCPP" self config --mirror auto > /dev/null -grep -q "url = \"$NEW_URL\"" "$CFG" || { echo "config.toml missing new url"; exit 1; } -grep -qF "artifact = { GLOBAL = \"$ART\", CN = \"$ART_CN\" }" "$CFG" \ - || { echo "config.toml missing the region artifact"; cat "$CFG"; exit 1; } -grep -q "\"url\": \"$NEW_URL\"" "$XJ" \ - || { echo "seeded .xlings.json missing new url"; cat "$XJ"; exit 1; } -python3 - "$XJ" "$ART" "$ART_CN" <<'PYEOF' || { echo "seeded .xlings.json missing the region artifact"; cat "$XJ"; exit 1; } -import json, sys -doc = json.load(open(sys.argv[1])) -entry = next(r for r in doc["index_repos"] if r["name"] == "mcpplibs") -assert entry["artifact"] == {"GLOBAL": sys.argv[2], "CN": sys.argv[3]}, entry -PYEOF -if grep -q 'mcpp-community/mcpp-index' "$CFG" "$XJ"; then - echo "old org URL leaked into fresh seed"; exit 1 -fi - -# 2. Legacy heal (pretty spacing): old org URL, no artifact. -cat > "$CFG" < "$XJ" < /dev/null -grep -q "url = \"$NEW_URL\"" "$CFG" || { echo "config.toml url not healed"; cat "$CFG"; exit 1; } -entry_has "{\"url\": \"$NEW_URL\", \"artifact\": $REGION}" \ - || { echo ".xlings.json not healed"; cat "$XJ"; exit 1; } -grep -q '"subos": "default"' "$XJ" || { echo "unrelated .xlings.json key lost"; exit 1; } -if grep -q 'mcpp-community/mcpp-index' "$CFG" "$XJ"; then - echo "old org URL survived heal"; exit 1 -fi - -# Idempotent: a second run must not touch either file again. -cp "$CFG" "$TMP/cfg1"; cp "$XJ" "$TMP/xj1" -"$MCPP" self config --mirror auto > /dev/null -cmp -s "$CFG" "$TMP/cfg1" || { echo "config.toml heal not idempotent"; exit 1; } -cmp -s "$XJ" "$TMP/xj1" || { echo ".xlings.json heal not idempotent"; exit 1; } - -# 3. Legacy heal (compact spacing, old index name): xlings-writer format. -printf '{"index_repos":[{"name":"mcpp-index","url":"%s"}],"mirror":"auto"}' "$OLD_URL" > "$XJ" -"$MCPP" self config --mirror auto > /dev/null -entry_has '{}' || { echo "compact name not healed"; cat "$XJ"; exit 1; } -grep -q 'xlings-res/mcpp-index' "$XJ" || { echo "compact artifact not injected"; cat "$XJ"; exit 1; } -grep -q 'gitcode.com/xlings-res/mcpp-index' "$XJ" || { echo "compact artifact has no CN half"; cat "$XJ"; exit 1; } - -# 4. A home seeded before the region object (flat GitHub base, both spacings) -# gains the CN half, once; a base the user wrote is left alone. -printf '{\n "index_repos": [\n { "name": "mcpplibs", "url": "%s", "artifact": "%s" }\n ],\n "mirror": "CN"\n}\n' "$NEW_URL" "$ART" > "$XJ" -"$MCPP" self config --mirror auto > /dev/null -entry_has "{\"artifact\": $REGION}" || { echo "flat default artifact not upgraded"; cat "$XJ"; exit 1; } -cp "$XJ" "$TMP/xj4"; "$MCPP" self config --mirror auto > /dev/null -cmp -s "$XJ" "$TMP/xj4" || { echo "region upgrade not idempotent"; diff "$TMP/xj4" "$XJ"; exit 1; } -# A base the user states in config.toml owns the entry, and has no CN half. -cat > "$CFG" < /dev/null -grep -q 'https://mirror.example/idx' "$XJ" || { echo "a user artifact base was rewritten"; cat "$XJ"; exit 1; } -if grep -q 'gitcode.com/xlings-res/mcpp-index' "$XJ"; then echo "a user artifact base gained a CN half"; cat "$XJ"; exit 1; fi -if grep -q 'mcpp-community' "$XJ"; then - echo "compact old org URL survived"; exit 1 -fi - -echo "OK" diff --git a/tests/e2e/152_test_subdir_names.sh b/tests/e2e/152_test_subdir_names.sh deleted file mode 100755 index 8e1fb34a6..000000000 --- a/tests/e2e/152_test_subdir_names.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# requires: -# tests/ subdirectories: same stem in two dirs must coexist, names are relative paths -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests/00-a pkg/tests/01-b -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "subdirs" -version = "0.1.0" -standard = "c++23" -EOF -cat > tests/00-a/0.cpp <<'EOF' -int main() { return 0; } -EOF -cat > tests/01-b/0.cpp <<'EOF' -int main() { return 0; } -EOF - -out=$("$MCPP" test 2>&1) || { echo "mcpp test failed: $out"; exit 1; } -[[ "$out" == *"00-a/0 ... ok"* ]] || { echo "missing path-based name 00-a/0: $out"; exit 1; } -[[ "$out" == *"01-b/0 ... ok"* ]] || { echo "missing path-based name 01-b/0: $out"; exit 1; } -[[ "$out" == *"2 passed"* ]] || { echo "expected 2 passed: $out"; exit 1; } -echo OK diff --git a/tests/e2e/153_test_isolation.sh b/tests/e2e/153_test_isolation.sh deleted file mode 100755 index 473d64033..000000000 --- a/tests/e2e/153_test_isolation.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Per-test compile isolation: a non-compiling test fails alone; others build & run. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "isolation" -version = "0.1.0" -standard = "c++23" -EOF -cat > tests/ok.cpp <<'EOF' -int main() { return 0; } -EOF -cat > tests/runfail.cpp <<'EOF' -int main() { return 1; } -EOF -cat > tests/nocompile.cpp <<'EOF' -int main() { D2X_YOUR_ANSWER x = 1; return x; } -EOF - -set +e -out=$("$MCPP" test 2>&1) -code=$? -set -e -[[ $code -eq 1 ]] || { echo "expected exit 1, got $code: $out"; exit 1; } -[[ "$out" == *"ok ... ok"* ]] || { echo "passing test did not run: $out"; exit 1; } -# Per-test lines now carry the test's own wall time — match the prefix, not the -# whole line, so the duration can be there without pinning its formatting here. -[[ "$out" == *"runfail ... FAIL (exit 1,"* ]] || { echo "runtime failure not reported: $out"; exit 1; } -[[ "$out" == *"nocompile ... FAIL (compile,"* ]] || { echo "compile failure not isolated: $out"; exit 1; } -[[ "$out" == *"1 passed"* && "$out" == *"2 failed"* ]] || { echo "bad summary: $out"; exit 1; } - -# Package-level failure stays a hard error: break a src module, all-red is wrong, -# 'build failed' is right. -mkdir -p src -cat > src/isolation.cppm <<'EOF' -export module isolation; -this is not C++; -EOF -set +e -out2=$("$MCPP" test 2>&1) -code2=$? -set -e -[[ $code2 -ne 0 ]] || { echo "package error must fail: $out2"; exit 1; } -[[ "$out2" != *"nocompile ... FAIL (compile,"* ]] || { echo "package error misattributed to tests: $out2"; exit 1; } -echo OK diff --git a/tests/e2e/154_test_filter.sh b/tests/e2e/154_test_filter.sh deleted file mode 100755 index 53d999ae3..000000000 --- a/tests/e2e/154_test_filter.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -# requires: -# `mcpp test `: substring filter on path-based test names -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests/00-a pkg/tests/01-b -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "filter" -version = "0.1.0" -standard = "c++23" -EOF -echo 'int main() { return 0; }' > tests/00-a/0.cpp -echo 'int main() { return 0; }' > tests/01-b/0.cpp - -out=$("$MCPP" test 00-a 2>&1) || { echo "filtered run failed: $out"; exit 1; } -[[ "$out" == *"00-a/0 ... ok"* ]] || { echo "filtered test did not run: $out"; exit 1; } -[[ "$out" != *"01-b/0"* ]] || { echo "filter leaked other tests: $out"; exit 1; } -[[ "$out" == *"1 passed"* ]] || { echo "bad summary: $out"; exit 1; } - -set +e -out2=$("$MCPP" test does-not-exist 2>&1) -code2=$? -set -e -[[ $code2 -eq 2 ]] || { echo "no-match should exit 2, got $code2: $out2"; exit 1; } -[[ "$out2" == *"no tests match"* ]] || { echo "missing no-match error: $out2"; exit 1; } -echo OK diff --git a/tests/e2e/155_test_json.sh b/tests/e2e/155_test_json.sh deleted file mode 100755 index 1da63c156..000000000 --- a/tests/e2e/155_test_json.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# requires: -# --message-format json: NDJSON per test, package error record, pure stdout -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "jsonout" -version = "0.1.0" -standard = "c++23" -EOF -cat > tests/ok.cpp <<'EOF' -#include -int main() { std::puts("hello from ok"); return 0; } -EOF -echo 'int main() { return 1; }' > tests/runfail.cpp -echo 'int main() { D2X_YOUR_ANSWER x = 1; return x; }' > tests/nocompile.cpp - -set +e -out=$("$MCPP" test --message-format json 2>/dev/null) -code=$? -set -e -[[ $code -eq 1 ]] || { echo "expected exit 1, got $code"; exit 1; } - -# stdout must be pure NDJSON: every line parses as a JSON object. -while IFS= read -r line; do - [[ -z "$line" ]] && continue - [[ "$line" == "{"* ]] || { echo "non-JSON line on stdout: $line"; exit 1; } -done <<< "$out" - -echo "$out" | grep -q '"test":"ok","status":"pass"' || { echo "missing pass record"; exit 1; } -echo "$out" | grep -q '"test":"runfail","status":"run_fail"' || { echo "missing run_fail record"; exit 1; } -echo "$out" | grep -q '"exit_code":1' || { echo "missing exit_code"; exit 1; } -echo "$out" | grep -q '"test":"nocompile","status":"compile_fail"' || { echo "missing compile_fail record"; exit 1; } -echo "$out" | grep -q 'D2X_YOUR_ANSWER' || { echo "compile_output missing diagnostics"; exit 1; } -echo "$out" | grep -q 'hello from ok' || { echo "run_output not captured"; exit 1; } -# The summary now leads with the member it belongs to (empty outside a -# workspace) and carries the build/run split, so match the counts, not the -# whole object prefix. -echo "$out" | grep -q '"passed":1,"failed":2' || { echo "missing summary"; exit 1; } -echo "$out" | grep -qE '"build_ms":[0-9]+,"run_ms":[0-9]+' || { echo "summary missing build/run split"; exit 1; } - -# Invalid format value → usage error -set +e -"$MCPP" test --message-format yaml >/dev/null 2>&1 -[[ $? -eq 2 ]] || { echo "invalid format should exit 2"; exit 1; } -set -e -echo OK diff --git a/tests/e2e/156_nested_ld_library_path.sh b/tests/e2e/156_nested_ld_library_path.sh deleted file mode 100755 index 100961120..000000000 --- a/tests/e2e/156_nested_ld_library_path.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Inherited LD_LIBRARY_PATH pointing at mcpp's private glibc payload must not -# crash mcpp's own children (ninja/gcc). Reproduces the nested-mcpp chain: -# outer `mcpp run` poisons the env for its child; the child spawns mcpp again; -# the inner mcpp's tools then load a mismatched libc and segfault in the -# dynamic linker (trace signature: bare `__vdso_time` line). -set -e - -# The selected mcpp state may be intentionally isolated from HOME. Poison with -# a libc from that same state so mcpp itself can start; the regression begins -# when the nested host tools inherit the private loader path. -MCPP_STATE_HOME=${MCPP_HOME:-"$HOME/.mcpp"} -GLIBC_LIB=$(ls -d "$MCPP_STATE_HOME"/registry/data/xpkgs/xim-x-glibc/*/lib64 2>/dev/null | head -1) -if [[ -z "$GLIBC_LIB" ]]; then - echo "SKIP: no private glibc payload installed" - exit 77 -fi - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "nest" -version = "0.1.0" -standard = "c++23" -EOF -echo 'int main() { return 0; }' > tests/t.cpp - -out=$(LD_LIBRARY_PATH="$GLIBC_LIB" "$MCPP" test 2>&1) \ - || { echo "poisoned run failed: $out"; exit 1; } -[[ "$out" == *"t ... ok"* ]] || { echo "test did not pass under poison: $out"; exit 1; } -echo OK diff --git a/tests/e2e/157_test_glob_flags.sh b/tests/e2e/157_test_glob_flags.sh deleted file mode 100755 index 5a25f1b82..000000000 --- a/tests/e2e/157_test_glob_flags.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# requires: -# [build].flags globs also cover tests/ TUs (via the per-target flag channel), -# and a glob that matches real files on disk (just not scanned sources) does -# NOT warn "matched no source file". -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "globtest" -version = "0.1.0" -standard = "c++23" - -[build] -flags = [ - { glob = "tests/tagged*.cpp", cxxflags = ["-DTAG=7"] }, - { glob = "tests/never/**", cxxflags = ["-DNOPE"] }, -] -EOF -cat > tests/tagged.cpp <<'EOF' -#ifndef TAG -#error "per-glob cxxflags did not reach the test TU" -#endif -int main() { return TAG == 7 ? 0 : 1; } -EOF -cat > tests/plain.cpp <<'EOF' -#ifdef TAG -#error "per-glob cxxflags leaked into an unmatched test TU" -#endif -int main() { return 0; } -EOF - -out=$("$MCPP" test 2>&1) || { echo "mcpp test failed: $out"; exit 1; } -[[ "$out" == *"tagged ... ok"* ]] || { echo "tagged test failed: $out"; exit 1; } -[[ "$out" == *"plain ... ok"* ]] || { echo "plain test failed: $out"; exit 1; } -[[ "$out" != *"'tests/tagged*.cpp' matched no source file"* ]] \ - || { echo "spurious dead-glob warning for a tests/ glob: $out"; exit 1; } -[[ "$out" == *"'tests/never/**' matched no source file"* ]] \ - || { echo "genuinely dead glob must still warn: $out"; exit 1; } -echo OK diff --git a/tests/e2e/158_test_signal_and_duration.sh b/tests/e2e/158_test_signal_and_duration.sh deleted file mode 100755 index 84e88d6f4..000000000 --- a/tests/e2e/158_test_signal_and_duration.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# Signaled tests report shell-convention exit codes (128+sig) with the signal -# number in JSON, and every JSON test record carries duration_ms. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "sigdur" -version = "0.1.0" -standard = "c++23" -EOF -cat > tests/crash.cpp <<'EOF' -int main() { int* p = nullptr; return *p; } -EOF -cat > tests/ok.cpp <<'EOF' -int main() { return 0; } -EOF - -set +e -out=$("$MCPP" test --message-format json 2>/dev/null) -code=$? -set -e -[[ $code -eq 1 ]] || { echo "expected exit 1, got $code"; exit 1; } - -echo "$out" | grep -q '"test":"crash","status":"run_fail","exit_code":139,"signal":11' \ - || { echo "signaled test not normalized to 139/11: $out"; exit 1; } -echo "$out" | grep -q '"test":"ok","status":"pass"' || { echo "ok test missing"; exit 1; } -[[ $(echo "$out" | grep -c '"duration_ms":') -eq 2 ]] \ - || { echo "duration_ms missing from test records: $out"; exit 1; } - -# 人读输出同样用 shell 惯例退出码 -out2=$("$MCPP" test crash 2>&1) || true -# Prefix match: the line now also carries the test's wall time. -[[ "$out2" == *"crash ... FAIL (exit 139,"* ]] || { echo "human output not 139: $out2"; exit 1; } -echo OK diff --git a/tests/e2e/159_test_list.sh b/tests/e2e/159_test_list.sh deleted file mode 100755 index 086ce69d2..000000000 --- a/tests/e2e/159_test_list.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# requires: -# `mcpp test --list`: enumerate (filtered) tests without building anything. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests/00-a pkg/tests/01-b -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "listing" -version = "0.1.0" -standard = "c++23" -EOF -echo 'int main() { return 0; }' > tests/00-a/0.cpp -echo 'this does not even parse' > tests/01-b/0.cpp # --list 不构建,坏文件也要列出 - -out=$("$MCPP" test --list 2>&1) || { echo "--list failed: $out"; exit 1; } -[[ "$out" == *"00-a/0"* && "$out" == *"01-b/0"* ]] || { echo "missing names: $out"; exit 1; } -[[ ! -d target ]] || { echo "--list must not build (target/ created)"; exit 1; } - -out=$("$MCPP" test --list 00-a --message-format json 2>/dev/null) -echo "$out" | grep -q '"test":"00-a/0","main":".*tests/00-a/0.cpp"' \ - || { echo "json list record missing/bad: $out"; exit 1; } -echo "$out" | grep -qv '"test":"01-b/0"' || { echo "filter leaked into list"; exit 1; } -echo "$out" | grep -q '"summary":{"total":1}' || { echo "missing list summary: $out"; exit 1; } -echo OK diff --git a/tests/e2e/15_test_passing.sh b/tests/e2e/15_test_passing.sh deleted file mode 100755 index 1dc5ad655..000000000 --- a/tests/e2e/15_test_passing.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# requires: -# `mcpp test` discovers tests/**/*.cpp and runs each as a separate binary. -# All passing → exit 0 + summary "ok. N passed". -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp - -# mcpp new auto-creates tests/test_smoke.cpp; add a second test for diversity. -cat > tests/test_other.cpp <<'EOF' -import std; -int main() { std::println("test_other: ok"); return 0; } -EOF - -out=$("$MCPP" test 2>&1) || { echo "mcpp test failed: $out"; exit 1; } -echo "$out" | grep -q 'test_smoke ... ok' || { echo "missing smoke pass: $out"; exit 1; } -echo "$out" | grep -q 'test_other ... ok' || { echo "missing other pass: $out"; exit 1; } -echo "$out" | grep -q '2 passed; 0 failed' || { echo "missing summary: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/160_test_timeout.sh b/tests/e2e/160_test_timeout.sh deleted file mode 100755 index ea8cf7b72..000000000 --- a/tests/e2e/160_test_timeout.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# `mcpp test --timeout `: a hung test is killed and reported as a -# timeout failure; the rest of the suite still runs. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/tests -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "hang" -version = "0.1.0" -standard = "c++23" -EOF -cat > tests/sleepy.cpp <<'EOF' -import std; -int main() { std::this_thread::sleep_for(std::chrono::seconds(30)); return 0; } -EOF -cat > tests/quick.cpp <<'EOF' -int main() { return 0; } -EOF - -start=$(date +%s) -set +e -out=$("$MCPP" test --timeout 2 2>&1) -code=$? -set -e -elapsed=$(( $(date +%s) - start )) -[[ $code -eq 1 ]] || { echo "expected exit 1, got $code: $out"; exit 1; } -[[ $elapsed -lt 25 ]] || { echo "timeout did not take effect (took ${elapsed}s)"; exit 1; } -[[ "$out" == *"sleepy ... FAIL (timeout"* ]] || { echo "missing timeout marker: $out"; exit 1; } -[[ "$out" == *"quick ... ok"* ]] || { echo "quick test did not run: $out"; exit 1; } - -set +e -outj=$("$MCPP" test --timeout 2 --message-format json 2>/dev/null) -set -e -echo "$outj" | grep -q '"test":"sleepy","status":"run_fail"' || { echo "json: no run_fail"; exit 1; } -echo "$outj" | grep -q '"timed_out":true' || { echo "json: timed_out missing: $outj"; exit 1; } -echo OK diff --git a/tests/e2e/161_xpkg_name_form.sh b/tests/e2e/161_xpkg_name_form.sh deleted file mode 100755 index 549061aca..000000000 --- a/tests/e2e/161_xpkg_name_form.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 161_xpkg_name_form.sh — SPEC-001 §3.2: `package.name` is a SINGLE ATOMIC -# SEGMENT; all hierarchy belongs in `package.namespace`. -# -# accepted namespace = "chriskohlhoff", name = "asio" -# accepted namespace = "mcpplibs.capi", name = "lua" -# accepted namespace = "compat", name = "compat.zlib" (legacy FQN, kept working) -# refused namespace = "mcpplibs", name = "capi.lua" (short name has a dot) -# -# Why the last one is rejected rather than reinterpreted: identity is the pair -# (namespace, name). A `name` carrying dots the namespace does not account for -# describes a package whose namespace nobody declared. mcpp used to split such a -# name on its LAST dot and silently invent `(mcpplibs.capi, lua)` — a namespace -# absent from the descriptor. It now refuses to guess. -# -# This test was the inverse before mcpp 0.0.106 (it asserted that the short form -# was the violation). See docs/specs/package-identity.md §3.2 and mcpp#278. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mk() { # mk - cat > "$1" < /dev/null - -# ── 2. hierarchical namespace with an atomic short name ───────────── -mk nested.lua "mcpplibs.capi" "lua" -"$MCPP" xpkg parse nested.lua > /dev/null - -# ── 3. legacy fully-qualified spelling still accepted ─────────────── -# Descriptors written before SPEC-001 repeat the namespace inside `name`. -# They resolve to the same identity and stay installable, so they must not -# become errors — that would break every currently published package. -mk legacy.lua "compat" "compat.zlib" -"$MCPP" xpkg parse legacy.lua > /dev/null - -# ── 4. non-atomic short name is rejected, with the fix spelled out ── -mk bad.lua "mcpplibs" "capi.lua" -if "$MCPP" xpkg parse bad.lua > /dev/null 2>&1; then - echo "FAIL: a short name containing '.' must be rejected"; exit 1 -fi -"$MCPP" xpkg parse bad.lua 2>&1 | tee bad.err -grep -q "single atomic segment" bad.err -# The diagnostic must name BOTH corrected fields, not just complain. -grep -q 'mcpplibs.capi' bad.err -grep -q '"lua"' bad.err - -# ── 5. dotted name with no namespace at all is rejected ───────────── -# It would resolve into a namespace written down nowhere. -mk nons.lua "" "mcpplibs.capi.lua" -if "$MCPP" xpkg parse nons.lua > /dev/null 2>&1; then - echo "FAIL: dotted name without a namespace must be rejected"; exit 1 -fi - -# ── 6. --json carries the violation machine-readably (index CI) ───── -if "$MCPP" xpkg parse --json bad.lua > bad.json 2>/dev/null; then - echo "FAIL: --json must still exit non-zero on violation"; exit 1 -fi -python3 - <<'PY' -import json -j = json.load(open("bad.json")) -assert "error" in j, j -assert "atomic" in j["error"], j -PY - -# ── 7. runtime fail-fast: a descriptor that skipped index CI ──────── -# A `[indices]` path index bypasses lint entirely, so the same predicate has -# to guard the install path — and must fire BEFORE any download. -mkdir -p idx/pkgs/d -cat > idx/pkgs/d/thing.lua <<'EOF' -package = { - spec = "1", namespace = "demo", name = "sub.thing", - xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } }, - macosx = { ["1.0.0"] = { url = "u", sha256 = "h" } }, - windows = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, - mcpp = { schema = "0.1", sources = { "*.cpp" } }, -} -EOF - -mkdir -p app/src && cd app -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[indices] -demo = { path = "../idx" } - -[dependencies.demo] -"sub.thing" = "1.0.0" -EOF -echo 'int main() { return 0; }' > src/main.cpp - -if "$MCPP" build > build.out 2>&1; then - echo "FAIL: non-atomic short name must not build"; cat build.out; exit 1 -fi -grep -q "single atomic segment" build.out || { cat build.out; exit 1; } -if grep -q "Downloading" build.out; then - echo "FAIL: must fail before any download"; cat build.out; exit 1 -fi - -echo "PASS 161_xpkg_name_form" diff --git a/tests/e2e/162_bare_name_namespace_scope.sh b/tests/e2e/162_bare_name_namespace_scope.sh deleted file mode 100755 index 7bd3126fe..000000000 --- a/tests/e2e/162_bare_name_namespace_scope.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 162_bare_name_namespace_scope.sh — INV-RESOLVE (#278): a bare dependency name -# resolves in exactly one place (mcpplibs); every other namespace must be -# written out. -# -# Before this, a bare name that matched nothing fell through to the FIRST -# candidate SILENTLY, so mcpp carried on with a namespace it had invented and -# the user met the failure much later, wrapped around that invented name. And -# the discovery rung `(no namespace, X)` accepted a descriptor from ANY -# namespace, which made resolution depend on which indices happened to be -# present — adding an index could silently retarget an existing dependency. -# -# Covered: the hard failure, the did-you-mean hint (a DIAGNOSTIC-only index -# scan that must never feed resolution), and that both documented spellings -# resolve the very same package the hint names. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# A path index owning namespaces `acme` and `compat`. Note the acme descriptor -# is FQN-correct — this test is about the CONSUMER's spelling, not the -# descriptor's. -mkdir -p idx/pkgs/a idx/pkgs/c -cat > idx/pkgs/a/acme.widget.lua <<'EOF' -package = { - spec = "1", namespace = "acme", name = "acme.widget", - xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } }, - macosx = { ["1.0.0"] = { url = "u", sha256 = "h" } }, - windows = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, - mcpp = { schema = "0.1", sources = { "*.cpp" } }, -} -EOF -# The compat wrapper shape every published `compat.*` package uses. Served from -# the fixture rather than the shared registry so section 5 asserts the same -# property offline that it used to assert against whatever gtest happened to be -# installed. -cat > idx/pkgs/c/compat.gadget.lua <<'EOF' -package = { - spec = "1", namespace = "compat", name = "gadget", - xpm = { linux = { ["2.0.0"] = { url = "u", sha256 = "h" } }, - macosx = { ["2.0.0"] = { url = "u", sha256 = "h" } }, - windows = { ["2.0.0"] = { url = "u", sha256 = "h" } } }, - mcpp = { schema = "0.1", sources = { "*.cpp" } }, -} -EOF - -mkdir -p app/src && cd app -echo 'int main() { return 0; }' > src/main.cpp - -write_manifest() { - cat > mcpp.toml < bare.out 2>&1; then - echo "FAIL: a bare name must not reach the acme namespace"; cat bare.out; exit 1 -fi -grep -q "no package found for exact selector" bare.out || { - echo "FAIL: expected the explicit not-found error"; cat bare.out; exit 1; } -# The identities actually attempted must be listed — the old silent fallback -# reported a namespace the user never wrote. -grep -q "tried:" bare.out || { cat bare.out; exit 1; } - -# ── 2. did-you-mean names the real package and both spellings ─────── -grep -q "acme.widget" bare.out || { - echo "FAIL: expected did-you-mean to name acme.widget"; cat bare.out; exit 1; } -# #487: the hint must also carry what the suggested package publishes — -# the fixture's xpm table lists exactly one version per OS. -grep -q "acme.widget (1.0.0)" bare.out || { - echo "FAIL: expected did-you-mean to show acme.widget's version"; cat bare.out; exit 1; } -grep -q "\[dependencies.acme\]" bare.out || { - echo "FAIL: expected the sub-table spelling in the hint"; cat bare.out; exit 1; } - -# ── 3. the dotted spelling the hint suggests resolves ─────────────── -write_manifest '[dependencies] -"acme.widget" = "1.0.0"' -"$MCPP" build > dotted.out 2>&1 || true -# The asset URL is a sentinel, so the fetch fails — but resolution must have -# gotten far enough to ADDRESS the package, which is what this asserts. -if grep -q "no package found for exact selector" dotted.out; then - echo "FAIL: dotted selector must resolve to (acme, widget)"; cat dotted.out; exit 1 -fi - -# ── 4. the sub-table spelling resolves identically ────────────────── -write_manifest '[dependencies.acme] -widget = "1.0.0"' -"$MCPP" build > subtable.out 2>&1 || true -if grep -q "no package found for exact selector" subtable.out; then - echo "FAIL: sub-table form must resolve to (acme, widget)"; cat subtable.out; exit 1 -fi - -# ── 5. namespace omission means mcpplibs, with a one-release exit ramp ── -# -# Every published `compat.*` package and every manifest written before exact -# identity spells its dependency bare. Turning that into an immediate hard -# error would make an mcpp upgrade break builds against data that is already -# published and can no longer be edited — the mirror image of the rule that -# keeps a raised index floor from bricking older clients. -# -# So a bare name still REACHES compat for one release, but it can no longer do -# so quietly: that silence, not the reach, was the #278 defect. The hit is -# announced, it names the exact replacement, and the canonical identity is what -# travels downstream. -# -# Asserted on RESOLUTION, not on a successful build: whether the sentinel asset -# downloads is irrelevant to whether the bare name addressed the package. -write_manifest '[dependencies] -gadget = "2.0.0"' -"$MCPP" build > compat.out 2>&1 || true -if grep -q "no package found for exact selector" compat.out; then - echo "FAIL: bare gadget must still reach compat during the migration window" - cat compat.out; exit 1 -fi -grep -q "deprecated bare-name search" compat.out || { - echo "FAIL: the bare-name fallback must announce itself"; cat compat.out; exit 1; } -grep -q "compat.gadget" compat.out || { - echo "FAIL: the warning must name the exact replacement"; cat compat.out; exit 1; } -grep -q "\[dependencies.compat\]" compat.out || { - echo "FAIL: the warning must show the manifest edit"; cat compat.out; exit 1; } - -# The explicit spelling resolves the same package and says nothing: a user who -# has already migrated must not be nagged. -write_manifest '[dependencies.compat] -gadget = "2.0.0"' -"$MCPP" build > compat-explicit.out 2>&1 || true -if grep -q "no package found for exact selector" compat-explicit.out; then - echo "FAIL: explicit compat.gadget must resolve"; cat compat-explicit.out; exit 1 -fi -if grep -q "deprecated bare-name search" compat-explicit.out; then - echo "FAIL: an already-exact selector must not warn" - cat compat-explicit.out; exit 1 -fi - -# ── 6. the exit ramp is not a wildcard ────────────────────────────── -# The fallback reaches `compat` and the namespace-less rung. It must NOT reach -# a third-party namespace — section 1 already proved bare `widget` fails, and -# it must keep failing now that a fallback exists at all. Stating the exact -# namespace is likewise not eligible: it is an identity, not an omission. -write_manifest '[dependencies.mcpplibs] -gadget = "2.0.0"' -if "$MCPP" build > explicit-default.out 2>&1; then - echo "FAIL: mcpplibs.gadget states an identity and must not reach compat" - cat explicit-default.out; exit 1 -fi -grep -q "no package found for exact selector" explicit-default.out || { - echo "FAIL: expected the explicit not-found error" - cat explicit-default.out; exit 1; } - -# ── 7. `mcpp add` did-you-mean carries versions too (#487) ────────── -# The same suggestion text serves the build path (sections 1-2) and the add -# path. Requesting a gadget under `acme` — a readable index that does not -# serve it — must fail AND point at compat.gadget with its published version, -# straight from the fixture's xpm table. -write_manifest '' -if MCPP_OFFLINE=1 "$MCPP" add acme.gadget@9.9.9 > add-hint.out 2>&1; then - echo "FAIL: adding a package the acme index does not serve must fail" - cat add-hint.out; exit 1 -fi -grep -q "compat.gadget (2.0.0)" add-hint.out || { - echo "FAIL: add hint must name compat.gadget with its published version" - cat add-hint.out; exit 1; } -# And the manifest must NOT have been mutated by the failed add. -if grep -q "gadget" mcpp.toml; then - echo "FAIL: a rejected add must not write the dependency"; cat mcpp.toml; exit 1 -fi - -echo "PASS 162_bare_name_namespace_scope" diff --git a/tests/e2e/163_identity_first_resolution.sh b/tests/e2e/163_identity_first_resolution.sh deleted file mode 100755 index e1be88561..000000000 --- a/tests/e2e/163_identity_first_resolution.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 163_identity_first_resolution.sh — SPEC-001 §3.4 / §6: a package is found by -# its DECLARED identity `(namespace, name)`, and addressed on the wire by the -# LITERAL `package.name`. -# -# Three properties, none of which held before mcpp 0.0.106: -# -# 1. A short-name descriptor (`namespace="acme", name="widget"`) installs. -# Previously mcpp re-derived the wire name as `.` and asked the -# index for `acme.widget`, which it is not keyed by → E_NOT_FOUND (#278). -# 2. The filename is only a hint. A descriptor called anything, in any letter -# directory, is still discovered by its declared identity. Previously -# discovery probed a fixed candidate-filename list, so such a file was -# invisible even though the identity gate would have accepted it. -# 3. Two packages sharing a short name under different namespaces coexist in -# ONE index and are each addressable. This needs xlings >= 0.4.69 -# (openxlings/xlings#381); mcpp's part is sending `:` -# rather than a reconstructed string. -# -# The legacy fully-qualified spelling must keep working throughout — every -# currently published descriptor uses it. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# A real, downloadable payload so this exercises the whole install path rather -# than stopping at resolution. Reuse whatever asio the registry already has. -SRC="" -for c in "$MCPP_HOME/indices/mcpplibs/pkgs/c/chriskohlhoff.asio.lua"; do - [ -f "$c" ] && SRC="$c" && break -done -if [ -z "$SRC" ]; then - echo "SKIP 163_identity_first_resolution (no asio descriptor in registry)" - exit 77 -fi - -mkidx() { # mkidx - mkdir -p "$1/pkgs/$(dirname "$2")" - # Rewrite the identity fields by SHAPE, never by their current values. The - # first version of this matched the literal `name = "chriskohlhoff.asio"`, - # which silently stopped matching the day mcpp-index migrated that - # descriptor to its SPEC-001 short name. The fixture then carried the WRONG - # identity and this test failed on three platforms for a reason that had - # nothing to do with what it asserts. - sed -e "s/^\( *namespace *= *\)\"[^\"]*\"/\1\"$3\"/" \ - -e "s/^\( *name *= *\)\"[^\"]*\"/\1\"$4\"/" \ - "$SRC" > "$1/pkgs/$2" - # Fail loudly rather than go on to test a fixture that isn't what we asked - # for — a missed rewrite must never masquerade as a resolution bug. - grep -qE "^ *namespace *= *\"$3\"" "$1/pkgs/$2" || { - echo "FAIL: fixture namespace rewrite missed (upstream descriptor shape changed?)" - exit 1; } - grep -qE "^ *name *= *\"$4\"" "$1/pkgs/$2" || { - echo "FAIL: fixture name rewrite missed (upstream descriptor shape changed?)" - exit 1; } -} - -mkapp() { # mkapp - mkdir -p "$1/src" - local index_host - index_host="$(host_path "$3")" - cat > "$1/mcpp.toml" < "$1/src/main.cpp" -} - -# ── 1. short-name descriptor installs ─────────────────────────────── -mkidx idx1 a/acme.widget.lua acme widget -mkapp app1 acme ../idx1 acme widget -(cd app1 && "$MCPP" build -v > out.txt 2>&1) || { cat app1/out.txt; exit 1; } -# Either verb: what this test is about is that the descriptor was found BY -# IDENTITY and the package became part of the build. Whether its objects were -# compiled here or served from the global build cache is a different subsystem's -# business — and now that the cache actually works, a sibling app dir under the -# same MCPP_HOME (or a restored CI sandbox) can legitimately supply them. -grep -qE "(Compiling|Cached) acme\.widget" app1/out.txt || { - cat app1/out.txt - echo "FAIL: acme.widget was neither compiled nor served from cache" - exit 1 -} -# xlings names the dir {namespace}-x-{literal name} — short name → acme-x-widget -test -d "app1/.mcpp/.xlings/data/xpkgs/acme-x-widget" \ - || { echo "FAIL: expected store dir acme-x-widget"; ls -R app1/.mcpp/.xlings/data/xpkgs 2>/dev/null; exit 1; } - -# ── 2. legacy fully-qualified spelling still installs ─────────────── -mkidx idx2 a/acme.widget.lua acme acme.widget -mkapp app2 acme ../idx2 acme widget -(cd app2 && "$MCPP" build > out.txt 2>&1) || { cat app2/out.txt; exit 1; } -# ...and lands under the dir its LITERAL name implies -test -d "app2/.mcpp/.xlings/data/xpkgs/acme-x-acme.widget" \ - || { echo "FAIL: expected store dir acme-x-acme.widget"; exit 1; } - -# ── 3. filename is only a hint ────────────────────────────────────── -# Arbitrary basename, and deliberately the "wrong" letter directory. -mkidx idx3 z/totally-unrelated-name.lua acme widget -mkapp app3 acme ../idx3 acme widget -(cd app3 && "$MCPP" build > out.txt 2>&1) || { - echo "FAIL: descriptor must be discoverable by identity, not filename" - cat app3/out.txt; exit 1; } - -# ── 4. two namespaces, one short name, ONE index ──────────────────── -# Needs xlings >= 0.4.69 (openxlings/xlings#381): before it, one index repo -# keyed its table by the bare `package.name`, so the second package was -# unreachable even with an explicit namespace. -# -# The sandbox xlings comes from bootstrapping the PREVIOUS mcpp release, so -# until 0.0.106 ships (bundling 0.4.69) CI still runs 0.4.68 here. Skip this -# sub-case rather than assert a capability the sandbox cannot have yet — -# properties 1-3 above are the ones this change is responsible for. -XLBIN="${MCPP_HOME:-$HOME/.mcpp}/registry/bin/xlings" -XLVER="" -[ -x "$XLBIN" ] && XLVER=$("$XLBIN" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) -xl_ge_0469() { - [ -n "$XLVER" ] || return 1 - printf '%s\n0.4.69\n' "$XLVER" | sort -V | head -1 | grep -qx "0.4.69" -} -if ! xl_ge_0469; then - echo "PASS 163_identity_first_resolution (step 4 skipped: sandbox xlings ${XLVER:-unknown} < 0.4.69)" - exit 0 -fi - -mkidx idx4 a/alpha.widget.lua alpha widget -mkidx idx4 b/beta.widget.lua beta widget - -mkapp app4a alpha ../idx4 alpha widget -(cd app4a && "$MCPP" build > out.txt 2>&1) || { cat app4a/out.txt; exit 1; } -test -d "app4a/.mcpp/.xlings/data/xpkgs/alpha-x-widget" \ - || { echo "FAIL: alpha:widget should land in alpha-x-widget"; exit 1; } - -mkapp app4b beta ../idx4 beta widget -(cd app4b && "$MCPP" build > out.txt 2>&1) || { - echo "FAIL: beta:widget unreachable — needs xlings >= 0.4.69 (#381)" - cat app4b/out.txt; exit 1; } -test -d "app4b/.mcpp/.xlings/data/xpkgs/beta-x-widget" \ - || { echo "FAIL: beta:widget should land in beta-x-widget"; exit 1; } - -echo "PASS 163_identity_first_resolution" diff --git a/tests/e2e/164_ninja_goal_command_length.sh b/tests/e2e/164_ninja_goal_command_length.sh deleted file mode 100755 index d358cbd15..000000000 --- a/tests/e2e/164_ninja_goal_command_length.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 164_ninja_goal_command_length.sh — an explicit ninja goal set must travel -# through the manifest, never on the command line. -# -# `mcpp test` names every shared prerequisite as a goal so that a broken package -# source fails once as a package error instead of N times as identical per-test -# compile failures. For a large package that is thousands of object paths: -# FFmpeg's 2281 translation units produced a 50,781-character argv. Windows -# joins argv into one command string for cmd.exe, which caps at 8191 characters -# — the command never ran and a bare 127 came back, from cmd.exe, with no output -# from either ninja or mcpp. mcpp-index's Windows job failed this way on the -# ffmpeg member from 0.0.104 (which introduced explicit goals) onward. -# -# The fix routes the goal set into build.ninja as a phony edge and puts one word -# on the command line. This test asserts that mechanism rather than the 8191 -# limit itself: reproducing the limit needs a package big enough to be far too -# slow here, and the limit does not exist on Linux/macOS at all. What is -# portable — and what actually regressed — is "goals go in the manifest". -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p proj/src proj/tests -cat > proj/mcpp.toml <<'EOF' -[package] -name = "goalset" -version = "0.1.0" -EOF - -# Enough units that a regression would be visible as a long argv, while still -# building in a couple of seconds. -for i in $(seq 1 30); do - echo "int unit_$i() { return $i; }" > "proj/src/unit_$i.cpp" -done -echo 'int main() { return 0; }' > proj/src/main.cpp -cat > proj/tests/sanity.cpp <<'EOF' -extern int unit_1(); -int main() { return unit_1() == 1 ? 0 : 1; } -EOF - -cd proj -"$MCPP" test > test.log 2>&1 || { cat test.log; echo "FAIL: mcpp test"; exit 1; } -grep -q "sanity ... ok" test.log || { cat test.log; exit 1; } - -# The test graph is test.ninja, beside build.ninja (2026.11.10.1). -NINJA=$(find target -name test.ninja -not -path '*/.build-mcpp/*' | head -1) -[ -n "$NINJA" ] || { echo "FAIL: no test.ninja"; exit 1; } - -# The phony edge is how the goal set reaches ninja. -grep -q "^build mcpp-requested-goals : phony " "$NINJA" || { - echo "FAIL: explicit goals must be aggregated into a phony edge" - grep -n "phony" "$NINJA" | head; exit 1; } - -# And the goals themselves must not have been left on a command line. mcpp -# records the ninja program it ran in target/.build_cache; the goal set never -# belongs there either. -if [ -f target/.build_cache ]; then - if grep -qE 'obj/.*obj/.*obj/' target/.build_cache; then - echo "FAIL: goal paths leaked into the recorded ninja invocation" - cat target/.build_cache; exit 1 - fi -fi - -echo "PASS 164_ninja_goal_command_length" diff --git a/tests/e2e/165_bare_name_cross_namespace_wire_address.sh b/tests/e2e/165_bare_name_cross_namespace_wire_address.sh deleted file mode 100755 index 19e7cf16f..000000000 --- a/tests/e2e/165_bare_name_cross_namespace_wire_address.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 165_bare_name_cross_namespace_wire_address.sh — SPEC-001 §6: the descriptor -# the identity gate accepted supplies BOTH halves of the wire address. -# -# THE INCIDENT THIS LOCKS DOWN (2026-07-25, 6 of 7 workflows red on main): -# -# An explicitly selected compat dependency must carry the descriptor's -# namespace into the xlings wire address. The historical bug mixed the request -# and descriptor halves and emitted `mcpplibs:gtest`. -# -# It passed for months by coincidence: the literal `package.name` used to read -# `compat.gtest`, so the hardcoded `compat.` retry caught it. When -# mcpp-index migrated to SPEC-001 short names (`name = "gtest"`), that -# coincidence vanished and every bare-name dependency served by a compat -# descriptor became uninstallable — 34 of the index's 48 packages. -# -# WHY THIS TEST IS HERMETIC. The index side validated that migration against -# its own examples, all of which spell the dependency `[dependencies.compat]` -# — the one form that was never broken. Verification matrix and consumption -# matrix were not the same shape, so 8 green checks meant nothing. This test -# builds its own index so it cannot drift with the live one, and it asserts on -# the address mcpp SENDS, not just on whether the install happened to work. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# Reuse a real, downloadable descriptor so this exercises the whole install -# path rather than stopping at resolution. -SRC="$MCPP_HOME/indices/mcpplibs/pkgs/c/chriskohlhoff.asio.lua" -if [ ! -f "$SRC" ]; then - echo "SKIP 165_bare_name_cross_namespace_wire_address (no asio descriptor in registry)" - exit 77 -fi - -# A `compat`-namespaced package named by its SPEC-001 short name. The -# `compat..lua` filename mirrors the live index, but selection is by the -# descriptor's exact declared identity, never by a bare-name fallback. -mkdir -p idx/pkgs/c -sed -e 's/^\( *namespace *= *\)"[^"]*"/\1"compat"/' \ - -e 's/^\( *name *= *\)"[^"]*"/\1"widget"/' \ - "$SRC" > idx/pkgs/c/compat.widget.lua -grep -qE '^ *namespace *= *"compat"' idx/pkgs/c/compat.widget.lua || { - echo "FAIL: fixture namespace rewrite missed (upstream descriptor shape changed?)"; exit 1; } -grep -qE '^ *name *= *"widget"' idx/pkgs/c/compat.widget.lua || { - echo "FAIL: fixture name rewrite missed (upstream descriptor shape changed?)"; exit 1; } - -# The consumer writes the compat namespace explicitly and routes it to the -# fixture index, so the whole thing stays offline. -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[indices] -compat = { path = "../idx" } - -[dependencies.compat] -widget = "1.38.1" -EOF -echo 'int main() { return 0; }' > app/src/main.cpp - -set +e -(cd app && MCPP_VERBOSE=1 "$MCPP" build > out.txt 2>&1) -build_rc=$? -set -e - -# ── The assertion that matters: which address went on the wire ────── -# -# Checked independently of whether the install then succeeded, so this keeps -# reporting the real defect even when the network or the sandbox xlings is the -# thing that failed. -# -# Read the `"targets":[...]` payload specifically rather than grepping the log -# as a whole — the human-facing "Downloading …" and error lines carry the -# resolved identity `mcpplibs.widget`, which is correct there and would make a -# looser assertion answer the wrong question. -# Un-escape first: the interface command is single-quoted on unix -# (--args '{"targets":[...]}') but double-quoted with backslash-escaped quotes -# on Windows (--args "{\"targets\":[...]}"). Normalising makes one pattern -# work on both instead of silently matching nothing on one of them. -sed 's/\\"/"/g' app/out.txt | grep -oE '"targets":\["[^"]*"\]' > targets.txt || true -test -s targets.txt || { - echo "FAIL: no install target was emitted at all"; cat app/out.txt; exit 1; } -if grep -q '"mcpplibs:widget@1\.38\.1"' targets.txt; then - echo "FAIL: wire address took its namespace from the REQUEST (mcpplibs:widget)" - echo " The descriptor declares namespace=\"compat\"; the address must be compat:widget." - cat targets.txt - exit 1 -fi -grep -q '"compat:widget@1\.38\.1"' targets.txt || { - echo "FAIL: expected the wire address compat:widget@1.38.1" - cat targets.txt - exit 1; } - -# ── And that the package actually installs under that identity ────── -if [ $build_rc -ne 0 ]; then - echo "FAIL: build failed even though the wire address was correct" - cat app/out.txt - exit 1 -fi - -# xlings names an xpkg directory {namespace}-x-{literal name}. Search for it by -# NAME rather than at a fixed path — the store root differs per platform, and -# the directory's name is the whole point of the assertion. -find app/.mcpp -type d -name "compat-x-widget" 2>/dev/null | grep -q . || { - echo "FAIL: expected a store dir named compat-x-widget" - find app/.mcpp -type d -name "*-x-*" 2>/dev/null | head -10 - exit 1; } - -echo "PASS 165_bare_name_cross_namespace_wire_address" diff --git a/tests/e2e/166_run_env_no_private_glibc.sh b/tests/e2e/166_run_env_no_private_glibc.sh deleted file mode 100755 index c855ad328..000000000 --- a/tests/e2e/166_run_env_no_private_glibc.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -# requires: -# mcpp#291 — a plain binary must NOT be handed the private glibc payload on -# LD_LIBRARY_PATH. -# -# That variable is inherited by the entire process subtree. When the target is -# something like a course provider that shells out (popen("mcpp test ...")), -# /bin/sh is a HOST binary: its PT_INTERP is baked in, so it loads the HOST -# ld.so while this variable hands it the PAYLOAD libc.so.6. glibc's libc and -# ld.so are version-locked to each other via GLIBC_PRIVATE, so on any host -# whose glibc differs from the payload's the shell dies of SIGSEGV inside the -# dynamic linker — before main, with empty stdout and no diagnostic. -# -# The payload dir belongs on LD_LIBRARY_PATH only when the build actually has -# a dlopen()-reachable dependency library (whose own DT_NEEDED closure cannot -# see the executable's RUNPATH). A project with no such dependency must get -# nothing. -# -# ASSERTS ON THE EMITTED ENVIRONMENT, not on whether a shell crashes: the crash -# needs host glibc != payload glibc. On a matching host (the common CI case) -# a crash-based test passes for the wrong reason and would never have caught -# this in the first place. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -mkdir -p pkg/src -cd pkg -cat > mcpp.toml <<'EOF' -[package] -name = "envprobe" -version = "0.1.0" -standard = "c++23" -EOF - -# Print the loader path variable exactly as the run target receives it. -cat > src/main.cpp <<'EOF' -#include -#include -int main() { - const char* p = std::getenv("LD_LIBRARY_PATH"); - std::printf("LDLP=[%s]\n", p ? p : ""); - return 0; -} -EOF - -out=$("$MCPP" run 2>&1) || { echo "FAIL: mcpp run failed"; echo "$out"; exit 1; } - -line=$(printf '%s\n' "$out" | grep -oE 'LDLP=\[[^]]*\]' | head -1) -[ -n "$line" ] || { echo "FAIL: probe never printed LDLP"; echo "$out"; exit 1; } -echo "observed: $line" - -# The specific poison: the private glibc payload store directory. -if printf '%s' "$line" | grep -q 'xim-x-glibc'; then - echo "FAIL: the run target was handed the private glibc payload on LD_LIBRARY_PATH." - echo " $line" - echo " A binary with no dlopen-reachable dependency must not get it —" - echo " it propagates to every descendant process, including host shells." - exit 1 -fi - -# ── The other half. This test used to require the OPPOSITE here: with a -# dlopen-reachable dependency library present, the payload dir had to BE on -# LD_LIBRARY_PATH, "because dlopen'd libraries do not consult the executable's -# RUNPATH". The guard was right to exist — it stopped anyone from "fixing" -# mcpp#291 by deleting the entry and quietly breaking dlopen — but it asserted -# the MECHANISM instead of the capability, and the mechanism was wrong: -# -# * a dlopen() performed by the executable DOES consult the executable's -# DT_RUNPATH, and the link model already puts the payload glibc there -# (measured: the artifact's RUNPATH is byte-identical with and without the -# environment entry); -# * so the entry bought nothing, while reaching every descendant process — -# and on a payload whose GLIBC_PRIVATE needs match its own loader, that -# kills /bin/sh outright (mcpp#401, glibc 2.44). -# -# The capability it was really protecting now lives in -# 208_private_libc_stays_in_the_binary.sh, which asserts the dlopen actually -# resolves and that the directory is in the artifact RUNPATH. What belongs -# here is the rule this test is named for, applied to BOTH shapes: the private -# libc is never handed to the process environment. -GLIBC_STORE=$(ls -d "$HOME"/.mcpp/registry/data/xpkgs/xim-x-glibc/*/ 2>/dev/null | head -1) -if [ -z "$GLIBC_STORE" ]; then - echo "SKIP (second half): no private glibc payload installed" - echo OK - exit 77 -fi - -cd "$TMP" -mkdir -p pkg2/src pkg2/runtime -cd pkg2 -cat > mcpp.toml <<'EOF' -[package] -name = "envprobe2" -version = "0.1.0" -standard = "c++23" - -[runtime] -library_dirs = ["runtime"] -EOF -cp ../pkg/src/main.cpp src/main.cpp - -out2=$("$MCPP" run 2>&1) || { echo "FAIL: mcpp run failed (second half)"; echo "$out2"; exit 1; } -line2=$(printf '%s\n' "$out2" | grep -oE 'LDLP=\[[^]]*\]' | head -1) -echo "observed (with [runtime] library_dirs): $line2" - -if printf '%s' "$line2" | grep -q 'xim-x-glibc'; then - echo "FAIL: a build WITH a dlopen-reachable dependency library dir still put" - echo " the private glibc payload on LD_LIBRARY_PATH. That variable" - echo " reaches every descendant process, including host shells the host" - echo " loader loads — see mcpp#401." - echo " $line2" - exit 1 -fi - -# The project's own runtime dir is a plain shared-library directory with no -# loader coupling, so it keeps its environment scope. Losing it here would mean -# the entry was dropped wholesale rather than narrowed to the private libc. -printf '%s' "$line2" | grep -q 'runtime' || { - echo "FAIL: the project's own [runtime] library_dirs entry disappeared too." - echo " Only the private libc is binary-scoped; ordinary dependency" - echo " runtime dirs still belong in the environment." - echo " $line2" - exit 1; } - -echo OK diff --git a/tests/e2e/167_build_defines_module_scan.sh b/tests/e2e/167_build_defines_module_scan.sh deleted file mode 100644 index d1a9bcd64..000000000 --- a/tests/e2e/167_build_defines_module_scan.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 167_build_defines_module_scan.sh — mcpp#296: `[build].defines` is the -# package-level macro channel. It must reach EVERY TU (module interface units -# included) on BOTH the compile edge and the P1689 module scan, and the cfg -# axis must be able to carry it like any other build input. -# -# The original report: an `import` guarded by a `[build].defines` macro was -# invisible to the scanner while `scan_overrides` asserted it existed, so the -# build died with "module-graph divergence" naming neither the key nor the -# manifest — because the key was read by nothing and dropped in silence. -# -# Deliberately toolchain-neutral: the module-graph half uses a LOCAL module -# rather than `import std`, so this runs on every platform — which matters, -# since #296 was reported on Windows. Macro delivery is asserted with #error -# guards, so the compiler itself is the assertion (cf. 85_target_cfg_build_flags). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── 1. the #296 shape: a macro-guarded import must reach the P1689 scan ── -# scan_overrides declares that `mid` imports `lib`. That only holds if -# -DUSE_LIB reached the SCAN of mid.cppm; otherwise the scanner sees no import -# and the planner's assumption diverges from the compiler's. -mkdir -p scan/src -cat > scan/src/lib.cppm <<'EOF' -export module lib; -export int lib_value() { return 42; } -EOF -cat > scan/src/mid.cppm <<'EOF' -export module mid; -#ifdef USE_LIB -import lib; -#else -#error "[build].defines did not reach the module interface unit mid.cppm" -#endif -export int mid_value() { return lib_value(); } -EOF -cat > scan/src/main.cpp <<'EOF' -import mid; -// The same macro must reach an ordinary TU, and must carry a value and a -// value CONTAINING A SPACE (the -D quoting path, where the space is part of -// one argv token rather than a token boundary — mcpp#234). -#ifndef USE_LIB -#error "[build].defines did not reach main.cpp" -#endif -#if LEVEL != 3 -#error "[build].defines dropped or mangled a NAME=value entry" -#endif -BIGINT wide = 1; -int main() { return (mid_value() == 42 && wide == 1) ? 0 : 1; } -EOF -cat > scan/mcpp.toml <<'EOF' -[package] -name = "scan" -version = "0.1.0" - -[build] -defines = ["USE_LIB", "LEVEL=3", "BIGINT=long long"] - -[scan_overrides."src/lib.cppm"] -provides = ["lib"] - -[scan_overrides."src/mid.cppm"] -provides = ["mid"] -imports = ["lib"] - -[scan_overrides."src/main.cpp"] -imports = ["mid"] -EOF - -cd scan -"$MCPP" build > build.log 2>&1 || { cat build.log - echo "FAIL: [build].defines did not reach the scan and/or the compile"; exit 1; } -# Run on its own line, not inside a pipeline: `x=$(cmd | tail)` takes the exit -# status of `tail`, so a crashing binary would be reported as a string mismatch -# (or not at all). -"$MCPP" run > run.log 2>&1 || { cat run.log; echo "FAIL: built binary did not run"; exit 1; } -cd "$TMP" - -# ── 2. the cfg axis carries `defines` too (#296 regression guard) ── -# `defines` is a BuildInputs member, so `[target.'cfg(...)'.build]` must merge -# it. HOST-AWARE, like 85_target_cfg_build_flags.sh: exactly the host's -# predicates apply, on whichever platform the runner is. -mkdir -p cond/src -cat > cond/src/main.cpp <<'EOF' -#if (defined(COND_LINUX) + defined(COND_MACOS) + defined(COND_WIN)) != 1 -#error "exactly one conditional [build].defines entry must apply on any host" -#endif -#ifndef BASE_DEFINE -#error "the unconditional [build].defines entry was lost when a cfg section merged" -#endif -int main() { return 0; } -EOF -cat > cond/mcpp.toml <<'EOF' -[package] -name = "cond" -version = "0.1.0" - -[build] -defines = ["BASE_DEFINE"] - -[target.'cfg(linux)'.build] -defines = ["COND_LINUX"] -[target.'cfg(macos)'.build] -defines = ["COND_MACOS"] -[target.'cfg(windows)'.build] -defines = ["COND_WIN"] -EOF -cd cond -"$MCPP" build > build.log 2>&1 || { cat build.log - echo "FAIL: conditional [build].defines did not reach the TU"; exit 1; } -cd "$TMP" - -# ── 3. a DEPENDENCY's own [build].defines reaches the dependency's TUs ── -# The root package must not be the only one folded: prepare_build folds at the -# path/git-dep site too, mirroring the #229 conditional-merge funnel. -mkdir -p app/src dep/src -cat > dep/src/dep.cppm <<'EOF' -export module dep; -#ifndef DEP_OWN_DEFINE -#error "the dependency's own [build].defines did not reach its module unit" -#endif -export int dep_value() { return 7; } -EOF -cat > dep/mcpp.toml <<'EOF' -[package] -name = "dep" -version = "0.1.0" - -[build] -defines = ["DEP_OWN_DEFINE"] - -[targets.dep] -kind = "lib" -EOF -cat > app/src/main.cpp <<'EOF' -import dep; -// A dependency's defines are PRIVATE build inputs — they must not leak into -// the consumer (propagating macros is what feature `defines` are for). -#ifdef DEP_OWN_DEFINE -#error "a dependency's [build].defines leaked into the consumer" -#endif -int main() { return dep_value() == 7 ? 0 : 1; } -EOF -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -dep = { path = "../dep" } -EOF -cd app -"$MCPP" build > build.log 2>&1 || { cat build.log - echo "FAIL: a dependency's [build].defines did not reach its own TUs"; exit 1; } -cd "$TMP" - -# ── 4. an unknown [build] key is reported, not silently dropped ── -# The root cause of #296: `defines` was not a key at all, and [build] swallowed -# it without a word. Same policy as [targets.] — a warning, an error -# under --strict. -mkdir -p unknown/src -cat > unknown/src/main.cpp <<'EOF' -int main() { return 0; } -EOF -cat > unknown/mcpp.toml <<'EOF' -[package] -name = "unknown" -version = "0.1.0" - -[build] -defnes = ["TYPO"] -EOF -cd unknown -"$MCPP" build > build.log 2>&1 || { cat build.log - echo "FAIL: an unknown [build] key must warn, not fail the build"; exit 1; } -grep -q "defnes" build.log || { - cat build.log; echo "FAIL: unknown [build] key 'defnes' was dropped in silence"; exit 1; } -if "$MCPP" build --strict > strict.log 2>&1; then - cat strict.log - echo "FAIL: --strict must reject an unknown [build] key"; exit 1 -fi -grep -q "defnes" strict.log || { - cat strict.log; echo "FAIL: --strict rejection did not name the offending key"; exit 1; } - -echo "OK" diff --git a/tests/e2e/169_semver_project_index.sh b/tests/e2e/169_semver_project_index.sh deleted file mode 100755 index 2da0d9472..000000000 --- a/tests/e2e/169_semver_project_index.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# #308: a SemVer constraint on a package served by a project `[indices]` entry -# must resolve. Version resolution used to read the shared registry directly -# instead of routing like candidate selection does, so the exact-version form -# (`gadget = "2.0.0"`) worked while the constraint form (`gadget = "^2.0"`) failed -# with "run `mcpp index update` first" — advice that does nothing for a local -# path index. Both forms are asserted here so the two cannot drift apart again. -# -# No network: the index is a local path and the package payload is pre-seeded -# into the project's xlings data dir, so nothing is ever downloaded. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj" -cd "$TMP/proj" - -# ── A project-local index carrying acme.gadget 2.0.0 and 2.1.0 ──────────── -mkdir -p local-index/pkgs/a -cat > local-index/pkgs/a/acme.gadget.lua <<'EOF' -package = { - spec = "1", - namespace = "acme", - name = "acme.gadget", - description = "Package reachable only through a project [indices] entry", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["2.0.0"] = { - url = "https://example.invalid/gadget-2.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - ["2.1.0"] = { - url = "https://example.invalid/gadget-2.1.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - macosx = { - ["2.0.0"] = { - url = "https://example.invalid/gadget-2.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - ["2.1.0"] = { - url = "https://example.invalid/gadget-2.1.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - windows = { - ["2.0.0"] = { - url = "https://example.invalid/gadget-2.0.0.zip", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - ["2.1.0"] = { - url = "https://example.invalid/gadget-2.1.0.zip", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = false, - sources = { "src/gadget.cppm" }, - targets = { ["gadget"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -# ── Pre-seed the payload so resolution is the only thing under test ───── -mkdir -p .mcpp/.xlings/data/xpkgs/acme.gadget/2.1.0/src -cat > .mcpp/.xlings/data/xpkgs/acme.gadget/2.1.0/src/gadget.cppm <<'EOF' -export module gadget; - -export int gadget_value() { - return 42; -} -EOF - -mkdir -p src -cat > src/main.cpp <<'EOF' -import gadget; - -int main() { - return gadget_value() == 42 ? 0 : 1; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "proj" -version = "0.1.0" - -[indices] -acme = { path = "local-index" } - -[dependencies.acme] -gadget = "^2.0" - -[targets.proj] -kind = "bin" -main = "src/main.cpp" -EOF - -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "FAIL: SemVer constraint did not resolve against the project [indices] entry" - exit 1 -} - -# The constraint must have been pinned to the newest matching version, not -# merely tolerated. -grep -q '→ v2.1.0' build.log || { - cat build.log - echo "FAIL: expected '^2.0' to resolve to v2.1.0" - exit 1 -} -grep -q 'index update' build.log && { - cat build.log - echo "FAIL: a local path index must never be answered with 'mcpp index update'" - exit 1 -} -grep -q '\[package\."acme.gadget"\]' mcpp.lock || { - cat mcpp.lock 2>/dev/null || true - echo "FAIL: expected acme.gadget lock entry" - exit 1 -} -# Only 2.1.0 is seeded above, so a build that got here at all consumed the -# resolved version rather than falling back to the constraint's lower bound. -# The lock records that resolution (mcpp#363 — it used to record the constraint -# `^2.0`, which locks nothing); 196 owns the full set of assertions about it. -grep -q 'version = "2.1.0"' mcpp.lock || { - cat mcpp.lock - echo "FAIL: the lock must record the resolved version, not the constraint" - exit 1 -} -# The digest is FNV-1a on EVERY host. It used `std::hash`, whose -# output is implementation-defined (MSVC FNV-1a vs libstdc++/libc++ MurmurHash), -# so the same dependency produced a different mcpp.lock on Windows and Linux. -# `acme:acme.gadget@2.1.0` is pinned here; a host-dependent hash fails on one of -# the two platforms rather than silently drifting. -grep -q 'hash = "fnv1a:2adea846f70078bc"' mcpp.lock || { - cat mcpp.lock - echo "FAIL: acme.gadget 2.1.0 lock hash is not the host-independent FNV-1a" - exit 1 -} - -"$MCPP" run > run.log 2>&1 || { cat run.log; echo "FAIL: run failed"; exit 1; } - -# ── The exact-version form keeps working through the same route ───────── -mkdir -p .mcpp/.xlings/data/xpkgs/acme.gadget/2.0.0/src -cp .mcpp/.xlings/data/xpkgs/acme.gadget/2.1.0/src/gadget.cppm \ - .mcpp/.xlings/data/xpkgs/acme.gadget/2.0.0/src/gadget.cppm -sed -i.bak 's/^gadget = "\^2\.0"$/gadget = "2.0.0"/' mcpp.toml && rm -f mcpp.toml.bak -grep -qE '^gadget = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "FAIL: rewrite to exact version did not apply"; exit 1; } -rm -f mcpp.lock -"$MCPP" build > build2.log 2>&1 || { - cat build2.log - echo "FAIL: exact version through a project [indices] entry regressed" - exit 1 -} -grep -q '2\.0\.0' mcpp.lock || { cat mcpp.lock; echo "FAIL: expected 2.0.0 pin"; exit 1; } -# Same host-independent digest contract for the exact-version form. -grep -q 'hash = "fnv1a:34544b46fc9621c3"' mcpp.lock || { - cat mcpp.lock - echo "FAIL: acme.gadget 2.0.0 lock hash is not the host-independent FNV-1a" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/16_test_failing.sh b/tests/e2e/16_test_failing.sh deleted file mode 100755 index 9be4c7d87..000000000 --- a/tests/e2e/16_test_failing.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# requires: scan-deps -# 1 ok + 1 fail → mcpp test exits 1, summary lists failures. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp - -# Replace bundled smoke with one that fails. -cat > tests/test_fail.cpp <<'EOF' -int main() { return 1; } -EOF - -# Run; expect non-zero exit -rc=0 -out=$("$MCPP" test 2>&1) || rc=$? - -[[ $rc -ne 0 ]] || { echo "expected non-zero exit, got 0"; exit 1; } -echo "$out" | grep -q 'test_fail ... FAIL' || { echo "missing FAIL marker: $out"; exit 1; } -echo "$out" | grep -q 'test_smoke ... ok' || { echo "smoke should still pass: $out"; exit 1; } -echo "$out" | grep -q 'failures:' || { echo "missing failures list: $out"; exit 1; } -echo "$out" | grep -q '1 passed; 1 failed' || { echo "missing summary: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/170_bmi_staging_no_cascade.sh b/tests/e2e/170_bmi_staging_no_cascade.sh deleted file mode 100755 index 2ca0ed3e9..000000000 --- a/tests/e2e/170_bmi_staging_no_cascade.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash -# requires: -# resources: machine -# 170_bmi_staging_no_cascade.sh — mcpp#311: the std BMI staging edge. -# -# Two invariants, both regressions that shipped: -# -# 1. Re-staging an UNCHANGED std BMI must not recompile anything. The old -# `cp -f` / `Copy-Item -Force` rule rewrote the file unconditionally and -# carried no `restat`, so every importer of `import std` rebuilt whenever -# the cache-side BMI got a newer mtime (which happens on any cwd change -# before the cache-root fix below). -# 2. The staging SOURCE must live under the cache root mcpp itself reports. That cache -# used to resolve its root through a private copy of the home logic that -# knew neither %USERPROFILE% nor self-contained installs, so on Windows it -# parked the cache in the current working directory as `.mcpp-bmi/`. -# -# Toolchain-neutral: only needs a package that says `import std;`. -set -e - -# build.ninja node names are ninja-ESCAPED: on Windows a drive letter arrives as -# `C$:/Users/...`. Unescape before touching the filesystem (the Windows job -# failed on exactly this). -unescape_ninja() { printf '%s' "$1" | sed 's/\$:/:/g; s/\$\$/$/g'; } -# Compare paths across the Windows fork: MCPP_HOME is `C:\Users\...` while ninja -# writes forward slashes. -norm_path() { printf '%s' "$1" | tr '\\' '/'; } - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -import std; -int main() { std::println("staged"); return 0; } -EOF -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF - -cd app -"$MCPP" build > build1.log 2>&1 || { cat build1.log; echo "FAIL: initial build"; exit 1; } - -NINJA=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -[[ -n "$NINJA" ]] || { echo "FAIL: no build.ninja produced"; exit 1; } - -# Parse the staging edge, NOT the rule declaration: `awk '{print $NF}'` over a -# grep for the rule NAME would return the literal rule name. -EDGE=$(grep -E '^build [^ ]+ : stage_file ' "$NINJA" | head -1) -[[ -n "$EDGE" ]] || { - grep -n "std" "$NINJA" | head -20 - echo "FAIL: no stage_file edge for the std BMI (did import std resolve?)"; exit 1; } -DST=$(unescape_ninja "$(echo "$EDGE" | awk '{print $2}')") -SRC=$(unescape_ninja "$(echo "$EDGE" | awk '{print $NF}')") -echo "staging: $SRC -> $DST" - -# ── invariant 2: the staging SOURCE lives under the tool's OWN cache root, and -# never in a cwd-local directory (nor in the pre-v1 bmi/ tree, which nothing -# reads now) ── -# -# The root is read from `mcpp cache dir` rather than re-derived here as -# ${MCPP_HOME:-$HOME/.mcpp}/build-cache/v1. That re-derivation is wrong for a -# SELF-CONTAINED install, where the unpacked tree itself is the home — which is -# the shape of every release tarball and every `xlings install mcpp`. Re-deriving -# it made this test fail against a released binary while the binary was right, -# and "where is the cache" having two answers in two places is the exact defect -# #311 spent a module (mcpp.home) removing. -CACHE_ROOT=$(norm_path "$("$MCPP" cache dir | head -1)") -[[ -n "$CACHE_ROOT" ]] || { echo "FAIL: mcpp cache dir printed nothing"; exit 1; } -case "$(norm_path "$SRC")" in - "$CACHE_ROOT"/std/*) ;; - *) echo "FAIL: std BMI cache is '$SRC', expected under '$CACHE_ROOT/std'" - exit 1 ;; -esac -for leftover in "$TMP/.mcpp-bmi" "$TMP/app/.mcpp-bmi"; do - [[ -d "$leftover" ]] && { echo "FAIL: legacy cache dir created at $leftover"; exit 1; } -done - -# ── invariant 1: a dirty staging edge alone must not cascade ── -# mtime only — never mutate the shared cache's CONTENT from a test. -touch "$SRC" -"$MCPP" build -v > build2.log 2>&1 || { cat build2.log; echo "FAIL: rebuild after re-stage"; exit 1; } - -grep -qE "stage .*--output" build2.log || { - cat build2.log; echo "FAIL: the staging edge did not re-run at all"; exit 1; } -if grep -qE '(-c|/c) .*main\.cpp' build2.log; then - cat build2.log - echo "FAIL: re-staging an unchanged std BMI recompiled main.cpp (cascade)" - exit 1 -fi - -# The staged copy must still be a faithful copy after the no-write path. -# $DST is relative to the build directory (ninja runs with cwd = outputDir). -cmp "$SRC" "$(dirname "$NINJA")/$DST" \ - || { echo "FAIL: staged BMI differs from the cache after staging"; exit 1; } - -# And the edge must not stay dirty forever: ninja's restat bookkeeping settles -# it on the next run. Check the staging edge itself rather than requiring the -# whole graph to print "no work to do": macOS may legitimately rebuild its -# generated iOS init object and relink without touching the settled BMI edge. -# (-v so ninja's own lines reach the log — mcpp only forwards captured output -# on failure otherwise.) -"$MCPP" build -v > build3.log 2>&1 || { cat build3.log; echo "FAIL: third build"; exit 1; } -if grep -qE "stage .*--output" build3.log; then - cat build3.log - echo "FAIL: staging edge stayed dirty after a settled re-stage"; exit 1 -fi -if grep -qE '(-c|/c) .*main\.cpp' build3.log; then - cat build3.log - echo "FAIL: settled staging edge recompiled main.cpp on the third build" - exit 1 -fi - -echo "OK" diff --git a/tests/e2e/171_bmi_staging_locked_dest.sh b/tests/e2e/171_bmi_staging_locked_dest.sh deleted file mode 100755 index f35a0effe..000000000 --- a/tests/e2e/171_bmi_staging_locked_dest.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 171_bmi_staging_locked_dest.sh — mcpp#311: staging must survive a destination -# another process is holding, and must fail LOUDLY when it genuinely can't. -# -# The reported failure: mcpp writes the staged std BMI path into -# compile_commands.json so clangd can resolve `import std;`, clangd -# memory-maps that file, and the old `Copy-Item -Force` staging step then tried -# to overwrite it in place → Windows error 1224 (a file with a user-mapped -# section open cannot be replaced) → the whole build reported "build failed". -# -# Windows: reproduce that exactly and WITHOUT clangd — a background PowerShell -# holds a MemoryMappedFile on the staged BMI. The holder signals readiness -# through a sentinel file, so a PowerShell that never started fails the test -# instead of letting it pass for the wrong reason. -# -# Note the holder is STRICTER than clangd: CreateFromFile(path, FileMode.Open) -# takes FileShare.None, so the destination cannot even be OPENED — content -# comparison is impossible. Surviving that is why the std staging edges verify -# by SIZE (fingerprint-scoped ⇒ equal size is equivalence, and size comes from -# directory metadata, which needs no open). Passing here therefore implies the -# real clangd case, which only denies writes. -# -# The load-bearing assertion is platform-neutral and root-proof: an equivalent -# destination must not be written AT ALL (same inode, same mtime, same size). -# Permissions alone would not do — root ignores them, and the container e2e job -# runs as root. -set -e - -# build.ninja node names are ninja-ESCAPED: on Windows a drive letter arrives as -# `C$:/Users/...`. Unescape before touching the filesystem (the Windows job -# failed on exactly this). -unescape_ninja() { printf '%s' "$1" | sed 's/\$:/:/g; s/\$\$/$/g'; } -# Compare paths across the Windows fork: MCPP_HOME is `C:\Users\...` while ninja -# writes forward slashes. -norm_path() { printf '%s' "$1" | tr '\\' '/'; } - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -import std; -int main() { std::println("locked"); return 0; } -EOF -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF - -cd app -"$MCPP" build > build1.log 2>&1 || { cat build1.log; echo "FAIL: initial build"; exit 1; } - -NINJA=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -EDGE=$(grep -E '^build [^ ]+ : stage_file ' "$NINJA" | head -1) -[[ -n "$EDGE" ]] || { echo "FAIL: no stage_file edge for the std BMI"; exit 1; } -DST="$(dirname "$NINJA")/$(unescape_ninja "$(echo "$EDGE" | awk '{print $2}')")" -SRC=$(unescape_ninja "$(echo "$EDGE" | awk '{print $NF}')") -[[ -f "$DST" ]] || { echo "FAIL: staged BMI missing at $DST"; exit 1; } - -# GNU stat vs BSD/macOS stat (the macOS job failed on `stat -c`). -identity() { - stat -c '%i %Y %s' "$1" 2>/dev/null || stat -f '%i %m %z' "$1" -} -BEFORE=$(identity "$DST") - -# Dirty the staging edge without touching the shared cache's CONTENT. -touch "$SRC" - -HOLDER="" -case "$(uname -s)" in - MINGW* | MSYS* | CYGWIN*) - WINDST="$(cygpath -w "$DST")" - READY="$TMP/mapped.flag" - WINREADY="$(cygpath -w "$READY")" - powershell -NoProfile -Command \ - "\$mm = [System.IO.MemoryMappedFiles.MemoryMappedFile]::CreateFromFile('$WINDST', [System.IO.FileMode]::Open); \ - Set-Content -Path '$WINREADY' -Value 'mapped'; Start-Sleep -Seconds 120" & - HOLDER=$! - for _ in $(seq 1 40); do [[ -f "$READY" ]] && break; sleep 1; done - [[ -f "$READY" ]] || { - kill "$HOLDER" 2>/dev/null || true - echo "FAIL: could not map the staged BMI — the test would not have proven anything" - exit 1; } - echo "holding a user-mapped section on $WINDST (pid $HOLDER)" - ;; - *) - chmod 444 "$DST" - echo "made $DST read-only" - ;; -esac - -set +e -"$MCPP" build -v > build2.log 2>&1 -rc=$? -set -e -# `wait` on a job we just killed returns 143, and under `set -e` that would -# abort the script right here — with the build's own result never asserted. -# (Exactly how this test failed on Windows the first time it got that far.) -[[ -n "$HOLDER" ]] && { kill "$HOLDER" 2>/dev/null || true; wait "$HOLDER" 2>/dev/null || true; } -chmod 644 "$DST" 2>/dev/null || true - -if [[ $rc -ne 0 ]]; then - cat build2.log - echo "FAIL: staging an already-equivalent BMI failed while the destination was held" - exit 1 -fi -grep -qE "stage .*--output" build2.log || { - cat build2.log; echo "FAIL: the staging edge did not run"; exit 1; } - -# The real assertion: nothing was written. Not "the write succeeded anyway". -AFTER=$(identity "$DST") -[[ "$BEFORE" == "$AFTER" ]] || { - echo "FAIL: an equivalent destination was rewritten (was '$BEFORE', now '$AFTER')" - exit 1; } -cmp "$SRC" "$DST" || { echo "FAIL: held destination no longer matches the cache"; exit 1; } - -cd "$TMP" - -# ── the loud half: a destination that cannot be produced must fail with an -# actionable diagnostic, never be downgraded to a warning ── -echo payload > src.bin -mkdir -p occupied/child -echo keep > occupied/child/keep.txt -set +e -"$MCPP" stage --output occupied src.bin > stage_err.log 2>&1 -rc=$? -set -e -if [[ $rc -eq 0 ]]; then - cat stage_err.log - echo "FAIL: staging onto an unusable destination reported success"; exit 1 -fi -grep -q "help:" stage_err.log || { - cat stage_err.log; echo "FAIL: staging failure carried no hint"; exit 1; } -grep -q "clangd" stage_err.log || { - cat stage_err.log; echo "FAIL: hint does not name the usual holder (clangd)"; exit 1; } -[[ -f occupied/child/keep.txt ]] || { echo "FAIL: failed staging clobbered the destination"; exit 1; } - -echo "OK" diff --git a/tests/e2e/172_build_cache_cross_project.sh b/tests/e2e/172_build_cache_cross_project.sh deleted file mode 100644 index 42e6e0350..000000000 --- a/tests/e2e/172_build_cache_cross_project.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 172_build_cache_cross_project.sh — the global build cache must actually save -# work, and it must save it ACROSS projects. -# -# Two defects met here, and this file is the gate for both. -# -# 1. The key was the whole-project fingerprint, which serializes every package -# in the graph INCLUDING the root — its name, its version, its [build] -# flags. So two projects never shared an entry, and bumping a project's own -# version invalidated every dependency it had. (Measured on one machine: -# 26 GB across 1198 fingerprint directories, `compat.zlib@1.3.2` stored 162 -# times.) -# -# 2. Even on a hit, nothing was saved. Artifacts were copied into the build dir -# from inside prepare_build while those paths stayed declared as compile edge -# outputs — and ninja treats an output it has no command line for in -# .ninja_log as dirty ("command line not found in log"), which a fresh build -# dir always is. Every "cached" unit was recompiled while the CLI printed -# "Cached". -# -# So the load-bearing assertion is not "an entry exists" but "the second project -# has ZERO compile edges for the dependency's sources". Read from build.ninja, -# not from the log: a status line is exactly what lied before. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -# ── an offline path index serving one library package ──────────────────────── -INDEX_DIR="$TMP/local-index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/s" -cat > "$INDEX_DIR/pkgs/s/shared-lib.lua" <<'EOF' -package = { - spec = "1", - name = "shared-lib", - description = "Shared across projects", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["1.0.0"] = { - url = "https://example.invalid/shared-lib-1.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = true, - sources = { "src/**/*.cppm" }, - targets = { ["shared-lib"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -# `make_project ` — same dependency, and -# deliberately DIFFERENT identity, because the consumer's identity is exactly -# what used to leak into the dependency's cache key. -make_project() { - local dir="$1" name="$2" version="$3" - mkdir -p "$TMP/$dir/src" - mkdir -p "$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.shared-lib/1.0.0/src" - cat > "$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.shared-lib/1.0.0/src/lib.cppm" <<'EOF' -export module shared.lib; -export int shared_value() { return 41; } -EOF - cat > "$TMP/$dir/src/main.cpp" <<'EOF' -import std; -import shared.lib; -int main() { std::println("{}", shared_value() + 1); return 0; } -EOF - cat > "$TMP/$dir/mcpp.toml" < build.log 2>&1 || { cat build.log; exit 1; } - -N1="$(find_ninja "$TMP/projone")" -[[ -n "$N1" ]] || { echo "FAIL: projone has no build.ninja"; exit 1; } -[[ "$(dep_compile_edges "$N1")" -gt 0 ]] || { - echo "FAIL: cold build had no compile edges for the dependency" - grep -n 'shared-lib' "$N1" | head - exit 1 -} -"$MCPP" cache list > list1.log 2>&1 -grep -q 'shared-lib' list1.log || { - echo "FAIL: cold build did not populate a cache entry" - cat list1.log; cat build.log - exit 1 -} - -# ── project two: DIFFERENT name and version, same dependency ───────────────── -make_project projtwo projtwo 9.9.9 -cd "$TMP/projtwo" -"$MCPP" build -v > build.log 2>&1 || { cat build.log; exit 1; } - -N2="$(find_ninja "$TMP/projtwo")" -[[ -n "$N2" ]] || { echo "FAIL: projtwo has no build.ninja"; exit 1; } - -# THE assertion. -edges="$(dep_compile_edges "$N2")" -if [[ "$edges" != "0" ]]; then - echo "FAIL: second project recompiled the dependency ($edges compile edges)" - echo " the cache key is still carrying the consumer's identity" - grep -nE ': (cxx_module|cxx_object|cxx_scan) .*shared-lib' "$N2" | head - exit 1 -fi -staged="$(dep_stage_edges "$N2")" -[[ "$staged" -gt 0 ]] || { - echo "FAIL: dependency has neither compile nor stage edges — it is missing" - grep -n 'shared-lib' "$N2" | head - exit 1 -} - -# The status line must agree, and must carry the unit count. The bare word -# "Cached" was printed for months while every unit was recompiled behind it; a -# number that has to match the skipped edges cannot go quietly wrong that way. -grep -qE 'Cached local-dev\.shared-lib v1\.0\.0 \((index [^,)]+, )?[0-9]+ units?\)' build.log || { - echo "FAIL: no 'cached N units' line for the reused dependency" - cat build.log - exit 1 -} - -# And it has to WORK: staged artifacts must link and run. -./target/*/*/bin/projtwo > run.log 2>&1 || { cat run.log; exit 1; } -grep -q '^42$' run.log || { echo "FAIL: staged artifacts produced wrong output"; cat run.log; exit 1; } - -# ── the consumer's own version must not invalidate the dependency ──────────── -cd "$TMP/projone" -sed -i.bak 's/version = "0.1.0"/version = "0.2.0"/' mcpp.toml -rm -f mcpp.toml.bak -rm -rf target -"$MCPP" build > bump.log 2>&1 || { cat bump.log; exit 1; } -N3="$(find_ninja "$TMP/projone")" -[[ "$(dep_compile_edges "$N3")" == "0" ]] || { - echo "FAIL: bumping the consumer's own version invalidated the dependency" - grep -nE ': (cxx_module|cxx_object|cxx_scan) .*shared-lib' "$N3" | head - exit 1 -} - -# ── a real change to the dependency MUST invalidate it (no false hits) ─────── -# Bump the dependency's version in the index and in the manifests: a different -# version is a different entry, so it has to be compiled again. -sed -i.bak 's/\["1\.0\.0"\]/["1.0.1"]/' "$INDEX_DIR/pkgs/s/shared-lib.lua" -rm -f "$INDEX_DIR/pkgs/s/shared-lib.lua.bak" -mv "$TMP/projone/.mcpp/.xlings/data/xpkgs/local-dev.shared-lib/1.0.0" \ - "$TMP/projone/.mcpp/.xlings/data/xpkgs/local-dev.shared-lib/1.0.1" -sed -i.bak 's/"local-dev.shared-lib" = "1.0.0"/"local-dev.shared-lib" = "1.0.1"/' \ - "$TMP/projone/mcpp.toml" -rm -f "$TMP/projone/mcpp.toml.bak" -rm -rf target -"$MCPP" build > newver.log 2>&1 || { cat newver.log; exit 1; } -N4="$(find_ninja "$TMP/projone")" -[[ "$(dep_compile_edges "$N4")" -gt 0 ]] || { - echo "FAIL: a new dependency version was served from the old entry" - cat newver.log - exit 1 -} - -echo "OK" diff --git a/tests/e2e/173_build_profile_isolation.sh b/tests/e2e/173_build_profile_isolation.sh deleted file mode 100644 index a637a4c56..000000000 --- a/tests/e2e/173_build_profile_isolation.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 173_build_profile_isolation.sh — the build profile must be an invalidation -# axis, and the fast path must not hand back another profile's artifacts. -# -# `[profile.]` lands its knobs in buildConfig.optLevel/debug/lto/strip and -# flags.cppm turns them into -O/-g/-flto, but the fingerprint serialized only -# cflags/cxxflags/ldflags. So `--dev`, `--release` and `--profile dist` produced -# ONE fingerprint, hence one target/// directory and one global cache -# entry — a release build could be served -O0 -g dependency objects. -# -# The second half was worse and user-visible without any cache involved: -# `.build_cache` keyed its fast-path entries by target triple alone, and the fast -# path only refuses to run when an EXPLICIT --profile/--dev/--release is passed. -# A bare `mcpp build` after `mcpp build --release` therefore took the fast path -# against the release build.ninja and reported success in 0.00s, leaving -O2 -# artifacts where -O0 -g was asked for. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -cd "$TMP" -mkdir -p app/src -cd app -cat > src/main.cpp <<'EOF' -#include -int main() { std::printf("hi\n"); return 0; } -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -standard = "c++23" - -[targets.app] -kind = "bin" -main = "src/main.cpp" -EOF - -# The -O level recorded in a build dir's build.ninja. Reading the generated graph -# rather than a status line: a status line is what lied here before. -opt_of() { grep -oE '\-O[0-9s]' "$1/build.ninja" | sort -u | tr -d '\n'; } -dir_for_opt() { - local want="$1" - for d in target/*/*/; do - [[ -f "$d/build.ninja" ]] || continue - [[ "$(opt_of "$d")" == "$want" ]] && { printf '%s' "$d"; return 0; } - done - return 1 -} - -# ── each profile gets its own build dir ───────────────────────────────────── -"$MCPP" build --dev > dev.log 2>&1 || { cat dev.log; exit 1; } -"$MCPP" build --release > release.log 2>&1 || { cat release.log; exit 1; } -"$MCPP" build --profile dist > dist.log 2>&1 || { cat dist.log; exit 1; } - -count=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | wc -l) -[[ "$count" -eq 3 ]] || { - echo "FAIL: expected one build dir per profile, found $count" - for d in target/*/*/; do echo " $d -> $(opt_of "$d")"; done - exit 1 -} -dir_for_opt "-O0" >/dev/null || { echo "FAIL: no -O0 (dev) build dir"; exit 1; } -dir_for_opt "-O2" >/dev/null || { echo "FAIL: no -O2 (release) build dir"; exit 1; } -dir_for_opt "-O3" >/dev/null || { echo "FAIL: no -O3 (dist) build dir"; exit 1; } - -# The announced profile must be the one that was built. -grep -q 'Finished dev' dev.log || { cat dev.log; echo "FAIL: dev not announced"; exit 1; } -grep -q 'Finished release' release.log || { cat release.log; echo "FAIL: release not announced"; exit 1; } -grep -q 'Finished dist' dist.log || { cat dist.log; echo "FAIL: dist not announced"; exit 1; } -# ...and the descriptor must not contradict the flags. "release [optimized]" was -# hardcoded, so a --dev build used to announce itself as an optimized release. -grep -q 'Finished dev \[unoptimized' dev.log || { - cat dev.log - echo "FAIL: dev build described as optimized" - exit 1 -} - -# ── the regression gate: bare build after --release must be dev ───────────── -DEVDIR="$(dir_for_opt "-O0")" -rm -rf target -"$MCPP" build --release > r2.log 2>&1 || { cat r2.log; exit 1; } -"$MCPP" build > bare.log 2>&1 || { cat bare.log; exit 1; } - -grep -q 'Finished dev' bare.log || { - echo "FAIL: bare build after --release did not resolve to the dev profile" - cat bare.log - exit 1 -} -DEVDIR="$(dir_for_opt "-O0")" || { - echo "FAIL: bare build produced no -O0 build dir (fast path served release)" - for d in target/*/*/; do echo " $d -> $(opt_of "$d")"; done - cat bare.log - exit 1 -} -# The dev objects must actually exist and carry debug info: an existing build dir -# with no objects in it would satisfy a path check but not a build. -[[ -f "$DEVDIR/obj/main.o" ]] || { - echo "FAIL: dev build dir has no object" - find "$DEVDIR" -type f | head - exit 1 -} -if command -v readelf > /dev/null 2>&1; then - readelf --debug-dump=info "$DEVDIR/obj/main.o" 2>/dev/null \ - | grep -q 'DW_AT_producer' || { - echo "FAIL: dev object has no debug info (it is a release object)" - exit 1 - } -fi - -# ── switching back is incremental, not a full rebuild ────────────────────── -# Both entries coexist in .build_cache now; keying on the triple alone made each -# profile evict the other, so a dev/release rotation could never be incremental. -"$MCPP" build --release > r3.log 2>&1 || { cat r3.log; exit 1; } -"$MCPP" build > bare2.log 2>&1 || { cat bare2.log; exit 1; } -if grep -qE 'src/main\.cpp' bare2.log; then - echo "FAIL: returning to the dev profile recompiled from scratch" - cat bare2.log - exit 1 -fi -grep -q 'Finished dev' bare2.log || { cat bare2.log; echo "FAIL: wrong profile"; exit 1; } - -echo "OK" diff --git a/tests/e2e/173_index_refresh_policy.sh b/tests/e2e/173_index_refresh_policy.sh deleted file mode 100755 index 54f88ef0e..000000000 --- a/tests/e2e/173_index_refresh_policy.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# #315: `mcpp build` must not refresh the package index just because time -# passed. It refreshes when — and only when — the local index cannot answer. -# -# Every case here is HERMETIC: the builtin registry is fabricated on disk and -# every path that could reach the network is either offline-gated or debounced, -# so this test never downloads anything and never depends on upstream state. -# -# The load-bearing case is INV-3 (step 3 below): a namespace the registry cannot -# refute — `xim`, whose descriptors declare no namespace, or any third-party ns -# — must NOT count as a miss. If it did, every build with such a dependency -# would sync EVERY TIME, which is strictly worse than the hourly TTL this -# change removes. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -INDEX="$MCPP_HOME/indices/mcpplibs" -mkdir -p "$INDEX/pkgs/w" - -cat > "$INDEX/pkgs/w/widget.lua" <<'EOF' -package = { - spec = "1", - namespace = "mcpplibs", - name = "widget", - description = "Present in the local index", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["1.2.0"] = { url = "https://example.invalid/w.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } }, - macosx = { ["1.2.0"] = { url = "https://example.invalid/w.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } }, - windows = { ["1.2.0"] = { url = "https://example.invalid/w.zip", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } }, - }, -} -EOF -echo "abc1234" > "$INDEX/.xlings-index-version" - -# Back-date the refresh marker well past any debounce window: under the old -# TTL policy this alone was enough to trigger a network sync on every build. -: > "$INDEX/.mcpp-index-updated" -touch -d '30 days ago' "$INDEX/.mcpp-index-updated" 2>/dev/null \ - || touch -A -300000 "$INDEX/.mcpp-index-updated" 2>/dev/null || true - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" -echo 'int main() { return 0; }' > src/main.cpp - -manifest() { # manifest - cat > mcpp.toml < - if grep -qE "$2" <<< "$1"; then - printf '%s\n' "$1" - echo "FAIL: $3" - exit 1 - fi -} -expect() { # expect - if ! grep -qE "$2" <<< "$1"; then - printf '%s\n' "$1" - echo "FAIL: $3" - exit 1 - fi -} - -# ── 1. Steady state: a resolvable dependency costs no network ───────────── -# The descriptor is present and the constraint is satisfiable, so the decision -# is "resolvable locally" no matter how old the marker is. The build is run -# offline so that a regression here surfaces as a missing/extra decision line -# rather than as a download. -manifest 'widget = "1.2.0"' -log=$(MCPP_VERBOSE=1 MCPP_OFFLINE=1 "$MCPP" build 2>&1 || true) -expect "$log" 'widget@1.2.0: resolvable locally' \ - 'a locally-resolvable dependency should be reported as such' -refute "$log" 'Refreshing +package index|Updating +package index' \ - 'a 30-day-old marker must NOT trigger a refresh when the index can answer' - -# A SemVer constraint that the local versions satisfy is equally steady. -manifest 'widget = "^1.2"' -log=$(MCPP_VERBOSE=1 MCPP_OFFLINE=1 "$MCPP" build 2>&1 || true) -expect "$log" 'widget@\^1.2: resolvable locally' \ - 'a satisfiable constraint should not be a version miss' -refute "$log" 'Refreshing +package index' \ - 'a satisfiable constraint must not trigger a refresh' - -# ── 2. A real miss is reported as one, and offline suppresses the sync ──── -manifest 'absentpkg = "1.0.0"' -log=$(MCPP_VERBOSE=1 MCPP_OFFLINE=1 "$MCPP" build 2>&1 || true) -expect "$log" 'absentpkg@1.0.0: offline mode' \ - 'offline mode must suppress the refresh and say so' -refute "$log" 'Refreshing +package index' \ - 'offline mode must not reach the network' -# The failure the user actually sees carries the index identity and age, so -# "not found" and "your index is from last month" are distinguishable. -expect "$log" 'index: local index abc1234' \ - 'the resolution failure should name the index revision' -expect "$log" 'mcpp index update' \ - 'the resolution failure should point at the explicit refresh command' - -# ── 3. INV-3: an unrefutable miss is not a miss ─────────────────────────── -# Neither of these may trigger a refresh. `xim` descriptors declare no -# namespace so `(xim, nasm)` can never match the identity gate, and a -# third-party namespace is outside the identities mcpp defines at all. -for dep in 'xim.nasm = "2.16.03"' 'somevendor.thing = "1.0.0"'; do - manifest "$dep" - log=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 || true) # deliberately NOT offline - expect "$log" 'no index can refute this' \ - "a miss under an unrefutable namespace must be inconclusive: $dep" - refute "$log" 'Refreshing +package index' \ - "an unrefutable miss must never trigger a refresh: $dep" -done - -# ── 4. Debounce: a second miss inside the window does not re-sync ───────── -: > "$INDEX/.mcpp-index-updated" # "just refreshed" -manifest 'absentpkg = "1.0.0"' -log=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 || true) # deliberately NOT offline -expect "$log" 'index was just refreshed' \ - 'a miss moments after a sync must be debounced, not re-synced' -refute "$log" 'Refreshing +package index' \ - 'debounce must prevent the second sync' - -# ── 5. [index] auto_refresh = false is honoured ─────────────────────────── -touch -d '30 days ago' "$INDEX/.mcpp-index-updated" 2>/dev/null \ - || touch -A -300000 "$INDEX/.mcpp-index-updated" 2>/dev/null || true -printf '\n[index]\nauto_refresh = false\n' >> "$MCPP_HOME/config.toml" -log=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 || true) # deliberately NOT offline -expect "$log" 'auto_refresh = false' \ - 'the config opt-out must suppress the refresh and name itself' -refute "$log" 'Refreshing +package index' \ - 'auto_refresh = false must prevent the sync' - -# ── 6. `mcpp index update` refuses offline instead of silently no-opping ── -log=$(MCPP_OFFLINE=1 "$MCPP" index update 2>&1 || true) -expect "$log" 'offline mode is on' \ - 'an explicit index update must refuse audibly when offline' - -# ── 7. `mcpp index status` reports the index revision ───────────────────── -log=$(MCPP_OFFLINE=1 "$MCPP" index status 2>&1 || true) -expect "$log" 'revision' 'index status should have a revision column' -expect "$log" 'abc1234' 'index status should show the local index revision' - -echo "PASS: index refresh is driven by local resolvability, not by the clock" diff --git a/tests/e2e/174_cache_modes_and_commands.sh b/tests/e2e/174_cache_modes_and_commands.sh deleted file mode 100644 index c6336927c..000000000 --- a/tests/e2e/174_cache_modes_and_commands.sh +++ /dev/null @@ -1,384 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 174_cache_modes_and_commands.sh — the three build modes, and the cache -# maintenance surface they need. -# -# mcpp build read + write the global cache (default) -# mcpp build --cache=local neither; every dependency compiles in target/ -# mcpp build --cache=off neither, and target/ is cleared first -# mcpp build --no-cache deprecated alias for --cache=off -# -# `--no-cache` used to be the only switch, and it only ever cleared target/ — -# a name that says nothing about a cache. It stays accepted. -# -# The maintenance half exists because a cache nobody can inspect, size or -# reclaim is a cache nobody can trust. `prune` in particular used to rank -# entries by the directory's mtime, which only records when an entry was -# WRITTEN — so a dependency that hit on every build looked as stale as one -# nobody had touched in a month. `gc` reads the later of entry.json's -# `accessed` stamp and entry.json's time, which a use sets (2026.10.31.3). -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -INDEX_DIR="$TMP/local-index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/m" -cat > "$INDEX_DIR/pkgs/m/mode-lib.lua" <<'EOF' -package = { - spec = "1", - name = "mode-lib", - description = "Cache mode fixture", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["1.0.0"] = { - url = "https://example.invalid/mode-lib-1.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = true, - sources = { "src/**/*.cppm" }, - targets = { ["mode-lib"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -mkdir -p "$TMP/app/src" \ - "$TMP/app/.mcpp/.xlings/data/xpkgs/local-dev.mode-lib/1.0.0/src" -cat > "$TMP/app/.mcpp/.xlings/data/xpkgs/local-dev.mode-lib/1.0.0/src/lib.cppm" <<'EOF' -export module mode.lib; -export int mode_value() { return 5; } -EOF -cat > "$TMP/app/src/main.cpp" <<'EOF' -import std; -import mode.lib; -int main() { std::println("{}", mode_value()); return 0; } -EOF -cat > "$TMP/app/mcpp.toml" </dev/null | wc -l; } - -# ── mcpp cache dir: where IS it? ──────────────────────────────────────────── -out=$("$MCPP" cache dir 2>&1) -[[ "$out" == "$MCPP_HOME/build-cache/v1"* ]] || { - echo "FAIL: cache dir reported '$out', expected '$MCPP_HOME/build-cache/v1'" - exit 1 -} - -# ── local: nothing read, nothing written ─────────────────────────────────── -"$MCPP" build --cache=local > local.log 2>&1 || { cat local.log; exit 1; } -[[ "$(entry_count)" -eq 0 ]] || { - echo "FAIL: --cache=local wrote $(entry_count) cache entries" - find "$MCPP_HOME/build-cache/v1/pkg" -maxdepth 4 2>/dev/null - exit 1 -} -# The dependency still has to be built, in-project. -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE ': (cxx_module|cxx_object) .*mode-lib' "$NINJA" || { - echo "FAIL: --cache=local did not compile the dependency locally" - grep -n 'mode-lib' "$NINJA" | head - exit 1 -} -./target/*/*/bin/app > run.log 2>&1 || { cat run.log; exit 1; } -grep -q '^5$' run.log || { echo "FAIL: local build produced wrong output"; cat run.log; exit 1; } - -# ── global (default): writes an entry ────────────────────────────────────── -rm -rf target -"$MCPP" build > global.log 2>&1 || { cat global.log; exit 1; } -[[ "$(entry_count)" -eq 1 ]] || { - echo "FAIL: default build wrote $(entry_count) entries, expected 1" - cat global.log - exit 1 -} - -# ── cache list / list --json / info / verify ─────────────────────────────── -"$MCPP" cache list > list.log 2>&1 -grep -q 'mode-lib' list.log || { cat list.log; echo "FAIL: list omits the entry"; exit 1; } - -"$MCPP" cache list --json > list.json 2>&1 -python3 - list.json > jsoncheck.log 2>&1 <<'PYEOF' || { cat jsoncheck.log; exit 1; } -import json, sys -d = json.load(open(sys.argv[1])) -if "root" not in d or "entries" not in d: - sys.exit(f"FAIL: list --json missing keys: {sorted(d)}") -pkgs = [e for e in d["entries"] if e["kind"] == "pkg"] -if not pkgs: - sys.exit("FAIL: list --json has no package entries") -e = pkgs[0] -for k in ("kind", "label", "key", "dir", "bytes", "files", "accessed", "complete"): - if k not in e: - sys.exit(f"FAIL: entry missing '{k}': {sorted(e)}") -if not e["complete"]: - sys.exit(f"FAIL: fresh entry reported incomplete: {e}") -if e["bytes"] <= 0: - sys.exit(f"FAIL: entry reports {e['bytes']} bytes") -PYEOF - -# info must print the recorded key inputs — that is the whole reason a cache -# entry describes itself, and the only way to diagnose a suspected wrong hit. -"$MCPP" cache info mode-lib > info.log 2>&1 || { cat info.log; exit 1; } -grep -q 'inputs' info.log || { cat info.log; echo "FAIL: info omits key inputs"; exit 1; } -grep -q 'opt_level' info.log || { cat info.log; echo "FAIL: info omits the profile axis"; exit 1; } - -"$MCPP" cache verify > verify.log 2>&1 || { cat verify.log; echo "FAIL: verify failed on a healthy cache"; exit 1; } -grep -q 'all complete' verify.log || { cat verify.log; echo "FAIL: verify message"; exit 1; } - -# verify must FAIL (non-zero) when an artifact is gone. Explicit if/exit rather -# than `! cmd`: under `set -e` a negated command is exempted from the errexit -# check, so `! "$MCPP" cache verify` can never fail this test. -victim="$(find "$MCPP_HOME/build-cache/v1/pkg" -path '*/obj/*' -type f | head -1)" -[[ -n "$victim" ]] || { echo "FAIL: no cached object to remove"; exit 1; } -mv "$victim" "$victim.bak" -rc=0 -"$MCPP" cache verify > verify2.log 2>&1 || rc=$? -if [[ "$rc" -eq 0 ]]; then - echo "FAIL: verify passed with a missing artifact" - cat verify2.log - exit 1 -fi -grep -q 'incomplete' verify2.log || { cat verify2.log; echo "FAIL: verify wording"; exit 1; } -mv "$victim.bak" "$victim" - -# ── off: clears this build's build dir and touches no cache ──────────────── -# The build DIRECTORY (target///), not all of target/: sibling dirs -# for other profiles and other targets are not this invocation's business. -builddir="$(dirname "$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)")" -marker="$builddir/_off_marker" -touch "$marker" -before="$(entry_count)" -"$MCPP" build --cache=off > off.log 2>&1 || { cat off.log; exit 1; } -[[ ! -f "$marker" ]] || { echo "FAIL: --cache=off did not clear the build dir"; exit 1; } -[[ "$(entry_count)" -eq "$before" ]] || { - echo "FAIL: --cache=off changed the cache ($before -> $(entry_count))" - exit 1 -} -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE ': (cxx_module|cxx_object) .*mode-lib' "$NINJA" || { - echo "FAIL: --cache=off served the dependency from the cache" - exit 1 -} - -# --no-cache must behave identically (deprecated alias). -touch "$marker" -"$MCPP" build --no-cache > nocache.log 2>&1 || { cat nocache.log; exit 1; } -[[ ! -f "$marker" ]] || { echo "FAIL: --no-cache did not clear the build dir"; exit 1; } - -# An unknown mode is refused, not silently taken as "global". -rc=0 -"$MCPP" build --cache=bogus --strict > bogus.log 2>&1 || rc=$? -[[ "$rc" -ne 0 ]] || { echo "FAIL: --cache=bogus accepted under --strict"; cat bogus.log; exit 1; } -grep -qi 'cache mode' bogus.log || { cat bogus.log; echo "FAIL: unhelpful error"; exit 1; } - -# MCPP_BUILD_CACHE is the env-level equivalent. -rm -rf target -MCPP_BUILD_CACHE=local "$MCPP" build > env.log 2>&1 || { cat env.log; exit 1; } -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE ': (cxx_module|cxx_object) .*mode-lib' "$NINJA" || { - echo "FAIL: MCPP_BUILD_CACHE=local was ignored" - exit 1 -} - -# [build] cache is the project-level equivalent, and --cache beats it. -cat >> mcpp.toml <<'EOF' - -[build] -cache = "local" -EOF -rm -rf target -"$MCPP" build > manifest.log 2>&1 || { cat manifest.log; exit 1; } -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE ': (cxx_module|cxx_object) .*mode-lib' "$NINJA" || { - echo "FAIL: [build] cache = local was ignored" - exit 1 -} -rm -rf target -"$MCPP" build --cache=global > override.log 2>&1 || { cat override.log; exit 1; } -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -if grep -qE ': (cxx_module|cxx_object) .*mode-lib' "$NINJA"; then - echo "FAIL: --cache=global did not override [build] cache = local" - exit 1 -fi - -# Drop the manifest override again — everything below assumes the default mode, -# and a leftover `cache = "local"` would make the gc section measure an empty -# cache and "pass" for the wrong reason. -python3 - mcpp.toml <<'PYEOF' -import sys -p = sys.argv[1] -lines = open(p).read().splitlines(keepends=True) -out, skip = [], False -for ln in lines: - if ln.strip() == "[build]": - skip = True - continue - if skip and ln.strip().startswith("cache"): - skip = False - continue - out.append(ln) -open(p, "w").write("".join(out)) -PYEOF -if grep -q '^cache' mcpp.toml; then - echo "FAIL: could not strip the [build] cache override" - cat mcpp.toml - exit 1 -fi - -# ── the fast path must honour the declared mode ───────────────────────────── -# A build.ninja generated under `global` contains stage_file edges reading the -# cache. Replaying it for a request that asked for `local` would use the cache the -# manifest just said not to use — and ruling the cache out is `local`'s whole -# purpose. .build_cache therefore records the mode, exactly as it records the -# profile, and a mismatch is a miss. -cat >> mcpp.toml <<'EOF' - -[build] -cache = "local" -EOF -rm -rf target -"$MCPP" build --cache=global > modeglobal.log 2>&1 || { cat modeglobal.log; exit 1; } -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -staged_before=$(grep -c ': stage_file .*mode-lib' "$NINJA" || true) -[[ "$staged_before" -gt 0 ]] || { - echo "FAIL: --cache=global did not produce stage edges (cache should be warm here)" - cat modeglobal.log - exit 1 -} -# Bare build: the manifest says local, and the fast path must NOT replay the -# global graph. Note mcpp.toml is untouched between these two builds, so its -# mtime cannot be what saves us — only the recorded mode can. -"$MCPP" build > modebare.log 2>&1 || { cat modebare.log; exit 1; } -NINJA="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -staged_after=$(grep -c ': stage_file .*mode-lib' "$NINJA" || true) -[[ "$staged_after" -eq 0 ]] || { - echo "FAIL: bare build replayed the global-mode graph ($staged_after stage edges)" - echo " [build] cache = local was bypassed by the fast path" - cat modebare.log - exit 1 -} -grep -qE ': (cxx_module|cxx_object) .*mode-lib' "$NINJA" || { - echo "FAIL: local mode did not compile the dependency" - exit 1 -} -# Strip the override again for the sections below. -python3 - mcpp.toml <<'PYEOF' -import sys -p = sys.argv[1] -out, skip = [], False -for ln in open(p).read().splitlines(keepends=True): - if ln.strip() == "[build]": - skip = True - continue - if skip and ln.strip().startswith("cache"): - skip = False - continue - out.append(ln) -open(p, "w").write("".join(out)) -PYEOF -rm -rf target -"$MCPP" build > moderestore.log 2>&1 || { cat moderestore.log; exit 1; } - -# ── gc: LRU by last USE, not by when the entry was written ───────────────── -# Backdate the entry's accessed stamp, then confirm an age-bounded gc collects it -# — and that a build afterwards refreshes the stamp so it would survive next time. -entry="$(find "$MCPP_HOME/build-cache/v1/pkg" -name entry.json | head -1)" -python3 - "$entry" <<'PYEOF' -import json, sys -p = sys.argv[1] -d = json.load(open(p)) -d["accessed"] = "1000" # 1970-ish -json.dump(d, open(p, "w"), indent=2) -PYEOF -touch -d '@1000' "$entry" # and the record's time, which a use sets -"$MCPP" cache gc --older-than 1s > gc.log 2>&1 || { cat gc.log; exit 1; } -[[ "$(entry_count)" -eq 0 ]] || { - echo "FAIL: gc did not collect a long-unused entry" - cat gc.log - exit 1 -} -grep -q 'Collected' gc.log || { cat gc.log; echo "FAIL: gc wording"; exit 1; } - -rm -rf target -"$MCPP" build > refill.log 2>&1 || { cat refill.log; exit 1; } -"$MCPP" build > touch.log 2>&1 || { cat touch.log; exit 1; } # a HIT -"$MCPP" cache gc --older-than 1h > gc2.log 2>&1 || { cat gc2.log; exit 1; } -[[ "$(entry_count)" -eq 1 ]] || { - echo "FAIL: gc collected an entry that was just used" - cat gc2.log - exit 1 -} - -# gc requires a budget; no arguments is a usage error, not a silent no-op. -rc=0 -"$MCPP" cache gc > gcnoargs.log 2>&1 || rc=$? -[[ "$rc" -ne 0 ]] || { echo "FAIL: gc with no budget succeeded"; cat gcnoargs.log; exit 1; } - -# gc --max-size evicts package entries to hit a budget, and leaves std entries -# alone: a std BMI is shared by every project on the machine and costs ~30 s to -# rebuild, so trading it for a little disk is the wrong trade. The summary must -# therefore talk about PACKAGE entries — reporting it as "cache now 0 B" would -# read as an empty cache while tens of MB of std BMIs sit next to it. -[[ "$(entry_count)" -eq 1 ]] || { echo "FAIL: expected one package entry before gc"; exit 1; } -[[ -d "$MCPP_HOME/build-cache/v1/std" ]] || { echo "FAIL: no std entries to protect"; exit 1; } -"$MCPP" cache gc --max-size 1B > gcsize.log 2>&1 || { cat gcsize.log; exit 1; } -[[ "$(entry_count)" -eq 0 ]] || { echo "FAIL: gc --max-size kept package entries"; cat gcsize.log; exit 1; } -[[ -d "$MCPP_HOME/build-cache/v1/std" ]] || { - echo "FAIL: gc --max-size evicted std entries" - cat gcsize.log - exit 1 -} -grep -q 'package and tool entries now' gcsize.log || { - cat gcsize.log - echo "FAIL: gc summary must scope its figure to package and tool entries" - exit 1 -} - -# Refill for the clean tests below. -rm -rf target -"$MCPP" build > refill2.log 2>&1 || { cat refill2.log; exit 1; } - -# ── clean --std / --all / --legacy ───────────────────────────────────────── -[[ -d "$MCPP_HOME/build-cache/v1/std" ]] || { echo "FAIL: no std cache dir"; exit 1; } -"$MCPP" cache clean --std > cleanstd.log 2>&1 -[[ ! -d "$MCPP_HOME/build-cache/v1/std" ]] || { - echo "FAIL: clean --std left the std entries" - exit 1 -} -[[ "$(entry_count)" -eq 1 ]] || { echo "FAIL: clean --std dropped package entries too"; exit 1; } - -"$MCPP" cache clean --all > cleanall.log 2>&1 -[[ "$(entry_count)" -eq 0 ]] || { echo "FAIL: clean --all left package entries"; exit 1; } - -# --legacy targets the pre-v1 tree, which nothing reads any more. -mkdir -p "$MCPP_HOME/bmi/deadbeef/deps/idx/pkg@1.0.0" -echo x > "$MCPP_HOME/bmi/deadbeef/deps/idx/pkg@1.0.0/old.o" -"$MCPP" cache clean --legacy > cleanlegacy.log 2>&1 -[[ ! -d "$MCPP_HOME/bmi" ]] || { cat cleanlegacy.log; echo "FAIL: clean --legacy left $MCPP_HOME/bmi"; exit 1; } -grep -q 'pre-v1' cleanlegacy.log || { cat cleanlegacy.log; echo "FAIL: legacy wording"; exit 1; } - -echo "OK" diff --git a/tests/e2e/175_cpp20_import_std.sh b/tests/e2e/175_cpp20_import_std.sh deleted file mode 100755 index d0bf19797..000000000 --- a/tests/e2e/175_cpp20_import_std.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 175_cpp20_import_std.sh — standard = "c++20" is a first-class level, and -# `import std;` still works there. -# -# `import std;` is a C++23 *library* feature, but named modules are C++20 and -# every implementation mcpp ships provides the std module in C++20 mode too -# (libstdc++'s bits/std.cc and libc++'s std.cppm carry no __cplusplus guard; -# MSVC STL unblocked it in microsoft/STL#3977). Verified by on-machine probes -# across gcc 15/16 x glibc/musl/mingw and clang 22 + libc++ — see -# .agents/docs/2026-07-31-cpp20-standard-support-design.md §2.2. -# -# Asserts: build + run succeed, -std=c++20 reaches every surface (global -# cxxflags, compile_commands.json, the std BMI prebuild), and no c++23 leaks in. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" - -cat > mcpp.toml <<'EOF' -[package] -name = "cpp20demo" -version = "0.1.0" -standard = "c++20" -EOF - -# Deliberately C++20-only library usage: no std::print / std::expected here. -cat > src/util.cppm <<'EOF' -export module cpp20demo.util; - -import std; - -export namespace cpp20demo { -inline int count_evens(const std::vector& v) { - return static_cast(std::ranges::count_if(v, [](int x) { return x % 2 == 0; })); -} -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -import cpp20demo.util; - -int main() { - std::vector v{1, 2, 3, 4, 5, 6}; - std::cout << "evens=" << cpp20demo::count_evens(v) << "\n"; - static_assert(__cplusplus == 202002L, "expected C++20 mode"); - return 0; -} -EOF - -"$MCPP" build --no-cache > "$TMP/build.log" 2>&1 || { - cat "$TMP/build.log" - echo "FAIL: c++20 build failed" - exit 1 -} - -binary=$(find target -type f -path '*/bin/cpp20demo' | head -1) -out=$("$binary") -[[ "$out" == "evens=3" ]] || { - echo "FAIL: unexpected output: $out" - exit 1 -} - -# The level reaches the module-graph-global cxxflags (every TU, deps included). -build_ninja="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE '^cxxflags = -std=c\+\+20' "$build_ninja" || { - grep '^cxxflags' "$build_ninja" || true - echo "FAIL: build.ninja cxxflags is not -std=c++20" - exit 1 -} -if grep -q -- '-std=c++23' "$build_ninja" compile_commands.json; then - echo "FAIL: -std=c++23 leaked into a command line under standard = c++20" - exit 1 -fi -grep -q -- '-std=c++20' compile_commands.json || { - echo "FAIL: compile_commands.json missing -std=c++20" - exit 1 -} - -# The std BMI prebuild must use the SAME level as the TUs importing it — a -# c++23 std.gcm fed to a c++20 TU is rejected outright ("language dialect -# differs"), so this is the cache-identity assertion, not cosmetics. -grep -rl '"std_flag": "-std=c++20"' "$MCPP_HOME/build-cache/v1/std" >/dev/null 2>&1 || { - find "$MCPP_HOME/build-cache/v1/std" -name std-module.json \ - -exec grep -H '"std_flag"' {} \; 2>/dev/null - echo "FAIL: no std-module.json records std_flag -std=c++20" - exit 1 -} - -# gnu++20 is accepted too and keeps the GNU dialect spelling. -mkdir -p "$TMP/gnu/src" -cd "$TMP/gnu" -cat > mcpp.toml <<'EOF' -[package] -name = "gnu20demo" -version = "0.1.0" -standard = "gnu++20" -EOF -cat > src/main.cpp <<'EOF' -import std; -int main() { std::cout << "gnu20 ok\n"; } -EOF -"$MCPP" build --no-cache > "$TMP/gnu-build.log" 2>&1 || { - cat "$TMP/gnu-build.log" - echo "FAIL: gnu++20 build failed" - exit 1 -} -gnu_ninja="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE '^cxxflags = -std=gnu\+\+20' "$gnu_ninja" || { - grep '^cxxflags' "$gnu_ninja" || true - echo "FAIL: gnu++20 did not reach cxxflags" - exit 1 -} - -# Below the floor stays rejected, and the message names the floor. -mkdir -p "$TMP/old/src" -cd "$TMP/old" -cat > mcpp.toml <<'EOF' -[package] -name = "olddemo" -version = "0.1.0" -standard = "c++17" -EOF -cat > src/main.cpp <<'EOF' -int main() { return 0; } -EOF -if "$MCPP" build --no-cache > "$TMP/old-build.log" 2>&1; then - echo "FAIL: standard = c++17 was accepted" - exit 1 -fi -grep -q 'c++20' "$TMP/old-build.log" || { - cat "$TMP/old-build.log" - echo "FAIL: rejection message does not advertise the c++20 floor" - exit 1 -} - -echo "PASS: c++20 is a first-class level, import std works, gnu++20 ok, c++17 rejected" diff --git a/tests/e2e/176_cpp20_std_bmi_isolation.sh b/tests/e2e/176_cpp20_std_bmi_isolation.sh deleted file mode 100755 index 5acc04157..000000000 --- a/tests/e2e/176_cpp20_std_bmi_isolation.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 176_cpp20_std_bmi_isolation.sh — switching [package].standard between c++20 -# and c++23 must give each level its own std BMI and its own target dir. -# -# This is not a hygiene preference: a std BMI built at one level is REJECTED by -# the compiler at another ("std: error: language dialect differs 'C++20', -# expected 'C++23'"). The standard is part of the fingerprint, of the std BMI -# identity and of the dependency build-cache key precisely so a level switch -# cannot produce a corrupt hit — this test locks that in both directions, -# including going back (the second c++23 build must still work). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" - -cat > src/main.cpp <<'EOF' -import std; -int main() { std::cout << "level ok\n"; return 0; } -EOF - -write_manifest() { - cat > mcpp.toml < "$TMP/build-$1.log" 2>&1 || { - cat "$TMP/build-$1.log" - echo "FAIL: build at $1 failed" - exit 1 - } - binary=$(find target -type f -path '*/bin/leveldemo' -newer mcpp.toml | head -1) - [[ -n "$binary" ]] || binary=$(find target -type f -path '*/bin/leveldemo' | head -1) - out=$("$binary") - [[ "$out" == "level ok" ]] || { - echo "FAIL: bad output at $1: $out" - exit 1 - } -} - -build_at "c++23" -build_at "c++20" -# Back again: the c++23 artifacts must still be valid and reusable, not -# clobbered by the c++20 run. -build_at "c++23" - -# Two distinct std identities on disk, one per level. -std20=$(grep -rl '"std_flag": "-std=c++20"' "$MCPP_HOME/build-cache/v1/std" 2>/dev/null | wc -l) -std23=$(grep -rl '"std_flag": "-std=c++23"' "$MCPP_HOME/build-cache/v1/std" 2>/dev/null | wc -l) -[[ "$std20" -ge 1 && "$std23" -ge 1 ]] || { - find "$MCPP_HOME/build-cache/v1/std" -name std-module.json \ - -exec grep -H '"std_flag"' {} \; 2>/dev/null - echo "FAIL: expected a std BMI for each level (c++20=$std20 c++23=$std23)" - exit 1 -} - -# Two distinct fingerprint dirs (target///) — the level is -# a fingerprint axis, so the two builds cannot share a product directory. -dirs=$(find target -mindepth 2 -maxdepth 2 -type d | wc -l) -[[ "$dirs" -ge 2 ]] || { - find target -mindepth 2 -maxdepth 2 -type d - echo "FAIL: expected separate target dirs per standard, found $dirs" - exit 1 -} - -echo "PASS: c++20 and c++23 keep separate std BMIs and target dirs" diff --git a/tests/e2e/177_cpp20_msvc.sh b/tests/e2e/177_cpp20_msvc.sh deleted file mode 100755 index fa1cbc422..000000000 --- a/tests/e2e/177_cpp20_msvc.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc -# resources: machine -# 177_cpp20_msvc.sh — standard = "c++20" on native cl.exe. -# -# This is the hard assertion behind the review decision to let MSVC answer 20 -# (design §2.3/§5.3): microsoft/STL#3945 was fixed by STL#3977 (first shipping -# in VS 2022 17.8 = cl 19.38), yet Microsoft Learn still documents -# /std:c++latest as a requirement for `import std;`. Documentation cannot settle -# this — a real cl must. If this test fails on a 19.38+ runner, the premise is -# wrong and msvc::std_module_min_level goes back to 23. -# -# Asserts: /std:c++20 is what reaches the command lines (not /std:c++latest), -# `import std;` compiles and runs, and the runner's cl version is printed so the -# log records WHICH toolset the claim was verified on. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -CONF="${MCPP_HOME:-$HOME/.mcpp}/config.toml" -ORIG_DEFAULT="" -if [[ -f "$CONF" ]]; then - ORIG_DEFAULT=$(sed -n '/^\[toolchain\]/,/^\[/p' "$CONF" \ - | grep -E '^default[[:space:]]*=' | head -1 | cut -d'"' -f2 || true) -fi -TMP=$(mktemp -d) -restore() { - if [[ -n "$ORIG_DEFAULT" ]]; then - "$MCPP" toolchain default "$ORIG_DEFAULT" >/dev/null 2>&1 || true - fi - rm -rf "$TMP" -} -trap restore EXIT - -cd "$TMP" -"$MCPP" toolchain default msvc >/dev/null \ - || { echo "FAIL: toolchain default msvc"; exit 1; } - -# Record the toolset this run verifies (design §5.3: the version must be a fact -# in the log, not an estimate in a document). -"$MCPP" toolchain list 2>&1 | grep -i msvc || true - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" -cat > mcpp.toml <<'EOF' -[package] -name = "cpp20cl" -version = "0.1.0" -standard = "c++20" -EOF -cat > src/main.cpp <<'EOF' -import std; -int main() { - std::vector v{1, 2, 3}; - std::cout << "cl20 " << v.size() << "\n"; - return 0; -} -EOF - -build_out=$("$MCPP" build 2>&1) || { - echo "$build_out" - echo "FAIL: msvc c++20 + import std build failed." - echo " If this runner's cl is >= 19.38, the design premise (MSVC" - echo " answers 20) is wrong — revert msvc::std_module_min_level to 23." - exit 1 -} -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: msvc c++20 run: $run_out"; exit 1; } -[[ "$run_out" == *"cl20 3"* ]] || { echo "FAIL: run output: $run_out"; exit 1; } - -# The MSVC spelling of level 20 is /std:c++20 — the `level <= 20` row in -# dialect.cppm that was unreachable until c++20 joined the allow-list. -grep -q '/std:c++20' compile_commands.json || { - echo "FAIL: compile_commands.json missing /std:c++20" - exit 1 -} -if grep -q '/std:c++latest' compile_commands.json; then - echo "FAIL: /std:c++latest reached the command line under standard = c++20" - exit 1 -fi - -echo "PASS: msvc builds and runs standard = c++20 with import std (/std:c++20)" diff --git a/tests/e2e/178_test_observability.sh b/tests/e2e/178_test_observability.sh deleted file mode 100755 index 74c29ae40..000000000 --- a/tests/e2e/178_test_observability.sh +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# 178_test_observability.sh — `mcpp test` is streamed, timed and bounded. -# -# Four properties, each of which used to be absent and each of which cost a real -# CI investigation: -# -# 1. stdout is line-buffered even into a pipe. It used to be block-buffered -# with a libc-dependent block SIZE (musl 1 KB, Apple libc 64 KB on a pipe), -# so the same ~13 KB of status output flushed 13 times on Linux and zero -# times on macOS — and when the job timeout then killed the process, the -# whole buffer went with it: a 45-minute step with no attributable output. -# 2. Per-test and per-member timings are printed, split into build vs run. -# The old `finished in` started its clock AFTER the package build, so a -# member that spent 87s linking reported 6.53s. -# 3. `--timeout` bounds a hung test RUN and the fan-out continues past it. -# 4. `--build-timeout` bounds a hung compile/link — the half `--timeout` -# never covered, and the one that actually fires in practice. -# -# The `# requires:` line above must stay on line 2 — run_all.sh reads it with -# `sed -n '2p'`, so a requires buried in this block is silently inert (which is -# how an earlier revision of this file ran on Windows and failed there). -# unix-shell, not gcc: macOS is the platform this whole file exists for, and it -# has no GCC. Windows is excluded on purpose — the deadline runner has no -# kill-by-handle path there (mcpp.platform.process), so --timeout and -# --build-timeout are documented POSIX-only and cannot be asserted. -# -# See .agents/docs/2026-07-31-test-workspace-observability-analysis.md. -set -uo pipefail - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -fail() { echo "FAIL: $*"; exit 1; } - -# ── fixture: a fast member and a slow-to-RUN member ─────────────────────── -mkdir -p fast/tests slow/tests -cat > mcpp.toml <<'EOF' -[workspace] -members = ["fast", "slow"] -EOF -for m in fast slow; do - printf '[package]\nname = "%s"\nversion = "0.1.0"\n' "$m" > "$m/mcpp.toml" -done -cat > fast/tests/quick.cpp <<'EOF' -int main() { return 0; } -EOF -cat > slow/tests/hang.cpp <<'EOF' -#include -#include -int main() { std::this_thread::sleep_for(std::chrono::seconds(30)); return 0; } -EOF - -# ── 1. streaming: output arrives while the process is still running ─────── -# The fan-out does `fast` first, then blocks 30s inside `slow`. Reading the -# fast member's result out of a pipe within a few seconds is only possible if -# mcpp flushed it; under block buffering nothing appears until exit. -# `<>` opens the fifo read-write, which never blocks and keeps both a reader and -# a writer alive for the whole test. Opening it read-only (or write-only) blocks -# until the other end appears, which is a deadlock waiting to happen — and a -# test written to make hangs debuggable must not be able to hang the suite. -mkfifo pipe -exec 3<>pipe -"$MCPP" test --workspace --timeout 25 >pipe 2>&1 & -mcpp_pid=$! -streamed="" -while IFS= read -r -t 25 line <&3; do - case "$line" in - *"quick ... ok"*) streamed=yes; break ;; - esac -done -kill "$mcpp_pid" 2>/dev/null -wait "$mcpp_pid" 2>/dev/null -exec 3>&- -[ -n "$streamed" ] || fail "no output reached the pipe before the process ended (stdout not flushed)" -echo " ok: stdout streams line-by-line into a pipe" - -# ── 2. timings: per-test duration + build/run split + per-member progress ─ -out=$("$MCPP" test -p fast 2>&1) || fail "test -p fast errored: $out" -echo "$out" | grep -qE "quick \.\.\. ok \([0-9]+\.[0-9]+s\)" \ - || { echo "$out"; fail "per-test line carries no duration"; } -echo "$out" | grep -qE "finished in [0-9]+\.[0-9]+s \(build [0-9]+\.[0-9]+s \+ run [0-9]+\.[0-9]+s\)" \ - || { echo "$out"; fail "summary carries no build/run split"; } -echo " ok: per-test duration + build/run split" - -# ── 3. --timeout bounds a hung RUN, and the fan-out continues ───────────── -out=$("$MCPP" test --workspace --timeout 2 2>&1) -rc=$? -echo "$out" | grep -q "hang ... FAIL (timeout after 2s)" \ - || { echo "$out"; fail "hung test was not killed by --timeout"; } -echo "$out" | grep -qE "member 'fast' \(1/2\) ok" \ - || { echo "$out"; fail "no per-member progress line"; } -echo "$out" | grep -q "failed members: slow" \ - || { echo "$out"; fail "workspace summary does not name the failed member"; } -[ "$rc" -ne 0 ] || fail "workspace test returned 0 despite a failed member" -echo " ok: --timeout bounds a hung run; fan-out continues; member attributed" - -# `--timeout 0` still means "no limit" — it just has to be asked for now. -"$MCPP" test -p fast --timeout 0 >/dev/null 2>&1 || fail "--timeout 0 rejected" - -# ── 4. --build-timeout bounds a hung COMPILE ───────────────────────────── -# 20k distinct class templates at the default (optimized) profile: seconds to -# compile everywhere, so a 1s ceiling fires with a wide margin. The assertion is -# on the *reported reason*, which must name the build timeout rather than being -# folded into a generic compile failure. -mkdir -p heavy/tests -printf '[package]\nname = "heavy"\nversion = "0.1.0"\n' > heavy/mcpp.toml -{ - i=0 - while [ "$i" -lt 20000 ]; do - printf 'template struct S%d { static constexpr long v = A*%d+1; };\n' "$i" "$i" - printf 'long f%d(long x) { return x + S%d<%d>::v; }\n' "$i" "$i" "$((i % 97))" - i=$((i + 1)) - done - echo 'int main() { return 0; }' -} > heavy/tests/slowbuild.cpp - -out=$(cd heavy && "$MCPP" test --build-timeout 1 2>&1) -echo "$out" | grep -q "build timeout after 1s" \ - || { echo "$out" | head -20; fail "--build-timeout did not stop the slow compile"; } -echo " ok: --build-timeout bounds a slow compile and names itself" - -# Argument validation is shared by all three deadlines. -out=$("$MCPP" test -p fast --build-timeout abc 2>&1) -echo "$out" | grep -q "invalid --build-timeout" || fail "bad --build-timeout accepted" - -# ── 5. --workspace-timeout stops the fan-out and reports what did not run ─ -# Its own fixture, with the slow member FIRST: the deadline is checked before -# each member starts, so it can only skip anything if time has already been -# spent. (In the main fixture `fast` runs first and finishes in milliseconds.) -mkdir -p wst/one/tests wst/two/tests -cat > wst/mcpp.toml <<'EOF' -[workspace] -members = ["one", "two"] -EOF -for m in one two; do - printf '[package]\nname = "wst_%s"\nversion = "0.1.0"\n' "$m" > "wst/$m/mcpp.toml" -done -cp slow/tests/hang.cpp wst/one/tests/hang.cpp -cp fast/tests/quick.cpp wst/two/tests/quick.cpp - -out=$(cd wst && "$MCPP" test --workspace --timeout 3 --workspace-timeout 1 2>&1) -rc=$? -echo "$out" | grep -q "member 'two'" \ - && { echo "$out"; fail "--workspace-timeout did not stop the fan-out"; } -echo "$out" | grep -q "not run (--workspace-timeout 1s reached)" \ - || { echo "$out"; fail "--workspace-timeout did not report skipped members"; } -[ "$rc" -ne 0 ] || fail "workspace timeout returned 0" -echo " ok: --workspace-timeout stops the fan-out and names what did not run" - -# ── 6. JSON contract: no stray lines, member-qualified, workspace summary ─ -out=$("$MCPP" test --workspace --timeout 2 --message-format json 2>/dev/null) -first=$(printf '%s\n' "$out" | head -1) -case "$first" in - '{'*) ;; - *) echo "$out" | head -3; fail "first NDJSON line is not JSON (stdout polluted)" ;; -esac -printf '%s\n' "$out" | grep -q '"member":"fast"' \ - || { echo "$out"; fail "test records are not member-qualified"; } -printf '%s\n' "$out" | grep -q '"workspace_summary"' \ - || { echo "$out"; fail "no workspace_summary record"; } -printf '%s\n' "$out" | grep -q '"failed_members":\["slow"\]' \ - || { echo "$out"; fail "workspace_summary does not name the failed member"; } -echo " ok: JSON stream is clean, member-qualified and has a workspace summary" - -echo "PASS: 178_test_observability" diff --git a/tests/e2e/179_spaced_paths.sh b/tests/e2e/179_spaced_paths.sh deleted file mode 100755 index c70661d15..000000000 --- a/tests/e2e/179_spaced_paths.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -# 179_spaced_paths.sh — a project whose paths contain spaces still builds -# -# #331: two independent places assumed no path ever contains a space. On -# Windows that means assuming nobody installs under `C:\Program Files` and no -# user account name has a space in it — both routinely false. The failures are -# not Windows-specific though (a Linux `/home/my dir/inc` splits identically), -# so this runs everywhere: same bug, much faster feedback. -set -e - -TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT - -# Every path below the project root carries a space, including the project -# directory itself. -mkdir -p "$TMP/my work dir" -cd "$TMP/my work dir" -"$MCPP" new "spaced_proj" >/dev/null 2>&1 -cd spaced_proj - -# An include directory with a space, holding a header reachable ONLY through -# it — so a split include flag fails to compile rather than silently passing. -mkdir -p "vendor inc/deep dir" -cat > "vendor inc/deep dir/spaced_header.hpp" <<'EOF' -#pragma once -inline int spaced_header_value() { return 4242; } -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "spaced_proj" -version = "0.1.0" - -[build] -include_dirs = ["vendor inc/deep dir"] -EOF - -# A build.mcpp exercises the other half: mcpp compiles and execs it, so its -# own argv carries the (spaced) payload path of the host compiler. -cat > build.mcpp <<'EOF' -#include -int main() { - std::puts("mcpp:cfg=SPACED_BUILD_PROGRAM_RAN"); - std::puts("mcpp:rerun-if-changed=build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -#include -import std; -int main() { -#ifndef SPACED_BUILD_PROGRAM_RAN - std::println("build.mcpp directive missing"); - return 1; -#endif - if (spaced_header_value() != 4242) { - std::println("wrong header value"); - return 1; - } - std::println("spaced-ok"); - return 0; -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: build in a spaced path: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } -[[ "$run_out" == *"spaced-ok"* ]] || { echo "FAIL: run output: $run_out"; exit 1; } - -# The include flag must reach the compiler as ONE shell word. Assert on the -# generated ninja file rather than on the build succeeding by luck: a split -# flag can still compile if the header happens to be findable another way. -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -[[ -n "$ninja_file" ]] || { echo "FAIL: no build.ninja found"; exit 1; } -grep -q "vendor" "$ninja_file" || { - echo "FAIL: include dir absent from build.ninja"; exit 1; } -# TWO escaping layers, in order, and the test has to know both: ninja escapes -# the space as `$ ` (so ninja itself does not treat it as a separator), and -# the shell quoting wraps the whole token (so what ninja hands to sh stays one -# word). The old code had only the first, which is exactly why the path -# survived ninja and then split in the shell. -# The quote character is the host shell's — POSIX sh single, cmd.exe double — -# so accept either rather than pinning whichever platform this happens to run -# on. (An earlier version hardcoded `'` and failed on Windows for a difference -# that was correct.) -grep -qE "['\"]-I[^'\"]*vendor\\\$ inc" "$ninja_file" || { - echo "FAIL: include dir not shell-quoted with its prefix:" - grep -oE "[^ ]*vendor[^ ]*( inc[^ ]*)?" "$ninja_file" | head -3 - exit 1; } - -echo "PASS: spaced paths — include dirs, project root and build.mcpp" diff --git a/tests/e2e/17_test_no_tests.sh b/tests/e2e/17_test_no_tests.sh deleted file mode 100755 index e3390445a..000000000 --- a/tests/e2e/17_test_no_tests.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Project with no tests/ → `mcpp test` says "no tests found" and exits 0. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp -rm -rf tests # remove the auto-generated test_smoke.cpp - -out=$("$MCPP" test 2>&1) -echo "$out" | grep -q 'no tests found' || { echo "missing 'no tests found': $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/180_msvc_build_mcpp.sh b/tests/e2e/180_msvc_build_mcpp.sh deleted file mode 100755 index 1f6e3b80d..000000000 --- a/tests/e2e/180_msvc_build_mcpp.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -# requires: windows msvc -# resources: machine -# 180_msvc_build_mcpp.sh — build.mcpp works under a native MSVC toolchain -# -# `MSVC x build.mcpp` was an empty cell in the CI matrix, and the feature was -# correspondingly at zero: `grep -i msvc` over build_program.cppm hit only -# comments. Three separate layers had to be fixed, and each only becomes -# visible once the previous one is gone — the argv[0] quoting, the GNU-only -# flag spellings, and the missing INCLUDE/LIB environment. A test that only -# checked "does it build" would have passed on any one of them being fixed, -# so this checks the produced effect instead. -set -e - -CONF="${MCPP_HOME:-$HOME/.mcpp}/config.toml" -ORIG_DEFAULT="" -if [[ -f "$CONF" ]]; then - ORIG_DEFAULT=$(sed -n '/^\[toolchain\]/,/^\[/p' "$CONF" \ - | grep -E '^default[[:space:]]*=' | head -1 | cut -d'"' -f2 || true) -fi -TMP=$(mktemp -d) -restore() { - if [[ -n "$ORIG_DEFAULT" ]]; then - "$MCPP" toolchain default "$ORIG_DEFAULT" >/dev/null 2>&1 || true - fi - rm -rf "$TMP" -} -trap restore EXIT - -cd "$TMP" -"$MCPP" toolchain default msvc >/dev/null 2>&1 \ - || { echo "FAIL: msvc@system not selectable"; exit 1; } - -"$MCPP" new msvc_bp >/dev/null 2>&1 -cd msvc_bp - -# 1) An #include-based build.mcpp — the supported shape under MSVC. It writes -# a file AND emits a define, so a pass requires the helper to have compiled, -# linked, and actually run. -cat > build.mcpp <<'EOF' -#include -int main() { - std::FILE* f = std::fopen("helper-ran", "w"); - if (!f) return 2; - std::fputs("ok\n", f); - std::fclose(f); - std::puts("mcpp:cfg=MSVC_BUILD_PROGRAM_RAN"); - std::puts("mcpp:rerun-if-changed=build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -int main() { -#ifdef MSVC_BUILD_PROGRAM_RAN - std::println("msvc-build-mcpp-ok"); - return 0; -#else - std::println("define missing"); - return 1; -#endif -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: msvc build with build.mcpp: $out"; exit 1; } -[[ -f helper-ran ]] || { echo "FAIL: build.mcpp did not run under MSVC: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } -[[ "$run_out" == *"msvc-build-mcpp-ok"* ]] \ - || { echo "FAIL: run output: $run_out"; exit 1; } - -# 2) `mcpp:link-lib` must be spelled the MSVC way. Naming a library that is -# always present in the SDK proves the translation reached the linker: -# the GNU spelling `-ladvapi32` would be an unknown option, then LNK1181. -cat > build.mcpp <<'EOF' -#include -int main() { - std::puts("mcpp:link-lib=advapi32"); - std::puts("mcpp:cfg=MSVC_LINK_LIB_OK"); - std::puts("mcpp:rerun-if-changed=build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -int main() { -#ifdef MSVC_LINK_LIB_OK - std::println("msvc-link-lib-ok"); - return 0; -#else - return 1; -#endif -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: msvc link-lib translation: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (link-lib): $run_out"; exit 1; } -[[ "$run_out" == *"msvc-link-lib-ok"* ]] \ - || { echo "FAIL: run output (link-lib): $run_out"; exit 1; } - -# 3) Named modules under cl.exe. build.mcpp is "one host C++ program", and the -# main build has compiled those with modules under cl.exe for a while -# (e2e 99 produces real .ifc artifacts) — so build.mcpp must too. It used to -# refuse, because it hand-rolled its own compile path instead of reading the -# shared BmiTraits/CommandDialect rows. -cat > build.mcpp <<'EOF' -import std; -import mcpp; -int main() { - std::string tag = std::format("MSVC_MODULES_{}", 1 + 1); - mcpp::define(tag.c_str()); - mcpp::rerun_if_changed("build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -int main() { -#ifdef MSVC_MODULES_2 - std::println("msvc-modules-ok"); - return 0; -#else - std::println("define missing"); - return 1; -#endif -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: msvc build.mcpp with modules: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (modules): $run_out"; exit 1; } -[[ "$run_out" == *"msvc-modules-ok"* ]] \ - || { echo "FAIL: run output (modules): $run_out"; exit 1; } - -# The .ifc really came from the msvc module pipeline, not a silent fallback. The -# bundled module is kept by key, in the global cache or the workspace's own store -# (#748), so both are searched. -CACHE_ROOT="$("$MCPP" cache dir | head -1)" -find "$CACHE_ROOT/pkg/_engine" target/.build-mcpp -name "*.ifc" 2>/dev/null | grep -q . \ - || { echo "FAIL: no .ifc produced for the bundled mcpp module"; exit 1; } - -echo "PASS: MSVC build.mcpp — include path, link-lib translation, named modules" diff --git a/tests/e2e/181_build_mcpp_import_std.sh b/tests/e2e/181_build_mcpp_import_std.sh deleted file mode 100755 index ee70ed5a6..000000000 --- a/tests/e2e/181_build_mcpp_import_std.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# 181_build_mcpp_import_std.sh — build.mcpp can `import std;` -# -# mcpp asks projects to `import std;` everywhere, then made their build script -# fall back to `#include` — there was no std BMI channel in the build.mcpp -# compile at all. This locks the gap shut. Cross-platform on purpose: -# `import std;` is not a Windows-specific concern, and running it on Linux -# gives the fastest feedback. -set -e - -TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT -cd "$TMP" -"$MCPP" new imp_std >/dev/null 2>&1 -cd imp_std - -# 1) `import std;` alone — the container/algorithm/format surface a real build -# script reaches for, none of which is available without the std module. -cat > build.mcpp <<'EOF' -import std; -int main() { - std::vector defines{"MCPP_FROM_IMPORT_STD", "MCPP_STD_COUNT_2"}; - std::ranges::sort(defines); - for (auto const& d : defines) std::println("mcpp:cfg={}", d); - std::println("mcpp:rerun-if-changed=build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -int main() { -#if defined(MCPP_FROM_IMPORT_STD) && defined(MCPP_STD_COUNT_2) - std::println("import-std-ok"); - return 0; -#else - std::println("defines missing"); - return 1; -#endif -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: build with import std: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } -[[ "$run_out" == *"import-std-ok"* ]] \ - || { echo "FAIL: run output: $run_out"; exit 1; } - -# 2) `import std;` together with `import mcpp;` — both module channels active -# at once. These share the staged-BMI cwd, and an implementation that -# handles them as two independent conditions gets the cwd wrong for one. -cat > build.mcpp <<'EOF' -import std; -import mcpp; -int main() { - std::string tag = std::format("MCPP_BOTH_{}", 1 + 1); - mcpp::define(tag.c_str()); - mcpp::rerun_if_changed("build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -int main() { -#ifdef MCPP_BOTH_2 - std::println("both-modules-ok"); - return 0; -#else - std::println("define missing"); - return 1; -#endif -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: build with import std + mcpp: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (both): $run_out"; exit 1; } -[[ "$run_out" == *"both-modules-ok"* ]] \ - || { echo "FAIL: run output (both): $run_out"; exit 1; } - -# 3) An `#include`-only build.mcpp must still take the plain path — no std BMI -# staged, no -fmodules, cwd = project root. Regression guard: the naive -# detector ("does the text contain 'import std'") would fire on a comment. -cat > build.mcpp <<'EOF' -#include -// This program deliberately mentions import std; in a comment. -int main() { - std::puts("mcpp:cfg=MCPP_PLAIN_PATH"); - std::puts("mcpp:rerun-if-changed=build.mcpp"); - return 0; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -int main() { -#ifdef MCPP_PLAIN_PATH - std::println("plain-path-ok"); - return 0; -#else - return 1; -#endif -} -EOF - -out=$("$MCPP" build 2>&1) || { echo "FAIL: build plain build.mcpp: $out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (plain): $run_out"; exit 1; } -[[ "$run_out" == *"plain-path-ok"* ]] \ - || { echo "FAIL: run output (plain): $run_out"; exit 1; } - -echo "PASS: build.mcpp import std (alone, with import mcpp, and the plain path)" diff --git a/tests/e2e/182_windows_no_msvc_fallback.sh b/tests/e2e/182_windows_no_msvc_fallback.sh deleted file mode 100755 index e13ac6fe8..000000000 --- a/tests/e2e/182_windows_no_msvc_fallback.sh +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env bash -# requires: windows no-msvc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 182_windows_no_msvc_fallback.sh — a bare Windows box builds with no setup -# -# A stock Windows install has the UCRT runtime DLLs but neither the MSVC STL -# nor the Windows SDK: both arrive only with Visual Studio's "Desktop -# development with C++" workload. mcpp's first-run default used to be clang -# targeting the MSVC ABI, which needs exactly those two — so `mcpp new && mcpp -# build` failed on every such machine, with a diagnostic from clang that said -# nothing about the working alternative sitting right next to it. -# -# The CI job that runs this masks the machine's Visual Studio first. Step 0 -# below is what makes that trustworthy: if the masking missed one of -# msvc.cppm's three discovery strategies, mcpp would still find MSVC, take the -# old path, and this test would pass for the wrong reason. Asserting that -# detection FAILS turns that silent false-green into a hard failure. -set -e - -TMP=$(mktemp -d) -# Preserve executable and loader evidence before the isolated store is removed. -# A missing helper and an unloadable helper require different repairs. -cleanup() { - local rc=$? - if [[ $rc -ne 0 ]]; then - local report="${RUNNER_TEMP:-$TMP}/bare-windows-diagnostics" - mkdir -p "$report" - find "$MCPP_HOME" -type f \( -name cc1plus.exe -o -name g++.exe -o -name '*.dll' \) \ - -printf '%p %s bytes\n' > "$report/payload-files.txt" 2>/dev/null || true - local driver_count=0 - while IFS= read -r compiler; do - driver_count=$((driver_count + 1)) - local driver_report="$report/drivers/$driver_count" - mkdir -p "$driver_report" - printf '%s\n' "$compiler" > "$driver_report/driver-path.txt" - "$compiler" --version > "$driver_report/driver-version.txt" 2>&1 || true - "$compiler" -v > "$driver_report/driver-configure.txt" 2>&1 || true - "$compiler" -print-prog-name=cc1plus > "$driver_report/driver-helper.txt" 2>&1 || true - "$compiler" -print-search-dirs > "$driver_report/driver-search.txt" 2>&1 || true - printf 'int main() { return 0; }\n' > "$driver_report/probe.cpp" - "$compiler" -v -### -c "$driver_report/probe.cpp" -o "$driver_report/probe.o" \ - > "$driver_report/driver-planned-command.txt" 2>&1 || true - local compile_rc=0 - "$compiler" -v -c "$driver_report/probe.cpp" -o "$driver_report/probe.o" \ - > "$driver_report/driver-compile.txt" 2>&1 || compile_rc=$? - printf 'direct compile exit: %s\n' "$compile_rc" >> "$driver_report/driver-compile.txt" - sha256sum "$compiler" >> "$report/sha256.txt" - # Keep DLL lookup identical while changing the executable basename. - # Compare Git Bash launch with a native PowerShell launch, so a - # name-dependent redirect cannot impersonate the cold compiler. - local probe="$(dirname "$compiler")/mcpp-driver-probe.exe" - cp "$compiler" "$probe" 2>/dev/null || true - "$probe" --version > "$driver_report/renamed-driver-version.txt" 2>&1 || true - "$probe" -print-search-dirs > "$driver_report/renamed-driver-search.txt" 2>&1 || true - local native_script='$p = $env:MCPP_DRIVER_DIAGNOSTIC; $item = Get-Item -LiteralPath $p; $item | Format-List FullName,Length,LinkType,Target; if ($env:MCPP_DRIVER_PATH_FORM -eq "long") { $p = $item.FullName }; Write-Output "invoked path: $p"; Get-FileHash -LiteralPath $p -Algorithm SHA256; & $p --version; Write-Output "version exit: $LASTEXITCODE"; & $p -print-search-dirs; Write-Output "search exit: $LASTEXITCODE"; & $p -v -c $env:MCPP_DRIVER_PROBE_SOURCE -o $env:MCPP_DRIVER_PROBE_OUTPUT; Write-Output "compile exit: $LASTEXITCODE"; Write-Output "object present: $(Test-Path -LiteralPath $env:MCPP_DRIVER_PROBE_OUTPUT)"' - MCPP_DRIVER_DIAGNOSTIC="$(cygpath -w "$compiler")" \ - MCPP_DRIVER_PATH_FORM=long \ - MCPP_DRIVER_PROBE_SOURCE="$(cygpath -w "$driver_report/probe.cpp")" \ - MCPP_DRIVER_PROBE_OUTPUT="$(cygpath -w "$driver_report/native-long-probe.o")" \ - powershell.exe -NoProfile -Command "$native_script" \ - > "$driver_report/native-driver-probe.txt" 2>&1 || true - # Preserve the exact 8.3 invocation spelling as a separate control. - # A failed short-path lookup never replaces the long-path report. - local short_driver - if short_driver=$(cygpath -d "$compiler" 2> "$driver_report/short-path-error.txt") \ - && [[ -n "$short_driver" ]]; then - printf '%s\n' "$short_driver" > "$driver_report/short-driver-path.txt" - MCPP_DRIVER_DIAGNOSTIC="$short_driver" \ - MCPP_DRIVER_PATH_FORM=short \ - MCPP_DRIVER_PROBE_SOURCE="$(cygpath -w "$driver_report/probe.cpp")" \ - MCPP_DRIVER_PROBE_OUTPUT="$(cygpath -w "$driver_report/native-short-probe.o")" \ - powershell.exe -NoProfile -Command "$native_script" \ - > "$driver_report/native-short-driver-probe.txt" 2>&1 || true - else - printf 'short path unavailable\n' > "$driver_report/native-short-driver-probe.txt" - fi - if command -v objdump >/dev/null; then - objdump -p "$compiler" > "$driver_report/driver-pe.txt" 2>&1 || true - fi - done < <(find "$MCPP_HOME/registry/data/xpkgs" -name g++.exe -type f 2>/dev/null) - local shim_count=0 - while IFS= read -r shim; do - shim_count=$((shim_count + 1)) - local shim_report="$report/shims/$shim_count" - mkdir -p "$shim_report" - printf '%s\n' "$shim" > "$shim_report/path.txt" - sha256sum "$shim" > "$shim_report/sha256.txt" - "$shim" --version > "$shim_report/version.txt" 2>&1 || true - "$shim" -print-search-dirs > "$shim_report/search.txt" 2>&1 || true - done < <(find "$MCPP_HOME/registry/subos" -name g++.exe -type f 2>/dev/null) - printf 'GCC_EXEC_PREFIX=%s\nCOMPILER_PATH=%s\nLIBRARY_PATH=%s\n' \ - "${GCC_EXEC_PREFIX:-}" "${COMPILER_PATH:-}" "${LIBRARY_PATH:-}" > "$report/driver-environment.txt" - printf 'GCC_ROOT=%s\nBINUTILS_ROOT=%s\nMCPP_E2E_GCC_ROOT=%s\n' \ - "${GCC_ROOT:-}" "${BINUTILS_ROOT:-}" "${MCPP_E2E_GCC_ROOT:-}" >> "$report/driver-environment.txt" - printf 'XLINGS_HOME=%s\nXLINGS_PROJECT_DIR=%s\nXLINGS_ACTIVE_SUBOS=%s\nPATH=%s\n' \ - "${XLINGS_HOME:-}" "${XLINGS_PROJECT_DIR:-}" "${XLINGS_ACTIVE_SUBOS:-}" "$PATH" \ - >> "$report/driver-environment.txt" - command -v g++ > "$report/ambient-driver-path.txt" 2>&1 || true - # Windows variable names are case-insensitive; Bash's lookup is not. - env | grep -Ei '^(gcc_[^=]*|binutils_root|mcpp_e2e_gcc_root|compiler_path|library_path|collect_gcc|collect_lto_wrapper|xlings_[^=]*|msys[^=]*)=' \ - > "$report/driver-environment-all-cases.txt" || true - while IFS= read -r helper; do - echo "Direct invocation: $helper" - "$helper" --version > "$report/cc1plus-version.txt" 2>&1 || \ - echo "cc1plus exit: $?" >> "$report/cc1plus-version.txt" - sha256sum "$helper" >> "$report/sha256.txt" - printf 'int diagnostic_answer() { return 42; }\n' > "$report/helper-probe.cpp" - local helper_rc=0 - "$helper" -quiet -std=c++23 "$report/helper-probe.cpp" -o "$report/helper-probe.s" \ - > "$report/cc1plus-compile.txt" 2>&1 || helper_rc=$? - printf 'direct frontend exit: %s\n' "$helper_rc" >> "$report/cc1plus-compile.txt" - if [[ -s "$report/helper-probe.s" ]]; then - printf 'assembly output: present\n' >> "$report/cc1plus-compile.txt" - else - printf 'assembly output: absent\n' >> "$report/cc1plus-compile.txt" - fi - if command -v objdump >/dev/null; then - objdump -p "$helper" > "$report/cc1plus-pe.txt" 2>&1 || true - fi - done < <(find "$MCPP_HOME" -name cc1plus.exe -type f 2>/dev/null) - powershell.exe -NoProfile -Command \ - 'Get-WinEvent -FilterHashtable @{LogName="Microsoft-Windows-Windows Defender/Operational"; StartTime=(Get-Date).AddHours(-1)} -ErrorAction SilentlyContinue | Select-Object TimeCreated,Id,Message | Format-List' \ - > "$report/defender-events.txt" 2>&1 || true - cat "$report/payload-files.txt" "$report"/drivers/*/driver-helper.txt "$report/cc1plus-version.txt" 2>/dev/null || true - echo "Diagnostics: $report" - fi - rm -rf "$TMP" - return "$rc" -} -trap cleanup EXIT -export MCPP_HOME="$TMP/mcpp-home" # isolated: no inherited default - -# ── 0) Self-check: the environment really has no usable MSVC ──────────────── -if "$MCPP" toolchain default msvc >/dev/null 2>&1; then - echo "FAIL: msvc@system still resolves — the MSVC masking is incomplete," - echo " so nothing below would be testing the no-Visual-Studio path." - exit 1 -fi - -# ── 1) First run must just work ───────────────────────────────────────────── -cd "$TMP" -"$MCPP" new bare_win >/dev/null 2>&1 || { echo "FAIL: mcpp new"; exit 1; } -cd bare_win - -build_out=$("$MCPP" build 2>&1) || { - echo "FAIL: first build on a machine without Visual Studio:" - echo "$build_out"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } -[[ "$run_out" == *"Hello"* || "$run_out" == *"hello"* ]] \ - || { echo "FAIL: unexpected run output: $run_out"; exit 1; } - -# The build must have announced the substitution rather than done it silently. -echo "$build_out" | grep -qi "x86_64-windows-gnu" \ - || { echo "FAIL: fallback not reported in build output:"; echo "$build_out"; exit 1; } - -# ── 2) Both axes persisted, so the next invocation is silent and `list` -# agrees with what the build actually used ─────────────────────────── -list_out=$("$MCPP" toolchain list 2>&1) -echo "$list_out" | grep -E '\*\s*gcc' >/dev/null \ - || { echo "FAIL: no gcc starred in Toolchains: $list_out"; exit 1; } -echo "$list_out" | grep -E '\*\s*x86_64-windows-gnu' >/dev/null \ - || { echo "FAIL: x86_64-windows-gnu not starred in Targets: $list_out"; exit 1; } - -# A second build must not re-announce the switch — the repair is persisted, -# not re-derived every time. -build2=$("$MCPP" build 2>&1) || { echo "FAIL: second build: $build2"; exit 1; } - -# ── 3) The produced exe is self-contained ────────────────────────────────── -EXE=$(find target -name "bare_win.exe" -path "*/bin/*" | head -1) -[[ -n "$EXE" ]] || { echo "FAIL: no exe produced"; exit 1; } -ISO="$TMP/iso"; mkdir -p "$ISO"; cp "$EXE" "$ISO/" -iso_rc=0 -iso_out=$(cd "$ISO" && PATH="/usr/bin:/c/Windows/System32" ./bare_win.exe 2>&1) || iso_rc=$? -[[ $iso_rc -eq 0 ]] || { - echo "FAIL: exe does not run without the toolchain on PATH (rc=$iso_rc): $iso_out" - exit 1; } - -# ── 4) An EXPLICIT choice must not be overruled ──────────────────────────── -# mcpp may revise a default it picked itself. It must not silently swap the -# ABI out from under a project that asked for MSVC in writing — a project -# linking vcpkg-built .lib files is far better served by an error. The MSVC -# ABI is written as the project's target: llvm alone does not ask for it, since -# clang pairs with the MinGW-w64 payload of x86_64-windows-gnu (SPEC-016 §6), -# which step 2 made this machine's default target. -cd "$TMP" -"$MCPP" new explicit_msvc >/dev/null 2>&1 -cd explicit_msvc -cat >> mcpp.toml <&1) -exp_rc=$? -set -e -[[ $exp_rc -ne 0 ]] || { - echo "FAIL: an explicit MSVC target with [toolchain] windows = llvm was silently replaced" - echo "$exp_out"; exit 1; } -echo "$exp_out" | grep -q "x86_64-windows-gnu" || { - echo "FAIL: the error does not point at the working alternative:" - echo "$exp_out"; exit 1; } - -echo "PASS: bare Windows — fallback, persistence, self-contained exe, explicit choice respected" diff --git a/tests/e2e/183_cross_no_relink.sh b/tests/e2e/183_cross_no_relink.sh deleted file mode 100755 index 14ea5f8f6..000000000 --- a/tests/e2e/183_cross_no_relink.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# requires: mingw-cross -# -# A cross build must not relink on every invocation. -# -# plan.cppm's target_output() used to spell the artifact suffix from -# mcpp::platform::exe_suffix — a HOST constant. Cross-compiling from Linux to -# a PE target, that yields `bin/foo` while mingw's GCC driver writes -# `bin/foo.exe`, so the file ninja was told to produce never appears. ninja -# finds the declared output missing on every run and reruns the link edge -# forever: incremental builds are effectively off for PE targets. -# -# The symptom is invisible to the other cross tests because they look for the -# REAL artifact (`find -name '*.exe'`), not for what ninja declared — both of -# their assertions hold while the inconsistency sits underneath them. -# -# See .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md. -set -euo pipefail - -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT -cd "$TMP" - -mkdir -p src -cat > mcpp.toml <<'EOF' -[package] -name = "relinkprobe" -version = "0.1.0" -EOF - -cat > src/main.cpp <<'EOF' -int main() { return 0; } -EOF - -TRIPLE=x86_64-windows-gnu - -"$MCPP" build --target "$TRIPLE" > "$TMP/build1.log" 2>&1 || { - echo "first cross build failed:"; cat "$TMP/build1.log"; exit 1; } - -# The produced artifact — whatever it is actually called. -ART="$(find "target/$TRIPLE" -type f -path '*/bin/*' -name 'relinkprobe*' | head -1)" -[[ -n "$ART" ]] || { echo "no artifact produced under target/$TRIPLE"; exit 1; } - -# ── The declared ninja output must be the file that actually gets written ──── -# This is the root assertion. Positive grep on purpose: `! cmd | grep` is exempt -# from errexit and can never fail. -NINJA="$(find "target/$TRIPLE" -name build.ninja | head -1)" -[[ -n "$NINJA" ]] || { echo "no build.ninja found"; exit 1; } - -DECLARED="$(grep -oE '^build [^:]*bin/relinkprobe[^ :]*' "$NINJA" | head -1 | sed 's/^build //' | tr -d ' ')" -[[ -n "$DECLARED" ]] || { echo "no link edge for relinkprobe in build.ninja"; exit 1; } - -BUILDDIR="$(dirname "$NINJA")" -if [[ ! -f "$BUILDDIR/$DECLARED" ]]; then - echo "FAIL: ninja declares output '$DECLARED' but that file does not exist" - echo " actually produced: $(basename "$ART")" - echo " => the link edge can never be satisfied, so it reruns every build" - exit 1 -fi - -# ── And the observable consequence: a second build must not relink ────────── -T1="$(stat -c %Y "$ART" 2>/dev/null || stat -f %m "$ART")" -sleep 1 # ensure a relink would be visible at 1s mtime granularity -"$MCPP" build --target "$TRIPLE" > "$TMP/build2.log" 2>&1 || { - echo "second cross build failed:"; cat "$TMP/build2.log"; exit 1; } -T2="$(stat -c %Y "$ART" 2>/dev/null || stat -f %m "$ART")" - -if [[ "$T1" != "$T2" ]]; then - echo "FAIL: artifact was relinked on an up-to-date rebuild ($T1 -> $T2)" - echo " ninja declared: $DECLARED" - echo " produced: $(basename "$ART")" - exit 1 -fi - -echo "OK: cross build declares the artifact it actually produces, and does not relink" diff --git a/tests/e2e/183_cxx_runtime_contract.sh b/tests/e2e/183_cxx_runtime_contract.sh deleted file mode 100755 index f471c9e0c..000000000 --- a/tests/e2e/183_cxx_runtime_contract.sh +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env bash -# mcpp-community/mcpp#336 — the C++ runtime distribution contract. -# -# Three things this locks down, all of which were broken or absent before: -# -# 1. A global object that uses std::cout during static initialization must -# run. On macOS with the default (self-contained → static libc++) it -# SIGSEGV'd at process start: Mach-O runs __init_offsets in link order and -# has no priority-ordered init section, so the stream initializer pulled -# out of libc++.a landed last and `std::cout`'s vptr was still zero. -# libc++'s carries no `ios_base::Init` guard of its own — the -# remedy the standard provides and libstdc++/MSVC STL use — so no amount -# of package-side code could fix it either. This is the repro from the -# issue, verbatim. -# -# 2. `cxx_runtime = "host-coupled"` (and its older spelling -# `static_stdlib = false`) must reach TEST binaries. It silently did not -# from 0.0.86 to 2026.8.2.2 while the documentation kept promising it, -# because the test link path was a second, ungated derivation of the same -# decision. -# -# 3. The contract is a claim about the artifact's runtime dependency set, so -# it is checked against the artifact, not against the flags we passed. -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -cd "$TMP" - -case "$(uname -s)" in - Darwin) HOST=macos ;; - MINGW*|MSYS*|CYGWIN*) HOST=windows ;; - *) HOST=linux ;; -esac - -# ── the repro ────────────────────────────────────────────────────────────── -mkdir -p siof/src siof/tests -cat > siof/mcpp.toml <<'EOF' -[package] -name = "siof" -version = "0.1.0" -EOF - -# `std::cout.rdbuf()` during static init. Touching the stream is the point: -# merely taking its address would not fault. -cat > siof/src/main.cpp <<'EOF' -#include -struct Early { - Early() { probe = std::cout.rdbuf(); } - std::streambuf* probe = nullptr; -}; -static Early early_user; -int main() { - std::cout << "static-init-ok\n"; - return early_user.probe == nullptr; -} -EOF - -# The same hazard in a test binary — the role whose opt-out was unreachable. -cat > siof/tests/early.cpp <<'EOF' -#include -struct Early { - Early() { probe = std::cout.rdbuf(); } - std::streambuf* probe = nullptr; -}; -static Early early_user; -int main() { - std::cout << "static-init-ok\n"; - return early_user.probe == nullptr; -} -EOF - -cd siof - -echo "== 1. default (self-contained): the repro must run ==" -"$MCPP" build >/dev/null 2>&1 || { echo "FAIL: default build"; exit 1; } -run_out=$("$MCPP" run 2>&1) || { - echo "FAIL: default-profile binary crashed during static initialization" - echo "$run_out" - echo "(macOS: this is #336 — the libc++ stream initializer ordered last" - echo " in __init_offsets. Check that the mcpp_ios_init object is the FIRST" - echo " input of the link edge in target/*/*/build.ninja.)" - exit 1; } -echo "$run_out" | grep -q "static-init-ok" || { - echo "FAIL: expected static-init-ok, got: $run_out"; exit 1; } - -echo "== 2. the same, as a test binary ==" -test_out=$("$MCPP" test 2>&1) || { - echo "FAIL: test binary crashed during static initialization" - echo "$test_out"; exit 1; } -echo "$test_out" | grep -q "test result ok" || { - echo "FAIL: unexpected test output: $test_out"; exit 1; } - -# On macOS the shim is what makes 1 and 2 pass, so assert it is actually -# there and actually first — a silently-absent shim would leave this test -# passing by luck on a future toolchain and failing for users on another. -if [[ "$HOST" == macos ]]; then - NINJA=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) - grep -q "mcpp_ios_init" "$NINJA" || { - echo "FAIL: no initializer-ordering shim in the macOS link"; exit 1; } - # `build : cxx_link ...` - link_line=$(grep -E "^build .*: cxx_link " "$NINJA" | head -1) - first_in=$(echo "$link_line" | sed -E 's/^build .*: cxx_link +([^ ]+).*/\1/') - case "$first_in" in - *mcpp_ios_init*) ;; - *) echo "FAIL: shim object is not the first link input: $link_line" - exit 1 ;; - esac -fi - -echo "== 3. cxx_runtime = host-coupled reaches BOTH roles (#336 part one) ==" -# Capture the self-contained shape first so the comparison is against this -# machine's actual output rather than a hard-coded flag string. -NINJA=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -self_units=$(grep -c "unit_ldflags" "$NINJA" || true) - -cat >> mcpp.toml <<'EOF' - -[build] -cxx_runtime = "host-coupled" -EOF -"$MCPP" build >/dev/null 2>&1 || { echo "FAIL: host-coupled build"; exit 1; } -"$MCPP" test >/dev/null 2>&1 || { echo "FAIL: host-coupled test"; exit 1; } - -# Whatever the platform's self-contained mechanism is (-static-libstdc++, -# -static, -load_hidden archives), host-coupled must not carry it — for the -# test target (test.ninja, 2026.11.10.1) as much as for the binary (build.ninja). -for graph in build.ninja test.ninja; do - NINJA=$(find target -name "$graph" -not -path '*/.build-mcpp/*' | head -1) - [ -n "$NINJA" ] || { echo "FAIL: no $graph"; exit 1; } - if grep -E "unit_ldflags.*(-static-libstdc\+\+|-load_hidden|-nostdlib\+\+)" "$NINJA" >/dev/null; then - echo "FAIL: host-coupled still emits a self-contained mechanism in $graph:" - grep "unit_ldflags" "$NINJA" - exit 1 - fi -done - -echo "== 4. the older spelling means the same thing ==" -"$MCPP" build >/dev/null 2>&1 -sed 's/cxx_runtime = "host-coupled"/static_stdlib = false/' mcpp.toml > mcpp.toml.tmp -mv mcpp.toml.tmp mcpp.toml -"$MCPP" build >/dev/null 2>&1 || { echo "FAIL: static_stdlib=false build"; exit 1; } -NINJA=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -if grep -E "unit_ldflags.*(-static-libstdc\+\+|-load_hidden|-nostdlib\+\+)" "$NINJA" >/dev/null; then - echo "FAIL: static_stdlib = false is not an alias of host-coupled:" - grep "unit_ldflags" "$NINJA" - exit 1 -fi - -echo "== 5. an invalid contract is rejected at parse time ==" -sed 's/static_stdlib = false/cxx_runtime = "static"/' mcpp.toml > mcpp.toml.tmp -mv mcpp.toml.tmp mcpp.toml -set +e -bad_out=$("$MCPP" build 2>&1) -bad_rc=$? -set -e -[[ $bad_rc -ne 0 ]] || { echo "FAIL: invalid cxx_runtime accepted"; exit 1; } -echo "$bad_out" | grep -q "self-contained" || { - echo "FAIL: the error does not list the valid values: $bad_out"; exit 1; } - -echo "== 6. self-contained is checked against the ARTIFACT ==" -# The contract is a claim about the runtime dependency set. Assert it there, -# not on the flags — a mechanism that stops working would otherwise keep -# reporting success. (The per-OS allowed set is deliberately narrow.) -cd "$TMP" -mkdir -p dist/src -cat > dist/mcpp.toml <<'EOF' -[package] -name = "dist" -version = "0.1.0" -EOF -cat > dist/src/main.cpp <<'EOF' -#include -int main() { std::cout << "hi\n"; } -EOF -cd dist -"$MCPP" build >/dev/null 2>&1 || { echo "FAIL: dist build"; exit 1; } - -case "$HOST" in - macos) - BIN=$(find target -path "*/bin/dist" -type f | head -1) - [[ -n "$BIN" ]] || { echo "FAIL: no binary"; exit 1; } - deps=$(otool -L "$BIN" | tail -n +2 | awk '{print $1}') - # A self-contained macOS artifact links libSystem and nothing else from - # /usr/lib — in particular NOT /usr/lib/libc++.1.dylib, which is what - # would pin it to the build machine's OS version. - if echo "$deps" | grep -q "libc++"; then - echo "FAIL: self-contained artifact still depends on a libc++ dylib:" - echo "$deps"; exit 1 - fi - ;; - linux) - BIN=$(find target -path "*/bin/dist" -type f | head -1) - [[ -n "$BIN" ]] || { echo "FAIL: no binary"; exit 1; } - if command -v readelf >/dev/null 2>&1; then - needed=$(readelf -d "$BIN" 2>/dev/null | grep NEEDED || true) - # libstdc++/libc++ must not be there; libc/libm/loader may be. - if echo "$needed" | grep -Eq "libstdc\+\+|libc\+\+"; then - echo "FAIL: self-contained artifact still depends on a C++ runtime:" - echo "$needed"; exit 1 - fi - else - echo " (readelf unavailable — dependency-set assertion skipped)" - fi - ;; - windows) - # PE has no rpath and no ldd; the equivalent check is that the exe runs - # with the toolchain off PATH. e2e 182 already owns that assertion for - # the MinGW leg, so this one stays on the flag contract. - echo " (windows: dependency-set assertion covered by e2e 182)" - ;; -esac - -echo "PASS: cxx runtime contract" diff --git a/tests/e2e/184_build_cache_object_layout.sh b/tests/e2e/184_build_cache_object_layout.sh deleted file mode 100755 index 3eb2b4828..000000000 --- a/tests/e2e/184_build_cache_object_layout.sh +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 184_build_cache_object_layout.sh — mcpp#344. -# -# A dependency's objects live in the global cache under a key that deliberately -# excludes the consumer. So the LAYOUT of those objects inside the entry must -# also exclude the consumer — otherwise two projects that share a key disagree -# about where the files are, and whichever runs SECOND asks the entry for a path -# the first never wrote. -# -# That is what happened. Basename disambiguation (#233) was decided by a census -# over every unit in the build directory, so `lib-a`'s `compress.c` compiled to -# obj/compress.o in a project that pulls lib-a alone -# obj/lib_a/src/compress.o in one that also pulls lib-b -# (lib-b ships its own compress.c) -# with the SAME cache key. The second project's build died in ninja's GRAPH -# phase — before a single command ran — with -# ninja: error: '/…/obj/compress.o', needed by 'obj/compress.o', -# missing and no known rule to make it -# one line after the CLI printed "Cached lib-a (N units)". -# -# The test runs both orderings, because the defect is symmetric: whichever -# project is second is the one that breaks. -# -# TWO assertions per direction, and the second one is not optional: -# 1. the build succeeds -# 2. the reused dependency has ZERO compile edges -# Without (2) this test would pass on a build that merely degraded every hit to -# a miss — which is a real regression (the cache silently stops paying for -# itself) that "it built fine" cannot see. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -INDEX_DIR="$TMP/local-index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/l" - -# ── two library packages that COLLIDE on a source basename ─────────────────── -# Neither package can avoid this: upstream zlib and upstream bzip2 both ship a -# file called compress.c, and a descriptor has no field that controls object -# paths. The collision has to be handled by mcpp or not at all. -make_descriptor() { # $1 = package name - cat > "$INDEX_DIR/pkgs/l/$1.lua" < ` -make_project() { - local dir="$1" name="$2" withb="$3" - local proj="$TMP/$dir" - mkdir -p "$proj/src" - - local payload="$proj/.mcpp/.xlings/data/xpkgs" - mkdir -p "$payload/local-dev.lib-a/1.0.0/src" - cat > "$payload/local-dev.lib-a/1.0.0/src/compress.c" <<'EOF' -int a_value(void) { return 40; } -EOF - local deps='"local-dev.lib-a" = "1.0.0"' - local extern='extern "C" int a_value(void);' - local expr='a_value() + 2' - if [[ "$withb" == "yes" ]]; then - mkdir -p "$payload/local-dev.lib-b/1.0.0/src" - cat > "$payload/local-dev.lib-b/1.0.0/src/compress.c" <<'EOF' -int b_value(void) { return 1; } -EOF - deps="$deps"$'\n' - deps="$deps"'"local-dev.lib-b" = "1.0.0"' - extern="$extern"$'\n''extern "C" int b_value(void);' - expr='a_value() + b_value() + 1' - fi - - cat > "$proj/src/main.cpp" < "$proj/mcpp.toml" < build.log 2>&1 || { - echo "FAIL: $dir failed to build ($mode)" - cat build.log - exit 1 - } - local nj; nj="$(find_ninja "$TMP/$dir")" - [[ -n "$nj" ]] || { echo "FAIL: $dir has no build.ninja"; exit 1; } - - if [[ "$mode" == "reuses" ]]; then - local edges; edges="$(a_compile_edges "$nj")" - if [[ "$edges" != "0" ]]; then - echo "FAIL: $dir recompiled lib-a ($edges compile edges) instead of" - echo " reusing the entry the other project populated." - echo " A hit that silently became a miss is still a regression:" - echo " the cache stops paying for itself with no signal." - grep -nE ': (cxx_module|cxx_object|c_object|cxx_scan) .*lib-a' "$nj" | head - exit 1 - fi - local staged; staged="$(a_stage_edges "$nj")" - [[ "$staged" -gt 0 ]] || { - echo "FAIL: $dir has neither compile nor stage edges for lib-a" - grep -n 'lib-a' "$nj" | head - exit 1 - } - fi - - ./target/*/*/bin/"$name" > run.log 2>&1 || { cat run.log; exit 1; } - grep -q '^42$' run.log || { - echo "FAIL: $dir produced the wrong answer — staged the wrong object?" - cat run.log - exit 1 - } -} - -make_project both both yes -make_project onlya onlya no - -# ── direction A: the project with BOTH libraries runs first ────────────────── -# It disambiguates (two compress.c in one build dir), so the entry is written -# with a nested layout; `onlya` then asks for the flat one. -rm -rf "$MCPP_HOME/build-cache" -check_project both both cold -check_project onlya onlya reuses - -# ── direction B: the single-library project runs first (fully symmetric) ───── -# Now the entry is written flat and `both` asks for the nested one. Before the -# fix this direction failed just as hard, with the paths swapped. -rm -rf "$MCPP_HOME/build-cache" -check_project onlya onlya cold -check_project both both reuses - -# ── the entry's own addresses must be package-internal ─────────────────────── -# `cache verify` reports an obj address that escaped its entry — the offline -# form of the same invariant, so a recurrence is auditable without reproducing -# a two-project build. -cd "$TMP/both" -"$MCPP" cache verify > verify.log 2>&1 || { - echo "FAIL: cache verify reported incomplete entries" - cat verify.log - exit 1 -} - -echo "OK" diff --git a/tests/e2e/185_index_floor_degrades.sh b/tests/e2e/185_index_floor_degrades.sh deleted file mode 100755 index 42a68ceb8..000000000 --- a/tests/e2e/185_index_floor_degrades.sh +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 185_index_floor_degrades.sh — an index cannot decide whether mcpp works. -# -# An index tree may declare a client-version floor (`index.toml` min_mcpp). Two -# behaviours around that floor had NO end-to-end coverage at all — only the pure -# predicate `floor_violation()` was unit-tested — which is why both were wrong -# for as long as the feature has existed: -# -# 1. A floor violation made every descriptor read return "nothing", which is -# the same answer as "this package is not in this index". The build then -# died on `E_NOT_FOUND ... wire address tried: ...` — naming neither the -# version nor the floor. The cause was printed much earlier, and the line -# that actually stopped the build pointed at addressing instead. -# -# 2. That same indistinguishable miss fed the refresh policy, which read it as -# "the local index is missing something, go fetch" — so an unusable index -# drove repeated refreshes of itself. -# -# Both assertions below are about what the user READS, because that is what was -# broken. `mcpp explain E0006` and the floor predicate were fine throughout. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -# ── two path indexes: one readable, one that demands a newer mcpp ──────────── -make_index() { # $1 = dir, $2 = min_mcpp ("" = no contract), $3 = pkg name - local dir="$1" floor="$2" pkg="$3" - mkdir -p "$dir/pkgs/${pkg:0:1}" - if [[ -n "$floor" ]]; then - cat > "$dir/index.toml" < "$dir/pkgs/${pkg:0:1}/$pkg.lua" < src/main.cpp <<'EOF' -import std; -int main() { std::println("ok"); return 0; } -EOF - -# ── 1. an unusable index must say so — not "package not found" ─────────────── -cat > mcpp.toml < build.log 2>&1 -rc=$? -set -e -[[ "$rc" != "0" ]] || { echo "FAIL: build unexpectedly succeeded"; cat build.log; exit 1; } - -grep -q "E0006" build.log || { - echo "FAIL: the failure never mentions E0006 — the user cannot tell that the" - echo " cause is an index that needs a newer mcpp." - cat build.log - exit 1 -} - -# THE assertion that was missing, and it has to be precise. -# -# "E0006 appears somewhere in the output" is NOT enough — it always did, and a -# test asserting only that passes on the broken build too (verified). What was -# broken is that the message which STOPS the build said only -# -# error: dependency 'toonew.newlib': not found in local index at '...' -# -# i.e. it blamed the package, pointing the user at publication or naming, while -# the actual answer (upgrade mcpp) had scrolled past. So: assert on the LAST -# error, and assert on the specific text that ties the two together. -last_error="$(grep -E '^error:' build.log | tail -1)" -echo "$last_error" | grep -q "not found" && { - # A bare not-found as the final word is the exact regression. It is only - # acceptable if the cause is attached to it. - grep -A 4 "$(echo "$last_error" | head -c 40)" build.log \ - | grep -qE "cannot read|E0006" || { - echo "FAIL: the build stopped on a bare 'not found' with no mention of the" - echo " index that could not be read. That blames the package for a" - echo " problem whose fix is 'upgrade mcpp'." - echo "--- output ---" - cat build.log - exit 1 - } -} -grep -q "this mcpp cannot read" build.log || { - echo "FAIL: nothing in the output names the unreadable index as the cause." - cat build.log - exit 1 -} -echo "PASS: an unusable index reports E0006 as the cause" - -# ── 2. an unusable index must not take a healthy one down with it ──────────── -# INV-2: the blast radius of a floor bump is the index that declared it. -cat > mcpp.toml < iso.log 2>&1 -iso_rc=$? -set -e -[[ "$iso_rc" == "0" ]] || { - echo "FAIL: a project that depends on NOTHING from the too-new index still" - echo " failed to build — an unusable index is not supposed to be" - echo " contagious." - cat iso.log - exit 1 -} -echo "PASS: an unusable index does not break a build that does not need it" - -# ── 3. the escape hatch still works ───────────────────────────────────────── -# It exists for debugging; if it ever stops working the only lever a blocked -# user has is gone. -cat > mcpp.toml < ignore.log 2>&1 || { - echo "FAIL: MCPP_INDEX_FLOOR=ignore no longer bypasses the floor" - cat ignore.log - exit 1 -} -grep -q "E0006" ignore.log && { - echo "FAIL: MCPP_INDEX_FLOOR=ignore still reported E0006" - cat ignore.log - exit 1 -} -echo "PASS: MCPP_INDEX_FLOOR=ignore bypasses the floor" - -echo "OK" diff --git a/tests/e2e/186_build_mcpp_protocol_and_bound.sh b/tests/e2e/186_build_mcpp_protocol_and_bound.sh deleted file mode 100755 index a506d1e3e..000000000 --- a/tests/e2e/186_build_mcpp_protocol_and_bound.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 186_build_mcpp_protocol_and_bound.sh — the build.mcpp CONTRACT hardening: -# wire-protocol version, the cache's semantic epoch, and the run bound. -# -# What each part protects against: -# * protocol — a build.mcpp written for a newer mcpp used to have its -# unknown directives WARNED about and dropped, producing a -# silently different build. It must now refuse. -# * legacy — a hand-written printf program announces nothing; its surface -# is frozen, so its unknown keys stay a warning (a typo), not -# an error. This asymmetry is the compatibility contract and -# has to be pinned by a test or it will be "simplified" away. -# * epoch — a cache entry written under a different directive -# interpretation must not be replayed under this one. -# * run bound — a build program that hangs used to hang the whole build with -# no diagnostic at all. -# -# See .agents/docs/2026-08-30-build-mcpp-extensibility-architecture.md §4. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p app/src -cd app - -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF - -cat > src/main.cpp <<'EOF' -#ifndef FROM_BUILD_MCPP -#error "define missing" -#endif -int main() { return 0; } -EOF - -fresh() { rm -rf target; } - -# ── 1. import mcpp; announces a protocol, so an unknown directive is fatal ── -cat > build.mcpp <<'EOF' -#include -import mcpp; -int main() { - mcpp::cxxflag("-DFROM_BUILD_MCPP=1"); - std::printf("mcpp:no-such-directive=1\n"); -} -EOF -fresh -if "$MCPP" build > b1.log 2>&1; then - cat b1.log; echo "FAIL: unknown directive from an announcing program was accepted"; exit 1 -fi -grep -q "no-such-directive" b1.log || { - cat b1.log; echo "FAIL: error does not name the offending directive"; exit 1; } -# The message must explain WHY it is fatal here but not for a printf program, -# otherwise the asymmetry reads as a bug. -grep -qi "protocol" b1.log || { - cat b1.log; echo "FAIL: error does not mention the protocol"; exit 1; } - -# ── 2. A hand-written printf program keeps warn-and-ignore ────────────────── -cat > build.mcpp <<'EOF' -#include -int main() { - std::printf("mcpp:cxxflag=-DFROM_BUILD_MCPP=1\n"); - std::printf("mcpp:no-such-directive=1\n"); -} -EOF -fresh -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: legacy printf program was rejected"; exit 1; } -grep -q "ignoring unknown directive" b2.log || { - cat b2.log; echo "FAIL: legacy program lost its warn-and-ignore behaviour"; exit 1; } - -# ── 3. A program claiming a newer protocol is refused with an upgrade hint ── -cat > build.mcpp <<'EOF' -#include -int main() { - std::printf("mcpp:protocol=999\n"); - std::printf("mcpp:cxxflag=-DFROM_BUILD_MCPP=1\n"); -} -EOF -fresh -if "$MCPP" build > b3.log 2>&1; then - cat b3.log; echo "FAIL: a newer-protocol program was accepted"; exit 1 -fi -grep -q "999" b3.log || { cat b3.log; echo "FAIL: error does not name the claimed protocol"; exit 1; } -grep -qi "upgrade" b3.log || { cat b3.log; echo "FAIL: error gives no actionable next step"; exit 1; } - -# ── 4. Run bound: a hanging program is killed and the error says so ───────── -cat > build.mcpp <<'EOF' -#include -#include -int main() { - std::printf("mcpp:cxxflag=-DFROM_BUILD_MCPP=1\n"); - std::fflush(stdout); - for (;;) ::sleep(60); -} -EOF -fresh -start=$(date +%s) -if MCPP_BUILD_PROGRAM_TIMEOUT=3 "$MCPP" build > b4.log 2>&1; then - cat b4.log; echo "FAIL: a hanging build.mcpp did not fail the build"; exit 1 -fi -elapsed=$(( $(date +%s) - start )) -[ "$elapsed" -lt 60 ] || { echo "FAIL: the bound did not fire (took ${elapsed}s)"; exit 1; } -grep -q "time limit" b4.log || { cat b4.log; echo "FAIL: no timeout diagnostic"; exit 1; } -# It must name the package: in a workspace or a dependency graph, "build.mcpp -# hung" is useless without knowing whose. -grep -q "'app'" b4.log || { cat b4.log; echo "FAIL: timeout error does not name the package"; exit 1; } -grep -q "MCPP_BUILD_PROGRAM_TIMEOUT" b4.log || { - cat b4.log; echo "FAIL: timeout error does not say how to change the bound"; exit 1; } -# ...and WHICH mcpp.toml to edit. When a DEPENDENCY's build program times out, -# the user's instinct is to edit their own manifest, which changes nothing. -grep -q "mcpp.toml" b4.log || { - cat b4.log; echo "FAIL: timeout error does not name the manifest to edit"; exit 1; } - -# ── 4b. The bound is configurable from the manifest ───────────────────────── -# -# Deliberately 2 seconds, not something near the 600s default: the point is -# that the KEY is read, and a long value would just make CI wait. -cat >> mcpp.toml <<'EOF' - -[build] -build_program_timeout = 2 -EOF -fresh -start=$(date +%s) -if "$MCPP" build > b4b.log 2>&1; then - cat b4b.log; echo "FAIL: [build] build_program_timeout did not bound the run"; exit 1 -fi -elapsed=$(( $(date +%s) - start )) -[ "$elapsed" -lt 60 ] || { echo "FAIL: the manifest bound did not fire (took ${elapsed}s)"; exit 1; } -grep -q "exceeded its 2s time limit" b4b.log || { - cat b4b.log; echo "FAIL: the manifest value is not the effective bound"; exit 1; } - -# The env var is a per-invocation override and must WIN over the manifest. -fresh -if MCPP_BUILD_PROGRAM_TIMEOUT=1 "$MCPP" build > b4c.log 2>&1; then - cat b4c.log; echo "FAIL: build succeeded under a 1s bound"; exit 1 -fi -grep -q "exceeded its 1s time limit" b4c.log || { - cat b4c.log; echo "FAIL: MCPP_BUILD_PROGRAM_TIMEOUT did not override the manifest"; exit 1; } - -# A negative value is a manifest error, not a silently-ignored one: it would -# otherwise read as "no bound" and remove the guard entirely. -cp mcpp.toml mcpp.toml.bak -sed -i.tmp 's/^build_program_timeout = 2$/build_program_timeout = -1/' mcpp.toml -if "$MCPP" build > b4d.log 2>&1; then - cat b4d.log; echo "FAIL: a negative build_program_timeout was accepted"; exit 1 -fi -grep -q "build_program_timeout" b4d.log || { - cat b4d.log; echo "FAIL: the error does not name the key"; exit 1; } -mv mcpp.toml.bak mcpp.toml -rm -f mcpp.toml.tmp - -# Restore a manifest with no bound override for the sections that follow. -sed -i '/^\[build\]$/,$d' mcpp.toml - -# ── 5. Cache: the entry carries an epoch, and a foreign one invalidates it ── -cat > build.mcpp <<'EOF' -#include -int main() { std::printf("mcpp:cxxflag=-DFROM_BUILD_MCPP=1\n"); } -EOF -fresh -"$MCPP" build > b5.log 2>&1 || { cat b5.log; echo "FAIL: build failed"; exit 1; } -CACHE=target/.build-mcpp/build.mcpp.cache -[ -f "$CACHE" ] || { echo "FAIL: no build.mcpp cache written"; exit 1; } -grep -q '^epoch ' "$CACHE" || { cat "$CACHE"; echo "FAIL: cache carries no epoch"; exit 1; } -cp "$CACHE" "$TMP/good.cache" - -# Touching a source defeats the whole-project fast path so prepare (and with it -# the build.mcpp cache) actually runs. -touch src/main.cpp -# A reused program is stated under --verbose only (build output design revision 3, §7.1). -"$MCPP" build -v > b6.log 2>&1 || { cat b6.log; echo "FAIL: build failed"; exit 1; } -grep -qE "^ *build\.mcpp .* cached" b6.log || { - cat b6.log; echo "FAIL: an unchanged build.mcpp was re-run"; exit 1; } - -sed 's/^epoch .*/epoch 987654/' "$TMP/good.cache" > "$CACHE" -touch src/main.cpp -"$MCPP" build > b7.log 2>&1 || { cat b7.log; echo "FAIL: build failed"; exit 1; } -grep -qE "^ *build\.mcpp .* ran [0-9]" b7.log || { - cat b7.log; echo "FAIL: a foreign cache epoch did not force a re-run"; exit 1; } - -# A `d` record this mcpp cannot interpret (a cache written by a NEWER mcpp) -# must invalidate the entry too — replaying the rest would apply a strict -# subset of what the program asked for. -cp "$TMP/good.cache" "$CACHE" -echo "d some-future-tag /x" >> "$CACHE" -touch src/main.cpp -"$MCPP" build > b8.log 2>&1 || { cat b8.log; echo "FAIL: build failed"; exit 1; } -grep -qE "^ *build\.mcpp .* ran [0-9]" b8.log || { - cat b8.log; echo "FAIL: an unknown cache record did not force a re-run"; exit 1; } - -echo "OK" diff --git a/tests/e2e/187_dep_host_tool.sh b/tests/e2e/187_dep_host_tool.sh deleted file mode 100755 index af9b99958..000000000 --- a/tests/e2e/187_dep_host_tool.sh +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 187_dep_host_tool.sh — #355: a dependency's `kind = "bin"` target, built for -# the HOST and handed to the consumer's build.mcpp. -# -# Why this cannot be done any other way: build.mcpp runs inside prepare, before -# the BuildPlan exists and long before build.ninja is written, so a tool -# produced by the main graph arrives too late to be called. The tool is -# therefore built by a nested, host-targeted sub-build into a global store. -# -# Covered here: -# 1. end to end — the tool is built, `mcpp::dep_bin()` finds it, the source it -# generates is compiled and linked -# 2. the cost gate — a tool target behind `required_features` is built because -# the sub-build ACTIVATES those features (in a tool sub-build the target is -# what was asked for, so its requirements are inputs, not a gate) -# 3. default-off — a consumer that does not ask gets nothing built -# 4. a bad tool name fails with the available targets listed -# 5. the override escape hatch skips the build entirely -# -# See .agents/docs/2026-08-05-issue355-dependency-host-tools-design.md. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# Isolate the tool store so a stale entry from a previous run cannot make a -# broken build look green. -export MCPP_HOME="$TMP/mcpphome" -mkdir -p "$MCPP_HOME" -# Keep the real registry (toolchains are expensive) but not its build cache. -if [ -d "$HOME/.mcpp/registry" ]; then - ln -s "$HOME/.mcpp/registry" "$MCPP_HOME/registry" -fi - -# ── the tool package ──────────────────────────────────────────────────────── -mkdir -p toolpkg/src -cat > toolpkg/mcpp.toml <<'EOF' -[package] -name = "toolpkg" -version = "0.1.0" - -[build] -sources = ["src/lib.cpp"] - -[features.codegen] -sources = ["src/codegen.cpp"] - -[targets.codegen] -kind = "bin" -main = "src/codegen.cpp" -required_features = ["codegen"] -EOF -cat > toolpkg/src/lib.cpp <<'EOF' -int toolpkg_lib() { return 1; } -EOF -# A minimal "code generator": writes a C++ source whose function returns 42. -cat > toolpkg/src/codegen.cpp <<'EOF' -#include -#include -int main(int argc, char** argv) { - if (argc < 2) return 2; - FILE* f = std::fopen(argv[1], "w"); - if (!f) return 3; - std::fprintf(f, "int generated_answer() { return 42; }\n"); - std::fclose(f); - return 0; -} -EOF - -# ── the consumer ──────────────────────────────────────────────────────────── -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -toolpkg = { path = "../toolpkg", tools = ["codegen"] } -EOF -cat > app/src/main.cpp <<'EOF' -#include -int generated_answer(); -int main() { std::printf("ANSWER=%d\n", generated_answer()); } -EOF -cat > app/build.mcpp <<'EOF' -#include -#include -#include -import mcpp; -int main() { - const char* tool = mcpp::dep_bin("toolpkg", "codegen"); - if (!tool || !*tool) { std::fprintf(stderr, "no tool path\n"); return 1; } - std::string out = std::string(mcpp::out_dir()) + "/gen.cpp"; - std::string cmd = std::string("\"") + tool + "\" \"" + out + "\""; - if (std::system(cmd.c_str()) != 0) { std::fprintf(stderr, "tool failed\n"); return 1; } - mcpp::generated(out.c_str()); -} -EOF - -cd app -"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build with a dep host tool failed"; exit 1; } -grep -q "host tool" b1.log || { cat b1.log; echo "FAIL: no tool-build announcement"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { echo "FAIL: generated source not linked: $out"; exit 1; } - -# The store keeps the tool, so a second build must not rebuild it. -rm -rf target -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: second build failed"; exit 1; } -grep -q "Building.*host tool" b2.log && { - cat b2.log; echo "FAIL: the tool was rebuilt despite a valid store entry"; exit 1; } - -# ── default-off: no `tools = [...]`, nothing gets built ───────────────────── -cd "$TMP" -mkdir -p plain/src -cat > plain/mcpp.toml <<'EOF' -[package] -name = "plain" -version = "0.1.0" - -[dependencies] -toolpkg = { path = "../toolpkg" } -EOF -cat > plain/src/main.cpp <<'EOF' -int main() {} -EOF -cd plain -"$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: plain consumer build failed"; exit 1; } -grep -q "host tool" b3.log && { - cat b3.log; echo "FAIL: a tool was provisioned for a consumer that never asked"; exit 1; } - -# ── a bad tool name names the alternatives ────────────────────────────────── -cd "$TMP" -sed 's/tools = \["codegen"\]/tools = ["nosuchtool"]/' app/mcpp.toml > app/mcpp.toml.new -mv app/mcpp.toml.new app/mcpp.toml -cd app && rm -rf target -if "$MCPP" build > b4.log 2>&1; then - cat b4.log; echo "FAIL: an unknown tool name was accepted"; exit 1 -fi -grep -q "nosuchtool" b4.log || { cat b4.log; echo "FAIL: error does not name the request"; exit 1; } -grep -q "codegen" b4.log || { - cat b4.log; echo "FAIL: error does not list the available bin targets"; exit 1; } - -# ── the override escape hatch ─────────────────────────────────────────────── -cd "$TMP" -sed 's/tools = \["nosuchtool"\]/tools = ["codegen"]/' app/mcpp.toml > app/mcpp.toml.new -mv app/mcpp.toml.new app/mcpp.toml -cat > fake_codegen.sh <<'EOF' -#!/usr/bin/env bash -printf 'int generated_answer() { return 7; }\n' > "$1" -EOF -chmod +x fake_codegen.sh -cd app && rm -rf target -MCPP_TOOL_TOOLPKG_CODEGEN="$TMP/fake_codegen.sh" "$MCPP" build > b5.log 2>&1 \ - || { cat b5.log; echo "FAIL: build with a tool override failed"; exit 1; } -grep -q "override" b5.log || { cat b5.log; echo "FAIL: the override was not reported"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=7" ]] || { echo "FAIL: the override was not actually used: $out"; exit 1; } - -# ── a NAMESPACED package is addressable by both spellings ────────────────── -# `toolpkg` above has no namespace, so its canonical and short names are the -# same string and only one env var is emitted — the two-spelling path is never -# exercised. A consumer may write either `myns.tp` or `tp`, exactly as -# mcpp::dep_dir() accepts both, and the tool lookup has to match. -cd "$TMP" -mkdir -p ns/src -cat > ns/mcpp.toml <<'EOF' -[package] -name = "myns.tp" -version = "0.1.0" - -[build] -sources = ["src/lib.cpp"] - -[targets.gen] -kind = "bin" -main = "src/gen.cpp" -EOF -printf 'int tp_lib(){return 1;}\n' > ns/src/lib.cpp -cat > ns/src/gen.cpp <<'EOF' -#include -int main(int c, char** v) { - if (c < 2) return 2; - FILE* f = std::fopen(v[1], "w"); - if (!f) return 3; - std::fprintf(f, "int gv() { return 5; }\n"); - std::fclose(f); - return 0; -} -EOF -mkdir -p nsapp/src -cat > nsapp/mcpp.toml <<'EOF' -[package] -name = "nsapp" -version = "0.1.0" - -[dependencies] -"myns.tp" = { path = "../ns", tools = ["gen"] } -EOF -printf '#include \nint gv();\nint main(){std::printf("G=%%d\\n",gv());}\n' > nsapp/src/main.cpp -cat > nsapp/build.mcpp <<'EOF' -#include -#include -#include -import mcpp; -int main() { - // BOTH spellings must resolve to the same tool. - const char* full = mcpp::dep_bin("myns.tp", "gen"); - const char* brief = mcpp::dep_bin("tp", "gen"); - if (!*full) { std::fprintf(stderr, "canonical spelling did not resolve\n"); return 1; } - if (!*brief) { std::fprintf(stderr, "short spelling did not resolve\n"); return 1; } - std::string out = std::string(mcpp::out_dir()) + "/g.cpp"; - std::string cmd = std::string("\"") + brief + "\" \"" + out + "\""; - if (std::system(cmd.c_str()) != 0) return 1; - mcpp::generated(out.c_str()); -} -EOF -cd nsapp -"$MCPP" build > b6.log 2>&1 || { cat b6.log; echo "FAIL: namespaced tool lookup failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^G=' | tail -1)" -[[ "$out" == "G=5" ]] || { echo "FAIL: namespaced tool did not generate: $out"; exit 1; } - -# ── a package that writes `namespace` and `name` apart (#647 E4.3) ───────── -# `myns.tp` above is the legacy dotted name, whose `package.name` is already the -# qualified spelling. A manifest writing `namespace = "myns2"`, `name = "tp2"` -# has `package.name == "tp2"`, and the tool used to be published under that -# spelling alone, so `dep_bin("myns2.tp2", ...)` read nothing while -# `dep_dir("myns2.tp2")` answered. -cd "$TMP" -mkdir -p ns2/src -cat > ns2/mcpp.toml <<'EOF' -[package] -name = "tp2" -namespace = "myns2" -version = "0.1.0" - -[targets.gen] -kind = "bin" -main = "src/gen.cpp" -EOF -cp ns/src/gen.cpp ns2/src/gen.cpp -mkdir -p ns2app/src -cat > ns2app/mcpp.toml <<'EOF' -[package] -name = "ns2app" -version = "0.1.0" - -[dependencies] -myns2.tp2 = { path = "../ns2", tools = ["gen"] } -EOF -printf '#include \nint gv();\nint main(){std::printf("G2=%%d\\n",gv());}\n' > ns2app/src/main.cpp -cat > ns2app/build.mcpp <<'EOF' -#include -#include -#include -import mcpp; -int main() { - const char* full = mcpp::dep_bin("myns2.tp2", "gen"); - const char* brief = mcpp::dep_bin("tp2", "gen"); - if (!*full) { std::fprintf(stderr, "qualified spelling did not resolve\n"); return 1; } - if (!*brief) { std::fprintf(stderr, "name spelling did not resolve\n"); return 1; } - std::string out = std::string(mcpp::out_dir()) + "/g.cpp"; - std::string cmd = std::string("\"") + full + "\" \"" + out + "\""; - if (std::system(cmd.c_str()) != 0) return 1; - mcpp::generated(out.c_str()); -} -EOF -cd ns2app -"$MCPP" build > b7.log 2>&1 || { cat b7.log; echo "FAIL: a namespace + name package's tool was not addressable by its qualified name"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^G2=' | tail -1)" -[[ "$out" == "G2=5" ]] || { echo "FAIL: the namespace + name tool did not generate: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/188_build_actions.sh b/tests/e2e/188_build_actions.sh deleted file mode 100755 index 54e0393bb..000000000 --- a/tests/e2e/188_build_actions.sh +++ /dev/null @@ -1,494 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 188_build_actions.sh — `mcpp:action=`: a build program DECLARES work instead -# of doing it, and the work becomes an edge in the build graph. -# -# The distinction this tests is the architectural one: a build program is a -# good place to decide what a build looks like (CONFIGURATION) and a bad place -# to perform it (WORK). Work done inside the program is serial, whole-set and -# reported as "build.mcpp exited 1". Declared as a node it is incremental, -# parallel and attributable to the edge that failed. -# -# All four wirings of the one primitive: -# source — outputs join the compile set, and are REGENERATED when an input -# changes (the property the eager path can never have) -# check — outputs are a stamp; a failing check fails the build -# object — outputs join the LINK set (mcpp#365). Without it the link inputs -# were the one attachment point a build graph obviously has and -# this table could not express, so people routed around the graph -# through `[build].ldflags` and lost incrementality. -# artifact — inputs are link outputs, so ninja orders it after the link with -# no phase machinery at all -# -# Also: a malformed action is refused rather than silently skipped. -# -# See .agents/docs/2026-08-30-build-mcpp-extensibility-architecture.md §3.1. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p app/src app/data -cd app - -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF - -cat > src/main.cpp <<'EOF' -#include -int generated_value(); -int main() { std::printf("VALUE=%d\n", generated_value()); } -EOF - -echo "11" > data/value.txt - -# The "generator": reads data/value.txt, writes a .cpp returning that number. -cat > gen.sh <<'EOF' -#!/usr/bin/env bash -printf 'int generated_value() { return %s; }\n' "$(cat "$1")" > "$2" -EOF -chmod +x gen.sh - -# A check that passes or fails depending on a marker file. -cat > check.sh <<'EOF' -#!/usr/bin/env bash -# The marker path is passed in: ninja runs commands with cwd = the BUILD dir, -# not the project root, so a bare relative name would never be found. -[ -f "$2" ] && { echo "check: refusing"; exit 1; } -: > "$1" -EOF -chmod +x check.sh - -cat > pack.sh <<'EOF' -#!/usr/bin/env bash -# $1 = the linked binary, $2 = the artifact to produce -cp "$1" "$2" -EOF -chmod +x pack.sh - -cat > build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - const std::string out = std::string(mcpp::out_dir()) + "/gen.cpp"; - - mcpp::action a; - a.id = "generate"; - a.role = "source"; - a.arg((root + "/gen.sh").c_str()).arg((root + "/data/value.txt").c_str()).arg(out.c_str()) - .input((root + "/data/value.txt").c_str()) - .output(out.c_str()) - .submit(); - - mcpp::action c; - c.id = "lint"; - c.role = "check"; - c.arg((root + "/check.sh").c_str()).arg("${mcpp.out_dir}/lint.stamp") - .arg((root + "/FAIL_THE_CHECK").c_str()) - .output("${mcpp.out_dir}/lint.stamp") - .submit(); - - mcpp::action p; - p.id = "package"; - p.role = "artifact"; - p.arg((root + "/pack.sh").c_str()).arg("${mcpp.target_file:app}").arg("${mcpp.out_dir}/app.pack") - .input("${mcpp.target_file:app}") - .output("${mcpp.out_dir}/app.pack") - .submit(); -} -EOF - -# ── 1. all three roles in one build ───────────────────────────────────────── -"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build with actions failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^VALUE=' | tail -1)" -[[ "$out" == "VALUE=11" ]] || { echo "FAIL: generated source not linked: $out"; exit 1; } - -OUTDIR=$(find target -name 'app.pack' -printf '%h\n' 2>/dev/null | head -1) -[ -n "$OUTDIR" ] || { cat b1.log; echo "FAIL: artifact action produced nothing"; exit 1; } -[ -f "$OUTDIR/lint.stamp" ] || { echo "FAIL: check action produced no stamp"; exit 1; } - -# ── 2. incrementality — the property a pre-pass cannot have ──────────────── -# Changing the generator's INPUT must regenerate, without build.mcpp re-running. -echo "23" > data/value.txt -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: rebuild failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^VALUE=' | tail -1)" -[[ "$out" == "VALUE=23" ]] || { - cat b2.log; echo "FAIL: action did not re-run when its input changed: $out"; exit 1; } - -# ...and an unrelated rebuild must NOT re-run it (that is the whole point). -# -# THIS ASSERTION USED TO BE VACUOUS, AND THAT IS WORTH THE COMMENT. It read -# `grep -q "GENERATE" b3.log && FAIL`, on the assumption that ninja's -# description line for the edge reaches this log. It does not: `mcpp build` -# passes `--quiet` to ninja whenever it is not `--verbose`, and surfaces -# ninja's captured stdout only on failure. A from-scratch build, which -# unquestionably runs the action, prints no such line either -- so the grep -# never matched and the check could not fail whether the action reran or not. -# -# The generated file's modification time is a direct measurement of the thing -# being asserted: the action rewrites it whenever it runs. -before=$(stat -c %Y "$(find target -name gen.cpp -print -quit)" 2>/dev/null \ - || stat -f %m "$(find target -name gen.cpp -print -quit)") -touch src/main.cpp -sleep 1 # coarser than any filesystem's mtime granularity here -"$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: rebuild failed"; exit 1; } -after=$(stat -c %Y "$(find target -name gen.cpp -print -quit)" 2>/dev/null \ - || stat -f %m "$(find target -name gen.cpp -print -quit)") -[[ -n "$before" && "$before" == "$after" ]] || { - cat b3.log - echo "FAIL: the action re-ran although its inputs were unchanged" - echo " (gen.cpp mtime $before -> $after)" - exit 1; } - -# ── 2b. changing the action's COMMAND also takes effect ──────────────────── -# Distinct from 2: there the action's declared INPUT changed and ninja noticed. -# Here nothing ninja tracks changed — only build.mcpp, which re-runs (its own -# source is part of its cache key), re-declares the action with a different -# command, and ninja re-runs the edge because the rule's command changed. Both -# halves have to work or an edited generator invocation is silently ignored. -sed -i 's|"/data/value.txt"|"/data/other.txt"|g' build.mcpp -echo "31" > data/other.txt -"$MCPP" build > b2b.log 2>&1 || { cat b2b.log; echo "FAIL: rebuild after editing build.mcpp failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^VALUE=' | tail -1)" -[[ "$out" == "VALUE=31" ]] || { - cat b2b.log; echo "FAIL: an edited action command did not take effect: $out"; exit 1; } - -# ── 3. a failing check fails the build ───────────────────────────────────── -touch FAIL_THE_CHECK -rm -f "$OUTDIR/lint.stamp" -if "$MCPP" build > b4.log 2>&1; then - cat b4.log; echo "FAIL: a failing check did not fail the build"; exit 1 -fi -rm -f FAIL_THE_CHECK - -# ── 2c. a companion output that is NOT a translation unit ────────────────── -# The single most natural generator shape: protoc emits foo.pb.cc AND foo.pb.h. -# Adopting every declared output into the compile set gave both the same object -# path and tripped "object path collision after uniqueness pass". The header -# must still be PRODUCED by the edge (things include it) but never compiled. -cat > genpair.sh <<'EOF' -#!/usr/bin/env bash -# $1 = .cc to write, $2 = .h to write -printf '#include "%s"\nint paired() { return 13; }\n' "$(basename "$2")" > "$1" -printf 'int paired();\n' > "$2" -EOF -chmod +x genpair.sh -cat >> src/main.cpp <<'EOF' -EOF -cat > build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - const std::string out = mcpp::out_dir(); - mcpp::action a; - a.id = "pair"; a.role = "source"; - a.arg((root + "/genpair.sh").c_str()) - .arg((out + "/p.cc").c_str()) - .arg((out + "/p.h").c_str()) - .output((out + "/p.cc").c_str()) - .output((out + "/p.h").c_str()) // companion: produced, NOT compiled - .submit(); - mcpp::include_dir(out.c_str()); - // Keep the earlier generated source in the build so main.cpp still links. - mcpp::action g; - g.id = "generate"; g.role = "source"; - g.arg((root + "/gen.sh").c_str()).arg((root + "/data/other.txt").c_str()) - .arg((out + "/gen.cpp").c_str()) - .input((root + "/data/other.txt").c_str()) - .output((out + "/gen.cpp").c_str()) - .submit(); -} -EOF -rm -rf target -"$MCPP" build > b2c.log 2>&1 || { - cat b2c.log; echo "FAIL: a companion (non-source) action output broke the build"; exit 1; } -grep -q "object path collision" b2c.log && { - cat b2c.log; echo "FAIL: the header was adopted as a translation unit"; exit 1; } - -# ── 3b/3c: their own minimal project ─────────────────────────────────────── -# Separate from `app`, whose main.cpp deliberately depends on the generated -# symbol — swapping its build.mcpp out would fail the LINK and say nothing -# about what these two are actually testing. -mkdir -p "$TMP/edge/src" -cd "$TMP/edge" -cat > mcpp.toml <<'EOF' -[package] -name = "edge" -version = "0.1.0" -EOF -printf 'int main() {}\n' > src/main.cpp - -# ── 3b. a literal `$` in a command survives to the tool ──────────────────── -# Shell-quoting alone does not save it: ninja expands `$foo` BEFORE the shell -# runs, so a token carrying a `$` (a path containing one, `-Wl,-rpath,$ORIGIN`, -# an awk program) needs ninja escaping too. -cat > dollar.sh <<'EOF' -#!/usr/bin/env bash -# $1 must arrive containing a literal dollar sign -case "$1" in *'$'*) : > "$2";; *) echo "lost the dollar: [$1]" >&2; exit 1;; esac -EOF -chmod +x dollar.sh -cat > build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - mcpp::action a; - a.id = "dollar"; a.role = "check"; - a.arg((root + "/dollar.sh").c_str()).arg("-Wl,-rpath,$ORIGIN") - .arg("${mcpp.out_dir}/dollar.stamp") - .output("${mcpp.out_dir}/dollar.stamp") - .submit(); -} -EOF -rm -rf target -"$MCPP" build > b3b.log 2>&1 || { - cat b3b.log; echo "FAIL: a literal \$ in an action command did not survive"; exit 1; } - -# ── 3c. an unknown target reference is an error, not an empty path ───────── -cat > build.mcpp <<'EOF' -#include -import mcpp; -int main() { - mcpp::action a; - a.id = "bad-ref"; a.role = "artifact"; - a.arg("/bin/true").arg("${mcpp.target_file:no_such_target}") - .input("${mcpp.target_file:no_such_target}") - .output("${mcpp.out_dir}/x.out") - .submit(); -} -EOF -rm -rf target -if "$MCPP" build > b3c.log 2>&1; then - cat b3c.log; echo "FAIL: an unknown target reference was accepted"; exit 1 -fi -grep -q "no_such_target" b3c.log || { - cat b3c.log; echo "FAIL: error does not name the unknown target"; exit 1; } - -# ── 3d. role = "object": outputs join the LINK set ───────────────────────── -# -# The fourth wiring (mcpp#365). Source attaches to the compile inputs, Artifact -# to the link outputs, Check to nothing — which left the link INPUTS, an -# obvious attachment point, inexpressible. The consequence was concrete: a -# pre-built object could only reach the linker by being named in -# `[build].ldflags`, where it is a flat string in the command rather than a file -# in the graph, so editing it produced "ninja: no work to do". -# -# The object is produced by THE COMPILER MCPP ITSELF RESOLVED, read out of the -# generated build.ninja. Reaching for the host's `cc` looks simpler and is not: -# on a machine with xlings shims installed, a bare `cc` can resolve to a -# dispatcher pointing at some other sandbox, and the object would either fail to -# build or be built by a compiler whose ABI has nothing to do with the link. -mkdir -p "$TMP/objrole/src" -cd "$TMP/objrole" -cat > mcpp.toml <<'EOF' -[package] -name = "objrole" -version = "0.1.0" -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 -not -path '*/.build-mcpp/*' | head -1) -# A command line, written into mkobj.sh as one: it may start with -# `env LD_LIBRARY_PATH=...` for a compiler without its own search path. -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; } - -cat > src/main.cpp <<'EOF' -#include -extern "C" int blob_value(); -int main() { std::printf("BLOB=%d\n", blob_value()); return blob_value() == 7 ? 0 : 1; } -EOF -# extern "C" keeps the symbol unmangled without needing a C driver. -printf 'extern "C" int blob_value() { return 7; }\n' > blob.cpp -cat > mkobj.sh < build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - const std::string out = mcpp::out_dir(); - mcpp::action o; - o.id = "blob"; o.role = "object"; - o.arg((root + "/mkobj.sh").c_str()) - .arg((root + "/blob.cpp").c_str()) - .arg((out + "/blob.o").c_str()) - .input((root + "/blob.cpp").c_str()) - .output((out + "/blob.o").c_str()) - .submit(); -} -EOF -"$MCPP" build > o1.log 2>&1 || { cat o1.log; echo "FAIL: role=object build failed"; exit 1; } -"$MCPP" run > o2.log 2>&1 || { cat o2.log; echo "FAIL: the object was not linked in"; exit 1; } -grep -q 'BLOB=7' o2.log || { cat o2.log; echo "FAIL: wrong value from the linked object"; exit 1; } - -# Tracked, unlike the ldflags workaround it replaces. -sleep 1 -printf 'extern "C" int blob_value() { return 9; }\n' > blob.cpp -"$MCPP" run > o3.log 2>&1 && { cat o3.log; echo "FAIL: expected the changed object to be relinked (main asserts ==7)"; exit 1; } -grep -q 'BLOB=9' o3.log || { cat o3.log; echo "FAIL: editing the object's input did not reach the link"; exit 1; } -printf 'extern "C" int blob_value() { return 7; }\n' > blob.cpp - -# An unknown target name is an error, not an edge that quietly attaches to -# nothing. (Artifact infers its target from ${mcpp.target_file:}; an object runs -# before the link and has to say the name.) -cat > build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - const std::string out = mcpp::out_dir(); - mcpp::action o; - o.id = "blob"; o.role = "object"; - o.arg((root + "/mkobj.sh").c_str()) - .arg((root + "/blob.cpp").c_str()) - .arg((out + "/blob.o").c_str()) - .input((root + "/blob.cpp").c_str()) - .output((out + "/blob.o").c_str()) - .target("no_such_target") - .submit(); -} -EOF -rm -rf target -if "$MCPP" build > o4.log 2>&1; then - cat o4.log; echo "FAIL: role=object accepted an unknown target"; exit 1 -fi -grep -q "no_such_target" o4.log || { - cat o4.log; echo "FAIL: error does not name the unknown target"; exit 1; } - -# ...and EVERY name is checked, not just the case where none of them matched. -# A typo alongside a name that does exist is the shape a typo actually takes, -# and the check used to be gated on "nothing attached" — so `objrole` absorbed -# it and the misspelling was dropped without a word. -sed -i.bak 's/.target("no_such_target")/.target("objrole").target("no_such_target")/' build.mcpp -rm -f build.mcpp.bak -rm -rf target -if "$MCPP" build > o4b.log 2>&1; then - cat o4b.log - echo "FAIL: an unknown target was silently dropped because a sibling matched"; exit 1 -fi -grep -q "no_such_target" o4b.log || { - cat o4b.log; echo "FAIL: error does not name the unknown target"; exit 1; } - -# ── 3e. the default target set includes TEST binaries ────────────────────── -# -# `mcpp build` linking and `mcpp test` failing on the very symbol the action -# exists to provide is the worst shape this can take: the object is what the -# library under test needs, and test link units are DISCOVERED from tests/*.cpp -# so their names cannot be spelled in build.mcpp without breaking plain -# `mcpp build`, where those units do not exist. -mkdir -p "$TMP/objtest/src" "$TMP/objtest/tests" -cd "$TMP/objtest" -cat > mcpp.toml <<'EOF' -[package] -name = "objtest" -version = "0.1.0" -EOF -printf 'export module objtest.engine;\nextern "C" int blob_value();\nexport int get(){ return blob_value(); }\n' > src/engine.cppm -printf 'import objtest.engine;\nint main(){ return get()==7?0:1; }\n' > src/main.cpp -printf 'import objtest.engine;\nint main(){ return get()==7?0:1; }\n' > tests/t_engine.cpp -printf 'extern "C" int blob_value() { return 7; }\n' > blob.cpp -cp "$TMP/objrole/mkobj.sh" mkobj.sh -cat > build.mcpp <<'EOF' -#include -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - const std::string out = mcpp::out_dir(); - mcpp::action o; - o.id = "blob"; o.role = "object"; - o.arg((root + "/mkobj.sh").c_str()) - .arg((root + "/blob.cpp").c_str()) - .arg((out + "/blob.o").c_str()) - .input((root + "/blob.cpp").c_str()) - .output((out + "/blob.o").c_str()) - .submit(); // no .target(): every image, tests included -} -EOF -"$MCPP" build > o5.log 2>&1 || { cat o5.log; echo "FAIL: objtest build failed"; exit 1; } -"$MCPP" test > o6.log 2>&1 || { - cat o6.log - echo "FAIL: the test binary did not receive the object (undefined symbol)"; exit 1; } -grep -q '1 passed' o6.log || { cat o6.log; echo "FAIL: the test did not run"; exit 1; } - -# ── 3f. an object with no consumer at all is reported, not silent ────────── -# -# The edge is deliberately kept out of `actionDefaults` (an object's outputs are -# supposed to be reachable through a link edge), so "no consumer" means the -# command never runs. Saying nothing there is the same failure `[resources]` -# reports as resources/no-image. -# -# THE ONLY TARGET IS GATED OFF, and that is what makes this case exist at -# all. A `kind = "lib"` target used to serve here, because a static library did -# not take an object action's outputs; it does now (design C-6, e2e 608), so a -# project with one is no longer a project with no consumer. A target behind an -# inactive `required_features` is absent from the build entirely, which is the -# one shape that leaves an object action with nowhere to go. -mkdir -p "$TMP/objnone/src" -cd "$TMP/objnone" -cat > mcpp.toml <<'EOF' -[package] -name = "objnone" -version = "0.1.0" - -[features] -gated = {} - -[targets.objnone] -kind = "lib" -required_features = ["gated"] -EOF -printf 'export module objnone;\nexport int f(){return 1;}\n' > src/objnone.cppm -cp "$TMP/objtest/blob.cpp" blob.cpp -cp "$TMP/objtest/mkobj.sh" mkobj.sh -cp "$TMP/objtest/build.mcpp" build.mcpp -"$MCPP" build > o7.log 2>&1 || { cat o7.log; echo "FAIL: objnone build failed"; exit 1; } -grep -q 'produces no target to put its outputs into' o7.log || { - cat o7.log; echo "FAIL: an object with no consumer must be reported"; exit 1; } - -# THE CONTROL: with the feature on, the target exists and the action is not -# reported. Without it the assertion above would pass on a build that had -# stopped emitting the diagnostic for every project. -"$MCPP" build --features gated > o7b.log 2>&1 || { - cat o7b.log; echo "FAIL: objnone build with the feature failed"; exit 1; } -grep -q 'produces no target to put its outputs into' o7b.log && { - cat o7b.log; echo "FAIL: the object was reported as having no consumer while its target existed" - exit 1; } - -cd "$TMP/edge" - -# ── 4. a malformed action is refused, not skipped ────────────────────────── -cat > build.mcpp <<'EOF' -#include -int main() { - std::printf("mcpp:protocol=1\n"); - // No `command`, no `outputs` — mcpp fixes the source set during prepare, - // so an output whose NAME is unknown cannot be built. - std::printf("mcpp:action={\"id\":\"broken\",\"role\":\"source\"}\n"); -} -EOF -rm -rf target -if "$MCPP" build > b5.log 2>&1; then - cat b5.log; echo "FAIL: a malformed action was accepted"; exit 1 -fi -grep -q "malformed action" b5.log || { - cat b5.log; echo "FAIL: no diagnostic for the malformed action"; exit 1; } - -echo "OK" diff --git a/tests/e2e/189_host_module_rules.sh b/tests/e2e/189_host_module_rules.sh deleted file mode 100755 index 80e0b0c24..000000000 --- a/tests/e2e/189_host_module_rules.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 189_host_module_rules.sh — reusable build rules distributed as ORDINARY mcpp -# packages: `host-module = true` makes a dependency's module importable from the -# consumer's build.mcpp. -# -# This is the answer to "a rule should be written once, not copy-pasted into -# every package's build.mcpp" — without introducing a second language. xmake -# reaches for Lua rules and Bazel for Starlark; mcpp's whole premise is that -# the build is written in C++, so a rule is a C++ module in a versioned package -# and rides the package manager that already exists. -# -# The load-bearing implementation detail this test pins: the rule module is -# compiled in the SAME command as build.mcpp, with the same flags. A BMI is -# only usable by a compile that agrees with it on standard, dialect and -# compiler identity — building it separately would leave that to chance, and -# disagreement surfaces as `module X CRC mismatch` rather than a clear error. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── the rule package ──────────────────────────────────────────────────────── -# An ordinary mcpp library package whose lib root is the rule module. -mkdir -p rules/src -cat > rules/mcpp.toml <<'EOF' -[package] -name = "rules" -version = "0.1.0" - -[targets.rules] -kind = "lib" -EOF -cat > rules/src/rules.cppm <<'EOF' -module; -#include -export module rules; -export namespace rules { -// A rule: emits the directives its users would otherwise hand-write. Written -// ONCE here instead of copy-pasted into every consumer's build.mcpp. -inline void banner(const char* macro) { - std::printf("mcpp:cxxflag=-D%s=1\n", macro); -} -inline void define_answer(int n) { - std::printf("mcpp:cxxflag=-DRULE_ANSWER=%d\n", n); -} -} -EOF - -# ── the consumer ──────────────────────────────────────────────────────────── -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -rules = { path = "../rules", host-module = true } -EOF -cat > app/src/main.cpp <<'EOF' -#include -#ifndef RULE_ANSWER -#error "the rule package's directive never reached the compile" -#endif -#ifndef RULE_BANNER_OK -#error "the second rule directive never reached the compile" -#endif -int main() { std::printf("ANSWER=%d\n", RULE_ANSWER); } -EOF -cat > app/build.mcpp <<'EOF' -import mcpp; -import rules; // a DEPENDENCY's module, compiled for the host -int main() { - rules::banner("RULE_BANNER_OK"); - rules::define_answer(42); -} -EOF - -cd app -"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build with a host rule module failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { - echo "FAIL: the rule package's directives did not reach the build: $out"; exit 1; } - -# Editing the RULE must re-run build.mcpp: the rule's content is part of what -# the helper was compiled from, so a cached run would silently keep the old -# behaviour — the exact failure the declared-input cache exists to prevent. -sed -i 's/define_answer(int n)/define_answer(int n_)/; s/RULE_ANSWER=%d\\n", n)/RULE_ANSWER=%d\\n", n_ + 1)/' ../rules/src/rules.cppm -touch src/main.cpp -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: rebuild after editing the rule failed"; exit 1; } -grep -qE "^ *build\.mcpp .* ran [0-9]" b2.log || { - cat b2.log; echo "FAIL: editing the rule module did not re-run build.mcpp"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=43" ]] || { - echo "FAIL: the edited rule did not take effect: $out"; exit 1; } - -# ── a rule that uses the API it exists to wrap ────────────────────────────── -# The two properties below are what make the feature usable for a REAL rule -# rather than one that hand-prints directives, and 2026.8.5.1 had neither: -# -# `import std;` the rule was compiled before the std module was built, so -# it failed with `module 'std' not found`. The scan for -# `import std` read build.mcpp only, so a rule that needed it -# did not even trigger the build. -# `import mcpp;` registering the host module never removed the package from -# the consumer's ORDINARY graph, so the same .cppm was also -# compiled as a normal library — where `mcpp` does not exist. -cd "$TMP" -mv rules/src/rules.cppm rules/src/rules.cppm.bak -cat > rules/src/rules.cppm <<'EOF' -export module rules; -import std; // must be usable: a rule is a normal C++23 module -import mcpp; // must be usable: the typed wrapper is the whole point - -export namespace rules { -inline void banner(const char* macro) { mcpp::define(macro); } -inline void define_answer(int n) { - mcpp::cxxflag(std::format("-DRULE_ANSWER={}", n).c_str()); -} -} -EOF -cd app && rm -rf target -"$MCPP" build > b4.log 2>&1 || { - cat b4.log - echo "FAIL: a rule using import std + import mcpp did not build"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { - echo "FAIL: the std/mcpp-based rule's directives did not land: $out"; exit 1; } - -# The rule package must NOT be compiled into the consumer's target. It is -# build-time-only; an object under obj/rules/ means it was also treated as an -# ordinary library, which is what made `import mcpp;` fail in the first place. -if find target -path '*obj/rules/*' -name '*.o' | grep -q .; then - find target -path '*obj/rules/*' -name '*.o' - echo "FAIL: the host-module package was also built as a normal library"; exit 1 -fi - -cd "$TMP" -rm -f rules/src/rules.cppm -mv rules/src/rules.cppm.bak rules/src/rules.cppm - -# The interface root is not what makes a library usable by a build program -# (mcpp#797): the module is found by the name its unit declares, wherever the -# file is. A unit renamed away from the conventional lib root still serves. -cd "$TMP" -mv rules/src/rules.cppm rules/src/elsewhere.cppm -cd app && rm -rf target -"$MCPP" build > b3.log 2>&1 || { - cat b3.log; echo "FAIL: a library whose interface is not at the conventional lib root was refused"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=43" ]] || { cat b3.log; echo "FAIL: the renamed interface's directive did not land: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/18_devdeps_isolation.sh b/tests/e2e/18_devdeps_isolation.sh deleted file mode 100755 index 550a23d83..000000000 --- a/tests/e2e/18_devdeps_isolation.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Verify dev-deps are NOT pulled by `mcpp build` but ARE pulled by `mcpp test`. -# We don't actually fetch a real dev-dep (would need network); we just verify -# that the dev-deps section in mcpp.toml does not appear in the build path's -# announcement output. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp - -# Add a (path-based, doesn't actually exist) dev-dep — mcpp build should -# ignore it entirely; mcpp test would TRY to load it (and fail because path -# is fake). We don't actually run the test path; just verify build doesn't -# even mention it. -cat > mcpp.toml <<'EOF' -[package] -name = "myapp" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp"] -[targets.myapp] -kind = "bin" -main = "src/main.cpp" -[dev-dependencies."ghost-test-framework"] -path = "/nonexistent/ghost-package" -EOF - -# `mcpp build` must succeed and NOT mention ghost-test-framework -out=$("$MCPP" build 2>&1) -echo "$out" | grep -q 'Finished' || { echo "build did not finish: $out"; exit 1; } -if echo "$out" | grep -q 'ghost-test-framework'; then - echo "build path leaked dev-dep: $out"; exit 1 -fi - -# `mcpp test` should fail (because dev-dep path is bogus) — proving the -# dev-dep IS resolved in the test path. -rc=0 -out=$("$MCPP" test 2>&1) || rc=$? -[[ $rc -ne 0 ]] || { echo "test should have failed (bogus dev-dep)"; exit 1; } -echo "$out" | grep -q 'ghost-test-framework' || { - echo "test path didn't reference dev-dep: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/190_link_rspfile_newlines.sh b/tests/e2e/190_link_rspfile_newlines.sh deleted file mode 100755 index 94a140025..000000000 --- a/tests/e2e/190_link_rspfile_newlines.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# 190_link_rspfile_newlines.sh — the link response file separates objects by -# NEWLINES, not spaces. -# -# Two ceilings sit between a link edge and the OS, and routing the objects -# through a response file only removes the first: -# -# 1. the COMMAND LINE — Windows CreateProcess 32 KiB, POSIX MAX_ARG_STRLEN -# 128 KiB (ninja spawns `sh -c ""`, so the command is one -# argv entry). Removed by using @rspfile at all — mcpp#344 / PR#345. -# 2. the response file's LINE LENGTH — link.exe caps it at 128 KiB: -# -# fatal error LNK1170: line in command file contains 135135 -# or more characters -# -# which is where mcpp-index's opencv-module landed on windows, with every -# object written onto a single line. -# -# `rspfile_content = $in_newline` removes the second. After it, no ceiling -# scales with the number of objects. -# -# Asserted structurally (the generated rule) AND observably (the file ninja -# actually writes, kept with -d keeprsp) — the first alone would still pass if -# ninja ever changed what $in_newline expands to. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p multi/src -cat > multi/mcpp.toml <<'EOF' -[package] -name = "multi" -version = "0.1.0" -EOF -# Enough objects that "one per line" is unambiguous — a single-object link -# would look identical either way. -i=1 -while [ "$i" -le 24 ]; do - printf 'int f%d() { return %d; }\n' "$i" "$i" > "multi/src/f$i.cpp" - i=$((i + 1)) -done -printf 'int main() { return 0; }\n' > multi/src/main.cpp - -cd multi -"$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL: build"; exit 1; } - -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -[ -n "$ninja_file" ] || { echo "FAIL: no build.ninja"; exit 1; } - -# 1. Structural: no link rule may write its response file on one line. Under -# the msvc dialect the content begins with a UTF-8 byte order mark, which -# link.exe and lib.exe need to read the file as UTF-8 (#693). -BOM=$'\xef\xbb\xbf' -if grep -qE "^[[:space:]]*rspfile_content = (${BOM})?\\\$in[[:space:]]*\$" "$ninja_file"; then - grep -nE '^[[:space:]]*rspfile_content' "$ninja_file" - echo "FAIL: a link rule still writes its response file on ONE line (\$in)" - exit 1 -fi -grep -qE "^[[:space:]]*rspfile_content = (${BOM})?\\\$in_newline[[:space:]]*\$" "$ninja_file" || { - grep -nE '^[[:space:]]*rspfile_content' "$ninja_file" - echo "FAIL: no link rule uses \$in_newline"; exit 1; } -echo " ok: link rules declare rspfile_content = \$in_newline" - -# 2. Observable: ninja keeps the response file under -d keeprsp, and it holds -# one object per line rather than all of them on the first. -bdir=$(dirname "$ninja_file") -# The program by its name: on an MSVC-ABI row bin/ also holds the toolset's -# runtime DLLs staged beside it (docs/20, toolchain-coupled), and relinking -# one of those names re-runs a copy, which writes no response file. -bin_rel=$(cd "$bdir" && ls bin/ 2>/dev/null | grep -E '^multi(\.exe)?$' | head -1) -[ -n "$bin_rel" ] || { echo "FAIL: no linked binary to inspect"; exit 1; } -(cd "$bdir" && rm -f "bin/$bin_rel" && ninja -d keeprsp "bin/$bin_rel" > /dev/null 2>&1) \ - || { echo "FAIL: relink under -d keeprsp"; exit 1; } - -rsp=$(find "$bdir" -name '*.rsp' | head -1) -[ -n "$rsp" ] || { echo "FAIL: -d keeprsp left no response file"; exit 1; } - -# 25 objects -> 24 newlines (the last line carries no trailing newline). -lines=$(wc -l < "$rsp") -[ "$lines" -ge 20 ] || { - echo "response file has $lines newline(s):"; head -c 300 "$rsp"; echo - echo "FAIL: objects are not one-per-line — the LNK1170 shape is back" - exit 1; } - -# ...and no single line is anywhere near link.exe's 128 KiB cap. -longest=$(awk '{ if (length($0) > m) m = length($0) } END { print m+0 }' "$rsp") -[ "$longest" -lt 4096 ] || { - echo "FAIL: longest response-file line is $longest chars"; exit 1; } -echo " ok: $((lines + 1)) objects, longest response-file line $longest chars" - -echo "OK" diff --git a/tests/e2e/191_link_scale.sh b/tests/e2e/191_link_scale.sh deleted file mode 100755 index 735796b75..000000000 --- a/tests/e2e/191_link_scale.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -# 191_link_scale.sh — a link edge with more objects than a command line can hold. -# -# WHAT THIS COVERS THAT NOTHING ELSE DID -# -# mcpp#346: no CI job builds a package of the opencv/ffmpeg magnitude. Every -# job builds either mcpp itself (tens of TUs) or a synthetic e2e project -# (single digits). Link-line length, the response-file path, and ninja graph -# size over a large object set therefore had ZERO coverage, and the whole -# command-length defect family surfaced in the ecosystem rather than in CI: -# -# #274 ninja goals argv 50781 chars vs cmd.exe 8191 -# #247 Windows CreateProcess 32 KiB -# #345 POSIX MAX_ARG_STRLEN 128 KiB (ninja spawns `sh -c ""`) -# #360 link.exe LNK1170, response-file LINE capped at 128 KiB -# -# 190 asserts the SHAPE of the generated rule (`rspfile_content = $in_newline`, -# 25 objects). This asserts the SCALE: it builds a link edge whose object list -# does not fit in a command line on any supported platform, and requires it to -# link and run. A regression that puts objects back on the command line fails -# here for the same reason opencv-module failed in the ecosystem — except in -# 20 seconds, with no external package, and with a diagnostic that names the -# cause. -# -# WHY THE SIZE ASSERTION IS PART OF THE TEST -# -# The regime is what gives this test its value, and the regime depends on -# incidental things — object naming, the disambiguation prefix, how many files -# the loop below writes. If any of them shrinks the object list back under the -# ceiling, the test would keep passing while covering nothing. So the response -# file's size is asserted directly: below the ceiling the test reports that it -# has stopped covering the axis, rather than passing quietly. -# -# VERIFIED TO FAIL WITHOUT THE FIX -# -# Reverting the generated `cxx_link` rule to its pre-#345 inline form on this -# exact project reproduces the original symptom verbatim: -# -# ninja: fatal: posix_spawn: Argument list too long -# -# COST -# -# C sources, one trivial function each. Measured at 1.3s wall on a developer -# machine (1400 TUs, parallel). Windows is the expensive leg — per-process -# spawn cost dominates there — and is bounded by the suite's 600s per-test -# timeout. -# -# The names are padded so each object path is ~100 bytes, which is what -# reaches the 128 KiB ceiling at a file count this small: padding trades -# compile time (expensive) for path length (free). Padding is bounded on the -# other side by Windows MAX_PATH — 100 bytes relative, plus the temp directory -# and the build directory, stays near 200 of the 260 available. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── The ceiling this test has to clear ──────────────────────────────────── -# POSIX MAX_ARG_STRLEN, the largest of the command-line limits in -# src/build/cmdlimits.cppm — clearing the largest clears all of them, so one -# number works for every platform the suite runs on. -CEILING=$((128 * 1024)) - -PAD=$(printf 'x%.0s' $(seq 1 88)) -N=1400 - -mkdir -p scale/src -cat > scale/mcpp.toml <<'EOF' -[package] -name = "scale" -version = "0.1.0" - -[build] -c_standard = "c11" -EOF - -i=1 -while [ "$i" -le "$N" ]; do - printf 'int f%04d(void) { return %d; }\n' "$i" "$i" > "scale/src/f${i}_${PAD}.c" - i=$((i + 1)) -done -# The bin target is inferred from src/main.cpp; the objects it links are the C -# TUs above. Referencing the FIRST and the LAST of them is what makes a -# truncated object list a link error rather than a smaller binary: a response -# file cut short at any point drops one of these two. -cat > scale/src/main.cpp < b.log 2>&1 || { tail -40 b.log; echo "FAIL: build $N objects"; exit 1; } -echo " ok: built $((N + 1)) objects" - -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -[ -n "$ninja_file" ] || { echo "FAIL: no build.ninja"; exit 1; } -bdir=$(dirname "$ninja_file") -# The program by its name: on an MSVC-ABI row bin/ also holds the toolset's -# runtime DLLs staged beside it (docs/20, toolchain-coupled), and relinking -# one of those names re-runs a copy, which writes no response file. -bin_rel=$(cd "$bdir" && ls bin/ 2>/dev/null | grep -E '^scale(\.exe)?$' | head -1) -[ -n "$bin_rel" ] || { echo "FAIL: nothing was linked"; exit 1; } - -# Relink with the response file kept, so its real contents can be inspected — -# the same technique as 190, at a size that matters. -(cd "$bdir" && rm -f "bin/$bin_rel" && ninja -d keeprsp "bin/$bin_rel" > relink.log 2>&1) || { - tail -20 "$bdir/relink.log"; echo "FAIL: relink under -d keeprsp"; exit 1; } - -rsp=$(find "$bdir" -name '*.rsp' | head -1) -[ -n "$rsp" ] || { echo "FAIL: -d keeprsp left no response file"; exit 1; } - -bytes=$(wc -c < "$rsp" | tr -d ' ') -objects=$(( $(wc -l < "$rsp" | tr -d ' ') + 1 )) - -# 1. NON-VACUITY: the object list must not fit in a command line. Without -# this, everything below could pass on a link edge small enough that the -# inline form would have worked too. -[ "$bytes" -gt "$CEILING" ] || { - echo "response file is $bytes bytes over $objects objects; the ceiling is $CEILING" - echo "FAIL: this test no longer reaches the regime it exists to cover." - echo " Raise N or PAD until the object list exceeds the ceiling again." - exit 1; } -echo " ok: object list is $bytes bytes ($objects objects), past the ${CEILING}-byte command-line ceiling" - -# 2. No single line approaches link.exe's per-line cap (the LNK1170 axis) — -# asserted here at real scale rather than 190's 25 objects. -longest=$(awk '{ if (length($0) > m) m = length($0) } END { print m+0 }' "$rsp") -[ "$longest" -lt 4096 ] || { - echo "FAIL: longest response-file line is $longest chars — objects are not one per line" - exit 1; } -echo " ok: longest response-file line $longest chars" - -# 3. The objects were really consumed. main.cpp calls into both ends of the -# object list, so a truncated response file cannot reach this point — it -# fails at link time with an undefined reference. Running the binary closes -# the remaining gap: that the values arriving at runtime are the ones the -# two TUs return. -"./$bdir/bin/$bin_rel" || { echo "FAIL: linked binary did not run cleanly"; exit 1; } -echo " ok: linked binary runs" - -echo "OK" diff --git a/tests/e2e/192_install_path_namespace.sh b/tests/e2e/192_install_path_namespace.sh deleted file mode 100755 index 72a7c9e63..000000000 --- a/tests/e2e/192_install_path_namespace.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 192_install_path_namespace.sh — a package must never be satisfied from -# ANOTHER namespace's install directory. -# -# `Fetcher::install_path(ns, shortName, version)` answers "where is THIS -# package installed". Its last-resort legacy scan matched any directory ending -# in `-x-`, whatever namespace that directory belonged to, so a -# lookup for `acme:widget@1.5.0` returned `compat-x-widget/1.5.0`. The caller -# then skips the install ("already present") and reads the other package's -# tree. -# -# WHY IT WAS UNREACHABLE UNTIL NOW -# -# install_path also matches on version, so two packages sharing a short name -# collided only if they also shared a version — and the ecosystem's module -# layers carried packaging counters (`imgui@0.0.6`) while the compat packages -# carried upstream versions (`compat.imgui@1.92.8`). Aligning the module layers -# to upstream (mcpp-index#163) makes them coincide, which is how this surfaced. -# -# WHY THE SILENT SHAPE IS THE ONE THAT MATTERS -# -# Discovered against a Form B neighbour, so the wrong verdir had no mcpp.toml -# and the build stopped — with a diagnostic naming the wrong cause. Between two -# packages whose sources live in the verdir, there is no error at all: the -# build compiles the wrong package's source. That is what this test pins. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj" -cd "$TMP/proj" - -# ── Two packages, same short name, same version, different namespaces ───── -mkdir -p local-index/pkgs/a -cat > local-index/pkgs/a/acme.widget.lua <<'EOF' -package = { - spec = "1", - namespace = "acme", - name = "widget", - description = "acme's widget", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["1.5.0"] = { url = "https://example.invalid/w.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } }, - macosx = { ["1.5.0"] = { url = "https://example.invalid/w.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } }, - windows = { ["1.5.0"] = { url = "https://example.invalid/w.zip", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } }, - }, - mcpp = { - language = "c++23", - import_std = false, - sources = { "src/widget.cppm" }, - targets = { ["widget"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -# ── Only the FOREIGN namespace's payload is on disk ─────────────────────── -# `compat:widget@1.5.0` is installed; `acme:widget@1.5.0` is not. Nothing may -# hand acme's lookup this directory. -# -# It goes in the GLOBAL store ($MCPP_HOME/registry/data/xpkgs), which is what -# `Fetcher::install_path` scans — the project's own .mcpp/.xlings tree is a -# different root reached by `install_path_from_project_data`. Seeding the wrong -# one makes this test pass on a broken binary, which is exactly what the first -# draft did. -mkdir -p "$MCPP_HOME/registry/data/xpkgs/compat-x-widget/1.5.0/src" -cat > "$MCPP_HOME/registry/data/xpkgs/compat-x-widget/1.5.0/src/widget.cppm" <<'EOF' -export module widget; -// If this ever reaches a build that asked for acme:widget, the wrong package -// was compiled. The value is the tell. -export int widget_value() { return 1; } -EOF - -mkdir -p src -cat > src/main.cpp <<'EOF' -import widget; -int main() { return widget_value() == 2 ? 0 : 1; } -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "consumer" -version = "0.1.0" - -[dependencies.acme] -widget = "1.5.0" - -[indices] -acme = { path = "local-index" } -EOF - -# The build must NOT succeed: acme:widget is not installed and its url is -# unreachable by design. What it must never do is satisfy the dependency from -# compat's directory. -if "$MCPP" build > b.log 2>&1; then - echo "--- build log ---"; cat b.log - echo "FAIL: build succeeded, so acme:widget was satisfied from somewhere —" - echo " the only widget payload on disk belongs to compat." - exit 1 -fi - -# Distinguish "correctly refused" from "compiled the wrong package and then -# failed for an unrelated reason": compat's source must never be compiled. -if grep -qE "compat-x-widget/1\.5\.0/src/widget\.cppm" b.log; then - grep -nE "compat-x-widget" b.log | head -5 - echo "FAIL: the build reached compat's source while resolving acme:widget" - exit 1 -fi -echo " ok: acme:widget was not satisfied from compat-x-widget" - -# And the diagnostic has to name the directory it looked in — `` as a -# literal placeholder cannot distinguish a missing mcpp field from a wrong -# verdir, which is what made this take a while to find. -if grep -q "" b.log; then - echo "FAIL: diagnostic still prints the literal placeholder ''" - exit 1 -fi -echo " ok: diagnostics name a real path" - -echo "OK" diff --git a/tests/e2e/193_provision_reexport.sh b/tests/e2e/193_provision_reexport.sh deleted file mode 100755 index c6ed3623e..000000000 --- a/tests/e2e/193_provision_reexport.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 193_provision_reexport.sh — #359: a library standing up a toolchain on its -# user's behalf. -# -# Before this, `tools = [...]` and `host-module = true` were recorded against -# the consumer of the edge that ASKED. A library could therefore build a tool -# but not hand it on, so its user had to declare every tool the library needed -# — for grpc that meant four dependency lines and knowing that gRPC codegen -# runs protobuf's protoc, which is the library's knowledge, not the user's. -# -# Covered here: -# 1. WITHOUT `reexport`, a library's tool stays with the library. That is the -# supply-chain default: an arbitrary transitive dependency must not be -# able to put entries in your build program's tool namespace. -# 2. WITH `reexport = true`, the consumer's build.mcpp sees the tool, the -# re-exported rule module, and the re-exported package's directory — -# while declaring ONE dependency. -# -# See .agents/docs/2026-08-06-provisions-and-build-inputs.md. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -export MCPP_HOME="$TMP/mcpphome" -mkdir -p "$MCPP_HOME" -if [ -d "$HOME/.mcpp/registry" ]; then - ln -s "$HOME/.mcpp/registry" "$MCPP_HOME/registry" -fi - -# ── the tool package (what a library depends on, and the user never names) ── -mkdir -p toolpkg/src -cat > toolpkg/mcpp.toml <<'EOF' -[package] -name = "toolpkg" -version = "0.1.0" - -[build] -sources = ["src/lib.cpp"] - -[targets.codegen] -kind = "bin" -main = "src/codegen.cpp" -EOF -printf 'int toolpkg_lib(){return 1;}\n' > toolpkg/src/lib.cpp -cat > toolpkg/src/codegen.cpp <<'EOF' -#include -int main(int argc, char** argv) { - if (argc < 2) return 2; - FILE* f = std::fopen(argv[1], "w"); - if (!f) return 3; - std::fprintf(f, "int generated_answer() { return 42; }\n"); - std::fclose(f); - return 0; -} -EOF - -# ── the rule package: an importable build rule, distributed as a package ──── -mkdir -p rulepkg/src -cat > rulepkg/mcpp.toml <<'EOF' -[package] -name = "rulepkg" -version = "0.1.0" - -[lib] -path = "src/rulepkg.cppm" -EOF -cat > rulepkg/src/rulepkg.cppm <<'EOF' -export module rulepkg; -import std; -import mcpp; -export namespace rulepkg { -// The rule knows which tool it needs. Its consumer does not have to. -bool generate() { - const char* tool = mcpp::dep_bin("toolpkg", "codegen"); - if (!tool || !*tool) { - std::println(std::cerr, "rulepkg: no codegen tool"); - return false; - } - // The tool package's own tree must be reachable too — that is where a real - // rule finds its data files (protoc's well-known .proto files). - if (std::string(mcpp::dep_dir("toolpkg")).empty()) { - std::println(std::cerr, "rulepkg: no dep_dir for toolpkg"); - return false; - } - std::string out = std::string(mcpp::out_dir()) + "/gen.cpp"; - std::string cmd = std::string("\"") + tool + "\" \"" + out + "\""; - if (std::system(cmd.c_str()) != 0) return false; - mcpp::generated(out.c_str()); - return true; -} -} -EOF - -# ── the library: it owns the knowledge, and re-exports what its user needs ── -mkdir -p lib/src -cat > lib/mcpp.toml <<'EOF' -[package] -name = "mylib" -version = "0.1.0" - -[build] -sources = ["src/mylib.cpp"] - -[dependencies] -toolpkg = { path = "../toolpkg", tools = ["codegen"] } -rulepkg = { path = "../rulepkg", host-module = true } -EOF -printf 'int mylib_fn(){return 1;}\n' > lib/src/mylib.cpp - -# ── 1. no reexport: the tool is built for the library, not handed on ──────── -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -mylib = { path = "../lib" } -EOF -cat > app/src/main.cpp <<'EOF' -#include -int generated_answer(); -int main() { std::printf("ANSWER=%d\n", generated_answer()); } -EOF -cat > app/build.mcpp <<'EOF' -#include -import mcpp; -int main() { - const char* tool = mcpp::dep_bin("toolpkg", "codegen"); - std::printf("SEES_TOOL=%d\n", (tool && *tool) ? 1 : 0); - return 1; // fail on purpose: the build must not proceed either way -} -EOF -cd app -if "$MCPP" build > b1.log 2>&1; then - cat b1.log; echo "FAIL: build.mcpp returned 1 but the build succeeded"; exit 1 -fi -if grep -q "SEES_TOOL=1" b1.log; then - cat b1.log - echo "FAIL: a library's tool leaked to its consumer without reexport" - exit 1 -fi -grep -q "SEES_TOOL=0" b1.log || { - cat b1.log; echo "FAIL: build.mcpp did not run"; exit 1; } - -# ── 2. reexport: ONE dependency line is enough ────────────────────────────── -cd "$TMP" -cat > lib/mcpp.toml <<'EOF' -[package] -name = "mylib" -version = "0.1.0" - -[build] -sources = ["src/mylib.cpp"] - -[dependencies] -toolpkg = { path = "../toolpkg", tools = ["codegen"], reexport = true } -rulepkg = { path = "../rulepkg", host-module = true, reexport = true } -EOF -# The consumer names neither the tool nor the rule's dependencies — only the -# rule's module, which is what it actually calls. -cat > app/build.mcpp <<'EOF' -import mcpp; -import rulepkg; -int main() { return rulepkg::generate() ? 0 : 1; } -EOF -cd app && rm -rf target -"$MCPP" build > b2.log 2>&1 || { - cat b2.log; echo "FAIL: build with re-exported provisions failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { - echo "FAIL: the re-exported tool's output was not linked: $out"; exit 1; } - -# The rule is BUILD-TIME only: it must not have been compiled into the binary. -if grep -q "rulepkg" <(nm -C "target"/*/*/bin/app 2>/dev/null || true); then - echo "FAIL: the rule package was linked into the consumer's binary"; exit 1 -fi - -# ── 3. a feature ADDING a request to an already-declared dependency ──────── -# The real shape: gRPC depends on protobuf always, and its `codegen` feature -# has to add `tools = ["protoc"], reexport = true` to that SAME edge. Moving -# the request to the unconditional entry is not an option — it would make every -# consumer build protoc — and dropping the feature's spec (try_emplace keeps -# the existing key) would silently lose the request. -cd "$TMP" -cat > lib/mcpp.toml <<'EOF' -[package] -name = "mylib" -version = "0.1.0" - -[build] -sources = ["src/mylib.cpp"] - -# Unconditional: the library links against this package no matter what. -[dependencies] -toolpkg = { path = "../toolpkg" } -rulepkg = { path = "../rulepkg", host-module = true, reexport = true } - -# The feature adds a REQUEST to the edge above, and nothing else. -[feature-deps.codegen] -toolpkg = { path = "../toolpkg", tools = ["codegen"], reexport = true } -EOF -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -mylib = { path = "../lib", features = ["codegen"] } -EOF -cd app && rm -rf target -"$MCPP" build > b3.log 2>&1 || { - cat b3.log - echo "FAIL: a feature could not add a tool request to an existing dependency" - exit 1 -} -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { echo "FAIL: expected ANSWER=42, got '$out'"; exit 1; } - -# … and without the feature, nothing is built. -cd "$TMP" -sed 's/, features = \["codegen"\]//' app/mcpp.toml > app/mcpp.toml.new -mv app/mcpp.toml.new app/mcpp.toml -cd app && rm -rf target -if "$MCPP" build > b4.log 2>&1; then - cat b4.log - echo "FAIL: the build succeeded without the codegen feature — the tool ran anyway" - exit 1 -fi -grep -q "host tool" b4.log && { - cat b4.log; echo "FAIL: a tool was built for a consumer that did not enable the feature"; exit 1; } - -echo "PASS: 193_provision_reexport" diff --git a/tests/e2e/194_build_mcpp_glob_input.sh b/tests/e2e/194_build_mcpp_glob_input.sh deleted file mode 100755 index 046433811..000000000 --- a/tests/e2e/194_build_mcpp_glob_input.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 194_build_mcpp_glob_input.sh — #359: an input that is a SET of files. -# -# `build.mcpp`'s re-run key was built from declared FILES and environment -# variables only, both fingerprinted by content. A program that globs its -# inputs therefore could not express "re-run me when a file appears": adding a -# .proto changed no declared file's hash, so the program did not re-run and the -# new file was silently never generated. Measured before the fix: -# `Finished dev in 0.01s`, zero artifacts. That is worse than requiring the -# author to list names, which is why grpc-m chose the explicit list. -# -# Two layers have to agree, and the second is easy to miss: the build.mcpp -# cache AND the project-level fast path, which skips prepare entirely when no -# source is newer than build.ninja. A new .proto moves no existing mtime, so -# the fast path is exactly where this silently did nothing. -# -# Covered here: -# 1. adding a matching file re-runs the program and changes the artifact -# 2. removing one re-runs it too (the set shrank) -# 3. editing a matched file's CONTENT does not re-run on the glob's account -# — content is what an ordinary rerun-if-changed entry is for -# 4. the program's own outputs live inside the project and must never be part -# of the set, or the build would re-run forever -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p app/src app/inputs -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF -cat > app/src/main.cpp <<'EOF' -#include -int count_inputs(); -int main() { std::printf("COUNT=%d\n", count_inputs()); } -EOF -# The program watches a directory of ".in" files and generates one function -# returning how many it found. It never names them: that is the point. -cat > app/build.mcpp <<'EOF' -import std; -import mcpp; -int main() { - namespace fs = std::filesystem; - mcpp::rerun_if_changed_glob("inputs/**/*.in"); - std::string root = mcpp::manifest_dir(); - int n = 0; - std::error_code ec; - for (auto const& e : fs::recursive_directory_iterator(root + "/inputs", ec)) - if (e.path().extension() == ".in") ++n; - std::string out = std::string(mcpp::out_dir()) + "/count.cpp"; - { std::ofstream os(out); os << "int count_inputs() { return " << n << "; }\n"; } - // Written into the project tree on purpose: a real rule does this, and it - // is what would make a naive glob re-run forever. - { std::ofstream os(root + "/marker.txt"); os << n; } - mcpp::generated(out.c_str()); - return 0; -} -EOF - -cd app -# Logs live OUTSIDE the project: the wide-glob case below asserts that the -# build's own outputs do not perturb the set, and a redirect creating b6.log -# inside the tree would perturb it for real — the test would then be checking -# the shell, not mcpp. -LOGS="$TMP/logs"; mkdir -p "$LOGS" -printf 'a\n' > inputs/a.in - -ran() { grep -qE "^ *build\.mcpp .* ran [0-9]" "$1"; } -cached() { grep -q "build.mcpp.*cached" "$1"; } - -"$MCPP" build > "$LOGS/b1.log" 2>&1 || { cat "$LOGS/b1.log"; echo "FAIL: first build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^COUNT=' | tail -1)" -[[ "$out" == "COUNT=1" ]] || { echo "FAIL: expected COUNT=1, got '$out'"; exit 1; } - -# ── 1. a new matching file re-runs the program ────────────────────────────── -printf 'b\n' > inputs/b.in -"$MCPP" build > "$LOGS/b2.log" 2>&1 || { cat "$LOGS/b2.log"; echo "FAIL: build after adding a file failed"; exit 1; } -ran "$LOGS/b2.log" || { - cat "$LOGS/b2.log" - echo "FAIL: adding a matching file did not re-run build.mcpp" - exit 1 -} -out="$("$MCPP" run 2>&1 | grep '^COUNT=' | tail -1)" -[[ "$out" == "COUNT=2" ]] || { echo "FAIL: expected COUNT=2, got '$out'"; exit 1; } - -# ── 2. removing one re-runs it as well ───────────────────────────────────── -rm inputs/b.in -"$MCPP" build > "$LOGS/b3.log" 2>&1 || { cat "$LOGS/b3.log"; echo "FAIL: build after removing a file failed"; exit 1; } -ran "$LOGS/b3.log" || { cat "$LOGS/b3.log"; echo "FAIL: removing a matching file did not re-run build.mcpp"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^COUNT=' | tail -1)" -[[ "$out" == "COUNT=1" ]] || { echo "FAIL: expected COUNT=1 after removal, got '$out'"; exit 1; } - -# ── 3. editing content does not re-run on the glob's account ─────────────── -# The set is unchanged, so the glob has nothing to say. (A program that cares -# about contents declares the file with rerun_if_changed, which hashes them.) -printf 'a totally different body\n' > inputs/a.in -"$MCPP" build > "$LOGS/b4.log" 2>&1 || { cat "$LOGS/b4.log"; echo "FAIL: build after an edit failed"; exit 1; } -ran "$LOGS/b4.log" && { - cat "$LOGS/b4.log" - echo "FAIL: editing a matched file's content re-ran the program via the glob" - exit 1 -} - -# ── 4. no re-run loop from the program's own outputs ─────────────────────── -# marker.txt is written into the project by the program itself, and target/ -# holds everything it generated. With a deliberately maximal pattern, a second -# build with nothing changed must still be a no-op; if the output tree were -# part of the set it never would be. -sed 's|"inputs/\*\*/\*\.in"|"**"|' build.mcpp > build.mcpp.new -mv build.mcpp.new build.mcpp -"$MCPP" build > "$LOGS/b5.log" 2>&1 || { cat "$LOGS/b5.log"; echo "FAIL: wide-glob build failed"; exit 1; } -ran "$LOGS/b5.log" || { cat "$LOGS/b5.log"; echo "FAIL: the edited program did not run"; exit 1; } -"$MCPP" build > "$LOGS/b6.log" 2>&1 || { cat "$LOGS/b6.log"; echo "FAIL: second wide-glob build failed"; exit 1; } -ran "$LOGS/b6.log" && { - cat "$LOGS/b6.log" - echo "FAIL: a wide glob re-runs forever — the build output tree is in its set" - exit 1 -} - -echo "PASS: 194_build_mcpp_glob_input" diff --git a/tests/e2e/195_target_cfg_feature_deps.sh b/tests/e2e/195_target_cfg_feature_deps.sh deleted file mode 100755 index 9b3ad322b..000000000 --- a/tests/e2e/195_target_cfg_feature_deps.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bash -# 195_target_cfg_feature_deps.sh — #359 D3a: `[target..feature-deps.]`. -# -# The conditional channel carried `dependencies`, `dev-dependencies` and -# `build-dependencies` and silently lacked `feature-deps`. That is the shape -# ConditionalConfig's own comment records for #258: the conditional reader -# keeps its own subset of the keys and falls behind without anyone noticing. -# -# It became load-bearing once a library could re-export build-time provisions. -# A library that puts a host tool behind a feature has no other way to say "not -# on this platform", and an unconditional declaration turns an unsupported -# platform into a hard error raised from inside the LIBRARY's manifest — which -# its user cannot edit and cannot work around. -# -# Two properties: -# 1. a matching predicate's feature-deps are pulled in when the feature is on -# 2. a non-matching predicate's are not — but the FEATURE still exists, so -# requesting it on that platform is not an unknown-feature error -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p widget/src -cat > widget/mcpp.toml <<'EOF' -[package] -name = "widget" -version = "0.1.0" -[targets.widget] -kind = "lib" -EOF -cat > widget/src/widget.cppm <<'EOF' -export module widget; -export int widget_anchor() { return 0; } -EOF - -mkdir -p lib/src -cat > lib/mcpp.toml <<'EOF' -[package] -name = "mylib" -version = "0.1.0" - -[build] -sources = ["src/mylib.cpp"] - -# Exactly one of these matches on any of the three CI platforms, so the -# `codegen` feature always pulls widget in. -[target.'cfg(unix)'.feature-deps.codegen] -widget = { path = "../widget", visibility = "public" } -[target.'cfg(windows)'.feature-deps.codegen] -widget = { path = "../widget", visibility = "public" } - -# Never matches. If a non-matching section were merged, resolving this bogus -# path would fail the build. -[target.'cfg(arch = "no_such_arch")'.feature-deps.codegen] -ghost = { path = "../this_path_does_not_exist" } -EOF -printf 'int mylib_fn(){return 1;}\n' > lib/src/mylib.cpp - -# ── 1. feature on: the matching platform's feature-dep is resolved ────────── -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -mylib = { path = "../lib", features = ["codegen"] } -EOF -cat > app/src/main.cpp <<'EOF' -import widget; // only reachable if the cfg-matched feature-dep resolved -int main() { return widget_anchor(); } -EOF -cd app -"$MCPP" build > b1.log 2>&1 || { - cat b1.log - echo "FAIL: a matching [target.*.feature-deps] was not merged" - exit 1 -} -if [ -f mcpp.lock ] && grep -q 'ghost' mcpp.lock; then - echo "FAIL: a non-matching predicate's feature-dep was pulled in"; exit 1 -fi - -# ── 2. feature off: nothing is pulled in ─────────────────────────────────── -cd "$TMP" -mkdir -p plain/src -cat > plain/mcpp.toml <<'EOF' -[package] -name = "plain" -version = "0.1.0" - -[dependencies] -mylib = { path = "../lib" } -EOF -printf 'int main(){}\n' > plain/src/main.cpp -cd plain -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: plain build failed"; exit 1; } -if [ -f mcpp.lock ] && grep -q 'widget' mcpp.lock; then - echo "FAIL: an inactive feature's conditional dep was resolved anyway"; exit 1 -fi - -# ── 3. the feature exists on EVERY platform ──────────────────────────────── -# Only what it pulls in is conditional. A consumer requesting it where no -# predicate matches must not be told the feature does not exist — that error -# would be the library's platform support leaking into its user's manifest. -cd "$TMP" -mkdir -p nomatch/src -cat > nomatch/mcpp.toml <<'EOF' -[package] -name = "nomatchlib" -version = "0.1.0" - -[build] -sources = ["src/nm.cpp"] - -[target.'cfg(arch = "no_such_arch")'.feature-deps.codegen] -ghost = { path = "../this_path_does_not_exist" } -EOF -printf 'int nm(){return 1;}\n' > nomatch/src/nm.cpp -mkdir -p nmapp/src -cat > nmapp/mcpp.toml <<'EOF' -[package] -name = "nmapp" -version = "0.1.0" - -[dependencies] -nomatchlib = { path = "../nomatch", features = ["codegen"] } -EOF -printf 'int main(){}\n' > nmapp/src/main.cpp -cd nmapp -# Asserted on the DIAGNOSTIC rather than with --strict: --strict promotes every -# degradation, including unrelated ones (clang on Windows reports "this -# toolchain and platform combination emits no GNU depfile"), so it would make -# this test fail for a reason it is not about. -"$MCPP" build > b3.log 2>&1 || { - cat b3.log - echo "FAIL: requesting a conditionally-populated feature failed where no predicate matches" - exit 1 -} -grep -q "does not declare requested feature" b3.log && { - cat b3.log - echo "FAIL: the feature was reported as undeclared where no predicate matches" - exit 1 -} - -echo "PASS: 195_target_cfg_feature_deps" diff --git a/tests/e2e/196_version_identity_and_lock.sh b/tests/e2e/196_version_identity_and_lock.sh deleted file mode 100755 index cf1790127..000000000 --- a/tests/e2e/196_version_identity_and_lock.sh +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# mcpp#363 — a resolved version must be an index KEY, and mcpp.lock must record -# what was resolved. -# -# Before this, pm/resolver.cppm parsed the index's literal version keys, threw -# them away, and re-rendered an address from the parsed numbers. Anything the -# renderer could not reproduce became an address that does not exist: -# -# 1.92.8-docking pre-release, truncated to 1.92.8 (and so indistinguishable -# from the non-docking release — a different tarball) -# 25.0.4.7.1 five segments, truncated to 25.0.4.7 -# b10069 not a number at all: skipped, then reported as -# "no valid versions in index" -# -# Every shape below is one the real xim-pkgindex publishes today (compat.imgui, -# jdk-corretto, jdk-temurin, khistory). The index here is a local path index so -# nothing is downloaded; the payload for the version that must win is pre-seeded, -# and the others deliberately have none — a build that resolves the wrong key -# fails to install, which is itself an assertion. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -mkdir -p "$TMP/proj/src" "$TMP/proj/local-index/pkgs/a" -cd "$TMP/proj" - -# ── One descriptor per upstream shape ───────────────────────────────────── -mk_pkg() { # $1 = short name, $2 = version-table body, $3 = module name (default gadget) - MOD="${3:-gadget}" - cat > "local-index/pkgs/a/acme.$1.lua" < ".mcpp/.xlings/data/xpkgs/acme.$1/$2/src/$MOD.cppm" -} -seed im 1.92.8 -seed jdk 25.0.4.7.1 -seed dv 1.0.0 devkit - -printf 'import gadget;\nint main(){ return gadget_value() == 42 ? 0 : 1; }\n' > src/main.cpp - -use_dep() { # $1 = short name, $2 = constraint - cat > mcpp.toml </dev/null; done; exit 1; } - -# ── 1. A range must not reach into a pre-release ────────────────────────── -use_dep im '^1.92.8' -"$MCPP" build > b1.log 2>&1 || fail "^1.92.8 did not build" b1.log -grep -q '^\s*Resolved acme.im .* → v1\.92\.8$' b1.log \ - || fail "^1.92.8 must resolve to 1.92.8, never the -docking branch" b1.log - -# ── 2. ...but naming it exactly still addresses it ──────────────────────── -# Only 1.92.8 is seeded, so this must fail AT INSTALL with the docking address — -# proving the wire address carried the literal key rather than a truncation. -use_dep im '1.92.8-docking' -"$MCPP" build > b2.log 2>&1 && fail "expected the unseeded -docking payload to fail" b2.log -grep -q '1\.92\.8-docking' b2.log \ - || fail "exact pre-release must be addressed literally, not truncated" b2.log - -# ── 3. Five segments survive, and an alias is not a candidate ───────────── -# `^25.0` used to render 25.0.4.7 (a key that does not exist) or pick the alias. -use_dep jdk '^25.0' -"$MCPP" build > b3.log 2>&1 || fail "^25.0 did not build" b3.log -grep -q '^\s*Resolved acme.jdk .* → v25\.0\.4\.7\.1$' b3.log \ - || fail "^25.0 must resolve to the real five-segment key, not an alias or a truncation" b3.log - -# ── 4. An unorderable key is named, not blamed on the index ─────────────── -use_dep kh '*' -"$MCPP" build > b4.log 2>&1 && fail "a range over unorderable keys must not succeed" b4.log -grep -q 'not ordered versions' b4.log \ - || fail "expected an error naming the unorderable keys" b4.log -grep -q 'acme.kh = "pre-v0.0.5"' b4.log \ - || fail "the error must show the exact pin that works" b4.log -grep -q 'no valid versions in index' b4.log \ - && fail "the old message blames the index for a package it publishes fine" b4.log - -# ...and pinning it exactly is accepted (fails later, at install, for want of a -# payload — which is proof that resolution let it through). -use_dep kh 'pre-v0.0.5' -"$MCPP" build > b5.log 2>&1 && fail "expected the unseeded payload to fail" b5.log -grep -q 'not ordered versions' b5.log \ - && fail "an exact unorderable key must resolve, not be rejected" b5.log - -# The `=` form must work too — it is exactly what the error above tells the user -# to write, and it is what try_merge_semver produces internally from a literal -# pin. Routing it through the SemVer grammar would reject the remedy. -use_dep kh '=pre-v0.0.5' -"$MCPP" build > b5b.log 2>&1 && fail "expected the unseeded payload to fail" b5b.log -grep -qE 'invalid version constraint|not ordered versions' b5b.log \ - && fail "'=' must resolve to that key" b5b.log - -# ── 5. A genuine precedence tie is refused, not guessed ─────────────────── -use_dep tie '^1.0' -"$MCPP" build > b6.log 2>&1 && fail "a tie must not silently pick one" b6.log -grep -q 'compare EQUAL' b6.log || fail "expected the tie to be named" b6.log -grep -q '1\.0\.0+a' b6.log && grep -q '1\.0\.0+b' b6.log \ - || fail "both tied keys must be listed" b6.log - -# ...while naming one exactly is not ambiguous at all: the ORDER cannot separate -# them (SemVer excludes build metadata from precedence), the LITERALS can. -use_dep tie '=1.0.0+b' -"$MCPP" build > b6b.log 2>&1 && fail "expected the unseeded payload to fail" b6b.log -grep -q 'compare EQUAL' b6b.log \ - && fail "an exact build-metadata pin must not be reported as a tie" b6b.log -grep -q '1\.0\.0+b' b6b.log || fail "the exact key must be addressed literally" b6b.log - -# ── 6. mcpp.lock records the resolution, not the constraint ─────────────── -use_dep im '^1.92.8' -"$MCPP" build -v > b7.log 2>&1 || fail "rebuild failed" b7.log -grep -q 'version = "1.92.8"' mcpp.lock \ - || fail "the lock must record the resolved version" mcpp.lock -grep -q '\^' mcpp.lock \ - && fail "a lock that records a range locks nothing" mcpp.lock -# The banner and the lock read the same data, so they cannot disagree. (The dep -# announces itself as Compiling or Cached depending on the build cache; both go -# through the same version string, which is the point.) -grep -qE '(Compiling|Cached) +acme\.im v1\.92\.8' b7.log \ - || fail "the dependency banner must announce the resolved version" b7.log -grep -q 'acme\.im v\^' b7.log \ - && fail "the banner must never print a constraint as a version" b7.log - -# Honest about what it is not: the lock is written but not yet read back for -# index deps. DELETE this assertion in the same change that makes it -# authoritative — it exists so that removal cannot be forgotten. -grep -q 'does not yet pin future builds' mcpp.lock \ - || fail "the lock must state that it does not pin yet" mcpp.lock - -# Idempotent: a second build must not churn the file. -cp mcpp.lock lock.first -"$MCPP" build > b8.log 2>&1 || fail "second build failed" b8.log -cmp -s mcpp.lock lock.first || fail "mcpp.lock is not stable across builds" mcpp.lock - -# ── 7. ...and stable across COMMANDS, which is the harder half ──────────── -# -# The lock is written from the resolution result, and `mcpp test` resolves -# dev-dependencies while `mcpp build` does not. Recording them therefore made a -# VCS-committed file depend on which command ran last: build, test, build wrote -# three different files. A lock has to be a function of the MANIFEST. -mkdir -p tests -printf 'import devkit;\nint main(){ return devkit_value()==42?0:1; }\n' > tests/t_dev.cpp -cat > mcpp.toml <<'EOF' -[package] -name = "proj" -version = "0.1.0" - -[indices] -acme = { path = "local-index" } - -[dependencies.acme] -im = "^1.92.8" - -[dev-dependencies.acme] -dv = "^1.0" - -[targets.proj] -kind = "bin" -main = "src/main.cpp" -EOF -rm -f mcpp.lock -"$MCPP" build > c1.log 2>&1 || fail "build with a dev-dep failed" c1.log -cp mcpp.lock lock.build -"$MCPP" test > c2.log 2>&1 || fail "test with a dev-dep failed" c2.log -# `mcpp test` announces the resolved version too, as the build's banner does. -grep -q 'acme\.im v\^' c2.log \ - && fail "mcpp test announced a constraint as a version" c2.log -cmp -s mcpp.lock lock.build \ - || { diff lock.build mcpp.lock || true - fail "mcpp test rewrote mcpp.lock — the lock must not depend on the command"; } -"$MCPP" build > c3.log 2>&1 || fail "build after test failed" c3.log -cmp -s mcpp.lock lock.build \ - || fail "mcpp build rewrote mcpp.lock after mcpp test" mcpp.lock -# The dev-dep really was resolved (so this is not passing by never reaching it)… -grep -q 'Resolved acme.dv' c2.log \ - || fail "the dev-dependency was never resolved — the check above is vacuous" c2.log -# …and it is deliberately absent from the file. -grep -q 'acme\.dv' mcpp.lock \ - && fail "dev-dependencies must not be recorded in mcpp.lock" mcpp.lock -grep -q 'dev-dependencies are excluded' mcpp.lock \ - || fail "the lock must state that dev-dependencies are excluded" mcpp.lock - -echo "OK" diff --git a/tests/e2e/197_windows_resources.sh b/tests/e2e/197_windows_resources.sh deleted file mode 100755 index 98ab8fe8c..000000000 --- a/tests/e2e/197_windows_resources.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# requires: windows -# mcpp#365 — native Windows: [resources] compiles to a .res through llvm-rc or -# rc.exe and is linked in by lld-link/link.exe. Shared assertions live in -# _windows_resources_body.sh; 198 runs the same ones through the GNU/windres -# fork so the two dialects cannot drift. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -BUILD_ARGS="" -EXE_SUFFIX=".exe" -source "$(dirname "$0")/_windows_resources_body.sh" diff --git a/tests/e2e/198_windows_resources_cross.sh b/tests/e2e/198_windows_resources_cross.sh deleted file mode 100755 index 2f49a7a26..000000000 --- a/tests/e2e/198_windows_resources_cross.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# requires: mingw-cross -# mcpp#365 — Linux → Windows through the MinGW cross toolchain. The GNU dialect -# takes the other fork: `windres -O coff`, because GNU ld cannot consume a .res -# at all. Same assertions as 197 (see _windows_resources_body.sh), plus the -# non-PE half that only a cross host can check: the very same manifest must -# build for the host with the section simply inapplicable. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="${MCPP_HOME:-$HOME/.mcpp}" - -BUILD_ARGS="--target x86_64-windows-gnu" -EXE_SUFFIX=".exe" -source "$(dirname "$0")/_windows_resources_body.sh" - -# ── A3. On a non-PE target the section is INAPPLICABLE ──────────────────── -# -# Not "degraded", not "skipped with a warning": there is no consumer, so the -# build is unchanged and says nothing. This is what makes cfg(windows) gating -# unnecessary — and it is also the half of issue #365's third request that IS -# satisfied (a Windows-only declaration must not break other platforms). -cd "$TMP/proj" -cat > mcpp.toml <<'TOML' -[package] -name = "resapp" -version = "1.2.3" - -[resources] -icon = "assets/app.ico" - -[targets.resapp] -kind = "bin" -main = "src/main.cpp" -TOML -"$MCPP" build > host.log 2>&1 || { cat host.log; echo "FAIL: host build with [resources] failed"; exit 1; } -grep -qi 'resource' host.log && { cat host.log; echo "FAIL: a non-PE build must say nothing about resources"; exit 1; } -HOST_DIR=$(dirname "$(find target -name 'build.ninja' -not -path '*/.build-mcpp/*' -print | xargs grep -L 'rc_object' | head -1)") -[ -d "$HOST_DIR/res" ] && { echo "FAIL: a non-PE build must not emit resource units"; exit 1; } - -# ── A4. "inapplicable" stops at COMPILATION, not at validation ──────────── -# -# Whether a declared path exists is a fact about the working tree, not about the -# target. Gating the existence check on is_pe() meant a Linux or macOS CI could -# not see a typo in `icon = ...` at all and only the Windows job went red — the -# same "find out late" failure the hard error exists to remove. So: nothing is -# compiled here, nothing is said when the files are fine, and a missing one is -# still an error. -sed -i.bak 's|^icon = .*|icon = "assets/typo.ico"|' mcpp.toml && rm -f mcpp.toml.bak -"$MCPP" build > host2.log 2>&1 \ - && { cat host2.log; echo "FAIL: a missing declared resource must fail on non-PE targets too"; exit 1; } -grep -q 'does not exist' host2.log \ - || { cat host2.log; echo "FAIL: expected the same missing-file error as on Windows"; exit 1; } - -echo "OK" diff --git a/tests/e2e/199_resources_validation.sh b/tests/e2e/199_resources_validation.sh deleted file mode 100755 index 331fa7401..000000000 --- a/tests/e2e/199_resources_validation.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# mcpp#365 — the half of [resources] that is identical on every target. -# -# 197 (native Windows) and 198 (mingw cross) own everything about COMPILING a -# resource, and neither can run on a plain Linux or macOS shard. What can — and -# what has to, because it is the regression this test exists for — is VALIDATION: -# whether a declared path exists is a fact about the working tree, not about the -# target. That check used to sit inside the `is_pe()` branch, so a typo in -# `icon = "assets/app.ico"` was invisible to every non-Windows job and the -# Windows build was the first thing to say so. -# -# Nothing here compiles a resource. On a non-PE host the section is inapplicable -# and the build must stay silent; the file must still have to exist. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } - -mkdir -p "$TMP/proj/src" "$TMP/proj/assets" -cd "$TMP/proj" -printf 'int main() { return 0; }\n' > src/main.cpp -: > assets/app.ico # contents are irrelevant to validation - -write_toml() { # $1 = [resources] body - cat > mcpp.toml < v.log 2>&1 \ - && fail "a missing declared resource must fail the build ($CASE)" v.log - grep -q 'does not exist' v.log \ - || fail "expected a clear missing-file error ($CASE)" v.log - # The error must name the key the user wrote, or it sends them hunting. - KEY="${CASE%% *}" - grep -q "$KEY" v.log \ - || fail "the error must name the [resources] key it came from ($KEY)" v.log -done - -# ── 2. ...and when everything exists, a non-PE build says nothing ───────── -# -# "Inapplicable" is not "degraded" and not "skipped with a warning": no units, -# no diagnostics, and no res/ directory. -# -# PE hosts stop here, before the build below: `assets/app.ico` is an EMPTY file -# — enough to exist, which is all §1 needed — and a real resource compiler would -# rightly reject it. 197 builds a structurally valid icon and owns everything -# about compiling one. -case "$(uname -s 2>/dev/null || echo unknown)" in - MINGW*|MSYS*|CYGWIN*|Windows*) echo "OK (PE host: 197 owns the rest)"; exit 0 ;; -esac - -write_toml 'icon = "assets/app.ico"' -rm -rf target -"$MCPP" build > ok.log 2>&1 || fail "build with a valid [resources] failed" ok.log - -grep -qi 'resource' ok.log \ - && fail "a non-PE build must say nothing about resources" ok.log -find target -type d -name res | grep -q . \ - && fail "a non-PE build must not emit resource units" ok.log - -echo "OK" diff --git a/tests/e2e/19_bmi_cache_reuse.sh b/tests/e2e/19_bmi_cache_reuse.sh deleted file mode 100755 index e67db2465..000000000 --- a/tests/e2e/19_bmi_cache_reuse.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 19_bmi_cache_reuse.sh — local-source packages must never enter the global -# build cache, DIRECTLY or TRANSITIVELY. -# -# 1. A direct path dep is not cached. -# 2. A path dep reached THROUGH another path dep is not cached either. -# -# (2) is the regression this file exists for. The old predicate looked the -# package up in the ROOT manifest's dependencies/dev-dependencies and skipped it -# when the spec was path/git — but a transitively-reached package is in neither -# map, so it fell through and WAS cached, with its index name defaulting to the -# default index (a workspace member `B` landed on disk as `mcpplibs/B@0.1.0`). -# Its sources can then change without changing name@version, so the cache key -# cannot see the change: a stale object, served silently. -# -# The cross-project positive case needs a registry dep and lives in -# 172_build_cache_cross_project.sh. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -# --- Part 1: build a path-dep project and verify NO cache entry is written --- -cd "$TMP" -"$MCPP" new mylibA > /dev/null -cd mylibA -cat > src/greet.cppm <<'EOF' -export module mylibA.greet; -import std; -export auto greet() -> void { std::println("hi"); } -EOF -rm src/main.cpp -cat > mcpp.toml <<'EOF' -[package] -name = "mylibA" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.mylibA] -kind = "lib" -EOF -cd .. - -"$MCPP" new myapp > /dev/null -cd myapp -cat > src/main.cpp <<'EOF' -import std; -import mylibA.greet; -int main() { greet(); return 0; } -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "myapp" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp"] -[targets.myapp] -kind = "bin" -main = "src/main.cpp" -[dependencies.mylibA] -path = "../mylibA" -EOF - -"$MCPP" build -v > build.log 2>&1 || { cat build.log; exit 1; } - -# The cache root exists (env init creates it) but holds no package entry for a -# path dep. -[[ -d "$MCPP_HOME/build-cache/v1" ]] || { echo "missing $MCPP_HOME/build-cache/v1"; exit 1; } -if find "$MCPP_HOME/build-cache/v1/pkg" -path "*mylibA*" 2>/dev/null | grep -q .; then - echo "FAIL: path dep mylibA was populated into the build cache (must be skipped)" - find "$MCPP_HOME/build-cache/v1" -maxdepth 5 - exit 1 -fi - -# Build output must NOT state that mylibA came from the cache (it's a path -# dep, not a registry dep): no `Cached mylibA` line. -if grep -qE 'Cached mylibA|Compiling mylibA .*cached [0-9]+ unit' build.log; then - echo "FAIL: path dep wrongly labeled Cached" - cat build.log; exit 1 -fi - -# --- Part 2: a path dep reached THROUGH a path dep is also excluded --------- -cd "$TMP" -"$MCPP" new mylibB > /dev/null -cd mylibB -cat > src/inner.cppm <<'EOF' -export module mylibB.inner; -export auto inner() -> int { return 7; } -EOF -rm src/main.cpp -cat > mcpp.toml <<'EOF' -[package] -name = "mylibB" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.mylibB] -kind = "lib" -EOF - -# mylibA now depends on mylibB by path, so myapp reaches mylibB transitively -# and mylibB appears in NEITHER of myapp's dependency maps. -cd "$TMP/mylibA" -cat > src/greet.cppm <<'EOF' -export module mylibA.greet; -import std; -import mylibB.inner; -export auto greet() -> void { std::println("hi {}", inner()); } -EOF -cat >> mcpp.toml <<'EOF' -[dependencies.mylibB] -path = "../mylibB" -EOF - -cd "$TMP/myapp" -rm -rf target -"$MCPP" build > build2.log 2>&1 || { cat build2.log; exit 1; } - -for name in mylibA mylibB; do - if find "$MCPP_HOME/build-cache/v1/pkg" -path "*$name*" 2>/dev/null | grep -q .; then - echo "FAIL: local package $name entered the build cache" - find "$MCPP_HOME/build-cache/v1/pkg" -maxdepth 4 2>/dev/null - cat build2.log - exit 1 - fi -done - -# And `mcpp cache list` — the user-visible surface — must not list them either. -"$MCPP" cache list > list.log 2>&1 -for name in mylibA mylibB; do - if grep -q "$name" list.log; then - echo "FAIL: cache list shows local package $name" - cat list.log - exit 1 - fi -done - -# Editing the transitive path dep must still take effect (it is compiled, not -# served from a cache that cannot see the change). -cd "$TMP/mylibB" -cat > src/inner.cppm <<'EOF' -export module mylibB.inner; -export auto inner() -> int { return 99; } -EOF -cd "$TMP/myapp" -"$MCPP" run > run.log 2>&1 || { cat run.log; exit 1; } -grep -q 'hi 99' run.log || { - echo "FAIL: edit to a transitive path dep did not reach the binary" - cat run.log - exit 1 -} - -echo "OK" diff --git a/tests/e2e/200_subos_env_reaches_program.sh b/tests/e2e/200_subos_env_reaches_program.sh deleted file mode 100755 index cc1f32dc5..000000000 --- a/tests/e2e/200_subos_env_reaches_program.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -# 200_subos_env_reaches_program.sh — one selected RuntimeBinding snapshot must -# reach full-path and cached `mcpp run` without an environment/CLI override. -set -euo pipefail - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -fail() { echo "FAIL: $*" >&2; exit 1; } - -# Match the mcpp-managed default's real libc so payload probing remains -# truthful; the fixture changes only environment declarations. -default_manifest="$MCPP_HOME/registry/subos/default/.xlings.json" -[[ -f "$default_manifest" ]] || fail "mcpp default SubOS is missing" -default_subos=$(dirname "$default_manifest") -runtime=$(sed -n 's/.*"runtime"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - "$default_manifest" | head -1) -[[ -n "$runtime" ]] || fail "default SubOS has no runtime identity" - -cd "$TMP" -"$MCPP" new hello >/dev/null -cd hello -cat >>mcpp.toml <<'EOF' - -[xlings] -subos = "probe" -EOF -cat >src/main.cpp <<'EOF' -#include -#include -int main() { - const char* v = std::getenv("MCPP_E2E_PROBE"); - std::printf("PROBE=%s\n", v ? v : "(unset)"); - return 0; -} -EOF - -subos="$PWD/.mcpp/.xlings/subos/probe" -mkdir -p "$subos/usr/lib/dri" -# A named SubOS is a physical development-OS view, not only metadata. Reuse -# the managed default's exact runtime payload so this fixture varies only the -# selected environment declarations. -ln -s "$default_subos/lib" "$subos/lib" -write_contract() { - local suffix=$1 - cat >"$subos/.xlings.json" <&1) || { echo "$out"; fail "first run"; } -grep -q "PROBE=$subos/usr/lib/dri" <<<"$out" \ - || { echo "$out"; fail "selected environment did not reach program"; } - -# Second invocation consumes the serialized snapshot and really takes fast path. -cached=$("$MCPP" run 2>&1) || { echo "$cached"; fail "cached run"; } -grep -q 'Resolving toolchain' <<<"$cached" \ - && { echo "$cached"; fail "second run did not take fast path"; } -grep -q "PROBE=$subos/usr/lib/dri" <<<"$cached" \ - || { echo "$cached"; fail "fast path changed the runtime environment"; } - -# MCPP_SUBOS_DIR was an undeclared second selection entrance. It is ignored: -# mcpp.toml remains the only project runtime selector. -evil="$TMP/evil" -mkdir -p "$evil" -cat >"$evil/.xlings.json" <&1) \ - || { echo "$no_override"; fail "run with ignored legacy variable"; } -grep -q "PROBE=$subos/usr/lib/dri" <<<"$no_override" \ - || { echo "$no_override"; fail "MCPP_SUBOS_DIR overrode mcpp.toml"; } - -# Contract mutation invalidates the fast path and creates a new fingerprint; -# this invocation uses one NEW snapshot consistently, rather than re-reading -# only at run time against objects from the old contract. -sleep 1 -write_contract dri2 -changed=$("$MCPP" run 2>&1) || { echo "$changed"; fail "changed contract run"; } -grep -q 'Resolving toolchain' <<<"$changed" \ - || { echo "$changed"; fail "contract mutation did not invalidate fast path"; } -grep -q "PROBE=$subos/usr/lib/dri2" <<<"$changed" \ - || { echo "$changed"; fail "new snapshot was not applied"; } - -# Absence means the managed default, never active/current or the prior named -# environment. The default does not declare our synthetic probe variable. -sed -i '/^\[xlings\]/,$d' mcpp.toml -plain=$("$MCPP" run 2>&1) || { echo "$plain"; fail "McppDefault run"; } -grep -q 'PROBE=(unset)' <<<"$plain" \ - || { echo "$plain"; fail "named environment leaked into McppDefault"; } - -# An old cache without the full serialized binding is a miss, not a run with a -# guessed/re-read SubOS. Re-select probe, create the cache, then age it. -cat >>mcpp.toml <<'EOF' - -[xlings] -subos = "probe" -EOF -write_contract dri -"$MCPP" run >/dev/null 2>&1 -cache="$PWD/target/.build_cache" -grep -q '^runtimeBinding=' "$cache" || fail "cache has no RuntimeBinding" -grep -v '^runtimeBinding=' "$cache" >"$cache.old" && mv "$cache.old" "$cache" -aged=$("$MCPP" run 2>&1) || { echo "$aged"; fail "aged-cache run"; } -grep -q 'Resolving toolchain' <<<"$aged" \ - || { echo "$aged"; fail "cache without RuntimeBinding was replayed"; } -grep -q "PROBE=$subos/usr/lib/dri" <<<"$aged" \ - || { echo "$aged"; fail "aged cache rebuild lost selected environment"; } -grep -q '^runtimeBinding=' "$cache" \ - || fail "rebuilt cache did not record RuntimeBinding" - -# A selected named SubOS that cannot answer the contract is a hard error; it -# cannot fall back to default, active or compiler-baked state. -sed -i 's/subos = "probe"/subos = "bare"/' mcpp.toml -bare="$PWD/.mcpp/.xlings/subos/bare" -mkdir -p "$bare" -echo '{ "workspace": {} }' >"$bare/.xlings.json" -if "$MCPP" run >bare.log 2>&1; then - cat bare.log - fail "named SubOS without RuntimeBinding was accepted" -fi -grep -Eq 'RuntimeBinding|no `subos_info`|does not describe itself' bare.log \ - || { cat bare.log; fail "missing-contract diagnostic is not actionable"; } - -echo "PASS: selected RuntimeBinding is root-local, immutable and cached" diff --git a/tests/e2e/201_gcc_no_specs_pollution.sh b/tests/e2e/201_gcc_no_specs_pollution.sh deleted file mode 100755 index 3ce136101..000000000 --- a/tests/e2e/201_gcc_no_specs_pollution.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf -# 201_gcc_no_specs_pollution.sh — a gcc artifact must carry only the RUNPATH -# entries this build asked for. -# -# mcpp used to write the loader and rpath into the gcc payload's `specs` file -# at install time, and the link command said nothing about either. Two -# consequences, both measured on a developer machine before this test existed: -# -# 1. the compile side (-L, chosen per build) and the run side (specs, frozen -# at toolchain install) could name different glibc versions, and did as -# soon as a second one was installed; -# 2. the substitution that wrote those specs had a one-path needle and a -# two-path replacement, so every home that ever patched the SHARED payload -# left one entry behind -- 68 of them, all pointing at deleted mktemp -# directories, in every gcc artifact the machine produced. -# -# The assertion is deliberately about the artifact, not about the specs file: -# what matters is what a user ends up shipping. A RUNPATH entry under /tmp is -# never something a build asked for. -set -euo pipefail - -SELF_DIR=$(cd "$(dirname "$0")" && pwd) # captured before any cd -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -export MCPP_HOME=$HOME/.mcpp - -cd "$TMP" -"$MCPP" new prog > /dev/null -cd prog -cat >> mcpp.toml <<'EOF' - -[toolchain] -linux = "gcc@16.1.0" -EOF - -"$MCPP" build > "$TMP/build.log" 2>&1 || { cat "$TMP/build.log"; echo "build failed"; exit 1; } - -# A cold build, because the generated spec lives in target/ and a cold build -# deletes target/. Written the obvious way -- generate during prepare, consume -# during the build -- gcc got a `-specs=` naming a file that had been removed -# in between, and EVERY `--no-cache` build failed with `cannot read spec file`. -# The warm path above cannot see that: the file is still there from last time. -"$MCPP" build --no-cache > "$TMP/cold.log" 2>&1 || { - cat "$TMP/cold.log"; echo "cold build failed"; exit 1; } - -bin=$(find target -type f -name prog -path '*/bin/*' | head -1) -[[ -n "$bin" ]] || { echo "no binary produced"; exit 1; } - -# `readelf` may be an xvm shim needing a per-tool pin; use the payload's own. -readelf=$(ls "$MCPP_HOME"/registry/data/xpkgs/xim-x-binutils/*/bin/readelf 2>/dev/null | head -1) -[[ -x "$readelf" ]] || { echo "SKIP: no payload readelf to inspect with"; exit 77; } - -runpath=$("$readelf" -d "$bin" 2>/dev/null | grep -iE 'RUNPATH|RPATH' | sed 's/.*\[\(.*\)\]/\1/' || true) - -# 1. No path under a temp directory. These can only come from a specs file that -# some other home patched; no build ever asks for one. -if echo "$runpath" | tr ':' '\n' | grep -q '^/tmp/'; then - echo "the artifact carries RUNPATH entries from other homes' temp dirs:" - echo "$runpath" | tr ':' '\n' | grep '^/tmp/' | head -5 - echo " ...total $(echo "$runpath" | tr ':' '\n' | grep -c '^/tmp/')" - echo "These come from the payload's specs, which mcpp must no longer write." - exit 1 -fi - -# 2. Every entry must exist. A RUNPATH naming a directory that is not there is -# a cost paid on every symbol resolution for nothing, and a reliable sign -# that something wrote a path belonging to a different machine state. -missing=0 -while IFS= read -r d; do - [[ -z "$d" ]] && continue - [[ "$d" == \$ORIGIN* ]] && continue - [[ -d "$d" ]] || { echo " RUNPATH entry does not exist: $d"; missing=$((missing+1)); } -done <<< "$(echo "$runpath" | tr ':' '\n')" -[[ "$missing" -eq 0 ]] || { echo "$missing dead RUNPATH entries"; exit 1; } - -# 3. The interpreter is the payload's, and the program runs. -file_out=$(file "$bin") -echo "$file_out" | grep -q 'interpreter .*xim-x-glibc' || { - echo "PT_INTERP is not the payload's glibc:"; echo "$file_out"; exit 1; } -out=$("$bin" 2>&1) || { echo "binary did not run: $out"; exit 1; } -echo "$out" | grep -q 'Hello' || { echo "unexpected output: $out"; exit 1; } - -# 4. Nothing loads from the host. -# -# "Does it run" is not this question, and answering the wrong one is how a -# real gap survived: with the compiler's own lib dir missing from RUNPATH, -# artifacts resolved libgcc_s.so.1 from /lib and ran perfectly on any machine -# that has a host toolchain -- which is every developer machine. It failed -# only on CI's throw-away home, and only as `error while loading shared -# libraries`, with no indication of what had gone missing or why. -# -# Asking where each library actually came from makes the same defect visible -# everywhere, host toolchain or not. -# Only lines whose LEFT side is a bare soname count. ldd resolves the program -# interpreter by running the host's loader, so it always reports the -# interpreter as `/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2` -# -- a statement about ldd, not about the artifact, and a false positive that -# failed this very check on CI. A real dependency has no slash on the left. -host_lib_lines() { - ldd "$1" 2>/dev/null \ - | grep -E '^[[:space:]]*[^/[:space:]]+ => +(/lib|/usr/lib|/lib64|/usr/lib64)/' \ - || true -} - -if command -v ldd > /dev/null 2>&1; then - host_libs=$(host_lib_lines "$bin") - if [[ -n "$host_libs" ]]; then - echo "the artifact loads libraries from the host:" - echo "$host_libs" | sed 's/^/ /' - echo "Every shared library a payload build needs is in the payload;" - echo "reaching outside it means a directory is missing from RUNPATH." - exit 1 - fi -fi - -# ── The same assertions again, in the OTHER link mode ──────────────────── -# -# RUNPATH is built by two separate paths -- CLibMode::Sysroot and -# CLibMode::PayloadFirst -- and a machine only ever takes one of them. This -# one has a usable sysroot, so everything above ran through Sysroot mode and -# PayloadFirst went untested here for as long as this file has existed. -# -# It was not academic: PayloadFirst omitted the compiler's own lib dir, so -# artifacts resolved libgcc_s.so.1 from the host. Invisible on any machine -# with a host toolchain; on CI's throw-away home it was `error while loading -# shared libraries` with no further explanation. -# -# A home with payloads but no subos usually has no sysroot to find, which is -# what selects PayloadFirst -- but "usually" is the honest word: where gcc -# reports a baked sysroot that happens to exist (another checkout on the same -# machine), mcpp accepts it as a last resort and this leg runs Sysroot mode a -# second time instead. Which is what it does on the machine these words were -# written on. -# -# So the deterministic guard for this invariant is the unit test -# (tests/unit/test_link_model_runtime_dirs.cpp), which names the mode rather -# than arranging for it. This leg is still worth running: it covers a second -# home shape end to end, and on CI -- where the baked path does not exist -- -# it is the real thing. -export MCPP_HOME="$TMP/payload-first-home" -MCPP_INHERIT_CONFIG=0 MCPP_INHERIT_SUBOS=0 \ - source "$SELF_DIR/_inherit_toolchain.sh" - -cd "$TMP" -"$MCPP" new pf > /dev/null -cd pf -cat >> mcpp.toml <<'EOF' - -[toolchain] -linux = "gcc@16.1.0" -EOF - -"$MCPP" build > "$TMP/pf.log" 2>&1 || { - cat "$TMP/pf.log"; echo "payload-first build failed"; exit 1; } - -pfbin=$(find target -type f -name pf -path '*/bin/*' | head -1) -[[ -n "$pfbin" ]] || { echo "no payload-first binary produced"; exit 1; } - -pf_host=$(host_lib_lines "$pfbin") -if [[ -n "$pf_host" ]]; then - echo "the payload-first artifact loads libraries from the host:" - echo "$pf_host" | sed 's/^/ /' - echo "RUNPATH was:" - "$readelf" -d "$pfbin" 2>/dev/null | grep -iE 'RUNPATH|RPATH' | sed 's/^/ /' - exit 1 -fi - -"$pfbin" > /dev/null 2>&1 || { - echo "payload-first binary did not run: $("$pfbin" 2>&1 | head -2)"; exit 1; } - -echo "PASS: gcc artifacts carry only the RUNPATH the build asked for and load" -echo " nothing from the host — in both sysroot and payload-first modes" diff --git a/tests/e2e/202_machine_output_contract.sh b/tests/e2e/202_machine_output_contract.sh deleted file mode 100755 index f39954531..000000000 --- a/tests/e2e/202_machine_output_contract.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# 202_machine_output_contract.sh — stdout belongs to the protocol. -# -# A client that asks for machine-readable output has exactly one channel for -# it. Anything mcpp says about ITSELF -- a usage error, an unsupported format -# -- has to go somewhere else, or the client's parser eats it. -# -# Measured before this test existed: -# -# mcpp cache list --format json -> stdout: "Error: unknown option: --format" -# stderr: (empty) rc=1 -# mcpp pack --pkg-format bogus -> stderr: "invalid --pkg-format ..." rc=2 -# -# Two shapes for the same class of mistake, and the first one writes human text -# into the channel a protocol owns. `mcpp cache list --format json | jq` gets a -# parse error with nothing to distinguish "this mcpp is too old" from "the -# command failed". -# -# Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md §2 -set -uo pipefail - -fail=0 -check() { # description expected_rc cmd... - local what=$1 want=$2; shift 2 - local out err rc - out=$("$@" 2>/dev/null); rc=$? - err=$("$@" 2>&1 >/dev/null) - if [[ -n "$out" ]]; then - echo "FAIL: $what wrote to stdout: $(echo "$out" | head -1 | cut -c1-70)" - fail=1 - fi - if [[ -z "$err" ]]; then - echo "FAIL: $what said nothing on stderr" - fail=1 - fi - if [[ "$rc" != "$want" ]]; then - echo "FAIL: $what exited $rc, expected $want" - fail=1 - fi -} - -# An option that does not exist. This is the path a client hits when it probes -# an older mcpp for a capability -- the most common machine-facing failure, -# and the one that used to print to stdout. -check "unknown option (cache list)" 2 "$MCPP" cache list --no-such-option -check "unknown option (self env)" 2 "$MCPP" self env --no-such-option -check "unknown option (top level)" 2 "$MCPP" --no-such-option - -# A value the command does not accept. `--format` exists on these now, so this -# is the branch that answers "I know the option, not that value". -check "unsupported value (self env)" 2 "$MCPP" self env --format yaml -check "unsupported value (cache list)" 2 "$MCPP" cache list --format yaml -check "unsupported value (pack)" 2 "$MCPP" pack --pkg-format bogus - -# And the supported ones must still be JSON on stdout. Asserted here because -# every check above is about what does NOT happen; without this, deleting the -# feature entirely would leave the file green. -# -# HERE-STRINGS, NOT PIPES. `set -o pipefail` is on, and `grep -q` exits the -# moment it matches — so `echo "$out" | grep -q` leaves `echo` writing into a -# closed pipe, takes SIGPIPE, and the pipeline reports 141 even though the -# match SUCCEEDED. It only shows up once `$out` is big enough that echo has not -# finished writing, which for `cache list` means "once this machine has built a -# few things": measured 3-4 failures in 5 runs on a developer box with a warm -# build cache, and zero on CI, with `grep -c` on the same string finding 153 -# matches. A test that fails on the size of an unrelated cache is a test that -# will be read as a regression in whatever happens to be in flight. -for cmd in "self env" "cache list"; do - out=$($MCPP $cmd --format json 2>/dev/null) || { echo "FAIL: $cmd --format json exited non-zero"; fail=1; } - grep -q '"schemaVersion"' <<<"$out" || { echo "FAIL: $cmd --format json has no schemaVersion"; fail=1; } - grep -q '"kind"' <<<"$out" || { echo "FAIL: $cmd --format json has no kind"; fail=1; } -done - -[[ "$fail" -eq 0 ]] || exit 1 -echo "PASS: usage errors stay off stdout and exit 2" diff --git a/tests/e2e/203_exact_selector_lock_migration.sh b/tests/e2e/203_exact_selector_lock_migration.sh deleted file mode 100755 index b264192e1..000000000 --- a/tests/e2e/203_exact_selector_lock_migration.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# A compact dotted selector used to try mcpplibs. first. During the one -# migration release, an existing lock keeps that already-selected identity; -# without the lock, the selector is exact. Both identities exist here so index -# order cannot accidentally make the assertion pass. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -INDEX_DIR="$TMP/index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -APP="$TMP/app" -mkdir -p "$INDEX_DIR/pkgs/c" "$INDEX_DIR/pkgs/m" "$APP/src" - -descriptor() { - local ns=$1 - local desc=$2 - cat < "$INDEX_DIR/pkgs/c/capi.lua.lua" -descriptor mcpplibs.capi "old prefixed identity" \ - > "$INDEX_DIR/pkgs/m/mcpplibs.capi.lua.lua" - -OLD_ROOT="$APP/.mcpp/.xlings/data/xpkgs/mcpplibs.capi-x-lua/1.0.0" -NEW_ROOT="$APP/.mcpp/.xlings/data/xpkgs/capi-x-lua/1.0.0" -mkdir -p "$OLD_ROOT/src" "$NEW_ROOT/src" -cat > "$OLD_ROOT/src/lua.cppm" <<'EOF' -export module selected.lua; -export int selected_value() { return 41; } -EOF -cat > "$NEW_ROOT/src/lua.cppm" <<'EOF' -export module selected.lua; -export int selected_value() { return 42; } -EOF -printf 'ok\n' > "$OLD_ROOT/.mcpp_ok" -printf 'ok\n' > "$NEW_ROOT/.mcpp_ok" - -cat > "$APP/src/main.cpp" <<'EOF' -import selected.lua; -int main() { return selected_value() == 41 ? 0 : 1; } -EOF -cat > "$APP/mcpp.toml" < "$APP/mcpp.lock" <<'EOF' -# Existing pre-exact-selector resolution anchor. -version = 2 - -[package."capi.lua"] -namespace = "mcpplibs.capi" -version = "1.0.0" -source = "index+mcpplibs.capi@1.0.0" -hash = "fnv1a:migration-fixture" -EOF - -cd "$APP" -"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } -grep -q "keeping the locked identity" build.log || { - cat build.log - echo "expected selector migration warning" - exit 1 -} -grep -q "mcpplibs.capi.lua" build.log || { - cat build.log - echo "warning must include the locked selector" - exit 1 -} -"$MCPP" run > run.log 2>&1 || { cat run.log; exit 1; } -grep -q 'namespace = "mcpplibs.capi"' mcpp.lock || { - cat mcpp.lock - echo "lock identity changed during migration" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/204_new_transactional_scaffold.sh b/tests/e2e/204_new_transactional_scaffold.sh deleted file mode 100755 index fba4054d3..000000000 --- a/tests/e2e/204_new_transactional_scaffold.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env bash -# 204_new_transactional_scaffold.sh — portable names and all-or-nothing new. -set -euo pipefail - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -assert_no_stage() { - local parent=$1 - if find "$parent" -maxdepth 1 -name '.mcpp-new-*' -print -quit \ - | grep -q .; then - find "$parent" -maxdepth 1 -name '.mcpp-new-*' -print >&2 - fail "transaction staging directory survived" - fi -} - -# Name validation precedes config/index/network activity as well as filesystem -# mutation. An intentionally missing package must never mask the bad name. -PRE_HOME="$TMP/prevalidation-home" -mkdir -p "$TMP/prevalidation-work" -cd "$TMP/prevalidation-work" -if MCPP_HOME="$PRE_HOME" "$MCPP" new '../escape' --template no-such-package \ - > invalid.log 2>&1; then - fail "path escape project name was accepted" -fi -grep -qi 'project name\|package selector' invalid.log \ - || { cat invalid.log; fail "invalid-name diagnostic missing"; } -[[ ! -e "$TMP/escape" ]] || fail "path escape created a sibling" -[[ ! -e "$PRE_HOME" ]] || fail "invalid name initialized MCPP_HOME" -assert_no_stage "$TMP/prevalidation-work" - -for name in '/absolute' '.' '..' 'a/b' 'a\b' 'CON' 'con.txt' \ - 'trail.' 'trail ' 'myPROJECTname' $'bad\tname'; do - if MCPP_HOME="$PRE_HOME" "$MCPP" new "$name" > invalid.log 2>&1; then - fail "invalid project name was accepted: [$name]" - fi - assert_no_stage "$TMP/prevalidation-work" -done - -export MCPP_HOME="$TMP/home" -mkdir -p "$MCPP_HOME" -"$MCPP" self config --mirror "${MCPP_E2E_TOOLCHAIN_MIRROR:-GLOBAL}" \ - >/dev/null 2>&1 || true - -# Exact foreign-namespace provider with a same-short default-namespace sibling. -INDEX="$MCPP_HOME/indices/mcpplibs/pkgs/t" -mkdir -p "$INDEX" -for row in 'acme tx-template acme-x-tx-template 2.0.0' \ - 'mcpplibs tx-template mcpplibs-x-tx-template 9.0.0'; do - set -- $row - ns=$1 short=$2 store=$3 version=$4 - cat > "$INDEX/${ns}.${short}.lua" < "$root/mcpp.toml" < "$root/templates/starter/template.toml" <<'EOF' -[template] -description = "single template is implicitly default" - -[template.inject] -self = { features = ["gui"] } -EOF - cat > "$root/templates/starter/mcpp.toml.in" <<'EOF' -[package] -name = "{{project.qualifiedName}}" -version = "0.1.0" - -[dependencies.compat] -tx-template = "1.0.0" - -# project={{project.name}} ns={{project.namespace}} qualified={{project.qualifiedName}} -# provider={{template.package.namespace}}/{{template.package.name}} -# selector={{template.package.selector}} version={{template.package.version}} -# template={{template.name}} -EOF - cat > "$root/templates/starter/src/main.cpp.in" <<'EOF' -// {{project.qualifiedName}} from {{template.package.selector}}@{{template.package.version}}:{{template.name}} -int main() { return 0; } -EOF -done - -WORK="$TMP/work" -mkdir -p "$WORK" -cd "$WORK" - -# Builtin scaffolding commits only after every file and manifest validate. -"$MCPP" new acme.tools.app > builtin.log 2>&1 \ - || { cat builtin.log; fail "builtin scaffold failed"; } -[[ -f acme.tools.app/mcpp.toml && -f acme.tools.app/src/main.cpp \ - && -f acme.tools.app/tests/test_smoke.cpp ]] \ - || fail "builtin scaffold is incomplete" -grep -q 'name = "acme.tools.app"' acme.tools.app/mcpp.toml \ - || fail "qualified project identity missing from builtin manifest" -grep -q 'Hello from app!' acme.tools.app/src/main.cpp \ - || fail "builtin project.name token did not use the short name" -assert_no_stage "$WORK" - -# Valid package scaffold preserves every identity component and injects the -# exact acme PackageId despite a compat package with the same short name. -"$MCPP" new org.demo.app --template acme.tx-template@2.0.0 \ - > package.log 2>&1 || { cat package.log; fail "package scaffold failed"; } -grep -q 'project=app ns=org.demo qualified=org.demo.app' \ - org.demo.app/mcpp.toml || fail "project RenderVars are incomplete" -grep -q 'provider=acme/tx-template' org.demo.app/mcpp.toml \ - || fail "provider PackageId was lost" -grep -q 'selector=acme.tx-template version=2.0.0' org.demo.app/mcpp.toml \ - || fail "resolved selector/version were lost" -grep -q 'template=starter' org.demo.app/mcpp.toml \ - || fail "implicit single-template selection was not rendered" -grep -q '^\[dependencies\.acme\]$' org.demo.app/mcpp.toml \ - && grep -q '^tx-template = { version = "2.0.0", features = \["gui"\] }$' \ - org.demo.app/mcpp.toml \ - || { cat org.demo.app/mcpp.toml; fail "exact self dependency missing"; } -grep -q '^tx-template = "1.0.0"$' org.demo.app/mcpp.toml \ - || fail "same-short compat dependency was overwritten" -assert_no_stage "$WORK" - -# Add failure templates only after proving that a sole template with no -# `default = true` is selected automatically. -PKG="$MCPP_HOME/registry/data/xpkgs/acme-x-tx-template/2.0.0/tx-template-2.0.0" -mkdir -p "$PKG/templates/broken" "$PKG/templates/collision/a" -cat > "$PKG/templates/broken/template.toml" <<'EOF' -[template] -description = "unknown token rollback" -EOF -cat > "$PKG/templates/broken/mcpp.toml.in" <<'EOF' -[package] -name = "{{project.typo}}" -version = "0.1.0" -EOF -cat > "$PKG/templates/collision/template.toml" <<'EOF' -[template] -description = "destination type collision rollback" -EOF -cat > "$PKG/templates/collision/mcpp.toml.in" <<'EOF' -[package] -name = "{{project.name}}" -version = "0.1.0" -EOF -# Whichever source entry is visited first, `a.in` rendering and directory `a` -# cannot both materialize at destination path `a`. -echo data > "$PKG/templates/collision/a.in" -echo nested > "$PKG/templates/collision/a/member.txt" - -# Unknown render tokens and destination type conflicts roll back the complete -# staging tree and never publish the final project directory. -for case_name in broken collision; do - target="fail-$case_name" - if "$MCPP" new "$target" \ - --template "acme.tx-template@2.0.0:$case_name" \ - > "$case_name.log" 2>&1; then - cat "$case_name.log" - fail "$case_name failure template unexpectedly succeeded" - fi - [[ ! -e "$target" ]] || fail "$case_name left a partial final target" - assert_no_stage "$WORK" -done -grep -q 'unknown template token' broken.log \ - || { cat broken.log; fail "unknown-token diagnostic missing"; } -grep -qi 'failed\|cannot\|directory' collision.log \ - || { cat collision.log; fail "destination-collision diagnostic missing"; } - -# Symlink payload rejection is exercised where the host permits symlinks. It -# is intentionally not a hard capability requirement so portable-name and -# renderer transaction coverage still runs on restricted Windows runners. -mkdir -p "$PKG/templates/symlinked" -cat > "$PKG/templates/symlinked/template.toml" <<'EOF' -[template] -description = "symlink rollback" -EOF -cat > "$PKG/templates/symlinked/mcpp.toml.in" <<'EOF' -[package] -name = "{{project.name}}" -version = "0.1.0" -EOF -symlink_path="$PKG/templates/symlinked/escape-link" -if ln -s mcpp.toml.in "$symlink_path" 2>/dev/null \ - && [[ -L "$symlink_path" ]]; then - if "$MCPP" new fail-symlink \ - --template acme.tx-template@2.0.0:symlinked \ - > symlink.log 2>&1; then - cat symlink.log - fail "symlink template unexpectedly succeeded" - fi - grep -qi 'symlink' symlink.log \ - || { cat symlink.log; fail "symlink rejection diagnostic missing"; } - [[ ! -e fail-symlink ]] || fail "symlink failure left final target" - assert_no_stage "$WORK" -else - # Some restricted Windows shells report success from `ln -s` without - # creating a symbolic link. Do not turn that environment quirk into a - # false assertion about scaffold behavior. - rm -f -- "$symlink_path" -fi - -echo OK diff --git a/tests/e2e/205_root_local_subos.sh b/tests/e2e/205_root_local_subos.sh deleted file mode 100755 index 68ca10766..000000000 --- a/tests/e2e/205_root_local_subos.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# resources: machine -# 205_root_local_subos.sh — root/workspace-root owns the local development OS; -# member/dependency declarations are non-transitive and active shell state is -# never a selector. -set -euo pipefail -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $*" >&2; exit 1; } - -default_manifest="$MCPP_HOME/registry/subos/default/.xlings.json" -[[ -f "$default_manifest" ]] || fail "mcpp default SubOS is missing" -default_subos=$(dirname "$default_manifest") -runtime=$(sed -n 's/.*"runtime"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - "$default_manifest" | head -1) -[[ -n "$runtime" ]] || fail "default SubOS has no runtime identity" - -write_contract() { - local dir=$1 marker=$2 - mkdir -p "$dir" - # Selection tests still need a truthful physical RuntimeBinding. Reuse the - # same libc/loader payload while varying only root ownership and metadata. - ln -s "$default_subos/lib" "$dir/lib" - cat >"$dir/.xlings.json" <"$TMP/default/mcpp.toml" <<'EOF' -[package] -name = "default-app" -version = "0.1.0" -EOF -echo 'int main() { return 0; }' >"$TMP/default/src/main.cpp" -cd "$TMP/default" -plain=$($MCPP build --print-fingerprint 2>&1) || { echo "$plain"; fail "default build"; } -active=$(XLINGS_ACTIVE_SUBOS=definitely-missing \ - $MCPP build --print-fingerprint 2>&1) \ - || { echo "$active"; fail "active state influenced default build"; } -plain_hash=$(field11 <<<"$plain") -active_hash=$(field11 <<<"$active") -[[ -n "$plain_hash" && "$plain_hash" == "$active_hash" ]] \ - || fail "McppDefault fingerprint changed with XLINGS_ACTIVE_SUBOS" - -# Explicit `default` is NamedSubos("default"), not absence; it intentionally -# carries a distinct selection/provenance identity even though the physical -# SubOS is the same managed default. -cat >>mcpp.toml <<'EOF' - -[xlings] -subos = "default" -EOF -explicit=$($MCPP build --print-fingerprint 2>&1) \ - || { echo "$explicit"; fail "explicit default build"; } -explicit_hash=$(field11 <<<"$explicit") -[[ -n "$explicit_hash" && "$explicit_hash" != "$plain_hash" ]] \ - || fail "explicit default collapsed into absence" - -# Workspace root wins before member substitution. The member deliberately -# names a missing SubOS: if its declaration leaks into selection, the build -# fails before compiling. Root materialization must also stay at the root. -ws="$TMP/workspace" -mkdir -p "$ws/app/src" -cat >"$ws/mcpp.toml" <<'EOF' -[workspace] -members = ["app"] - -[xlings] -subos = "root-el8" -EOF -cat >"$ws/app/mcpp.toml" <<'EOF' -[package] -name = "workspace-app" -version = "0.1.0" - -[xlings] -subos = "missing-member-subos" -EOF -echo 'int main() { return 0; }' >"$ws/app/src/main.cpp" -write_contract "$ws/.mcpp/.xlings/subos/root-el8" root -cd "$ws" -workspace_out=$($MCPP build -p app --print-fingerprint 2>&1) \ - || { echo "$workspace_out"; fail "workspace root did not own SubOS"; } -workspace_hash=$(field11 <<<"$workspace_out") -[[ -n "$workspace_hash" ]] || fail "workspace binding hash missing" -grep -q '"subos": "root-el8"' "$ws/.mcpp/.xlings.json" \ - || { cat "$ws/.mcpp/.xlings.json"; fail "workspace root xlings config not materialized"; } -[[ ! -f "$ws/app/.mcpp/.xlings.json" ]] \ - || fail "member xlings config was materialized during workspace build" - -# The same member is authoritative when it becomes an independent root. -standalone="$TMP/standalone" -cp -R "$ws/app" "$standalone" -write_contract "$standalone/.mcpp/.xlings/subos/missing-member-subos" member -cd "$standalone" -standalone_out=$($MCPP build --print-fingerprint 2>&1) \ - || { echo "$standalone_out"; fail "standalone member selection"; } -standalone_hash=$(field11 <<<"$standalone_out") -[[ -n "$standalone_hash" && "$standalone_hash" != "$workspace_hash" ]] \ - || fail "standalone member reused workspace RuntimeBinding" - -# A dependency's own SubOS declaration is not consulted. It remains source -# distributed and is built inside the consumer root's selected environment. -dep="$TMP/dep" -dep_host="$(host_path "$dep")" -app="$TMP/consumer" -mkdir -p "$dep/src" "$app/src" -cat >"$dep/mcpp.toml" <<'EOF' -[package] -name = "localdep" -version = "0.1.0" - -[modules] -sources = ["src/**/*.cppm"] - -[targets.localdep] -kind = "lib" - -[xlings] -subos = "missing-dependency-subos" -EOF -cat >"$dep/src/value.cppm" <<'EOF' -export module localdep.value; -export int local_value() { return 7; } -EOF -cat >"$app/mcpp.toml" <"$app/src/main.cpp" <<'EOF' -import localdep.value; -int main() { return local_value() == 7 ? 0 : 1; } -EOF -cd "$app" -dep_out=$($MCPP run 2>&1) \ - || { echo "$dep_out"; fail "dependency SubOS leaked into consumer"; } - -echo "PASS: SubOS selection is root-local, workspace-owned and non-transitive" diff --git a/tests/e2e/206_runtime_binding_physics.sh b/tests/e2e/206_runtime_binding_physics.sh deleted file mode 100755 index 1e5232e74..000000000 --- a/tests/e2e/206_runtime_binding_physics.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# 206_runtime_binding_physics.sh — a selected RuntimeBinding must become the -# physical PT_INTERP/libc pair, and a host DSO linked under `allow_host_libs` -# must still BUILD (the declaration is honoured) while being reported honestly -# (the private loader does not consult /usr/lib). Proven mismatches fail at link -# completion; hot no-ops neither relink nor rewrite/re-probe the stored verdict. -set -euo pipefail - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -fail() { echo "FAIL: $*" >&2; exit 1; } - -command -v readelf >/dev/null || fail "readelf is required" -command -v jq >/dev/null || fail "jq is required" -tinfo_link=$(find /lib /usr/lib -name 'libtinfo.so' -print -quit 2>/dev/null) -[[ -n "$tinfo_link" ]] || fail "host libtinfo development symlink is required" -tinfo_dir=$(dirname "$tinfo_link") - -mkdir -p "$TMP/safe/src" -cat >"$TMP/safe/mcpp.toml" <"$TMP/safe/src/main.cpp" <<'EOF' -extern "C" int setupterm(const char*, int, int*); -int main() { - int error = 0; - // Keep libtinfo in DT_NEEDED; execution is not part of this link-physics - // control because TERM/tty availability is unrelated to the closure. - return setupterm(nullptr, 1, &error) == -99; -} -EOF - -cd "$TMP/safe" -safe_out=$($MCPP build 2>&1) || { echo "$safe_out"; fail "safe host-DSO build"; } -output_dir=$(sed -n '2p' target/.build_cache) -[[ -d "$output_dir" ]] || fail "build cache did not name output directory" -artifact="$output_dir/bin/runtime-physics-safe" -verdict="$output_dir/.mcpp-runtime-verdicts.json" -[[ -x "$artifact" && -f "$verdict" ]] || fail "artifact/verdict missing" -readelf -d "$artifact" | grep -q 'libtinfo\.so' \ - || fail "safe control did not retain host libtinfo in DT_NEEDED" - -# INCONCLUSIVE, not pass — and the change from `pass` is a correction, not a -# relaxation. -# -# This artifact links a HOST libtinfo while carrying a PRIVATE PT_INTERP, and -# measured with LD_DEBUG=libs the private loader's built-in default path is the -# glibc payload's own build-time prefix (`…/fromsource-x-glibc/2.39/lib`) — a -# directory that does not exist on this machine. /usr/lib is never consulted, -# and the binary exits 127 with "libtinfo.so.6: cannot open shared object file". -# -# It used to report `pass` because the closure model fell back to the HOST's -# default directories, where libtinfo of course is. The model was describing a -# loader the artifact does not use — the same defect that shipped a GL program -# as `validation: pass` while it exited 127. This file's own header note -# ("execution is not part of this link-physics control") is what let the false -# green stand: nothing ever ran it. -# -# `allow_host_libs` is still honoured, and that is the point of the state: the -# user declared they are reaching outside the sandbox, so mcpp REPORTS rather -# than blocks — resolution at run time (LD_LIBRARY_PATH, or installing where the -# private loader looks) is theirs to arrange. -# WHICH OF THE TWO VERDICTS IS CORRECT IS DECIDED BY THE MACHINE, SO THE TEST -# MEASURES IT RATHER THAN ASSUMING IT. -# -# The paragraph above says `inconclusive` because the private loader cannot -# reach a host `libtinfo`. That is true on a machine whose runtime search path -# does not contain one -- and false on a machine where it does. `xim:ncurses` -# is an ordinary ecosystem package, and a sub-OS that has it links -# `libtinfo.so.6` into the library view that IS on the artifact's RPATH. There -# the closure genuinely closes and `pass` is the right answer; asserting -# `inconclusive` would report a defect that is not there. (Measured -# 2026-09-06: green on every CI runner, red on a developer machine with -# ncurses provisioned. An earlier note in this file blamed a stale glibc -# payload, which was the wrong object.) -# -# So: read the artifact's own runtime search path, decide from it which verdict -# the model owes, and assert THAT. The test is then total -- it fails if the -# model says `pass` where nothing can be found, and equally if it says -# `inconclusive` where the library is sitting on the path. -soname=libtinfo.so.6 -search=$(readelf -d "$artifact" | sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p' | tr ':' '\n') -reachable=no -while read -r dir; do - [[ -n "$dir" ]] || continue - [[ -e "$dir/$soname" ]] && { reachable=yes; found="$dir/$soname"; break; } -done <<<"$search" -echo "runtime search path of the artifact:"; sed 's/^/ /' <<<"$search" -echo "$soname reachable from it: $reachable${found:+ ($found)}" - -status=$(jq -r '.artifacts["bin/runtime-physics-safe"].status' "$verdict") -if [[ "$reachable" == yes ]]; then - # The declared host library is on the private loader's path after all. - [[ "$status" == pass ]] \ - || { cat "$verdict"; fail "$soname is at $found, so the closure closes; the model says '$status'"; } -else - # `allow_host_libs` is still honoured, and that is the point of the state: - # the user declared they are reaching outside the sandbox, so mcpp REPORTS - # rather than blocks -- resolution at run time (LD_LIBRARY_PATH, or - # installing where the private loader looks) is theirs to arrange. - [[ "$status" == inconclusive ]] \ - || { cat "$verdict"; fail "nothing on the search path provides $soname; the model says '$status'"; } - jq -r '.artifacts["bin/runtime-physics-safe"].diagnostics[]' "$verdict" \ - | grep -q 'libtinfo\.so\.6' \ - || { cat "$verdict"; fail "the verdict does not name the library it could not resolve"; } - jq -r '.artifacts["bin/runtime-physics-safe"].diagnostics[]' "$verdict" \ - | grep -q 'allow_host_libs' \ - || { cat "$verdict"; fail "the verdict does not say why it stopped short of a proof"; } -fi - -artifact_before=$(stat -c '%y:%s' "$artifact") -verdict_before=$(stat -c '%y:%s' "$verdict") -noop_out=$($MCPP build 2>&1) || { echo "$noop_out"; fail "hot no-op build"; } -[[ $(stat -c '%y:%s' "$artifact") == "$artifact_before" ]] \ - || fail "hot no-op relinked artifact" -[[ $(stat -c '%y:%s' "$verdict") == "$verdict_before" ]] \ - || fail "hot no-op rewrote/re-probed verdict" - -doctor_out=$($MCPP self doctor 2>&1 || true) -grep -q 'last runtime closure verdict' <<<"$doctor_out" \ - || { echo "$doctor_out"; fail "doctor omitted stored runtime verdict"; } -grep -q "runtime-physics-safe.*$status" <<<"$doctor_out" \ - || { echo "$doctor_out"; fail "doctor did not reuse the stored verdict ('$status')"; } - -# Force form-X into a proven Rule-B mismatch: mcpp still supplies the selected -# private libc RUNPATH, while the final user flag replaces PT_INTERP with the -# host loader. The linker accepts this shape; mcpp must reject it before the -# user reaches a pre-main GLIBC_PRIVATE/version crash. -mkdir -p "$TMP/mismatch/src" -cat >"$TMP/mismatch/mcpp.toml" <<'EOF' -[package] -name = "runtime-physics-mismatch" -version = "0.1.0" - -[build] -allow_host_libs = true -ldflags = ["-Wl,--dynamic-linker=/lib64/ld-linux-x86-64.so.2"] -EOF -echo 'int main() { return 0; }' >"$TMP/mismatch/src/main.cpp" -cd "$TMP/mismatch" -set +e -mismatch_out=$($MCPP build 2>&1) -mismatch_rc=$? -set -e -[[ $mismatch_rc -ne 0 ]] || { echo "$mismatch_out"; fail "Rule-B mismatch passed"; } -grep -q 'runtime closure validation failed' <<<"$mismatch_out" \ - || { echo "$mismatch_out"; fail "missing runtime validation headline"; } -grep -q 'rule B' <<<"$mismatch_out" \ - || { echo "$mismatch_out"; fail "missing Rule-B explanation"; } -grep -q 'ld-linux-x86-64.so.2' <<<"$mismatch_out" \ - || { echo "$mismatch_out"; fail "diagnostic omitted actual interpreter"; } -grep -q 'mcpp.toml' <<<"$mismatch_out" \ - || { echo "$mismatch_out"; fail "diagnostic omitted copyable remediation"; } - -# A mismatch is not forgiven just because the second ninja drive is a no-op. -# Reuse the cached verdict without reparsing, but preserve the hard failure. -mismatch_verdict=$(find target -name '.mcpp-runtime-verdicts.json' -print -quit) -[[ -f "$mismatch_verdict" ]] || fail "mismatch verdict was not persisted" -mismatch_before=$(stat -c '%y:%s' "$mismatch_verdict") -set +e -mismatch_again=$($MCPP build 2>&1) -mismatch_again_rc=$? -set -e -[[ $mismatch_again_rc -ne 0 ]] \ - || { echo "$mismatch_again"; fail "cached Rule-B mismatch became green"; } -grep -q 'rule B' <<<"$mismatch_again" \ - || { echo "$mismatch_again"; fail "cached mismatch lost its explanation"; } -[[ $(stat -c '%y:%s' "$mismatch_verdict") == "$mismatch_before" ]] \ - || fail "cached mismatch was reparsed/rewritten" - -echo "PASS: RuntimeBinding payload physics, host-DSO control and hot verdict cache" diff --git a/tests/e2e/207_runtime_contract_provenance.sh b/tests/e2e/207_runtime_contract_provenance.sh deleted file mode 100755 index 77f8ca3c5..000000000 --- a/tests/e2e/207_runtime_contract_provenance.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bash -# requires: elf python3 unix-shell -# Provider-neutral runtime provenance: same-short-name providers remain exact, -# requirements cannot self-provide, and `mcpp why runtime` only interprets the -# stored resolution (it succeeds even when the manifest can no longer parse). -set -e - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -mk_provider() { # dir namespace value - dir=$1 - ns=$2 - value=$3 - mkdir -p "$dir/src" "$dir/runtime" - printf 'artifact-%s\n' "$ns" > "$dir/runtime/libbackend.fact" - cat > "$dir/mcpp.toml" < "$dir/src/backend.cppm" < "$TMP/app/mcpp.toml" < "$TMP/app/src/main.cpp" <<'EOF' -import alpha.backend; -import beta.backend; -int main() { return alpha_backend_value() + beta_backend_value() == 5 ? 0 : 1; } -EOF - -cd "$TMP/app" -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "runtime contract fixture build failed" - exit 1 -} - -RES=$(find target -name resolution.json -not -path '*/.build-mcpp/*' | head -1) -test -n "$RES" || { echo "resolution.json missing"; exit 1; } -python3 - "$RES" <<'PY' -import json, sys -doc = json.load(open(sys.argv[1])) -runtime = doc["runtime"] -assert doc["schema_version"] == 2, doc -providers = [p for p in runtime["providers"] if p["capability"] == "render.demo"] -ids = [p["provider"]["canonical"] for p in providers] -assert ids == ["alpha.backend@2.0.0", "beta.backend@3.0.0"], ids -assert all(p["provider"]["source"].startswith("path+") for p in providers), providers -requirements = [r for r in runtime["requirements"] if r["value"] == "render.demo"] -assert len(requirements) == 1, requirements -assert requirements[0]["requester"]["canonical"] == "mcpplibs.app@1.0.0", requirements -assert requirements[0]["requester"]["canonical"] not in ids, (requirements, ids) -optional = [r for r in runtime["requirements"] if r["value"] == "abi:musl"] -assert len(optional) == 1 and optional[0]["required"] is False, optional -artifacts = runtime["artifacts"] -assert [a["provider"]["canonical"] for a in artifacts] == ids, artifacts -assert all(a["provenance"] == "payload" for a in artifacts), artifacts -assert runtime["search"]["format"] == "elf", runtime["search"] -assert runtime["validation"]["status"] == "pass", runtime["validation"] -assert runtime["binding"]["contract_hash"], runtime["binding"] -PY - -# The runtime explanation must not resolve the now-invalid manifest and must -# not launch familiar graphics diagnostic programs placed at the front of PATH. -cp mcpp.toml mcpp.toml.good -printf '[package\n' > mcpp.toml -mkdir -p fake-bin -for name in vulkaninfo glxinfo nvidia-smi; do - cat > "fake-bin/$name" <> '$TMP/probes.log' -exit 99 -EOF - chmod +x "fake-bin/$name" -done -PATH="$PWD/fake-bin:$PATH" "$MCPP" why runtime > why.log 2>&1 || { - cat why.log - echo "why runtime re-resolved instead of reading stored facts" - exit 1 -} -mv mcpp.toml.good mcpp.toml -test ! -e "$TMP/probes.log" || { - cat "$TMP/probes.log" - echo "mcpp launched a provider-specific probe" - exit 1 -} -grep -q 'alpha.backend@2.0.0' why.log || { cat why.log; exit 1; } -grep -q 'beta.backend@3.0.0' why.log || { cat why.log; exit 1; } -grep -q 'validation: pass' why.log || { cat why.log; exit 1; } -grep -q 'owned by xlings' why.log || { cat why.log; exit 1; } - -echo "PASS 207_runtime_contract_provenance" diff --git a/tests/e2e/208_private_libc_stays_in_the_binary.sh b/tests/e2e/208_private_libc_stays_in_the_binary.sh deleted file mode 100755 index 8300a308c..000000000 --- a/tests/e2e/208_private_libc_stays_in_the_binary.sh +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf unix-shell -# 208_private_libc_stays_in_the_binary.sh — mcpp#401. -# -# mcpp ships a private glibc. Its libc.so.6 and its ld.so are version-locked to -# each other through GLIBC_PRIVATE symbols — glibc 2.44's libc.so.6 carries an -# undefined reference to `__pointer_chk_guard`, exported only by 2.44's own -# loader. An mcpp-built program is unaffected: PT_INTERP names the private -# loader. `/bin/sh` is not: its PT_INTERP names the HOST loader, and no -# environment variable can override it. -# -# So the moment the private libc directory reaches LD_LIBRARY_PATH — which is -# inherited by every process the program ever spawns — any popen()/system() in -# the program dies during relocation, before main: -# -# sh: symbol lookup error: …/xim-x-glibc/2.44/lib64/libc.so.6: -# undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE -# -# The reporter hit it through an application probing the desktop theme with -# `gsettings`: the child never ran, popen() returned nothing, and the app -# silently used the wrong theme. Nothing in the mcpp output said anything. -# -# The directory is still needed — a dlopen()'d library's own DT_NEEDED closure -# does not consult the executable's RUNPATH — so it is published through the -# ARTIFACT (DT_RUNPATH, which reaches exactly the object carrying it) instead -# of the ENVIRONMENT. This test pins both halves: the child survives, and the -# library still loads. -set -e - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -cd "$TMP" -mkdir -p app/src app/runtime - -# A dlopen-only plugin. Its presence is what makes mcpp publish the private -# libc directory at all, so the test would not exercise the bug without it. -cat > app/runtime/plugin.c <<'EOF' -int runtime_plugin_answer(void) { return 42; } -EOF -gcc -shared -fPIC app/runtime/plugin.c -o app/runtime/libruntime_plugin.so - -cat > app/src/main.cpp <<'EOF' -#include -#include -#include -#include - -using answer_fn = int (*)(); - -int main() { - // 1. What the program was handed. Printed so the test can assert on the - // real environment rather than on how it was constructed. - const char* ldlp = std::getenv("LD_LIBRARY_PATH"); - std::printf("LDLP=[%s]\n", ldlp ? ldlp : ""); - - // 2. A child that the HOST loader loads. This is the #401 failure. - std::FILE* pipe = ::popen("/bin/sh -c 'echo mcpp-child-ok'", "r"); - if (!pipe) { std::puts("CHILD=popen-failed"); return 10; } - char buf[64] = {0}; - const bool got = std::fgets(buf, sizeof buf, pipe) != nullptr; - const int status = ::pclose(pipe); - buf[strcspn(buf, "\n")] = '\0'; - std::printf("CHILD=[%s] status=%d\n", got ? buf : "", status); - if (!got || std::strcmp(buf, "mcpp-child-ok") != 0) return 11; - - // 3. …and the dlopen the private libc directory exists to serve must still - // work, or the fix traded one breakage for another. - void* handle = ::dlopen("libruntime_plugin.so", RTLD_NOW); - if (!handle) { std::printf("DLOPEN=[%s]\n", ::dlerror()); return 12; } - auto answer = reinterpret_cast( - ::dlsym(handle, "runtime_plugin_answer")); - const int value = answer ? answer() : -1; - ::dlclose(handle); - std::printf("DLOPEN=ok answer=%d\n", value); - return value == 42 ? 0 : 13; -} -EOF - -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[build] -sources = ["src/*.cpp"] -ldflags = ["-ldl"] - -[runtime] -library_dirs = ["runtime"] - -[targets.app] -kind = "bin" -main = "src/main.cpp" -EOF - -cd app -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } - -NINJA=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -GLIBC_LIB=$(grep -oE '/[^ ",]*/xim-x-glibc/[0-9.]+/lib(64)?' "$NINJA" | head -1 || true) -if [[ -z "$GLIBC_LIB" ]]; then - echo "SKIP: this toolchain has no private glibc payload" - exit 77 -fi -echo "private libc payload: $GLIBC_LIB" - -# ── 1. published through the artifact ─────────────────────────────── -# The link model emits this next to --dynamic-linker. mcpp does not add a -# second copy — the link line has a hard 128KiB ceiling — so this asserts the -# coverage it relies on instead of duplicating it. If a toolchain ever stops -# providing it, dlopen() resolution would silently lose the payload libc, and -# this is the test that says so. -grep -F -- "-Wl,-rpath,$GLIBC_LIB" "$NINJA" >/dev/null || { - echo "FAIL: private libc directory is not in the artifact RUNPATH" - grep -nF -- "$GLIBC_LIB" "$NINJA" || true - exit 1 -} - -run_out=$("$MCPP" run 2>&1) || { echo "$run_out"; echo "run failed"; exit 1; } - -# ── 2. the program's own environment must not carry it ────────────── -ldlp_line=$(printf '%s\n' "$run_out" | grep '^LDLP=' || true) -[[ -n "$ldlp_line" ]] || { echo "$run_out"; echo "FAIL: program printed no LD_LIBRARY_PATH"; exit 1; } -case "$ldlp_line" in - *"$GLIBC_LIB"*) - echo "$ldlp_line" - echo "FAIL: private libc directory reached LD_LIBRARY_PATH — every child" - echo " process the program spawns now loads it under the host loader" - exit 1 - ;; -esac - -# ── 3. the child the host loader loads must survive ───────────────── -printf '%s\n' "$run_out" | grep -q 'CHILD=\[mcpp-child-ok\] status=0' || { - echo "$run_out" - echo "FAIL: a /bin/sh child did not run cleanly under mcpp run (mcpp#401)" - exit 1 -} - -# ── 4. …and the dlopen it exists for still resolves ───────────────── -printf '%s\n' "$run_out" | grep -q 'DLOPEN=ok answer=42' || { - echo "$run_out" - echo "FAIL: dlopen through the runtime library dir regressed" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/209_runtime_verdict_never_reruns_green.sh b/tests/e2e/209_runtime_verdict_never_reruns_green.sh deleted file mode 100755 index 1aa2fe1bf..000000000 --- a/tests/e2e/209_runtime_verdict_never_reruns_green.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc python3 -# 209_runtime_verdict_never_reruns_green.sh — a proven runtime mismatch must -# not disappear when you simply run the build again. -# -# The Linux runtime closure check (Rule A/B) runs after the link, and its -# verdict is cached against the artifact's stat plus the RuntimeBinding -# contract hash. Caching a FAILURE is the interesting half: an unchanged -# artifact that was proven wrong is still wrong, and a build system that -# reports success on the second invocation has told the user the problem went -# away when nothing about the program changed. -# -# It would be easy to get wrong by accident, because the project-level fast -# path exists precisely to skip work when nothing changed — including, if -# nobody stopped it, the check that would have failed. So the fast path is only -# allowed to engage when every stored verdict is a PASS, and this test pins -# that: poison one stored verdict and the very next `mcpp build` must fail -# without touching a single source file. -# -# The other half matters too: the verdict is cached, not permanent. Change the -# program and it is re-derived from the new artifact. -set -euo pipefail - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -cd "$TMP" -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF -cat > app/src/main.cpp <<'EOF' -int main() { return 0; } -EOF - -cd app -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "initial build failed"; exit 1; } - -CACHE=$(find target -name '.mcpp-runtime-verdicts.json' | head -1) -if [[ -z "$CACHE" ]]; then - echo "SKIP: no glibc runtime binding on this host (no stored verdicts)" - exit 77 -fi - -# Every artifact must have been validated and recorded as passing, or the rest -# of this test is asserting against the wrong starting state. -python3 - "$CACHE" <<'PY' -import json, sys -doc = json.load(open(sys.argv[1])) -artifacts = doc.get("artifacts") or {} -assert artifacts, "no artifacts recorded in the verdict cache" -bad = {k: v.get("status") for k, v in artifacts.items() if v.get("status") != "pass"} -assert not bad, f"expected every artifact to pass on a clean build, got {bad}" -print(f"stored verdicts: {len(artifacts)} pass") -PY - -# ── 1. a stored mismatch survives a no-op re-run ──────────────────── -# The fingerprint is left untouched: this is exactly the state a real Rule A/B -# failure leaves behind, and exactly the state the fast path would otherwise -# treat as "nothing to do". -python3 - "$CACHE" <<'PY' -import json, sys -path = sys.argv[1] -doc = json.load(open(path)) -for entry in doc["artifacts"].values(): - entry["status"] = "proven_mismatch" - entry["diagnostics"] = ["synthetic mismatch recorded by 209"] -json.dump(doc, open(path, "w"), indent=2) -PY - -if "$MCPP" build > rerun.log 2>&1; then - cat rerun.log - echo "FAIL: a stored proven mismatch went green on a plain re-run" - exit 1 -fi -grep -q "runtime closure validation failed" rerun.log || { - cat rerun.log - echo "FAIL: the re-run failed, but not for the recorded runtime mismatch" - exit 1 -} -grep -q "synthetic mismatch recorded by 209" rerun.log || { - cat rerun.log - echo "FAIL: the stored diagnostics were not reported to the user" - exit 1 -} - -# ── 2. `mcpp self doctor` explains the stored verdict without re-probing ── -doctor_out=$("$MCPP" self doctor 2>&1 || true) -printf '%s\n' "$doctor_out" | grep -qi "mismatch" || { - printf '%s\n' "$doctor_out" - echo "FAIL: doctor did not surface the stored runtime mismatch" - exit 1 -} - -# ── 3. cached, not permanent ──────────────────────────────────────── -# A changed artifact gets a fresh verdict. Otherwise the first real mismatch -# would wedge the project forever and the only cure would be deleting target/. -cat > src/main.cpp <<'EOF' -int main() { return 0; } -// touched so the artifact is relinked and the verdict is re-derived -EOF -"$MCPP" build > recovered.log 2>&1 || { - cat recovered.log - echo "FAIL: a rebuilt artifact must be re-validated, not stay condemned" - exit 1 -} - -python3 - "$CACHE" <<'PY' -import json, sys -doc = json.load(open(sys.argv[1])) -statuses = {k: v.get("status") for k, v in (doc.get("artifacts") or {}).items()} -assert statuses, "verdict cache lost its artifacts after the rebuild" -assert all(s == "pass" for s in statuses.values()), statuses -print("re-derived verdicts:", statuses) -PY - -echo "OK" diff --git a/tests/e2e/20_p1689_scanner.sh b/tests/e2e/20_p1689_scanner.sh deleted file mode 100755 index 47d79a18a..000000000 --- a/tests/e2e/20_p1689_scanner.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# 20_p1689_scanner.sh — verify M3.3.a opt-in P1689 scanner end-to-end. -# -# Builds the same multi-module project twice — once under the default -# regex scanner, once under MCPP_SCANNER=p1689 — and verifies: -# 1. Both invocations succeed. -# 2. The runtime output of the produced binary is identical. -# 3. The compile graph (set of compiled .gcm files) matches. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new myapp > /dev/null -cd myapp - -# A small project with a partition + a consumer to exercise scanner edges. -cat > src/lib.cppm <<'EOF' -export module myapp.lib; -import std; -export import :greet; -export auto banner() -> void { std::println("== myapp =="); } -EOF - -cat > src/lib-greet.cppm <<'EOF' -export module myapp.lib:greet; -import std; -export auto greet(std::string_view who) -> void { - std::println("hello, {}", who); -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -import myapp.lib; -int main() { - banner(); - greet("p1689"); - return 0; -} -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "myapp" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp"] -[targets.myapp] -kind = "bin" -main = "src/main.cpp" -EOF - -# --- Path A: regex scanner (default) --- -"$MCPP" build > build.regex.log 2>&1 -triple=$(ls -d target/x86_64-linux-*/ | head -1) -fp_dir=$(ls "$triple") -out_regex=$(${triple}${fp_dir}/bin/myapp) -gcm_regex=$(find target -name '*.gcm' | sort | xargs -n1 basename | sort -u) -rm -rf target - -# --- Path B: P1689 scanner --- -MCPP_SCANNER=p1689 "$MCPP" build > build.p1689.log 2>&1 -triple=$(ls -d target/x86_64-linux-*/ | head -1) -fp_dir=$(ls "$triple") -out_p1689=$(${triple}${fp_dir}/bin/myapp) -gcm_p1689=$(find target -name '*.gcm' | sort | xargs -n1 basename | sort -u) - -# --- Compare --- -if [[ "$out_regex" != "$out_p1689" ]]; then - echo "FAIL: runtime output differs" - echo " regex: $out_regex" - echo " p1689: $out_p1689" - exit 1 -fi - -if [[ "$gcm_regex" != "$gcm_p1689" ]]; then - echo "FAIL: compiled BMI set differs" - echo "--- regex ---"; echo "$gcm_regex" - echo "--- p1689 ---"; echo "$gcm_p1689" - exit 1 -fi - -# Sanity: the expected modules really were compiled. -echo "$gcm_p1689" | grep -q 'myapp.lib.gcm' || { echo "missing myapp.lib.gcm"; exit 1; } -echo "$gcm_p1689" | grep -q 'myapp.lib-greet.gcm' || { echo "missing partition gcm"; exit 1; } - -echo "OK" diff --git a/tests/e2e/210_local_index_addressing_on_every_host.sh b/tests/e2e/210_local_index_addressing_on_every_host.sh deleted file mode 100755 index 0aea5085d..000000000 --- a/tests/e2e/210_local_index_addressing_on_every_host.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 210_local_index_addressing_on_every_host.sh — a `[indices]` path must address -# the same directory on every host, whether it is written absolute or inherited -# from a workspace root. -# -# This exists because the tests that covered local-index addressing all needed -# a compiler or a fresh sandbox, and Windows has neither capability — so the -# platform where path SEMANTICS actually differ was the one platform never -# asserting them. A fixture wrote an MSYS path into mcpp.toml, a native -# mcpp.exe read the leading `/` as "root of the current drive", and the -# resulting "package not found in any configured index" was chased for a day -# through the workspace inheritance code, which was not involved at all. -# -# So: no compiler, no sandbox bootstrap, no network. Everything here is -# assertable at the resolution layer, which is exactly the layer that was -# broken. `mcpp add` is the probe because its existence gate reads through the -# same routing `mcpp build` resolves dependencies with. -set -euo pipefail -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -make_index() { # make_index - mkdir -p "$1/pkgs/a" - cat > "$1/pkgs/a/acme.util.lua" <<'EOF' -package = { - spec = "1", - namespace = "acme", - name = "util", - description = "local index fixture", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { ["2.0.0"] = { url = "https://example.invalid/u.tar.gz" } }, - macosx = { ["2.0.0"] = { url = "https://example.invalid/u.tar.gz" } }, - windows = { ["2.0.0"] = { url = "https://example.invalid/u.zip" } }, - }, -} -EOF -} - -# ── 1. an ABSOLUTE [indices] path ─────────────────────────────────── -make_index "$TMP/abs-index" -INDEX_HOST="$(host_path "$TMP/abs-index")" -mkdir -p "$TMP/abs-app" -cat > "$TMP/abs-app/mcpp.toml" < /dev/null || { - echo "FAIL: an absolute local index path did not resolve on this host" - "$MCPP" add acme.util@2.0.0 2>&1 | sed 's/^/ /' - exit 1 -} -grep -qE '^util = "2\.0\.0"$' mcpp.toml || { - cat mcpp.toml; echo "FAIL: dependency was not written"; exit 1; } - -# A miss must report the index as READABLE. "root absent" here would mean the -# path was addressed but does not exist — the exact fingerprint of a path -# written in the wrong spelling. -err=$("$MCPP" add acme.nope@1.0.0 2>&1) && { - echo "FAIL: expected a miss for acme.nope"; exit 1; } -[[ "$err" == *"route: local index 'acme': root present, pkgs present"* ]] || { - echo "$err" - echo "FAIL: the absolute index is not being addressed as a readable root" - exit 1 -} - -# ── 2. a workspace member inheriting a ROOT-RELATIVE index ────────── -# The member sees no [indices] of its own; the root's relative `path = "index"` -# is anchored at the ROOT, not at the member's directory. -mkdir -p "$TMP/ws" -make_index "$TMP/ws/index" -cat > "$TMP/ws/mcpp.toml" <<'EOF' -[workspace] -members = ["m1"] - -[workspace.indices] -acme = { path = "index" } -EOF -mkdir -p "$TMP/ws/m1" -cat > "$TMP/ws/m1/mcpp.toml" <<'EOF' -[package] -name = "m1" -version = "0.1.0" -EOF -cd "$TMP/ws/m1" - -"$MCPP" add acme.util@2.0.0 > /dev/null || { - echo "FAIL: a workspace member could not read its root-owned local index" - "$MCPP" add acme.util@2.0.0 2>&1 | sed 's/^/ /' - exit 1 -} -grep -qE '^util = "2\.0\.0"$' mcpp.toml || { - cat mcpp.toml; echo "FAIL: member did not inherit the workspace [indices]"; exit 1; } - -err=$("$MCPP" add acme.nope@1.0.0 2>&1) && { - echo "FAIL: expected a miss inside the workspace member"; exit 1; } -[[ "$err" == *"route: local index 'acme': root present, pkgs present"* ]] || { - echo "$err" - echo "FAIL: the inherited index was anchored somewhere unreadable" - exit 1 -} - -# ── 3. the route diagnostic stays privacy-safe ────────────────────── -# It is what makes a wrong path diagnosable at all, so it must be printed — and -# it must not print the path, which is why it says present/absent instead. -case "$err" in - *"$TMP"* | *"$(host_path "$TMP")"*) - echo "FAIL: the route diagnostic leaked a filesystem path" - exit 1 - ;; -esac - -echo "OK" diff --git a/tests/e2e/211_configure_only_cdb.sh b/tests/e2e/211_configure_only_cdb.sh deleted file mode 100755 index 6ba36cade..000000000 --- a/tests/e2e/211_configure_only_cdb.sh +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 211_configure_only_cdb.sh — `mcpp build --configure-only` must publish the -# compile database before compiling source files. The generated database is -# intended for clangd, so a broken source is deliberately part of the test. -set -euo pipefail -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -mkdir -p "$TMP/devkit/include" "$TMP/devkit/src" -cat > "$TMP/devkit/mcpp.toml" <<'EOF' -[package] -name = "devkit" -version = "0.1.0" - -[build] -include_dirs = ["include"] -EOF -echo '#define DEVKIT_MARKER 1' > "$TMP/devkit/include/devkit.hpp" - -mkdir -p "$TMP/app/src" "$TMP/app/tests" -cd "$TMP/app" - -cat > mcpp.toml <<'EOF' -[package] -name = "configure-only" -version = "0.1.0" -standard = "c++23" - -[build] -flags = [{ glob = "tests/**/*.cpp", cxxflags = ["-DMCPP_CONFIGURE_ONLY_TEST_FLAG=1"] }] - -[dev-dependencies] -devkit = { path = "../devkit" } -EOF - -# This must never reach a compiler in configure-only mode. It still needs a -# CDB entry so clangd can report the actual syntax error interactively. -printf 'int main( { return 0; }\n' > src/main.cpp -cat > tests/test_smoke.cpp <<'EOF' -#include -int test_entry() { return 0; } -EOF - -out=$("$MCPP" build --configure-only 2>&1) || { - echo "configure-only failed unexpectedly:" - echo "$out" - exit 1 -} - -[[ -f compile_commands.json ]] || { - echo "configure-only did not publish compile_commands.json" - echo "$out" - exit 1 -} - -grep -q 'src[\/][\/]*main.cpp' compile_commands.json || { - echo "CDB is missing the ordinary source TU" - cat compile_commands.json - exit 1 -} -grep -q 'tests[\/][\/]*test_smoke.cpp' compile_commands.json || { - echo "CDB is missing the test TU" - cat compile_commands.json - exit 1 -} -if command -v python3 >/dev/null 2>&1; then - # The devkit path is derived from the entry's own `file`, in the spelling - # mcpp wrote: the shell's `$TMP` is not it on Windows (an MSYS root) or on - # macOS (`/var` against the resolved `/private/var`). `directory` no longer - # serves, because since C3 (design 2026-09-26 §3.3) it is the output - # directory the compiler runs in, not the project root. - python3 - compile_commands.json <<'PY' -import json, sys -entries = json.load(open(sys.argv[1], encoding="utf-8")) -normal = lambda p: p.replace("\\", "/").rstrip("/") -test = next(e for e in entries if normal(e["file"]).endswith("/tests/test_smoke.cpp")) -main = next(e for e in entries if normal(e["file"]).endswith("/src/main.cpp")) -args = test["arguments"] -# Compared by the fixture directory's own name: Windows may spell the same -# temporary directory with its 8.3 short form in one field and its long form in -# another (`RUNNER~1` against `runneradmin`), and the name below it is the same -# in both. -project = normal(test["file"])[: -len("/tests/test_smoke.cpp")] -fixture_name = project.rsplit("/", 2)[-2] -expected_tail = f"/{fixture_name}/devkit/include".casefold() -include_args = [ - normal(a[2:]).casefold() - for a in args - if a[:2].casefold() == "-i" -] -assert any(i.endswith(expected_tail) for i in include_args), (expected_tail, args) -assert any("MCPP_CONFIGURE_ONLY_TEST_FLAG=1" in a for a in args), args -assert not any("MCPP_CONFIGURE_ONLY_TEST_FLAG=1" in a for a in main["arguments"]), main -PY -else - grep -q -- '-DMCPP_CONFIGURE_ONLY_TEST_FLAG=1' compile_commands.json || { - echo "CDB is missing [build].flags for the test TU" - cat compile_commands.json - exit 1 - } -fi - -if find target -type f \( -name '*.o' -o -name '*.obj' \) -print -quit 2>/dev/null | grep -q .; then - echo "configure-only produced an object file" - find target -type f \( -name '*.o' -o -name '*.obj' \) - exit 1 -fi -[[ ! -d target/bin ]] || { echo "configure-only produced target/bin"; exit 1; } -[[ ! -e target/.build_cache ]] || { echo "configure-only wrote target/.build_cache"; exit 1; } - -# Configuring an ALREADY-BUILT project must not poison the build fast path. -# The backend writes build.ninja before it honors dryRun, and a configure plan's -# graph is not a normal build's graph: it carries the test targets, so its -# `default` line names the TEST binaries and the package's own target is not in -# it at all. The fast path re-runs a cached build.ninja after comparing mtimes -# against the SOURCES — it never looks at the graph — so a surviving cache entry -# makes the next plain `mcpp build` link the tests, skip the target, and print -# `Finished`. Do NOT delete the built binary before the second build: a missing -# output makes ninja fail in a way the fast path reads as a stale graph and -# falls back to a full prepare, which hides exactly the defect under test. -mkdir -p "$TMP/fastpath/src" "$TMP/fastpath/tests" -cat > "$TMP/fastpath/mcpp.toml" <<'EOF' -[package] -name = "fastpath" -version = "0.1.0" -EOF -printf 'int main() { return 0; }\n' > "$TMP/fastpath/src/main.cpp" -printf 'int main() { return 0; }\n' > "$TMP/fastpath/tests/smoke.cpp" -cd "$TMP/fastpath" -"$MCPP" build > build-1.log 2>&1 || { cat build-1.log; exit 1; } -# Windows links `fastpath.exe`; the suffix is a host constant, so derive the -# whole path from what the build actually produced instead of assuming either. -target_bin=$(find target -type f \( -path '*/bin/fastpath' -o -path '*/bin/fastpath.exe' \) \ - -print -quit) -[[ -n "$target_bin" ]] || { - echo "baseline build produced no binary"; find target -type f -path '*/bin/*'; exit 1; } -bindir=$(dirname "$target_bin") -exe_suffix="" -[[ "$target_bin" == *.exe ]] && exe_suffix=".exe" -test_bin="$bindir/smoke$exe_suffix" - -"$MCPP" build --configure-only > configure-fastpath.log 2>&1 || { - cat configure-fastpath.log; exit 1; } -[[ ! -e "$test_bin" ]] || { echo "configure-only linked a test binary"; exit 1; } - -"$MCPP" build > build-2.log 2>&1 || { cat build-2.log; exit 1; } -[[ ! -e "$test_bin" ]] || { - echo "build after configure-only replayed the configure graph and linked the tests" - cat build-2.log - ls -la "$bindir" - exit 1 -} -[[ -f "$target_bin" ]] || { - echo "build after configure-only lost the package target" - cat build-2.log - exit 1 -} - -# A workspace has one compile database per configuration, at its root -# (workspace design 2026-09-29 §7.1): configuring every member fills it with -# every member's units, and `-p` configures that member's closure into the -# same database. -mkdir -p "$TMP/ws/a/src" "$TMP/ws/a/tests" "$TMP/ws/b/src" "$TMP/ws/b/tests" -cat > "$TMP/ws/mcpp.toml" <<'EOF' -[workspace] -members = ["a", "b"] -EOF -for member in a b; do - cat > "$TMP/ws/$member/mcpp.toml" < "$TMP/ws/$member/src/main.cpp" - printf 'int test_entry() { return 0; }\n' > "$TMP/ws/$member/tests/main.cpp" -done -cd "$TMP/ws" -"$MCPP" build --configure-only > configure-workspace.log 2>&1 || { - cat configure-workspace.log - exit 1 -} -cdb=compile_commands.json -[[ -s "$cdb" ]] || { echo "missing $cdb"; exit 1; } -for member in a b; do - [[ ! -e "$member/compile_commands.json" ]] || { - echo "$member/compile_commands.json written beside the workspace's database"; exit 1; } - grep -q "$member[\\/][\\/]*src[\\/][\\/]*main.cpp" "$cdb" || { cat "$cdb"; exit 1; } - grep -q "$member[\\/][\\/]*tests[\\/][\\/]*main.cpp" "$cdb" || { cat "$cdb"; exit 1; } -done -# From a clean workspace, `-p a` configures a's closure and nothing of b. -rm -rf target compile_commands.json -"$MCPP" build --configure-only -p a > configure-member.log 2>&1 || { - cat configure-member.log - exit 1 -} -grep -q 'a[\/][\/]*src[\/][\/]*main.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } -if grep -q 'b[\/][\/]*src[\/][\/]*main.cpp' compile_commands.json; then - echo "-p a configured member b" - cat compile_commands.json - exit 1 -fi - -# A failed replacement must be non-fatal for a normal build, but fatal for -# configure-only; the pre-existing destination must remain untouched. -mkdir -p "$TMP/publish-policy/src" "$TMP/publish-policy/compile_commands.json" -cat > "$TMP/publish-policy/mcpp.toml" <<'EOF' -[package] -name = "publish-policy" -version = "0.1.0" -standard = "c++20" -EOF -printf 'int main() { return 0; }\n' > "$TMP/publish-policy/src/main.cpp" -echo keep > "$TMP/publish-policy/compile_commands.json/last-known-good" -cd "$TMP/publish-policy" -normal_out=$("$MCPP" build 2>&1) || { - echo "normal build failed on optional CDB publication:" - echo "$normal_out" - exit 1 -} -[[ "$normal_out" == *"compile_commands.json was not updated"* ]] || { - echo "normal build did not warn about CDB publication: $normal_out" - exit 1 -} -[[ -f compile_commands.json/last-known-good ]] || { - echo "normal build removed the prior CDB destination" - exit 1 -} -configure_rc=0 -configure_out=$("$MCPP" build --configure-only 2>&1) || configure_rc=$? -[[ $configure_rc -ne 0 ]] || { - echo "configure-only accepted failed CDB publication: $configure_out" - exit 1 -} -[[ -f compile_commands.json/last-known-good ]] || { - echo "configure-only removed the prior CDB destination" - exit 1 -} - -echo "OK" diff --git a/tests/e2e/212_cached_dep_std_is_ordered.sh b/tests/e2e/212_cached_dep_std_is_ordered.sh deleted file mode 100755 index 5a8def622..000000000 --- a/tests/e2e/212_cached_dep_std_is_ordered.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc fresh-sandbox -# 212_cached_dep_std_is_ordered.sh — mcpp#405. -# -# A dependency whose modules `import std`, consumed by a project that does NOT, -# must build on a cache HIT as well as on a miss. -# -# On a miss the dependency is compiled locally and that puts `gcm.cache/std.gcm` -# in the graph as a real prerequisite of its compile edges. On a HIT those -# compile edges are replaced by stage edges, and the std stage edge — emitted -# earlier, outside the loop that fills `_mcpp_staged_cache` — is left with zero -# consumers. ninja never runs it, and the restored BMI reports -# -# std: failed to read compiled module: No such file or directory -# : failed to read compiled module: Bad import dependency -# -# which names neither the cache nor std's absence from the graph. -# -# WHY THE FIRST PROJECT ALWAYS WORKED: miss and hit are different code paths. -# "It used to work and now it doesn't" is what a per-package cache turning warm -# looks like from the outside; the version never mattered. -# -# TWO THINGS THIS TEST MUST NOT DO -# -# * It must NOT delete build outputs between builds. A missing artifact makes -# ninja fail in the shape of a stale graph, the fast path falls back to a -# full prepare, and the defect is covered — the unfixed binary goes green. -# * It must NOT assert only on the exit code. A machine that happens to have -# `gcm.cache/std.gcm` already staged passes for the wrong reason, so the -# graph itself is asserted too. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -INDEX_DIR="$TMP/local-index" -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/s" -cat > "$INDEX_DIR/pkgs/s/stdlib-dep.lua" <<'EOF' -package = { - spec = "1", - name = "stdlib-dep", - description = "A dependency whose module imports std", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["1.0.0"] = { - url = "https://example.invalid/stdlib-dep-1.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = true, - sources = { "src/**/*.cppm" }, - targets = { ["stdlib-dep"] = { kind = "lib" } }, - deps = {}, - }, -} -EOF - -# `make_project ` — the consumer deliberately does NOT `import std`. -# That one line is the whole reproduction: with it, the consumer's own compile -# edge names gcm.cache/std.gcm and the stage edge acquires a consumer. -make_project() { - local dir="$1" name="$2" - mkdir -p "$TMP/$dir/src" - mkdir -p "$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.stdlib-dep/1.0.0/src" - cat > "$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.stdlib-dep/1.0.0/src/lib.cppm" <<'EOF' -export module stdlib.dep; -import std; -export std::string dep_greeting() { return std::string("42"); } -EOF - cat > "$TMP/$dir/src/main.cpp" <<'EOF' -import stdlib.dep; -extern "C" int puts(const char*); -int main() { puts(dep_greeting().c_str()); return 0; } -EOF - cat > "$TMP/$dir/mcpp.toml" < build.log 2>&1 || { - echo "FAIL: cold build (cache miss) failed — fixture problem, not #405" - cat build.log - exit 1 -} - -# ── project two: cache HIT. This is the one that used to fail. ─────────────── -make_project projhit projhit -cd "$TMP/projhit" -if ! "$MCPP" build -v > build.log 2>&1; then - echo "FAIL: second project (cache hit) did not build" - if grep -q 'Bad import dependency\|std.gcm' build.log; then - echo " this is mcpp#405: the restored BMI's std edge is not in the graph" - fi - cat build.log - exit 1 -fi - -N="$(find_ninja "$TMP/projhit")" -[[ -n "$N" ]] || { echo "FAIL: projhit has no build.ninja"; exit 1; } - -# The hit actually happened — otherwise the assertion below proves nothing. -grep -qE 'Cached local-dev\.stdlib-dep v1\.0\.0 \((index [^,)]+, )?[0-9]+ units?\)' build.log || { - echo "FAIL: the second project did not hit the cache, so #405 was not exercised" - cat build.log - exit 1 -} - -# THE graph assertion. The std BMI stage edge must be reachable, and the -# aggregate every non-staged edge already depends on is where it becomes so. -phony="$(grep -E '^build _mcpp_staged_cache : phony' "$N" || true)" -[[ -n "$phony" ]] || { - echo "FAIL: no _mcpp_staged_cache aggregate, so the dependency was not staged" - grep -n 'stdlib-dep' "$N" | head - exit 1 -} -case "$phony" in - *std.gcm*) ;; - *) - echo "FAIL: the staged-cache aggregate does not include the std BMI" - echo " $phony" - echo " nothing else names gcm.cache/std.gcm, so ninja will never stage it" - exit 1 - ;; -esac - -# And it has to run. Nothing is deleted anywhere in this file. -./target/*/*/bin/projhit > run.log 2>&1 || { cat run.log; exit 1; } -grep -q '^42$' run.log || { - echo "FAIL: staged artifacts produced wrong output" - cat run.log - exit 1 -} - -echo "PASS: a cached dependency's transitive std BMI is staged for a consumer that does not import std" diff --git a/tests/e2e/213_build_after_test_is_not_the_test_graph.sh b/tests/e2e/213_build_after_test_is_not_the_test_graph.sh deleted file mode 100755 index 57cae78b1..000000000 --- a/tests/e2e/213_build_after_test_is_not_the_test_graph.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 213_build_after_test_is_not_the_test_graph.sh — mcpp#407. -# -# `mcpp build`, `mcpp test` and `mcpp build --configure-only` all wrote -# `target///build.ninja` (`mcpp test` writes `test.ninja` beside it -# since 2026.11.10.1), and they land in the same directory -# because the fingerprint covers neither dev-dependencies nor test targets. -# A test-mode plan's `default` line names the TEST binaries and does not -# contain the package's own target at all. -# -# The fast path used to check build.ninja's mtime against the SOURCES and -# nothing else, so: -# -# mcpp build → default bin/ -# mcpp test → default bin/ (same file, rewritten) -# mcpp build → Finished in 0.00s (asked ninja for bin/) -# -# reported success for a build that never linked the target. And because -# `tests/` is not in the source sweep, breaking a test file made a plain -# `mcpp build` FAIL with src/ untouched. -# -# DO NOT DELETE ARTIFACTS ANYWHERE IN THIS FILE. A missing output makes ninja -# fail in the shape of a stale graph, the fast path falls back to a full -# prepare, and an unfixed binary goes green. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -mkdir -p "$TMP/proj/src" "$TMP/proj/tests" -cd "$TMP/proj" - -cat > mcpp.toml <<'EOF' -[package] -name = "tst" -version = "0.1.0" -EOF -cat > src/main.cpp <<'EOF' -int main() { return 0; } -EOF -cat > tests/smoke.cpp <<'EOF' -int main() { return 0; } -EOF - -find_ninja() { find "$TMP/proj/target" -name build.ninja | head -1; } -default_line() { grep -E '^default ' "$1" | head -1; } - -# ── 1. plain build ────────────────────────────────────────────────────────── -"$MCPP" build > b1.log 2>&1 || { cat b1.log; exit 1; } -N="$(find_ninja)" -[[ -n "$N" ]] || { echo "FAIL: no build.ninja after the first build"; exit 1; } - -BIN="$(dirname "$(dirname "$N")")" # unused guard; the real path is globbed below -target_bin() { ls target/*/*/bin/tst 2>/dev/null | head -1; } - -[[ -n "$(target_bin)" ]] || { - echo "FAIL: plain build produced no bin/tst" - cat b1.log - exit 1 -} - -# The graph must say what it is. Without this line there is nothing for the -# fast path to check, and the rest of this test would pass for the wrong -# reason on a binary that simply always does a full prepare. -grep -q '^# mcpp:graph=normal' "$N" || { - echo "FAIL: a plain build's build.ninja does not declare graph=normal" - head -3 "$N" - exit 1 -} - -# ── 2. mcpp test writes its graph beside the build's ──────────────────────── -# Since 2026.11.10.1 the test graph is `test.ninja` in the same directory: the -# objects, `.ninja_log` and `.ninja_deps` are shared, and build.ninja keeps the -# build's graph. The read-side check below still has to hold for the graphs -# `mcpp build --configure-only` writes into build.ninja. -"$MCPP" test > t1.log 2>&1 || { cat t1.log; exit 1; } -T2="$(dirname "$N")/test.ninja" -[[ -f "$T2" ]] || { echo "FAIL: mcpp test wrote no test.ninja beside $N"; ls "$(dirname "$N")"; exit 1; } -grep -q '^# mcpp:graph=test' "$T2" || { - echo "FAIL: mcpp test's test.ninja does not declare graph=test" - head -3 "$T2" - exit 1 -} -grep -q '^# mcpp:graph=normal' "$N" || { - echo "FAIL: mcpp test rewrote build.ninja" - head -3 "$N" - exit 1 -} - -# ── 3. THE assertion: a plain build after a test must not replay that graph ─ -# -# NOTHING IS TOUCHED HERE. Not the sources, not the outputs. An earlier draft -# ran `touch src/main.cpp` to make the relink observable — which invalidates -# the fast path by mtime and makes the test pass on an unfixed binary. The -# defect only exists while the fast path is ELIGIBLE, so the test has to leave -# every input alone and read the graph instead. -"$MCPP" build > b2.log 2>&1 || { - echo "FAIL: plain build after mcpp test failed" - cat b2.log - exit 1 -} -N3="$(find_ninja)" -grep -q '^# mcpp:graph=normal' "$N3" || { - echo "FAIL: plain build replayed the test graph (build.ninja still says test)" - echo " this is mcpp#407: the fast path never checked what graph it had" - head -3 "$N3" - cat b2.log - exit 1 -} -case "$(default_line "$N3")" in - *bin/tst*) ;; - *) - echo "FAIL: default line still names the test graph: $(default_line "$N3")" - exit 1 - ;; -esac -[[ -n "$(target_bin)" ]] || { - echo "FAIL: bin/tst is gone after a plain build" - exit 1 -} - -# ── 4. a broken TEST file must not fail a plain build ─────────────────────── -# src/ is untouched here. Before the fix this failed with a compile error in -# tests/smoke.cpp, from `mcpp build`. -cat > tests/smoke.cpp <<'EOF' -this is not valid C++ at all -EOF -"$MCPP" build > b3.log 2>&1 || { - echo "FAIL: a broken tests/*.cpp failed a plain mcpp build" - cat b3.log - exit 1 -} - -echo "PASS: a plain build never replays the test graph, and tests/ cannot break it" diff --git a/tests/e2e/214_executable_carries_dt_rpath.sh b/tests/e2e/214_executable_carries_dt_rpath.sh deleted file mode 100755 index 1f403a661..000000000 --- a/tests/e2e/214_executable_carries_dt_rpath.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf python3 -# 214_executable_carries_dt_rpath.sh — the loader-tag contract. -# -# executable DT_RPATH -# shared library DT_RUNPATH -# -# and both halves are measured, not stylistic. -# -# DT_RUNPATH is consulted only for the object carrying it and for the dlopen() -# that object performs ITSELF. DT_RPATH is consulted for every dlopen anywhere -# in the process. A GL program reaches its driver through three to four -# dlopen() calls it does not make — libGLX.so.0 makes them — so an executable -# tagged DT_RUNPATH has the right path and cannot reach through it: same paths, -# tag flipped, egl/gles2/egl-surfaceless move from llvmpipe to the GPU. -# -# The other half runs the opposite way: forcing DT_RPATH onto a LIBRARY pushes -# its search path into every lookup below it and eglInitialize fails outright -# (openxlings/xlings#593). So this is a split, and both sides are asserted. -# -# WHY THIS TEST ASSERTS THE DEFAULT FIRST -# -# Every linker mcpp targets defaults to --enable-new-dtags today. If that ever -# changes, an assertion of "the executable has DT_RPATH" would keep passing -# while the flag that produces it had been deleted — a test that cannot fail is -# indistinguishable from one that is not running. So step 0 builds WITHOUT the -# contract and requires DT_RUNPATH: the test states its own premise. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# ── tag reader: no external tool ──────────────────────────────────────────── -# The binutils in a sandbox home is not reliably present (and on at least one -# real machine its shims pointed at a deleted directory), so the dynamic -# section is parsed directly. Reads the WHOLE section, not the first hit: with -# both tags present the loader ignores DT_RPATH, and DT_RPATH-first is the -# common layout, so a first-hit reader reports the opposite of the truth. -read_tag() { -python3 - "$1" <<'PY' -import struct, sys -d = open(sys.argv[1], 'rb').read() -if d[:4] != b'\x7fELF' or d[4] != 2: - print("NOT-ELF64"); raise SystemExit -phoff, = struct.unpack_from(' mcpp.toml <<'EOF' -[package] -name = "tags" -version = "0.1.0" - -[targets.taglib] -kind = "shared" - -[targets.tagbin] -kind = "bin" -main = "src/main.cpp" -EOF -cat > src/lib.cppm <<'EOF' -export module tags.lib; -export int tag_value() { return 7; } -EOF -cat > src/main.cpp <<'EOF' -import tags.lib; -int main() { return tag_value() == 7 ? 0 : 1; } -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } - -# ── step 0: the flag must be where the contract says, and only there ──────── -# -# THE SHARED LIBRARY IS THE CONTROL GROUP. It is linked by the same driver, -# with the same ldflags, and deliberately WITHOUT the contract flag — so its -# tag is this linker's default. That makes the premise self-checking without a -# probe binary: if the default ever becomes DT_RPATH, the library assertion -# below fails and says so, instead of the executable assertion passing for a -# reason that has nothing to do with mcpp. -# -# Reading it out of the graph as well as off the binary matters because they -# fail differently: a missing flag is a regression in mcpp, while a flag that -# is present but produced the wrong tag is a regression in the toolchain. -NINJA="$(ls target/*/*/build.ninja | head -1)" -[[ -n "$NINJA" ]] || { echo "FAIL: no build.ninja"; exit 1; } - -bin_edge="$(grep -A4 -E '^build bin/tagbin *: *cxx_link' "$NINJA" | grep unit_ldflags || true)" -lib_edge="$(grep -A4 -E '^build bin/libtaglib\.so.* : *cxx_shared' "$NINJA" | grep unit_ldflags || true)" -case "$bin_edge" in - *--disable-new-dtags*) ;; - *) - echo "FAIL: the executable's link edge carries no loader-tag flag" - echo " $bin_edge" - exit 1 - ;; -esac -case "$lib_edge" in - *--disable-new-dtags*) - echo "FAIL: the shared library's link edge carries the executable's flag" - echo " forcing DT_RPATH on a library breaks eglInitialize (xlings#593)" - echo " $lib_edge" - exit 1 - ;; -esac - -BIN="$(ls target/*/*/bin/tagbin 2>/dev/null | head -1)" -LIB="$(ls target/*/*/bin/libtaglib.so 2>/dev/null | head -1)" -[[ -n "$BIN" ]] || { echo "FAIL: no executable produced"; ls -R target | head -40; exit 1; } -[[ -n "$LIB" ]] || { echo "FAIL: no shared library produced"; ls -R target | head -40; exit 1; } - -bin_tag="$(read_tag "$BIN")" -lib_tag="$(read_tag "$LIB")" -echo "executable: $bin_tag" -echo "library: $lib_tag" - -case "$bin_tag" in - "executable RPATH") ;; - *) - echo "FAIL: executable must carry DT_RPATH, got: $bin_tag" - echo " a search path under DT_RUNPATH is unreachable from a dlopen()" - echo " performed by another object — which is every dlopen in the" - echo " graphics stack" - grep -n 'unit_ldflags' target/*/*/build.ninja | head - exit 1 - ;; -esac -case "$lib_tag" in - "shared_library RUNPATH"|"shared_library NONE") ;; - "shared_library RPATH"|"shared_library BOTH") - echo "FAIL: shared library came out as $lib_tag" - echo " Its link edge carries NO loader-tag flag (asserted above), so" - echo " this is the linker's DEFAULT — and if the default is now" - echo " DT_RPATH, the executable assertion below is passing for a" - echo " reason unrelated to mcpp. Re-derive the contract." - echo " Also: forcing DT_RPATH on a library pushes its search path" - echo " into every lookup below it (openxlings/xlings#593)." - exit 1 - ;; - *) - echo "FAIL: unexpected library tag: $lib_tag" - exit 1 - ;; -esac - -# ── the finding must be recorded, not just true ───────────────────────────── -# A check whose only output is silence is indistinguishable from a check that -# never ran. resolution.json is where the answer lives. -RES="$(ls target/*/*/resolution.json 2>/dev/null | head -1)" -[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } -grep -q '"loader_tags"' "$RES" || { - echo "FAIL: resolution.json records no loader_tags — rule E did not run" - exit 1 -} -if grep -q '"status": *"violation"' "$RES"; then - echo "FAIL: rule E reported a violation on mcpp's own output" - python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1]))['runtime']['loader_tags'],indent=1))" "$RES" - exit 1 -fi -# `ok` for both artifacts, not merely "no violation": absence of a violation is -# also what a check that silently did nothing produces. -python3 - "$RES" <<'PY2' || exit 1 -import json, sys -tags = json.load(open(sys.argv[1]))["runtime"]["loader_tags"] -by_form = {t["form"]: t for t in tags} -for form, want in (("executable", "DT_RPATH"), ("shared_library", "DT_RUNPATH")): - t = by_form.get(form) - if t is None: - print(f"FAIL: rule E recorded no {form}"); raise SystemExit(1) - if t["status"] != "ok" or t["required"] != want: - print(f"FAIL: {form}: {t}"); raise SystemExit(1) -print("rule E: executable=%s library=%s" % ( - by_form["executable"]["actual"], by_form["shared_library"]["actual"])) -PY2 - -# ── the record must SURVIVE a build that produced nothing ─────────────────── -# -# Rule E used to re-read every link artifact on every backend drive, whether or -# not anything had been relinked. `mcpp test` drives the backend once per test, -# so on the 83-test suite that was 1.87s x 85 = 158.7s of a 190s hot run — -# reading ELF files nothing had touched. -# -# The fix reads back the stored verdict for an artifact whose stat did not -# move. That has its own failure mode, and it is the one asserted here: the -# cheap version of "skip what did not change" also skips RECORDING it, so -# resolution.json shrinks to whatever was rebuilt — and a no-op build empties -# it. An empty record and a compliant build then look identical, which is the -# confusion this whole rule exists to prevent. -count_tags() { - python3 -c "import json,sys;print(len(json.load(open(sys.argv[1]))['runtime']['loader_tags']))" "$1" -} -before_count=$(count_tags "$RES") -[ "$before_count" -ge 2 ] || { echo "FAIL: rule E recorded only $before_count artifact(s)"; exit 1; } - -# (a) a build that relinks NOTHING. -"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; exit 1; } -noop_count=$(count_tags "$RES") -[ "$before_count" = "$noop_count" ] || { - echo "FAIL: a no-op build changed the loader-tag record: $before_count -> $noop_count" - cat "$RES"; exit 1 -} - -# (b) THE ONE THAT MATTERS: a build that relinks ONE of the two. `main.cpp` -# belongs to tagbin alone, so the executable is rewritten and the library is -# not. A rule that records only what it re-read drops the library here — and -# (a) alone cannot see that, because a no-op rewrites nothing at all and so -# leaves even a broken record looking intact. -sleep 1 -printf '\n// touch\n' >> src/main.cpp -"$MCPP" build > partial.log 2>&1 || { cat partial.log; exit 1; } -partial_count=$(count_tags "$RES") -[ "$before_count" = "$partial_count" ] || { - echo "FAIL: relinking one artifact shrank the loader-tag record: $before_count -> $partial_count" - cat "$RES"; exit 1 -} -after_count=$partial_count - -# and it has to run -"$MCPP" run tagbin > run.log 2>&1 || { cat run.log; exit 1; } - -echo "PASS: executables carry DT_RPATH, shared libraries keep DT_RUNPATH, and rule E recorded it ($after_count artifacts, stable across a no-op build)" diff --git a/tests/e2e/215_pack_has_no_build_machine_paths.sh b/tests/e2e/215_pack_has_no_build_machine_paths.sh deleted file mode 100755 index 1483fd2e2..000000000 --- a/tests/e2e/215_pack_has_no_build_machine_paths.sh +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env bash -# requires: pack patchelf elf python3 -# 215_pack_has_no_build_machine_paths.sh — a bundle must not depend on the -# machine that built it, and its executable must carry DT_RPATH. -# -# TWO DEFECTS, ONE FILE. -# -# 1. Only the main executable's search path was rewritten. Every bundled .so -# kept the RUNPATH it was LINKED with, and in this ecosystem that is a list -# of absolute paths into the build machine's xlings store: -# -# /xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 -# : /compat-x-glx-runtime/…/lib : $ORIGIN -# -# "Depends on the xlings ecosystem" would be a design choice; "depends on -# THIS machine's store" is a defect — and it is invisible, because the -# bundle runs perfectly where it was built. -# -# 2. `patchelf --set-rpath` writes DT_RUNPATH by default. For an executable -# that is the graphics defect one layer later: DT_RUNPATH is not consulted -# for a dlopen() performed by another object, and every dlopen in the GL -# stack is performed by another object. -# -# The assertion sweeps EVERY ELF in the bundle, not just the binary — the -# first defect lived precisely in the ones nobody looked at. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME=$HOME/.mcpp - -# The ELF reader lives in _elf_tag.sh — 264 asks the same question of a -# LIBRARY package, and two copies of this parser would be two definitions of -# what "carries a build-machine path" means. -source "$(dirname "$0")/_elf_tag.sh" - -# The bundle must actually CONTAIN a library, or this test sweeps one -# executable and proves nothing about the defect it exists for — which lived -# entirely in the objects nobody looked at. -# -# `force_bundle` on libgcc_s is the cheapest way to get one, and it is not a -# contrivance: it resolves to a file inside the build machine's xlings store, -# carrying that store's paths in its own RUNPATH. That is the defect's exact -# shape. (The project's own `kind = "shared"` target does NOT work here — mcpp -# links module objects into the executable, so it is never a DT_NEEDED.) -cd "$TMP" -"$MCPP" new bundled > /dev/null -cd bundled -cat >> mcpp.toml <<'EOF' - -[pack.bundle-project] -force_bundle = ["libgcc_s.so.1"] -EOF - -"$MCPP" pack > "$TMP/pack.log" 2>&1 || { cat "$TMP/pack.log"; exit 1; } - -TARBALL="$(ls target/dist/*.tar.gz | head -1)" -[[ -n "$TARBALL" ]] || { echo "FAIL: no tarball"; cat "$TMP/pack.log"; exit 1; } -mkdir -p "$TMP/x" && tar -xzf "$TARBALL" -C "$TMP/x" - -# EVERYTHING under the mcpp home is machine-local, not just the store. -# -# This used to be `$MCPP_HOME/registry/data/xpkgs`, which is a SUBSET of what -# the sweep claims to cover — and the gap was load-bearing. The SubOS farm -# (`/registry/subos//lib`) now lands in the artifact's DT_RPATH by -# design, and it sits outside `data/xpkgs`, so a leaked farm path would have -# walked straight past this guard while the file's own title says no -# build-machine paths survive. A check narrower than its claim reports "clean" -# for the one thing it cannot see. -STORE="$(cd "$MCPP_HOME" 2>/dev/null && pwd || true)" - -fail=0 -found_exe=0 -found_lib=0 -while IFS= read -r obj; do - head -c4 "$obj" 2>/dev/null | grep -q $'\x7fELF' || continue - read -r form tag paths <<<"$(read_tag "$obj")" - [[ "$form" == "NOT-ELF64" ]] && continue - printf ' %-40s %-14s %s %s\n' "${obj#$TMP/x/}" "$form" "$tag" "$paths" - - if [[ -n "$STORE" && "$paths" == *"$STORE"* ]]; then - echo "FAIL: bundled object still points at the BUILD MACHINE's store" - echo " $obj" - echo " $paths" - fail=1 - fi - if [[ "$form" == "executable" ]]; then - found_exe=1 - case "$tag" in - RPATH|NONE) ;; - *) - echo "FAIL: bundled executable carries $tag, contract requires DT_RPATH" - echo " a bundled vendor library cannot be reached through DT_RUNPATH" - echo " when the dlopen is performed by something else" - fail=1 - ;; - esac - else - found_lib=1 - case "$tag" in - RUNPATH|NONE) ;; - *) - echo "FAIL: bundled library carries $tag, contract requires DT_RUNPATH" - fail=1 - ;; - esac - fi -done < <(find "$TMP/x" -type f) - -[[ "$found_exe" == "1" ]] || { echo "FAIL: swept no executable — the test proved nothing"; exit 1; } -# The load-bearing coverage guard: the defect this file exists for was in the -# BUNDLED LIBRARIES, so a run that found none has not tested it. -[[ "$found_lib" == "1" ]] || { - echo "FAIL: the bundle contains no shared library, so the defect this test" - echo " targets (bundled .so keeping build-machine paths) was not exercised" - find "$TMP/x" -type f | sed "s|$TMP/x/||" - exit 1 -} -[[ "$fail" == "0" ]] || exit 1 - -# A program with no host capabilities must NOT get a requirements file: an -# empty one would be read as a claim that nothing is needed. -if find "$TMP/x" -name HOST-REQUIREMENTS | grep -q .; then - echo "FAIL: a program with no host capabilities got a HOST-REQUIREMENTS file" - exit 1 -fi - -# ── the mode that bundles NOTHING ─────────────────────────────────────────── -# -# `--mode system` is the one mode that ships no libraries, and until now no -# test packed with it — so nothing checked the one thing it must get right: -# an artifact that carries no dependencies must also carry no addressing for -# them. It links against a private glibc with the SubOS farm in its DT_RPATH, -# and both of those are this machine's alone. -"$MCPP" pack --mode system > "$TMP/pack-system.log" 2>&1 || { - cat "$TMP/pack-system.log"; exit 1; } -SYSTAR="$(ls target/dist/*-system.tar.gz | head -1)" -[[ -n "$SYSTAR" ]] || { echo "FAIL: no system-mode tarball"; exit 1; } -mkdir -p "$TMP/xs" && tar -xzf "$SYSTAR" -C "$TMP/xs" - -sysfail=0 -sysfound=0 -while IFS= read -r obj; do - head -c4 "$obj" 2>/dev/null | grep -q $'\x7fELF' || continue - read -r form tag paths <<<"$(read_tag "$obj")" - [[ "$form" == "NOT-ELF64" ]] && continue - sysfound=1 - printf ' [system] %-30s %-14s %s %s\n' "${obj#$TMP/xs/}" "$form" "$tag" "$paths" - if [[ -n "$STORE" && "$paths" == *"$STORE"* ]]; then - echo "FAIL: --mode system artifact points at the BUILD MACHINE" - echo " $obj" - echo " $paths" - sysfail=1 - fi -done < <(find "$TMP/xs" -type f) -[[ "$sysfound" == "1" ]] || { echo "FAIL: --mode system swept no ELF"; exit 1; } -[[ "$sysfail" == "0" ]] || exit 1 - -echo "PASS: no build-machine paths survive packing (vendored or system), and every object carries its contract tag" diff --git a/tests/e2e/216_selfcontained_refuses_host_capability.sh b/tests/e2e/216_selfcontained_refuses_host_capability.sh deleted file mode 100755 index d1a6f2fd1..000000000 --- a/tests/e2e/216_selfcontained_refuses_host_capability.sh +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env bash -# requires: pack patchelf elf -# 216_selfcontained_refuses_host_capability.sh — a bundle that carries its own -# libc cannot consume a capability the host must satisfy. -# -# WHY BOTH MODES -# -# `static` has no libc to share; `self-contained` brings its own. For a library -# the target must supply, the consequence is the same: that .so arrives with -# its own requirements on the HOST's libc, and the process does not have that -# libc. Measured in both directions as mcpp#392 / mcpp#401 — a private glibc -# meeting host-loaded objects dies during relocation, before main. -# -# The graphics drivers are the everyday case: they cannot be bundled at all -# (version-locked to the running kernel module; redistribution not permitted), -# so they are always the host's. -# -# Today both modes link and then fail at startup, or silently fall back to -# software rendering — which is worse than not building, because nothing says -# so. -# -# THE REFUSAL MUST NAME A WAY FORWARD. A check that only says no is a check -# users route around; `vendored` is the mode that actually works here, and the -# test asserts the message says so. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME=$HOME/.mcpp - -# ── the capability comes from a DEPENDENCY, not from this project ─────────── -# -# THIS IS THE SHAPE REAL PROJECTS HAVE, and getting it wrong is how the first -# version of this gate shipped half-working. Almost no application declares -# `capability:opengl.glx.driver` itself — it depends on glfw / an SDL wrapper / -# a GL runtime that does, and the resolver stamps each requirement with its -# requester. A gate that reads the ROOT manifest answers "did the author write -# it down" (nearly always no) instead of "does the resolved graph need it". -# -# Measured on a real imgui project: `mcpp why runtime` listed -# `capability:opengl.glx.driver [run] <- compat.glfw@3.4 (required)`, and -# `--mode self-contained` packaged it happily — while THIS test passed, because -# its fixture declared the capability at the root. The fixture had the one -# shape real projects do not. -INDEX_DIR="$TMP/local-index" -# The manifest is read by mcpp, not by the shell, so the path has to be in HOST -# spelling — an MSYS `/c/...` written into mcpp.toml is a path Windows cannot -# open. `00_fixture_path_hygiene` enforces this statically. -INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" -mkdir -p "$INDEX_DIR/pkgs/g" -cat > "$INDEX_DIR/pkgs/g/gfx-runtime.lua" <<'EOF' -package = { - spec = "1", - name = "gfx-runtime", - description = "A dependency that needs the host to provide a driver", - licenses = {"MIT"}, - type = "package", - xpm = { - linux = { - ["1.0.0"] = { - url = "https://example.invalid/gfx-runtime-1.0.0.tar.gz", - sha256 = "0000000000000000000000000000000000000000000000000000000000000000", - }, - }, - }, - mcpp = { - language = "c++23", - import_std = true, - sources = { "src/**/*.cppm" }, - targets = { ["gfx-runtime"] = { kind = "lib" } }, - deps = {}, - runtime = { - requirements = { - { kind = "capability", value = "opengl.glx.driver", phase = "run", - required = true, discovery = "rpath-of-dispatch" }, - }, - }, - }, -} -EOF - -mkdir -p "$TMP/gfxapp/src" -mkdir -p "$TMP/gfxapp/.mcpp/.xlings/data/xpkgs/local-dev.gfx-runtime/1.0.0/src" -cd "$TMP/gfxapp" -cat > .mcpp/.xlings/data/xpkgs/local-dev.gfx-runtime/1.0.0/src/lib.cppm <<'EOF' -export module gfx.runtime; -export int gfx_ready() { return 1; } -EOF -cat > src/main.cpp <<'EOF' -import gfx.runtime; -int main() { return gfx_ready() == 1 ? 0 : 1; } -EOF -# The application itself declares NOTHING. That is the point. -cat > mcpp.toml < "$TMP/build.log" 2>&1 || { cat "$TMP/build.log"; exit 1; } -"$MCPP" why runtime > "$TMP/why.log" 2>&1 || { cat "$TMP/why.log"; exit 1; } -grep -q 'capability:opengl.glx.driver' "$TMP/why.log" || { - echo "FAIL: the dependency's capability never reached the resolved graph —" - echo " this test would then prove nothing about the gate" - cat "$TMP/why.log" - exit 1 -} -grep -q 'gfx-runtime' "$TMP/why.log" || { - echo "FAIL: the requirement is not attributed to the dependency" - cat "$TMP/why.log" - exit 1 -} -echo " requirement arrives from the dependency, not from this project" - -# ── the two modes that carry their own libc must refuse ───────────────────── -for mode in self-contained static; do - if "$MCPP" pack --mode "$mode" > "$TMP/$mode.log" 2>&1; then - echo "FAIL: --mode $mode packed a program that needs a host capability" - echo " it would link and then fail at startup on the user's machine" - tail -20 "$TMP/$mode.log" - exit 1 - fi - grep -q 'opengl.glx.driver' "$TMP/$mode.log" || { - echo "FAIL: --mode $mode refused without naming the capability" - cat "$TMP/$mode.log" - exit 1 - } - grep -q 'vendored' "$TMP/$mode.log" || { - echo "FAIL: --mode $mode refused without naming a way forward" - echo " a refusal with no next step is a refusal users disable" - cat "$TMP/$mode.log" - exit 1 - } - echo " $mode: refused, named the capability and the alternative" -done - -# ── vendored must WORK, and must state what the host has to provide ───────── -"$MCPP" pack --mode vendored > "$TMP/vendored.log" 2>&1 || { - echo "FAIL: --mode vendored refused a program it can package" - cat "$TMP/vendored.log" - exit 1 -} -TARBALL="$(ls target/dist/*.tar.gz | head -1)" -mkdir -p "$TMP/x" && tar -xzf "$TARBALL" -C "$TMP/x" -REQ="$(find "$TMP/x" -name HOST-REQUIREMENTS | head -1)" -[[ -n "$REQ" ]] || { - echo "FAIL: vendored bundle does not state what the host must provide" - find "$TMP/x" -type f | sed "s|$TMP/x/||" - exit 1 -} -cat "$REQ" - -grep -q 'capability=opengl.glx.driver' "$REQ" || { - echo "FAIL: HOST-REQUIREMENTS does not list the capability" - exit 1 -} -# The discovery mechanism is the actionable half. GLX is found through the -# dispatch library's own DT_RPATH, while EGL is found through a JSON file whose -# library_path is ABSOLUTE — so "copy the directory over" fixes one and not the -# other. A row without it is not actionable. -grep -q 'discovery=rpath-of-dispatch' "$REQ" || { - echo "FAIL: HOST-REQUIREMENTS states the capability but not how it is found" - cat "$REQ" - exit 1 -} - -echo "PASS: modes that carry their own libc refuse host capabilities; vendored packages and declares them" diff --git a/tests/e2e/217_module_extensions.sh b/tests/e2e/217_module_extensions.sh deleted file mode 100755 index a8b866599..000000000 --- a/tests/e2e/217_module_extensions.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 217_module_extensions.sh — `[build] module_extensions`: a project declares -# which extensions its module INTERFACES use, and mcpp treats them as such -# everywhere. -# -# What each part protects against: -# -# * opt-in — `.ccm`/`.cxxm`/`.ixx` are NOT built in. If they were, the -# default source glob would widen with them and a published -# package with a vendored MSVC-only `.ixx` under src/ would -# start compiling it on the next mcpp upgrade — a break its -# author cannot fix, because that version's tarball shipped. -# Part 1 pins the un-configured behaviour. -# * rule routing — mcpp#272 fixed link-object collection while `pick_rule` -# stayed keyed on the extension, so a `.ixx` was routed to -# `cxx_object`: the edge still DECLARED a BMI output (that -# line reads providesModule) while the command line lost -# `-fmodule-output=`. GCC's gcm.cache made it look fine. -# * clang leg — and that is why this test must not be GCC-only. Clang does -# not recognize `.ixx` at all: it hands the file to the -# LINKER, warns, and exits 0 having produced no BMI. A -# GCC-only test would report green for a build that cannot -# work anywhere else. -# * object names — giving all four module extensions the `.m` prefix (as -# mcpp#272 proposed) makes `foo.cppm` and `foo.ccm` both -# `foo.m.o`. Part 2 pins one object per source. -set -e - -# Resolve before cd'ing: $0 is relative and every leg runs from $TMP. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -fixture() { # $1 = dir, $2 = extra mcpp.toml lines - mkdir -p "$1/src" - cat > "$1/mcpp.toml" < void { std::println("from .cppm"); }\n' > "$1/src/a.cppm" - printf 'export module extfix.b;\nimport std;\nexport auto b() -> void { std::println("from .ccm"); }\n' > "$1/src/b.ccm" - printf 'export module extfix.c;\nimport std;\nexport auto c() -> void { std::println("from .cxxm"); }\n' > "$1/src/c.cxxm" - printf 'export module extfix.d;\nimport std;\nexport auto d() -> void { std::println("from .ixx"); }\n' > "$1/src/d.ixx" -} - -# ── Part 1: NOT configured ⇒ only .cppm is a module interface ────────────── -# -# Reverse assertion. Without it, someone "simplifying" the built-in table into -# all four extensions would make every test above pass and break every -# published package that ships a stray .ixx. -fixture nocfg "" -printf 'import std;\nimport extfix.a;\nint main(){ a(); std::println("NOCFG OK"); }\n' > nocfg/src/main.cpp -cd nocfg -"$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL: default build broke"; exit 1; } -grep -q 'sources \[src/\*\*/\*\.{cppm,cpp,cc,c,S,s,asm}\]' b.log || { - cat b.log; echo "FAIL: default source glob changed"; exit 1; } -nj="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -for stray in b.ccm c.cxxm d.ixx; do - grep -q "$stray" "$nj" && { echo "FAIL: $stray compiled without opt-in"; exit 1; } -done -out="$("$MCPP" run 2>&1)" -[[ "$out" == *"NOCFG OK"* ]] || { echo "FAIL: $out"; exit 1; } -echo " ok: un-configured build ignores .ccm/.cxxm/.ixx" -cd "$TMP" - -# ── Part 2: configured ⇒ all four build, link and RUN ────────────────────── -run_leg() { # $1 = leg name, $2 = extra [toolchain] lines - local dir="leg_$1" - fixture "$dir" " -[build] -module_extensions = [\".ccm\", \".cxxm\", \".ixx\"] -$2" - printf 'import std;\nimport extfix.a;\nimport extfix.b;\nimport extfix.c;\nimport extfix.d;\nint main(){ a(); b(); c(); d(); std::println("ALL OK"); }\n' > "$dir/src/main.cpp" - cd "$dir" - - "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL[$1]: build"; exit 1; } - - # The declared extensions must reach the default glob, or the key would - # change how files are TREATED without changing whether they are FOUND. - grep -q 'sources \[src/\*\*/\*\.{cppm,ccm,cxxm,ixx,cpp,cc,c,S,s,asm}\]' b.log || { - cat b.log; echo "FAIL[$1]: declared extensions missing from default glob"; exit 1; } - - out="$("$MCPP" run 2>&1)" - for want in "from .cppm" "from .ccm" "from .cxxm" "from .ixx" "ALL OK"; do - [[ "$out" == *"$want"* ]] || { echo "FAIL[$1]: missing '$want' in: $out"; exit 1; } - done - - local nj; nj="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" - - # Every module interface must use the MODULE rule. Asserting on the rule — - # not on the BMI path — is the point: the BMI output line is emitted from - # providesModule and stayed correct while the rule was wrong. - for src in a.cppm b.ccm c.cxxm d.ixx; do - grep -qE "^build [^:]*: cxx_module .*${src}\$|^build [^:]*: cxx_module .*${src} " "$nj" \ - || grep -q ": cxx_module .*${src}" "$nj" \ - || { echo "FAIL[$1]: $src did not use the cxx_module rule"; exit 1; } - done - - # One object per source, no two alike. - local objs - objs="$(grep -oE 'obj/[A-Za-z0-9_.]+\.o' "$nj" | sort -u)" - for want in "obj/a.m.o" "obj/b.ccm.o" "obj/c.cxxm.o" "obj/d.ixx.o"; do - grep -qx "$want" <<< "$objs" || { - echo "FAIL[$1]: expected object $want; got:"; echo "$objs"; exit 1; } - done - - echo " ok[$1]: four extensions build, link and run" - cd "$TMP" -} - -run_leg gcc "" - -# ── Part 3: the same, on Clang ───────────────────────────────────────────── -# -# Not optional coverage. `.ixx` is the extension Clang does not know, so this -# leg is the one that would have caught the pick_rule defect. Skipped only -# when no LLVM payload is installed. -source "$SCRIPT_DIR/_llvm_env.sh" -if [[ -d "$LLVM_ROOT" ]]; then - run_leg "clang" " -[toolchain] -default = \"llvm@${LLVM_VERSION}\"" -else - echo " skip: no LLVM payload installed — clang leg not run" -fi - -echo "OK" diff --git a/tests/e2e/218_module_extensions_graph_shape.sh b/tests/e2e/218_module_extensions_graph_shape.sh deleted file mode 100755 index eb155200e..000000000 --- a/tests/e2e/218_module_extensions_graph_shape.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 218_module_extensions_graph_shape.sh — a declared module extension has to -# behave like a module interface to the two mechanisms that decide WHETHER TO -# REBUILD, not just to the compiler. -# -# * Part 1 — the freshness fast path must sweep it. -# -# `sources_newer_than` asks "could the SHAPE of the graph have changed", -# which is a different question from "did a file change" (ninja answers -# that one). Its extension list used to be hand-written and did not include -# `.ixx`, so adding an `import` to one changed nothing it could see: the -# fast path replayed a stale graph, ninja recompiled the object because its -# mtime moved, the dyndep edges stayed as they were, and NOTHING reported -# anything. That is the worst failure mode in this area — silent, and it -# surfaces later as an unrelated BMI error. -# -# The edit below adds a real `import`. Do NOT reduce it to `touch`: an -# mtime-only change is exactly what a correct implementation is also -# allowed to ignore, so a touch-based test can pass with the bug present. -# And do NOT delete artifacts to force a rebuild: ninja then fails, the -# failure is read as a stale-graph signature, and the fast path falls back -# to a full prepare for the wrong reason — the assertion below would hold -# while proving nothing. -# -# * Part 2 — the key must reach the graph. -# -# `module_extensions` decides which units emit a BMI and which objects link -# unconditionally. It is an attribute of the package that declares it, not -# of the configuration (workspace design 2026-09-29 §3): the build -# directory stays, and the graph written into it must classify the files -# the key now names. A `.ccm` file the key starts naming becomes a module -# interface with its own BMI edge, in the same directory. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" -mkdir -p src - -cat > mcpp.toml <<'EOF' -[package] -name = "gshape" -version = "0.1.0" - -[build] -module_extensions = [".ixx"] -EOF - -printf 'export module gshape.helper;\nimport std;\nexport auto helper() -> int { return 41; }\n' > src/helper.cppm -printf 'export module gshape.face;\nimport std;\nexport auto face() -> int { return 1; }\n' > src/face.ixx -printf 'import std;\nimport gshape.face;\nint main(){ std::println("{}", face()); }\n' > src/main.cpp - -# Ask mcpp for the fingerprint instead of inferring it from the build tree. -# -# The obvious `find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1` is WRONG here and was -# flaky in exactly the way this test is meant to catch: after part 2 there are -# TWO output dirs, and `head -1` picks whichever `find` happened to walk first. -# It passed standalone and failed inside the suite. Assert on the value, not on -# a directory listing. -fingerprint() { - "$MCPP" build --print-fingerprint 2>&1 | sed -n 's/^Fingerprint: //p' | head -1 -} - -# ── 0. First build, then confirm the fast path actually engages ──────────── -"$MCPP" build > b0.log 2>&1 || { cat b0.log; echo "FAIL: first build"; exit 1; } -FP_BEFORE="$(fingerprint)" -[ -n "$FP_BEFORE" ] || { echo "FAIL: could not read the fingerprint"; exit 1; } - -# Whether the last build planned: the full path writes build.ninja (or moves -# its time when the text is unchanged), and the fast path replays it untouched. -# A `Compiling` line no longer tells the two apart: it states that a package's -# steps ran, on either path (build progress design 2026-09-29). -planned_since() { find target -name build.ninja -not -path '*/.build-mcpp/*' -newer "$1" 2>/dev/null | grep -q .; } -touch .before-b1; sleep 1 -"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: no-change build"; exit 1; } -planned_since .before-b1 && { - cat b1.log; echo "FAIL: fast path did not engage — the rest proves nothing"; exit 1; } -echo " ok: fast path engages on a no-change build" - -# ── 1. A NEW import inside the .ixx must invalidate the graph ────────────── -touch .before-b2; sleep 1 -printf 'export module gshape.face;\nimport std;\nimport gshape.helper;\nexport auto face() -> int { return helper() + 1; }\n' > src/face.ixx - -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: build after .ixx edit"; exit 1; } -planned_since .before-b2 || { - cat b2.log - echo "FAIL: editing a .ixx did not invalidate the fast path" - echo " (the freshness sweep is not classifying it as a graph-shape input)" - exit 1; } -echo " ok: a new import inside a .ixx forces a full prepare" - -out="$("$MCPP" run 2>&1)" -[[ "$out" == *"42"* ]] || { echo "FAIL: expected 42 (41+1), got: $out"; exit 1; } -echo " ok: the new dependency edge is real (41+1 = 42)" - -# ── 2. Changing module_extensions must change the graph ──────────────────── -printf 'export module gshape.extra;\nexport auto extra() -> int { return 2; }\n' > src/extra.ccm -cat > mcpp.toml <<'EOF' -[package] -name = "gshape" -version = "0.1.0" - -[build] -module_extensions = [".ixx", ".ccm"] -EOF - -"$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: build after key change"; exit 1; } -FP_AFTER="$(fingerprint)" - -[[ "$FP_BEFORE" == "$FP_AFTER" ]] || { - echo "FAIL: module_extensions moved the build directory ($FP_BEFORE -> $FP_AFTER)" - echo " (a package attribute entered the configuration's name)" - exit 1; } -ninja_file="$(find target -path "*/$FP_AFTER/build.ninja" | head -1)" -grep -q 'gshape\.extra' "$ninja_file" || { - echo "FAIL: the .ccm named by module_extensions is not a module interface of the graph" - exit 1; } -echo " ok: the key reaches the graph in the same directory ($FP_AFTER)" - -# ── 3. A dead entry is reported, not silently ignored ────────────────────── -# -# Otherwise a typo (".ixxx") is indistinguishable from "this project has none -# yet": the build succeeds and the key does nothing. -cat > mcpp.toml <<'EOF' -[package] -name = "gshape" -version = "0.1.0" - -[build] -module_extensions = [".ixx", ".nosuchext"] -EOF -"$MCPP" build > b4.log 2>&1 || { cat b4.log; echo "FAIL: build with a dead entry"; exit 1; } -grep -q "nosuchext" b4.log || { - cat b4.log; echo "FAIL: a module_extensions entry matching nothing was not reported"; exit 1; } -echo " ok: a dead module_extensions entry is reported" - -# ── 4. A reserved extension is a hard error, not a warning ───────────────── -# -# Claiming `.c` would route C files to the C++ module rule and fail somewhere -# that names neither the file nor the key. -cat > mcpp.toml <<'EOF' -[package] -name = "gshape" -version = "0.1.0" - -[build] -module_extensions = [".c"] -EOF -if "$MCPP" build > b5.log 2>&1; then - cat b5.log; echo "FAIL: [build] module_extensions = [\".c\"] was accepted"; exit 1 -fi -grep -q "module_extensions" b5.log || { - cat b5.log; echo "FAIL: the error does not name the offending key"; exit 1; } -echo " ok: claiming a non-module extension is refused, and the error names the key" - -echo "OK" diff --git a/tests/e2e/219_runtime_search_farm_is_last.sh b/tests/e2e/219_runtime_search_farm_is_last.sh deleted file mode 100755 index 831cc1dbe..000000000 --- a/tests/e2e/219_runtime_search_farm_is_last.sh +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf python3 -# resources: machine -# 219_runtime_search_farm_is_last.sh — the run-time search closure, and the one -# invariant in it. -# -# WHAT IS BEING FIXED -# -# mcpp already treats the selected SubOS as its sysroot: `--sysroot=` is -# on the compile AND link lines, so `-lGL` resolves out of `/lib` with -# no flags from the user. Nothing carried that view into the RUN-time search -# path, which was built from toolchain payload directories alone. The result -# was a link that succeeded and an executable that could not start. -# -# THE INVARIANT -# -# payload directories first, the artifact's own directory next, -# the SubOS farm LAST — literally last, `$ORIGIN` included -# -# and it is about mutability, not taste. `/lib` is a symlink view -# rewritten by every `xlings install`; a payload directory is written once and -# the artifact's own directory holds the exact files this link resolved -# against. Payload-first keeps libc / libm / libstdc++ resolving from the -# pinned payload; `$ORIGIN` before the farm keeps the artifact running against -# what it was built with; the farm supplies only what nothing else does. -# -# Farm-first lets a later install silently change which library an ALREADY -# LINKED artifact loads — a failure that appears long after the build that -# caused it, and one that actually shipped: with the farm ahead of `$ORIGIN` a -# GLFW/imgui application linked against the libX11 mcpp had just built from -# `compat.x11` sources and then LOADED the `xim:libX11` xlings had installed. -# -# WHY THIS ASSERTS THE ARTIFACT AND THE RECORD -# -# They fail differently. A wrong DT_RPATH is a regression in what mcpp emits; a -# record that disagrees with the DT_RPATH means resolution.json is describing a -# build that did not happen, and every downstream reader (`mcpp why runtime`, -# `mcpp pack`, CI) is then reasoning about fiction. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# THE PROJECT CONSUMES A SHARED LIBRARY FROM A DEPENDENCY, AND THAT IS THE POINT. -# -# A bare `int main()` produces an executable with NO `$ORIGIN` in its DT_RPATH, -# so the ordering this test exists to check is not even present — the earlier -# version of this test passed on a binary that could not exhibit the bug. -# -# A shared TARGET in the same package is not enough either: mcpp links that -# package's module objects into the executable directly, so there is still no -# `-l` and no `$ORIGIN`. It takes a DEPENDENCY that ships a shared library — -# which is exactly the shape of the artifact that broke (an application whose -# `compat.x11` dependency builds `libX11.so` into the artifact directory). -mkdir -p "$TMP/greetdep/src" "$TMP/proj/src" -cat > "$TMP/greetdep/mcpp.toml" <<'EOF' -[package] -name = "greetdep" -version = "0.1.0" - -[targets.greetdep] -kind = "shared" -EOF -# Interface and implementation are split so the call is a real cross-library -# reference: an inline definition in the interface would be emitted into the -# consumer and the dependency edge would vanish. -cat > "$TMP/greetdep/src/greetdep.cppm" <<'EOF' -export module greetdep; -export int greet_value(); -EOF -cat > "$TMP/greetdep/src/greetdep.cpp" <<'EOF' -module greetdep; -int greet_value() { return 7; } -EOF - -cd "$TMP/proj" -cat > mcpp.toml <<'EOF' -[package] -name = "closure" -version = "0.1.0" - -[dependencies.greetdep] -path = "../greetdep" -EOF -cat > src/main.cpp <<'EOF' -import greetdep; -int main() { return greet_value() == 7 ? 0 : 1; } -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } - -RES="$(ls target/*/*/resolution.json 2>/dev/null | head -1)" -BIN="$(ls target/*/*/bin/closure 2>/dev/null | head -1)" -[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } -[[ -n "$BIN" ]] || { echo "FAIL: no executable"; ls -R target | head -30; exit 1; } - -# ── the recorded closure ──────────────────────────────────────────────────── -python3 - "$RES" > "$TMP/closure.txt" <<'PY' || exit 1 -import json, sys -doc = json.load(open(sys.argv[1])) -search = doc["runtime"].get("search", {}) -closure = search.get("closure") -if closure is None: - print("MISSING", file=sys.stderr) - raise SystemExit("FAIL: resolution.json records no runtime search closure") -for d in closure: - print(d["origin"], d["path"], sep="\t") -PY -cat "$TMP/closure.txt" - -FARM="$(awk -F'\t' '$1=="subos_farm"{print $2}' "$TMP/closure.txt" | tail -1)" -PAYLOAD_FIRST="$(awk -F'\t' 'NR==1{print $1}' "$TMP/closure.txt")" - -if [[ -z "$FARM" ]]; then - # SKIP, LOUDLY. A SubOS with no lib view is a legitimate configuration - # (a bare or freshly created one), but a test that silently passes in that - # state would report "the farm is last" on a machine that has no farm. - echo "SKIP: this SubOS exposes no library view, so there is no farm entry" - echo " to order. Recorded closure:" - cat "$TMP/closure.txt" - exit 0 -fi - -# ── invariant 1: the closure is ordered, farm last ────────────────────────── -LAST_ORIGIN="$(tail -1 "$TMP/closure.txt" | cut -f1)" -[[ "$LAST_ORIGIN" == "subos_farm" ]] || { - echo "FAIL: the last entry of the search closure is '$LAST_ORIGIN', not the farm" - echo " A mutable symlink view ahead of an immutable payload lets a later" - echo " \`xlings install\` change which libc an already-linked artifact loads." - cat "$TMP/closure.txt" - exit 1 -} -[[ "$PAYLOAD_FIRST" == "payload" ]] || { - echo "FAIL: the closure does not start with a payload directory (got '$PAYLOAD_FIRST')" - cat "$TMP/closure.txt" - exit 1 -} - -# ── invariant 2: the artifact says the same thing ─────────────────────────── -# -# Parsed from the dynamic section directly rather than via readelf: a sandbox -# home does not reliably have binutils, and on at least one real machine its -# shims pointed into a deleted directory. -DT_RPATH="$(python3 - "$BIN" <<'PY' -import struct, sys -d = open(sys.argv[1], 'rb').read() -if d[:4] != b'\x7fELF' or d[4] != 2: - print(""); raise SystemExit -phoff, = struct.unpack_from(' file offset via PT_LOAD - loads = [] - for k in range(phnum): - o = phoff + k * phentsize - t, = struct.unpack_from(' : $ORIGIN` and with -# `… : $ORIGIN : ` the list of absolute entries is IDENTICAL, so it passed -# on both. It reported "farm is last" on a binary whose farm was not last. -# -# What decides the winner is a SONAME present in two directories, and that is -# routine here: mcpp builds `compat.x11` from source into the artifact's own -# directory while xlings has `xim:libX11` in the farm. Farm-first meant an -# application linked against one libX11 and loaded the other, dying before main -# with `undefined symbol: _ZNKSt13runtime_error4whatEv`. -RPATH_LAST="$(python3 -c " -print('''$DT_RPATH'''.split(':')[-1]) -")" -[[ "$RPATH_LAST" == "$FARM" ]] || { - echo "FAIL: the farm is not the last entry of DT_RPATH" - echo " recorded farm: $FARM" - echo " last entry: $RPATH_LAST" - echo " full: $DT_RPATH" - exit 1 -} - -# ── invariant 2b: the artifact's own directory is ON the path, and ahead ───── -# -# Both halves are load-bearing. Without `$ORIGIN` the project under test cannot -# exhibit the bug and every other assertion here is vacuous; with `$ORIGIN` -# behind the farm, a mutable view outranks the exact files this link resolved -# against. -case ":$DT_RPATH:" in - *':$ORIGIN:'*) ;; - *) echo "FAIL: no \$ORIGIN in DT_RPATH — this project cannot exercise the" - echo " ordering it is meant to check. full: $DT_RPATH" - exit 1 ;; -esac -python3 - < p.index('''$FARM'''): - print("FAIL: the SubOS farm outranks \$ORIGIN, so this artifact can load a") - print(" different build of a library than it was linked against.") - print(" full: " + ':'.join(p)) - sys.exit(1) -PY - -# ── invariant 2b: the RECORD and the ARTIFACT agree ITEM BY ITEM (#415) ──── -# -# This is the assertion the record could not support until `$ORIGIN` entered the -# closure. `resolution.json` called itself the one place the search order is -# decided, while the artifact's own directory was emitted on a separate -# per-unit channel and never appeared in the record — so the two lists could not -# be compared at all, and every check here had to be about ORDER RELATIONS -# between entries that happened to be in both. -# -# Now they are the same list in the same order, and this compares them directly. -# `artifact` is the closure's spelling of `$ORIGIN`; nothing else is filtered, -# translated or excused — an exception here would be the gap coming back, not a -# detail of the test. -python3 - "$TMP/closure.txt" "$DT_RPATH" <<'ITEMWISE' || exit 1 -import sys - -rows = [] -for line in open(sys.argv[1], encoding="utf-8"): - line = line.rstrip("\n") - if not line: - continue - origin, _, path = line.partition("\t") - rows.append((origin, path)) - -recorded = ["$ORIGIN" if o == "artifact" else p for o, p in rows] -actual = [e for e in sys.argv[2].split(":") if e] - -if recorded != actual: - print("FAIL: the recorded closure and the artifact's DT_RPATH differ") - print(" recorded (artifact -> $ORIGIN):") - for e in recorded: - print(" " + e) - print(" DT_RPATH:") - for e in actual: - print(" " + e) - print() - print(" These are supposed to BE the same list. A missing $ORIGIN in the") - print(" record means Origin::Artifact stopped entering the closure (#415);") - print(" an $ORIGIN in the record with none in the artifact means it is") - print(" being recorded for a project that emits none.") - sys.exit(1) -print(f"closure == DT_RPATH, {len(actual)} entries, item by item") -ITEMWISE - -# ── invariant 3: libc still comes from the payload, not the farm ──────────── -# -# The point of the ordering. Both directories can hold a libc.so.6 (the farm's -# is a symlink to the payload's today), so "it works" proves nothing — what -# matters is which one is consulted FIRST. Asserting the payload entry that -# precedes the farm actually holds a libc is what makes this a measurement. -RPATH_FIRST="${DT_RPATH%%:*}" -[[ -e "$RPATH_FIRST/libc.so.6" ]] || { - echo "FAIL: the first DT_RPATH entry holds no libc.so.6, so this test is not" - echo " measuring the ordering it claims to measure." - echo " first: $RPATH_FIRST" - exit 1 -} -case ":$DT_RPATH:" in - *":$FARM:"*) ;; - *) echo "FAIL: farm missing from DT_RPATH entirely"; exit 1 ;; -esac -FARM_POS="$(python3 -c " -import sys -p = '''$DT_RPATH'''.split(':') -print(p.index('''$FARM''')) -")" -FIRST_POS=0 -[[ "$FARM_POS" -gt "$FIRST_POS" ]] || { - echo "FAIL: the farm precedes the payload that supplies libc" - exit 1 -} - -# ── invariant 4: the LOADER agrees, measured rather than inferred ─────────── -# -# Everything above reads a data structure. This runs the program and watches -# the dynamic linker walk the path, because the shape of DT_RPATH is a proxy -# and the behaviour is the thing: which physical file does the process open? -# -# THIS ASSERTION MUST NOT DEPEND ON A CRASH. The defect it guards produced a -# spectacular one (`undefined symbol: _ZNKSt13runtime_error4whatEv`), but that -# symptom exists only while shared libraries statically embed libstdc++. Once -# they stop, farm-first degrades from a crash to a SILENT version mismatch — -# the artifact quietly running a different build than it linked against — and -# an assertion written against the crash would go green for the wrong reason. -# So it asserts the search itself. -# -# `libgreetdep.so` lives in the artifact's directory and nowhere else, so if -# `$ORIGIN` is consulted first the farm is never tried for it at all. A farm -# path appearing in this trace means the farm was consulted FIRST. -LD_DEBUG=libs "$BIN" > "$TMP/run.log" 2> "$TMP/ld.log" || { - echo "FAIL: the built executable did not run"; tail -20 "$TMP/ld.log"; exit 1; -} -SONAME="$(ls "$(dirname "$BIN")" | grep -E '^libgreetdep\.so' | head -1)" -[[ -n "$SONAME" ]] || { - echo "FAIL: no shared library was produced, so invariant 4 is vacuous" - ls -la "$(dirname "$BIN")"; exit 1; -} -awk -v soname="$SONAME" -v farm="$FARM" ' - index($0, "find library=" soname) { inblock = 1; next } - inblock && /find library=/ { inblock = 0 } - inblock && index($0, "trying file=") { - if (index($0, farm "/")) { print; found = 1 } - } - END { exit found ? 1 : 0 } -' "$TMP/ld.log" || { - echo "FAIL: the loader consulted the SubOS farm for $SONAME before the" - echo " artifact's own directory. A mutable view is being searched" - echo " ahead of the exact files this artifact was linked against." - echo " farm: $FARM" - grep -A8 "find library=$SONAME" "$TMP/ld.log" | head -20 - exit 1 -} - -echo "PASS: search closure is payload-first / farm-last, the artifact agrees," -echo " and the loader resolves the artifact's own library from \$ORIGIN" diff --git a/tests/e2e/21_ninja_dyndep.sh b/tests/e2e/21_ninja_dyndep.sh deleted file mode 100755 index 3a290f50a..000000000 --- a/tests/e2e/21_ninja_dyndep.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# 21_ninja_dyndep.sh — verify M3.3.b: ninja dyndep build mode produces -# byte-identical runtime output to the static-deps mode. -# -# Toggled by env: MCPP_NINJA_DYNDEP=1 -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new myapp > /dev/null -cd myapp - -# Multi-module project to exercise scan + collect + dyndep BMI edges. -cat > src/lib.cppm <<'EOF' -export module myapp.lib; -import std; -export import :greet; -export auto banner() -> void { std::println("== myapp =="); } -EOF -cat > src/lib-greet.cppm <<'EOF' -export module myapp.lib:greet; -import std; -export auto greet(std::string_view who) -> void { - std::println("hello, {}", who); -} -EOF -cat > src/main.cpp <<'EOF' -import std; -import myapp.lib; -int main() { banner(); greet("dyndep"); return 0; } -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "myapp" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm", "src/**/*.cpp"] -[targets.myapp] -kind = "bin" -main = "src/main.cpp" -EOF - -# --- Path A: static deps (default) --- -"$MCPP" build > build.static.log 2>&1 -triple=$(ls -d target/x86_64-linux-*/ | head -1) -fp_dir=$(ls "$triple") -out_static=$(${triple}${fp_dir}/bin/myapp) -rm -rf target - -# --- Path B: dyndep mode --- -MCPP_NINJA_DYNDEP=1 "$MCPP" build > build.dyndep.log 2>&1 -triple=$(ls -d target/x86_64-linux-*/ | head -1) -fp_dir=$(ls "$triple") -out_dyndep=$(${triple}${fp_dir}/bin/myapp) - -# --- Assertions --- -if [[ "$out_static" != "$out_dyndep" ]]; then - echo "FAIL: runtime output differs" - echo " static: $out_static" - echo " dyndep: $out_dyndep" - exit 1 -fi - -# P1 (0.0.10+): per-file dyndep — each .cppm gets its own .dd file. -dd_files=$(find "${triple}${fp_dir}" -name '*.ddi.dd' | wc -l) -[[ "$dd_files" -gt 0 ]] || { - echo "FAIL: no per-file .ddi.dd files produced under MCPP_NINJA_DYNDEP=1" - exit 1 -} - -# Dyndep mode must have emitted scan + per-file dyndep rules. -grep -q '^rule cxx_scan' ${triple}${fp_dir}/build.ninja || { - echo "FAIL: build.ninja missing cxx_scan rule"; exit 1; } -grep -q '^rule cxx_dyndep' ${triple}${fp_dir}/build.ninja || { - echo "FAIL: build.ninja missing cxx_dyndep rule"; exit 1; } -grep -q ' dyndep = ' ${triple}${fp_dir}/build.ninja || { - echo "FAIL: compile edges missing dyndep ="; exit 1; } - -# Static mode must NOT have those rules (sanity). -grep -q '^rule cxx_scan' build.static.log && { - echo "FAIL: static mode leaked cxx_scan"; exit 1; } || true - -# Verify .ddi file content for one TU is real P1689 JSON. -ddi=$(find target -name '*.cppm.ddi' | head -1) -[[ -n "$ddi" ]] || { echo "FAIL: no .ddi file produced"; exit 1; } -grep -q '"rules"' "$ddi" || { echo "FAIL: .ddi missing rules"; exit 1; } -grep -q '"primary-output"' "$ddi" || { echo "FAIL: .ddi missing primary-output"; exit 1; } - -# Per-file .dd content sanity. -ddep=$(find "${triple}${fp_dir}" -name '*.ddi.dd' | head -1) -[[ -n "$ddep" ]] || { echo "FAIL: no .ddi.dd file"; exit 1; } -grep -q 'ninja_dyndep_version = 1' "$ddep" || { - echo "FAIL: dyndep file missing version header"; exit 1; } - -# Incremental: re-run dyndep build → must be noop. -out2=$(MCPP_NINJA_DYNDEP=1 "$MCPP" build 2>&1) -[[ "$out2" == *'Finished'* ]] || { echo "FAIL: incremental rebuild output: $out2"; exit 1; } - -echo "OK" diff --git a/tests/e2e/220_farm_only_library_runs.sh b/tests/e2e/220_farm_only_library_runs.sh deleted file mode 100755 index 074e32065..000000000 --- a/tests/e2e/220_farm_only_library_runs.sh +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf python3 -# 220_farm_only_library_runs.sh — the assertion that cannot be faked. -# -# TWO HALVES, BOTH ABOUT THE SAME MISTAKE -# -# 1. A library that only the SubOS farm provides must LOAD. Not "the path is in -# DT_RPATH" — 219 asserts that, and it is not enough: the artifact runs under -# a PRIVATE loader whose defaults differ from the host's, so a path can be -# present, correct, and still not be what the program consults. Only exec -# tells the truth. -# -# This is also why the host cannot be used as an oracle. On a developer -# machine /usr/lib usually has libGL.so.1 too, so a checker that falls back -# to host directories reports "resolved" for a binary that exits 127. That -# is precisely how `validation: pass` was printed for a program that could -# not start. -# -# 2. A DT_NEEDED that NOTHING provides must turn the build RED. Under a -# hermetic binding mcpp computes the whole search path, so "not found" is a -# measurement, not an absence of one — reporting it as `inconclusive` files -# a proven failure under "not checked". -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# ── half 0: a farm this test OWNS ─────────────────────────────────────────── -# -# The opportunistic half below can only measure what this machine happens to -# have installed, and on a CI SubOS that is nothing the payloads do not already -# provide — so it skips, and the decisive assertion never runs where it matters -# most. This half builds the condition instead of hoping for it. -# -# A project-local named SubOS (`/.mcpp/.xlings/subos/`) is a real -# SubOS as far as the binding is concerned, and it lives entirely inside this -# test's temp directory. Nothing shared is written — writing a probe library -# into the machine's actual SubOS would be the "one test's failure comes from -# the previous run" defect this suite has already paid for once. -# -# `allow_host_libs` is required and is not a workaround: a SubOS with no -# `subos_info` names no runtime, so mcpp declines payload-first rather than -# guessing a libc, and the hermeticity check correctly reports the host -# fallback. The declaration is what says "yes, I meant that". -mkdir -p "$TMP/ownfarm/src" "$TMP/ownfarm/.mcpp/.xlings/subos/probe/lib" -cd "$TMP/ownfarm" -FARM_OWN="$TMP/ownfarm/.mcpp/.xlings/subos/probe/lib" - -# The probe library, built by mcpp itself so it matches the toolchain exactly. -mkdir -p "$TMP/probelib/src" -cat > "$TMP/probelib/mcpp.toml" <<'EOF' -[package] -name = "probelib" -version = "0.1.0" - -[targets.mcppfarmprobe] -kind = "shared" -EOF -cat > "$TMP/probelib/src/probe.cppm" <<'EOF' -export module probelib.probe; -export int mcpp_farm_probe() { return 42; } -EOF -( cd "$TMP/probelib" && "$MCPP" build > build.log 2>&1 ) || { - echo "FAIL: probe library build"; cat "$TMP/probelib/build.log"; exit 1; } -PROBE_SO="$(ls "$TMP"/probelib/target/*/*/bin/libmcppfarmprobe.so | head -1)" -[[ -n "$PROBE_SO" ]] || { echo "FAIL: no probe .so"; ls -R "$TMP/probelib/target" | head -20; exit 1; } -cp "$PROBE_SO" "$FARM_OWN/libmcppfarmprobe.so" - -cat > mcpp.toml < src/main.cpp -"$MCPP" build > build.log 2>&1 || { - echo "FAIL: build against a SubOS-provided library"; cat build.log; exit 1; } - -OWN_BIN="$(ls target/*/*/bin/ownfarm | head -1)" -[[ -n "$OWN_BIN" ]] || { echo "FAIL: no artifact"; exit 1; } - -# The library exists ONLY under the SubOS view. `-L` got it linked; only the -# farm entry in DT_RPATH can get it loaded — nothing else on the search path -# has ever heard of it. -"$OWN_BIN" -rc=$? -[[ $rc -eq 0 ]] || { - echo "FAIL: an artifact could not load a library its own SubOS provides (exit $rc)" - echo " farm: $FARM_OWN" - echo " This is the run-time half of the closure: the SubOS is the" - echo " sysroot at link time and must be on the search path at load time." - exit 1 -} -echo "ran: exit 0 against a library only this SubOS provides" -# Measured against the previous release (2026.8.10.3) this half fails at the -# BINDING — an undeclared SubOS was a hard error there — so it exercises both -# halves of this change together rather than isolating the farm. The half below -# isolates the farm alone, when the machine has a populated SubOS to isolate it -# with; on the machine this was written on, `-lEGL` linked and exited 127 before -# the change and exits 0 after. -cd "$TMP" - -# ── find a library only the farm provides ─────────────────────────────────── -# -# Derived from the recorded closure rather than hardcoded to libGL: CI SubOSes -# do not all have graphics packages installed, and a test that names a library -# it cannot find would either fail for the wrong reason or quietly pass. -mkdir -p "$TMP/probe/src" -cd "$TMP/probe" -cat > mcpp.toml <<'EOF' -[package] -name = "probe" -version = "0.1.0" -EOF -echo 'int main() { return 0; }' > src/main.cpp -"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } -RES="$(ls target/*/*/resolution.json | head -1)" - -read -r FARM_LIB FARM_DIR <<<"$(python3 - "$RES" <<'PY' -import json, os, sys, re -doc = json.load(open(sys.argv[1])) -closure = doc["runtime"].get("search", {}).get("closure", []) -farms = [d["path"] for d in closure if d["origin"] == "subos_farm"] -payloads = [d["path"] for d in closure if d["origin"] != "subos_farm"] - -# Names the payloads already provide are useless here: the farm would not be -# the reason they load. -provided = set() -for p in payloads: - try: provided.update(os.listdir(p)) - except OSError: pass - -# A link name (`libfoo.so`) whose SONAME-ish sibling exists, skipping the C -# runtime: those are the payload's job and are on the search path ahead of the -# farm by construction. -skip = re.compile(r'^lib(c|m|dl|rt|pthread|gcc_s|stdc\+\+|atomic|c\+\+.*)\.so') -for d in farms: - try: names = sorted(os.listdir(d)) - except OSError: continue - for n in names: - if not n.endswith(".so") or not n.startswith("lib"): continue - if skip.match(n): continue - if n in provided: continue - if not os.path.exists(os.path.join(d, n[:-3] + ".so.1")) \ - and not os.path.islink(os.path.join(d, n)): continue - print(n[3:-3], d) - raise SystemExit -print("", "") -PY -)" - -if [[ -z "$FARM_LIB" ]]; then - # SKIP, LOUDLY, WITH THE REASON. Silence here would mean "the farm works" - # on a machine where nothing was ever loaded from it. - echo "SKIP: this SubOS farm provides no library the payloads do not already" - echo " provide, so there is nothing whose loading proves the farm is" - echo " reachable. Install any library into the SubOS to exercise this." - exit 77 -fi -echo "farm-only library: -l$FARM_LIB (from $FARM_DIR)" - -# ── half 1: it must LINK and RUN ──────────────────────────────────────────── -# -# `--no-as-needed` is what makes this a test of the loader rather than of the -# linker: with no symbol referenced, --as-needed would drop the DT_NEEDED -# entirely and the program would run for a reason that proves nothing. -mkdir -p "$TMP/uses/src" -cd "$TMP/uses" -cat > mcpp.toml < src/main.cpp - -"$MCPP" build > build.log 2>&1 || { - echo "FAIL: linking against a farm-provided library failed" - cat build.log - exit 1 -} -BIN="$(ls target/*/*/bin/uses | head -1)" -python3 - "$BIN" "$FARM_LIB" <<'PY' || exit 1 -import struct, sys -d = open(sys.argv[1], 'rb').read() -phoff, = struct.unpack_from(') and must be reachable at load time too." - exit 1 -} -echo "ran: exit 0 with a farm-only DT_NEEDED" - -# ── half 2: an unsatisfiable DT_NEEDED must turn the build RED ────────────── -# -# Built by giving a real shared library a SONAME that names no file anywhere. -# The link succeeds (ld was handed the file), the DT_NEEDED records the SONAME, -# and nothing on the artifact's search path can ever satisfy it. -mkdir -p "$TMP/ghostlib/src" -cd "$TMP/ghostlib" -cat > mcpp.toml <<'EOF' -[package] -name = "ghostlib" -version = "0.1.0" - -[targets.ghost] -kind = "shared" - -[build] -ldflags = ["-Wl,-soname,libmcpp_ghost_probe.so.1"] -EOF -cat > src/ghost.cppm <<'EOF' -export module ghostlib.ghost; -export int ghost_value() { return 1; } -EOF -"$MCPP" build > build.log 2>&1 || { echo "FAIL: ghost library build"; cat build.log; exit 1; } -GHOST_SO="$(ls target/*/*/bin/libghost.so 2>/dev/null | head -1)" -[[ -n "$GHOST_SO" ]] || { echo "FAIL: no libghost.so produced"; ls -R target | head -30; exit 1; } -# ABSOLUTE. The consumer below is built from a different directory, and a -# relative -L there resolves to nothing — which fails the build for the wrong -# reason and would read exactly like the failure this half is asserting. -GHOST_DIR="$(cd "$(dirname "$GHOST_SO")" && pwd)" - -mkdir -p "$TMP/needsghost/src" -cd "$TMP/needsghost" -cat > mcpp.toml < src/main.cpp - -if "$MCPP" build > build.log 2>&1; then - echo "FAIL: a DT_NEEDED that nothing can provide built successfully" - echo " The artifact cannot start; reporting that as a pass is the" - echo " defect this half exists for." - grep -i "ghost" build.log | head - exit 1 -fi -grep -qi "libmcpp_ghost_probe.so.1" build.log || { - echo "FAIL: the build failed, but not for the unresolvable DT_NEEDED —" - echo " a failure that does not name the cause is not this assertion." - cat build.log - exit 1 -} -echo "unresolvable DT_NEEDED failed the build and named itself" - -echo "PASS: farm-only libraries load, unsatisfiable ones fail the build" diff --git a/tests/e2e/221_subos_without_info_still_builds.sh b/tests/e2e/221_subos_without_info_still_builds.sh deleted file mode 100755 index 9f43221cc..000000000 --- a/tests/e2e/221_subos_without_info_still_builds.sh +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 221_subos_without_info_still_builds.sh — absence degrades, it does not -# invalidate. -# -# THE REGRESSION THIS PINS (openxlings/xlings#543) -# -# A SubOS with no `subos_info` block made `resolve_runtime_binding` return an -# error, and every caller of `mcpp build` / `mcpp test` propagated it. On -# Windows — where xlings does not write that block, and where the facts it -# carries (PT_INTERP, a private libc, GL vendor directories) do not exist at -# all — that meant the tool stopped, with a message about graphics drivers: -# -# error: selected SubOS 'default' cannot provide a RuntimeBinding: … does not -# describe itself … a GL application will not find its drivers -# -# Shipped in 2026.8.10.2; 2026.8.8.4 was fine. The shape is the index-floor -# incident again: DATA THAT IS MISSING OR NEWER MUST NOT INVALIDATE THE PROGRAM -# THAT READS IT. -# -# WHY THIS TEST DECLARES NO CAPABILITIES -# -# On purpose, and it is the whole point. The regression was Windows-only in -# practice, and a `# requires: elf` or `# requires: gcc` line would skip it on -# exactly the platform it exists to protect — the same way `# requires: gcc` -# kept 217 from ever running on Windows or macOS. It needs no ELF, no graphics, -# and no particular toolchain: only that a build completes. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" - -# A named SubOS that exists and says nothing about itself. `[xlings] subos` -# resolves to /.mcpp/.xlings/subos/, so the directory is created -# there. Empty is the extreme form of "no subos_info block", and it is a real -# state: a freshly created SubOS, or any SubOS on a platform whose xlings does -# not write the block. -mkdir -p ".mcpp/.xlings/subos/bare" - -cat > mcpp.toml <<'EOF' -[package] -name = "bare" -version = "0.1.0" - -[xlings] -subos = "bare" -EOF -echo 'int main() { return 0; }' > src/main.cpp - -set +e -out="$("$MCPP" build 2>&1)" -rc=$? -set -e -echo "$out" - -# ── the assertion that IS the regression ──────────────────────────────────── -case "$out" in - *"cannot provide a RuntimeBinding"*) - echo "FAIL: an undeclared SubOS still invalidates the build" - echo " Absence of a declaration leaves some facts unknown; it does" - echo " not make the build wrong. Only a CONTRADICTION (a named" - echo " SubOS that is not there) may fail." - exit 1 - ;; -esac -# A build MAY still fail here, but only for a reason that is about the C -# runtime rather than about the binding — and that distinction is the fix. -# -# An undeclared SubOS names no runtime, so on a platform whose C runtime comes -# from a payload there is nothing to bind to and mcpp declines PayloadFirst -# rather than guessing a version. The hermeticity guard then reports, exactly -# and actionably, that the toolchain resolved its C runtime outside the -# sandbox. That is a different statement from "this SubOS cannot provide a -# RuntimeBinding", and it is one the user can act on. -# -# Anything ELSE failing is not this test passing — it is a new defect wearing -# the old one's clothes. -if [[ $rc -ne 0 ]]; then - case "$out" in - *"hermetic link check failed"*) ;; - *) - echo "FAIL: mcpp build exited $rc for a reason this test does not" - echo " recognise. An undeclared SubOS may cost hermeticity; it" - echo " must not cost anything else." - exit 1 - ;; - esac -fi - -# ── and with the host runtime allowed, it must build everywhere ───────────── -# The green half. Without it the test above could be satisfied by mcpp failing -# for *some* accepted reason on every platform, which is not what "the build is -# unaffected" means. -cat > mcpp.toml <<'EOF' -[package] -name = "bare" -version = "0.1.0" - -[xlings] -subos = "bare" - -[build] -allow_host_libs = true -EOF -"$MCPP" build > allow.log 2>&1 || { - echo "FAIL: even with allow_host_libs, an undeclared SubOS blocks the build" - cat allow.log - exit 1 -} - -# ── and it must SAY so ────────────────────────────────────────────────────── -# A degradation nobody prints is indistinguishable from no degradation, which -# is the property that made mcpp#352 expensive. Passing silently here would -# mean the next person cannot tell "my SubOS is fine" from "my SubOS told mcpp -# nothing and mcpp shrugged". -case "$out" in - *"does not describe itself"*) ;; - *) - echo "FAIL: the build succeeded but never reported the degradation." - echo " 'it did not happen' and 'it succeeded' must not look alike." - exit 1 - ;; -esac - -# ── the contradiction case still fails ────────────────────────────────────── -# The other half of the rule, asserted here so relaxing absence cannot quietly -# relax everything: a SubOS the user NAMED and that does not exist cannot be -# satisfied under any interpretation, and guessing a different one would make -# one mcpp.toml mean different ABIs on different machines. -cat > mcpp.toml <<'EOF' -[package] -name = "bare" -version = "0.1.0" - -[xlings] -subos = "definitely-not-created" -EOF -set +e -out2="$("$MCPP" build 2>&1)" -rc2=$? -set -e -[[ $rc2 -ne 0 ]] || { - echo "FAIL: naming a SubOS that does not exist was accepted" - echo "$out2" - exit 1 -} -case "$out2" in - *"does not exist"*) ;; - *) - echo "FAIL: the missing-SubOS error no longer says what is wrong" - echo "$out2" - exit 1 - ;; -esac - -echo "PASS: an undeclared SubOS degrades and reports it; a missing one still fails" diff --git a/tests/e2e/222_shared_library_cxx_runtime_contract.sh b/tests/e2e/222_shared_library_cxx_runtime_contract.sh deleted file mode 100755 index 24ffb4996..000000000 --- a/tests/e2e/222_shared_library_cxx_runtime_contract.sh +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc elf python3 -# 222_shared_library_cxx_runtime_contract.sh — a shared library must not become -# the executable's C++ runtime. -# -# WHAT WENT WRONG -# -# `LinkUnit::SharedLibrary` shared the `Distributable` role with executables, so -# a .so got the same self-contained contract: `-static-libstdc++`. On ELF that -# is not a private copy. There is ONE global symbol namespace, a shared object -# exports every global it defines, and the embedded libstdc++ went into its -# dynamic symbol table UNVERSIONED — 777 GLOBAL std definitions (plus 2154 weak -# ones) out of a pure-C compat package. `libXau.so` was 9.5MB: 39KB of Xau and -# the rest libstdc++. -# -# The executable then linked `-lX11` BEFORE the driver's `-lstdc++`, so ld -# resolved its own `std::runtime_error::what()` against the .so and never -# pulled the archive member. Its `-static-libstdc++` became a no-op and its C++ -# runtime was, in fact, that .so. Swap the .so for another build of the same -# SONAME — which is exactly what a farm-first DT_RPATH did — and the program -# dies before main: -# -# undefined symbol: _ZNKSt13runtime_error4whatEv -# -# WHY THIS IS A SEPARATE TEST FROM 219 -# -# 219 asserts the SEARCH ORDER. This asserts that a wrong search order can no -# longer be fatal, and that the executable keeps the contract it was promised. -# They fail independently and neither subsumes the other. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# ── ELF readers: no binutils ──────────────────────────────────────────────── -# A sandbox home does not reliably have binutils, and on at least one real -# machine its `readelf` shim pointed into a deleted directory. -cat > "$TMP/elf.py" <<'PY' -import struct, sys - -def load(path): - d = open(path, 'rb').read() - if d[:4] != b'\x7fELF' or d[4] != 2: - raise SystemExit("not an ELF64 file: " + path) - return d - -def sections(d): - # Elf64_Shdr: name(4) type(4) flags(8) addr(8) offset(8) size(8) link(4) … - shoff, = struct.unpack_from('> 4)) - if not found: - raise SystemExit( - "no .dynsym in " + path + " — this object was stripped of section " - "headers, so a symbol count here would be zero for the wrong reason") - return out - -def dynsym_defined(path): return dynsyms(path, True) -def dynsym_undefined(path): return dynsyms(path, False) - -def needed(path): - d = load(path) - phoff, = struct.unpack_from(' mcpp.toml <<'EOF' -[package] -name = "sharedrt" -version = "0.1.0" - -[targets.sharedrtlib] -kind = "shared" - -[targets.sharedrt] -kind = "bin" -main = "src/main.cpp" -EOF -# The library genuinely uses the standard library, and the executable -# genuinely throws — `std::runtime_error::what()` is the exact symbol whose -# disappearance produced the original crash. -cat > src/lib.cppm <<'EOF' -export module sharedrt.lib; -import std; -export std::string sharedrt_greet() { - try { throw std::runtime_error("hello"); } - catch (const std::exception& e) { return std::string(e.what()); } -} -EOF -cat > src/main.cpp <<'EOF' -import std; -import sharedrt.lib; -int main() { return sharedrt_greet() == "hello" ? 0 : 1; } -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } - -BIN="$(ls target/*/*/bin/sharedrt 2>/dev/null | head -1)" -LIB="$(ls target/*/*/bin/libsharedrtlib.so 2>/dev/null | head -1)" -[[ -n "$BIN" ]] || { echo "FAIL: no executable"; ls -R target | head -40; exit 1; } -[[ -n "$LIB" ]] || { echo "FAIL: no shared library"; ls -R target | head -40; exit 1; } - -# ── invariant 1: the shared library exports no libstdc++ RUNTIME symbols ──── -# -# Zero GLOBAL std definitions, not "few". One is enough to satisfy an -# executable's reference and take over its runtime — that is not a matter of -# degree. (Weak/COMDAT template instantiations are excluded and must be: a -# library using std::string emits ~30 of them and unifying those across the -# process is the intended C++ ABI behaviour. See `std-exports` in elf.py.) -EXPORTED="$(elf std-exports "$LIB")" -[[ "$EXPORTED" == "0" ]] || { - echo "FAIL: the shared library exports $EXPORTED GLOBAL standard-library" - echo " symbols. Those come from libstdc++.a and nowhere else, so an" - echo " executable linking this library will bind ITS std references" - echo " here and silently lose its own C++ runtime contract." - exit 1 -} -[[ "$(elf defines "$LIB" "$CRASH_SYM")" == "no" ]] || { - echo "FAIL: the shared library exports $CRASH_SYM — the exact symbol whose" - echo " disappearance killed the artifact this test exists for." - exit 1 -} - -# ── invariant 2: …because it COUPLES to the runtime instead of embedding it ─ -# -# Asserted separately from invariant 1 on purpose. "Exports nothing" would also -# be true of a library that embedded a HIDDEN copy, and that is a different -# artifact with a different failure mode (two std runtimes in one process). -# What the default promises on ELF is one runtime, shared. -elf needed "$LIB" | grep -qx 'libstdc++.so.6' || { - echo "FAIL: the shared library declares no dependency on libstdc++.so.6, so" - echo " it is carrying a private C++ runtime after all. NEEDED was:" - elf needed "$LIB" - exit 1 -} - -# ── invariant 3: the EXECUTABLE keeps its own contract ────────────────────── -# -# This is the payoff, and the exact shape of the original crash: with the .so -# exporting std, the executable's `-static-libstdc++` silently became a no-op -# and it carried an UNDEFINED `_ZNKSt13runtime_error4whatEv` that only the .so -# could satisfy. -UNDEF="$(elf std-undefined "$BIN")" -[[ "$UNDEF" == "0" ]] || { - echo "FAIL: the executable has $UNDEF undefined standard-library symbols." - echo " Its self-contained C++ runtime is being supplied by something else." - exit 1 -} - -"$BIN" || { echo "FAIL: the executable did not run"; exit 1; } - -# ── invariant 4: the escape hatch works AND stays guarded ─────────────────── -# -# `cxx_runtime = { shared = "self-contained" }` is legitimate — a .so that -# ships alone needs it. What must not come back is the export. `--exclude-libs` -# is what keeps the hatch from re-opening the defect. -cat > mcpp.toml <<'EOF' -[package] -name = "sharedrt" -version = "0.1.0" - -[build] -cxx_runtime = { shared = "self-contained" } - -[targets.sharedrtlib] -kind = "shared" - -[targets.sharedrt] -kind = "bin" -main = "src/main.cpp" -EOF -rm -rf target -"$MCPP" build > build2.log 2>&1 || { cat build2.log; exit 1; } -LIB="$(ls target/*/*/bin/libsharedrtlib.so 2>/dev/null | head -1)" -[[ -n "$LIB" ]] || { echo "FAIL: no shared library on the second build"; exit 1; } - -elf needed "$LIB" | grep -qx 'libstdc++.so.6' && { - echo "FAIL: cxx_runtime = { shared = \"self-contained\" } was ignored —" - echo " the library still couples to libstdc++.so.6." - exit 1 -} -EXPORTED="$(elf std-exports "$LIB")" -[[ "$EXPORTED" == "0" ]] || { - echo "FAIL: an explicitly self-contained shared library exported $EXPORTED" - echo " GLOBAL standard-library symbols. --exclude-libs is missing, so" - echo " the escape hatch re-opens the defect it was allowed to work" - echo " around." - exit 1 -} -[[ "$(elf defines "$LIB" "$CRASH_SYM")" == "no" ]] || { - echo "FAIL: the embedded runtime is exported — $CRASH_SYM is visible from" - echo " an explicitly self-contained shared library." - exit 1 -} - -echo "PASS: a shared library couples to the C++ runtime instead of exporting it," -echo " the executable keeps its own contract, and the opt-out stays hidden" diff --git a/tests/e2e/22_doctor_cache_publish.sh b/tests/e2e/22_doctor_cache_publish.sh deleted file mode 100755 index 1e1467e1a..000000000 --- a/tests/e2e/22_doctor_cache_publish.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -# 22_doctor_cache_publish.sh — M4 #1 #2 #4 smoke tests: -# - mcpp doctor runs and reports -# - mcpp cache list / clean -# - mcpp publish --dry-run -# - mcpp --explain E0001 -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -HERE="$(cd "$(dirname "$0")" && pwd)" -cd "$TMP" - -# 0) cache list on a genuinely empty cache: friendly message. -out=$("$MCPP" cache list 2>&1) -[[ "$out" == *"empty"* ]] || { echo "cache list on empty cache: '$out'"; exit 1; } - -# 1) doctor: should always run, exit 0 or 1 (warn ok), never 2 -rc=0 -"$MCPP" self doctor > doctor.log 2>&1 || rc=$? -[[ "$rc" -le 1 ]] || { echo "doctor exit code $rc unexpected"; cat doctor.log; exit 1; } -grep -q 'Checking toolchain' doctor.log || { cat doctor.log; echo "no toolchain check"; exit 1; } -grep -q 'Checking std module' doctor.log || { cat doctor.log; echo "no std check"; exit 1; } -grep -q 'Checking registry' doctor.log || { cat doctor.log; echo "no registry check"; exit 1; } -grep -q 'Checking cache health' doctor.log || { cat doctor.log; echo "no cache check"; exit 1; } -grep -q 'Doctor result' doctor.log || { cat doctor.log; echo "no result line"; exit 1; } - -# 1b) doctor changes nothing (2026.10.17.1): its plan is prepared offline and -# builds nothing, so the cache is still empty after it. -out=$("$MCPP" cache list 2>&1) -[[ "$out" == *"empty"* ]] || { echo "doctor wrote to the build cache: '$out'"; exit 1; } - -[ ! -e "$MCPP_HOME/registry" ] || { echo "doctor bootstrapped the cold home"; exit 1; } - -# Only the build needs installed tools. Keep the doctor's cold-home check -# separate, then share payloads without warming this home's std build cache. -source "$HERE/_inherit_toolchain.sh" - -# 2) after a build, the std module it precompiled must be VISIBLE. std entries -# dominate the cache's size, and `cache list` used to walk only dep entries. -# Hiding them is how 16 GB of duplicated std BMIs went unnoticed, so listing -# them is the fix, not a regression. -( cd "$TMP" && "$MCPP" new stdprobe >/dev/null && cd stdprobe && "$MCPP" build > "$TMP/stdprobe.log" 2>&1 ) \ - || { cat "$TMP/stdprobe.log"; echo "the build that precompiles std failed"; exit 1; } -out=$("$MCPP" cache list 2>&1) -[[ "$out" == *"std"* ]] || { echo "cache list omits the std entry: '$out'"; exit 1; } -# The age column must be plausible: file_time_type's epoch is not the Unix -# epoch, and reading it as if it were printed ages like "74509d ago". -if [[ "$out" =~ ([0-9]+)d\ ago ]] && (( BASH_REMATCH[1] > 3650 )); then - echo "cache list reports an implausible age (clock epoch bug): '$out'" - exit 1 -fi - -# 2b) cache dir must point at the cache the rest of the tool uses. -out=$("$MCPP" cache dir 2>&1) -[[ "$out" == "$MCPP_HOME/build-cache/v1"* ]] || { - echo "cache dir '$out' != '$MCPP_HOME/build-cache/v1'"; exit 1; } - -# 2c) verify must pass on a healthy cache. -"$MCPP" cache verify > /tmp/_v.log 2>&1 || { cat /tmp/_v.log; echo "verify failed"; exit 1; } - -# 3) publish dry-run on a fresh package. Publish uses `git archive` for the -# source tarball, so we git-init + commit first. We also need a non-empty -# [package].repo for the convention-based tarball URL — patch the -# auto-generated mcpp.toml in place. -cd "$TMP" -"$MCPP" new myapp >/dev/null -cd myapp -sed -i.bak 's|^repo[[:space:]]*=.*|repo = "https://github.com/example/myapp"|' mcpp.toml && rm -f mcpp.toml.bak -grep -q '^repo' mcpp.toml || cat >> mcpp.toml <<'EOF' - -[package] -repo = "https://github.com/example/myapp" -EOF - -git init -q -git config user.email "test@local" -git config user.name "test" -git add . -git commit -q -m "init" - -out=$("$MCPP" publish --dry-run 2>&1) -[[ "$out" == *"Packaging myapp"* ]] || { echo "no Packaging line: $out"; exit 1; } -[[ "$out" == *"Tarball"* ]] || { echo "no Tarball status: $out"; exit 1; } -[[ "$out" == *"SHA-256"* ]] || { echo "no SHA-256 status: $out"; exit 1; } -[[ "$out" == *"--- xpkg.lua content ---"* ]] || { echo "missing dry-run header: $out"; exit 1; } -[[ "$out" == *"name = \"myapp\""* ]] || { echo "missing pkg name in lua: $out"; exit 1; } -[[ "$out" == *"--- end ---"* ]] || { echo "missing end marker: $out"; exit 1; } -[[ "$out" == *"Next steps"* ]] || { echo "missing next-steps section: $out"; exit 1; } -[[ -f target/dist/myapp-0.1.0.tar.gz ]] || { echo "tarball not created"; exit 1; } -[[ -f target/dist/myapp.lua ]] || { echo "xpkg.lua not created"; exit 1; } - -# 4) cache prune missing flag -rc=0 -"$MCPP" cache prune > /tmp/_p.log 2>&1 || rc=$? -[[ "$rc" -ne 0 ]] || { echo "prune w/o --older-than should fail"; exit 1; } -grep -q 'older-than' /tmp/_p.log || { cat /tmp/_p.log; echo "no helpful error"; exit 1; } - -# 5) cache clean (idempotent, even on empty) -"$MCPP" cache clean > /tmp/_c.log 2>&1 -grep -q 'Cleaned' /tmp/_c.log || { cat /tmp/_c.log; echo "no clean message"; exit 1; } - -# 6) --explain -out=$("$MCPP" --explain E0001 2>&1) -[[ "$out" == *"E0001"* && "$out" == *"name mismatch"* ]] || { - echo "--explain output: $out"; exit 1; } -rc=0 -"$MCPP" --explain E9999 > /tmp/_x.log 2>&1 || rc=$? -[[ "$rc" -ne 0 ]] || { echo "unknown code should fail"; exit 1; } - -echo "OK" diff --git a/tests/e2e/231_jobs_option.sh b/tests/e2e/231_jobs_option.sh deleted file mode 100755 index 8ffa6b991..000000000 --- a/tests/e2e/231_jobs_option.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bash -# `--jobs N|auto` and the `--` boundary that keeps it from eating a program's flags. -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# -# Two separate contracts, both easy to break without noticing: -# 1. the option is honoured and a bad value is REPORTED, not silently dropped -# (a typo that quietly restores the default is a build mysteriously slower -# than the user asked for); -# 2. `-j` is a common enough flag on other programs that `mcpp run -- -j 4` -# must reach the child untouched. `--jobs` reaches its consumer through the -# MCPP_JOBS side channel, and that pre-scan used to walk the whole argv. -set -e - -# mcpp's e2e runner exports MCPP as the binary under test. Filled in when unset -# so this file can be run BY HAND — otherwise it dies on -# line 45: : command not found -# which names neither the variable nor the fix. (Same treatment as -# 230_bench_harness.sh; the bench harness itself deliberately refuses to guess, -# because there the binary IS the measurement.) -if [ -z "${MCPP:-}" ]; then - _root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" - [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet — run \`mcpp build\` first"; exit 77; } - case "$MCPP" in /*|?:[/\\]*) ;; *) MCPP="$_root/$MCPP" ;; esac - export MCPP -fi - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -cat > mcpp.toml < src/echo.cppm <<'EOF' -module; -#include -export module jobsopt.echo; -export void echo_arg(const char* s) { std::printf("%s\n", s); } -EOF -cat > src/main.cpp <<'EOF' -import jobsopt.echo; -int main(int argc, char** argv) { - for (int i = 1; i < argc; ++i) echo_arg(argv[i]); -} -EOF - -# 1. A numeric value builds. -"$MCPP" build --release --jobs 2 > "$TMP/j2.txt" 2>&1 \ - || { echo "--jobs 2 failed:"; cat "$TMP/j2.txt"; exit 1; } - -# 2. `auto` builds too. Its value depends on the host, so the assertion is that -# it is ACCEPTED — asserting a particular number would encode this machine. -"$MCPP" build --release --jobs auto > "$TMP/jauto.txt" 2>&1 \ - || { echo "--jobs auto failed:"; cat "$TMP/jauto.txt"; exit 1; } -if grep -qi 'invalid job count' "$TMP/jauto.txt"; then - echo "'auto' was rejected as an invalid job count:"; cat "$TMP/jauto.txt"; exit 1 -fi - -# 3. A bad value must WARN and still build (degrading to the backend default). -# Asserted from both sides: a silent drop and a hard failure are both wrong. -"$MCPP" build --release --jobs bogus > "$TMP/jbad.txt" 2>&1 \ - || { echo "a bad --jobs value should warn, not fail the build:"; cat "$TMP/jbad.txt"; exit 1; } -grep -qi 'invalid job count' "$TMP/jbad.txt" \ - || { echo "a bad --jobs value was accepted silently:"; cat "$TMP/jbad.txt"; exit 1; } - -# 4. THE BOUNDARY. Everything after `--` belongs to the program. -"$MCPP" run -- -j bogus > "$TMP/sep.txt" 2>&1 \ - || { echo "run with trailing program args failed:"; cat "$TMP/sep.txt"; exit 1; } -grep -qx -- '-j' "$TMP/sep.txt" || { echo "'-j' did not reach the program:"; cat "$TMP/sep.txt"; exit 1; } -grep -qx -- 'bogus' "$TMP/sep.txt" || { echo "'bogus' did not reach the program:"; cat "$TMP/sep.txt"; exit 1; } -# ...and mcpp must not have interpreted it as its own concurrency setting. -if grep -qi 'invalid job count' "$TMP/sep.txt"; then - echo "mcpp consumed a flag that belonged to the program:"; cat "$TMP/sep.txt"; exit 1 -fi - -echo "jobs option OK" - -# 10. `--toolchain` selects a toolchain for ONE build, without touching the -# manifest. This is the usable form of "which compiler": on mcpp itself the -# choice is worth 2.5x (gcc 81.8s vs llvm 32.6s), but changing the DEFAULT -# would invalidate every published package's fingerprint, so per-build -# selection is the part that costs nobody anything. -# -# Asserted by OBSERVING THE RESOLUTION, not by timing: a timing assertion on -# CI measures the runner's mood. -# The spec comes from THIS platform's own resolution, not a hard-coded -# `gcc@16.1.0`: the fixture pins llvm on macOS and Windows, and asserting a -# compiler that is not installed there tests the payload index, not the flag. -own=$("$MCPP" build --release 2>&1 | sed -n 's/.*Resolved \([^ ]*\) .*/\1/p' | head -1) -[ -n "$own" ] || { echo "could not learn this platform's toolchain"; exit 1; } -out=$("$MCPP" build --release --toolchain "$own" 2>&1) \ - || { echo "--toolchain $own failed:"; echo "$out"; exit 1; } -echo "$out" | grep -q "Resolved $own" \ - || { echo "--toolchain did not reach toolchain resolution:"; echo "$out"; exit 1; } - -# ...and it must BEAT the manifest, or it is not an override. The fixture pins -# gcc on Linux, so asking for something else has to change what gets resolved. -if [ "$(uname -s)" = "Linux" ]; then - out=$("$MCPP" build --release --toolchain llvm@${LLVM_VERSION} 2>&1) || true - echo "$out" | grep -q "Resolved llvm@${LLVM_VERSION}" \ - || { echo "--toolchain lost to the manifest pin:"; echo "$out"; exit 1; } -fi - -echo "toolchain override OK" - -# 11. The split module schedule (L2). Default is OFF; `on` selects it. Asserted -# on the GRAPH's own declaration and on the edges it emits — not on timing, -# which on CI measures the runner's mood. -"$MCPP" build --release > /dev/null 2>&1 -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -grep -q 'schedule=none' "$ninja_file" \ - || { echo "default should not use the split schedule:"; head -2 "$ninja_file"; exit 1; } - -# The MANIFEST KEY is asserted as well as the environment override. They are -# two spellings of one switch and only the env one was ever exercised, so -# `[build] bmi_schedule` could be renamed, mistyped or dropped entirely and -# every test would still pass — the key would just silently stop working and -# the build would quietly use the default. (It was called `schedule` until -# it was renamed to agree with `MCPP_BMI_SCHEDULE`; this is what makes the -# next such rename fail loudly.) -rm -rf target -cp mcpp.toml "$TMP/mcpp.toml.bak" -printf '\n[build]\nbmi_schedule = "on"\n' >> mcpp.toml -"$MCPP" build --release > "$TMP/sched-manifest.txt" 2>&1 \ - || { echo "bmi_schedule = \"on\" failed to build:"; cat "$TMP/sched-manifest.txt"; exit 1; } -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -grep -qE 'schedule=(detach-codegen|two-phase)' "$ninja_file" \ - || { echo "[build] bmi_schedule = \"on\" did not select a split schedule:" - head -2 "$ninja_file"; exit 1; } -cp "$TMP/mcpp.toml.bak" mcpp.toml -echo "manifest key bmi_schedule OK" - -rm -rf target -MCPP_BMI_SCHEDULE=on "$MCPP" build --release > "$TMP/sched.txt" 2>&1 \ - || { echo "schedule=on failed to build:"; cat "$TMP/sched.txt"; exit 1; } -ninja_file=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -grep -q 'schedule=detach-codegen\|schedule=two-phase\|schedule=none' "$ninja_file" \ - || { echo "graph does not declare its schedule:"; head -2 "$ninja_file"; exit 1; } - -# Both split shapes must declare their edges. Which one appears is a property of -# the compiler, not of this test, so the assertion is "the shape the graph says -# it has is the shape it emitted" — an empty split (rules present, zero edges) -# has happened twice and looks exactly like a working build. -if grep -q 'schedule=detach-codegen' "$ninja_file"; then - grep -q ': cxx_module_bmi ' "$ninja_file" \ - || { echo "graph declares detach-codegen but emits no BMI edge"; exit 1; } -elif grep -q 'schedule=two-phase' "$ninja_file"; then - grep -q ': cxx_precompile ' "$ninja_file" \ - || { echo "graph declares two-phase but emits no BMI edge"; exit 1; } - grep -q ': cxx_module_object ' "$ninja_file" \ - || { echo "graph declares two-phase but emits no object edge"; exit 1; } -fi - -# The no-op check is GATED on the split shape actually being in effect. It -# exists to catch one specific defect — a dyndep/depfile record that does not -# match the edge's output, which looks exactly like success while recompiling -# everything — and that defect only exists where split edges do. -# The reference mark is taken AFTER the first build, not from its stdout -# redirect: that file's mtime is when the shell opened it, which is before the -# objects exist, so every object counted as "newer" and the comparison measured -# nothing but timestamp ordering. -if grep -qE 'schedule=(detach-codegen|two-phase)' "$ninja_file"; then - sleep 1 - touch "$TMP/mark" - MCPP_BMI_SCHEDULE=on "$MCPP" build --release > /dev/null 2>&1 - rebuilt=$(find target \( -name '*.o' -o -name '*.pcm' -o -name '*.gcm' \) -newer "$TMP/mark") - # Name them. "rebuilt 1 artifact(s)" cost a CI round trip to turn into - # "which one" — and the answer (a generated .c rewritten on every drive, so - # macOS-only) was not guessable from the count. - [ -z "$rebuilt" ] \ - || { echo "second build under the split schedule rebuilt:"; echo "$rebuilt"; exit 1; } -fi - -echo "split schedule OK" diff --git a/tests/e2e/234_bmi_schedule_on.sh b/tests/e2e/234_bmi_schedule_on.sh deleted file mode 100755 index c4dd79bb7..000000000 --- a/tests/e2e/234_bmi_schedule_on.sh +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env bash -# `bmi_schedule = "on"` end to end, and the token leak that used to hang it. -# resources: machine -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# -# The split schedule reorders the module graph: a BMI edge that exits as soon as -# the compiler has published its BMI, plus a join edge that waits for code -# generation. Until now the only coverage was a unit test of `decide()` — which -# checks the TABLE, not the mechanism. Nothing built a project with the feature -# on, so every hazard in detach_codegen.cppm was guarded by review alone. -# -# Two contracts here, and the second is the one that bit: -# -# 1. A build with the feature on produces a working binary, and the graph says -# it is split. If the strategy silently fell back, the timings would look -# like a regression in the feature rather than like the feature being off. -# -# 2. STALE CONCURRENCY TOKENS MUST BE RECLAIMED. Real compiler concurrency is -# bounded by a semaphore of directories under `/.mcpp-sched`, and a -# token is removed by the supervisor holding it. A supervisor that never runs -# its cleanup — Ctrl-C on the build, the OOM killer, a reboot — leaves its -# directory behind, and nothing used to remove it. Each such event lowered -# the cap for that build directory PERMANENTLY, and after `cap` of them the -# next build waited for a token that could never be released: no output, no -# diagnostic, forever. Interrupting a build is an ordinary thing to do. -# -# This test plants a full set of stale tokens and requires the next build to -# finish anyway. -set -e - -_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [ -z "${MCPP:-}" ]; then - MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" - [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet — run \`mcpp build\` first"; exit 77; } -fi -# ABSOLUTISE WHATEVER WE GOT, not just what we derived. This test `cd`s into a -# temp directory, so a RELATIVE MCPP — which is what `ls -t target/*/*/bin/mcpp` -# hands you, and what a caller naturally exports — stops resolving the moment we -# leave the repository, with -# line 77: target/.../bin/mcpp: No such file or directory -# reported as "the build with bmi_schedule=on failed". The normalisation used to -# live inside the `if`, so it ran only when the test found the binary itself. -case "$MCPP" in /*|?:[/\\]*) ;; *) MCPP="$_root/$MCPP" ;; esac -[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } -export MCPP - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# PIN PER PLATFORM, not just `default`. A bare `default = "gcc@16.1.0"` sends -# macOS and Windows looking for a gcc payload that does not exist for them, and -# the whole test dies at step 1 with `'xim:gcc@16.1.0' not in current index` — -# reported as "the build with bmi_schedule=on failed", which is a completely -# different diagnosis. Same three-line block every other e2e in this directory -# uses. -cat > mcpp.toml < src/core.cppm <<'EOF' -export module schedon.core; -import std; -export int core_value() { return 7; } -EOF -cat > src/mid.cppm <<'EOF' -export module schedon.mid; -import std; -import schedon.core; -export int mid_value() { return core_value() + 1; } -EOF -cat > src/main.cpp <<'EOF' -import std; -import schedon.mid; -int main() { std::println("{}", mid_value()); return mid_value() == 8 ? 0 : 1; } -EOF - -# detach-codegen is the GCC strategy. On a machine whose default toolchain -# resolves to clang the decision is TwoPhase and there is no semaphore at all, -# so the second contract has nothing to test — say so rather than pass hollowly. -say_skip_reason() { echo "SKIP: $1"; exit 77; } - -echo "== 1. builds with bmi_schedule = \"on\" ==" -"$MCPP" build --release --verbose > build.log 2>&1 || { - echo "FAIL: build with bmi_schedule=on failed"; tail -30 build.log; exit 1; -} - -grep -q 'schedule:' build.log || { - echo "FAIL: --verbose printed no schedule line — the decision is not being reported" - tail -20 build.log; exit 1 -} -sched_line=$(grep -m1 'schedule:' build.log) -echo " $sched_line" - -case "$sched_line" in - *detach-codegen*) strategy=detach ;; - *two-phase*) strategy=twophase ;; - *) say_skip_reason "the schedule resolved to '${sched_line#*schedule: }' on this host; \ -this test covers the split strategies" ;; -esac - -BIN=$(ls -t target/*/*/bin/schedon 2>/dev/null | head -1) -[ -n "$BIN" ] || { echo "FAIL: no binary produced"; exit 1; } -out=$("$BIN") -[ "$out" = "8" ] || { echo "FAIL: binary printed '$out', expected 8"; exit 1; } -echo " built and ran: $out" - -BUILD_DIR=$(dirname "$(dirname "$BIN")") - -echo "== 2. the graph declares its shape ==" -# HAZARD 4 in detach_codegen.cppm: build.ninja is shared mutable state and the -# fast path replays it, so a split graph must say so about itself. Asserting the -# tag exists is what keeps a later fast-path change from replaying a split graph -# as if it were an ordinary one. -grep -q '^# mcpp:graph=' "$BUILD_DIR/build.ninja" || { - echo "FAIL: build.ninja carries no '# mcpp:graph=' line"; exit 1; } -echo " $(grep -m1 '^# mcpp:graph=' "$BUILD_DIR/build.ninja")" - -if [ "$strategy" != detach ]; then - echo "== 3. skipped: no semaphore under the $strategy strategy ==" - echo "234 bmi_schedule OK ($strategy)" - exit 0 -fi - -echo "== 3. an incremental build still works ==" -touch src/core.cppm -"$MCPP" build --release > inc.log 2>&1 || { - echo "FAIL: incremental build after touching an interface failed"; tail -30 inc.log; exit 1; } -echo " ok" - -echo "== 4. a full set of STALE TOKENS does not hang the next build ==" -SEM="$BUILD_DIR/.mcpp-sched" -mkdir -p "$SEM" -# Every slot taken, by nothing. Before the reclaim this made `acquire_token` -# wait forever on the first module edge. -for i in $(seq 0 63); do mkdir -p "$SEM/$i"; done -planted=$(ls "$SEM" | wc -l) -[ "$planted" -gt 0 ] || { echo "FAIL: could not plant tokens"; exit 1; } -echo " planted $planted stale tokens" - -# Force real work, so the build must actually take a token. -printf '\nexport int core_extra() { return 1; }\n' >> src/core.cppm - -# A generous bound that is still far below the in-process 2h fallback: if the -# reclaim is gone this hangs, and the timeout is what turns that into a failure -# a CI log can explain. -# CAPTURE THE STATUS BEFORE ANY TEST TOUCHES IT. Inside `if ! cmd; then`, -# `$?` is the status of `! cmd` — which is 0 exactly when cmd FAILED — so a real -# failure got reported as "exited 0" and the timeout case could never be -# distinguished from any other. Same shape as reading `$?` after `cmd | tail`. -# -# `set -e` has to come off for exactly this command: with errexit on, a failing -# build aborts the script before `rc=$?` runs, which is what pushed the previous -# version into the `if !` form that then could not read the status at all. -set +e -timeout 600 "$MCPP" build --release > stale.log 2>&1 -rc=$? -set -e -if [ "$rc" != 0 ]; then - if [ "$rc" = 124 ]; then - echo "FAIL: the build HUNG with a full set of stale tokens in $SEM" - echo " (the per-build reclaim in prepare.cppm is not running)" - else - echo "FAIL: build after planting stale tokens exited $rc" - fi - tail -30 stale.log - exit 1 -fi -echo " build completed with stale tokens present" - -left=$(ls "$SEM" 2>/dev/null | wc -l) -[ "$left" -lt "$planted" ] || { - echo "FAIL: $left of $planted stale tokens survived — they were not reclaimed," - echo " so the build only succeeded by not needing a slot" - exit 1; } -echo " reclaimed: $planted -> $left" - -BIN=$(ls -t target/*/*/bin/schedon 2>/dev/null | head -1) -out=$("$BIN") -[ "$out" = "8" ] || { echo "FAIL: binary printed '$out' after the stale-token build"; exit 1; } - -echo "== 5. a BMI removal is not publication ==" -# GCC unlinks the previous .gcm immediately before renaming .gcm~ into place. -# Amplify only that interval: a polling loop must not release an importer -# while the module is absent. This deterministic publisher exercises the same -# supervisor and two-phase join without depending on a microsecond race. -mkdir publication -cd publication -printf 'old BMI' > published.gcm -cat > publish.sh <<'EOF' -rm published.gcm -sleep 0.2 -printf 'new BMI' > next.gcm -mv next.gcm published.gcm -sleep 0.05 -printf object > unit.o -EOF -printf '"%s" publish.sh\n' "$BASH" > command.txt -"$MCPP" bmi-compile --bmi published.gcm --slot unit --self "$MCPP" \ - --command-file command.txt -published=0 -[ ! -f published.gcm ] || published=1 -# Join even when the assertion will fail, so no publisher survives cleanup. -"$MCPP" bmi-await --slot unit --object unit.o -[ "$published" = 1 ] || { - echo "FAIL: phase 1 released an importer while the BMI was absent"; exit 1; } -[ "$(cat published.gcm)" = "new BMI" ] || { - echo "FAIL: phase 1 did not wait for the new BMI"; exit 1; } -echo " absence withheld; new BMI published and object joined" - -printf '"%s" -c "rm published.gcm"\n' "$BASH" > command.txt -if "$MCPP" bmi-compile --bmi published.gcm --slot unit --self "$MCPP" \ - --command-file command.txt > missing.log 2>&1; then - echo "FAIL: a successful compiler without a BMI was accepted"; exit 1 -fi -grep -q 'compiler reported success but.*is missing' missing.log || { - echo "FAIL: the missing BMI was not diagnosed"; cat missing.log; exit 1; } -[ "$(cat published.gcm)" = "new BMI" ] || { - echo "FAIL: the previous BMI was not restored after missing publication"; exit 1; } -echo " missing publication diagnosed and previous BMI restored" - -echo "== 6. ordinary and detached compilers share the actual cap ==" -python3 "$_root/tests/e2e/helpers/check_shared_compiler_cap.py" "$MCPP" "$TMP/cap-load" - -echo "== 7. runtime generations preserve normal and quiet cached replay ==" -python3 - "$MCPP" "$TMP" "$TMP/$BUILD_DIR" <<'PY_REPLAY' -from pathlib import Path -import hashlib -import subprocess -import sys -mcpp, root, output = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3]) -graph = output / "build.ninja" -graph_before = (graph.stat().st_mtime_ns, graph.read_bytes()) -def identity(): - return {str(path.relative_to(output)): (path.stat().st_mtime_ns, - hashlib.sha256(path.read_bytes()).hexdigest()) - for pattern in ("obj/**/*.o", "gcm.cache/**/*.gcm") - for path in output.glob(pattern)} -before = identity() -assert before, "no compiler outputs to verify" -for flags in ([], ["--quiet"]): - result = subprocess.run([mcpp, "build", *flags], cwd=root, - capture_output=True, text=True, timeout=60) - assert result.returncode == 0, result.stdout + result.stderr - assert identity() == before, "cached replay recompiled unchanged inputs" - assert (graph.stat().st_mtime_ns, graph.read_bytes()) == graph_before, "cached replay rewrote its graph" -assert b"MCPP_COMPILER_RUN" not in graph_before[1] -assert b".mcpp/compiler-runs" not in graph_before[1] -print("PASS: normal/quiet cached replay retains graph, objects and BMIs without persisting generations") -PY_REPLAY - - -echo "== 8. missing cached objects recover before Ninja admission ==" -python3 "$_root/tests/e2e/helpers/check_cached_object_recovery.py" "$MCPP" "$TMP" "$TMP/$BUILD_DIR" - -echo "== 9. final status is reread after controller exit ==" -python3 "$_root/tests/e2e/helpers/check_completion_observation.py" "$MCPP" "$TMP/completion-observation" - -echo "234 bmi_schedule OK (detach-codegen, tokens reclaimed, publication checked)" diff --git a/tests/e2e/235_std_object_only_when_needed.sh b/tests/e2e/235_std_object_only_when_needed.sh deleted file mode 100755 index 21972c022..000000000 --- a/tests/e2e/235_std_object_only_when_needed.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# `obj/std.o` is linked into a unit only when that unit actually needs it (#416). -# -# `std.o` holds the `std` module's global initialiser (exactly one symbol, -# `_ZGIW3std` — measured). It used to be appended to EVERY Binary, TestBinary -# and SharedLibrary whenever the toolchain merely HAD a prebuilt std module, -# regardless of whether anything in that unit imported it. -# -# THE TEST THAT MATTERS IS THE TRANSITIVE ONE. A unit that never writes -# `import std` itself still needs the initialiser when a module it imports does. -# Checking only "does this unit's own source say import std" is the same -# "the edge exists but nobody depends on it" mistake as #405, and it fails in -# the direction that breaks builds. Both directions are asserted here: -# -# 1. a project with NO std anywhere → std.o must NOT be linked -# 2. a project that reaches std INDIRECTLY → std.o must be linked, and the -# binary must actually run -# -# The failure mode of getting (2) wrong is loud (undefined `_ZGIW3std` at link -# time), which is why (1) is the one that could regress quietly. -set -e - -_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [ -z "${MCPP:-}" ]; then - MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" - [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet"; exit 77; } -fi -case "$MCPP" in /*) ;; *) MCPP="$_root/$MCPP" ;; esac -[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } -export MCPP - -TMP=$(mktemp -d) -trap "rm -rf $TMP || true" EXIT - -toolchain_block() { - printf "[toolchain]\ndefault = \"gcc@16.1.0\"\nmacos = \"llvm@${LLVM_VERSION}\"\nwindows = \"llvm@${LLVM_VERSION}\"\n" -} - -# ── 1. nothing imports std anywhere ──────────────────────────────────────── -mkdir -p "$TMP/nostd/src" -{ printf '[package]\nname = "nostd"\nversion = "0.1.0"\n'; toolchain_block; } > "$TMP/nostd/mcpp.toml" -cat > "$TMP/nostd/src/x.cppm" <<'EOF' -export module nostd.x; -export int v() { return 3; } -EOF -cat > "$TMP/nostd/src/main.cpp" <<'EOF' -import nostd.x; -int main() { return v() - 3; } -EOF - -( cd "$TMP/nostd" && "$MCPP" build --release ) > "$TMP/nostd.log" 2>&1 || { - echo "FAIL: the no-std project did not build"; tail -30 "$TMP/nostd.log"; exit 1; } - -NINJA="$(find "$TMP/nostd/target" -name build.ninja | head -1)" -[ -n "$NINJA" ] || { echo "FAIL: no build.ninja produced"; exit 1; } -if grep -q 'obj/std\.o' "$NINJA"; then - echo "FAIL: std.o is referenced by a project that imports std nowhere." - echo " That object carries the std module's global initialiser; linking" - echo " it into a unit with no std at all is what made a pure-C compat" - echo " package carry one (#416)." - grep -n 'obj/std\.o' "$NINJA" | head -5 - exit 1 -fi -echo " no-std project: std.o absent from the graph" - -# ── 2. std is reached INDIRECTLY, through one module in between ───────────── -mkdir -p "$TMP/trans/src" -{ printf '[package]\nname = "trans"\nversion = "0.1.0"\n'; toolchain_block; } > "$TMP/trans/mcpp.toml" -# a imports std; b imports a; main imports b. Nothing but `a` mentions std. -cat > "$TMP/trans/src/a.cppm" <<'EOF' -export module trans.a; -import std; -export std::string greet() { return "hi"; } -EOF -cat > "$TMP/trans/src/b.cppm" <<'EOF' -export module trans.b; -import trans.a; -export int n() { return static_cast(greet().size()); } -EOF -cat > "$TMP/trans/src/main.cpp" <<'EOF' -import trans.b; -int main() { return n() - 2; } -EOF - -( cd "$TMP/trans" && "$MCPP" build --release ) > "$TMP/trans.log" 2>&1 || { - echo "FAIL: the transitive project did not build." - echo " An undefined \`_ZGIW3std\` here means the reachability walk did" - echo " not follow the import edge a <- b <- main." - tail -30 "$TMP/trans.log"; exit 1; } - -NINJA="$(find "$TMP/trans/target" -name build.ninja | head -1)" -grep -q 'obj/std\.o' "$NINJA" || { - echo "FAIL: std.o is NOT in the graph, but this project reaches std through" - echo " trans.b -> trans.a -> std. The predicate is not transitive." - exit 1; } -echo " transitive project: std.o present" - -# Running it is what proves the initialiser is not merely referenced but works: -# `greet()` returns a std::string, so a std module that never ran its global -# initialiser is a crash rather than a link error. -BIN="$(find "$TMP/trans/target" -path '*/bin/trans' -type f | head -1)" -[ -n "$BIN" ] || { echo "FAIL: no binary produced by the transitive project"; exit 1; } -"$BIN" || { echo "FAIL: the transitive binary exited $?"; exit 1; } -echo " transitive project: binary runs" - -echo "235 std.o linked only when needed OK" diff --git a/tests/e2e/236_module_extensions_default_toolchain.sh b/tests/e2e/236_module_extensions_default_toolchain.sh deleted file mode 100755 index ad7f24312..000000000 --- a/tests/e2e/236_module_extensions_default_toolchain.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# `[build] module_extensions` on the platform's DEFAULT toolchain (#412). -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# -# WHY THIS EXISTS SEPARATELY FROM 217. `217_module_extensions.sh` declares -# `# requires: gcc`, and `run_all.sh` deliberately does not grant `gcc` on -# Windows or macOS: -# -# # Windows runners may have g++.exe (MinGW/Strawberry) in PATH but it's -# # not a proper mcpp-compatible GCC. Don't add gcc capability. -# # macOS g++ is Apple Clang, not real GCC — don't add gcc capability. -# -# So `module_extensions` has only ever been exercised on Linux. The specific -# combination "declare `.ixx` and build it with MSVC" — the one a user porting -# an MSVC project actually takes — has never run anywhere. It follows by -# reasoning (`.ixx` is cl's own convention and mcpp emits `/interface /TP` -# unconditionally), but reasoning is not measurement: this is the same shape as -# mcpp#272, where `# requires: elf gcc` meant the Clang and MSVC legs were never -# reached at all. -# -# This test grants itself no capability. It uses whatever toolchain the platform -# defaults to — MSVC on Windows, LLVM on macOS, GCC on Linux — so it runs -# EVERYWHERE and covers the two legs 217 cannot reach. -set -e - -_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [ -z "${MCPP:-}" ]; then - MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" - [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet"; exit 77; } -fi -case "$MCPP" in /*|?:[/\\]*) ;; *) MCPP="$_root/$MCPP" ;; esac -[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } -export MCPP - -TMP=$(mktemp -d) -trap "rm -rf $TMP || true" EXIT -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" - -# `.ixx` is the extension this is about: cl's own spelling, and the one an MSVC -# project arrives with. The toolchain block gives each platform its default — -# the point is that no leg is skipped, not that they all use the same compiler. -cat > mcpp.toml < src/greet.ixx <<'EOF' -export module extdefault.greet; -import std; -export auto greet() -> std::string { return "ixx-ok"; } -EOF - -cat > src/main.cpp <<'EOF' -import std; -import extdefault.greet; -int main() { std::println("{}", greet()); return greet() == "ixx-ok" ? 0 : 1; } -EOF - -echo "== build ==" -"$MCPP" build --release > build.log 2>&1 || { - echo "FAIL: build with module_extensions = [\".ixx\"] failed on this platform's" - echo " default toolchain. This is the leg 217 cannot reach." - tail -40 build.log - exit 1 -} - -# A BUILD THAT EXITS 0 IS NOT ENOUGH, and this is the exact trap the -# extension work was full of: Clang hands an unrecognised `.ixx` to the LINKER, -# warns, and exits 0 having produced NO BMI. So assert the interface really was -# compiled as one — a BMI exists for the module. -BMI="$(find target -type f \( -name '*.gcm' -o -name '*.pcm' -o -name '*.ifc' \) 2>/dev/null \ - | grep -iE 'greet' | head -1)" -[ -n "$BMI" ] || { - echo "FAIL: no BMI produced for the .ixx interface — it was not treated as a" - echo " module interface unit at all. A zero exit code does not prove" - echo " compilation here: an unrecognised extension is a LINKER input." - find target -type f \( -name '*.gcm' -o -name '*.pcm' -o -name '*.ifc' \) | head -10 - exit 1 -} -echo " BMI: $BMI" - -echo "== run ==" -BIN="$(find target -type f -path '*/bin/*' \( -name 'extdefault' -o -name 'extdefault.exe' \) | head -1)" -[ -n "$BIN" ] || { echo "FAIL: no binary produced"; find target -path '*/bin/*' -type f | head; exit 1; } -OUT="$("$BIN")" || { echo "FAIL: the binary exited $?"; exit 1; } -[ "$OUT" = "ixx-ok" ] || { echo "FAIL: binary printed '$OUT', expected 'ixx-ok'"; exit 1; } -echo " ran: $OUT" - -echo "236 module_extensions on the default toolchain OK" diff --git a/tests/e2e/237_default_subos_without_info.sh b/tests/e2e/237_default_subos_without_info.sh deleted file mode 100755 index a3a052a6b..000000000 --- a/tests/e2e/237_default_subos_without_info.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# A DEFAULT SubOS that predates `subos_info` must not stop the build (#427). -# -# THE REGRESSION THIS PINS -# -# `ensure_post_install_fixup` needs a C runtime to bind a freshly installed -# toolchain payload to. It took that identity from the caller's RuntimeBinding -# and, when the caller had none, read `/subos/default` itself and -# turned a missing description into a hard error: -# -# error: toolchain post-install fixup: cannot fix up gcc toolchain '…': -# default SubOS has no RuntimeBinding identity (… no `subos_info` block) -# -# Shipped in 2026.8.10.x; 2026.8.8.4 was fine. Measured trigger: a SubOS whose -# `.xlings.json` is `{"workspace":{}}` — 16 bytes, written by an xlings older -# than the block. mcpp's own message says as much ("A newer xlings writes this -# block"), i.e. it KNEW this was a version difference and failed anyway. -# -# It is the index-floor rule and mcpp#221 again: DATA THAT IS MISSING OR NEWER -# MUST NOT INVALIDATE THE PROGRAM THAT READS IT. -# -# WHY `221_subos_without_info_still_builds.sh` DOES NOT COVER THIS. -# -# 221 creates a PROJECT-LOCAL SubOS (`[xlings] subos = "bare"` → -# `/.mcpp/.xlings/subos/bare`). The fixup read a HARDCODED -# `/subos/default`. The two never intersect, so 221's empty SubOS -# reached none of this code, and on any machine whose real `default` describes -# itself the gate simply passed. A test for "absence degrades" has to put the -# absence on THE OBJECT THAT IS READ. -# -# WHAT THIS FILE CAN AND CANNOT REACH -# -# The gate returns early for a payload that resolves outside the caller's -# registry ("inherited payload, owner is responsible for its fixup"), and this -# test inherits its toolchain by symlink so it costs no download. So the gate's -# own branches — degrade vs. contradiction, marker vs. no marker — are pinned in -# tests/unit/test_post_install.cpp, where the payload is a real directory inside -# the test's registry. What is asserted HERE is the user-visible contract: the -# build no longer dies on the fixup, and `allow_host_libs` reaches the link. -set -e - -_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [ -z "${MCPP:-}" ]; then - MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" - [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet"; exit 77; } -fi -case "$MCPP" in /*) ;; *) MCPP="$_root/$MCPP" ;; esac -[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } - -TMP=$(mktemp -d) -trap "chmod -R u+w $TMP 2>/dev/null; rm -rf $TMP || true" EXIT - -# A HOME of our own. The toolchain payload is inherited from the real one by -# symlink so this test costs no download — the fixup's containment guard skips -# patching an inherited payload anyway, which is exactly the "no patching -# happens" state this test is about. -export MCPP_HOME="$TMP/home" -REAL_HOME="${MCPP_HOME_REAL:-$HOME/.mcpp}" -mkdir -p "$MCPP_HOME/registry/data" "$MCPP_HOME/registry/subos/default" -if [ -d "$REAL_HOME/registry/data/xpkgs" ]; then - ln -s "$REAL_HOME/registry/data/xpkgs" "$MCPP_HOME/registry/data/xpkgs" -else - echo "SKIP: no xpkgs payloads to inherit from $REAL_HOME"; exit 77 -fi -for d in "$REAL_HOME/registry"/*; do - b="$(basename "$d")" - case "$b" in data|subos) continue ;; esac - ln -s "$d" "$MCPP_HOME/registry/$b" 2>/dev/null || true -done - -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" -cat > mcpp.toml < src/main.cpp - -# ── 1. absence: the exact 16 bytes an old xlings leaves behind ────────────── -printf '{"workspace":{}}' > "$MCPP_HOME/registry/subos/default/.xlings.json" - -set +e -out="$("$MCPP" build 2>&1)" -rc=$? -set -e -echo "$out" | sed 's/^/ /' - -case "$out" in - *"has no RuntimeBinding identity"*) - echo "FAIL: an undescribed DEFAULT SubOS still kills the build." - echo " Absence leaves some facts unknown; it does not make the" - echo " build wrong. The fixup degrades — it must not decide." - exit 1 ;; -esac -# The build MAY still stop here, but only for a reason that is about the C -# RUNTIME rather than about the toolchain fixup — and that distinction is the -# whole fix. With no declared runtime there is nothing to bind to, mcpp -# declines to guess a glibc version, and the hermeticity guard says so in terms -# the user can act on. Same accepted outcome as 221, and its wording is checked -# here so a future failure cannot inherit this test's blessing. -if [ "$rc" != 0 ]; then - case "$out" in - *"hermetic link check failed"*) ;; - *) - echo "FAIL: mcpp build exited $rc for a reason this test does not" - echo " recognise. An undescribed SubOS may cost hermeticity;" - echo " it must not cost anything else." - exit 1 ;; - esac -fi -echo " absence: no fixup error (exit $rc)" - -# ── 2. THE CONTROL: describe the same SubOS, change nothing else ─────────── -# The green half. Without it, part 1 could be satisfied by mcpp failing for some -# accepted reason on every platform, which is not what "the build is unaffected" -# means. This is also what proves the degradation in part 1 is a degradation and -# not a broken toolchain: same home, same payloads, same project — one JSON -# block is the entire difference. -# -# NOT `allow_host_libs = true`. That was the first version and it FAILED ON -# CI while passing here: falling back to the host needs a host C runtime, and -# the runners have no `crt1.o` / `libm` to fall back TO. The green half must not -# depend on a fact about the machine — binding to the payload glibc does not. -# ASK THE REAL HOME WHICH GLIBC IT USES, do not take the first directory. -# A machine with two glibc payloads installed has one the toolchain was patched -# against and one it was not, and `ls | head -1` picks by alphabetical order — -# which would make this control fail for a reason that has nothing to do with -# what it is controlling for. -GLIBC_DIR="$MCPP_HOME/registry/data/xpkgs/xim-x-glibc" -GLIBC_VER="$(python3 - "$REAL_HOME/registry/subos/default/.xlings.json" <<'PY' -import json, sys -try: - rt = json.load(open(sys.argv[1], encoding="utf-8"))["subos_info"]["runtime"] - print(rt.split("@", 1)[1] if "@" in rt else "") -except Exception: - print("") -PY -)" -[ -n "$GLIBC_VER" ] && [ -d "$GLIBC_DIR/$GLIBC_VER" ] \ - || GLIBC_VER="$(ls "$GLIBC_DIR" 2>/dev/null | head -1)" -[ -n "$GLIBC_VER" ] || { echo "SKIP: no glibc payload to bind to"; exit 77; } -python3 - "$MCPP_HOME/registry/subos/default/.xlings.json" "$GLIBC_VER" <<'PY' -import json, sys -path, ver = sys.argv[1], sys.argv[2] -json.dump({"workspace": {}, - "subos_info": {"schema_version": 1, "runtime": "glibc@" + ver, - "host_glibc": ver, "envs": {}}}, - open(path, "w", encoding="utf-8")) -PY -rm -rf target -"$MCPP" build > described.log 2>&1 || { - echo "FAIL: the SAME home builds nothing even once the SubOS describes" - echo " itself (runtime glibc@$GLIBC_VER), so part 1 above proved" - echo " nothing — this environment cannot build either way." - tail -20 described.log | sed 's/^/ /' - exit 1; } -echo " described (glibc@$GLIBC_VER): builds" - -# ...and restore the absent state for part 3, so the install path is exercised -# against the condition this test is about. -printf '{"workspace":{}}' > "$MCPP_HOME/registry/subos/default/.xlings.json" - -# ── 3. `mcpp toolchain install` survives it too ───────────────────────────── -# The path the fix could most easily have BROKEN: it carries no RuntimeBinding -# of its own, so removing the fallback without also giving it a resolver would -# have made it skip the fixup forever — a silently incomplete install in place -# of a loud failure. See tests/unit/test_post_install.cpp for the gate itself. -set +e -iout="$("$MCPP" toolchain install gcc@16.1.0 2>&1)" -irc=$? -set -e -[ "$irc" = 0 ] || { - echo "FAIL: \`mcpp toolchain install\` exited $irc with an undescribed SubOS" - echo "$iout" | sed 's/^/ /' - exit 1; } -case "$iout" in - *"has no RuntimeBinding identity"*) - echo "FAIL: the install path still turns an absent description into an error" - exit 1 ;; -esac -echo " toolchain install: exit 0" - -echo "237 an undescribed default SubOS costs hermeticity and nothing else OK" diff --git a/tests/e2e/238_c_only_unit_links_with_c_driver.sh b/tests/e2e/238_c_only_unit_links_with_c_driver.sh deleted file mode 100755 index 313f08441..000000000 --- a/tests/e2e/238_c_only_unit_links_with_c_driver.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# A link unit with no C++ in it is linked by the C driver (#426). -# -# Every link went through `$cxx`. `g++` appends `-lstdc++` unconditionally, and -# mcpp uses `--as-needed` in exactly one place (`-latomic`, flags.cppm), so a -# PURE C shared library came out depending on the C++ runtime: -# -# $ readelf -d bin/libpurec.so | grep NEEDED -# libstdc++.so.6 ← a library with no C++ in it -# libm.so.6 -# libgcc_s.so.1 -# libc.so.6 -# -# Measured against the C driver on the same object with the same ldflags: -# `libc.so.6`, and nothing else. All three extra entries came from the driver; -# `nm -D --undefined-only` shows the only C++-looking undefined symbol is -# `__cxa_finalize@GLIBC_2.2.5`, which is glibc's, not libstdc++'s. -# -# Nobody's build breaks from this — it is a distribution defect. Anyone -# deploying that .so has to ship a C++ runtime with it, and its runtime closure -# is larger than the code justifies. -# -# BOTH DIRECTIONS. Checking only that libstdc++ disappeared would pass an -# implementation that linked EVERYTHING with the C driver, which turns every -# C++ target into a pile of undefined `_ZSt…`. So a C++ target must still link, -# run, and use the C++ rule. -set -e - -_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [ -z "${MCPP:-}" ]; then - MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" - [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet"; exit 77; } -fi -case "$MCPP" in /*) ;; *) MCPP="$_root/$MCPP" ;; esac -[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } - -TMP=$(mktemp -d) -trap "rm -rf $TMP || true" EXIT - -# THREE SEPARATE PROJECTS, not three targets in one. Targets in a single -# package share that package's objects — a `[targets.X] sources` list does not -# fence them off — so a C++ target next door would put its object into the -# pure-C library's link and the predicate under test would never see a C-only -# unit at all. Discovered by writing it the other way first. -tc_block() { - printf "[toolchain]\ndefault = \"gcc@16.1.0\"\nmacos = \"llvm@${LLVM_VERSION}\"\nwindows = \"llvm@${LLVM_VERSION}\"\n" -} - -# ── 1. a pure-C shared library ───────────────────────────────────────────── -mkdir -p "$TMP/purec/src" -{ printf '[package]\nname = "purec"\nversion = "0.1.0"\n\n'; tc_block - printf '\n[targets.purec]\nkind = "shared"\n'; } > "$TMP/purec/mcpp.toml" -cat > "$TMP/purec/src/purec.c" <<'EOF' -int purec_add(int a, int b) { return a + b; } -EOF - -echo "== 1. pure C ==" -( cd "$TMP/purec" && "$MCPP" build --release ) > "$TMP/purec.log" 2>&1 || { - echo "FAIL: the pure-C project did not build"; tail -30 "$TMP/purec.log"; exit 1; } - -NINJA="$(find "$TMP/purec/target" -name build.ninja | head -1)" -grep -qE '^build [^:]*libpurec\.so[^:]*: c_shared ' "$NINJA" || { - echo "FAIL: the pure-C shared library is not linked by the C rule:" - grep -E '^build [^:]*libpurec\.so' "$NINJA" | sed 's/^/ /' - exit 1; } - -SO="$(find "$TMP/purec/target" -name 'libpurec.so*' -type f | head -1)" -[ -n "$SO" ] || { echo "FAIL: no libpurec.so produced"; exit 1; } -for lib in libstdc++.so.6 libm.so.6 libgcc_s.so.1; do - if readelf -d "$SO" | grep -q "\[$lib\]"; then - echo "FAIL: the pure-C library still depends on $lib" - readelf -d "$SO" | grep NEEDED | sed 's/^/ /' - exit 1 - fi -done -readelf -d "$SO" | grep -q '\[libc.so.6\]' || { - echo "FAIL: the pure-C library lost its dependency on libc itself —" - echo " the C driver was not given the link flags it needs." - readelf -d "$SO" | grep NEEDED | sed 's/^/ /' - exit 1; } -# ...and it still WORKS. Fewer NEEDED entries is only an improvement if the -# artifact still resolves and runs. -nm -D --defined-only "$SO" | grep -q ' T purec_add' || { - echo "FAIL: purec_add is no longer exported"; exit 1; } -echo " c_shared, NEEDED = libc only, symbol exported" - -# ── 2. one C++ TU keeps the whole unit on the C++ driver ─────────────────── -mkdir -p "$TMP/mixed/src" -{ printf '[package]\nname = "mixed"\nversion = "0.1.0"\n\n'; tc_block - printf '\n[targets.mixed]\nkind = "shared"\n'; } > "$TMP/mixed/mcpp.toml" -cat > "$TMP/mixed/src/mixed.c" <<'EOF' -int mixed_c(int a) { return a * 2; } -EOF -cat > "$TMP/mixed/src/mixed_cxx.cpp" <<'EOF' -#include -extern "C" int mixed_cxx_len(const char* s) { - return static_cast(std::string(s).size()); -} -EOF - -echo "== 2. mixed C/C++ ==" -( cd "$TMP/mixed" && "$MCPP" build --release ) > "$TMP/mixed.log" 2>&1 || { - echo "FAIL: the mixed project did not build"; tail -30 "$TMP/mixed.log"; exit 1; } -NINJA="$(find "$TMP/mixed/target" -name build.ninja | head -1)" -grep -qE '^build [^:]*libmixed\.so[^:]*: cxx_shared ' "$NINJA" || { - echo "FAIL: a unit containing a C++ translation unit was linked by the C" - echo " driver. One C++ object decides the unit, and this direction" - echo " fails as undefined symbols rather than as a fat binary." - grep -E '^build [^:]*libmixed\.so' "$NINJA" | sed 's/^/ /' - exit 1; } -echo " cxx_shared" - -# ── 3. a C++ binary is untouched, and runs ───────────────────────────────── -mkdir -p "$TMP/cxx/src" -{ printf '[package]\nname = "cxxbin"\nversion = "0.1.0"\n\n'; tc_block; } > "$TMP/cxx/mcpp.toml" -cat > "$TMP/cxx/src/main.cpp" <<'EOF' -import std; -int main() { std::println("cdriver-ok"); return 0; } -EOF - -echo "== 3. C++ binary ==" -( cd "$TMP/cxx" && "$MCPP" build --release ) > "$TMP/cxx.log" 2>&1 || { - echo "FAIL: the C++ project did not build"; tail -30 "$TMP/cxx.log"; exit 1; } -NINJA="$(find "$TMP/cxx/target" -name build.ninja | head -1)" -grep -qE '^build [^:]*bin/cxxbin[^:]*: cxx_link ' "$NINJA" || { - echo "FAIL: the C++ binary is no longer linked by the C++ rule" - grep -E '^build [^:]*bin/cxxbin' "$NINJA" | sed 's/^/ /' - exit 1; } -BIN="$(find "$TMP/cxx/target" -path '*/bin/cxxbin' -type f | head -1)" -OUT="$("$BIN")" || { echo "FAIL: the C++ binary exited $?"; exit 1; } -[ "$OUT" = "cdriver-ok" ] || { echo "FAIL: binary printed '$OUT'"; exit 1; } -echo " cxx_link, runs" - -# ── 4. c_ldflags is always defined ───────────────────────────────────────── -# `c_link`/`c_shared` reference `$c_ldflags`. Defining it only when it differs -# from `$ldflags` would make an EMPTY link line the failure mode on exactly the -# toolchains where the two happen to agree — a link that succeeds and produces -# something unrunnable. -grep -q '^c_ldflags ' "$NINJA" || { - echo "FAIL: c_ldflags is not defined, but the C rules reference it"; exit 1; } -echo " c_ldflags: defined even where it equals ldflags" - -echo "238 C-only link units use the C driver OK" diff --git a/tests/e2e/23_remove_update.sh b/tests/e2e/23_remove_update.sh deleted file mode 100755 index 4810dbaef..000000000 --- a/tests/e2e/23_remove_update.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 23_remove_update.sh — M4 #3: mcpp remove / mcpp update. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" - -cd "$TMP" -"$MCPP" new myapp >/dev/null -cd myapp - -# Seed dependencies directly in mcpp.toml (not via `mcpp add`) so this test -# exercises remove/update in isolation regardless of package-index state. -cat >> mcpp.toml <<'EOF' - -[dependencies] -foo = "1.0.0" -bar = "2.0.0" -EOF - -# remove foo -"$MCPP" remove foo > /tmp/_r.log 2>&1 -grep -q 'Removing' /tmp/_r.log || { cat /tmp/_r.log; exit 1; } -if grep -qE '^("foo"|foo) = ' mcpp.toml; then echo "foo not actually removed"; cat mcpp.toml; exit 1; fi -grep -qE '^("bar"|bar) = "2\.0\.0"' mcpp.toml || { echo "bar accidentally removed"; cat mcpp.toml; exit 1; } - -# remove non-existent → exit code 1 -rc=0 -"$MCPP" remove nonexistent > /tmp/_r2.log 2>&1 || rc=$? -[[ "$rc" -eq 1 ]] || { echo "remove nonexistent should exit 1, got $rc"; exit 1; } - -# Set up a fake mcpp.lock to test update -cat > mcpp.lock <<'EOF' -version = 1 -[package.bar] -version = "2.0.0" -source = "mcpplibs+https://example.com" -hash = "sha256:placeholder" -EOF - -# Targeted update bar -"$MCPP" update bar > /tmp/_u.log 2>&1 -grep -q 'Updating' /tmp/_u.log || { cat /tmp/_u.log; exit 1; } -if grep -q 'bar' mcpp.lock; then - echo "update bar didn't drop bar from lock"; cat mcpp.lock; exit 1 -fi - -# Wholesale update (no arg) → wipes lock -echo 'version = 1' > mcpp.lock # ensure file exists -"$MCPP" update > /tmp/_u2.log 2>&1 -grep -q 'all dependencies' /tmp/_u2.log || { cat /tmp/_u2.log; exit 1; } -[[ ! -f mcpp.lock ]] || { echo "lock not removed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/240_pack_pe_zip_cross.sh b/tests/e2e/240_pack_pe_zip_cross.sh deleted file mode 100755 index 314af0017..000000000 --- a/tests/e2e/240_pack_pe_zip_cross.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env bash -# requires: mingw-cross python3 -# 240_pack_pe_zip_cross.sh — packaging a Windows program FROM LINUX. -# -# This is the acceptance criterion for §4 of -# .agents/docs/2026-08-16-windows-toolchain-three-axes-design.md, and it is -# written so that running it on Windows would prove nothing: -# -# "在 Linux 上为 Windows 产物打出 zip,内含正确 DLL 闭包 -# — 跨 OS 是这条的全部意义;同 OS 打包证明不了" -# -# `mcpp pack` used to refuse Windows with `#if defined(_WIN32)`, and the -# reason given was that the tools were POSIX-only. The real reason was one -# layer down: the dependency closure came from -# -# LD_TRACE_LOADED_OBJECTS=1 '' -# -# which RUNS the artifact — so it could cross neither an OS nor an -# architecture, and no amount of porting `tar` would have helped. Reading the -# import table instead (mcpp.pack.binfmt) removes both limits at once, and -# this test is what says that actually happened rather than that the guard was -# deleted. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new winpack > /dev/null -cd winpack -cat > src/main.cpp <<'EOF' -#include -int main() { std::printf("winpack ok\n"); return 0; } -EOF -rm -f src/winpack.cppm - -cat > mcpp.toml <<'EOF' -[package] -name = "winpack" -version = "0.1.0" - -# THIS TEST DROPS A FILE INTO THE BUILD TREE AND EXPECTS `pack` TO SEE IT, -# so the two commands have to agree on WHICH build tree that is. -# -# `mcpp pack` builds with the `release` fallback (a packaged artifact leaves -# this machine), while a bare `mcpp build` uses `dev` — two profiles, two -# fingerprint directories, and the stand-in DLL below would land in the one -# `pack` does not use. Nothing about the closure would be wrong; the file would -# simply not be there, and the assertion would read as "the closure reader -# failed". -# -# Stating the profile in the manifest settles it for both, and doubles as a -# check that `[build] default-profile` still outranks pack's fallback. -[build] -default-profile = "dev" - -# `force_bundle` reaching a SYSTEM name is what makes the next assertion -# positive rather than vacuous — see the comment at the msvcrt.dll check. -[pack.bundle-project] -force_bundle = ["msvcrt.dll"] -EOF - -"$MCPP" build --target x86_64-windows-gnu > build.log 2>&1 || { cat build.log; exit 1; } -EXE="$(find target/x86_64-windows-gnu -name 'winpack.exe' | head -1)" -[[ -n "$EXE" ]] || { echo "FAIL: no PE was produced"; exit 1; } - -# The artifact really is a PE, checked here rather than assumed from the -# triple: everything below is about reading THAT file. -python3 - "$EXE" <<'PY' -import struct, sys -d = open(sys.argv[1], 'rb').read() -assert d[:2] == b'MZ', "not an MZ image" -lfanew, = struct.unpack_from(' "$BINDIR/msvcrt.dll" - -"$MCPP" pack --target x86_64-windows-gnu > pack.log 2>&1 || { cat pack.log; exit 1; } - -ZIP="$(find target/dist -name '*.zip' | head -1)" -[[ -n "$ZIP" ]] || { - echo "FAIL: no .zip produced — a Windows package is a zip whoever built it" - ls -R target/dist || true - cat pack.log - exit 1 -} -# And NOT a tarball: the extension follows the artifact, not the host. -[[ -z "$(find target/dist -name '*.tar.gz' | head -1)" ]] || { - echo "FAIL: produced a .tar.gz for a Windows target"; exit 1; } - -python3 - "$ZIP" <<'PY' -import sys, zipfile -z = zipfile.ZipFile(sys.argv[1]) - -# An INDEPENDENT reader. mcpp writes this archive itself (no host has a zip -# tool that exists everywhere), so "our writer agrees with our reader" would -# be worth nothing. -bad = z.testzip() -assert bad is None, f"corrupt entry: {bad}" - -names = z.namelist() -tops = {n.split('/')[0] for n in names} -assert len(tops) == 1, f"archive has no single wrapper directory: {tops}" -wrapper = tops.pop() - -exe = f"{wrapper}/winpack.exe" -assert exe in names, f"the executable is missing: {names}" - -# FLAT, beside the .exe. On PE that is not a layout preference — it is the -# relocation mechanism: the Win32 loader resolves a DLL from the directory of -# the executable, and there is no rpath to point anywhere else. -dll = f"{wrapper}/msvcrt.dll" -assert dll in names, ( - "msvcrt.dll was not bundled. It is named in the EXE's import table and in " - f"[pack] force_bundle, so this is the closure reader failing: {names}") -assert z.read(dll) == b'MZ-not-a-real-dll', "bundled the wrong file" - -# The other half, which the assertion above cannot give: the closure must -# EXCLUDE Windows' own. A parser that read nothing at all would also produce -# an archive with no kernel32.dll in it — but it could not have produced the -# msvcrt.dll above, so the two together are decisive. -lower = [n.lower() for n in names] -for sysdll in ("kernel32.dll", "ntdll.dll", "ucrtbase.dll"): - assert not any(n.endswith(sysdll) for n in lower), ( - f"{sysdll} was bundled — shipping a private copy of a Windows " - "component is a broken program, not a heavier one") - -# The .exe keeps its executable bit, which only matters because a Windows -# package is routinely unpacked and inspected (and wine-tested) from Linux. -info = z.getinfo(exe) -assert (info.external_attr >> 16) & 0o111, "the executable bit was lost" -print("OK: zip verified by an independent reader") -PY - -# `--mode system` promises the target provides everything. A DLL beside the -# .exe would contradict that, and the modes have to mean the same thing on -# both formats or the flag is decoration. -rm -rf target/dist -"$MCPP" pack --target x86_64-windows-gnu --mode system > pack2.log 2>&1 || { - cat pack2.log; exit 1; } -python3 - "$(find target/dist -name '*.zip' | head -1)" <<'PY' -import sys, zipfile -names = zipfile.ZipFile(sys.argv[1]).namelist() -assert not any(n.lower().endswith('.dll') for n in names), \ - f"--mode system bundled a DLL: {names}" -PY - -# The contract reaches packaging (design §4.3). `toolchain-coupled` says the -# toolchain's runtime travels WITH the artifact; a mode that bundles nothing -# cannot deliver that, and a contract with no executor is a promise the build -# prints and the package quietly drops. -cat >> mcpp.toml <<'EOF' - -[build] -cxx_runtime = "toolchain-coupled" -EOF -rm -rf target/dist -if "$MCPP" pack --target x86_64-windows-gnu --mode system > pack3.log 2>&1; then - echo "FAIL: cxx_runtime = toolchain-coupled + --mode system was accepted" - cat pack3.log - exit 1 -fi -grep -q 'toolchain-coupled' pack3.log || { - echo "FAIL: the refusal does not name the contract"; cat pack3.log; exit 1; } -grep -q 'vendored' pack3.log || { - echo "FAIL: the refusal offers no way out"; cat pack3.log; exit 1; } - -echo "OK" diff --git a/tests/e2e/241_windows_ucrt_runtime_identity.sh b/tests/e2e/241_windows_ucrt_runtime_identity.sh deleted file mode 100755 index 9bc9f29fe..000000000 --- a/tests/e2e/241_windows_ucrt_runtime_identity.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc python3 -# 241_windows_ucrt_runtime_identity.sh — the Windows SDK has an identity, and -# it reaches the build's runtime contract. -# -# `RuntimeBinding::runtimeId`'s own comment has documented `ucrt@…` since the -# field existed, and nothing ever wrote one. The cost was not cosmetic: the -# SDK version never reached `runtimeContractHash`, which keys the build cache, -# so TWO SDKs shared ONE cache key — the version axis simply stopped existing -# one layer below the compiler. -# -# The unit tests pin the hash function (two versions → two hashes). What they -# cannot see is whether the value ever gets there on a real Windows build, and -# a green Windows CI does not distinguish "the identity is filled in" from -# "the code path ran and produced nothing" — an empty string flows through -# every one of those jobs without a complaint. So this asserts the VALUE. -# -# WHY IT PINS msvc@system EXPLICITLY. This is the cl.exe row's half. Windows' -# default toolchain is clang targeting the MSVC ABI, and that row now resolves -# its toolset and SDK in mcpp as well (the row's sysroot), so it carries the -# same identity; 760 asserts it there. Pinning keeps this test about the row -# it was written for. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -"$MCPP" new ucrtid > /dev/null -cd ucrtid -cat >> mcpp.toml <<'EOF' - -[toolchain] -windows = "msvc@system" -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } - -RES="$(find target -name resolution.json -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } - -python3 - "$RES" <<'PY' -import json, sys -d = json.load(open(sys.argv[1])) -b = d.get("runtime", {}).get("binding", {}) -rid = b.get("runtime_id", "") - -assert rid.startswith("ucrt@"), ( - "the Windows runtime identity is not filled in: runtime_id=" - f"{rid!r}. The SDK version never reaches runtimeContractHash, so two " - "SDKs share one build-cache key.") - -version = rid[len("ucrt@"):] -assert version and version[0].isdigit(), f"implausible SDK version in {rid!r}" - -# The identity is only worth anything if it PARTICIPATES. An empty contract -# hash would mean the value was recorded and then not used for anything. -assert b.get("contract_hash"), "runtime binding has no contract hash" - -# ...and it must NOT have been projected into the private-libc field. That -# field is read by the loader/patchelf machinery, and ucrt has no payload for -# it to name — `ucrtbase.dll` is a Windows component. See -# mcpp.runtime.binding on why the two providers are not isomorphic. -assert not b.get("libc"), ( - f"ucrt was projected into `libc` ({b.get('libc')!r}); that field names a " - "private libc PAYLOAD, and there is no such thing for ucrt") - -print(f"OK: runtime identity {rid}, contract {b['contract_hash']}") -PY - -echo "OK" diff --git a/tests/e2e/242_pack_library_interface_and_headers.sh b/tests/e2e/242_pack_library_interface_and_headers.sh deleted file mode 100755 index a9b7652a0..000000000 --- a/tests/e2e/242_pack_library_interface_and_headers.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: "a library package works on every target" has to be TESTED on -# every target. `# requires: gcc` would skip this on macOS and Windows — Apple -# Clang is not the gcc capability — leaving the claim unverified while the -# suite stayed green.) -# -# Deliberately NO implementation partition here. This test is about the two -# interface modes; 243 owns partitions, and they do not build on Windows/clang -# today (a pre-existing gap this work found, see 243's header). -# 242_pack_library_interface_and_headers.sh — `mcpp pack ` produces -# a package a consumer can use through EITHER interface mode, or both at once. -# -# Acceptance for §1.1 / §2 of -# .agents/docs/2026-08-17-library-distribution-design.md: a package carries -# `include/` (text, consumed by #include) and `interface/` (module units the -# consumer compiles), the two do not interfere, and neither needs a flag. -# -# Also pins the two lists `mcpp pack` prints. A closed-source publisher needs -# to see what is NOT travelling as much as what is. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── the producer: a module interface AND a C header, over one library ── -mkdir -p mathkit/src mathkit/include -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export import :api; -EOF -cat > mathkit/src/api.cppm <<'EOF' -export module mathkit:api; -export namespace mk { int add(int a, int b); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int add(int a, int b) { return a + b; } } -EOF -cat > mathkit/src/capi.c <<'EOF' -int mathkit_add(int a, int b) { return a + b; } -EOF -cat > mathkit/include/mathkit_c.h <<'EOF' -#ifdef __cplusplus -extern "C" { -#endif -int mathkit_add(int a, int b); -#ifdef __cplusplus -} -#endif -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp", "src/*.c"] -include_dirs = ["include"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } - -# The container as stated, compressed by mcpp itself; a single file is a -# program's, not a library's. -"$MCPP" pack mathkit --pkg-format tar --compress zstd > pack-zst.log 2>&1 || { cat pack-zst.log; echo "pack --compress zstd failed"; exit 1; } -zst="$(find target/dist -maxdepth 1 -type f -name 'mathkit-0.1.0-*.tar.zst' | head -1)" -[[ -n "$zst" ]] || { cat pack-zst.log; ls target/dist; echo "no .tar.zst for --compress zstd"; exit 1; } -[[ "$(head -c 4 "$zst" | od -An -tx1 | tr -d ' \n')" == "28b52ffd" ]] || { echo "$zst is not a zstd frame"; exit 1; } -"$MCPP" pack mathkit --pkg-format zip > pack-zip.log 2>&1 || { cat pack-zip.log; echo "pack --pkg-format zip failed"; exit 1; } -ls target/dist/mathkit-0.1.0-*.zip > /dev/null 2>&1 || { cat pack-zip.log; ls target/dist; echo "no zip for --pkg-format zip"; exit 1; } -if "$MCPP" pack mathkit --pkg-format single-file > pack-sf.log 2>&1; then - cat pack-sf.log; echo "a library was packed as a single file"; exit 1 -fi -grep -q "is a library" pack-sf.log || { cat pack-sf.log; echo "the single-file refusal does not say why"; exit 1; } -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } - -pkg="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -# The manifest below is FILE CONTENT: on Git Bash a shell-spelled -# /tmp/... path is read by a native mcpp.exe as "root of the current -# drive". host_path is the conversion (tests/e2e/_host_path.sh). -PKG_HOST="$(host_path "$TMP/mathkit/$pkg")" -[[ -n "$pkg" ]] || { cat pack.log; echo "no package directory"; exit 1; } - -# The layout is the contract: two interface modes, one artifact dir per triple. -for f in mcpp.toml interface/mathkit.cppm interface/api.cppm include/mathkit_c.h; do - [[ -f "$pkg/$f" ]] || { echo "package is missing $f"; find "$pkg" -type f; exit 1; } -done -# NOT a hard-coded `libmathkit.a`: the artifact's name follows the ENVIRONMENT, -# not the OS — MinGW writes libfoo.a where MSVC writes foo.lib. That is exactly -# why `lib/` is keyed by triple, and hard-coding the GNU spelling here made this -# test fail on Windows against a package that had been built correctly. -[[ -n "$(find "$pkg/lib" -type f \( -name 'libmathkit.*' -o -name 'mathkit.*' \) | head -1)" ]] || { - echo "package has no artifact under lib//"; find "$pkg" -type f; exit 1; } - -# Both lists are printed, and the implementation partition is on the right one. -grep -q 'Interface.*mathkit.cppm' pack.log || { cat pack.log; echo "no interface list"; exit 1; } -grep -q 'Withheld.*impl.cpp' pack.log || { cat pack.log; echo "no withheld list"; exit 1; } - -cd "$TMP" - -# ── three consumers: header only, module only, both ──────────────────── -consume() { # $1 = name, $2 = main.cpp body - mkdir -p "$1/src" - printf '%s' "$2" > "$1/src/main.cpp" - cat > "$1/mcpp.toml" < run.log 2>&1 ) || { cat "$1/run.log"; echo "$1 failed"; exit 1; } - grep -q 'ok=5' "$1/run.log" || { cat "$1/run.log"; echo "$1 printed the wrong answer"; exit 1; } -} - -consume c_hdr '#include -#include -int main(){ std::printf("ok=%d\n", mathkit_add(2,3)); return 0; } -' -consume c_mod '#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::add(2,3)); return 0; } -' -consume c_both '#include -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mathkit_add(2,3) + mk::add(2,3) - 5); return 0; } -' - -echo "PASS: a library package serves header, module, and both-at-once consumers" diff --git a/tests/e2e/243_pack_library_interface_closure.sh b/tests/e2e/243_pack_library_interface_closure.sh deleted file mode 100755 index d9baffafc..000000000 --- a/tests/e2e/243_pack_library_interface_closure.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: this must hold on every target.) -# -# This test is also the regression for a scanner bug it uncovered. An -# IMPLEMENTATION PARTITION (`module M:part;`, no `export`) had no coverage -# anywhere in mcpp, and the scanner recorded it as "requires M:part, provides -# nothing" — so the file required its own name, the graph held no edge from the -# unit importing the partition to the unit defining it, and build order was -# unconstrained. GCC and macOS clang recovered via their own scan; Windows clang -# failed with `failed to read compiled module`. The scanner now models it, and -# the warning it used to print on every platform ("imported but not provided in -# this build") is gone. -# 243_pack_library_interface_closure.sh — what travels is the module closure of -# the published root, and the archive keeps exactly what the closure does not. -# -# Two asymmetric failures are pinned here, and only one of them is loud in the -# wild: -# -# * publishing TOO LITTLE fails in the consumer's compile, naming the module; -# * publishing TOO MUCH silently ships a closed-source implementation -# partition's SOURCE. Nothing fails. That is what this test is for. -# -# The archive side is the same closure used the other way round, and getting it -# wrong was measured: dropping every `.m.o` also drops the implementation -# partition's object, and every target then fails to link with an undefined -# reference nowhere near its cause. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export import :api; -EOF -cat > mathkit/src/api.cppm <<'EOF' -export module mathkit:api; -export namespace mk { int answer(); } -EOF -# An implementation partition: it produces a BMI and a `.m.o`, and its source -# is the thing a closed-source publisher must not ship. -cat > mathkit/src/secret.cppm <<'EOF' -module mathkit:secret; -namespace mk { int secret_bias() { return 40; } } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { -int secret_bias(); -int answer() { return secret_bias() + 2; } -} -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } -pkg="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -# The manifest below is FILE CONTENT: on Git Bash a shell-spelled -# /tmp/... path is read by a native mcpp.exe as "root of the current -# drive". host_path is the conversion (tests/e2e/_host_path.sh). -PKG_HOST="$(host_path "$TMP/mathkit/$pkg")" - -# ── the confidentiality criterion ────────────────────────────────────── -[[ -f "$pkg/interface/mathkit.cppm" ]] || { echo "root interface not published"; exit 1; } -[[ -f "$pkg/interface/api.cppm" ]] || { echo "interface partition not published"; exit 1; } -[[ ! -e "$pkg/interface/secret.cppm" ]] || { - echo "LEAK: the implementation partition's source was published"; exit 1; } -grep -RIl 'secret_bias' "$pkg/interface" "$pkg/include" 2>/dev/null | grep -q . && { - echo "LEAK: implementation source text found in the published interface"; exit 1; } - -# ── the archive criterion: published objects out, everything else in ──── -# A functional probe, not `command -v ar`: on macOS `ar` resolves to an xlings -# shim that reports "not installed" and exits non-zero, which under `set -e` -# kills the test instead of skipping the inspection. -archive="$(find "$pkg/lib" -type f \( -name 'libmathkit.*' -o -name 'mathkit.*' \) | head -1)" -if [[ -n "$archive" ]] && members="$(ar t "$archive" 2>/dev/null)"; then - echo "$members" | grep -q 'secret.m.o' || { - echo "the implementation partition's OBJECT was dropped; nothing would link" - echo "$members"; exit 1; } - echo "$members" | grep -q 'mathkit.m.o' && { - echo "a published interface unit's object is still in the archive" - echo "$members"; exit 1; } - echo "$members" | grep -q 'api.m.o' && { - echo "a published interface unit's object is still in the archive" - echo "$members"; exit 1; } -fi - -# ── the hazard case: an interface that reaches the partition ──────────── -# -# Legal, and the consumer needs that source to build the BMI at all — so it is -# published rather than refused. But for a closed-source library it is the one -# outcome nobody wants by accident, and `import :secret;` in an interface looks -# like any other import, so `mcpp pack` has to say it. -cd "$TMP/mathkit" -cp src/mathkit.cppm "$TMP/root.bak" -printf 'import :secret;\n' >> src/mathkit.cppm -rm -rf target -"$MCPP" pack mathkit > hazard.log 2>&1 || { cat hazard.log; echo "hazard pack failed"; exit 1; } -grep -q 'secret.cppm is an implementation partition' hazard.log || { - cat hazard.log - echo "FAIL: publishing an implementation partition's source was not reported" - exit 1; } -hazpkg="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -[[ -e "$hazpkg/interface/secret.cppm" ]] || { - echo "the warning fired but the source was not actually published"; exit 1; } -cp "$TMP/root.bak" src/mathkit.cppm -rm -rf target -"$MCPP" pack mathkit > repack.log 2>&1 || { cat repack.log; echo "repack failed"; exit 1; } -pkg="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -[[ ! -e "$pkg/interface/secret.cppm" ]] || { echo "restore did not withhold it again"; exit 1; } -PKG_HOST="$(host_path "$TMP/mathkit/$pkg")" - -# ── and it still links and runs ───────────────────────────────────────── -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < run.log 2>&1 ) || { cat app/run.log; echo "consumer failed"; exit 1; } -grep -q 'ok=42' app/run.log || { cat app/run.log; echo "wrong answer"; exit 1; } - -echo "PASS: the closure decides both what is published and what is dropped" diff --git a/tests/e2e/244_pack_library_gate.sh b/tests/e2e/244_pack_library_gate.sh deleted file mode 100755 index 04c26e808..000000000 --- a/tests/e2e/244_pack_library_gate.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: the refusals have to hold on every platform, so this test has -# to RUN on every platform.) -# 244_pack_library_gate.sh — the three refusals a prebuilt package must make. -# -# THE FIRST ONE IS WHY THIS FEATURE HAS A GATE AT ALL. Measured before it -# existed, on a real build: change one line of a shipped interface — swap two -# `int` members of a struct, which the Itanium ABI does not mangle — and the -# consumer compiles, links, runs, and prints transposed data. Exit code 0. No -# diagnostic at any stage, from any tool. -# -# 1. an edited interface → refuse (the silent-wrong-data case) -# 2. an artifact for another toolchain → refuse, and LIST the tags it has -# 3. `mcpp build` inside the package → refuse (it would "succeed" emptily) -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -# The manifest below is FILE CONTENT: on Git Bash a shell-spelled -# /tmp/... path is read by a native mcpp.exe as "root of the current -# drive". host_path is the conversion (tests/e2e/_host_path.sh). -PKG_HOST="$(host_path "$pkg")" -cd "$TMP" - -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < ok.log 2>&1 ) || { cat app/ok.log; echo "baseline failed"; exit 1; } -grep -q 'ok=42' app/ok.log || { cat app/ok.log; echo "baseline wrong answer"; exit 1; } - -# ── 1. an edited interface is refused ────────────────────────────────── -cp "$pkg/interface/mathkit.cppm" "$TMP/interface.bak" -printf '\n// tampered\n' >> "$pkg/interface/mathkit.cppm" -rm -rf app/target -if ( cd app && "$MCPP" build > tamper.log 2>&1 ); then - cat app/tamper.log - echo "FAIL: an edited interface built anyway — this is the silent-wrong-data case" - exit 1 -fi -grep -q 'does not match what was packaged' app/tamper.log || { - cat app/tamper.log; echo "refused, but not for the interface digest"; exit 1; } -cp "$TMP/interface.bak" "$pkg/interface/mathkit.cppm" - -# ── 2. a foreign toolchain tag is refused, and the real tags are shown ── -cp "$pkg/mcpp.toml" "$TMP/manifest.bak" -# Forge the STDLIB segment, whatever it is called. Hard-coding `gcc` would have -# made this a no-op on macOS (llvm) and Windows (msvc) — the tag would stay -# valid, the package would be correctly accepted, and the test would report -# "a package built for another compiler was accepted" against a product that -# did nothing wrong. -sed -i.bak -E 's/(abi[[:space:]]*=[[:space:]]*"[^"]*-)[a-z]+[0-9]+(-)/\1forgedstl99\2/' "$pkg/mcpp.toml" -rm -rf app/target -if ( cd app && "$MCPP" build > tag.log 2>&1 ); then - cat app/tag.log - echo "FAIL: a package built for another compiler was accepted" - exit 1 -fi -grep -q 'no prebuilt artifact matches this toolchain' app/tag.log || { - cat app/tag.log; echo "refused, but not for the abi tag"; exit 1; } -# The diagnostic has to say what IS available — a refusal the reader cannot act -# on sends them looking for a package that is right in front of them. -grep -q 'published tags' app/tag.log || { - cat app/tag.log; echo "the refusal did not list the published tags"; exit 1; } -grep -q 'forgedstl99' app/tag.log || { - cat app/tag.log; echo "the refusal did not name the tag it found"; exit 1; } -cp "$TMP/manifest.bak" "$pkg/mcpp.toml" - -# ── 3. building INSIDE the package is refused ────────────────────────── -if ( cd "$pkg" && "$MCPP" build > "$TMP/inside.log" 2>&1 ); then - cat "$TMP/inside.log" - echo "FAIL: building inside a distribution package 'succeeded' — it compiles" - echo " declarations, links nothing, and reports Finished" - exit 1 -fi -grep -q 'distribution package produced by' "$TMP/inside.log" || { - cat "$TMP/inside.log"; echo "refused, but not as a distribution package"; exit 1; } - -# ── and the restored package still builds ────────────────────────────── -rm -rf app/target -( cd app && "$MCPP" run > final.log 2>&1 ) || { cat app/final.log; echo "restore failed"; exit 1; } -grep -q 'ok=42' app/final.log || { cat app/final.log; echo "restore wrong answer"; exit 1; } - -echo "PASS: interface tamper, tag mismatch, and in-package build are all refused" diff --git a/tests/e2e/245_pack_library_fat_target_selection.sh b/tests/e2e/245_pack_library_fat_target_selection.sh deleted file mode 100755 index b337aee76..000000000 --- a/tests/e2e/245_pack_library_fat_target_selection.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc musl -# 245_pack_library_fat_target_selection.sh — one package, several targets, and -# each build picks exactly its own leg. -# -# `musl` is now declared as well as `gcc`. It always needed both — the pack below -# asks for a musl leg outright — and CI was green only because the payload -# happens to be warm there. A requirement the test does not declare is one that -# turns into a confusing failure the day it stops being true. -# -# The Windows counterpart (msvc + mingw legs in one package) is 256; `# requires:` -# cannot express "gcc+musl OR msvc+mingw", and a host that has neither should -# skip rather than half-run. -# -# THE PREDICATE IS THE POINT. Each leg is selected by a generated -# `cfg(all(arch=…, os=…, env=…))` block and NOT by a bare `[target.'']` -# key. The bare form only matched when `--target` was passed: a plain -# `mcpp build` resolved the host and compared it against an empty string, so -# the section was silently inert. That shape is the worst kind — CI passes -# `--target` and is green, the developer's own build drops the flags, and the -# failure arrives at the linker naming a symbol instead of a predicate. -# -# So this test builds the NATIVE case as well as the explicit ones, and asserts -# the selected directory each time. -# -# gnu + musl and not gnu + windows: CI warms both of those toolchains, so this -# test RUNS rather than skipping. A `# requires: mingw-cross` here would have -# made the whole fat-package mechanism unverified on every ordinary CI run -# while still reporting a green suite. The PE leg is covered by 248. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit \ - --target x86_64-linux-gnu \ - --target x86_64-linux-musl > pack.log 2>&1 \ - || { cat pack.log; echo "fat pack failed"; exit 1; } - -pkg="$TMP/mathkit/target/dist/mathkit-0.1.0" -# The manifest below is FILE CONTENT: on Git Bash a shell-spelled -# /tmp/... path is read by a native mcpp.exe as "root of the current -# drive". host_path is the conversion (tests/e2e/_host_path.sh). -PKG_HOST="$(host_path "$pkg")" -for t in x86_64-linux-gnu x86_64-linux-musl; do - [[ -n "$(find "$pkg/lib/$t" -name 'libmathkit.a' | head -1)" ]] || { - echo "no artifact for $t"; find "$pkg" -type f; exit 1; } -done - -# The generated blocks must be cfg(...), never a bare triple. -grep -q "target\.'cfg(" "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml"; echo "legs are not selected by cfg() predicates"; exit 1; } -grep -qE "^\[target\.'x86_64-" "$pkg/mcpp.toml" && { - cat "$pkg/mcpp.toml" - echo "a leg is selected by a BARE TRIPLE, which is inert on a native build" - exit 1; } - -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < "$TMP/$label.log" 2>&1 ) \ - || { cat "$TMP/$label.log"; echo "$label build failed"; exit 1; } - local nj; nj="$(find app/target -name build.ninja | head -1)" - # Separator-agnostic and anchored on the PACKAGE name: a native mcpp.exe - # writes native separators into build.ninja, so a `dist/…/lib/…` pattern - # matches nothing there and the failure reads like a packaging bug. Same - # helper as 256, which is where that was measured. - grep -oE "mathkit-0\.1\.0[\\/]lib[\\/][A-Za-z0-9_-]+" "$nj" \ - | sed 's|.*[\\/]||' | sort -u > "$TMP/$label.legs" - [[ "$(wc -l < "$TMP/$label.legs")" -eq 1 ]] || { - echo "$label saw $(wc -l < "$TMP/$label.legs") leg(s), expected exactly 1:" - cat "$TMP/$label.legs"; exit 1; } - grep -qx "$want" "$TMP/$label.legs" || { - echo "$label picked the wrong leg:"; cat "$TMP/$label.legs"; exit 1; } -} - -# The native build is the one the bare-triple form used to get wrong. -check native x86_64-linux-gnu -check gnu x86_64-linux-gnu --target x86_64-linux-gnu -check musl x86_64-linux-musl --target x86_64-linux-musl - -echo "PASS: a fat package selects one leg per target, native build included" diff --git a/tests/e2e/246_explicit_empty_sources.sh b/tests/e2e/246_explicit_empty_sources.sh deleted file mode 100755 index 50a81d8a7..000000000 --- a/tests/e2e/246_explicit_empty_sources.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: the probe below greps a FILENAME out of build.ninja, not a -# compiler flag, so nothing here is toolchain-specific. It carried -# `# requires: gcc` for one round out of copy-paste, and that capability is -# Linux-only by design — it skipped on macOS and Windows for no reason.) -# 246_explicit_empty_sources.sh — `sources = []` means "compile nothing", and -# omitting the key still means "the default glob". -# -# They used to be byte-identical: the parser filled the default glob whenever -# the vector was empty, so an author had NO spelling for "nothing". A binary -# distribution package needs one — a header-only package compiles nothing, and -# any file left under `src/` would otherwise be swept up and compiled into the -# consumer's build, where it can collide with the symbols the prebuilt library -# already defines. -# -# The probe is a leftover source that must NOT be compiled in one case and MUST -# be in the other. Asserting only the first would pass against a parser that -# ignores `sources` entirely. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p probe/src -echo 'int mcpp_leftover_probe(void) { return 1; }' > probe/src/leftover.cpp -echo 'int main() { return 0; }' > probe/main.cpp - -write_manifest() { # $1 = the [build] body - cat > probe/mcpp.toml < empty.log 2>&1 ) || { cat probe/empty.log; echo "build failed"; exit 1; } -n="$(hits)" -[[ "$n" -eq 0 ]] || { - echo "FAIL: 'sources = []' still compiled the leftover source ($n references)" - exit 1; } - -# ── absent: the default glob still applies ───────────────────────────── -write_manifest '# no sources key at all' -rm -rf probe/target -( cd probe && "$MCPP" build > default.log 2>&1 ) || { cat probe/default.log; echo "build failed"; exit 1; } -n="$(hits)" -[[ "$n" -gt 0 ]] || { - echo "FAIL: omitting 'sources' stopped applying the default glob" - exit 1; } - -echo "PASS: an explicitly empty sources list is distinguishable from an absent one" diff --git a/tests/e2e/247_bare_triple_conditional_native.sh b/tests/e2e/247_bare_triple_conditional_native.sh deleted file mode 100755 index 938f3d095..000000000 --- a/tests/e2e/247_bare_triple_conditional_native.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: the probe greps the MACRO NAME, never the `-D` prefix — MSVC -# spells that `/D`, and the prefix was the only thing that tied this test to -# one compiler family. The predicate it checks is compiler-independent, so the -# test has to run everywhere the predicate does.) -# 247_bare_triple_conditional_native.sh — `[target.''.build]` applies to -# a NATIVE build, not only to one with an explicit `--target`. -# -# It did not, and the failure shape is the dangerous one: CI passes `--target` -# and is green, the developer's plain `mcpp build` silently drops the section, -# and whatever it carried (a `-L`, a define, a source) goes missing somewhere -# far from the manifest. `cfg(linux)` matched the same build all along, so the -# two spellings of one statement disagreed. -# -# The probe asserts BOTH spellings under BOTH invocations. Asserting only the -# native case would pass against a build that applies every section always. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# The host triple, asked of mcpp rather than guessed. -# -# The first version pattern-matched `mcpp self env` for -# `--` and fell back to `x86_64-linux-gnu`. macOS's canonical -# triple is `aarch64-macos` — TWO segments, no env — so the match failed, the -# fallback was used, and the test then asserted that a *Linux* section applied -# to a macOS build. It reported "the bare triple was inert" against a product -# that was behaving correctly. -# -# `target//` is mcpp's own answer to the same question, so take it from -# there: build once with nothing conditional, read the directory name, then -# write the real manifest. -mkdir -p probe/src -echo 'int main() { return 0; }' > probe/src/main.cpp -cat > probe/mcpp.toml <<'EOF' -[package] -name = "probe" -version = "0.1.0" -[targets.probe] -kind = "bin" -main = "src/main.cpp" -EOF -( cd probe && "$MCPP" build > discover.log 2>&1 ) \ - || { cat probe/discover.log; echo "discovery build failed"; exit 1; } -host_triple="$(ls probe/target | head -1)" -[[ -n "$host_triple" ]] || { ls -R probe/target; echo "no target/ dir"; exit 1; } -echo "host triple: $host_triple" - -cat > probe/mcpp.toml <`: the prefix is dialect-specific (`/D` on MSVC), - # and what this test is about is whether the section applied at all. - local nj; nj="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" - grep -c -- "$1" "$nj" || true -} - -cd probe - -# ── native: no --target ──────────────────────────────────────────────── -rm -rf target -"$MCPP" build > native.log 2>&1 || { cat native.log; echo "native build failed"; exit 1; } -bare="$(count MCPP_BARE_TRIPLE)" -alias_="$(count MCPP_CFG_ALIAS)" -[[ "$alias_" -gt 0 ]] || { echo "cfg() did not apply on a native build"; exit 1; } -[[ "$bare" -gt 0 ]] || { - echo "FAIL: [target.'$host_triple'.build] was inert on a native build" - echo " (cfg() applied $alias_ times, the bare triple $bare)" - exit 1; } - -# ── explicit --target: unchanged ─────────────────────────────────────── -rm -rf target -"$MCPP" build --target "$host_triple" > explicit.log 2>&1 \ - || { cat explicit.log; echo "explicit build failed"; exit 1; } -[[ "$(count MCPP_BARE_TRIPLE)" -gt 0 ]] || { echo "bare triple inert with --target"; exit 1; } -[[ "$(count MCPP_CFG_ALIAS)" -gt 0 ]] || { echo "cfg() inert with --target"; exit 1; } - -echo "PASS: a bare-triple conditional applies to native and explicit builds alike" diff --git a/tests/e2e/248_pack_library_fat_pe_leg.sh b/tests/e2e/248_pack_library_fat_pe_leg.sh deleted file mode 100755 index d66e83a5f..000000000 --- a/tests/e2e/248_pack_library_fat_pe_leg.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc mingw-cross -# 248_pack_library_fat_pe_leg.sh — a fat package whose legs cross an OS -# boundary, not just a libc one. -# -# 245 covers the fat-package MECHANISM with gnu + musl, because CI warms both -# and that test must never skip. This one adds the leg that changes binary -# format: `x86_64-windows-gnu` is PE, and its artifact naming follows the -# ENVIRONMENT rather than the OS — MinGW writes `libfoo.a` where MSVC would -# write `foo.lib`, which is exactly why `lib/` is keyed by triple and not by OS. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit \ - --target x86_64-linux-gnu \ - --target x86_64-windows-gnu > pack.log 2>&1 \ - || { cat pack.log; echo "cross-OS fat pack failed"; exit 1; } - -pkg="$TMP/mathkit/target/dist/mathkit-0.1.0" -# The manifest below is FILE CONTENT: on Git Bash a shell-spelled -# /tmp/... path is read by a native mcpp.exe as "root of the current -# drive". host_path is the conversion (tests/e2e/_host_path.sh). -PKG_HOST="$(host_path "$pkg")" -for t in x86_64-linux-gnu x86_64-windows-gnu; do - [[ -n "$(find "$pkg/lib/$t" -name 'libmathkit.a' | head -1)" ]] || { - echo "no artifact for $t"; find "$pkg" -type f; exit 1; } -done - -# Each leg records the triple it was built for. Two legs, two distinct tags — -# if the tag were taken from the compiler's own `-dumpmachine` instead of -# mcpp's canonical vocabulary, the Windows one would say `x86_64-w64-mingw32` -# and disagree with the cfg() block selecting it. -grep -q 'abi *= *"x86_64-linux-gnu-' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml"; echo "no linux-gnu tag"; exit 1; } -grep -q 'abi *= *"x86_64-windows-gnu-' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml"; echo "no windows-gnu tag (compiler spelling leaked?)"; exit 1; } - -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < win.log 2>&1 ) \ - || { cat app/win.log; echo "PE consumer failed"; exit 1; } -exe="$(find app/target/x86_64-windows-gnu -name 'app.exe' | head -1)" -[[ -n "$exe" ]] || { echo "no .exe produced"; exit 1; } -file "$exe" | grep -q 'PE32+' || { file "$exe"; echo "not a PE image"; exit 1; } - -nj="$(find app/target/x86_64-windows-gnu -name build.ninja | head -1)" -grep -o "dist/mathkit-0.1.0/lib/[a-z0-9_-]*" "$nj" | sort -u > "$TMP/legs" -[[ "$(wc -l < "$TMP/legs")" -eq 1 ]] || { echo "more than one leg:"; cat "$TMP/legs"; exit 1; } -grep -q 'lib/x86_64-windows-gnu$' "$TMP/legs" || { - echo "the PE build did not pick the PE leg:"; cat "$TMP/legs"; exit 1; } - -echo "PASS: a fat package crosses an OS boundary and each build picks its own leg" diff --git a/tests/e2e/249_pack_workspace_root_unchanged.sh b/tests/e2e/249_pack_workspace_root_unchanged.sh deleted file mode 100755 index c2a982830..000000000 --- a/tests/e2e/249_pack_workspace_root_unchanged.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: a library package is claimed to work on every target, so this -# test has to RUN on every platform. `# requires: gcc` would have skipped it on -# macOS and Windows — Apple Clang is not the gcc capability — leaving the claim -# unverified while the suite stayed green.) -# 249_pack_workspace_root_unchanged.sh — `mcpp pack` in a workspace root still -# packs the member's program. -# -# Routing `mcpp pack` through `[targets.].kind` means something has to read -# the manifest before the build does. A workspace root has no targets of its -# own — a virtual one has no `[package]` either — so the first version of that -# routing read the root's empty target list and concluded there was nothing to -# pack, turning a working command into an error. -# -# Caught by running the previous release against examples/04-workspace and -# comparing. This test is that comparison, made permanent. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p ws/apps/hello/src ws/libs/mathcore/src - -cat > ws/mcpp.toml <<'EOF' -[workspace] -members = ["libs/mathcore", "apps/hello"] -EOF - -cat > ws/libs/mathcore/mcpp.toml <<'EOF' -[package] -name = "mathcore" -version = "0.1.0" -EOF -cat > ws/libs/mathcore/src/mathcore.cppm <<'EOF' -export module mathcore; -export int core_answer() { return 42; } -EOF - -cat > ws/apps/hello/mcpp.toml <<'EOF' -[package] -name = "hello" -version = "0.1.0" -[dependencies] -mathcore = { path = "../../libs/mathcore" } -EOF -cat > ws/apps/hello/src/main.cpp <<'EOF' -#include -import mathcore; -int main() { std::printf("ok=%d\n", core_answer()); return 0; } -EOF - -cd ws - -# The same legs on every host. A Mach-O program packs as the others do -# (2026.9.14.2+, its closure read from its load commands); the macOS branch -# that asserted the earlier refusal naming the member's program is gone. - -"$MCPP" pack --mode system > pack.log 2>&1 || { - cat pack.log - echo "FAIL: packing from a virtual workspace root stopped working" - exit 1 -} -grep -q 'Packed' pack.log || { cat pack.log; echo "no archive reported"; exit 1; } -# .tar.gz everywhere except a Windows target, which produces a .zip — the -# archive format follows the artifact, not the packer. -[[ -n "$(find . -name 'hello-0.1.0-*.tar.gz' -o -name 'hello-0.1.0-*.zip' | head -1)" ]] || { - cat pack.log; echo "the member's archive was not produced"; find . -name 'hello-0.1.0-*'; exit 1; } - -echo "PASS: a workspace root still packs its member's program" diff --git a/tests/e2e/24_git_dependency.sh b/tests/e2e/24_git_dependency.sh deleted file mode 100755 index bfbce6d4d..000000000 --- a/tests/e2e/24_git_dependency.sh +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env bash -# requires: fresh-sandbox -# 24_git_dependency.sh — M4 #5: git-based dep clones to ~/.mcpp/git// -# and is treated as a path dep. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -# Inherit toolchain from user's installed mcpp (avoids reinstalling gcc). -source "$(dirname "$0")/_inherit_toolchain.sh" -# Now override HOME — but inheritance must happen first while $HOME points -# at the user's real home. -export HOME="$TMP/home" # so default_cache_root + git cache resolve here -mkdir -p "$HOME" - -# 1. Build a tiny mcpp lib in a real local git repo (acts as origin). -mkdir "$TMP/origin" && cd "$TMP/origin" -git init --quiet -git config user.email "test@local" -git config user.name "test" -"$MCPP" new mylibA >/dev/null -mv mylibA/* mylibA/.??* . 2>/dev/null || true -rmdir mylibA 2>/dev/null || true -cat > src/greet.cppm <<'EOF' -export module mylibA.greet; -import std; -export auto greet() -> void { std::println("hello from git dep"); } -EOF -rm src/main.cpp -cat > mcpp.toml <<'EOF' -[package] -name = "mylibA" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.mylibA] -kind = "lib" -EOF -git add -A >/dev/null -git commit --quiet -m "init" -HEAD_REV=$(git rev-parse HEAD) - -# 2. Consumer references the git repo + rev. -cd "$TMP" -"$MCPP" new myapp >/dev/null -cd myapp -cat > src/main.cpp <<'EOF' -import std; -import mylibA.greet; -int main() { greet(); return 0; } -EOF -cat > mcpp.toml < build.log 2>&1 -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$(${triple}${fp_dir}/bin/myapp) -[[ "$out" == *"hello from git dep"* ]] || { - echo "FAIL: git dep module not invoked: $out" - cat build.log; exit 1; } - -# Verify we cached the clone under MCPP_HOME/git -[[ -d "$MCPP_HOME/git" ]] || { echo "FAIL: \$MCPP_HOME/git not created"; ls "$MCPP_HOME"; exit 1; } -test -n "$(ls -A "$MCPP_HOME/git" 2>/dev/null)" || { echo "FAIL: git cache empty"; exit 1; } - -# Reuse the same dep — second build should NOT re-clone. -build2=$("$MCPP" build 2>&1) -echo "$build2" | grep -q 'Cloning' && { echo "FAIL: re-cloned on rebuild"; exit 1; } || true - -# 3. Branch refs should update when the user asks for mcpp update. -mkdir "$TMP/branch-origin" && cd "$TMP/branch-origin" -git init --quiet -git checkout -B main --quiet -git config user.email "test@local" -git config user.name "test" -"$MCPP" new branchlib >/dev/null -mv branchlib/* branchlib/.??* . 2>/dev/null || true -rmdir branchlib 2>/dev/null || true -cat > src/greet.cppm <<'EOF' -export module branchlib.greet; -import std; -export auto greet() -> void { std::println("branch dep v1"); } -EOF -rm src/main.cpp -cat > mcpp.toml <<'EOF' -[package] -name = "branchlib" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cppm"] -[targets.branchlib] -kind = "lib" -EOF -git add -A >/dev/null -git commit --quiet -m "v1" - -cd "$TMP" -"$MCPP" new branchapp >/dev/null -cd branchapp -cat > src/main.cpp <<'EOF' -import std; -import branchlib.greet; -int main() { greet(); return 0; } -EOF -cat > mcpp.toml < branch-v1.log 2>&1 -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$(${triple}${fp_dir}/bin/branchapp) -[[ "$out" == *"branch dep v1"* ]] || { - echo "FAIL: branch dep v1 not invoked: $out" - cat branch-v1.log; exit 1; } - -# A rebuild with the lock in place must resolve the branch dep from the -# recorded commit rather than calling `git ls-remote`. -# -# `mcpp clean` first: a bare `mcpp build` here would take the fast path in -# cmd_build (build.ninja is newer than every input → just run ninja), so -# prepare_build never runs and neither the anchor nor `ls-remote` would be -# exercised — the assertion would pass identically without the feature. clean -# wipes only `target/`, so mcpp.lock and the git cache both survive. -# -# Run under MCPP_OFFLINE as well: with the commit in the lock and the clone on -# disk there is nothing left to fetch, so this must hold with the network -# refused. It also pins the local-remote rule — this fixture's `git =` is a -# directory path, which costs no network and must never be refused offline. -"$MCPP" clean >/dev/null -build2=$(MCPP_OFFLINE=1 "$MCPP" build 2>&1) || { - echo "FAIL: offline rebuild with lock + cache present failed" - echo "--- build2 ---"; cat <<<"$build2"; exit 1; } -echo "$build2" | grep -q 'from mcpp.lock' || { echo "FAIL: branch dep not resolved from mcpp.lock on rebuild"; echo "--- build2 ---"; cat <<<"$build2"; exit 1; } -echo "$build2" | grep -q 'Cloning' && { echo "FAIL: branch dep re-cloned on rebuild"; exit 1; } || true - -grep -q 'source = "git+' mcpp.lock || { - echo "FAIL: git dep lock source is not marked as git" - cat mcpp.lock; exit 1; } -grep -q 'branch=main' mcpp.lock || { - echo "FAIL: git dep lock source does not record branch ref" - cat mcpp.lock; exit 1; } -! grep -q 'source = "index+' mcpp.lock || { - echo "FAIL: git dep lock source incorrectly uses index source" - cat mcpp.lock; exit 1; } - -cd "$TMP/branch-origin" -cat > src/greet.cppm <<'EOF' -export module branchlib.greet; -import std; -export auto greet() -> void { std::println("branch dep v2"); } -EOF -git add -A >/dev/null -git commit --quiet -m "v2" - -cd "$TMP/branchapp" - -# mcpp.lock is authoritative, not a hint the cache has to confirm. The branch -# now points at v2 and the git cache is gone, so the only surviving record of -# what this project builds is the commit in mcpp.lock — and the build must -# still produce v1 from it. If the lock were merely an optimisation over the -# cache, evicting ~/.mcpp/git would silently advance the build to v2. -rm -rf "$MCPP_HOME/git" -"$MCPP" clean >/dev/null -"$MCPP" build > branch-evicted.log 2>&1 || { - echo "FAIL: rebuild after git-cache eviction" - cat branch-evicted.log; exit 1; } -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$(${triple}${fp_dir}/bin/branchapp) -[[ "$out" == *"branch dep v1"* ]] || { - echo "FAIL: git-cache eviction silently advanced the locked branch: $out" - echo "--- branch-evicted.log ---"; cat branch-evicted.log - echo "--- mcpp.lock ---"; cat mcpp.lock; exit 1; } - -"$MCPP" update branchlib >/dev/null -"$MCPP" clean >/dev/null -"$MCPP" build > branch-v2.log 2>&1 -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$(${triple}${fp_dir}/bin/branchapp) -[[ "$out" == *"branch dep v2"* ]] || { - echo "FAIL: branch dep did not refresh after mcpp update: $out" - echo "--- branch-v2.log ---" - cat branch-v2.log - echo "--- mcpp.lock ---" - cat mcpp.lock - exit 1 -} - -# Cleanup so trap doesn't blow up if any subprocess holds files. -echo "OK" diff --git a/tests/e2e/250_pack_names_the_target.sh b/tests/e2e/250_pack_names_the_target.sh deleted file mode 100755 index bb7c4264a..000000000 --- a/tests/e2e/250_pack_names_the_target.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: a library package is claimed to work on every target, so this -# test has to RUN on every platform. `# requires: gcc` would have skipped it on -# macOS and Windows — Apple Clang is not the gcc capability — leaving the claim -# unverified while the suite stayed green.) -# 250_pack_names_the_target.sh — `mcpp pack ` packs the target it was -# given, and refuses a name it cannot pack. -# -# The positional is what decides application-bundle vs library-package, so it -# has to reach BOTH pipelines. It did not reach the application one at first: -# a project with two `bin` targets accepted `mcpp pack app2` and bundled app1, -# which is the shape where the command succeeds and the answer is wrong. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p two/src -cat > two/src/alpha.cpp <<'EOF' -#include -int main() { std::printf("alpha\n"); return 0; } -EOF -cat > two/src/beta.cpp <<'EOF' -#include -int main() { std::printf("beta\n"); return 0; } -EOF -cat > two/mcpp.toml <<'EOF' -[package] -name = "two" -version = "0.1.0" - -[targets.alpha] -kind = "bin" -main = "src/alpha.cpp" - -[targets.beta] -kind = "bin" -main = "src/beta.cpp" -EOF - -cd two - -# The same legs on every host. A Mach-O program packs as the others do -# (2026.9.14.2+), so the named program is looked for in the staged tree on -# macOS too, rather than in the name an earlier refusal carried. - -# ── the named program is the one that gets bundled ───────────────────── -"$MCPP" pack beta --mode system > beta.log 2>&1 || { cat beta.log; echo "pack beta failed"; exit 1; } -staged="$(find target/dist -maxdepth 1 -type d -name 'two-0.1.0*' | head -1)" -[[ -n "$staged" ]] || { cat beta.log; echo "no staging dir"; exit 1; } -find "$staged" -type f -name 'beta*' | grep -q . || { - echo "FAIL: 'mcpp pack beta' did not bundle beta"; find "$staged" -type f; exit 1; } -find "$staged" -type f -name 'alpha*' | grep -q . && { - echo "FAIL: 'mcpp pack beta' bundled alpha instead"; find "$staged" -type f; exit 1; } - -# ── and the other one, to prove the first result was not the default ─── -rm -rf target/dist -"$MCPP" pack alpha --mode system > alpha.log 2>&1 || { cat alpha.log; echo "pack alpha failed"; exit 1; } -staged="$(find target/dist -maxdepth 1 -type d -name 'two-0.1.0*' | head -1)" -find "$staged" -type f -name 'alpha*' | grep -q . || { - echo "FAIL: 'mcpp pack alpha' did not bundle alpha"; find "$staged" -type f; exit 1; } - -# ── an unknown name is refused, and says what there is ───────────────── -if "$MCPP" pack nosuch --mode system > bad.log 2>&1; then - cat bad.log; echo "FAIL: an unknown target name was accepted"; exit 1 -fi -grep -q "no target named 'nosuch'" bad.log || { cat bad.log; echo "wrong refusal"; exit 1; } -grep -q 'alpha' bad.log && grep -q 'beta' bad.log || { - cat bad.log; echo "the refusal did not list the available targets"; exit 1; } - -echo "PASS: mcpp pack packs the target it is given" diff --git a/tests/e2e/251_pack_library_shared.sh b/tests/e2e/251_pack_library_shared.sh deleted file mode 100755 index 26ca02880..000000000 --- a/tests/e2e/251_pack_library_shared.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 251_pack_library_shared.sh — a `kind = "shared"` package is either produced -# CORRECTLY or refused CLEARLY. Never silently wrong. -# -# Both halves, because testing only the working one cannot tell "the gate is -# handled" from "there is no gate". Which half applies is now a property of the -# TARGET, not of "is it Linux": -# -# ELF, Mach-O, PE/MinGW, PE/MSVC produced — the package carries every name -# the platform needs to link it and to find -# it later -# -# Every format mcpp targets now produces one. MSVC was the last holdout, and -# what it was missing was never the linker: it exports nothing from a DLL -# without `__declspec(dllexport)` or a `.def`, so mcpp generates the `.def` from -# the objects (258 pins that path in detail, 259 pins Mach-O relocatability, 257 -# the PE/MinGW one). What this file adds is that one fixture and one command -# produce a usable package on whichever platform it runs. -# -# A shared library is LINKED by `lib.so` and FOUND at run time by its -# SONAME, and those are two different filenames. The first version of this -# packer shipped only the built file: the consumer linked, and then mcpp's own -# runtime-closure check reported -# -# libmathkit.so.1 not found on the search path this artifact will actually use -# -# which is the good outcome only because that check exists. Without it the -# program would have failed to start with a loader error naming a file the user -# never asked for. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit-shared] -kind = "shared" -soname = "libmathkit.so.1" -EOF - -cd mathkit - -# ── Windows and macOS: it is produced; the deep claims live in 258 / 259 ── -if [[ "$(uname -s)" != "Linux" ]]; then - "$MCPP" pack mathkit-shared > pack.log 2>&1 \ - || { cat pack.log; echo "FAIL: shared pack failed off ELF"; exit 1; } - nonelf="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" - # `.dylib` on macOS, `.dll` on Windows — asked for by shape rather than by - # `uname`, so the assertion is about the artifact and not about the host. - [[ -n "$(find "$nonelf" \( -name '*.dylib' -o -name '*.dll' \) | head -1)" ]] || { - find "$nonelf" \( -type f -o -type l \) - echo "FAIL: no shared library in the package"; exit 1; } - echo "PASS: a shared library package is produced off ELF too" - exit 0 -fi - -# ── the ELF side: produce it, with both names ────────────────────────── -"$MCPP" pack mathkit-shared > pack.log 2>&1 || { cat pack.log; echo "shared pack failed"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -# The manifest below is FILE CONTENT: on Git Bash a shell-spelled -# /tmp/... path is read by a native mcpp.exe as "root of the current -# drive". host_path is the conversion (tests/e2e/_host_path.sh). -PKG_HOST="$(host_path "$pkg")" - -libdir="$(dirname "$(find "$pkg/lib" -name 'libmathkit-shared.so' | head -1)")" -[[ -n "$libdir" ]] || { echo "no .so in the package"; find "$pkg" -type f; exit 1; } -# Both names. The SONAME one may be a symlink or a copy — either is fine, its -# absence is not. -[[ -e "$libdir/libmathkit.so.1" ]] || { - echo "FAIL: the package does not carry the SONAME the loader will ask for" - ls -l "$libdir"; exit 1; } - -# The manifest declares it as a shared library and gives a runtime search dir: -# link_library_dirs is not rpath, and a shared package needs both. -grep -q 'role *= *"shared-library"' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml"; echo "artifact is not recorded as a shared library"; exit 1; } -grep -q 'runtime_search_dirs' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml"; echo "no runtime_search_dirs — the consumer could not find it"; exit 1; } - -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < run.log 2>&1 ) || { cat app/run.log; echo "consumer failed to run"; exit 1; } -grep -q 'ok=42' app/run.log || { cat app/run.log; echo "wrong answer"; exit 1; } - -exe="$(find app/target -name app -type f | head -1)" -readelf -d "$exe" 2>/dev/null | grep -q 'libmathkit.so.1' || { - readelf -d "$exe"; echo "the consumer does not NEED the soname"; exit 1; } - -echo "PASS: a shared library package carries both names and the consumer starts" diff --git a/tests/e2e/252_pack_library_old_client.sh b/tests/e2e/252_pack_library_old_client.sh deleted file mode 100755 index f220c7bc0..000000000 --- a/tests/e2e/252_pack_library_old_client.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 252_pack_library_old_client.sh — an mcpp that predates library packaging must -# still BUILD against a package produced by one that has it. -# -# That claim is the reason the generated manifest introduces no section and no -# key: everything in it — `sources`, `include_dirs`, `[modules] exports`, a -# `cfg(...)` block per leg, `[[runtime.artifacts]]` — was already parsed before -# this feature existed. An older client reads the package and links it; what it -# does not do is run the gates, because it has no way to know that -# `provenance = "mcpp-pack …"` means anything. -# -# Two halves, because only one of them can run everywhere: -# -# 1. STATIC — the generated manifest's top-level sections are a subset of the -# vocabulary that predates this feature. Portable, and it is the actual -# invariant rather than a proxy for it. -# 2. REAL — consume the package with $MCPP_BOOT, the released mcpp each CI job -# bootstraps from. Skipped with a loud note when that is not available, -# never silently. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -PKG_HOST="$(host_path "$pkg")" - -# ── 1. no section outside the pre-existing vocabulary ────────────────── -# -# Listed literally rather than derived: the point is that this set was frozen -# before the feature, so a new entry has to be added here deliberately — and -# adding one is exactly the moment to ask whether older clients can still read -# the package. -# `\[\[?` covers both a table and an array-of-tables header: `[[runtime.artifacts]]` -# is the same section as `[runtime]` for this purpose, and the first version of -# this pattern matched only the single-bracket form — so it flagged the very -# section the design deliberately reuses. -known='^\[\[?(package|build|modules|targets\.|target\.|dependencies|dev-dependencies|runtime|profile\.|features|lib|pack|workspace|indices|resources|xlings|capabilities|tools)' -bad="$(grep -E '^\[' "$pkg/mcpp.toml" | grep -Ev "$known" || true)" -[[ -z "$bad" ]] || { - echo "FAIL: the generated manifest uses sections an older mcpp cannot read:" - printf '%s\n' "$bad" - echo " Either express the fact with an existing key, or accept that packages" - echo " need a version floor — and say so in docs/12." - exit 1; } - -# ── 2. the released client actually builds against it ────────────────── -mkdir -p "$TMP/app/src" -cat > "$TMP/app/src/main.cpp" <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > "$TMP/app/mcpp.toml" < new.log 2>&1 ) \ - || { cat "$TMP/app/new.log"; echo "the PR binary could not consume its own package"; exit 1; } -grep -q 'ok=42' "$TMP/app/new.log" || { cat "$TMP/app/new.log"; echo "wrong answer"; exit 1; } - -# The boot entry each CI job bootstraps from is an xvm SHIM, and a shim -# resolves against the home it is asked in — under the e2e suite's environment -# it answers `xlings: 'mcpp' is not installed` and prints NOTHING for -# `--version`. The first version of this guard compared that empty string -# against the PR binary's version, found them "different", and concluded it had -# found an old client — then reported a compatibility failure against a package -# that is perfectly readable. So the guard demands a version-SHAPED answer; -# anything else means "no usable old binary here", which is a note, not a -# verdict. -boot_ver="" -new_ver="$("$MCPP" --version 2>/dev/null || true)" -if [[ -n "${MCPP_BOOT:-}" && -x "${MCPP_BOOT}" ]]; then - boot_ver="$("$MCPP_BOOT" --version 2>/dev/null || true)" -fi -usable=0 -case "$boot_ver" in - mcpp\ [0-9]*) usable=1 ;; -esac - -if [[ "$usable" == 1 && "$boot_ver" != "$new_ver" ]]; then - echo "old client: $boot_ver" - rm -rf "$TMP/app/target" - ( cd "$TMP/app" && "$MCPP_BOOT" run > old.log 2>&1 ) || { - cat "$TMP/app/old.log" - echo "FAIL: the released mcpp cannot build against a package this one produced." - echo " The compatibility claim in docs/12 is then false: such packages" - echo " need a version floor, and publishing one without it bricks older" - echo " clients rather than degrading them." - exit 1; } - grep -q 'ok=42' "$TMP/app/old.log" || { - cat "$TMP/app/old.log"; echo "the old client built it but ran it wrong"; exit 1; } - echo "PASS: a released mcpp builds and runs against a package from this one" -else - if [[ -n "${MCPP_BOOT:-}" && "$usable" != 1 ]]; then - echo "NOTE: \$MCPP_BOOT=${MCPP_BOOT} does not answer --version with a version" - echo " (got: '${boot_ver}'), so it is not a usable old client here — a" - echo " shim resolves against the home it is asked in. The REAL old-client" - echo " check therefore did not run; run it with MCPP_BOOT pointing at a" - echo " released mcpp binary directly." - else - echo "NOTE: \$MCPP_BOOT is unset or identical to \$MCPP — the real old-client" - echo " check did not run here." - fi - echo "PASS: the generated manifest introduces no section an older mcpp cannot read" -fi diff --git a/tests/e2e/253_pack_library_undetermined_partition.sh b/tests/e2e/253_pack_library_undetermined_partition.sh deleted file mode 100755 index 2869412bc..000000000 --- a/tests/e2e/253_pack_library_undetermined_partition.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: nothing here is toolchain-specific — the fixture uses a local -# module rather than `import std`, and both assertions read mcpp's own output.) -# -# 253_pack_library_undetermined_partition.sh — when mcpp CANNOT TELL whether a -# published partition is an interface partition or an implementation one, it has -# to say so. -# -# `mcpp pack` publishes the module closure of the lib root as SOURCE. Whether a -# partition's source may travel depends on one keyword: -# -# export module M:api; interface partition — publishing it is the point -# module M:impl; implementation partition — publishing it is a leak -# -# The text scanner reads that keyword. Two other paths do not: -# -# * `[scan_overrides.""]` names the modules a file provides and has -# nowhere to say whether the declaration carries `export`; -# * a P1689 scanner may omit `is-interface` (the key is optional). -# -# Both used to arrive as "interface" — the value that produces NO warning. So an -# implementation partition declared in `[scan_overrides]` was published in -# silence, which is the one failure mode the whole closure design exists to -# prevent. -# -# PINNED FROM BOTH SIDES, on purpose. Asserting only that the override case -# warns cannot distinguish "mcpp models three states" from "mcpp warns about -# every partition it publishes". So the same fixture is packed twice — once -# scanned, once overridden — and the two must produce DIFFERENT sentences: -# -# scanned → "secret.cppm is an implementation partition" (it knows) -# overridden → "cannot tell which kind" (it doesn't) -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -# The root interface reaches the partition, so the partition is published either -# way — this test is about WHAT MCPP SAYS while publishing it, not about whether -# it does (243 pins that). -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -import :secret; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/secret.cppm <<'EOF' -module mathkit:secret; -namespace mk { int secret_bias() { return 40; } } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { -int secret_bias(); -int answer() { return secret_bias() + 2; } -} -EOF - -manifest() { # $1 = extra manifest body (the override, or nothing) - # Absolute: this is called from inside mathkit/ the second time, and a - # relative path there writes a manifest nobody reads (or nothing at all). - cat > "$TMP/mathkit/mcpp.toml" < scanned.log 2>&1 || { cat scanned.log; echo "pack failed"; exit 1; } -grep -q 'secret.cppm is an implementation partition' scanned.log || { - cat scanned.log - echo "FAIL: the scanned case lost its known-implementation-partition warning" - exit 1; } -grep -q 'cannot tell which kind' scanned.log && { - cat scanned.log - echo "FAIL: mcpp read \`module mathkit:secret;\` and still claims it cannot tell." - echo " Then the undetermined state is not a state, it is every partition," - echo " and the warning carries no information." - exit 1; } - -# ── side 2: overridden. Nobody told mcpp whether it is exported. ──────── -# -# The override declares exactly what the scanner would have found, EXCEPT the -# one thing the schema cannot express — so the graph is identical apart from the -# unknown, and any difference in output is attributable to it alone. -manifest ' -[scan_overrides."src/secret.cppm"] -provides = ["mathkit:secret"]' -rm -rf target -"$MCPP" pack mathkit > overridden.log 2>&1 || { cat overridden.log; echo "override pack failed"; exit 1; } -grep -q 'cannot tell which kind' overridden.log || { - cat overridden.log - echo "FAIL: a partition declared in [scan_overrides] was published without a word." - echo " That is the silent half of the asymmetry: too FEW published sources" - echo " fails the consumer's compile, too MANY ships private source and" - echo " nothing fails. Unknown must warn." - exit 1; } -grep -q 'secret.cppm is an implementation partition' overridden.log && { - cat overridden.log - echo "FAIL: mcpp asserted it IS an implementation partition. Nothing told it so —" - echo " the override cannot say, and stating it anyway is a guess wearing" - echo " a diagnostic's clothes." - exit 1; } - -# It is still published: the consumer cannot build the root's BMI without it. -pkg="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -[[ -e "$pkg/interface/secret.cppm" ]] || { - echo "FAIL: the warning fired but the source was not published — the consumer" - echo " would fail to compile the interface it was shipped." - exit 1; } -PKG_HOST="$(host_path "$TMP/mathkit/$pkg")" - -# ── and the package a warned-about pack produces still works ──────────── -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < run.log 2>&1 ) || { cat app/run.log; echo "consumer failed"; exit 1; } -grep -q 'ok=42' app/run.log || { cat app/run.log; echo "wrong answer"; exit 1; } - -echo "PASS: an undetermined partition is published loudly, and it says so differently" diff --git a/tests/e2e/254_pack_library_platform_coverage.sh b/tests/e2e/254_pack_library_platform_coverage.sh deleted file mode 100755 index 45b0dc1db..000000000 --- a/tests/e2e/254_pack_library_platform_coverage.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: every assertion reads mcpp's own output about a manifest key.) -# -# 254_pack_library_platform_coverage.sh — `[package] platforms` is a support -# CLAIM, and `mcpp pack` is where it first becomes checkable against evidence: -# the legs in the package are the platforms it can actually serve. -# -# THE HARD PART IS NOT FINDING THE GAP, IT IS NOT SHOUTING ABOUT IT. -# -# Four comparisons exist and only two may be printed: -# -# packed, not declared always actionable — the manifest disclaims a -# platform the package demonstrably serves. -# declared, not packed actionable ONLY IF THIS HOST COULD HAVE BUILT IT. -# The normal release flow is one `mcpp pack` per -# platform in CI, so a Linux runner producing no -# macos leg is not an omission, it is every single -# run. A warning that fires on every run is one -# nobody reads, and then the real one is invisible -# too. -# -# So the silence is as much the feature as the warning, and both are asserted. -# -# THE PER-HOST PICKS BELOW MIRROR THE PROVIDERS' `hosts` (data/builtin.toml, -# which `host_can_serve` reads since 2026.11.12.1). That is deliberate and it -# is a tripwire: when a provider's hosts change, this test starts failing — -# which is the correct outcome, because the table it encodes will have changed -# and the expectations here have to be re-derived rather than assumed. It -# tripped once, as predicted: the `mingw-llvm` provider (2026.11.10.1) serves -# the GNU-ABI Windows rows on every host, a macOS host included, and the -# function this test mirrored then still said a macOS host builds for macOS -# alone. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF - -manifest() { # $1 = the platforms array body, e.g. '"macos"' - cat > "$TMP/mathkit/mcpp.toml" < probe.log 2>&1 || { cat probe.log; echo "pack failed"; exit 1; } -host_triple="$(find target -mindepth 1 -maxdepth 1 -type d ! -name dist -exec basename {} \; | head -1)" -case "$host_triple" in - *-linux-*|*-linux) HOST_OS=linux ;; - *-macos*) HOST_OS=macos ;; - *-windows-*) HOST_OS=windows ;; - *) echo "FAIL: could not read the host platform out of target/$host_triple"; exit 1 ;; -esac -echo "host platform: $HOST_OS (from target/$host_triple)" - -# Per host: one platform this host CANNOT serve, and one it can but did not pack. -case "$HOST_OS" in - linux) UNSERVABLE=macos SERVABLE_UNPACKED=windows ;; - windows) UNSERVABLE=macos SERVABLE_UNPACKED=linux ;; - # macOS has no Linux-targeting payload (a Linux identity is the graph's - # there, which is not this question), and it builds PE through the - # MinGW-w64 base package as every host does. - macos) UNSERVABLE=linux SERVABLE_UNPACKED=windows ;; -esac - -# ── 1. packed, not declared ───────────────────────────────────────────── -manifest "\"$UNSERVABLE\"" -rm -rf target -"$MCPP" pack mathkit > undeclared.log 2>&1 || { cat undeclared.log; echo "pack failed"; exit 1; } -grep -q "ships a $HOST_OS binary" undeclared.log || { - cat undeclared.log - echo "FAIL: the package ships a $HOST_OS artifact while [package] platforms lists" - echo " only $UNSERVABLE, and mcpp said nothing. The manifest disclaims a" - echo " platform the package serves, which is the claim consumers resolve against." - exit 1; } - -# ── 2. declared and unservable here: SILENCE ──────────────────────────── -# -# The half that keeps the warning worth reading. Asserted on its own manifest so -# a stray match from case 1 cannot satisfy it. -manifest "\"$HOST_OS\", \"$UNSERVABLE\"" -rm -rf target -"$MCPP" pack mathkit > quiet.log 2>&1 || { cat quiet.log; echo "pack failed"; exit 1; } -grep -q "claims $UNSERVABLE" quiet.log && { - cat quiet.log - echo "FAIL: mcpp asked for a $UNSERVABLE leg on a $HOST_OS host, which cannot" - echo " build one. That warning would fire on every release run of every" - echo " cross-platform package, and a warning that always fires hides the" - echo " one that matters." - exit 1; } -grep -q "ships a $HOST_OS binary" quiet.log && { - cat quiet.log - echo "FAIL: $HOST_OS is declared AND packed, and mcpp still complained about it" - exit 1; } - -# ── 3. declared, servable here, not packed ────────────────────────────── -if [[ -n "$SERVABLE_UNPACKED" ]]; then - manifest "\"$HOST_OS\", \"$SERVABLE_UNPACKED\"" - rm -rf target - "$MCPP" pack mathkit > gap.log 2>&1 || { cat gap.log; echo "pack failed"; exit 1; } - grep -q "claims $SERVABLE_UNPACKED" gap.log || { - cat gap.log - echo "FAIL: [package] platforms claims $SERVABLE_UNPACKED, this host can build" - echo " for it, no such leg was packed, and mcpp said nothing. Consumers" - echo " on $SERVABLE_UNPACKED resolve this package and find no artifact." - exit 1; } - echo "PASS: coverage gaps are reported, and unservable platforms are not" -else - echo "NOTE: a $HOST_OS host serves exactly one target, so there is no" - echo " servable-but-unpacked platform to assert here. Case 3 did not run." - echo "PASS: an undeclared platform is reported, an unservable one stays quiet" -fi diff --git a/tests/e2e/255_pack_library_msvc_archiver.sh b/tests/e2e/255_pack_library_msvc_archiver.sh deleted file mode 100755 index a92b53f0f..000000000 --- a/tests/e2e/255_pack_library_msvc_archiver.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc -# 255_pack_library_msvc_archiver.sh — packing a library with the MSVC toolchain -# actually runs `lib.exe /REMOVE:`. -# -# WHY THIS EXISTS. `mcpp pack` deletes the published interface's objects from the -# archive before shipping it: the consumer compiles those sources itself, so -# leaving the objects in gives it two definitions of each published module's -# initialiser, resolved by link order. The two archivers disagree about how to -# say that, in both directions: -# -# ar one verb, then the archive, then every member -# ar d libmathkit.a mathkit.m.o api.m.o -# lib.exe one flag PER member, and the archive comes LAST -# lib.exe /REMOVE:mathkit.m.o /REMOVE:api.m.o mathkit.lib -# -# The packer originally assumed `ar` syntax everywhere. Nothing caught it, -# because mcpp's own Windows CI builds with clang and archives with `llvm-ar`, -# which takes the GNU spelling — so the MSVC branch has never executed in any -# job on any platform. test_pack_archive_remove.cpp pins the string; only a real -# `lib.exe` can tell whether the string is right. -# -# WHY PACK SUCCEEDING *IS* THE ASSERTION. A wrong spelling does not degrade -# quietly here: run_library_pack refuses, quoting the command and the archiver's -# output, because shipping the objects would be the silent outcome. So this test -# does not need to inspect the archive to know the branch worked — but it does -# have to prove the branch was TAKEN, and that is what the two checks below are -# for: a `.lib` (so the family is MSVC, not llvm-ar's `.a`) and a non-empty -# published interface (so there was something to remove at all). -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export import :api; -EOF -cat > mathkit/src/api.cppm <<'EOF' -export module mathkit:api; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -# msvc@system pinned, not defaulted: Windows' default toolchain is clang -# targeting the MSVC ABI, and it archives with llvm-ar — i.e. taking the default -# here would exercise the GNU branch and pass while proving nothing. -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -[toolchain] -windows = "msvc@system" -EOF - -cd mathkit -"$MCPP" pack mathkit > pack.log 2>&1 || { - cat pack.log - echo "FAIL: packing with msvc@system failed." - echo " If the message above is 'cannot drop published interface objects'," - echo " the lib.exe spelling is wrong — /REMOVE: takes one flag per member" - echo " and the archive comes LAST (src/pack/library.cppm)." - exit 1; } - -pkg="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -[[ -n "$pkg" ]] || { cat pack.log; echo "FAIL: no package directory"; exit 1; } - -# The branch was taken: MSVC names archives `.lib` with no `lib` prefix, -# so finding one means `archive_tool` resolved to lib.exe rather than llvm-ar. -archive="$(find "$pkg/lib" -type f -name 'mathkit.lib' | head -1)" -[[ -n "$archive" ]] || { - find "$pkg/lib" -type f - echo "FAIL: no mathkit.lib in the package — this ran with a GNU-spelling" - echo " archiver, so the lib.exe path was never exercised." - exit 1; } - -# And there was something to remove: an empty interface list means dropObjects -# is empty and the removal is skipped entirely. -[[ -f "$pkg/interface/mathkit.cppm" && -f "$pkg/interface/api.cppm" ]] || { - ls -R "$pkg" - echo "FAIL: nothing was published as source, so nothing had to be removed" - exit 1; } - -# Best-effort member inspection. `lib.exe` lives in the VC toolset, not on PATH, -# so this is a bonus rather than the criterion — mcpp resolves it internally and -# the pack above already depended on it working. -if command -v lib &>/dev/null && members="$(lib /nologo /LIST "$(host_path "$archive")" 2>/dev/null)"; then - echo "$members" | grep -qi 'impl' || { - echo "$members" - echo "FAIL: the implementation object is gone — nothing would link" - exit 1; } - echo "$members" | grep -qi 'api\.m\.obj' && { - echo "$members" - echo "FAIL: a published interface unit's object is still in the archive" - exit 1; } - echo " (verified against lib /LIST)" -fi - -# NO CONSUMER STEP, and the reason is a measured one rather than a -# simplification. The package is built with msvc@system, so its tag is -# `…-msvc19-…`; the consumer would have to use the same toolchain or the ABI gate -# refuses it — correctly: -# -# error: no prebuilt artifact matches this toolchain. -# your toolchain : x86_64-windows-msvc-clang20-msvcstl20-c++23 -# published tags : x86_64-windows-msvc-msvc19-unknownstl0-c++23 -# -# And a consumer that DOES pin msvc@system cannot link the package either: the -# emitted manifest spells its link flags `-Llib/ -l`, and native -# cl.exe rejects `-L` (docs/12 records that limit). So the only consumer this test -# could have is one whose failure says nothing about the archiver. -# -# That is fine, because pack succeeding IS the assertion: a wrong /REMOVE: -# spelling makes run_library_pack refuse, quoting the command and lib.exe's own -# output. 242 covers consumption on Windows, with the default toolchain on both -# sides. - -echo "PASS: lib.exe /REMOVE: really removes, and the package is well-formed" diff --git a/tests/e2e/256_pack_library_fat_windows.sh b/tests/e2e/256_pack_library_fat_windows.sh deleted file mode 100755 index c32e1e990..000000000 --- a/tests/e2e/256_pack_library_fat_windows.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc mingw -# 256_pack_library_fat_windows.sh — a fat package on Windows: an MSVC leg and a -# MinGW leg in ONE package, each selected by its own predicate. -# -# WHY THIS IS THE SHARPEST VERSION OF THE TEST. 245 packs `linux-gnu` + -# `linux-musl`, and both legs are called `libmathkit.a`. Here they are not: -# -# x86_64-windows-msvc mathkit.lib (lib.exe, no `lib` prefix) -# x86_64-windows-gnu libmathkit.a (ar, GNU naming) -# -# Two different filenames for the same target in one package is the plainest -# possible evidence that `lib/` has to be keyed by TRIPLE rather than by -# platform — a layout keyed by "windows" could hold only one of these, and the -# one it held would link for exactly half of its consumers. -# -# WHY IT DID NOT EXIST BEFORE, AND WHY THAT WAS THE WRONG CALL. This -# combination was reported as "not possible on Windows". It is: host_can_serve -# (registry.cppm:542-566) grants a Windows host `*-windows-msvc`, -# `*-windows-gnu` AND host-arch `*-linux-musl` — three targets, so a fat package -# needs no cross-compilation trickery at all. What was actually missing was the -# MinGW payload in the Windows e2e job. macOS is the genuinely impossible case: -# it serves exactly one target ("macOS has no Linux-targeting payload at all"), -# so there is no second leg to pack there under any circumstances. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -EOF - -# The host arch, from mcpp's own answer rather than assumed: an aarch64 Windows -# runner would make every hardcoded `x86_64-` here a silent skip of the real work. -cd mathkit -"$MCPP" build > probe.log 2>&1 || { cat probe.log; echo "probe build failed"; exit 1; } -host_triple="$(find target -mindepth 1 -maxdepth 1 -type d ! -name dist -exec basename {} \; | head -1)" -ARCH="${host_triple%%-*}" -[[ -n "$ARCH" && "$host_triple" == *windows* ]] || { - echo "FAIL: expected a windows host triple, read '$host_triple'"; exit 1; } -rm -rf target - -MSVC_LEG="$ARCH-windows-msvc" -MINGW_LEG="$ARCH-windows-gnu" - -"$MCPP" pack mathkit --target "$MSVC_LEG" --target "$MINGW_LEG" > pack.log 2>&1 \ - || { cat pack.log; echo "fat pack failed"; exit 1; } - -pkg="$TMP/mathkit/target/dist/mathkit-0.1.0" -PKG_HOST="$(host_path "$pkg")" - -# ── the two legs, under their two different names ─────────────────────── -[[ -f "$pkg/lib/$MSVC_LEG/mathkit.lib" ]] || { - find "$pkg" -type f - echo "FAIL: the MSVC leg is not lib/$MSVC_LEG/mathkit.lib" - exit 1; } -[[ -f "$pkg/lib/$MINGW_LEG/libmathkit.a" ]] || { - find "$pkg" -type f - echo "FAIL: the MinGW leg is not lib/$MINGW_LEG/libmathkit.a" - exit 1; } -# Stated as an inequality too, because "both files exist" would still pass if the -# packer had put the same artifact in both directories. -[[ "$(basename "$pkg/lib/$MSVC_LEG/mathkit.lib")" \ - != "$(basename "$pkg/lib/$MINGW_LEG/libmathkit.a")" ]] || { - echo "FAIL: the two legs ended up with the same filename"; exit 1; } - -# Selected by predicate, never by a bare triple (inert on a native build — the -# defect 245's header describes). -grep -q "target\.'cfg(" "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml"; echo "FAIL: legs are not selected by cfg() predicates"; exit 1; } -grep -qE "^\[target\.'$ARCH-" "$pkg/mcpp.toml" && { - cat "$pkg/mcpp.toml" - echo "FAIL: a leg is selected by a BARE TRIPLE, which is inert on a native build" - exit 1; } -# An env axis must appear: `os = "windows"` alone cannot separate these two legs, -# and a predicate that cannot separate them would hand MSVC consumers the MinGW -# archive. Matched with optional spaces — the emitted spelling is `env = "msvc"`, -# and the first version of this grep looked for `env=`, which is nowhere in the -# file and failed against a package that was perfectly correct. -grep -qE 'env[[:space:]]*=' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml" - echo "FAIL: the predicates carry no env axis, so both legs match both targets" - exit 1; } - -# ── and each consumer resolves its own ────────────────────────────────── -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < "$TMP/$label.log" 2>&1 ) \ - || { cat "$TMP/$label.log"; echo "$label build failed"; exit 1; } - local nj; nj="$(find app/target -name build.ninja | head -1)" - # Separator-agnostic, and matched from the PACKAGE name rather than from - # `dist/`. A native mcpp.exe writes native separators into build.ninja, so - # the first version of this — `grep -o "dist/mathkit-0.1.0/lib/…"` — matched - # nothing at all on Windows and reported "saw more than one leg" over an - # empty list, which reads like a packaging bug and is a grep bug. - grep -oE "mathkit-0\.1\.0[\\/]lib[\\/][A-Za-z0-9_-]+" "$nj" \ - | sed 's|.*[\\/]||' | sort -u > "$TMP/$label.legs" - [[ "$(wc -l < "$TMP/$label.legs")" -eq 1 ]] || { - echo "$label saw $(wc -l < "$TMP/$label.legs") leg(s), expected exactly 1:" - cat "$TMP/$label.legs" - echo "--- lines mentioning the package ---" - grep -n 'mathkit' "$nj" | head -20 - exit 1; } - grep -qx "$want" "$TMP/$label.legs" || { - echo "$label picked the wrong leg:"; cat "$TMP/$label.legs"; exit 1; } -} - -# Native first: that is the case a bare-triple key silently failed. -check native "$MSVC_LEG" -check msvc "$MSVC_LEG" --target "$MSVC_LEG" -check mingw "$MINGW_LEG" --target "$MINGW_LEG" - -echo "PASS: one Windows package carries an MSVC leg and a MinGW leg, chosen apart" diff --git a/tests/e2e/258_shared_library_msvc_auto_def.sh b/tests/e2e/258_shared_library_msvc_auto_def.sh deleted file mode 100755 index f00e651c0..000000000 --- a/tests/e2e/258_shared_library_msvc_auto_def.sh +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc -# 258_shared_library_msvc_auto_def.sh — a DLL on the MSVC ABI, exported without -# a single `__declspec(dllexport)` in the source. -# -# MSVC exports nothing from a DLL unless the source says `__declspec(dllexport)` -# or a `.def` lists the symbols. Without either, the import library comes out -# empty and every consumer fails with unresolved externals for symbols that are -# plainly in the object files — a diagnostic pointing nowhere near its cause. -# MinGW's linker auto-exports and hides the whole problem; lld-link's MSVC -# flavour does not, deliberately, because PE caps exports at 65535. -# -# So mcpp writes the `.def` from the objects, which is what CMake's -# `WINDOWS_EXPORT_ALL_SYMBOLS` has done since 3.4. The filtering rules are unit -# tested against synthetic and real COFF (tests/unit/test_coff_exports.cpp); what -# only a real link can answer is whether link.exe accepts the result and whether -# a consumer can then resolve a symbol through it. That is this test. -# -# THE TWO LIMITS ARE ASSERTED, NOT ASSUMED. Auto-export cannot make a data -# symbol readable without `__declspec(dllimport)` on the consumer's declaration, -# and CMake documents the same limit for the same mechanism. The test exercises a -# FUNCTION across the boundary, which is what auto-export does cover, and says so -# — a test that quietly used only functions would read as if the limit did not -# exist. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -# No dllexport anywhere. That is the point. -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "shared" -[toolchain] -windows = "msvc@system" -EOF - -cd mathkit -"$MCPP" build > build.log 2>&1 || { - cat build.log - echo "FAIL: a kind=\"shared\" target did not build on the MSVC ABI." - echo " If the message mentions dllexport, the old refusal is still in place." - exit 1; } - -# ── the .def was generated, and it is a build-graph node ──────────────── -nj="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -grep -qE "^build .*mathkit\.def : coff_def " "$nj" || { - grep -n 'def' "$nj" | head - echo "FAIL: no coff_def edge. The .def has to be produced from the same" - echo " objects the link consumes, or the exported surface can drift" - echo " from what was compiled." - exit 1; } -def="$(find target -name 'mathkit.def' | head -1)" -[[ -n "$def" ]] || { echo "FAIL: no .def was written"; exit 1; } -grep -q '^EXPORTS' "$def" || { cat "$def"; echo "FAIL: malformed .def"; exit 1; } -# Non-empty is the criterion: an empty EXPORTS section links fine and produces -# exactly the import library this whole mechanism exists to avoid. -[[ "$(grep -cvE '^(LIBRARY|EXPORTS|$)' "$def")" -gt 0 ]] || { - cat "$def" - echo "FAIL: the .def exports nothing, so the import library is empty and" - echo " every consumer will fail with unresolved externals." - exit 1; } - -# ── the DLL and its import library both exist ─────────────────────────── -[[ -n "$(find target -name 'mathkit.dll' | head -1)" ]] || { echo "FAIL: no DLL"; exit 1; } -[[ -n "$(find target -name 'mathkit.lib' | head -1)" ]] || { - find target -name '*.lib' - echo "FAIL: no import library beside the DLL"; exit 1; } - -# ── and a consumer resolves a function through it ─────────────────────── -# -# A function, deliberately: auto-export covers code. Exported DATA additionally -# needs `__declspec(dllimport)` on the consumer's declaration — a limit of the -# mechanism, documented in docs/12, not a defect of this test. -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "FAIL: pack"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -PKG_HOST="$(host_path "$pkg")" - -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("msvc-dll=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < run.log 2>&1 ) || { - cat app/run.log - echo "FAIL: the consumer could not link or start against the MSVC DLL." - echo " 'unresolved external symbol' here means the .def did not reach" - echo " the link, or exported the wrong names." - exit 1; } -grep -q 'msvc-dll=42' app/run.log || { cat app/run.log; echo "FAIL: wrong answer"; exit 1; } - -# ── and an ANNOTATED library keeps its own surface ────────────────────── -# -# `__declspec(dllexport)` writes `/EXPORT:` directives into the object, and a -# generated list on top would export the same names twice (LNK4197) AND export -# everything else besides — replacing a chosen public surface with all of it. -# So the annotation wins and the generated `.def` stays empty. -cd "$TMP" -mkdir -p annotated/src -cat > annotated/src/a.cppm <<'EOF' -export module annkit; -export namespace ak { __declspec(dllexport) int chosen(); } -EOF -cat > annotated/src/a.cpp <<'EOF' -module annkit; -namespace ak { __declspec(dllexport) int chosen() { return 7; } } -EOF -cat > annotated/mcpp.toml <<'EOF' -[package] -name = "annkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.annkit] -kind = "shared" -[toolchain] -windows = "msvc@system" -EOF -( cd annotated && "$MCPP" build > build.log 2>&1 ) || { - cat annotated/build.log; echo "FAIL: the annotated library did not build"; exit 1; } -anndef="$(find annotated/target -name 'annkit.def' | head -1)" -[[ -n "$anndef" ]] || { echo "FAIL: no .def edge for the annotated library"; exit 1; } -[[ "$(grep -cvE '^(LIBRARY|EXPORTS|$)' "$anndef")" -eq 0 ]] || { - cat "$anndef" - echo "FAIL: mcpp generated an export list for a library that declares its own." - echo " That exports every other symbol too, which replaces the author's" - echo " chosen surface with all of it." - exit 1; } - -# ── the LIMIT, made reproducible ──────────────────────────────────────── -# -# Auto-export covers code. Exported DATA additionally needs -# `__declspec(dllimport)` on the CONSUMER's declaration — CMake documents the -# same limit for the same mechanism. Until now that lived in prose, and a claim -# about what does NOT work is exactly the kind that drifts: the generated `.def` -# already marks variables `DATA`, so it is reasonable to wonder whether the limit -# still bites. It does, and here is the difference. -# -# The assertion is on the DIFFERENCE between the two spellings, not on any -# particular error text — which form the failure takes (a link error, or a -# pointer read where a value was meant) depends on the toolset version, and -# pinning one of them would make this test a hostage to that. -cd "$TMP" -mkdir -p datalib/src -cat > datalib/src/datalib.cppm <<'EOF' -export module datalib; -export extern "C" int dl_value; -EOF -cat > datalib/src/datalib.cpp <<'EOF' -module datalib; -extern "C" int dl_value = 99; -EOF -cat > datalib/mcpp.toml <<'EOF' -[package] -name = "datalib" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.datalib] -kind = "shared" -[toolchain] -windows = "msvc@system" -EOF -( cd datalib && "$MCPP" build > build.log 2>&1 ) || { - cat datalib/build.log; echo "FAIL: the data library did not build"; exit 1; } - -# It IS exported, and marked DATA — so the limit is about the consumer's -# declaration, not about the export. -datadef="$(find datalib/target -name 'datalib.def' | head -1)" -grep -q 'dl_value DATA' "$datadef" || { - cat "$datadef" - echo "FAIL: the exported variable is not marked DATA. Without that the" - echo " linker generates a call thunk and the consumer reads the thunk." - exit 1; } - -consume() { # $1 = declaration, $2 = log - rm -rf "$TMP/dataapp" - mkdir -p "$TMP/dataapp/src" - cat > "$TMP/dataapp/src/main.cpp" < -$1 -int main(){ std::printf("data=%d\n", dl_value); return 0; } -EOF - local dep_host; dep_host="$(host_path "$TMP/datalib")" - cat > "$TMP/dataapp/mcpp.toml" < "$2" 2>&1 ) -} - -consume 'extern "C" int dl_value;' "$TMP/plain.log" && plain_ok=1 || plain_ok=0 -consume 'extern "C" __declspec(dllimport) int dl_value;' "$TMP/imported.log" && imported_ok=1 || imported_ok=0 - -if [[ "$imported_ok" != 1 ]]; then - cat "$TMP/imported.log" - echo "FAIL: even WITH __declspec(dllimport) the data symbol was unusable." - echo " Then the limit documented in docs/12 is not the whole story and" - echo " the documentation needs to say what actually holds." - exit 1 -fi -grep -q 'data=99' "$TMP/imported.log" || { - cat "$TMP/imported.log"; echo "FAIL: dllimport built but read the wrong value"; exit 1; } - -if [[ "$plain_ok" == 1 ]] && grep -q 'data=99' "$TMP/plain.log"; then - cat "$TMP/plain.log" - echo "NOTE: reading exported DATA without __declspec(dllimport) WORKED here." - echo " docs/12 (and CMake's documentation for the same mechanism) say it" - echo " should not. Either this toolset is more forgiving than the" - echo " documented contract, or the limit has stopped applying — and the" - echo " documentation is now the thing that is wrong." - exit 1 -fi -echo " (the limit reproduces: dllimport is required for exported data)" - -echo "PASS: MSVC exports without dllexport, defers to dllexport, and data still needs dllimport" diff --git a/tests/e2e/259_shared_library_macho.sh b/tests/e2e/259_shared_library_macho.sh deleted file mode 100755 index ca54f511d..000000000 --- a/tests/e2e/259_shared_library_macho.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# requires: macos -# 259_shared_library_macho.sh — `kind = "shared"` on Mach-O: a `.dylib` that can -# be moved, packed, and loaded from beside the consumer's binary. -# -# WHY THE INSTALL NAME IS THE WHOLE TEST. A Mach-O shared library records the -# name it will be FOUND by, and the default is the path it was LINKED at. So a -# library built in `/private/var/folders/…/target/…/bin` records that path, and -# the moment it is packed and extracted somewhere else, every consumer of it -# fails at load time looking for a directory that no longer exists — on the -# publisher's machine it works perfectly. -# -# `@rpath/` is the only default that survives being moved: it defers the -# question to the consumer, whose own `-Wl,-rpath,@loader_path` answers "next to -# me". mcpp used to emit `-install_name` only when the manifest declared a -# `soname`, and to decide Mach-O-ness with `#if defined(__APPLE__)` on the HOST -# rather than from the target. -# -# AND WHY IT ASSERTS THE PATH IS *GONE*. Checking that the consumer runs in -# place proves nothing: the absolute install name still resolves while the build -# directory exists. The package has to be consumed from a location the original -# build path cannot satisfy — so the library is packed, the producer's whole -# build tree is DELETED, and only then is the consumer built and run. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "shared" -EOF - -# ── 1. it builds, and it is a dylib ───────────────────────────────────── -cd mathkit -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -dylib="$(find target -name 'libmathkit.dylib' | head -1)" -[[ -n "$dylib" ]] || { find target -type f | head; echo "FAIL: no .dylib"; exit 1; } - -# ── 2. the install name is @rpath, not this machine's build path ───────── -# -# A functional probe, not `command -v otool`: on macOS a bare tool name can -# resolve to an xlings shim that reports "not installed" and exits non-zero, -# which under `set -e` would kill the test instead of skipping the inspection. -if install_name="$(otool -D "$dylib" 2>/dev/null | tail -1)"; then - case "$install_name" in - @rpath/libmathkit.dylib) ;; - *) - echo "FAIL: install name is '$install_name'" - echo " Anything absolute here is this machine's build directory, and the" - echo " library stops loading the moment it is extracted anywhere else." - exit 1 ;; - esac -else - echo "NOTE: otool unavailable — the install name was not inspected directly." - echo " Step 4 still fails if it is wrong, just with a loader error." -fi - -# ── 3. pack it ────────────────────────────────────────────────────────── -rm -rf target -"$MCPP" pack mathkit --pkg-format dir > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } -pkgrel="$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0*' | head -1)" -[[ -n "$pkgrel" ]] || { cat pack.log; echo "FAIL: no package"; exit 1; } -# OUT of the producer's tree, so the next step can delete that tree entirely. -pkg="$TMP/pkg" -cp -R "$TMP/mathkit/$pkgrel" "$pkg" -# `find`, not `[[ -f "$pkg/lib/"*"/libmathkit.dylib" ]]`: inside `[[ ]]` the `*` -# is not path-expanded, so that form tests a literal string containing an -# asterisk and is false for every real package — a check that can only fail. -[[ -n "$(find "$pkg/lib" -name 'libmathkit.dylib' | head -1)" ]] || { - find "$pkg" \( -type f -o -type l \) - echo "FAIL: no dylib in the package"; exit 1; } - -# ── 4. the producer's build tree is deleted, then the consumer runs ────── -# -# This is what makes the install-name assertion above load-bearing rather than -# decorative: after this `rm -rf`, an absolute install name names nothing. -rm -rf "$TMP/mathkit" - -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -PKG_HOST="$(host_path "$pkg")" -cat > app/mcpp.toml < run.log 2>&1 ) || { - cat app/run.log - echo "FAIL: the consumer could not build or run against the packaged dylib." - echo " 'image not found' naming a path under /private/var means the" - echo " install name was the build directory after all." - exit 1; } -grep -q 'ok=42' app/run.log || { cat app/run.log; echo "FAIL: wrong answer"; exit 1; } - -echo "PASS: a Mach-O shared library relocates — @rpath install name, packed, run" diff --git a/tests/e2e/25_convention_mode.sh b/tests/e2e/25_convention_mode.sh deleted file mode 100755 index 8e450577f..000000000 --- a/tests/e2e/25_convention_mode.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env bash -# requires: -# 25_convention_mode.sh — verify M5.0 convention-first schema: -# - 3-line mcpp.toml builds + runs successfully -# - Inferred banner shown for sources / target -# - Default include_dirs = ["include"] when ./include/ exists -# - Library auto-target inferred from .cppm presence (no main.cpp) -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# --- Test 1: minimal binary project (3-line mcpp.toml) --- -mkdir -p "$TMP/tinyapp/src" -cd "$TMP/tinyapp" -cat > mcpp.toml <<'EOF' -[package] -name = "tinyapp" -version = "0.1.0" -EOF -cat > src/main.cpp <<'EOF' -import std; -int main() { std::println("convention-mode bin OK"); return 0; } -EOF - -"$MCPP" build > build.log 2>&1 -grep -q 'Inferred sources \[src/\*\*' build.log || { cat build.log; echo "FAIL: no Inferred sources line"; exit 1; } -grep -q 'Inferred target tinyapp (bin from src/main.cpp)' build.log || { cat build.log; echo "FAIL: no Inferred target line"; exit 1; } -grep -q 'Compiling tinyapp v0.1.0' build.log || { cat build.log; echo "FAIL: not compiling"; exit 1; } - -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$("${triple}${fp_dir}/bin/tinyapp") -[[ "$out" == "convention-mode bin OK" ]] || { echo "FAIL: runtime out='$out'"; exit 1; } - -# --- Test 2: include/ dir present → default include_dirs picked up --- -mkdir -p "$TMP/inc/src" "$TMP/inc/include/inc" -cd "$TMP/inc" -cat > mcpp.toml <<'EOF' -[package] -name = "inc" -version = "0.1.0" -EOF -cat > include/inc/api.hpp <<'EOF' -#pragma once -inline int answer() { return 42; } -EOF -cat > src/main.cpp <<'EOF' -#include -import std; -int main() { std::println("answer = {}", answer()); return 0; } -EOF -"$MCPP" build > build.log 2>&1 -grep -q 'Inferred include_dirs \[include\]' build.log || { - cat build.log; echo "FAIL: include/ not auto-picked"; exit 1; } -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$("${triple}${fp_dir}/bin/inc") -[[ "$out" == "answer = 42" ]] || { echo "FAIL: include resolution: $out"; exit 1; } - -# --- Test 3: library project (no main.cpp, has .cppm) → auto kind=lib --- -mkdir -p "$TMP/mylib/src" -cd "$TMP/mylib" -cat > mcpp.toml <<'EOF' -[package] -name = "mylib" -version = "0.1.0" -EOF -cat > src/mylib.cppm <<'EOF' -export module mylib; -import std; -export auto greet() -> void { std::println("from auto-lib"); } -EOF -"$MCPP" build > build.log 2>&1 -grep -q 'Inferred target mylib (lib from .cppm in src/)' build.log || { - cat build.log; echo "FAIL: lib auto-target not inferred"; exit 1; } -grep -q 'Finished' build.log || { cat build.log; echo "FAIL: lib didn't finish"; exit 1; } - -# --- Test 4: backward-compat — old [language]/[modules] schema still works --- -mkdir -p "$TMP/legacy/src" -cd "$TMP/legacy" -cat > mcpp.toml <<'EOF' -[package] -name = "legacy" -version = "0.1.0" -[language] -standard = "c++23" -modules = true -import_std = true -[modules] -sources = ["src/**/*.cpp"] -[targets.legacy] -kind = "bin" -main = "src/main.cpp" -EOF -cat > src/main.cpp <<'EOF' -import std; -int main() { std::println("legacy schema OK"); return 0; } -EOF -"$MCPP" build > build.log 2>&1 -# Should NOT see Inferred lines (everything explicit). -if grep -q 'Inferred' build.log; then - cat build.log; echo "FAIL: legacy schema fired Inferred banner unexpectedly"; exit 1 -fi -triple=$(ls -d target/*/ | head -1) -fp_dir=$(ls "$triple") -out=$("${triple}${fp_dir}/bin/legacy") -[[ "$out" == "legacy schema OK" ]] || { echo "FAIL: legacy runtime: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/260_module_extension_is_configuration.sh b/tests/e2e/260_module_extension_is_configuration.sh deleted file mode 100755 index cc2a16836..000000000 --- a/tests/e2e/260_module_extension_is_configuration.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: mcpp states a module interface's LANGUAGE explicitly per -# toolchain, so a declared extension behaves the same on all three.) -# -# 260_module_extension_is_configuration.sh — `[build] module_extensions` is the -# knob, and everything downstream follows it without a second declaration. -# -# `.ixx` is NOT built in, on purpose: the extension set is configuration, not a -# list mcpp grows one entry at a time as extensions come into fashion. What that -# demands in return is two things, and this test is both of them: -# -# 1. a DECLARED extension works with no further help; -# 2. an UNDECLARED one is refused where the mistake is, not four steps later. -# -# WHY (2) IS THE INTERESTING HALF. Before this, an undeclared `.ixx` matched -# by `sources` was accepted and produced a compile edge whose object NOTHING -# LINKS — measured: -# -# build obj/mathkit.ixx.o | gcm.cache/mathkit.gcm : cxx_object … -# bmi_out = gcm.cache/mathkit.gcm ← the BMI was produced -# build bin/app : cxx_link obj/main.o ← the object is not here -# -# ld: undefined reference to `mk::answer@mathkit()' -# -# Two answers to "is this a module interface" and only one of them read: the -# scanner sees `export module` and records `provides`, which is why the edge got -# a `bmi_out`; the classifier says `Other`, and the link set is built from the -# classifier. So the author was told about a symbol when what happened was a -# missing line of configuration. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p proj/src -cat > proj/src/mathkit.ixx <<'EOF' -export module mathkit; -export namespace mk { int answer() { return 42; } } -EOF -cat > proj/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ixx-ok=%d\n", mk::answer()); return 0; } -EOF - -manifest() { # $1 = extra [build] body - cat > "$TMP/proj/mcpp.toml" < undeclared.log 2>&1; then - echo "FAIL: an undeclared '.ixx' was accepted." - nj="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" - grep -n 'mathkit' "$nj" | head - echo " Check whether its object is in the link edge — if it is not," - echo " this 'success' produces an undefined reference at link time." - exit 1 -fi -grep -q "no role for the extension '.ixx'" undeclared.log || { - cat undeclared.log - echo "FAIL: refused, but not for the classification reason. A message that" - echo " does not name the extension leaves the author with a symbol." - exit 1; } -grep -q "module_extensions" undeclared.log || { - cat undeclared.log - echo "FAIL: the message does not name the key that fixes it, so it is a" - echo " diagnosis without a remedy." - exit 1; } - -# ── 2. declared: works, on this toolchain, with nothing else ──────────── -manifest 'module_extensions = [".ixx"]' -rm -rf target -"$MCPP" run > declared.log 2>&1 || { cat declared.log; echo "FAIL: declared .ixx did not build"; exit 1; } -grep -q 'ixx-ok=42' declared.log || { cat declared.log; echo "FAIL: wrong answer"; exit 1; } - -echo "PASS: a declared module extension needs no further help; an undeclared one is refused" diff --git a/tests/e2e/261_pack_follows_the_module_extension.sh b/tests/e2e/261_pack_follows_the_module_extension.sh deleted file mode 100755 index 423defcf0..000000000 --- a/tests/e2e/261_pack_follows_the_module_extension.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: nothing here is toolchain-specific.) -# -# 261_pack_follows_the_module_extension.sh — `mcpp pack` follows the project's -# module extension on its own, and the package it writes states that extension -# for its consumers. -# -# The principle: a project says `module_extensions = [".ixx"]` ONCE. Packing it -# and consuming the package must both work with nothing further declared on -# either side. Anything else makes the knob a knob plus two things to remember. -# -# TWO DEFECTS SAT BEHIND THIS, and the first was silent in both directions. -# -# 1. The lib-root convention hard-coded `.cppm`: `src/.cppm`. For an -# `.ixx` project that file does not exist, so the closure started nowhere: -# -# $ mcpp pack mathkit -# Interface (headers only) ← the module interface, gone -# Withheld (nothing) -# Packed …-x86_64-linux-gnu ← the C-SURFACE tag -# -# Both halves wrong without a word. No consumer could `import mathkit`; and -# an empty published set is precisely how the packer recognises a C surface, -# so the package ALSO stopped constraining the C++ ABI and the compatibility -# gate stopped checking compiler and stdlib. A package that claims less than -# it should is the failure this whole design exists to prevent. -# -# 2. The generated manifest listed `sources = ["interface/mathkit.ixx"]` and did -# not say what an `.ixx` is, so the consumer was refused by the classifier. -# Now the package declares it — computed from the published FILES, so it -# cannot disagree with `sources`. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.ixx <<'EOF' -export module mathkit; -export import :api; -EOF -cat > mathkit/src/api.ixx <<'EOF' -export module mathkit:api; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.ixx", "src/*.cpp"] -module_extensions = [".ixx"] -[targets.mathkit] -kind = "lib" -EOF - -cd mathkit -"$MCPP" pack mathkit --pkg-format dir > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" - -# ── the closure actually started ──────────────────────────────────────── -[[ -f "$pkg/interface/mathkit.ixx" && -f "$pkg/interface/api.ixx" ]] || { - find "$pkg" -type f - cat pack.log - echo "FAIL: the module interface was not published. 'Interface (headers only)'" - echo " in the log above means the lib root resolved to a file that does" - echo " not exist — the convention looked for .cppm." - exit 1; } -[[ ! -e "$pkg/interface/impl.cpp" ]] || { echo "FAIL: the implementation unit was published"; exit 1; } - -# ── and the package is tagged as the C++ surface it is ────────────────── -# -# Asserted separately because it fails INDEPENDENTLY: an empty published set is -# how a C surface is recognised, so losing the interface also silently downgrades -# the compatibility tag, and a package that constrains nothing is accepted by -# every toolchain. -grep -qE '^abi +=.*-c\+\+[0-9]+"' "$pkg/mcpp.toml" || { - grep -n 'abi' "$pkg/mcpp.toml" - echo "FAIL: the leg carries a C-surface tag. A package that publishes a C++" - echo " module interface constrains the C++ ABI, and this one now says" - echo " it does not — the gate will accept any compiler." - exit 1; } - -# ── the package tells consumers what an .ixx is ───────────────────────── -grep -q 'module_extensions' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml" - echo "FAIL: the generated manifest lists .ixx sources without declaring the" - echo " extension, so every consumer is refused by the classifier." - exit 1; } - -# ── a consumer that declares NOTHING builds and runs ──────────────────── -PKG_HOST="$(host_path "$pkg")" -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("pack-ixx=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < run.log 2>&1 ) || { - cat app/run.log - echo "FAIL: the consumer had to be told about .ixx. The package should carry it." - exit 1; } -grep -q 'pack-ixx=42' app/run.log || { cat app/run.log; echo "FAIL: wrong answer"; exit 1; } - -# ── a .cppm package's manifest is unchanged ───────────────────────────── -# -# The new key must appear only when the published set needs it, or every -# existing package's manifest changes shape for no reason. -cd "$TMP" -mkdir -p plain/src -cat > plain/src/plain.cppm <<'EOF' -export module plain; -export namespace pl { int v() { return 1; } } -EOF -cat > plain/mcpp.toml <<'EOF' -[package] -name = "plain" -version = "0.1.0" -[build] -sources = ["src/*.cppm"] -[targets.plain] -kind = "lib" -EOF -( cd plain && "$MCPP" pack plain --pkg-format dir > pack.log 2>&1 ) || { - cat plain/pack.log; echo "plain pack failed"; exit 1; } -plainpkg="$(find plain/target/dist -maxdepth 1 -type d -name 'plain-0.1.0-*' | head -1)" -grep -q 'module_extensions' "$plainpkg/mcpp.toml" && { - cat "$plainpkg/mcpp.toml" - echo "FAIL: a .cppm-only package gained a module_extensions key it does not need" - exit 1; } - -echo "PASS: pack follows the project's module extension, and the package carries it" diff --git a/tests/e2e/262_pack_consumed_by_native_cl.sh b/tests/e2e/262_pack_consumed_by_native_cl.sh deleted file mode 100755 index 1d40a1ea5..000000000 --- a/tests/e2e/262_pack_consumed_by_native_cl.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# requires: msvc -# 262_pack_consumed_by_native_cl.sh — a packaged library linked by NATIVE cl.exe. -# -# The generated manifest states each leg's link line twice. The first spelling is -# GNU — `-Llib/ -l` — which every compiler DRIVER mcpp uses -# accepts, including clang targeting the MSVC ABI, and which native `cl.exe` -# rejects at the first `-L`. The second is the dialect-neutral pair: -# -# [target.'cfg(…, env = "msvc")'.runtime] -# link_library_dirs = ["lib/x86_64-windows-msvc"] -# libraries = ["mathkit"] -# -# mcpp renders that as `/LIBPATH:` + `.lib` when it invokes link.exe -# directly, and as `-L` + `-l` for every compiler driver. Both spellings -# ship because an older mcpp reads only the first and silently ignores the -# second, so dropping the first would leave every older client with no link line. -# -# WHY BOTH HALVES ARE ASSERTED. The renderer is unit tested -# (tests/unit/test_link_intent_spelling.cpp) and only a real `cl.exe` can say -# whether the result links. But a consumer that merely SUCCEEDS proves less than -# it looks: if the neutral form were ignored and the ldflags applied instead, -# clang would still link it. So the consumer here pins `msvc@system`, which is -# the one toolchain that cannot survive a stray `-L`. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -[toolchain] -windows = "msvc@system" -EOF - -cd mathkit -"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" - -# ── the package carries the neutral form ──────────────────────────────── -grep -q "\.runtime\]" "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml" - echo "FAIL: no conditional [runtime] block. Without it a cl.exe consumer has" - echo " only the GNU ldflags, and cl stops at the first -L." - exit 1; } -grep -q 'link_library_dirs' "$pkg/mcpp.toml" || { cat "$pkg/mcpp.toml"; echo "FAIL: no link_library_dirs"; exit 1; } -grep -q 'libraries' "$pkg/mcpp.toml" || { cat "$pkg/mcpp.toml"; echo "FAIL: no libraries"; exit 1; } -# …and still the GNU one, for clients that predate the neutral form. -grep -q 'ldflags' "$pkg/mcpp.toml" || { - cat "$pkg/mcpp.toml" - echo "FAIL: the ldflags are gone. An older mcpp reads only those, so removing" - echo " them leaves it with no link line at all." - exit 1; } - -# ── and native cl.exe links it ────────────────────────────────────────── -PKG_HOST="$(host_path "$pkg")" -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("cl-ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < run.log 2>&1 ) || { - cat app/run.log - echo "FAIL: native cl.exe could not consume the package." - echo " A message naming '-L' means the GNU ldflags reached the command" - echo " line — the neutral form must REPLACE that leg's library" - echo " references, not be added alongside them." - exit 1; } -grep -q 'cl-ok=42' app/run.log || { cat app/run.log; echo "FAIL: wrong answer"; exit 1; } - -# ── the GNU spelling did not reach the command line ───────────────────── -# -# The run above could also pass if `-L` were accepted and ignored. Asserted -# against the generated graph so the claim is about what mcpp emitted. -nj="$(find app/target -name build.ninja | head -1)" -grep -E 'ldflags|unit_ldflags' "$nj" | grep -q -- '-Llib/' && { - grep -n -- '-Llib/' "$nj" | head -3 - echo "FAIL: a GNU -L for the package leg is still on the link line. cl.exe" - echo " happens to be tolerant here only by accident." - exit 1; } - -echo "PASS: native cl.exe links a packaged library through the neutral link intent" diff --git a/tests/e2e/263_lib_root_follows_the_extension.sh b/tests/e2e/263_lib_root_follows_the_extension.sh deleted file mode 100755 index 13b33c821..000000000 --- a/tests/e2e/263_lib_root_follows_the_extension.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# requires: -# (no capability: both assertions read mcpp's own output about a manifest key.) -# -# 263_lib_root_follows_the_extension.sh — the lib-root convention offers one -# candidate per DECLARED module extension, at every call site. -# -# WHY THIS EXISTS AS ITS OWN TEST. The previous round fixed the resolver the -# packer uses and left two other callers on the non-probing form, because the -# path under test was the only one anybody looked at. Both were reachable and -# both were wrong for an `.ixx` project: -# -# validate.cppm warned that the lib root was missing when it was right there -# prepare.cppm handed a host-module dependency a path to a file that does -# not exist -# -# Control-verified against the RELEASED 2026.8.18.2 binary on this fixture: -# -# warning: src/mathkit.cppm: lib target without conventional lib root -# 'src/mathkit.cppm' (create the file or set [lib].path) -# -# "Fixed the path I was testing" is not "fixed the decision", and a test per -# CALL SITE is the only thing that distinguishes them. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── 1. the validator does not warn about a lib root that is present ───── -mkdir -p lib/src -cat > lib/src/mathkit.ixx <<'EOF' -export module mathkit; -export namespace mk { int answer() { return 42; } } -EOF -cat > lib/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.ixx"] -module_extensions = [".ixx"] -[targets.mathkit] -kind = "lib" -EOF -( cd lib && "$MCPP" build > build.log 2>&1 ) || { cat lib/build.log; echo "FAIL: build"; exit 1; } -grep -q 'conventional lib root' lib/build.log && { - cat lib/build.log - echo "FAIL: the validator looked for src/mathkit.cppm on a project whose" - echo " interface is src/mathkit.ixx. The convention has to offer one" - echo " candidate per DECLARED extension, not just the built-in one." - exit 1; } - -# The negative control: a project with NO lib root at all must still be warned -# about, or this test would pass against a validator that stopped checking. -mkdir -p noroot/src -cat > noroot/src/other.ixx <<'EOF' -export module other; -export namespace o { int v() { return 1; } } -EOF -cat > noroot/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.ixx"] -module_extensions = [".ixx"] -[targets.mathkit] -kind = "lib" -EOF -( cd noroot && "$MCPP" build > build.log 2>&1 ) || true -grep -q 'conventional lib root' noroot/build.log || { - cat noroot/build.log - echo "FAIL: a genuinely missing lib root produced no warning, so the check" - echo " above proves nothing — it would pass against a validator that" - echo " simply stopped looking." - exit 1; } - -# ── 2. a host-module dependency resolves its .ixx lib root ────────────── -# -# The second call site, which had no test of any kind. A build rule's interface -# is handed to build_program.cppm BY PATH, so a lib root resolved to a file that -# does not exist fails inside the build program rather than here. -mkdir -p rulepkg/src -cat > rulepkg/src/rulepkg.ixx <<'EOF' -export module rulepkg; -export namespace rp { int magic() { return 7; } } -EOF -cat > rulepkg/mcpp.toml <<'EOF' -[package] -name = "rulepkg" -version = "0.1.0" -[build] -sources = ["src/*.ixx"] -module_extensions = [".ixx"] -[targets.rulepkg] -kind = "lib" -[modules] -exports = ["rulepkg"] -EOF - -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -#ifndef RULE_MAGIC -#error "the host-module rule did not reach this compile" -#endif -int main(){ std::printf("host-mod=%d\n", RULE_MAGIC); return 0; } -EOF -cat > app/build.mcpp <<'EOF' -import std; -import rulepkg; -int main() { - std::println("mcpp:rerun-if-changed=build.mcpp"); - // A real directive, so the assertion is on an EFFECT rather than on a log - // line: the rule's return value has to reach the consumer's compile. - std::println("mcpp:cfg=RULE_MAGIC={}", rp::magic()); - return 0; -} -EOF -# `[dependencies]`, not `[build-dependencies]`: `host-module = true` is what -# makes it build-time-only, and the key lives in the ordinary dependency table -# (docs/05 §2.14). The first version of this fixture put it under -# `[build-dependencies]` and failed identically for a `.cppm` package — i.e. the -# fixture was wrong, not the resolver. Checking the `.cppm` case first is what -# separated those two. -RULEPKG_HOST="$(host_path "$TMP/rulepkg")" -cat > app/mcpp.toml < build.log 2>&1 ) || { - cat app/build.log - echo "FAIL: a host-module dependency whose interface is .ixx could not be" - echo " resolved. 'module rulepkg not found' means the lib root was" - echo " looked for as src/rulepkg.cppm, which does not exist." - exit 1; } -# The `#error` above is the real assertion: if the rule module could not be -# imported, or its value never reached the compile, this file does not build. -grep -qE "warning:.*module_extensions declares" app/build.log && { - cat app/build.log - echo "FAIL: the rule package was warned about a dead module_extensions entry." - echo " Its sources are emptied on purpose (that is what keeps a build" - echo " rule out of the consumer's binary), so every declared extension" - echo " looks dead — a warning about a correct manifest, with nothing to fix." - exit 1; } - -echo "PASS: the lib-root convention follows the declared extension at every call site" diff --git a/tests/e2e/264_pack_library_is_relocatable.sh b/tests/e2e/264_pack_library_is_relocatable.sh deleted file mode 100755 index 5130020e0..000000000 --- a/tests/e2e/264_pack_library_is_relocatable.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# requires: pack python3 -# 264_pack_library_is_relocatable.sh — a packed `kind = "shared"` library must -# not carry the BUILD MACHINE's loader search path (issue #460). -# -# WHAT WAS WRONG, AND WHY NOBODY SAW IT -# -# `run_library_pack` copied the built `.so` into the package and did nothing -# else, so it kept the DT_RUNPATH the link gave it: -# -# /registry/data/xpkgs/xim-x-glibc/2.44/lib64 -# : /registry/data/xpkgs/xim-x-gcc/16.1.0/lib64 -# : /registry/subos/default/lib -# -# On another machine those directories do not exist, and — this is the part -# that makes it fatal rather than untidy — a shared object that carries ANY -# DT_RUNPATH makes the loader skip the whole inherited DT_RPATH chain when -# resolving that object's own dependencies. So the consumer's carefully -# computed DT_RPATH (payload + package dir + SubOS farm) is not consulted, and -# the program dies with -# -# error while loading shared libraries: libstdc++.so.6: cannot open shared object file -# -# AND WHY `$ORIGIN` IS NOT THE FIX. Measured: a DT_RUNPATH of `$ORIGIN`, and -# a DT_RUNPATH of the empty string, both fail exactly the same way. It is the -# TAG's presence that disables inheritance, not its contents. The criterion is -# therefore "there is no tag", never "the tag is relative". -# -# AND WHY THIS TEST DELIBERATELY BREAKS THE PACKAGE HALF-WAY THROUGH. -# Asserting only that the fixed package runs cannot tell "the defect is fixed" -# from "this machine happens to satisfy the stale path" — which is precisely -# what made the pre-existing e2e (251) green throughout the bug's life: it -# consumes the package on the machine that built it, where those directories -# are right there. So the defect is put BACK with patchelf, the consumer is -# required to FAIL, and only then is it restored. A guard that cannot observe -# the defect is not a guard. -set -e -source "$(dirname "$0")/_host_path.sh" -source "$(dirname "$0")/_elf_tag.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit-shared] -kind = "shared" -soname = "libmathkit.so.1" -EOF - -cd mathkit -"$MCPP" pack mathkit-shared > pack.log 2>&1 || { cat pack.log; echo "FAIL: pack"; exit 1; } -pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -PKG_HOST="$(host_path "$pkg")" -libdir="$pkg/lib/x86_64-linux-gnu" -[[ -d "$libdir" ]] || libdir="$(dirname "$(find "$pkg/lib" -name 'libmathkit-shared.so' | head -1)")" -so="$libdir/libmathkit-shared.so" -[[ -f "$so" ]] || { find "$pkg" -type f; echo "FAIL: no .so in the package"; exit 1; } - -# ── 1. static: EVERY ELF in the package, and both of the .so's names ──────── -# -# The SONAME alias is checked too, and that is not belt-and-braces: it is a -# symlink here, but on a filesystem where `create_symlink` fails the packer -# falls back to a COPY — and that copy used to be made from the BUILD TREE's -# file rather than from the staged one, i.e. from the unrelocated original, -# under the exact name the loader asks for. -fail=0 -swept=0 -while IFS= read -r obj; do - head -c4 "$obj" 2>/dev/null | grep -q $'\x7fELF' || continue - read -r form tag paths <<<"$(read_tag "$obj")" - [[ "$form" == "NOT-ELF64" ]] && continue - swept=$((swept + 1)) - printf ' %-34s %-14s %s %s\n' "$(basename "$obj")" "$form" "$tag" "$paths" - if [[ "$tag" != "NONE" ]]; then - echo "FAIL: a packaged library carries $tag ($paths)" - echo " A shipped .so must carry NO loader search path: any DT_RUNPATH —" - echo " including \$ORIGIN and the empty string — stops the consumer's" - echo " DT_RPATH from being inherited for this object's dependencies." - fail=1 - fi -done < <(find "$libdir" \( -type f -o -type l \)) -[[ "$swept" -ge 1 ]] || { echo "FAIL: swept no ELF — the test proved nothing"; exit 1; } -[[ "$fail" == "0" ]] || exit 1 - -# ── 2. a consumer of the package starts ──────────────────────────────────── -cd "$TMP" -mkdir -p app/src -cat > app/src/main.cpp <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF -cat > app/mcpp.toml < build.log 2>&1 ) || { cat app/build.log; echo "FAIL: consumer build"; exit 1; } -exe="$(find app/target -path '*/bin/app' -type f | head -1)" -[[ -x "$exe" ]] || { echo "FAIL: no consumer binary"; exit 1; } - -run_bare() { "$exe" 2>&1; } # NOT `mcpp run`: no injected environment - -out="$(run_bare)" && rc=0 || rc=$? -[[ "$rc" == "0" && "$out" == *"ok=42"* ]] || { - echo "FAIL: the consumer does not start against the packed library" - echo " rc=$rc out=$out"; exit 1; } - -# ── 3. put the defect BACK, and require the consumer to fail ─────────────── -# -# `/nonexistent-machine/...` stands in for "the publisher's store, on somebody -# else's computer". Nothing is rebuilt: only the shipped library's dynamic -# section changes, which is exactly the difference between the two machines. -cp "$so" "$so.packed" -patchelf --set-rpath '/nonexistent-machine/.mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/lib64' "$so" -out="$(run_bare)" && rc=0 || rc=$? -if [[ "$rc" == "0" ]]; then - echo "FAIL: the consumer still ran with a stale RUNPATH restored on the library." - echo " That means this test cannot observe the defect it exists for —" - echo " the consumer is finding libstdc++ some other way, so a regression" - echo " in the packer would pass unnoticed." - exit 1 -fi -[[ "$out" == *"cannot open shared object file"* ]] || { - echo "FAIL: the restored defect produced an unexpected failure: $out"; exit 1; } -echo " defect restored → consumer fails as expected (rc=$rc)" - -# ── 4. …and restore, so the pass is not an artifact of step 3 ────────────── -cp "$so.packed" "$so" -out="$(run_bare)" && rc=0 || rc=$? -[[ "$rc" == "0" && "$out" == *"ok=42"* ]] || { - echo "FAIL: restoring the packed library did not restore the behaviour" - echo " rc=$rc out=$out"; exit 1; } - -echo "PASS: a packed shared library carries no build-machine search path, and the guard can see the defect" diff --git a/tests/e2e/265_pack_strips_but_stays_usable.sh b/tests/e2e/265_pack_strips_but_stays_usable.sh deleted file mode 100755 index ccf8b2b6f..000000000 --- a/tests/e2e/265_pack_strips_but_stays_usable.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env bash -# requires: pack python3 -# 265_pack_strips_but_stays_usable.sh — `mcpp pack` ships stripped artifacts, -# and stripping must not make them unusable. -# -# THE ONE THAT BITES: A STATIC ARCHIVE CANNOT BE `--strip-all`ed. -# -# `strip` with no shape argument defaults to strip-all, and on a `.a` that -# removes the ARCHIVE SYMBOL INDEX. The package then fails at the CONSUMER's -# link with -# -# ld: libmathkit.a: error adding symbols: archive has no index; run ranlib to add one -# -# — a message that names neither `strip` nor the publisher, arriving on a -# machine the publisher does not have. Measured: `--strip-debug` keeps the -# index (2988 → 1244 bytes) and the consumer links and runs. So the packer -# uses dh_strip's division, and this test consumes BOTH shapes rather than -# inspecting them: "the archive still has an index" is a proxy, "a program -# linked against it prints the right number" is the thing. -# -# The three configuration channels are pinned from BOTH sides — a test that -# only checks the default cannot tell "the switch works" from "there is no -# switch". -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p mathkit/src -cat > mathkit/src/mathkit.cppm <<'EOF' -export module mathkit; -export namespace mk { int answer(); } -EOF -cat > mathkit/src/impl.cpp <<'EOF' -module mathkit; -namespace mk { int answer() { return 42; } } -EOF -cat > mathkit/mcpp.toml <<'EOF' -[package] -name = "mathkit" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.mathkit] -kind = "lib" -[targets.mathkit-shared] -kind = "shared" -soname = "libmathkit.so.1" -EOF - -# `file` is not required by the harness; ask the ELF itself instead. -has_symtab() { -python3 - "$1" <<'PY' -import struct, sys -d = open(sys.argv[1], 'rb').read() -if d[:4] != b'\x7fELF' or d[4] != 2: - print("no"); raise SystemExit -shoff, = struct.unpack_from(' — build a program against it and run - local pkg="$1" want="$2" tag="$3" - # Through a named *_HOST variable, not interpolated inline: the manifest - # below is FILE CONTENT, and on Git Bash a shell-spelled /tmp/... path is - # read by a native mcpp.exe as "root of the current drive". 00_fixture_path - # _hygiene enforces the naming so the conversion is visible at the use site. - local PKG_HOST - PKG_HOST="$(host_path "$pkg")" - rm -rf "$TMP/consumer" - mkdir -p "$TMP/consumer/src" - cat > "$TMP/consumer/src/main.cpp" <<'EOF' -#include -import mathkit; -int main(){ std::printf("ok=%d\n", mk::answer()); return 0; } -EOF - cat > "$TMP/consumer/mcpp.toml" < run.log 2>&1 ) || { - cat "$TMP/consumer/run.log" - echo "FAIL[$tag]: a consumer cannot use the stripped package"; exit 1; } - grep -q "$want" "$TMP/consumer/run.log" || { - cat "$TMP/consumer/run.log"; echo "FAIL[$tag]: wrong answer"; exit 1; } -} - -cd mathkit - -# ── 1. static: stripped, and STILL LINKABLE ──────────────────────────────── -rm -rf target/dist -"$MCPP" pack mathkit > pack-a.log 2>&1 || { cat pack-a.log; echo "FAIL: static pack"; exit 1; } -grep -q "Stripped" pack-a.log || { cat pack-a.log; echo "FAIL: the static leg was not stripped"; exit 1; } -pkg_a="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -consume "$pkg_a" "ok=42" "static" -echo " static archive: stripped and still linkable" - -# ── 2. shared: stripped, and STILL LOADABLE ──────────────────────────────── -rm -rf target/dist -"$MCPP" pack mathkit-shared > pack-b.log 2>&1 || { cat pack-b.log; echo "FAIL: shared pack"; exit 1; } -grep -q "Stripped" pack-b.log || { cat pack-b.log; echo "FAIL: the shared leg was not stripped"; exit 1; } -pkg_b="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -so="$(find "$pkg_b/lib" -name 'libmathkit-shared.so' -type f | head -1)" -[[ -n "$so" ]] || { echo "FAIL: no .so"; exit 1; } -# `--strip-unneeded` must leave `.dynsym` — it IS the export list. If this -# regressed to `--strip-all` the link below would still work (ld reads -# `.dynsym`), so the run is what proves it. -[[ "$(has_symtab "$so")" == "no" ]] || { - echo "FAIL: the shared library still carries a symbol/debug table after stripping"; exit 1; } -consume "$pkg_b" "ok=42" "shared" -echo " shared library: stripped and still loadable" - -# ── 3. the OTHER side of every switch ────────────────────────────────────── -rm -rf target/dist -"$MCPP" pack mathkit-shared --no-strip > pack-c.log 2>&1 || { cat pack-c.log; echo "FAIL: --no-strip pack"; exit 1; } -grep -q "Stripped" pack-c.log && { cat pack-c.log; echo "FAIL: --no-strip stripped anyway"; exit 1; } -pkg_c="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -so_c="$(find "$pkg_c/lib" -name 'libmathkit-shared.so' -type f | head -1)" -[[ "$(has_symtab "$so_c")" == "yes" ]] || { - echo "FAIL: --no-strip produced an artifact with no symbols — the flag did nothing" - exit 1; } -echo " --no-strip: symbols kept" - -rm -rf target/dist -cp mcpp.toml mcpp.toml.bak -printf '\n[pack]\nstrip = false\n' >> mcpp.toml -"$MCPP" pack mathkit-shared > pack-d.log 2>&1 || { cat pack-d.log; echo "FAIL: [pack] strip pack"; exit 1; } -grep -q "Stripped" pack-d.log && { cat pack-d.log; echo "FAIL: [pack] strip = false was ignored"; exit 1; } -mv mcpp.toml.bak mcpp.toml -echo " [pack] strip = false: honoured" - -# ── 4. --debug-symbols keeps the symbols, beside the artifact ────────────── -rm -rf target/dist -"$MCPP" pack mathkit-shared --debug-symbols dbg > pack-e.log 2>&1 || { - cat pack-e.log; echo "FAIL: --debug-symbols pack"; exit 1; } -# PER TRIPLE, mirroring `lib//`. A fat package's legs share an artifact -# NAME (`libmathkit-shared.so` for both a gnu and a musl leg is the normal case), -# so a flat debug directory would have the second leg overwrite the first — and -# the first artifact's `.gnu_debuglink` would then resolve to the other target's -# symbols, silently. Asserting the layout is what keeps that from regressing. -dbgfile="$(find dbg -name 'libmathkit-shared.so.debug' | head -1)" -[[ -n "$dbgfile" ]] || { ls -R dbg 2>&1; echo "FAIL: no separated debug file"; exit 1; } -[[ "$dbgfile" == dbg/*/libmathkit-shared.so.debug ]] || { - echo "FAIL: the debug file is not under a per-triple directory: $dbgfile" - echo " A fat package's legs share an artifact name; a flat layout loses one." - exit 1; } -[[ "$(has_symtab "$dbgfile")" == "yes" ]] || { - echo "FAIL: the separated debug file carries no debug information"; exit 1; } -pkg_e="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" -so_e="$(find "$pkg_e/lib" -name 'libmathkit-shared.so' -type f | head -1)" -[[ "$(has_symtab "$so_e")" == "no" ]] || { - echo "FAIL: --debug-symbols left the debug information in the shipped artifact too" - exit 1; } -# The link back. Without it a debugger has the file and no way to find it. -python3 - "$so_e" <<'PY' || { echo "FAIL: no .gnu_debuglink in the stripped artifact"; exit 1; } -import struct, sys -d = open(sys.argv[1], 'rb').read() -shoff, = struct.unpack_from('' -# -# That variable is glibc's. dyld has never heard of it, so on macOS this did -# not trace anything — IT RAN THE USER'S PROGRAM, and a bundle containing just -# the binary was written and reported as `Packed`. mcpp then refused a Mach-O -# program outright, which this test asserted. Since #634 A3 the closure is -# read from the program's load commands, so the program packs; what must stay -# true is that packing it runs nothing. The program writes a marker file when -# it runs, and the marker must not exist after the pack. -# -# BOTH SIDES, ON THE SAME HOST: the same run also packs a LIBRARY target, -# which takes a different pipeline and must still succeed. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p proj/src -cat > proj/src/mathkit.cppm <<'CPP' -export module mathkit; -export namespace mk { int answer(); } -CPP -cat > proj/src/impl.cpp <<'CPP' -module mathkit; -namespace mk { int answer() { return 42; } } -CPP -cat > proj/src/main.cpp <<'CPP' -#include -import mathkit; -int main() { - // The side effect the criterion reads: a file beside the working directory. - if (std::FILE* f = std::fopen("ran.marker", "w")) std::fclose(f); - std::printf("ok=%d\n", mk::answer()); - return 0; -} -CPP -cat > proj/mcpp.toml <<'TOML' -[package] -name = "proj" -version = "0.1.0" -[build] -sources = ["src/*.cppm", "src/*.cpp"] -[targets.proj] -kind = "bin" -main = "src/main.cpp" -[targets.mathkit] -kind = "lib" -TOML - -cd proj - -# ── 1. the program packs, and packing it ran nothing ──────────────────────── -"$MCPP" pack proj > pack.log 2>&1 || { - cat pack.log - echo "FAIL: mcpp pack refused a Mach-O program whose closure is the OS's alone." - exit 1; } -if [ -e ran.marker ] || [ -n "$(find target -name ran.marker | head -1)" ]; then - cat pack.log - echo "FAIL: packing the program RAN it (ran.marker exists)." - exit 1 -fi -archive="$(find target/dist -maxdepth 1 -name 'proj-0.1.0-*.tar.gz' | head -1)" -[[ -n "$archive" ]] || { cat pack.log; echo "FAIL: no archive under target/dist"; exit 1; } -tar -tzf "$archive" | grep -q '/bin/proj$' || { - tar -tzf "$archive"; echo "FAIL: the archive has no bin/proj"; exit 1; } -echo " a Mach-O program packs, and the pack did not run it" - -# ── 2. …and a library target on the same host still packs ────────────────── -"$MCPP" pack mathkit > packlib.log 2>&1 || { - cat packlib.log - echo "FAIL: a library target does not pack on macOS." - exit 1; } -# Searched by content: the program's staged tree above shares the package's -# name prefix, so a directory match could pick either. -[[ -n "$(find target/dist -name '*.a' | head -1)" ]] || { - find target/dist -type f; echo "FAIL: no archive in the library package"; exit 1; } -echo " a library target still packs" - -echo "PASS: mcpp pack reads a Mach-O program without running it, and library packaging is unaffected" diff --git a/tests/e2e/267_target_requested_is_empty_for_a_native_build.sh b/tests/e2e/267_target_requested_is_empty_for_a_native_build.sh deleted file mode 100755 index 5b8ad3cb4..000000000 --- a/tests/e2e/267_target_requested_is_empty_for_a_native_build.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# `MCPP_TARGET_REQUESTED` — the value a build program needs and `MCPP_TARGET` -# cannot give it. -# -# WHY THE TWO VARIABLES ARE NOT THE SAME QUESTION. -# -# `MCPP_TARGET` answers "which machine is this for", and it is filled in with -# the host when nobody named a target — which is right for that question and -# makes it useless for a different one a platform package has to ask: **was this -# build POINTED at a target**, or is it an ordinary native build? -# -# The two differ even when the triples are equal. `mcpp build --target -# aarch64-macos` on an arm64 Mac names the very machine the host is, and yet it -# is the dependency graph that supplies the target side — so mcpp puts no system -# SDK on the link, and the package that knows the system is the only thing that -# can name one. A native build on the same machine gets the SDK and needs -# nothing from the package. -# -# Both readings of that question have already been measured wrong in -# `openkal-macos`: -# -# from the host → right for the cross, and `library not found for -lSystem` -# for `--target aarch64-macos` ON a Mac -# from MCPP_TARGET → right for the cross, and `undefined symbol: wcslen` for -# the native build, because it is never empty and the -# package's three-name stub shadowed the vendor's complete one -# -# ⇒ The assertions below are the contract those two attempts needed: EMPTY for a -# native build, and the requested triple otherwise. -# -# `"$MCPP"`, never a bare `mcpp`: the harness passes the binary under test, -# and a bare name resolves through PATH to whichever engine is installed. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p src -cat > mcpp.toml <<'EOF' -[package] -name = "target-requested-probe" -version = "0.1.0" -EOF -printf 'int main() { return 0; }\n' > src/main.cpp - -# The build program reports through a NON-ZERO exit, because mcpp prints what -# it captured only from a program that failed. A probe that returned zero would -# have its output discarded and this test would assert nothing. -cat > build.mcpp <<'EOF' -import mcpp; -import std; -int main() { - const char* t = std::getenv("MCPP_TARGET"); - const char* r = std::getenv("MCPP_TARGET_REQUESTED"); - std::cerr << "PROBE target=[" << (t ? t : "") << "] " - << "requested=[" << (r ? r : "") << "]\n"; - return 1; -} -EOF - -# ── A native build: nobody named a target ────────────────────────────────── -native=$("$MCPP" build 2>&1 || true) -echo "$native" | grep -q 'PROBE ' || { - echo "the build program did not report; mcpp prints a failing program's output" >&2 - echo "$native" >&2 - exit 1 -} -echo "$native" | grep -qE 'requested=\[\]' || { - echo "MCPP_TARGET_REQUESTED should be empty for a native build" >&2 - echo "$native" | grep 'PROBE ' >&2 - exit 1 -} -# And the positive control on the same line: `MCPP_TARGET` must be filled in. -# Without this the assertion above would also pass if mcpp had stopped setting -# any of them. -echo "$native" | grep -qE 'target=\[[a-z0-9_]+-[a-z0-9-]+\]' || { - echo "MCPP_TARGET should carry the host triple for a native build" >&2 - echo "$native" | grep 'PROBE ' >&2 - exit 1 -} - -# ── A build pointed at a target ──────────────────────────────────────────── -# -# THE HOST'S OWN TRIPLE, NAMED EXPLICITLY — which is the case that -# distinguishes the two variables rather than merely one that differs from the -# native run. `--target ` produces equal values for `MCPP_TARGET` and the -# host triple, and `MCPP_TARGET_REQUESTED` is non-empty because a target was -# named. A test that used a foreign triple would pass with a variable that -# merely echoed `MCPP_TARGET`. -# -# And it needs no payload. The first version of this test named -# `x86_64-linux-musl` on the grounds that every host could resolve it, which was -# an assumption rather than a measurement: -# -# error: target 'x86_64-linux-musl' cannot be built on this host — -# no toolchain payload exists that runs here and produces it -# -# on the macOS leg. The host's own triple is the one target every host has by -# construction. -host_triple=$(echo "$native" | sed -n 's/.*PROBE target=\[\([^]]*\)\].*/\1/p' | head -1) -[ -n "$host_triple" ] || { echo "could not read the host triple from the probe" >&2; exit 1; } -cross=$("$MCPP" build --target "$host_triple" 2>&1 || true) -echo "$cross" | grep -q 'PROBE ' || { - echo "the build program did not report on the cross build" >&2 - echo "$cross" >&2 - exit 1 -} -echo "$cross" | grep -qE "requested=\[$host_triple\]" || { - echo "MCPP_TARGET_REQUESTED should carry the triple that was named" >&2 - echo "$cross" | grep 'PROBE ' >&2 - exit 1 -} - -echo "MCPP_TARGET_REQUESTED distinguishes a native build from a named target" diff --git a/tests/e2e/268_target_side_resolution.sh b/tests/e2e/268_target_side_resolution.sh deleted file mode 100755 index d6bcfa8fd..000000000 --- a/tests/e2e/268_target_side_resolution.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell -# Target-side resolution: which layer of a target comes from where. -# -# WHAT THIS COVERS THAT THE UNIT TESTS CANNOT. -# -# `mcpp.targetside` is a pure function and its table is asserted directly in -# tests/unit/test_targetside.cpp. What only a build can show is the wiring: that -# a package's `provides` line actually reaches the resolver, that the resolution -# reaches the report, and that a misspelling in mcpp's reserved namespace stops -# the build instead of quietly disabling the behaviour it was meant to select. -# -# The packages here are local and trivial on purpose. The real ecosystem -# exercise lives in .github/workflows/openkal-cross.yml, which builds nine -# artifacts on three hosts; what this file needs is the mechanism, not the -# stack, and a test that fetched an ecosystem over the network to assert a -# string in a report would be slower and no more conclusive. -# -# NO `sed -i` ANYWHERE, AND THAT IS NOT STYLE. BSD sed reads the argument -# after `-i` as a backup suffix, so an in-place edit written for GNU sed fails -# on macOS. Measured on the macOS leg of this suite, in this very file: -# -# sed: 1: "sys/mcpp.toml": unterminated substitute pattern -# -# The provider's manifest is therefore written from scratch for each case. -# -# `"$MCPP"`, never a bare `mcpp`: the harness passes the binary under test, and -# a bare name resolves through PATH to whichever engine is installed. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p sys/src -printf 'int fake_system_marker(void) { return 0; }\n' > sys/src/sys.c - -# Rewrite the provider's manifest with the capabilities given as arguments, and -# drop the output tree so the next build re-plans from it. -provider_declares() { - { - echo '[package]' - echo 'name = "fake-system"' - echo 'version = "2.0.0"' - if [ $# -gt 0 ]; then - printf 'provides = [' - sep="" - for cap in "$@"; do printf '%s"%s"' "$sep" "$cap"; sep=", "; done - printf ']\n' - fi - echo '' - echo '[targets.fake-system]' - echo 'kind = "lib"' - echo 'sources = ["src/*.c"]' - } > sys/mcpp.toml - rm -rf target -} - -mkdir -p src -cat > mcpp.toml <<'EOF' -[package] -name = "target-side-probe" -version = "0.1.0" - -[dependencies] -fake-system = { path = "sys" } -EOF -printf 'int main() { return 0; }\n' > src/main.cpp - -# ── A package that supplies two layers ────────────────────────────────────── -# -# One package standing in for the platform implementation and the C library -# both. The resolver reads a layer per capability, not a layer per package, so -# a single provider of two layers is a legitimate shape and a useful one to -# assert: it proves the two lookups are independent. -# -# `|| true`, AND THAT IS THE TEST'S SUBJECT RATHER THAN A CONCESSION. The -# resolution is reported during planning, before a single object is compiled, so -# what this file asserts is complete whether or not the link afterwards -# succeeds. Requiring a successful link would additionally require a working C -# runtime payload for the host, which is a different thing to test and one the -# rest of the suite already covers. -provider_declares "mcpp:kernel-abi=fakeos" "mcpp:c-abi=fakelibc" -out=$("$MCPP" build 2>&1 || true) - -echo "$out" | grep -q 'kernel-abi *fakeos' || { - echo "the kernel-abi layer must report the interface the package declared" >&2 - echo "$out" >&2; exit 1 -} -echo "$out" | grep -q 'c-abi *fakelibc' || { - echo "the c-abi layer must report the interface the package declared" >&2 - echo "$out" >&2; exit 1 -} -echo "$out" | grep -qE 'fake-system@2\.0\.0' || { - echo "the report must name the providing package and its version" >&2 - echo "$out" >&2; exit 1 -} -echo "$out" | grep -q 'graph' || { - echo "a layer supplied by a dependency must be reported as coming from the graph" >&2 - echo "$out" >&2; exit 1 -} - -# AND THE POSITIVE CONTROL ON THE SAME OUTPUT. Without this, the assertions -# above would also pass on an engine that printed the same three lines for -# every build regardless of what the graph contained. -# The label is the capability name — `c++-abi`, matching `mcpp:c++-abi` — so -# that the report and the manifest use one vocabulary for one layer. -echo "$out" | grep -q 'c++-abi *—' || { - echo "a project with no C++ runtime package must report that layer absent" >&2 - echo "$out" >&2; exit 1 -} - -# ── The same project without the declarations ─────────────────────────────── -# -# The control that makes the block above mean something: remove the capability -# line and every layer must fall back to the payload. -provider_declares -# MCPP_VERBOSE, because an ordinary report prints only the layers the compiler -# payload did NOT supply. That suppression is itself the control's subject here: -# with nothing in the graph there is nothing to print, and the assertion below -# needs the full stack to check that the fallback happened rather than that the -# lines merely vanished. -plain=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 || true) -echo "$plain" | grep -q 'fakeos' && { - echo "a package that declares no capability must not fill a layer" >&2 - echo "$plain" >&2; exit 1 -} -echo "$plain" | grep -qE 'kernel-abi .*payload' || { - echo "with nothing in the graph, the layers come from the payload" >&2 - echo "$plain" >&2; exit 1 -} - -# ── A name inside mcpp's reserved namespace is never silent ───────────────── -# -# This is the whole reason the prefix exists: an unvalidated capability array -# turns one wrong letter into a behaviour that silently does not happen while -# the build reports success. -# -# IN A DEPENDENCY IT IS A WARNING RATHER THAN AN ERROR, AND THE CHANGE WAS -# FORCED BY A MEASUREMENT. mcpp cannot distinguish a misspelling from a layer -# named after this build tool was released, and the two mistakes cost different -# amounts. Refusing meant a package declaring a NEW layer failed to load under -# every engine released before it — the vocabulary could never be extended by a -# published package, permanently. Warning means a dependency's typo costs a -# missing layer the consumer is told about, and cannot fix anyway. -# -# What this file asserts is therefore the property the prefix was introduced -# for — the name is reported — not the severity. The root project's own -# manifest still errors; e2e 281 covers that half. -provider_declares "mcpp:kernel_abi=fakeos" "mcpp:c-abi=fakelibc" -bad=$("$MCPP" build 2>&1 || true) -echo "$bad" | grep -q "kernel_abi" || { - echo "a misspelled capability in the mcpp: namespace must be reported" >&2 - echo "$bad" >&2; exit 1 -} -echo "$bad" | grep -q 'mcpp:kernel-abi' || { - echo "the report must list the layer names that do exist" >&2 - echo "$bad" >&2; exit 1 -} -# And the misspelled layer must be UNFILLED — reporting the name and then -# resolving the layer anyway would be the worst of both. -echo "$bad" | grep -qE 'kernel-abi +fakeos' && { - echo "a name this engine does not know must not fill a layer" >&2 - echo "$bad" >&2; exit 1 -} -# The layer beside it, spelled correctly, still resolves: one bad entry costs -# one layer and not the package. -echo "$bad" | grep -qE 'c-abi +fakelibc' || { - echo "a correctly spelled capability beside a misspelled one must still resolve" >&2 - echo "$bad" >&2; exit 1 -} - -# ── A name outside the namespace is none of mcpp's business ──────────────── -# -# `provides` also carries capabilities packages match among themselves. Closing -# the whole array would reject those, and the freestanding allocator selection -# that already ships is one. -provider_declares "a-capability-mcpp-never-heard-of" "mcpp:c-abi=fakelibc" -free=$("$MCPP" build 2>&1 || true) -echo "$free" | grep -q "names no capability mcpp knows" && { - echo "a capability outside the mcpp: namespace must pass through untouched" >&2 - echo "$free" >&2; cat sys/mcpp.toml >&2; exit 1 -} -echo "$free" | grep -q 'c-abi *fakelibc' || { - echo "the layer beside the unknown name must still resolve" >&2 - echo "$free" >&2; exit 1 -} - -echo "target-side resolution reads the graph, reports it, and validates its own namespace" diff --git a/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh b/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh deleted file mode 100755 index bb41b2b93..000000000 --- a/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# requires: import-std-libcxx -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# The older toolchain spelling keeps working, and keeps meaning the same thing. -# -# WHY THIS FILE EXISTS. -# -# `openkal-llvm` was a toolchain family that named the same payload as `llvm` -# and existed to carry one fact: that a project's C library, C++ runtime and -# platform implementation come from packages rather than from a payload beside -# the compiler. That fact now belongs to `mcpp.targetside`, is read from what -# packages declare, and is resolved after the dependency graph exists — so the -# family name carries nothing and nothing branches on it. -# -# What must not happen is that a manifest written against the older spelling -# stops building. There is no deprecation deadline here on purpose: the spelling -# costs one row in a name table, and an engine that refuses a manifest it used to -# accept has broken a project that did nothing wrong. -# -# The assertion is about the RESOLVED DRIVER rather than about a successful -# build, because the two spellings are supposed to be indistinguishable at that -# point and a build additionally depends on what the project contains. -# -# `"$MCPP"`, never a bare `mcpp`: the harness passes the binary under test. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" -mkdir -p src -printf 'int main() { return 0; }\n' > src/main.cpp - -manifest() { - cat > mcpp.toml <&1 | sed -n 's/.*Resolved [^ ]* → \(.*\)$/\1/p' | head -1 -} - -new_spelling=$(driver_for "llvm@${LLVM_VERSION}") -old_spelling=$(driver_for "openkal-llvm@${LLVM_VERSION}") - -[ -n "$new_spelling" ] || { - echo "could not read the resolved driver for the current spelling" >&2 - manifest "llvm@${LLVM_VERSION}"; "$MCPP" build 2>&1 | head -20 >&2 - exit 1 -} - -[ "$new_spelling" = "$old_spelling" ] || { - echo "the two spellings must resolve to the same driver" >&2 - echo " llvm@${LLVM_VERSION} → $new_spelling" >&2 - echo " openkal-llvm@${LLVM_VERSION} → $old_spelling" >&2 - exit 1 -} - -# AND THE OLDER SPELLING MUST NOT STILL DECIDE ANYTHING. Both manifests here -# have an empty dependency graph, so both must report a target side supplied -# entirely by the payload. If the family name still carried the fact it used to, -# the second would report `graph` somewhere and the first would not. -manifest "openkal-llvm@${LLVM_VERSION}" -rm -rf target -# MCPP_VERBOSE, because an ordinary report prints only the layers the compiler -# payload did NOT supply — and every layer here is the payload's, which is -# precisely what this assertion is about. -old_report=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 | grep -E 'kernel-abi|c-abi' || true) -echo "$old_report" | grep -q 'graph' && { - echo "the toolchain family name must no longer decide where the target side comes from" >&2 - echo "$old_report" >&2 - exit 1 -} -echo "$old_report" | grep -q 'payload' || { - echo "with an empty graph every layer comes from the payload" >&2 - echo "$old_report" >&2 - exit 1 -} - -echo "the openkal-llvm spelling resolves as llvm and decides nothing" diff --git a/tests/e2e/26_c_language_support.sh b/tests/e2e/26_c_language_support.sh deleted file mode 100755 index 5cc23cfd5..000000000 --- a/tests/e2e/26_c_language_support.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# C-language compile rule: .c files routed to `c_object` with cc / cflags, -# distinct from the .cppm/.cpp `cxx_object` rule. Verifies that a mixed -# C + modular-C++23 project links and runs, and that build.ninja contains -# the expected `c_object` / `cc` / `cflags` plumbing. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new cmix > /dev/null -cd cmix - -# A pure-C source that *requires* the C frontend: uses `restrict` and -# implicit `void* → int*` from malloc(), both rejected by g++ in C++ mode. -cat > src/cmix_core.c <<'EOF' -#include -#include -int *cmix_dup(const int *restrict src, size_t n) { - int *out = malloc(n * sizeof(int)); - if (!out) return 0; - memcpy(out, src, n * sizeof(int)); - return out; -} -int cmix_sum(const int *p, size_t n) { - int s = 0; - for (size_t i = 0; i < n; ++i) s += p[i]; - return s; -} -EOF - -cat > src/main.cpp <<'EOF' -import std; -extern "C" int *cmix_dup(const int *src, std::size_t n); -extern "C" int cmix_sum(const int *p, std::size_t n); -int main() { - int data[] = {1, 2, 3, 4, 5}; - int *copy = cmix_dup(data, 5); - int s = cmix_sum(copy, 5); - std::println("cmix sum = {}", s); - std::free(copy); - return s == 15 ? 0 : 1; -} -EOF - -# Add user-supplied cflags/cxxflags so we also exercise the package-owned -# per-unit flag-forwarding path through the manifest. -cat > mcpp.toml <<'EOF' -[package] -name = "cmix" -version = "0.1.0" -[build] -cflags = ["-DCMIX_C_BUILD=1"] -cxxflags = ["-DCMIX_CXX_BUILD=1"] -c_standard = "c11" -EOF - -"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } - -ninja_file="$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } - -# c_object rule must be present alongside cxx_object. -grep -q '^rule c_object' "$ninja_file" || { cat "$ninja_file"; echo "missing c_object rule"; exit 1; } -# cc / cflags must be defined and pick a C compiler (gcc / cc / clang). -grep -qE '^cc = .*(gcc|cc|clang)' "$ninja_file" || { - echo "cc variable not pointing at a C compiler"; exit 1; } -grep -qE '^cflags = -std=c11' "$ninja_file" || { - echo "cflags missing -std=c11 baseline"; exit 1; } -grep -qE '^cxxflags = -std=c\+\+23' "$ninja_file" || { - echo "cxxflags missing -std=c++23 baseline"; exit 1; } -grep -q -- 'unit_cflags = -DCMIX_C_BUILD=1' "$ninja_file" || { - echo "unit cflags missing user C flag"; exit 1; } -grep -q -- 'unit_cxxflags = -DCMIX_CXX_BUILD=1' "$ninja_file" || { - echo "unit cxxflags missing user C++ flag"; exit 1; } -# The .c source must be routed through c_object (not cxx_object). -grep -qE 'build obj/cmix_core\.o : c_object .*cmix_core\.c' "$ninja_file" || { - echo "cmix_core.c not routed to c_object rule"; exit 1; } -grep -qE 'build obj/cmix_core\.o : cxx_object' "$ninja_file" && { - echo "cmix_core.c was incorrectly routed to cxx_object"; exit 1; } || true - -# Run the binary; it must print "cmix sum = 15" and exit 0. -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "cmix sum = 15" ]] || { - echo "unexpected output: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/26_toolchain_management.sh b/tests/e2e/26_toolchain_management.sh deleted file mode 100755 index 932428742..000000000 --- a/tests/e2e/26_toolchain_management.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 26_toolchain_management.sh — verify M5.5 toolchain CLI + isolation: -# - mcpp toolchain install / list / default / remove -# - hard error when no toolchain configured -# - global default lets project-level [toolchain] be omitted -# - mcpp uses ~/.mcpp/registry/, never falls back to system PATH -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -# Disable first-run auto-install — we explicitly drive install/list/default -# through the CLI here, and want any silent auto-install to surface as a -# test failure rather than a successful build under a different default. -export MCPP_NO_AUTO_INSTALL=1 - -# 1) toolchain list with empty home -out=$("$MCPP" toolchain list 2>&1) -[[ "$out" == *"no toolchains installed"* ]] || { echo "FAIL: empty list: $out"; exit 1; } - -# 2) hard error when no toolchain configured (with MCPP_NO_AUTO_INSTALL). -mkdir -p "$TMP/proj/src" -cd "$TMP/proj" -cat > mcpp.toml <<'EOF' -[package] -name = "tinybin" -version = "0.1.0" -EOF -cat > src/main.cpp <<'EOF' -import std; -int main() { std::println("ok"); return 0; } -EOF -rc=0 -out=$("$MCPP" build 2>&1) || rc=$? -[[ "$rc" -ne 0 ]] || { echo "FAIL: build should hard-error without toolchain"; exit 1; } -[[ "$out" == *"no toolchain configured"* ]] || { echo "FAIL: error not helpful: $out"; exit 1; } - -# 3) Install gcc 16.1.0 -if [[ -n "${MCPP_E2E_TOOLCHAIN_MIRROR:-}" ]]; then - "$MCPP" self config --mirror "$MCPP_E2E_TOOLCHAIN_MIRROR" - "$MCPP" self config -fi -rc=0 -"$MCPP" toolchain install gcc 16.1.0 > /tmp/_inst.log 2>&1 || rc=$? -if [[ $rc -ne 0 ]]; then - echo "FAIL: install exited $rc; log:" - cat /tmp/_inst.log - echo "" - echo "--- diagnostic: sandbox tree depth 4 ---" - find "$MCPP_HOME/registry" -maxdepth 4 -type d 2>&1 | head -20 - echo "--- xpkgs contents ---" - ls "$MCPP_HOME/registry/data/xpkgs/" 2>&1 | head -10 - echo "--- xim-x-gcc tree ---" - find "$MCPP_HOME/registry/data/xpkgs/xim-x-gcc" 2>&1 | head -20 - echo "--- any g++ ---" - find "$MCPP_HOME" -name g++ 2>&1 | head -5 - echo "--- runtimedir gcc tarball ---" - ls "$MCPP_HOME/registry/data/runtimedir/" 2>&1 | head - exit 1 -fi -grep -q 'Installed' /tmp/_inst.log || { cat /tmp/_inst.log; echo "FAIL: no Installed line"; exit 1; } - -# 4) toolchain list now non-empty -out=$("$MCPP" toolchain list 2>&1) -echo "$out" | grep -q 'gcc' || { echo "FAIL: list missing gcc: $out"; exit 1; } -echo "$out" | grep -q '16.1.0' || { echo "FAIL: list missing version: $out"; exit 1; } - -# 5) Set as default -"$MCPP" toolchain default gcc@16.1.0 > /tmp/_def.log 2>&1 -grep -q 'Default' /tmp/_def.log || { cat /tmp/_def.log; echo "FAIL: no Default msg"; exit 1; } -out=$("$MCPP" toolchain list 2>&1) -echo "$out" | grep -q '\*' || { echo "FAIL: default mark missing: $out"; exit 1; } -"$MCPP" toolchain default gcc 16.1.0 > /tmp/_def_split.log 2>&1 || { - cat /tmp/_def_split.log; echo "FAIL: space-separated default failed"; exit 1; } -grep -q 'Default' /tmp/_def_split.log || { - cat /tmp/_def_split.log; echo "FAIL: no Default msg for space-separated form"; exit 1; } - -# 6) Build now succeeds (project has no [toolchain]; uses global default) -"$MCPP" build > /tmp/_build.log 2>&1 || { cat /tmp/_build.log; echo "FAIL: build failed"; exit 1; } -grep -q 'Finished' /tmp/_build.log || { cat /tmp/_build.log; echo "FAIL: no Finished"; exit 1; } -triple=$(ls -d target/x86_64-linux-*/ | head -1) -fp_dir=$(ls "$triple") -[[ "$(${triple}${fp_dir}/bin/tinybin)" == "ok" ]] || { echo "FAIL: runtime"; exit 1; } - -# 7) The compile commands in build.ninja should reference the mcpp-private -# binary, NOT the system xlings wrapper. -grep -q "$MCPP_HOME/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++" \ - "$triple$fp_dir/build.ninja" || { - echo "FAIL: build.ninja doesn't reference private binary" - grep -m1 '^cxx' "$triple$fp_dir/build.ninja" - exit 1 -} -if grep -q '/usr/bin/g++\|/usr/local/bin/g++' "$triple$fp_dir/build.ninja"; then - echo "FAIL: build.ninja referenced system path"; exit 1 -fi - -# 8) `mcpp env` reports the default -out=$("$MCPP" self env 2>&1) -echo "$out" | grep -q 'default toolchain = gcc@16.1.0' || { - echo "FAIL: env missing default toolchain line"; exit 1; } - -# 9) Remove -rm -rf target -# A family alone names no payload: the error says the version is missing, -# not that the spec is invalid (which reads as a wrong family name, #284). -rc=0 -"$MCPP" toolchain remove gcc > /tmp/_rm_nover.log 2>&1 || rc=$? -[[ "$rc" -eq 2 ]] || { cat /tmp/_rm_nover.log; echo "FAIL: remove without a version should exit 2 (got $rc)"; exit 1; } -grep -q 'needs the exact version' /tmp/_rm_nover.log || { - cat /tmp/_rm_nover.log; echo "FAIL: remove without a version did not name the missing version"; exit 1; } -"$MCPP" toolchain remove gcc 16.1.0 > /tmp/_rm.log 2>&1 -grep -q 'Removed' /tmp/_rm.log || { cat /tmp/_rm.log; echo "FAIL: no Removed msg for space-separated form"; exit 1; } -# The issue's cross-target spelling should parse too and reach the payload -# lookup, even when that target is not installed in this test workspace. -rc=0 -"$MCPP" toolchain remove gcc 16.1.0 --target x86_64-linux-musl > /tmp/_rm_target.log 2>&1 || rc=$? -[[ "$rc" -ne 0 ]] || { echo "FAIL: removing an absent target toolchain should fail"; exit 1; } -grep -q 'is not installed' /tmp/_rm_target.log || { - cat /tmp/_rm_target.log; echo "FAIL: split form with --target did not reach installed-payload check"; exit 1; } -# The compact spelling remains valid too; after removal it should reach the -# installed-payload check rather than fail spec parsing. -rc=0 -"$MCPP" toolchain remove gcc@16.1.0 > /tmp/_rm_at.log 2>&1 || rc=$? -[[ "$rc" -ne 0 ]] || { echo "FAIL: removing an absent toolchain should fail"; exit 1; } -grep -q 'is not installed' /tmp/_rm_at.log || { - cat /tmp/_rm_at.log; echo "FAIL: @-form remove did not reach installed-payload check"; exit 1; } - -# 10) After removing, the toolchain dir is gone, but config.toml still -# points to it as default. Verify list shows nothing for that compiler now. -out=$("$MCPP" toolchain list 2>&1) -if echo "$out" | grep -qE '^gcc.*15\.1\.0'; then - echo "FAIL: list still shows removed toolchain: $out"; exit 1 -fi - -echo "OK" diff --git a/tests/e2e/27_namespace_dependencies.sh b/tests/e2e/27_namespace_dependencies.sh deleted file mode 100755 index b2039cca2..000000000 --- a/tests/e2e/27_namespace_dependencies.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# requires: fresh-sandbox -# Namespaced dependencies: `[dependencies.] name = { path = "..." }` -# is parsed correctly and the dep is actually picked up by the build. -# Also verifies that the legacy `"." = "..."` quoted form still -# round-trips through the manifest parser. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -TMP_HOST="$(host_path "$TMP")" -trap "rm -rf $TMP" EXIT - -export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -# ── 1. Sibling lib package (acme:util). Pure-modular C++23. ───────────── -mkdir -p "$TMP/util-pkg" -cd "$TMP/util-pkg" -"$MCPP" new util > /dev/null -cd util -rm -f src/main.cpp -cat > src/util.cppm <<'EOF' -export module acme.util; -import std; -export int answer() { return 42; } -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "util" -version = "0.1.0" -[modules] -sources = ["src/**/*.cppm"] -[targets.util] -kind = "lib" -EOF - -# ── 2. Consumer that pulls util via the new namespaced subtable form. ─── -mkdir -p "$TMP/app" -cd "$TMP/app" -"$MCPP" new app > /dev/null -cd app -cat > src/main.cpp <<'EOF' -import std; -import acme.util; -int main() { std::println("answer = {}", answer()); return answer() == 42 ? 0 : 1; } -EOF -cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } -out=$("$MCPP" run 2>&1 | tail -1) -[[ "$out" == "answer = 42" ]] || { echo "unexpected output: $out"; exit 1; } - -# ── 3. Same consumer, legacy quoted-dotted form. Must still parse. ────── -cat > mcpp.toml < build-legacy.log 2>&1 || { cat build-legacy.log; echo "legacy form build failed"; exit 1; } -out=$("$MCPP" run 2>&1 | tail -1) -[[ "$out" == "answer = 42" ]] || { echo "legacy form unexpected output: $out"; exit 1; } - -# ── 4. Default-namespace flat form keeps working with no quotes. ──────── -mkdir -p "$TMP/util2-pkg" -cd "$TMP/util2-pkg" -"$MCPP" new util2 > /dev/null -cd util2 -rm -f src/main.cpp -cat > src/util2.cppm <<'EOF' -export module util2; -import std; -export int two() { return 2; } -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "util2" -version = "0.1.0" -[modules] -sources = ["src/**/*.cppm"] -[targets.util2] -kind = "lib" -EOF - -cd "$TMP/app/app" -cat > src/main.cpp <<'EOF' -import std; -import util2; -int main() { std::println("two = {}", two()); return two() == 2 ? 0 : 1; } -EOF -cat > mcpp.toml < build-flat.log 2>&1 || { cat build-flat.log; echo "flat form build failed"; exit 1; } -out=$("$MCPP" run 2>&1 | tail -1) -[[ "$out" == "two = 2" ]] || { echo "flat form unexpected output: $out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/280_target_side_layers.sh b/tests/e2e/280_target_side_layers.sh deleted file mode 100755 index 725ac9fd5..000000000 --- a/tests/e2e/280_target_side_layers.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# requires: -# The target side is five layers, and the report shows what is not ordinary. -# -# WHY THIS FILE EXISTS. -# -# A zero-configuration build resolves every layer from one compiler payload. A -# report that prints five lines reading `(payload)` answers a question nobody -# asked, and the lines that matter — a C library or a C++ runtime that came from -# somewhere else — are then read out of a block that always looks the same. -# -# The two assertions below are the two halves of that rule: silence when there -# is nothing to say, and every layer when the reader asks for it. -set -e - -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -"$MCPP" new plain > /dev/null -cd plain - -# ── 1. Nothing came from elsewhere, so no layer earns a line ──────────────── -out="$("$MCPP" build 2>&1)" -target_line="$(printf '%s\n' "$out" | grep -E '^\s+Target ' || true)" -[[ -n "$target_line" ]] || { echo "no Target line at all:"; echo "$out"; exit 1; } - -# The labels go through `grep -F`, not a pattern. `c++-abi` in an extended -# regular expression is a repetition operator applied to a repetition operator, -# which GNU grep tolerates and BSD grep rejects outright — measured on macOS: -# grep: repetition-operator operand invalid -for label in compiler compiler-runtime kernel-abi c-abi c++-abi; do - if printf '%s\n' "$out" | grep -qF " ${label} "; then - echo "a zero-configuration build printed the '$label' layer, which came" - echo "from the compiler payload and is therefore not news:" - echo "$out" - exit 1 - fi -done - -# ── 2. Asked for it, every layer is there ────────────────────────────────── -"$MCPP" clean > /dev/null 2>&1 -verbose="$(MCPP_VERBOSE=1 "$MCPP" build 2>&1)" -for label in compiler compiler-runtime kernel-abi c-abi c++-abi; do - printf '%s\n' "$verbose" | grep -qF " ${label} " || { - echo "MCPP_VERBOSE did not print the '$label' layer:" - printf '%s\n' "$verbose" | grep -A 8 'Target ' - exit 1 - } -done - -# The compiler layer reports the FAMILY, which is the spelling every toolchain -# spec and every capability uses. `clang` is the driver's name and would make a -# requirement written as `mcpp:compiler=llvm` unsatisfiable. -printf '%s\n' "$verbose" | grep -qE "compiler +(llvm|gcc|msvc) " || { - echo "the compiler layer must report a family, not a driver name:" - printf '%s\n' "$verbose" | grep -F " compiler " - exit 1 -} - -echo "OK" diff --git a/tests/e2e/281_target_side_rules.sh b/tests/e2e/281_target_side_rules.sh deleted file mode 100755 index e1ed164c5..000000000 --- a/tests/e2e/281_target_side_rules.sh +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# The two rules that govern the target side, each stated as a refusal. -# -# WHY THIS FILE EXISTS. -# -# Both conditions below used to reach a compiler or a linker. One produced -# -# fatal error: __config: No such file or directory -# -# from a `std` module source the package supplied and gcc could not consume; -# the other silently selected whichever supplier the graph traversal reached -# first and carried the loser's build inputs into the same command line. -# -# A message from a compiler about a target-side combination is a missing -# diagnostic: the engine knows the combination is untenable before it emits any -# command line. These assertions are that the engine says so. -set -e - -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -# ── Rule two: an implementation is configured for the layer beneath it ────── -# -# The package declares a requirement the resolved compiler does not meet. It -# supplies no sources, so nothing here depends on the requirement being wrong -# in any particular way — only on it being stated. -mkdir -p runtime app/src -cat > runtime/mcpp.toml <<'TOML' -[package] -namespace = "probe" -name = "needs-llvm" -version = "0.1.0" -provides = ["mcpp:c++-abi=libc++"] -requires = ["mcpp:compiler=llvm"] - -[build] -sources = [] -TOML -cat > app/mcpp.toml <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -needs-llvm = { path = "../runtime" } -TOML -printf 'int main(){ return 0; }\n' > app/src/main.cpp - -cd app -rc=0 -out="$(MCPP_TOOLCHAIN=gcc@16.1.0 "$MCPP" build 2>&1)" || rc=$? -[[ "$rc" -ne 0 ]] || { echo "a requirement gcc does not meet was accepted:"; echo "$out"; exit 1; } -grep -q "requires the compiler to be" <<< "$out" || { - echo "the refusal does not name the requirement:"; echo "$out"; exit 1; } -# THE NEXT STEP MUST BE ONE THAT WOULD ACTUALLY WORK, AND UNTIL 2026.8.26.2 -# THE FIRST ONE OFFERED WAS `mcpp toolchain default llvm`. -# -# That is a GLOBAL change — the default for every project on the machine — -# printed because ONE project's dependency asked. And since the graph's -# requirement is now applied wherever mcpp's own answer was revisable, the only -# way to reach this refusal is a compiler the project STATED (here, -# `MCPP_TOOLCHAIN`). In that situation the global default is not what is being -# used, so changing it fixes nothing: the advice has to name the statement that -# decided. -grep -q "remove it" <<< "$out" || { - echo "the refusal names no next step:"; echo "$out"; exit 1; } -# `if`, NOT `grep … && { … }`. Under `set -e` a trailing `&&` list whose -# left side fails takes the script down — and here grep FAILING is the passing -# case. This repo has paid for that shape more than once. -if grep -q "mcpp toolchain default" <<< "$out"; then - echo "the refusal offers a global change that would not fix this failure:" - echo "$out"; exit 1 -fi -# The evidence the decision rests on must be printed even though the compiler -# layer comes from the payload and is suppressed in an ordinary report. -grep -qE "^\s+compiler\s+gcc" <<< "$out" || { - echo "the refusal omits the layer it rests on:"; echo "$out"; exit 1; } -cd .. - -# ── Rule one: one supplier per layer ──────────────────────────────────────── -# -# A C library is a mutually exclusive choice, not an additive contribution. -# Selecting the wrong one does not fail the link; it produces a program that -# runs and intermittently does not. -mkdir -p a b app2/src -for n in a b; do - cat > "$n/mcpp.toml" < app2/mcpp.toml <<'TOML' -[package] -name = "app2" -version = "0.1.0" - -[dependencies] -libc-a = { path = "../a" } -libc-b = { path = "../b" } -TOML -printf 'int main(){ return 0; }\n' > app2/src/main.cpp - -cd app2 -rc=0 -out="$("$MCPP" build 2>&1)" || rc=$? -[[ "$rc" -ne 0 ]] || { echo "two suppliers of one layer were accepted:"; echo "$out"; exit 1; } -grep -q "two packages supply the c-abi" <<< "$out" || { - echo "the refusal does not name the layer:"; echo "$out"; exit 1; } -for pkg in "libc-a@0.1.0" "libc-b@0.1.0"; do - grep -q "$pkg" <<< "$out" || { - echo "the refusal does not name $pkg:"; echo "$out"; exit 1; } -done - -# ── The compiler is a layer no package may supply ─────────────────────────── -cd .. -mkdir -p c app3/src -cat > c/mcpp.toml <<'TOML' -[package] -namespace = "probe" -name = "claims-compiler" -version = "0.1.0" -provides = ["mcpp:compiler=llvm"] - -[build] -sources = [] -TOML -cat > app3/mcpp.toml <<'TOML' -[package] -name = "app3" -version = "0.1.0" - -[dependencies] -claims-compiler = { path = "../c" } -TOML -printf 'int main(){ return 0; }\n' > app3/src/main.cpp -cd app3 -rc=0 -out="$("$MCPP" build 2>&1)" || rc=$? -[[ "$rc" -ne 0 ]] || { echo "a package was allowed to supply the compiler:"; echo "$out"; exit 1; } -grep -q "not a layer a package can supply" <<< "$out" || { - echo "the refusal does not say why:"; echo "$out"; exit 1; } - -# ── The layer vocabulary must be extensible by a published package ────────── -# -# This one comes from a measurement, and the behaviour it replaces made the -# vocabulary permanently un-extendable: -# -# error: dependency 'openkal-llvm-runtime': mcpp.toml: error: -# `provides = ["mcpp:compiler-runtime=compiler-rt"]` names no -# capability mcpp knows. -# -# A package declaring a newly named layer failed to load under every engine -# released before that layer was named. Whose manifest it is decides the -# answer: the root's is a misspelling the author is looking at, a dependency's -# is a manifest written against a newer engine. -cd .. -mkdir -p future app4/src -cat > future/mcpp.toml <<'TOML' -[package] -namespace = "probe" -name = "from-the-future" -version = "0.1.0" -provides = ["mcpp:quantum-abi=spooky"] - -[build] -sources = [] -TOML -cat > app4/mcpp.toml <<'TOML' -[package] -name = "app4" -version = "0.1.0" - -[dependencies] -from-the-future = { path = "../future" } -TOML -printf 'int main(){ return 0; }\n' > app4/src/main.cpp -cd app4 -rc=0 -out="$("$MCPP" build 2>&1)" || rc=$? -[[ "$rc" -eq 0 ]] || { - echo "a dependency naming a layer this engine does not know must not fail the build:" - echo "$out"; exit 1; } -grep -q "quantum-abi" <<< "$out" || { - echo "the ignored layer must be named:"; echo "$out"; exit 1; } - -# And the same name in the ROOT's own manifest is the author's to fix. -cat > mcpp.toml <<'TOML' -[package] -name = "app4" -version = "0.1.0" -provides = ["mcpp:quantum-abi=spooky"] -TOML -rc=0 -out="$("$MCPP" build 2>&1)" || rc=$? -[[ "$rc" -ne 0 ]] || { - echo "a misspelled layer name in the root manifest must be an error:" - echo "$out"; exit 1; } -grep -q "names no capability mcpp knows" <<< "$out" || { - echo "the refusal does not say what is wrong:"; echo "$out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/282_target_is_a_request.sh b/tests/e2e/282_target_is_a_request.sh deleted file mode 100755 index 7e7fa5d0e..000000000 --- a/tests/e2e/282_target_is_a_request.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# The target triple states a request; the target side states the fact. -# -# WHY THIS FILE EXISTS. -# -# Two symptoms, one cause. A triple serves as an IDENTITY — the output -# directory, a cache key, the subject of a `cfg()` — and identities must be -# total, so `parse` fills `x86_64-linux` in as `x86_64-linux-gnu`. It also -# serves as a REQUEST, and a request must be able to say nothing. The filling -# destroyed the second, and the target row's convention was applied before the -# graph that decides whether it is needed even exists. -# -# Measured before this: -# -# Target x86_64-linux-gnu → x86_64-unknown-linux-gnu -# c-abi musl (openkal-musl@0.3.3, graph) -# -# — the name contradicts the line under it, and the build succeeded anyway. -set -e - -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -mkdir -p libc/src app/src -cat > libc/mcpp.toml <<'TOML' -[package] -namespace = "probe" -name = "tiny-musl" -version = "0.1.0" -provides = ["mcpp:c-abi=musl"] - -[build] -sources = [] -TOML -cat > app/mcpp.toml <<'TOML' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -tiny-musl = { path = "../libc" } -TOML -printf 'int main(){ return 0; }\n' > app/src/main.cpp -cd app - -# ── 1. Declining to name a C library is not naming `gnu` ──────────────────── -# -# `|| true`, and that is the subject rather than a concession: the stand-in -# package declares the layer without supplying one, so the LINK afterwards has -# no C library to find. What this file asserts — the report and the refusal — -# is complete before a single object is compiled. -out="$("$MCPP" build --target x86_64-linux 2>&1 || true)" -grep -q "requests the" <<< "$out" && { - echo "declining to name a C library must not be a contradiction:" - echo "$out"; exit 1; } -grep -qE "Target x86_64-linux( |$)" <<< "$out" || { - echo "the report must show the target as the project spelled it:"; echo "$out"; exit 1; } -grep -q "Target x86_64-linux-gnu" <<< "$out" && { - echo "a segment the project did not write must not appear in the report:" - echo "$out"; exit 1; } - -# ── 2. Naming one the graph disagrees with is reported, not refused ───────── -# -# The severity was decided by a measurement. Refusing is the clean answer — -# only one of the two names can describe the artifact — and it broke every -# project and CI configuration spelling the host target `x86_64-linux-gnu`, -# which is what `mcpp toolchain list` prints and therefore what people write. -# mcpp's own openkal matrix was the first casualty. -# -# What settles it is that the request changes nothing: the graph supplies the C -# library either way, so the segment is ignored rather than violated. -out="$("$MCPP" build --target x86_64-linux-gnu 2>&1 || true)" -grep -q "asks for the .gnu. C ABI" <<< "$out" || { - echo "the mismatch was not reported:"; echo "$out"; exit 1; } -grep -q "musl" <<< "$out" || { - echo "the report does not name what resolved:"; echo "$out"; exit 1; } -grep -q -- "--target x86_64-linux" <<< "$out" || { - echo "the report names no correct spelling to use instead:"; echo "$out"; exit 1; } - -echo "OK" diff --git a/tests/e2e/283_run_target_flag_owns_its_cache_slot.sh b/tests/e2e/283_run_target_flag_owns_its_cache_slot.sh deleted file mode 100755 index c4f93e737..000000000 --- a/tests/e2e/283_run_target_flag_owns_its_cache_slot.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# `mcpp run --target X` must not write X's build into the HOST's cache slot. -# -# WHAT MAKES THIS FAIL SILENTLY RATHER THAN LOUDLY. -# -# The build cache is keyed on the target triple, and `mcpp run`'s fast path -# matches the entry whose key is EMPTY — empty means "built for this machine", -# which is the only case where exec'ing a cached artifact directly is correct. -# `build_run_target` received the `--target` value, used it to prepare a correct -# cross build, and then did not pass it to the function that writes the cache. -# The cross build was recorded under the host's key. -# -# Nothing about that build is wrong and nothing reports it. The damage lands on -# the NEXT command: a bare `mcpp run` finds an entry claiming to be a host -# build, skips prepare_build entirely — so no host artifact is produced and no -# runner is resolved — and exec's the foreign binary on this machine. -# -# Measured on 2026.8.24.3 with a bare-metal target, from a clean `target/`: -# -# $ mcpp run --target riscv64-none-elf # correct, ran under qemu -# $ mcpp run -# Running `target/riscv64-none-elf/…/bin/openkal-same-source` -# exit=1 -# -# THE ASSERTION IS ON THE PATH, NOT ON THE EXIT CODE. A foreign-architecture -# artifact fails to exec, so a test checking only the status would pass for the -# wrong reason there and MISS the defect wherever the foreign binary happens to -# run — which is what a same-architecture cross build does. What is wrong is -# which directory the program came from. -# -# The comment on `try_fast_run` records this same defect reached through the -# manifest's `[build] target` and guards that door. This is the other door. -set -e - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -mkdir -p app/src -cat > app/mcpp.toml <<'TOML' -[package] -name = "runslot" -version = "0.1.0" -TOML - -cat > app/src/main.cpp <<'CPP' -#include -int main() { std::printf("host-artifact\n"); } -CPP - -cd app - -# A second target this machine can build AND run: the same architecture and OS, -# spelled with the other C library. A bare-metal target would expose the defect -# through a crash, which is the weaker signal — see the note above. -cross=x86_64-linux-musl - -if ! "$MCPP" build --target "$cross" >/dev/null 2>&1; then - echo "SKIP: this machine cannot build $cross"; exit 77 -fi - -rm -rf target -if ! "$MCPP" run --target "$cross" >/dev/null 2>&1; then - echo "SKIP: $cross builds but does not run here"; exit 77 -fi - -# ── The command under test ────────────────────────────────────────────────── -if ! out="$("$MCPP" run 2>&1)"; then - echo "FAIL: a bare \`mcpp run\` after a cross run exited non-zero" - printf '%s\n' "$out" | tail -5 - exit 1 -fi - -# The load-bearing line. `Running `…`` names the artifact's path, and the -# path names the target it was built for. -ran="$(printf '%s\n' "$out" | sed -n 's/.*Running `\([^`]*\)`.*/\1/p' | head -1)" -if [ -z "$ran" ]; then - echo "FAIL: no \`Running\` line in the output"; printf '%s\n' "$out"; exit 1 -fi - -case "$ran" in - *"/$cross/"*) - echo "FAIL: a bare \`mcpp run\` exec'd the $cross artifact" - echo " $ran" - echo " The cross build was recorded under the host's cache key." - exit 1 ;; -esac - -if ! printf '%s\n' "$out" | grep -q 'host-artifact'; then - echo "FAIL: the host program's own output is missing" - printf '%s\n' "$out" - exit 1 -fi - -echo "OK: \`mcpp run --target $cross\` left the host's cache slot alone" diff --git a/tests/e2e/284_env_segment_is_optional_everywhere.sh b/tests/e2e/284_env_segment_is_optional_everywhere.sh deleted file mode 100755 index c92e0350a..000000000 --- a/tests/e2e/284_env_segment_is_optional_everywhere.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# `arch-os` is a target on every platform, not only on Linux. -# -# WHY THIS WAS ASYMMETRIC AND WHY THE ASYMMETRY COST SOMETHING. -# -# A target triple states a REQUEST, and a request must be able to say nothing. -# `x86_64-linux` was accepted on that basis; `x86_64-windows` was rejected with -# `unknown target`, and `riscv64-none` likewise. The two platforms where the -# segment could not be declined are exactly the two where it names something -# other than a C library — the object ABI on Windows, the object format on bare -# metal — so a project was required to write a word describing nothing it had -# chosen. Under a graph-supplied target side that word is `gnu`, while the -# compiler is clang, the linker lld, the C library musl and the C++ runtime -# libc++: nothing in the build is GNU. -# -# THE ASSERTION IS ON THE IDENTITY, NOT ONLY ON THE EXIT STATUS. Accepting the -# short spelling is worth nothing if it produces a second output directory or a -# second fingerprint: the project would then build twice, cache nothing, and the -# two artefacts could drift. The identity must stay total — `x86_64-windows` IS -# `x86_64-windows-gnu` — while only the RECORD of what was asked for differs. -set -e - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -mkdir -p app/src -cat > app/mcpp.toml <<'TOML' -[package] -name = "envopt" -version = "0.1.0" -TOML -cat > app/src/main.cpp <<'CPP' -#include -int main() { std::printf("ok\n"); } -CPP -cd app - -# ── The pair under test, per platform ──────────────────────────────────────── -# Each is "short spelling" and "the canonical form it must resolve to". -check_pair() { - short="$1"; canonical="$2" - - rm -rf target - if ! out="$("$MCPP" build --target "$short" 2>&1)"; then - case "$out" in - *"unknown target"*) - echo "FAIL: \`$short\` was rejected" - printf '%s\n' "$out" | head -3 - return 1 ;; - *) echo "SKIP: $short cannot build here (no payload/toolchain)" - return 0 ;; - esac - fi - - # One directory, named for the CANONICAL form. - if [ ! -d "target/$canonical" ]; then - echo "FAIL: \`$short\` did not build into target/$canonical" - echo " got: $(ls target 2>/dev/null | tr '\n' ' ')" - return 1 - fi - fp_short="$(ls "target/$canonical" | head -1)" - - # The load-bearing step: the long spelling must land on the SAME - # fingerprint, which is what makes the second build a cache hit rather than - # a second full build. - "$MCPP" build --target "$canonical" >/dev/null 2>&1 || { - echo "FAIL: canonical \`$canonical\` failed after \`$short\` succeeded" - return 1; } - n="$(ls "target/$canonical" | wc -l)" - if [ "$n" != "1" ]; then - echo "FAIL: the two spellings produced $n fingerprints, not one" - echo " $(ls "target/$canonical" | tr '\n' ' ')" - return 1 - fi - - # And the report heads with what was WRITTEN, so a reader is not told they - # asked for something they declined. - case "$out" in - *"Target $short"*) : ;; - *) echo "FAIL: the report did not head with \`$short\`" - printf '%s\n' "$out" | grep -m1 Target - return 1 ;; - esac - - echo " ok $short → $canonical (one fingerprint: $fp_short)" - return 0 -} - -rc=0 -check_pair x86_64-linux x86_64-linux-gnu || rc=1 -# `x86_64-windows` IS A PATTERN (SPEC-016 §4): its candidates are the MSVC, the -# musl (openkal-platform) and the MinGW identities in that order of priority, and -# the identity is the first available here -- MSVC on a Windows host that has it, -# the musl identity elsewhere. Whichever it is, it is one identity, one directory. -win=x86_64-windows-musl -case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) - VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" - [ -f "$VSWHERE" ] && "$VSWHERE" -latest -products '*' \ - -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath \ - 2>/dev/null | grep -q . && win=x86_64-windows-msvc ;; -esac -check_pair x86_64-windows "$win" || rc=1 -check_pair riscv64-none riscv64-none-elf || rc=1 - -[ "$rc" = 0 ] || exit 1 -echo "OK: the env segment is optional on every platform, and declining it changes no identity" - -# ── The Windows C-library axis has a name of its own ───────────────────────── -# -# `x86_64-windows-musl` is a target mcpp names and LLVM cannot: clang is given -# `x86_64-w64-windows-gnu`, the Itanium C++ ABI the C library was compiled for, -# and mcpp's identity names the C library. Its provider is openkal-platform's -# hosted part (SPEC-016 §3), so naming it is enough to build it: the platform -# brings openkal-musl and the LLVM run-time, and pins clang -- the one family -# that emits it. A project names no dependency for it. -out="$("$MCPP" build --target x86_64-windows-musl 2>&1 || true)" - -case "$out" in - *"unknown target"*) - echo "FAIL: x86_64-windows-musl is not in the target table" - exit 1 ;; -esac - -case "$out" in - *"Platform openkal-platform"*"built-in, hosted"*) - echo " ok x86_64-windows-musl is served by openkal-platform's hosted part" ;; - *) - echo "FAIL: the build was not served by openkal-platform" - printf '%s\n' "$out" | grep -m3 -iE 'error|Target|Platform' - exit 1 ;; -esac - -case "$out" in - *"→ x86_64-windows-musl →"*"llvm"*|*"llvm"*"→ x86_64-windows-musl →"*) - echo " ok the toolchain line carries mcpp's own name, and clang" ;; - *) - echo "FAIL: the toolchain line did not carry mcpp's own name for the target" - printf '%s\n' "$out" | grep -m2 -i 'resolv' - exit 1 ;; -esac - -case "$out" in - *"Target x86_64-windows-musl → x86_64-w64-windows-gnu"*) - echo " ok the compiler is given the Itanium triple" ;; - *) - echo "FAIL: the report does not show the compiler's triple" - printf '%s\n' "$out" | grep -m2 'Target' - exit 1 ;; -esac - -echo "OK: mcpp names a C library LLVM cannot spell, and its provider builds it" diff --git a/tests/e2e/28_target_static.sh b/tests/e2e/28_target_static.sh deleted file mode 100755 index ae08e2f66..000000000 --- a/tests/e2e/28_target_static.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# requires: musl elf -# resources: machine -# 28_target_static.sh — `mcpp build --target ` produces a binary -# matching the requested target, and `--target *-linux-musl` yields a -# fully-static ELF (no PT_INTERP, no RUNPATH). -# -# Skip if the host doesn't have musl-gcc available — we don't auto-install -# it in this test (would be a 200 MB download per CI run). -set -e - -# The musl toolchain is the one this home has (`mcpp toolchain install gcc -# -musl`; CI's shards install it), whichever release that is. The probe -# named 15.1.0 until 2026-11-12, the runners installed a later one, and the -# script skipped itself on every runner. -source "$(dirname "$0")/_toolchain_env.sh" -if ! [ -x "$MUSL_GCC_ROOT/bin/x86_64-linux-musl-g++" ]; then - echo "SKIP: no musl-gcc payload in this home: not testing --target=*-linux-musl" - echo "OK" - exit 77 -fi - -# Detect installed GNU gcc version for the default-build regression check. -# Without a known GNU toolchain the default build would auto-install -# musl-gcc and produce another musl binary, making the assertion meaningless. -XPKGS="${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs" -GNU_GCC_VER="" -if [[ -d "$XPKGS/xim-x-gcc" ]]; then - GNU_GCC_VER=$(ls "$XPKGS/xim-x-gcc" 2>/dev/null | sort -V | tail -1) -fi - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -cd "$TMP" -"$MCPP" new staticapp >/dev/null -cd staticapp - -# Add a [target.x86_64-linux-musl] override that tells mcpp to use musl-gcc. -# If a GNU gcc is available, also pin [toolchain].linux so the default build -# uses it (preventing fallback to auto-install musl-gcc as default). -if [[ -n "$GNU_GCC_VER" ]]; then -cat >> mcpp.toml <> mcpp.toml < build.log 2>&1 || { - cat build.log; echo "build failed"; exit 1; } - -# Output path includes the triple — `target/x86_64-linux-musl//bin/staticapp`. -binary=$(find target/x86_64-linux-musl -type f -name staticapp | head -1) -[[ -n "$binary" ]] || { echo "binary not produced under target/x86_64-linux-musl"; ls -R target; exit 1; } - -# `file` should report a statically-linked ELF. -file "$binary" | grep -q 'statically linked' || { - file "$binary"; echo "binary is not statically linked"; exit 1; } - -# Sanity-check it runs. -"$binary" >/dev/null 2>&1 || { echo "static binary failed to run"; exit 1; } - -# Defensive: no PT_INTERP and no RUNPATH should be present. -if readelf -l "$binary" 2>/dev/null | grep -q 'INTERP'; then - echo "static binary unexpectedly has PT_INTERP"; readelf -l "$binary" | grep -A1 INTERP; exit 1 -fi -if readelf -d "$binary" 2>/dev/null | grep -qE 'RUNPATH|RPATH'; then - echo "static binary unexpectedly has RUNPATH/RPATH"; readelf -d "$binary" | grep -E 'RUNPATH|RPATH'; exit 1 -fi - -# Default GNU build still works (regression: --target should not have -# clobbered the default codepath for follow-up commands). -# Skip this check if no GNU gcc is available — without it the default -# build would also use musl-gcc, making the assertion meaningless. -if [[ -n "$GNU_GCC_VER" ]]; then - "$MCPP" build > build-gnu.log 2>&1 || { - cat build-gnu.log; echo "default GNU build broke after musl build"; exit 1; } - gnu_binary=$(find target -type d -name x86_64-linux-musl -prune -o -type f -name staticapp -print | head -1) - [[ -n "$gnu_binary" ]] || { echo "default GNU binary missing"; exit 1; } -else - echo "SKIP: no GNU gcc installed — skipping default-build regression check" -fi - -echo "OK" diff --git a/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh b/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh deleted file mode 100755 index 4fd1a3e7c..000000000 --- a/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell -# A build program looks up a tool and finds the one the PROJECT declared. -# -# IT USED TO FIND WHATEVER THE MACHINE HAD, AND `command -v` CANNOT TELL THE -# DIFFERENCE. Measured 2026-08-25 with a build program that printed its own -# PATH: the project's declared environment appeared nowhere in it, and -# `command -v qemu-system-riscv64` returned a shim that answers, when run, -# -# [error] qemu-system-riscv64 is not installed in this subos (_) -# -# — found, reported present, unable to execute, while a working copy sat in an -# environment the build could not reach. -# -# THIS FILE ASSERTS BOTH DIRECTIONS, BECAUSE ONLY ONE OF THEM IS THE -# FEATURE. Prepending unconditionally would pass the "declared" half, and that -# is the design that was withdrawn: a shared directory in front of every -# project makes what a build sees depend on what else was installed on that -# machine. The declaration is what puts it there, so a project that declares -# nothing must come out unchanged. -# -# AND "UNCHANGED" IS COMPARED AGAINST THE INHERITED VALUE, NOT AGAINST A -# PATTERN. The first version of this half rejected a first entry matching -# `*/subos/*/bin` — and CI's own PATH already begins with one, because the -# runner activates an xlings environment to get mcpp at all: -# -# MCPP: /home/runner/.xlings/subos/default/bin/mcpp -# -# so it reported mcpp prepending something that was already there. A test for -# "did not change it" has to hold the before and the after side by side. -set -e - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT - -# A NON-ZERO EXIT, BECAUSE THAT IS WHAT MAKES THE OUTPUT VISIBLE. mcpp -# prints a build program's stdout only when it fails — a probe that succeeds -# says nothing, which is a property of the protocol and not of this test. -probe='import std; - -int main() { - const char* p = std::getenv("PATH"); - std::println("PROBE_PATH={}", p ? p : ""); - return 1; -}' - -# Echoes the child's PATH, or nothing if the build program did not run. -run_probe() { - local dir="$1" out - out="$(cd "$dir" && "$MCPP" build 2>&1 || true)" - printf '%s\n' "$out" | grep -oP 'PROBE_PATH=\K.*' | head -1 -} - -make_project() { - local dir="$1" xlings="$2" - mkdir -p "$dir/src" - { printf '[package]\nname = "pathprobe"\nversion = "0.1.0"\n' - [ -n "$xlings" ] && printf '\n%s\n' "$xlings"; } > "$dir/mcpp.toml" - printf 'int main() { return 0; }\n' > "$dir/src/main.cpp" - printf '%s\n' "$probe" > "$dir/build.mcpp" -} - -# The value every assertion below is relative to. mcpp inherits this shell's -# PATH, so this is exactly what an unchanged child would report. -inherited="$PATH" - -# ── Half one: a project that declared nothing ───────────────────────────── -make_project "$work/plain" "" -plain="$(run_probe "$work/plain")" -if [ -z "$plain" ]; then - echo "SKIP: the build program did not report — it may not have run here" - exit 77 -fi - -if [ "$plain" = "$inherited" ]; then - echo " ok a project that declares nothing gets the PATH mcpp was started with" -else - echo "FAIL: a project that declared no environment had its PATH changed" - diff <(printf '%s\n' "$inherited" | tr ':' '\n') \ - <(printf '%s\n' "$plain" | tr ':' '\n') | head -6 | sed 's/^/ /' - exit 1 -fi - -# ── Half two: the same project, declaring one ───────────────────────────── -# -# `default` rather than a private name: mcpp READS an environment and never -# creates one, so a name nobody has bootstrapped is a hard error by design. -# What is under test is the prepending, and `default` exercises it on any -# machine that has run `mcpp self init`. -make_project "$work/declared" '[xlings] -subos = "default"' -declared="$(run_probe "$work/declared")" -if [ -z "$declared" ]; then - echo "SKIP: the declaring project's build program did not report" - exit 77 -fi - -if [ "$declared" = "$inherited" ]; then - echo "FAIL: the declaration changed nothing — the environment is not in front" - echo " PATH: $(printf '%s' "$declared" | cut -c1-100)…" - exit 1 -fi - -first="${declared%%:*}" -case "$first" in - */subos/*/bin) echo " ok the declared environment is first: $first" ;; - *) echo "FAIL: something other than a subos was prepended" - echo " got: $first" - exit 1 ;; -esac - -# AND THE INHERITED VALUE IS STILL THERE, WHOLE, BEHIND IT. Prefixing means -# the rest is untouched; a test that only checked the first entry would pass on -# a PATH that had thrown everything else away, which would break every build -# program that calls `git`, `python3` or a shell. -if [ "${declared#*:}" = "$inherited" ]; then - echo " ok and the inherited PATH follows it, unchanged" -else - echo "FAIL: the inherited PATH was not preserved behind the prefix" - diff <(printf '%s\n' "$inherited" | tr ':' '\n') \ - <(printf '%s\n' "${declared#*:}" | tr ':' '\n') | head -6 | sed 's/^/ /' - exit 1 -fi - -echo "OK: the declaration puts an environment in front, and only the declaration does" diff --git a/tests/e2e/29_toolchain_partial_versions.sh b/tests/e2e/29_toolchain_partial_versions.sh deleted file mode 100755 index 43823b774..000000000 --- a/tests/e2e/29_toolchain_partial_versions.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 29_toolchain_partial_versions.sh — `mcpp toolchain default` accepts partial -# versions in either positional or @-separated form, AND `mcpp build` -# auto-installs the default toolchain on a first run with no toolchain -# configured. The full install path is covered by 26_toolchain_management.sh. -# -# We isolate config/default state via MCPP_HOME, while reusing already prepared -# xlings payloads when available so CI does not redownload full toolchains. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -inherit_payloads_only() { - MCPP_INHERIT_CONFIG=0 MCPP_INHERIT_SUBOS=0 source "$(dirname "$0")/_inherit_toolchain.sh" -} - -configure_e2e_mirror() { - if [[ -n "${MCPP_E2E_TOOLCHAIN_MIRROR:-}" ]]; then - "$MCPP" self config --mirror "$MCPP_E2E_TOOLCHAIN_MIRROR" > "$TMP/mirror.log" 2>&1 || { - cat "$TMP/mirror.log" - echo "failed to configure e2e mirror" - exit 1 - } - fi -} - -# ─── Section 1: dual-form + partial-version toolchain commands ───────── -export MCPP_HOME="$TMP/h1" -inherit_payloads_only -configure_e2e_mirror - -# Reuse the CI-prepared gcc payload. The full install path is covered by -# 26_toolchain_management.sh; this test focuses on partial/default parsing -# without redownloading large toolchain archives. -out=$("$MCPP" toolchain list 2>&1) -[[ "$out" == *"gcc"*"16.1.0"* ]] || { echo "gcc 16.1.0 missing from list:"; echo "$out"; exit 1; } - -# `default gcc 16` (positional) should pick highest 16.x.y. -"$MCPP" toolchain default gcc 16 > "$TMP/def1.log" 2>&1 || { - cat "$TMP/def1.log"; echo "default 'gcc 16' failed"; exit 1; } -grep -q 'gcc@16.1.0' "$TMP/def1.log" || { - cat "$TMP/def1.log"; echo "default 'gcc 16' didn't resolve to 16.1.0"; exit 1; } - -# `default gcc@16` (@-form) should also resolve to 16.1.0. -"$MCPP" toolchain default gcc@16 > "$TMP/def2.log" 2>&1 || { - cat "$TMP/def2.log"; echo "default 'gcc@16' failed"; exit 1; } -grep -q 'gcc@16.1.0' "$TMP/def2.log" || { - cat "$TMP/def2.log"; echo "default 'gcc@16' didn't resolve to 16.1.0"; exit 1; } - -# ─── Section 2: first-run auto-install ────────────────────────────────── -# Brand-new MCPP_HOME with no config/default state, brand-new package with no -# [toolchain] declared — `mcpp build` should auto-install the canonical -# default (platform-native glibc gcc — musl-static is opt-in via --target) + -# use it. We still inherit payloads so CI does not download the same large -# archives into a throw-away home. -export MCPP_HOME="$TMP/h2" -inherit_payloads_only -configure_e2e_mirror -mkdir -p "$TMP/proj" -cd "$TMP/proj" -"$MCPP" new hello > /dev/null -cd hello - -# Sanity: the generated mcpp.toml does not declare a [toolchain] section. -if grep -q '^\[toolchain\]' mcpp.toml; then - echo "scaffolding regression: mcpp new now adds [toolchain]; update this test" - exit 1 -fi - -"$MCPP" build > "$TMP/firstrun.log" 2>&1 || { - cat "$TMP/firstrun.log"; echo "first-run build failed"; exit 1; } - -# Must show the friendly first-run banner AND the build must succeed. -grep -q 'First run' "$TMP/firstrun.log" || { - cat "$TMP/firstrun.log"; echo "missing First-run banner"; exit 1; } -grep -q 'gcc@16.1.0' "$TMP/firstrun.log" || { - cat "$TMP/firstrun.log"; echo "first run didn't pick glibc gcc@16.1.0 as default"; exit 1; } -grep -q 'Finished' "$TMP/firstrun.log" || { - cat "$TMP/firstrun.log"; echo "build did not finish"; exit 1; } - -# Built binary must exist and run. The default toolchain is now platform-native -# glibc gcc (musl-static is opt-in via --target), so it is dynamically linked. -binary=$(find target -name hello -type f | head -1) -[[ -n "$binary" && -x "$binary" ]] || { echo "no hello binary produced"; exit 1; } -file "$binary" | grep -q 'statically linked' && { - file "$binary" - echo "first-run default unexpectedly produced a static binary (glibc default expected)" - exit 1 -} -# Say WHY when it does not. "did not run" alone is unactionable: an ELF fails -# to start for exactly a few reasons -- a missing interpreter, an unresolvable -# RUNPATH entry, a symbol the runtime does not have -- and which one it is is -# the whole answer. This assertion sat here reporting none of them while CI -# failed on it and every local run passed. -if ! out=$("$binary" 2>&1); then - rc=$? - echo "first-run binary did not run (exit $rc): $out" - interp=$(file "$binary" | sed -n 's/.*interpreter \([^,]*\).*/\1/p') - echo " PT_INTERP: ${interp:-}" - [[ -n "$interp" ]] && { [[ -e "$interp" ]] && echo " exists" \ - || echo " MISSING"; } - rp=$(readelf -d "$binary" 2>/dev/null \ - | sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p') - echo " RUNPATH: ${rp:-}" - while IFS= read -r d; do - [[ -z "$d" ]] && continue - [[ -d "$d" ]] && echo " ok $d" || echo " MISSING $d" - done <<< "$(echo "$rp" | tr ':' '\n')" - ldd "$binary" 2>&1 | sed 's/^/ ldd: /' | head -12 - exit 1 -fi - -# Second build should be silent on toolchain — no re-install banner. -"$MCPP" build > "$TMP/secondrun.log" 2>&1 || { - cat "$TMP/secondrun.log"; echo "second build failed"; exit 1; } -if grep -q 'First run' "$TMP/secondrun.log"; then - cat "$TMP/secondrun.log" - echo "second build re-printed First-run banner — default not persisted" - exit 1 -fi - -echo "OK" diff --git a/tests/e2e/304_the_target_side_reaches_every_unit.sh b/tests/e2e/304_the_target_side_reaches_every_unit.sh deleted file mode 100755 index 9c1b65b26..000000000 --- a/tests/e2e/304_the_target_side_reaches_every_unit.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell jq -# A layer the GRAPH supplies is beneath every unit, including a sibling -# dependency package that has never heard of it. -# -# THE SET WAS ALREADY COMPUTED AND REACHED ONE TRANSLATION UNIT. -# -# A package that supplies a target-side layer publishes the headers the whole -# target is built against. Those travelled as an ordinary `publicUsage`, which -# propagates ALONG DEPENDENCY EDGES — so the root and the provider's own units -# received them and a SIBLING dependency package did not. `nlohmann.json` is -# not downstream of `openkal-llvm-runtime`; it is beside it. -# -# The result is two flavours of BMI in one build: `std` compiled over the -# target's libc++ (correct — prepare hands it exactly this set) and the -# dependency packages compiled over the payload's. Any unit importing both -# fails at the first template instantiation touching a declaration present in -# both header sets: -# -# istream:1245: error: reference to 'space' is ambiguous -# -# mcpp#514 §A. -# -# NO openkal, NO CROSS, NO NETWORK. A path package declaring one capability -# and one `include_dirs` entry reproduces it, which is the point: this is not a -# property of openkal but of how a target side was modelled. -# -# THE CRITERION IS THE CDB ROW, NOT A GREP OVER THE TREE. `compile_commands.json` -# says what each unit is actually compiled with; a grep over `build.ninja` would -# also match the global rule and pass for the wrong reason. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -mkdir -p src abi/src abi/abi-include abi/abi-internal dep/src dep/dep-include - -# The provider: supplies a target-side layer, publishes one directory, and -# keeps one to itself. -# -# THE TWO KEYS OF THIS RELEASE MEET IN THIS ONE PACKAGE, AND THAT IS THE -# REAL-WORLD SHAPE. `openkal-musl` declares `provides = ["mcpp:c-abi=musl"]` -# AND carries an internal header overlay. A layer's directories reach EVERY -# unit, so publishing the overlay by mistake would put musl's `hidden`/`weak` -# macros not merely on its consumers but on every translation unit of the -# build — a strictly wider blast radius than the leak `private_include_dirs` -# was written for. -# -# The two compose correctly by construction (the target side is assembled from -# the provider's `publicUsage`, which is the filtered set), and "by -# construction" is what an assertion is for. -cat > abi/mcpp.toml <<'EOF' -[package] -name = "abiprov" -version = "0.1.0" -provides = ["mcpp:c++-abi=libc++"] - -[build] -include_dirs = ["abi-internal", "abi-include"] -private_include_dirs = ["abi-internal"] -EOF -printf 'export module abiprov;\nexport int abiprov_v() { return 1; }\n' > abi/src/abiprov.cppm -printf '#pragma once\n' > abi/abi-include/marker.h -printf '#pragma once\n' > abi/abi-internal/private.h - -# The sibling: a dependency package that does NOT depend on the provider. -cat > dep/mcpp.toml <<'EOF' -[package] -name = "sibling" -version = "0.1.0" - -[build] -include_dirs = ["dep-include"] -EOF -printf 'export module sibling;\nexport int sibling_v() { return 2; }\n' > dep/src/sibling.cppm -printf '#pragma once\n' > dep/dep-include/own.h - -cat > mcpp.toml <<'EOF' -[package] -name = "reachprobe" -version = "0.1.0" - -[dependencies] -abiprov = { path = "abi" } -sibling = { path = "dep" } -EOF -# THE C HEADER, NOT THE C++ ONE. `abiprov` declares itself the C++ layer, and -# a package that answers for that layer answers for its headers: the payload's -# libc++ directories are withheld from every unit (#630, item 4). This stub -# ships no standard library, so the probe uses the C library's ``, -# which the payload or the SDK still supplies, rather than ``. -cat > src/main.cpp <<'EOF' -#include -import abiprov; -import sibling; -int main() { printf("%d\n", abiprov_v() + sibling_v()); } -EOF - -"$MCPP" build >/dev/null 2>&1 || { - echo "FAIL: the probe project did not build" - "$MCPP" build 2>&1 | tail -20 | sed 's/^/ /' - exit 1 -} - -[ -s compile_commands.json ] || { echo "FAIL: no compile_commands.json"; exit 1; } - -# A DENOMINATOR. A CDB with no sibling row would make every assertion below -# vacuously true, which is the false green this criterion has to rule out. -siblings="$(jq -r '[.[] | select(.file | test("/dep/src/"))] | length' compile_commands.json)" -if [ "$siblings" -lt 1 ]; then - echo "FAIL: the CDB has no sibling-package row — nothing was checked" - exit 1 -fi - -missing="$(jq -r ' - [ .[] - | select(.file | test("/dep/src/")) - | { f: .file, has: ((.arguments // (.command | split(" "))) | map(test("abi-include")) | any) } - | select(.has | not) - | .f - ] | .[]' compile_commands.json)" - -if [ -n "$missing" ]; then - echo "FAIL: the target side did not reach $siblings sibling unit(s):" - printf '%s\n' "$missing" | sed 's/^/ /' - exit 1 -fi - -# And the reverse direction: a package that supplies NO layer must not have its -# own directory pushed onto everyone. Otherwise this test would pass for a -# build that simply gives every include dir to every unit. -leaked="$(jq -r ' - [ .[] - | select(.file | test("/abi/src/")) - | { f: .file, has: ((.arguments // (.command | split(" "))) | map(test("dep-include")) | any) } - | select(.has) - | .f - ] | .[]' compile_commands.json)" - -if [ -n "$leaked" ]; then - echo "FAIL: a non-layer package's directory reached a sibling — the fix is too wide:" - printf '%s\n' "$leaked" | sed 's/^/ /' - exit 1 -fi - -# AND THE LAYER'S *PRIVATE* DIRECTORY REACHES NOBODY BUT ITSELF. -# -# A layer reaches every unit, so publishing an internal overlay by mistake is -# wider here than anywhere else: it would land on every translation unit of the -# build rather than on the provider's consumers. -private_leak="$(jq -r ' - [ .[] - | select((.file | test("/abi/src/")) | not) - | { f: .file, has: ((.arguments // (.command | split(" "))) | map(test("abi-internal")) | any) } - | select(.has) - | .f - ] | .[]' compile_commands.json)" - -if [ -n "$private_leak" ]; then - echo "FAIL: the layer's PRIVATE directory reached units outside the package:" - printf '%s\n' "$private_leak" | sed 's/^/ /' - exit 1 -fi - -# The control: private is not the same as dropped. The provider's own units -# must still be built with it, or the assertion above passes for a build that -# simply lost the directory. -own_private="$(jq -r ' - [ .[] - | select(.file | test("/abi/src/")) - | select((.arguments // (.command | split(" "))) | map(test("abi-internal")) | any) - ] | length' compile_commands.json)" -if [ "$own_private" -lt 1 ]; then - echo "FAIL: the provider lost its own private directory — the check above proves nothing" - exit 1 -fi - -echo "OK: the graph-supplied target side reaches every unit ($siblings sibling units), its private directory reaches only its own $own_private unit(s), and nothing else spreads" diff --git a/tests/e2e/305_built_from_is_not_published.sh b/tests/e2e/305_built_from_is_not_published.sh deleted file mode 100755 index f5c8db705..000000000 --- a/tests/e2e/305_built_from_is_not_published.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bash -# requires: unix-shell jq -# A package can say which of its include directories stop at its own boundary. -# -# `publicUsage` TOOK `privateBuild`'s DIRECTORIES ENTIRE, so a package was -# built from exactly the set it published. For almost every package those are -# the same set; for one that vendors a library with an internal header overlay -# they are not, and the difference reaches every consumer. -# -# `mcpplibs/openkal-musl` wrote the case down in its own source, having found -# it three times (port/include/features.h): -# -# ⓘ THIS IS THE SECOND-BEST REMEDY. The first would be for a package to -# distinguish the directories it is BUILT FROM from the directories it -# PUBLISHES. Measured 2026-08-22: mcpp cannot express it. -# -# musl reaches its own declarations through `src/include`, whose headers define -# `hidden`, `weak` and `weak_alias` — names meaningful only to musl's sources. -# Publishing that directory hands those macros to every consumer; which -# consumer breaks on which name was discovered one at a time. -# -# THE CRITERION IS THE DIRECTORY, NOT THE SYMPTOM. Asserting that `hidden` -# no longer collides would go green again the moment the package patched that -# one macro, while the leak stayed. This asserts that the directory is not on -# the consumer's command line — and, separately, that a consumer using the name -# as an ordinary identifier compiles, which is what the user actually reported. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -cd "$work" - -mkdir -p src lib/src lib/pub lib/internal lib/gen/one lib/gen/two - -cat > lib/mcpp.toml <<'EOF' -[package] -name = "vendored" -version = "0.1.0" - -[build] -# ORDER IS LOAD-BEARING and is why `private_include_dirs` is a SUBSET of -# this list rather than a second list: the internal overlay must precede the -# public headers for the package's OWN build, and two TOML arrays cannot -# express one order. -# -# `gen/*` IS A GLOB ON BOTH LINES. The filter is applied AFTER expansion, so -# the glob withholds exactly the directories it expands to. Comparing the -# unexpanded spellings would be the obvious implementation and would publish -# every one of them, because `gen/*` is not literally equal to `gen/one`. -include_dirs = ["internal", "gen/*", "pub"] -private_include_dirs = ["internal", "gen/*"] -EOF -printf 'export module vendored;\nexport int vendored_v() { return 7; }\n' > lib/src/vendored.cppm -printf '#pragma once\n#define VENDORED_PUBLIC 1\n' > lib/pub/pub.h -# The internal overlay: a macro that is meaningful to the package and a -# perfectly ordinary identifier to everyone else. -printf '#pragma once\n#define hidden __attribute__((visibility("hidden")))\n' > lib/internal/overlay.h -printf '#pragma once\n' > lib/gen/one/one.h -printf '#pragma once\n' > lib/gen/two/two.h - -cat > mcpp.toml <<'EOF' -[package] -name = "boundaryprobe" -version = "0.1.0" - -[dependencies] -vendored = { path = "lib" } -EOF -# `hidden` as a local variable — the exact shape openkal-musl#13 reported. -cat > src/main.cpp <<'EOF' -#include -#include -import vendored; -int main() { - int hidden = 1; - std::printf("%d %d\n", vendored_v() + hidden, VENDORED_PUBLIC); -} -EOF - -if ! "$MCPP" build >/dev/null 2>&1; then - echo "FAIL: the consumer did not build — the private overlay is still reaching it" - "$MCPP" build 2>&1 | tail -20 | sed 's/^/ /' - exit 1 -fi - -[ -s compile_commands.json ] || { echo "FAIL: no compile_commands.json"; exit 1; } - -# DENOMINATORS ON BOTH SIDES. With no provider row or no consumer row the -# assertions below are vacuously true. -prov="$(jq -r '[.[] | select(.file | test("/lib/src/"))] | length' compile_commands.json)" -cons="$(jq -r '[.[] | select(.file | test("/src/main"))] | length' compile_commands.json)" -if [ "$prov" -lt 1 ] || [ "$cons" -lt 1 ]; then - echo "FAIL: CDB has provider=$prov consumer=$cons rows — nothing was checked" - exit 1 -fi - -has_dir() { # file-pattern, dir-pattern → "true"/"false" - jq -r --arg f "$1" --arg d "$2" ' - [ .[] | select(.file | test($f)) - | ((.arguments // (.command | split(" "))) | map(test($d)) | any) ] - | any' compile_commands.json -} - -fail=0 -# The provider is built from BOTH — the private directory is private, not unused. -[ "$(has_dir '/lib/src/' '/lib/internal')" = true ] || { - echo "FAIL: the provider lost its own private directory"; fail=1; } -[ "$(has_dir '/lib/src/' '/lib/pub')" = true ] || { - echo "FAIL: the provider lost its own public directory"; fail=1; } -# The consumer gets the public one and NOT the private one. -[ "$(has_dir '/src/main' '/lib/pub')" = true ] || { - echo "FAIL: the public directory did not reach the consumer"; fail=1; } -[ "$(has_dir '/src/main' '/lib/internal')" = false ] || { - echo "FAIL: the private directory leaked to the consumer"; fail=1; } - -# AND THE GLOB WITHHOLDS WHAT IT EXPANDS TO — both directories, by name. -# Checking `gen` alone would pass for an implementation that matched the -# unexpanded spelling and published `gen/one` and `gen/two` anyway. -for g in one two; do - [ "$(has_dir '/lib/src/' "/lib/gen/$g")" = true ] || { - echo "FAIL: the provider lost 'gen/$g', which its own glob names"; fail=1; } - [ "$(has_dir '/src/main' "/lib/gen/$g")" = false ] || { - echo "FAIL: 'gen/$g' leaked to the consumer — the glob was compared unexpanded" - fail=1; } -done - -[ "$fail" = 0 ] || exit 1 -echo "OK: built-from and published are separable, globs included (provider=$prov consumer=$cons rows)" diff --git a/tests/e2e/306_dependency_linkage_axis.sh b/tests/e2e/306_dependency_linkage_axis.sh deleted file mode 100755 index 23f18aa8e..000000000 --- a/tests/e2e/306_dependency_linkage_axis.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -# 306_dependency_linkage_axis.sh — `[build] dependency_linkage` (issue #519). -# -# The consumer, not the package author, decides whether a dependency arrives -# merged into the image or as a separate shared library. Three things have to -# hold and each of them has bitten before: -# -# 1. the DEFAULT changes nothing — an existing project must keep its output -# directory and its build.ninja, or every user pays for a key they did -# not write; -# 2. asking for `shared` actually produces a shared object the program LOADS -# (not merely one that gets built and then ignored beside a statically -# merged copy); -# 3. the two configurations live in DIFFERENT output directories. They do -# not merely differ in flags: `-fPIC` is whole-build, so sharing a -# directory means sharing objects between two incompatible compilations. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p core/src app/src - -cat > core/mcpp.toml <<'EOF' -[package] -name = "core" -version = "0.1.0" -[build] -c_standard = "c11" -sources = ["src/*.c"] -[targets.core] -kind = "lib" -EOF -cat > core/src/core.c <<'EOF' -int core_answer(void) { return 42; } -EOF - -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -[build] -c_standard = "c11" -[targets.app] -kind = "bin" -main = "src/main.c" -[dependencies] -core = { path = "../core" } -EOF -cat > app/src/main.c <<'EOF' -#include -int core_answer(void); -int main(void) { printf("%d\n", core_answer()); return 0; } -EOF - -cd app - -# ── 1. the default is the old behaviour ──────────────────────────────────── -"$MCPP" build > static.log 2>&1 || { cat static.log; exit 1; } -# The FINGERPRINT directory, `target///`, not the triple one. -# `ls target/*/ -d` yields the triple, and comparing a fingerprint against it -# in step 3 would pass no matter what — a criterion aimed at the wrong object -# does not report an error, it quietly answers a different question. -static_fp="$(basename "$(ls -d target/*/*/ | head -1)")" -[[ "$static_fp" != "$(basename "$(ls -d target/*/ | head -1)")" ]] || { - echo "FAIL: could not locate the fingerprint directory"; ls -R target | head; exit 1; } -count_before="$(ls -d target/*/*/ | wc -l)" -[[ "$count_before" == 1 ]] || { echo "FAIL: expected exactly one build dir, got $count_before"; exit 1; } - -bin="$(find target -name app -type f | head -1)" -[[ -n "$bin" ]] || { cat static.log; echo "no executable"; exit 1; } -"$bin" | grep -qx 42 || { echo "static build does not run"; exit 1; } - -# No shared object at all: a `kind = "lib"` dependency stays merged. -if find target -name 'libcore.so' | grep -q .; then - echo "FAIL: the default produced a shared library"; exit 1 -fi - -# ── 2. asking for shared produces one, and the program loads it ──────────── -sed -i 's/^c_standard = "c11"$/c_standard = "c11"\ndependency_linkage = "shared"/' mcpp.toml -grep -q 'dependency_linkage = "shared"' mcpp.toml || { echo "sed did not apply"; exit 1; } - -"$MCPP" build > shared.log 2>&1 || { cat shared.log; exit 1; } -so="$(find target -name 'libcore.so' -type f | head -1)" -[[ -n "$so" ]] || { cat shared.log; echo "FAIL: no shared library was built"; exit 1; } -file "$so" | grep -q 'ELF.*shared object' || { echo "FAIL: not an ELF .so"; exit 1; } - -sbin="$(find target -name app -type f -newer "$so" | head -1)" -[[ -n "$sbin" ]] || sbin="$(dirname "$so")/app" -[[ -x "$sbin" ]] || { echo "FAIL: no executable beside the .so"; exit 1; } - -# The WHOLE point: the symbol must be imported, not merged. Asserted on the -# artifact rather than on a log line — a message is free to be reworded, and a -# "shared library exists" check passes just as well when the executable also -# contains a static copy of every symbol in it. -readelf --dyn-syms -W "$sbin" | grep -qE 'UND +core_answer' || { - echo "FAIL: core_answer is not an imported symbol in the executable" - readelf --dyn-syms -W "$sbin" | grep core_answer || true - exit 1 -} -readelf -d -W "$sbin" | grep -q 'NEEDED.*libcore.so' || { - echo "FAIL: the executable does not declare libcore.so as NEEDED"; exit 1; } - -"$sbin" | grep -qx 42 || { echo "FAIL: the shared build does not run"; exit 1; } - -# ── 3. the two configurations do not share an output directory ───────────── -count_after="$(ls -d target/*/*/ | wc -l)" -[[ "$count_after" == 2 ]] || { - echo "FAIL: expected two build directories (static + shared), got $count_after" - ls -d target/*/*/ - exit 1 -} -shared_fp="" -for d in target/*/*/; do - b="$(basename "$d")" - [[ "$b" != "$static_fp" ]] && shared_fp="$b" -done -[[ -n "$shared_fp" ]] || { - echo "FAIL: both configurations landed in one output directory ($static_fp)" - exit 1 -} - -# ── 4. the bundle a shared build produces must actually start ────────────── -# -# A `.so` that is built and then not shipped is the worst outcome of this -# axis: everything is green locally, the archive uploads, and it fails on the -# user's machine with `error while loading shared libraries`. The closure is -# resolved by RUNNING the artifact, and the library is found through `$ORIGIN` -# — which points at an empty directory if the copy in the staging tree is the -# one being traced. -"$MCPP" pack > pack.log 2>&1 || { cat pack.log; echo "FAIL: pack"; exit 1; } -archive="$(find target/dist -name '*.tar.gz' | head -1)" -[[ -n "$archive" ]] || { cat pack.log; echo "FAIL: no archive"; exit 1; } - -extract="$(mktemp -d)" -tar xzf "$archive" -C "$extract" -bundled="$(find "$extract" -name 'libcore.so' | head -1)" -[[ -n "$bundled" ]] || { - echo "FAIL: the shared dependency was not bundled" - find "$extract" -type f | head -20 - rm -rf "$extract"; exit 1 -} -packed="$(find "$extract" -name app -type f -perm -u+x | head -1)" -[[ -n "$packed" ]] || { echo "FAIL: no executable in the bundle"; rm -rf "$extract"; exit 1; } -out="$("$(cd "$(dirname "$packed")" && pwd)/$(basename "$packed")" 2>&1)" || { - echo "FAIL: the extracted bundle does not start: $out"; rm -rf "$extract"; exit 1; } -[[ "$out" == "42" ]] || { echo "FAIL: bundle printed '$out'"; rm -rf "$extract"; exit 1; } -rm -rf "$extract" - -echo "ok: default static ($static_fp), shared ($shared_fp), symbol imported, bundle runs" diff --git a/tests/e2e/307_symbol_provision.sh b/tests/e2e/307_symbol_provision.sh deleted file mode 100755 index 4b2ae90a6..000000000 --- a/tests/e2e/307_symbol_provision.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env bash -# requires: elf python3 -# 307_symbol_provision.sh — one library, one provider (issue #519). -# -# THE SILENT CASE IS THE IMPORTANT ONE, and it is why this test has two -# halves. -# -# A shared dependency over a static package used to put the static package in -# the consumer's executable, and the shared library bound back to it at run -# time; this test then asserted that the executable's export stayed silent. -# Since #646 F1 the static package is linked INTO the shared library, which is -# the only arrangement that works on every format, so the executable exports -# nothing for it and the check still measures the image (half 1). -# -# The finding is a SECOND provider of a name the executable holds. So: -# -# half 1 app -> wrap.so (shared) -> core (static) must stay SILENT, -# with core linked into wrap.so -# half 2 app -> core (static) and alt.so, which defines the same name and -# calls it must REPORT both -# -# Asserted against `resolution.json` rather than stdout: the message is free -# to improve, the recorded verdict is the contract. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p core/src wrap/src alt/src app/src - -cat > core/mcpp.toml <<'EOF' -[package] -name = "core" -version = "0.1.0" -[build] -c_standard = "c11" -sources = ["src/*.c"] -[targets.core] -kind = "lib" -EOF -echo 'int shared_answer(void) { return 7; }' > core/src/core.c - -# A shared library that CALLS the static package's symbol without defining it. -cat > wrap/mcpp.toml <<'EOF' -[package] -name = "wrap" -version = "0.1.0" -[build] -c_standard = "c11" -sources = ["src/*.c"] -[targets.wrap] -kind = "shared" -[dependencies] -core = { path = "../core" } -EOF -cat > wrap/src/wrap.c <<'EOF' -int shared_answer(void); -int wrap_call(void) { return shared_answer() + 1; } -EOF - -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -[build] -c_standard = "c11" -[targets.app] -kind = "bin" -main = "src/main.c" -[dependencies] -wrap = { path = "../wrap" } -EOF -cat > app/src/main.c <<'EOF' -#include -int wrap_call(void); -int main(void) { printf("%d\n", wrap_call()); return 0; } -EOF - -verdict() { # - "$PYTHON" - "$1" "$2" <<'PY' -import json, sys -doc = json.load(open(sys.argv[1])) -entries = doc.get("runtime", {}).get("symbol_provision") -if not entries: - print("MISSING"); raise SystemExit(0) -app = [e for e in entries if e["path"].endswith("app")] -if not app: - print("NO-APP-ENTRY"); raise SystemExit(0) -print(app[0].get(sys.argv[2], "MISSING")) -PY -} - -PYTHON="$(command -v python3 || command -v python || true)" -[[ -n "$PYTHON" ]] || { echo "skip: no python for JSON assertions"; exit 0; } - -cd app - -# ── half 1: the legitimate arrangement must stay silent ──────────────────── -"$MCPP" build > silent.log 2>&1 || { cat silent.log; exit 1; } -res="$(find target -name resolution.json -not -path '*/.build-mcpp/*' | head -1)" -[[ -n "$res" ]] || { echo "FAIL: no resolution.json"; exit 1; } - -status="$(verdict "$res" status)" -[[ "$status" == "clean" ]] || { - echo "FAIL: the legitimate shared→static arrangement reported '$status'" - cat "$res"; exit 1; } - -# It must have been MEASURED, not skipped: the denominator is real. "0 -# findings" and "never looked" must not read alike. -exported="$(verdict "$res" exported)" -total="$(verdict "$res" dynamic_symbols)" -[[ "$total" -gt "$exported" ]] || { - echo "FAIL: implausible denominator ($exported of $total)"; exit 1; } - -# core lives in wrap.so now, not in the executable (#646 F1): a host that never -# linked core loads it. -wrapso="$(ls "$(dirname "$res")"/bin/libwrap.so 2>/dev/null | head -1)" -[[ -n "$wrapso" ]] || { echo "FAIL: no libwrap.so"; exit 1; } -"$PYTHON" -c 'import ctypes, os, sys; assert ctypes.CDLL(sys.argv[1], mode=os.RTLD_NOW).wrap_call() == 8' "$wrapso" \ - || { echo "FAIL: a host that did not link core cannot load libwrap.so"; exit 1; } - -grep -qi "also provided by" silent.log && { - echo "FAIL: a correct build printed a conflict warning"; exit 1; } - -# ── half 2: a second provider turns it into a finding ────────────────────── -cat > ../alt/mcpp.toml <<'EOF' -[package] -name = "alt" -version = "0.1.0" -[build] -c_standard = "c11" -sources = ["src/*.c"] -[targets.alt] -kind = "shared" -EOF -cat > ../alt/src/alt.c <<'EOF' -int shared_answer(void) { return 999; } -int alt_ping(void) { return shared_answer(); } -EOF - -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -[build] -c_standard = "c11" -[targets.app] -kind = "bin" -main = "src/main.c" -[dependencies] -core = { path = "../core" } -alt = { path = "../alt" } -EOF -cat > src/main.c <<'EOF' -#include -int shared_answer(void); -int alt_ping(void); -int main(void) { printf("%d %d\n", shared_answer(), alt_ping()); return 0; } -EOF - -"$MCPP" build > conflict.log 2>&1 || { cat conflict.log; exit 1; } - -# `target//` ACCUMULATES one directory per fingerprint, and adding a -# dependency changed the fingerprint — so half 1's directory is still there -# with its own `app` and its own clean verdict. Taking "the first" of either -# would answer about the build that is not under test. Everything below is -# keyed off the directory that contains the conflicting verdict. -found=0 -conflict_dir="" -for r in $(find target -name resolution.json -not -path '*/.build-mcpp/*'); do - if [[ "$(verdict "$r" status)" == "conflict" ]]; then - conflict_dir="$(dirname "$r")" - "$PYTHON" - "$r" <<'PY' || exit 1 -import json, sys -doc = json.load(open(sys.argv[1])) -app = [e for e in doc["runtime"]["symbol_provision"] if e["path"].endswith("app")][0] -conflicts = app["conflicts"] -names = {c["symbol"] for c in conflicts} -assert "shared_answer" in names, f"expected shared_answer, got {names}" -providers = {p for c in conflicts for p in c["also_provided_by"]} -assert any("libalt.so" in p for p in providers), \ - f"expected libalt.so among providers, got {providers}" -assert all(c["kind"] == "func" for c in conflicts), conflicts -PY - found=1 - fi -done -[[ "$found" == 1 ]] || { - echo "FAIL: two providers of shared_answer were not reported" - cat conflict.log; exit 1; } - -# The defect is real, not theoretical: alt's 999 is unreachable because the -# executable's copy wins for every caller in the process, alt's own call -# included. -cbin="$conflict_dir/bin/app" -[[ -x "$cbin" ]] || { echo "FAIL: no executable in $conflict_dir"; exit 1; } -"$cbin" | grep -qx "7 7" || { - echo "FAIL: expected '7 7' (the executable's copy winning), got: $("$cbin")"; exit 1; } - -# ── --strict turns the finding into a failure, in ONE place ──────────────── -touch src/main.c -if "$MCPP" build --strict > strict.log 2>&1; then - echo "FAIL: --strict did not fail on a reported conflict"; cat strict.log; exit 1 -fi - -echo "ok: silent on the legitimate arrangement, reported on a real conflict" diff --git a/tests/e2e/308_dependency_required_features.sh b/tests/e2e/308_dependency_required_features.sh deleted file mode 100755 index 96e347544..000000000 --- a/tests/e2e/308_dependency_required_features.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -# 308_dependency_required_features.sh — a DEPENDENCY's target gate (issue #519). -# -# `[targets.] required_features` was filtered for the root package only. -# A dependency that wrote it got the OPPOSITE of what it asked for: the target -# was emitted for every consumer, active feature or not. -# -# That is not cosmetic when the gated target is `kind = "shared"`. A package -# with any shared target has ALL of its objects taken out of every consumer's -# link and put behind a shared library instead — so an "optional" target -# silently changed how the package was linked into everyone. -# -# The criterion is the ARTIFACT, not a log line: with the feature off there -# must be no shared object; with it on there must be one. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p dep/src app/src - -cat > dep/mcpp.toml <<'EOF' -[package] -name = "dep" -version = "0.1.0" -[build] -c_standard = "c11" -sources = ["src/*.c"] -[features] -plugin = [] -[targets.dep] -kind = "lib" -[targets.depshared] -kind = "shared" -required_features = ["plugin"] -EOF -cat > dep/src/dep.c <<'EOF' -int dep_value(void) { return 5; } -EOF - -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -[build] -c_standard = "c11" -[targets.app] -kind = "bin" -main = "src/main.c" -[dependencies] -dep = { path = "../dep" } -EOF -cat > app/src/main.c <<'EOF' -#include -int dep_value(void); -int main(void) { printf("%d\n", dep_value()); return 0; } -EOF - -cd app - -# ── the gate is CLOSED: no shared object, and the program still works ─────── -"$MCPP" build > off.log 2>&1 || { cat off.log; exit 1; } -if find target -name 'libdepshared.so' | grep -q .; then - echo "FAIL: a feature-gated dependency target was built with the feature off" - find target -name 'libdepshared.so' - exit 1 -fi -bin="$(find target -name app -type f | head -1)" -[[ -n "$bin" ]] || { cat off.log; echo "FAIL: no executable"; exit 1; } -"$bin" | grep -qx 5 || { echo "FAIL: the gated-off build does not run"; exit 1; } - -# ── the gate is OPEN: the target appears ─────────────────────────────────── -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -[build] -c_standard = "c11" -[targets.app] -kind = "bin" -main = "src/main.c" -[dependencies] -dep = { path = "../dep", features = ["plugin"] } -EOF - -"$MCPP" build > on.log 2>&1 || { cat on.log; exit 1; } -so="$(find target -name 'libdepshared.so' -type f | head -1)" -[[ -n "$so" ]] || { - echo "FAIL: requesting the feature did not build the gated target" - cat on.log; exit 1; } - -echo "ok: a dependency's required_features gate opens and closes" diff --git a/tests/e2e/309_host_module_identity.sh b/tests/e2e/309_host_module_identity.sh deleted file mode 100755 index 44ca9246f..000000000 --- a/tests/e2e/309_host_module_identity.sh +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 309_host_module_identity.sh — a build rule's MODULE NAME is what its source -# declares, not what its package is called. -# -# The codebase already states the general rule for ordinary packages ("module -# names are authored API and are not required to mirror package identity"), and -# the host-module path was the one place that derived the name from -# `package.name` instead of reading it. -# -# That was not merely inconsistent. `build_host_module` binds the name -# differently per compiler family: MSVC is handed `/reference =` and -# Clang `-fmodule-file==`, while GCC names nothing at all because its -# BMIs are implicit under gcm.cache, keyed by the name the SOURCE declares. So a -# rule whose lib root declared anything other than its package name built under -# GCC and failed under the other two, reporting a module nobody had written. -# -# Pinned here: -# 1. a divergent declared name is what the consumer imports; -# 2. a dotted name survives whole (`a.b.c`, not `a`); -# 3. two rules declaring ONE module name are refused, naming both packages — -# before this, the second overwrote the first's object file in silence; -# 4. an `mcpp.` module name from an outside namespace WARNS and still builds. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── 1. the declared name diverges from the package name ───────────────────── -mkdir -p rules/src -cat > rules/mcpp.toml <<'EOF' -[package] -name = "protobufgen" -version = "0.1.0" - -[targets.protobufgen] -kind = "lib" -EOF -# Package `protobufgen`, module `acme.rules.protobuf`. Under the old rule mcpp -# would have registered `protobufgen`, and the consumer's `import` would have -# failed to find it on Clang and MSVC. -cat > rules/src/protobufgen.cppm <<'EOF' -export module acme.rules.protobuf; -import std; -import mcpp; -export namespace acme::rules::protobuf { -inline void apply() { mcpp::cxxflag("-DDIVERGENT_NAME_REACHED=1"); } -} -EOF - -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -protobufgen = { path = "../rules", host-module = true } -EOF -cat > app/src/main.cpp <<'EOF' -#include -#ifndef DIVERGENT_NAME_REACHED -#error "the rule's directive never reached the compile" -#endif -int main() { std::printf("DIVERGENT=%d\n", DIVERGENT_NAME_REACHED); } -EOF -cat > app/build.mcpp <<'EOF' -import mcpp; -import acme.rules.protobuf; // the DECLARED name, not the package name -int main() { acme::rules::protobuf::apply(); } -EOF - -cd app -"$MCPP" build > b1.log 2>&1 || { - cat b1.log - echo "FAIL: a rule whose declared module name differs from its package name did not build" - exit 1; } -out="$("$MCPP" run 2>&1 | grep '^DIVERGENT=' | tail -1)" -[[ "$out" == "DIVERGENT=1" ]] || { - echo "FAIL: the divergently-named rule's directive did not land: $out"; exit 1; } - -# THE IMPORT ABOVE DOES NOT DISCRIMINATE ON GCC: GCC's BMIs are implicit under -# gcm.cache and keyed by the name the SOURCE declares, so a build that -# registered the PACKAGE name succeeded there and failed under Clang and MSVC, -# which are handed an explicit `=` mapping. -# -# The build programs' graph (mcpp#797) compiles the library as a package of -# its plan, and the plan names a BMI for the module the source declares. The -# BMI is the platform-independent evidence of which name the engine used. -BM=target/.build-mcpp/host -[ -n "$(find "$BM" -name 'acme.rules.protobuf.*' 2>/dev/null)" ] || { - echo "FAIL: the host module was not named for its DECLARED module name" - find "$BM" 2>/dev/null | head -40 - exit 1; } -if [ -n "$(find "$BM" \( -name 'protobufgen.gcm' -o -name 'protobufgen.pcm' -o -name 'protobufgen.ifc' \) 2>/dev/null)" ]; then - find "$BM" | head -40 - echo "FAIL: a BMI is named for the PACKAGE name" - exit 1 -fi - -# ── 2. two rules, one module name ─────────────────────────────────────────── -# Two DIFFERENT packages declaring ONE module name. This is the risk that -# decoupling the module name from the package name introduces, which is why I1 -# and this check ship together rather than in sequence. -# -# Measured with the check removed and the rest of this change in place: both -# compiles write `sharedname.o`, the second overwrites the first, and the one -# surviving object is handed to the link twice — once per registered module. -# The build fails, but it fails as -# -# ld: …/sharedname.o: multiple definition of `initializer for module -# sharedname'; …/sharedname.o: beta.cppm: first defined here -# -# naming one file twice, one package twice, and never saying that two packages -# are involved or which two. The overwrite is the silent part; the diagnostic -# is merely unactionable. This check is what turns it into a sentence. -cd "$TMP" -for n in alpha beta; do - mkdir -p "dup-$n/src" - cat > "dup-$n/mcpp.toml" < "dup-$n/src/$n.cppm" < dupapp/mcpp.toml <<'EOF' -[package] -name = "dupapp" -version = "0.1.0" - -[dependencies] -alpha = { path = "../dup-alpha", host-module = true } -beta = { path = "../dup-beta", host-module = true } -EOF -echo 'int main() { return 0; }' > dupapp/src/main.cpp -cat > dupapp/build.mcpp <<'EOF' -import mcpp; -import sharedname; -int main() { shared::go(); } -EOF -cd dupapp -if "$MCPP" build > b2.log 2>&1; then - cat b2.log - echo "FAIL: two rules declaring the same module name were accepted" - exit 1 -fi -# The two package identities and the two interface paths ARE the content of -# this diagnostic: with one module name shared they are the only way to tell -# the packages apart. Asserted on the whole output, not on a keyword, so a -# reworded message that dropped them would fail here. -for want in "sharedname" "vendoralpha.alpha" "vendorbeta.beta" \ - "dup-alpha/src/alpha.cppm" "dup-beta/src/beta.cppm"; do - grep -qF "$want" b2.log || { - cat b2.log - echo "FAIL: the collision diagnostic does not name '$want'"; exit 1; } -done - -# ── 3. the reserved `mcpp.` prefix warns, and the build still succeeds ─────── -# Both halves. A test that only checked for the warning could not tell a -# warning from an error. -cd "$TMP" -mkdir -p claimer/src -cat > claimer/mcpp.toml <<'EOF' -[package] -name = "claimer" -namespace = "acme" -version = "0.1.0" - -[targets.claimer] -kind = "lib" -EOF -cat > claimer/src/claimer.cppm <<'EOF' -export module mcpp.build.claimed; -import std; -import mcpp; -export namespace claimed { inline void go() { mcpp::cxxflag("-DCLAIMED=1"); } } -EOF -mkdir -p claimapp/src -cat > claimapp/mcpp.toml <<'EOF' -[package] -name = "claimapp" -version = "0.1.0" - -[dependencies] -claimer = { path = "../claimer", host-module = true } -EOF -cat > claimapp/src/main.cpp <<'EOF' -#include -#ifndef CLAIMED -#error "the rule never ran" -#endif -int main() { std::printf("CLAIMED=%d\n", CLAIMED); } -EOF -cat > claimapp/build.mcpp <<'EOF' -import mcpp; -import mcpp.build.claimed; -int main() { claimed::go(); } -EOF -cd claimapp -"$MCPP" build > b3.log 2>&1 || { - cat b3.log - echo "FAIL: the reserved prefix must WARN, not fail the build"; exit 1; } -grep -qF "mcpp.build.claimed" b3.log || { - cat b3.log; echo "FAIL: no warning naming the claimed module"; exit 1; } -grep -qF "acme.claimer" b3.log || { - cat b3.log; echo "FAIL: the warning does not name the package making the claim"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^CLAIMED=' | tail -1)" -[[ "$out" == "CLAIMED=1" ]] || { - echo "FAIL: the warned-about rule did not actually work: $out"; exit 1; } - -# A package IN the mcpp namespace says the same thing without a warning. -cd "$TMP" -sed -i 's/^namespace = "acme"/namespace = "mcpp"/' claimer/mcpp.toml -cd claimapp && rm -rf target -"$MCPP" build > b4.log 2>&1 || { - cat b4.log; echo "FAIL: the official namespace build failed"; exit 1; } -if grep -qF "reserved for rules maintained by the mcpp project" b4.log; then - cat b4.log - echo "FAIL: a package in the mcpp namespace was warned about its own prefix" - exit 1 -fi - -echo "OK" diff --git a/tests/e2e/30_pack_modes.sh b/tests/e2e/30_pack_modes.sh deleted file mode 100755 index 6ea7e2d23..000000000 --- a/tests/e2e/30_pack_modes.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env bash -# requires: pack patchelf elf -# 30_pack_modes.sh — `mcpp pack` smoke tests for all three modes. -# -# Verifies the contract of each mode by extracting the produced tarball -# and inspecting the resulting binary + (for non-static) bundled libs. -# -# Mode A needs a musl-gcc payload and is left out where the home has none; the -# payload is whichever release the home has (_toolchain_env.sh). Its probe named -# 15.1.0 until 2026-11-12, the runners installed a later one, and Mode A ran on -# no runner. The default Mode C path runs unconditionally. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -export MCPP_HOME=$HOME/.mcpp -source "$(dirname "$0")/_toolchain_env.sh" - -cd "$TMP" -"$MCPP" new myapp > /dev/null -cd myapp -# Keep Mode C independent from whatever global default toolchain earlier -# e2e tests wrote into ~/.mcpp/config.toml. -cat >> mcpp.toml <<'EOF' - -[toolchain] -linux = "gcc@16.1.0" -EOF - -# ─── Mode C (default = bundle-project) ───────────────────────────────── -"$MCPP" pack > "$TMP/pack-c.log" 2>&1 || { - cat "$TMP/pack-c.log"; echo "Mode C pack failed"; exit 1; } - -tarball="target/dist/myapp-0.1.0-x86_64-linux-gnu.tar.gz" -[[ -f "$tarball" ]] || { echo "Mode C tarball not produced at $tarball"; exit 1; } - -# Extract + verify binary + RUNPATH/INTERP rewrite + runs from new location. -mkdir -p "$TMP/c" -tar -xzf "$tarball" -C "$TMP/c" -[[ -x "$TMP/c/myapp-0.1.0-x86_64-linux-gnu/bin/myapp" ]] || { echo "missing bin/myapp"; exit 1; } - -# Mode C: PT_INTERP must be repointed to the LSB-standard path. Use -# `file` rather than `readelf` because some sandboxes route readelf -# through an xvm shim that needs a per-tool version pin we can't rely -# on in CI. -file_out=$(file "$TMP/c/myapp-0.1.0-x86_64-linux-gnu/bin/myapp") -echo "$file_out" | grep -q 'interpreter /lib64/ld-linux-x86-64.so.2' || { - echo "Mode C: PT_INTERP not repointed to /lib64/ld-linux-x86-64.so.2" - echo "$file_out" - exit 1 -} - -# Mode C with no third-party deps: lib/ should be empty (or absent). -if [[ -d "$TMP/c/myapp-0.1.0-x86_64-linux-gnu/lib" ]]; then - extra=$(ls "$TMP/c/myapp-0.1.0-x86_64-linux-gnu/lib" 2>/dev/null | wc -l) - [[ "$extra" -eq 0 ]] || { - echo "Mode C: project has no third-party deps but lib/ has $extra entries:" - ls "$TMP/c/myapp-0.1.0-x86_64-linux-gnu/lib"; exit 1; } -fi - -# Run from new location. Should work because PT_INTERP is system-standard. -"$TMP/c/myapp-0.1.0-x86_64-linux-gnu/bin/myapp" > "$TMP/c-out.log" 2>&1 || { - cat "$TMP/c-out.log"; echo "Mode C extracted binary failed to run"; exit 1; } -grep -q 'Hello' "$TMP/c-out.log" || { - cat "$TMP/c-out.log"; echo "Mode C runtime output missing"; exit 1; } - -# Mode C should also drop a top-level entry-point script so users -# can do `./myapp` from the bundle root without typing bin/. -[[ -x "$TMP/c/myapp-0.1.0-x86_64-linux-gnu/myapp" ]] || { - ls "$TMP/c/myapp-0.1.0-x86_64-linux-gnu"; echo "Mode C: missing top-level entry"; exit 1; } -"$TMP/c/myapp-0.1.0-x86_64-linux-gnu/myapp" > "$TMP/c-name.log" 2>&1 || { - cat "$TMP/c-name.log"; echo "Mode C: top-level entry failed to run"; exit 1; } -grep -q 'Hello' "$TMP/c-name.log" || { - cat "$TMP/c-name.log"; echo "Mode C: top-level entry output missing"; exit 1; } - -# ─── Mode A (static, musl) ───────────────────────────────────────────── -musl_gcc="$MUSL_GCC_ROOT/bin/x86_64-linux-musl-g++" -if [[ -x "$musl_gcc" ]]; then - "$MCPP" pack --mode static > "$TMP/pack-a.log" 2>&1 || { - cat "$TMP/pack-a.log"; echo "Mode A pack failed"; exit 1; } - - tarball_a="target/dist/myapp-0.1.0-x86_64-linux-musl-static.tar.gz" - [[ -f "$tarball_a" ]] || { echo "Mode A tarball missing at $tarball_a"; exit 1; } - - mkdir -p "$TMP/a" - tar -xzf "$tarball_a" -C "$TMP/a" - [[ -x "$TMP/a/myapp-0.1.0-x86_64-linux-musl-static/bin/myapp" ]] \ - || { echo "Mode A: missing bin/myapp"; exit 1; } - file "$TMP/a/myapp-0.1.0-x86_64-linux-musl-static/bin/myapp" | grep -q 'statically linked' || { - file "$TMP/a/myapp-0.1.0-x86_64-linux-musl-static/bin/myapp" - echo "Mode A: not statically linked" - exit 1 - } - "$TMP/a/myapp-0.1.0-x86_64-linux-musl-static/bin/myapp" > "$TMP/a-out.log" 2>&1 || { - cat "$TMP/a-out.log"; echo "Mode A extracted binary failed to run"; exit 1; } - grep -q 'Hello' "$TMP/a-out.log" || { echo "Mode A runtime output missing"; exit 1; } - - # Mode A also gets a top-level entry-point wrapper. - [[ -x "$TMP/a/myapp-0.1.0-x86_64-linux-musl-static/myapp" ]] || { - echo "Mode A: missing top-level entry"; exit 1; } - "$TMP/a/myapp-0.1.0-x86_64-linux-musl-static/myapp" > "$TMP/a-name.log" 2>&1 || { - echo "Mode A: top-level entry failed to run"; exit 1; } - grep -q 'Hello' "$TMP/a-name.log" || { - echo "Mode A: top-level entry output missing"; exit 1; } -else - echo "(skip Mode A: musl-gcc not installed)" -fi - -# ─── Mode B (bundle-all + wrapper) ───────────────────────────────────── -"$MCPP" pack --mode bundle-all > "$TMP/pack-b.log" 2>&1 || { - cat "$TMP/pack-b.log"; echo "Mode B pack failed"; exit 1; } - -tarball_b="target/dist/myapp-0.1.0-x86_64-linux-gnu-bundle-all.tar.gz" -[[ -f "$tarball_b" ]] || { echo "Mode B tarball missing at $tarball_b"; exit 1; } - -mkdir -p "$TMP/b" -tar -xzf "$tarball_b" -C "$TMP/b" -[[ -x "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/bin/myapp" ]] || { echo "Mode B: missing bin/myapp"; exit 1; } -[[ -x "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" ]] || { echo "Mode B: missing run.sh wrapper"; exit 1; } -[[ -d "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/lib" ]] || { echo "Mode B: missing lib/"; exit 1; } - -# Mode B must include the dynamic linker + libc — those are the libs the -# wrapper relies on to detach from the host's glibc. -ls "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/lib" | grep -q 'ld-linux' \ - || { echo "Mode B: ld-linux not bundled"; exit 1; } -ls "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/lib" | grep -q '^libc\.so' \ - || { echo "Mode B: libc.so not bundled"; exit 1; } - -# Wrapper script must run via both run.sh AND the program-name entry. -[[ -x "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/myapp" ]] || { - ls "$TMP/b" - echo "Mode B: missing top-level entry alongside run.sh" - exit 1 -} -"$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" > "$TMP/b-out.log" 2>&1 || { - cat "$TMP/b-out.log"; echo "Mode B run.sh failed"; exit 1; } -grep -q 'Hello' "$TMP/b-out.log" || { - cat "$TMP/b-out.log"; echo "Mode B runtime output missing"; exit 1; } -"$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/myapp" > "$TMP/b-name.log" 2>&1 || { - cat "$TMP/b-name.log"; echo "Mode B entry failed"; exit 1; } -grep -q 'Hello' "$TMP/b-name.log" || { - cat "$TMP/b-name.log"; echo "Mode B -entry output missing"; exit 1; } - -# Launching through the loader makes the kernel set /proc/self/exe to the -# LOADER, so every "find my resources next to the executable" path silently -# resolves against lib/ instead. The wrapper must therefore hand the program -# an answer that survives — and it must hand the same one from both entry -# points, since they are documented as interchangeable. -for entry in run.sh myapp; do - grep -q 'MCPP_BUNDLE_DIR' \ - "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/$entry" || { - echo "Mode B: $entry does not export MCPP_BUNDLE_DIR — an application" - echo " cannot locate its own resources under the bundled loader" - exit 1; } -done - -# The value must be the bundle ROOT (`$here`), not the loader's directory and -# not a path baked in at pack time — the bundle is relocatable, so anything -# absolute would be wrong the moment it is extracted somewhere else. -grep -q 'MCPP_BUNDLE_DIR="\$here"' \ - "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" || { - echo "Mode B: MCPP_BUNDLE_DIR is not derived from the bundle root (\$here)" - grep MCPP_BUNDLE_DIR "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" - exit 1; } - -# ─── Mode `system` (Mode::None — depend on OS for all .so) ───────────── -"$MCPP" pack --mode system > "$TMP/pack-sys.log" 2>&1 || { - cat "$TMP/pack-sys.log"; echo "system pack failed"; exit 1; } - -tarball_sys="target/dist/myapp-0.1.0-x86_64-linux-gnu-system.tar.gz" -[[ -f "$tarball_sys" ]] || { echo "system tarball missing at $tarball_sys"; exit 1; } - -mkdir -p "$TMP/sys" -tar -xzf "$tarball_sys" -C "$TMP/sys" -root_sys="$TMP/sys/myapp-0.1.0-x86_64-linux-gnu-system" -[[ -x "$root_sys/bin/myapp" ]] || { echo "system: missing bin/myapp"; exit 1; } - -# system mode bundles NOTHING — lib/ must be empty or absent even though the -# binary still links libc/libstdc++ (those come from the host). -if [[ -d "$root_sys/lib" ]]; then - n=$(ls "$root_sys/lib" 2>/dev/null | wc -l) - [[ "$n" -eq 0 ]] || { echo "system: lib/ should be empty, has $n"; ls "$root_sys/lib"; exit 1; } -fi - -# PT_INTERP repointed to the LSB-standard loader (host glibc). -file "$root_sys/bin/myapp" | grep -q 'interpreter /lib64/ld-linux-x86-64.so.2' || { - file "$root_sys/bin/myapp"; echo "system: PT_INTERP not repointed to LSB loader"; exit 1; } - -# Runs on this (glibc) host. -"$root_sys/bin/myapp" > "$TMP/sys-out.log" 2>&1 || { - cat "$TMP/sys-out.log"; echo "system: extracted binary failed to run"; exit 1; } -grep -q 'Hello' "$TMP/sys-out.log" || { echo "system: runtime output missing"; exit 1; } - -# Old alias must still parse identically (back-compat). -"$MCPP" pack --mode bundle-project > "$TMP/pack-alias.log" 2>&1 || { - cat "$TMP/pack-alias.log"; echo "alias bundle-project failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/310_build_dependencies.sh b/tests/e2e/310_build_dependencies.sh deleted file mode 100755 index 0a264dcc5..000000000 --- a/tests/e2e/310_build_dependencies.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 310_build_dependencies.sh — `[build-dependencies]`: what serves the BUILD and -# what reaches the TARGET are two different questions. -# -# The section has been parsed, merged across workspace members and -# conditionalised by target predicate for a long time, and until now nothing -# read it to make a decision: writing it produced a manifest that loaded, no -# diagnostic, and no effect. `types.cppm` even annotated the field -# "host-side tools (M5+ behavior)". -# -# The consequence was not merely a dormant feature. Both live build-time -# channels (`tools`, `host-module`) are written by the CONSUMER on an edge, so a -# build rule had no way to request anything on its own behalf — and what it -# wrote in `[dependencies]` LEAKED, because the rule-only predicate looked at -# edges into the rule and not at what lay behind it. -# -# Pinned here: -# 1. a rule's own `[dependencies]` do not reach the consumer's binary; -# 2. `[build-dependencies]` and everything behind them do not either; -# 3. a package the project depends on DIRECTLY still reaches the target even -# when a build-time path also reaches it — the axes are orthogonal. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# A plain library. Whether its object ends up in a consumer's binary is the -# only thing this test looks at, so it carries one unmistakable symbol. -mk_lib() { - local name="$1" sym="$2" - mkdir -p "$name/src" - cat > "$name/mcpp.toml" < "$name/src/$name.cppm" < rules/mcpp.toml <<'EOF' -[package] -name = "ruleswithdeps" -version = "0.1.0" - -[targets.ruleswithdeps] -kind = "lib" - -[dependencies] -behindrule = { path = "../behindrule" } -EOF -cat > rules/src/ruleswithdeps.cppm <<'EOF' -export module ruleswithdeps; -import std; -import mcpp; -export namespace rules { inline void go() { mcpp::cxxflag("-DRULE_RAN=1"); } } -EOF - -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -ruleswithdeps = { path = "../rules", host-module = true } -bothsides = { path = "../bothsides" } - -[build-dependencies] -buildside = { path = "../buildside" } -EOF -cat > app/src/main.cpp <<'EOF' -#include -#ifndef RULE_RAN -#error "the rule never ran" -#endif -import bothsides; -int main() { std::printf("APP=%d\n", bothsides_symbol()); } -EOF -cat > app/build.mcpp <<'EOF' -import mcpp; -import ruleswithdeps; -int main() { rules::go(); } -EOF - -cd app -"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^APP=' | tail -1)" -[[ "$out" == "APP=1" ]] || { echo "FAIL: the app did not run: $out"; exit 1; } - -# The criterion is the ARTIFACT, not a log line. `nm` comes from the toolchain -# that produced the binary rather than from PATH: a host `nm` may not read what -# a private toolchain emits, and `command -v nm` has answered for the wrong -# toolchain in this repository before. -BIN="$("$MCPP" run --print-artifact 2>/dev/null || true)" -if [[ ! -x "${BIN:-}" ]]; then - mapfile -t bins < <(find target -type f -name app -perm -u+x) - (( ${#bins[@]} == 1 )) || { - echo "FAIL: expected exactly one app binary, found ${#bins[@]}" - printf ' %s\n' "${bins[@]}"; exit 1; } - BIN="${bins[0]}" -fi - -has_symbol() { grep -qF "$1" <(strings -a "$BIN" 2>/dev/null; nm -C "$BIN" 2>/dev/null || true); } - -# 3. the orthogonal case FIRST, so a wholesale failure to link anything cannot -# pass this test by accident. This symbol MUST be present. -has_symbol bothsides_symbol || { - echo "FAIL: a direct [dependencies] library did not reach the target"; exit 1; } - -# 1. a rule's own dependency must not be in the consumer's binary -if has_symbol behindrule_symbol; then - echo "FAIL: a build rule's own [dependencies] leaked into the consumer's binary" - exit 1 -fi - -# 2. nor a [build-dependencies] entry -if has_symbol buildside_symbol; then - echo "FAIL: a [build-dependencies] entry reached the target" - exit 1 -fi - -# ── the dual-role case, stated the other way round ────────────────────────── -# The same package named in BOTH tables by one consumer. The ordinary -# declaration wins: a `[build-dependencies]` line must not quietly drop a -# library the target needs. -cd "$TMP" -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -ruleswithdeps = { path = "../rules", host-module = true } -bothsides = { path = "../bothsides" } - -[build-dependencies] -bothsides = { path = "../bothsides" } -EOF -cd app && rm -rf target -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: dual-table build failed"; exit 1; } -mapfile -t bins < <(find target -type f -name app -perm -u+x) -(( ${#bins[@]} == 1 )) || { - echo "FAIL: expected exactly one app binary, found ${#bins[@]}"; exit 1; } -BIN="${bins[0]}" -has_symbol bothsides_symbol || { - echo "FAIL: naming a library in [build-dependencies] too removed it from the target" - exit 1; } - -echo "OK" diff --git a/tests/e2e/311_rule_build_dependencies.sh b/tests/e2e/311_rule_build_dependencies.sh deleted file mode 100755 index 03fb4b775..000000000 --- a/tests/e2e/311_rule_build_dependencies.sh +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 311_rule_build_dependencies.sh — a build rule may depend on another build -# rule, declared in its OWN `[build-dependencies]`. -# -# Before this, a rule was a leaf. Not by design: both live build-time channels -# (`tools`, `host-module`) are written by the CONSUMER on an edge, so a rule had -# no way to request anything on its own behalf. `[build-dependencies]` is the -# only declaration site a package owns, and it was read by nothing. -# -# The mechanism is ORDERING, not new machinery. build_program.cppm accumulates -# the module flags as it compiles the host modules in list order, so each entry -# already sees the BMIs of everything ahead of it; the caller topologically -# sorts the list and a rule can import a rule. -# -# Pinned here: -# 1. a rule imports its own `[build-dependencies]` host module and works; -# 2. the consumer may NOT import it — provisions cross one further edge only -# on a `reexport = true` edge, and mcpp enforces that on every platform -# because GCC cannot (its BMIs are reachable by name whatever the flags); -# 3. `reexport = true` makes it importable by the consumer; -# 4. a cycle is reported AS a cycle, naming the packages on it. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# ── the inner rule ────────────────────────────────────────────────────────── -mkdir -p globbing/src -cat > globbing/mcpp.toml <<'EOF' -[package] -name = "globbing" -version = "0.1.0" - -[targets.globbing] -kind = "lib" -EOF -cat > globbing/src/globbing.cppm <<'EOF' -export module globbing; -import std; -export namespace globbing { -inline int answer() { return 7; } -} -EOF - -# ── the outer rule, which imports it ──────────────────────────────────────── -mk_outer() { # $1 = extra keys on the globbing edge - mkdir -p tidyrule/src - cat > tidyrule/mcpp.toml < tidyrule/src/tidyrule.cppm <<'EOF' -export module tidyrule; -import std; -import mcpp; -import globbing; // a rule importing a rule: impossible before this change -export namespace tidyrule { -inline void go() { - mcpp::cxxflag(std::format("-DTIDY_ANSWER={}", globbing::answer()).c_str()); -} -} -EOF -} -mk_outer "" - -mkdir -p app/src -cat > app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -tidyrule = { path = "../tidyrule", host-module = true } -EOF -cat > app/src/main.cpp <<'EOF' -#include -#ifndef TIDY_ANSWER -#error "the outer rule never ran" -#endif -int main() { std::printf("ANSWER=%d\n", TIDY_ANSWER); } -EOF -cat > app/build.mcpp <<'EOF' -import mcpp; -import tidyrule; -int main() { tidyrule::go(); } -EOF - -cd app -"$MCPP" build > b1.log 2>&1 || { - cat b1.log; echo "FAIL: a rule with its own [build-dependencies] did not build"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -# The value travels through the inner rule, so it is evidence that the inner -# module was actually compiled and linked rather than merely named. -[[ "$out" == "ANSWER=7" ]] || { - echo "FAIL: the inner rule's value did not reach the compile: $out"; exit 1; } - -# The inner rule must be BUILD-time only: it has no business in the binary. -mapfile -t bins < <(find target -type f -name app -perm -u+x) -(( ${#bins[@]} == 1 )) || { echo "FAIL: expected one app binary, got ${#bins[@]}"; exit 1; } - -# ── 2. the consumer may not import what it never declared ─────────────────── -cd "$TMP" -cat > app/build.mcpp <<'EOF' -import mcpp; -import tidyrule; -import globbing; // never declared by this package -int main() { tidyrule::go(); } -EOF -cd app && rm -rf target -if "$MCPP" build > b2.log 2>&1; then - cat b2.log - echo "FAIL: build.mcpp imported a module reachable only as another rule's prerequisite" - exit 1 -fi -grep -qF "globbing" b2.log || { - cat b2.log; echo "FAIL: the diagnostic does not name the module"; exit 1; } -grep -qF "reexport" b2.log || { - cat b2.log; echo "FAIL: the diagnostic does not say how to fix it"; exit 1; } - -# ── 3. reexport = true makes it the consumer's to import ─────────────────── -cd "$TMP" -mk_outer ", reexport = true" -cd app && rm -rf target -"$MCPP" build > b3.log 2>&1 || { - cat b3.log; echo "FAIL: a re-exported host module was still refused"; exit 1; } -out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" -[[ "$out" == "ANSWER=7" ]] || { - echo "FAIL: the re-exported path broke the value: $out"; exit 1; } - -# ── 3b. withdrawing reexport is caught on a warm cache ───────────────────── -# Ordered right after the successful build above, so build.mcpp's cache and the -# whole-project fast path are both warm. Dropping `reexport` leaves the module -# SET and every interface hash identical, so nothing in the cache identity -# moves; the refusal is reached only because it is asked BEFORE the cache is -# consulted. -# -# This assertion does not prove that placement on its own. Measured: with -# the check below the fast path it still fired here, because `ctxHash` happens -# to change as well. The placement is what makes the property hold; this pins -# the behaviour, not the reason. -cd "$TMP" -mk_outer "" -touch app/src/main.cpp # the whole-project fast path never reaches build.mcpp -cd app -if "$MCPP" build > b3b.log 2>&1; then - cat b3b.log - echo "FAIL: withdrawing reexport was replayed from the build.mcpp cache" - exit 1 -fi -grep -qF "globbing" b3b.log || { - cat b3b.log; echo "FAIL: the refusal after cache invalidation does not name the module"; exit 1; } - -# ── 4. a cycle is reported as a cycle ────────────────────────────────────── -cd "$TMP" -cat >> globbing/mcpp.toml <<'EOF' - -[build-dependencies] -tidyrule = { path = "../tidyrule", host-module = true } -EOF -cd app && rm -rf target -if "$MCPP" build > b4.log 2>&1; then - cat b4.log; echo "FAIL: a rule import cycle was accepted"; exit 1 -fi -grep -qi "cycle" b4.log || { - cat b4.log; echo "FAIL: the cycle was not reported as a cycle"; exit 1; } -# Naming the packages on the ring is the actionable half; a bare "cycle -# detected" leaves the reader to find it. -grep -qF "globbing" b4.log && grep -qF "tidyrule" b4.log || { - cat b4.log; echo "FAIL: the cycle diagnostic does not name the packages on it"; exit 1; } - -echo "OK" diff --git a/tests/e2e/312_build_rules_example.sh b/tests/e2e/312_build_rules_example.sh deleted file mode 100755 index f89470772..000000000 --- a/tests/e2e/312_build_rules_example.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 312_build_rules_example.sh — examples/08-build-rules actually builds and runs. -# -# mcpp's `examples/` are not otherwise exercised by CI, so an example that stops -# working says nothing until a reader tries it. This one is worth the e2e -# because it is the only place the ecosystem-facing shape of a rule package is -# written down as running code: two rule packages, one depending on the other -# through its own `[build-dependencies]`, covering `role = "check"` and a -# generated source, and a consumer that uses both. -# -# It is copied to a temporary directory rather than built in place: building in -# the worktree would leave target/ directories inside the repository and would -# make a second run measure the first one's cache. -set -e - -SRC="$(cd "$(dirname "$0")/../.." && pwd)/examples/08-build-rules" -[[ -d "$SRC" ]] || { echo "FAIL: $SRC is missing"; exit 1; } - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cp -r "$SRC" "$TMP/ex" -# Anything a previous run left behind travels with the copy, and the object -# count below would then be measuring that run instead of this one. Caught by -# the denominator on the first attempt: four objects where two were expected. -find "$TMP/ex" -maxdepth 3 -type d -name target -exec rm -rf {} + 2>/dev/null || true -find "$TMP/ex" -maxdepth 3 -name mcpp.lock -delete 2>/dev/null || true -cd "$TMP/ex/app" - -"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: the example did not build"; exit 1; } - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "hello from an embedded asset" ]] || { - echo "FAIL: the embedded asset did not reach the program: '$out'"; exit 1; } - -# The rules are build-time only. Asserted on the ARTIFACT: a rule's objects have -# no business in the consumer's binary, and `Compiling rules-embed (path)` in -# the log is the host-module compile, not evidence either way. -# The store a host module is kept in (`.build-mcpp/host-modules`, #748) has an -# `obj/` of its own and is not the consumer's: it is left out. -mapfile -t objs < <(find target -path '*obj*' -not -path '*/.build-mcpp/*' -name '*.o' -printf '%f\n' | sort) -for o in "${objs[@]}"; do - case "$o" in - main.o|embed_greeting.o) ;; - *) printf ' %s\n' "${objs[@]}" - echo "FAIL: unexpected object '$o' — a rule package reached the link" - exit 1 ;; - esac -done -# And the denominator: an empty object list would pass the loop above while -# proving nothing. -(( ${#objs[@]} == 2 )) || { - printf ' %s\n' "${objs[@]}" - echo "FAIL: expected exactly 2 objects, found ${#objs[@]}"; exit 1; } - -# The generated source is a graph node, so editing its INPUT re-runs that edge -# and changes the program. This is what `role`-based actions buy over writing -# the file eagerly from build.mcpp. -sleep 1 -printf 'a different embedded asset\n' > assets/greeting.txt -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: rebuild failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "a different embedded asset" ]] || { - echo "FAIL: editing the asset did not re-run the embed action: '$out'"; exit 1; } - -# The check's ANALYSER never touches the stamp — `tools/check.sh` reads a file -# and answers with an exit code, which is what clang-tidy does. mcpp creates -# the stamp when the command succeeds. -# -# ASSERT ON THE STAMP AND ON THE RE-RUN, NOT ON THE BUILD SUCCEEDING. -# Measured with the wrapper removed: ninja does NOT fail when a declared output -# goes unproduced. It leaves the file missing and re-runs that edge on every -# build, forever — so the build stays green, this script's earlier assertions -# stay green, and the only visible symptom is work being redone. A test that -# checked exit codes here would have passed either way. -mapfile -t stamps < <(find target -name '*.stamp') -(( ${#stamps[@]} == 1 )) || { - printf ' %s\n' "${stamps[@]}" - echo "FAIL: expected exactly one check stamp, found ${#stamps[@]}"; exit 1; } - -"$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: no-op rebuild failed"; exit 1; } -if grep -q 'CHECK' b3.log; then - cat b3.log - echo "FAIL: the check re-ran with nothing changed — its stamp is not satisfying the edge" - exit 1 -fi - -# A failing check fails the build. Without this the check role would be -# decoration: it runs beside the compile, so nothing else would notice it. -printf 'int main() { goto done; done: return 0; }\n' > src/main.cpp -if "$MCPP" build > b3.log 2>&1; then - cat b3.log; echo "FAIL: a failing check did not fail the build"; exit 1 -fi -grep -qF "goto" b3.log || { - cat b3.log; echo "FAIL: the build failed without reporting what the check found"; exit 1; } - -echo "OK" diff --git a/tests/e2e/313_check_stamp_on_every_platform.sh b/tests/e2e/313_check_stamp_on_every_platform.sh deleted file mode 100755 index 654e06f1b..000000000 --- a/tests/e2e/313_check_stamp_on_every_platform.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -# 313_check_stamp_on_every_platform.sh — a `role = "check"` whose command never -# touches its stamp, on EVERY platform. -# -# WHY THIS EXISTS SEPARATELY FROM 188 AND 312. -# -# The engine writes a check's stamp so that an analyser -- which answers with an -# exit code and writes nothing -- can be a check without a wrapper script. The -# whole justification for doing it in the engine rather than in a script is that -# an action's command is an argv with no shell assumed, so the wrapper cannot be -# written portably: there is no `touch` on Windows. -# -# And every existing test of that mechanism declares `# requires: gcc`, so all -# of them SKIP on Windows. The feature's reason to exist was verified nowhere -# near the platform it exists for. This file carries no `# requires:` line at -# all, so it runs wherever the suite runs. -# -# To do that it must avoid a POSIX shell entirely, which the new mechanism makes -# possible: the check's command is `mcpp --version` -- an executable that is -# present by construction, exits 0, and writes no stamp. -set -e - -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p src -cat > mcpp.toml <<'EOF' -[package] -name = "stampprobe" -version = "0.1.0" -EOF -cat > src/main.cpp <<'EOF' -#include -#ifndef CHECK_DECLARED -#error "the build program never ran" -#endif -int main() { std::printf("STAMP_PROBE_OK\n"); } -EOF - -MCPP_HOST="$(host_path "$MCPP")" -cat > build.mcpp < b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == *STAMP_PROBE_OK* ]] || { - echo "FAIL: the program did not run: '$out'"; exit 1; } - -# THE criterion. Not the build's exit code: measured, ninja does not fail when a -# declared output goes unproduced -- it leaves the file absent and re-runs that -# edge on every build afterwards, so a build with no stamp mechanism at all -# stays green and merely redoes work. -# NOT `mapfile`. macOS ships bash 3.2, which does not have it, and this file -# runs there — `47_cdb_prebuilt_module_path_abs.sh` already says so in its own -# comment, and every other user of `mapfile` in this suite declares -# `# requires: gcc`, which macOS never satisfies. So the constraint was written -# down and the tests that could violate it could not reach macOS to find out. -# This one did, on its first CI run, with `mapfile: command not found`. -stamp_count=$(find target -name 'probe.stamp' | wc -l | tr -d '[:space:]') -if [ "$stamp_count" != "1" ]; then - find target -name 'probe.stamp' | sed 's/^/ /' - echo "FAIL: expected exactly one stamp written by the engine, found $stamp_count" - exit 1 -fi - -# And the other half: a satisfied edge is not re-run. Without the stamp this -# would repeat forever, which is the symptom the exit code hides. -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: no-op rebuild failed"; exit 1; } -if grep -q 'CHECK' b2.log; then - cat b2.log - echo "FAIL: the check re-ran with nothing changed — its stamp is not satisfying the edge" - exit 1 -fi - -echo "OK" diff --git a/tests/e2e/314_dependency_action_generated_header.sh b/tests/e2e/314_dependency_action_generated_header.sh deleted file mode 100755 index 30a84eec5..000000000 --- a/tests/e2e/314_dependency_action_generated_header.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 314_dependency_action_generated_header.sh — a package that generates its own -# header compiles after the generator, not alongside it (mcpp#534). -# -# THE SHAPE THAT WAS BROKEN. `role = "source"` was documented as "outputs join -# the compile set; the compile edge consumes them", and that is true of a -# generated `.cpp` and false of a generated `.h`. A header is never an edge -# input — it is reached through `-I`, and the depfile that would record it does -# not exist until a compile has already succeeded. So the action's node sat in -# build.ninja with nothing able to reach it: not in `default` (Source outputs -# are deliberately excluded), not in the goal phony (objects and link outputs -# only), and consumed by no edge. -# -# THE ASSERTION IS ON CONTENT, NOT EXISTENCE. `prepare_actions` used to -# write a zero-byte placeholder for every Source output including headers, so -# the file was on disk whether or not the generator ran — which is exactly why -# mcpp#534 was filed as an intermittent race when it was a deterministic -# never-runs. A test asserting `[[ -f gen.h ]]` would have passed against the -# defect. -# -# The dependency is a PATH dependency because that is the reported shape: the -# generator, the header and the consumer of the header are all inside one -# dependency package, which is the case `tests/examples/protobuf-protoc` does -# not cover (there the generated header is used by the ROOT project). -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -# ── the dependency: generates a header, and compiles a source that uses it ── -mkdir -p "$TMP/proto/src" -cat > "$TMP/proto/mcpp.toml" <<'EOF' -[package] -name = "proto" -version = "0.1.0" - -[targets.proto] -kind = "lib" -EOF - -cat > "$TMP/proto/gen.sh" <<'EOF' -#!/usr/bin/env bash -# Slow on purpose: if the ordering edge is missing, the compile wins the race -# every time rather than occasionally, so a red run means the defect and not -# the weather. -sleep 2 -printf '#define PROTO_ANSWER 42\n' > "$1" -EOF -chmod +x "$TMP/proto/gen.sh" - -# The lib root is a module interface, so this package really is a module -# library — keeping the lib-root convention in play rather than side-stepping -# it. It includes the generated header from its own implementation. -cat > "$TMP/proto/src/proto.cppm" <<'EOF' -module; -#include "proto_generated.h" -export module proto; -export int proto_answer() { return PROTO_ANSWER; } -EOF - -cat > "$TMP/proto/build.mcpp" <<'EOF' -#include -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - const std::string out = mcpp::out_dir(); - mcpp::action a; - a.id = "proto:header"; - a.role = "source"; - a.arg((root + "/gen.sh").c_str()) - .arg((out + "/proto_generated.h").c_str()) - .output((out + "/proto_generated.h").c_str()) // HEADER, and the only output - .submit(); - mcpp::include_dir(out.c_str()); -} -EOF - -# ── the consumer ──────────────────────────────────────────────────────────── -mkdir -p "$TMP/app/src" -cat > "$TMP/app/mcpp.toml" <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -proto = { path = "../proto" } -EOF -cat > "$TMP/app/src/main.cpp" <<'EOF' -#include -import proto; -int main() { std::printf("ANSWER=%d\n", proto_answer()); } -EOF - -cd "$TMP/app" -"$MCPP" build > b.log 2>&1 || { - cat b.log - echo "FAIL: a dependency whose action generates its only header did not build" - exit 1; } - -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "ANSWER=42" ]] || { - echo "FAIL: expected ANSWER=42 from the generated header, got '$out'"; exit 1; } - -# The generator actually ran. Size, not existence — see the header comment. -hdr=$(find "$TMP" -name proto_generated.h | head -1) -[[ -n "$hdr" ]] || { echo "FAIL: no proto_generated.h anywhere"; exit 1; } -bytes=$(wc -c < "$hdr") -[[ "$bytes" -gt 0 ]] || { - echo "FAIL: $hdr is $bytes bytes — the placeholder, not the generator's output" - exit 1; } -grep -q 'PROTO_ANSWER' "$hdr" || { - echo "FAIL: $hdr exists and is non-empty but is not what gen.sh writes:" - cat "$hdr"; exit 1; } - -# The ordering is declared, not merely observed. A green run on a fast machine -# proves nothing on its own; this is the edge that makes it a property. -nj=$(find "$TMP/app/target" -name build.ninja | head -1) -[[ -n "$nj" ]] || { echo "FAIL: no build.ninja"; exit 1; } - -# The phony is named for the QUALIFIED package (`mcpp-actions-mcpplibs.proto`), -# because that is how `CompileUnit::packageName` spells it and the two are -# matched against each other. Read the name out rather than hardcoding it: a -# test that spelled it by hand would be asserting the naming policy, which is -# not what this test is about. -phony=$(grep -oE '^build (mcpp-actions-[^ ]*proto[^ ]*) : phony' "$nj" \ - | awk '{print $2}' | head -1) -[[ -n "$phony" ]] || { - echo "FAIL: no action phony was emitted for the dependency" - grep -nE '^build mcpp-actions' "$nj" || echo " (none at all)" - exit 1; } -count=$(grep -cE "^build $phony : phony" "$nj" || true) -[[ "$count" -eq 1 ]] || { - echo "FAIL: expected exactly one '$phony' edge, found $count"; exit 1; } - -# DENOMINATOR. "every edge that should carry it does" is vacuously true when no -# edge does, and that vacuum is precisely the pre-fix state — so the count of -# the dependency's compile edges is asserted separately from the count that -# carries the ordering. -# Matched on the RULE and the SOURCE, not on the object path. An object edge -# can carry implicit outputs before the colon (`build a.o | gcm.cache/x.gcm :`), -# so a pattern anchored on the output is a pattern that quietly matches nothing -# — which, for a denominator, is the one failure mode that matters. -rules='cxx_object|c_object|cxx_module|cxx_module_obj|cxx_module_bmi|cxx_scan|asm_object' -src='proto/src/proto\.cppm' -edges=$(grep -cE "^build .*: *($rules) .*$src" "$nj" || true) -ordered=$(grep -cE "^build .*: *($rules) .*$src.*\|\| *$phony" "$nj" || true) -[[ "$edges" -gt 0 ]] || { - echo "FAIL: no compile edge for the dependency was found at all — the" - echo " assertion below would have passed by describing nothing" - grep -nE '^build ' "$nj" | head -20 - exit 1; } -[[ "$ordered" -eq "$edges" ]] || { - echo "FAIL: $ordered of $edges dependency compile edges wait for the action" - grep -nE "^build .*: *($rules) .*$src" "$nj" - exit 1; } - -echo "PASS: 314 (generator ran before the compile; $ordered/$edges edges ordered)" diff --git a/tests/e2e/315_blocking_check_gates_compilation.sh b/tests/e2e/315_blocking_check_gates_compilation.sh deleted file mode 100755 index 722b54ce6..000000000 --- a/tests/e2e/315_blocking_check_gates_compilation.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 315_blocking_check_gates_compilation.sh — `blocking = true` on a check does -# what it has always been documented to do (mcpp#534). -# -# WHAT THIS DEFENDS. `blocking` was typed (`BuildAction::blocking`), emitted -# over the build-program protocol (`hostprogram.cppm`), parsed -# (`directives.cppm`), documented in both languages (`docs/30-build-mcpp.md` -# and its Chinese counterpart) and demonstrated in a shipped example -# (`examples/08-build-rules/rules-tidy`) — and read by nothing. The only -# order-only edge the ninja backend emitted was the staged-BMI one. So a check -# marked blocking ran alongside compilation exactly like a non-blocking one, -# and the flag was a no-op with a paper trail. -# -# ASSERTED ON THE ARTIFACT, NOT ON A LOG LINE. "the check failed" appears in -# the output either way; what distinguishes blocking from non-blocking is -# whether the object was PRODUCED. A grep over stderr would pass against the -# defect. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -mkdir -p "$TMP/app/src" -cd "$TMP/app" - -cat > mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" -EOF -printf '#include \nint main(){ std::printf("ok\\n"); }\n' > src/main.cpp - -# A check that fails, slowly. The sleep is what makes the difference -# observable: without the ordering edge the compile finishes long before the -# check does, so the object exists on disk when the build gives up. -cat > check.sh <<'EOF' -#!/usr/bin/env bash -sleep 2 -echo "the check says no" >&2 -exit 1 -EOF -chmod +x check.sh - -emit_build_mcpp() { # $1 = "true" | "false" - cat > build.mcpp < -import mcpp; -int main() { - const std::string root = mcpp::manifest_dir(); - mcpp::action a; - a.id = "gate"; - a.role = "check"; - a.blocking = $1; - a.arg((root + "/check.sh").c_str()) - .output("\${mcpp.out_dir}/gate.stamp") - .submit(); -} -EOF -} - -# ── blocking = true: the object must NOT be produced ──────────────────────── -emit_build_mcpp true -rm -rf target -if "$MCPP" build > b_blocking.log 2>&1; then - cat b_blocking.log - echo "FAIL: a failing blocking check let the build succeed"; exit 1 -fi -if find target -name 'main*.o' | grep -q .; then - echo "FAIL: a failing BLOCKING check did not stop the compile — the object exists:" - find target -name 'main*.o' - echo " (this is the pre-fix behaviour: 'blocking' was parsed and never read)" - exit 1 -fi - -# The edge that makes it a property rather than a race lost by the compiler. -nj=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -[[ -n "$nj" ]] || { echo "FAIL: no build.ninja"; exit 1; } -grep -qE '^build mcpp-actions-app : phony.*gate\.stamp' "$nj" || { - echo "FAIL: the blocking check's stamp is not in the package's action phony" - grep -nE '^build mcpp-actions' "$nj" || echo " (no phony at all)" - exit 1; } -ordered=$(grep -cE '^build [^:]*main[^:]*\.o *:.*\|\|.*mcpp-actions-app' "$nj" || true) -[[ "$ordered" -ge 1 ]] || { - echo "FAIL: the compile edge does not wait for the blocking check"; exit 1; } - -# ── blocking = false: the CONTROL. Same check, same failure, compile runs ─── -# -# Without this half, a build that refused to compile for any unrelated reason -# would pass the assertion above. What must differ between the two runs is -# exactly one line of build.mcpp. -emit_build_mcpp false -rm -rf target -"$MCPP" build > b_parallel.log 2>&1 || true # still fails: the check still fails -if ! find target -name 'main*.o' | grep -q .; then - cat b_parallel.log - echo "FAIL: a NON-blocking check also stopped the compile — 'blocking' is" - echo " not distinguishing anything, it is just always on" - exit 1 -fi -nj2=$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1) -if grep -qE '^build mcpp-actions-app : phony.*gate\.stamp' "$nj2"; then - echo "FAIL: a NON-blocking check joined the ordering phony"; exit 1 -fi - -echo "PASS: 315 (blocking gates the compile; non-blocking does not)" diff --git a/tests/e2e/316_link_unit_with_no_inputs_is_refused.sh b/tests/e2e/316_link_unit_with_no_inputs_is_refused.sh deleted file mode 100755 index a7cb25ef0..000000000 --- a/tests/e2e/316_link_unit_with_no_inputs_is_refused.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 316_link_unit_with_no_inputs_is_refused.sh — a target with nothing to link is -# an error that names the target (mcpp#533). -# -# WHERE THIS CAME FROM. A dependency whose `install()` was skipped over a -# package-identity collision left a version directory with no source tree. mcpp -# planned a shared-library target for it anyway, emitted a link edge with zero -# inputs, and the user was shown: -# -# /bin/sh: 1: -shared: not found -# -# — four layers from the cause and naming nothing that had anything to do with -# it. (`$cc` was emitted only when the compile set held a C or asm unit; a -# package with no sources has neither, so the variable expanded to nothing and -# the shell was handed `-shared` as a program name.) -# -# THE STATIC CASE IS THE ONE THAT MATTERED. `ar rcs libfoo.a` with no -# members exits 0 and writes an 8-byte archive, so before this the build -# REPORTED SUCCESS and every consumer failed later with undefined symbols. Both -# kinds are asserted here, and the static one is why the check is at plan time -# rather than a better linker diagnostic. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT - -check_kind() { # $1 = mcpp kind, $2 = human name, $3 = artifact glob - local dir="$TMP/$1" - mkdir -p "$dir/src" - cat > "$dir/mcpp.toml" < b.log 2>&1; then - echo "FAIL($1): a target with no sources built successfully" - find . -name "$3" -printf ' produced %p (%s bytes)\n' 2>/dev/null - exit 1 - fi - - # The message names the target, and says which kind — a user reading it has - # to be able to find the thing in their manifest. - grep -q "target 'ghost'" b.log || { - echo "FAIL($1): the error does not name the target:"; cat b.log; exit 1; } - grep -q "$2" b.log || { - echo "FAIL($1): the error does not say it is a $2:"; cat b.log; exit 1; } - - # THE NEGATIVE HALF. This is the assertion that catches a regression back - # to the reported symptom: if the refusal is ever removed, the build reaches - # the linker again and this is what comes out. - if grep -q -- '-shared: not found' b.log; then - echo "FAIL($1): the build reached the shell and reported the old symptom:" - cat b.log; exit 1 - fi - if grep -qi 'no input files' b.log; then - echo "FAIL($1): the build reached the compiler driver instead of being" - echo " refused at plan time:"; cat b.log; exit 1 - fi - - # Nothing was written. For the static kind this is the whole point. - if find . -name "$3" | grep -q .; then - echo "FAIL($1): an artifact was produced by a refused target:" - find . -name "$3" -printf ' %p (%s bytes)\n' - exit 1 - fi -} - -check_kind shared "shared library" 'libghost.so*' -check_kind lib "static library" 'libghost.a' - -# ── the control: a target WITH sources still builds ───────────────────────── -# -# Without this, a refusal that fired on every library target would pass -# everything above. -mkdir -p "$TMP/ok/src" -cat > "$TMP/ok/mcpp.toml" <<'EOF' -[package] -name = "ok" -version = "0.1.0" - -[targets.ok] -kind = "shared" -EOF -printf 'int ok_value(void) { return 7; }\n' > "$TMP/ok/src/ok.c" -cd "$TMP/ok" -"$MCPP" build > b.log 2>&1 || { - cat b.log; echo "FAIL: a library target WITH sources was refused"; exit 1; } -find . -name 'libok.so*' | grep -q . || { - echo "FAIL: the control target produced no library"; exit 1; } - -echo "PASS: 316 (empty shared and static targets refused by name; populated one builds)" diff --git a/tests/e2e/317_project_build_hooks.sh b/tests/e2e/317_project_build_hooks.sh deleted file mode 100644 index c32972f23..000000000 --- a/tests/e2e/317_project_build_hooks.sh +++ /dev/null @@ -1,522 +0,0 @@ -#!/usr/bin/env bash -# requires: -# Project-level build hooks (#496): lifecycle order, policy, timeout, the -# directory a hook runs in, and the two places nothing may fire. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -mkdir -p "$TMP/app/src" "$TMP/app/nested" - -cat > "$TMP/app/src/main.cpp" <<'EOF' -int main() { return 0; } -EOF - -write_manifest() { - cat > "$TMP/app/mcpp.toml" -} - -# cmd.exe writes CRLF and /bin/sh writes LF, so the line ENDINGS are the host's -# and the line CONTENT is the hook's. Compare the content. -# expect_log ... -expect_log() { - local file=$1; shift - local want got - want=$(printf '%s\n' "$@") - got=$(tr -d '\r' < "$file" 2>/dev/null || true) - if [[ "$got" != "$want" ]]; then - echo "FAIL: $file" - echo "--- expected ---"; printf '%s\n' "$want" - echo "--- actual ---"; printf '%s\n' "$got" - exit 1 - fi -} - -# A WHOLE line, matched as one: a substring would still pass if the message -# were reworded around it. No pipe into grep — a `grep -q` that exits on the -# first match SIGPIPEs its producer, which reads as failure the moment anyone -# adds `set -o pipefail` to this file. -expect_line() { # expect_line - local file=$1 line=$2 - local body - body=$(tr -d '\r' < "$file" 2>/dev/null || true) - if [[ $'\n'"$body"$'\n' != *$'\n'"$line"$'\n'* ]]; then - cat "$file" - echo "FAIL: '$line' is not a line of $file" - exit 1 - fi -} - -# ── The success lifecycle, twice ───────────────────────────────────────── -# -# Twice on purpose: the second `mcpp build` is the one that would otherwise -# take the no-op fast path, which skips preparation and would skip the hooks -# with it. A single run cannot tell the two paths apart. -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_start = "echo start>>hooks.log" -build_failed = "echo failed>>hooks.log" -build_finished = "echo finished>>hooks.log" -timeout_seconds = 10 -enabled = true -side_effect = false -EOF - -# Invoked BELOW the project root: a hook's relative paths belong to the root, -# not to wherever the user happened to type `mcpp build`. -cd "$TMP/app/nested" -"$MCPP" build > success-1.log 2>&1 || { - cat success-1.log; echo "FAIL: hooked build failed"; exit 1; } -"$MCPP" build > success-2.log 2>&1 || { - cat success-2.log; echo "FAIL: second hooked build failed"; exit 1; } -cd "$TMP/app" -[[ ! -e nested/hooks.log ]] || { - echo "FAIL: hook ran in the invocation directory, not the project root"; exit 1; } -expect_log hooks.log start finished start finished - -# ── A compiler failure chooses build_failed, never build_finished ──────── -cat > src/main.cpp <<'EOF' -#error "intentional hook failure path" -int main() { return 0; } -EOF -: > hooks.log -rc=0 -"$MCPP" build > build-failed.log 2>&1 || rc=$? -[[ $rc -ne 0 ]] || { cat build-failed.log; echo "FAIL: broken source built"; exit 1; } -expect_log hooks.log start failed - -cat > src/main.cpp <<'EOF' -int main() { return 0; } -EOF - -# ── A failing hook is REPORTED and the build keeps its own result ─────── -# -# While `[hooks]` is experimental it may not decide whether a build succeeded. -# The build below succeeds with a hook that cannot run at all, and the two -# assertions are both needed: the exit code says the hook had no vote, and the -# warning says it was not silently swallowed. -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_finished = "mcpp-hook-command-that-does-not-exist" -EOF -"$MCPP" build > ignored-hook-failure.log 2>&1 || { - cat ignored-hook-failure.log - echo "FAIL: a failing hook changed the build result"; exit 1; } -grep -q "warning: hook 'build_finished' exited with status" ignored-hook-failure.log || { - cat ignored-hook-failure.log; echo "FAIL: the hook failure was not reported"; exit 1; } -# `if grep` rather than `grep && { }`: a NEGATIVE assertion written with `&&` -# leaves the list's exit status as grep's, and reading that under `set -e` is -# an argument this file should not be having. -if grep -q "error: hook 'build_finished'" ignored-hook-failure.log; then - cat ignored-hook-failure.log - echo "FAIL: an experimental hook failure was reported as an error"; exit 1 -fi - -# The explicit spelling of the current behaviour keeps working, so a manifest -# does not have to change when the feature is promoted. -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_finished = "mcpp-hook-command-that-does-not-exist" -side_effect = false -EOF -"$MCPP" build > explicit-false.log 2>&1 || { - cat explicit-false.log; echo "FAIL: side_effect=false was not accepted"; exit 1; } - -# ── `side_effect = true` is REFUSED, not quietly downgraded ───────────── -# -# Honouring it would give an experimental feature a veto over every build; -# ignoring it would leave the project believing its build is gated on a -# notifier when nothing is. Both silent options are worse than an error. -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_finished = "echo done" -side_effect = true -EOF -rc=0 -"$MCPP" build > side-effect-true.log 2>&1 || rc=$? -[[ $rc -ne 0 ]] || { - cat side-effect-true.log; echo "FAIL: side_effect=true was accepted"; exit 1; } -grep -q "experimental" side-effect-true.log || { - cat side-effect-true.log - echo "FAIL: the refusal does not say why"; exit 1; } - -# ── enabled = false switches off a table that still names commands ────── -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_start = "echo disabled>>disabled.log" -build_finished = "echo disabled>>disabled.log" -enabled = false -EOF -rm -f disabled.log -"$MCPP" build > disabled.log.out 2>&1 || { - cat disabled.log.out; echo "FAIL: disabled hook build failed"; exit 1; } -[[ ! -e disabled.log ]] || { - cat disabled.log; echo "FAIL: enabled=false still ran hooks"; exit 1; } - -# ── The timeout is a real bound, on both host shells ──────────────────── -case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) HOST_WINDOWS=1 ;; - *) HOST_WINDOWS=0 ;; -esac -if [[ $HOST_WINDOWS -eq 1 ]]; then - slow_command="ping -n 6 127.0.0.1 >NUL" - pause_2s="ping -n 3 127.0.0.1 >NUL" - pause_5s="ping -n 6 127.0.0.1 >NUL" -else - slow_command="sleep 5" - pause_2s="sleep 2" - pause_5s="sleep 5" -fi -write_manifest < timeout.log 2>&1 || { - cat timeout.log; echo "FAIL: a timed-out hook changed the build result"; exit 1; } -# The bound is real even though the build survives it: without the timeout the -# command would have run for five seconds, and the line below would be absent. -expect_line timeout.log "warning: hook 'build_finished' timed out after 1s" - -# ── An invalid value fails the manifest, before any command runs ──────── -# -# And it fails as a MANIFEST error naming the key — `[hooks]` is a section of -# mcpp.toml, so a bad value there is reported by the same parser and in the -# same words as a bad value anywhere else in the file. -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_start = "echo should-not-run>>invalid.log" -timeout_seconds = 0 -EOF -rm -f invalid.log -rc=0 -"$MCPP" build > invalid-config.log 2>&1 || rc=$? -[[ $rc -ne 0 ]] || { cat invalid-config.log; echo "FAIL: invalid hooks were accepted"; exit 1; } -[[ ! -e invalid.log ]] || { cat invalid.log; echo "FAIL: invalid hook ran"; exit 1; } -grep -q "\[hooks\].timeout_seconds must be a positive integer" invalid-config.log || { - cat invalid-config.log; echo "FAIL: invalid hook diagnostic is missing"; exit 1; } - -# ── An unknown key is a warning, and the known ones still run ─────────── -: > hooks.log -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_start = "echo start>>hooks.log" -build_finsihed = "echo typo>>hooks.log" -EOF -"$MCPP" build > unknown-key.log 2>&1 || { - cat unknown-key.log; echo "FAIL: unknown hook key was fatal"; exit 1; } -grep -q "\[hooks\] has unsupported key 'build_finsihed'" unknown-key.log || { - cat unknown-key.log; echo "FAIL: unknown hook key was not reported"; exit 1; } -expect_log hooks.log start - -# ── Preparation failing fires NOTHING ─────────────────────────────────── -# -# The lifecycle is paired: build_finished/build_failed are only reachable after -# build_start has run. A project that cannot be prepared has not started -# building — and its hook program may be exactly what preparation would have -# installed, so "report the failure" is not available here. -: > hooks.log -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_start = "echo start>>hooks.log" -build_failed = "echo failed>>hooks.log" - -[dependencies] -absent = { path = "no/such/dependency" } -EOF -rc=0 -"$MCPP" build > prepare-failed.log 2>&1 || rc=$? -[[ $rc -ne 0 ]] || { - cat prepare-failed.log; echo "FAIL: a missing path dependency built"; exit 1; } -expect_log hooks.log - -# ── `during_build`: an interval closed by the build, not by the command ── -# -# The assertions below are about STATE, never about a log line. "mcpp said it -# stopped the command" passes whether or not anything stopped; "the file it was -# appending to has not grown in a second" does not. -if [[ $HOST_WINDOWS -eq 1 ]]; then - cat > writer.cmd <<'EOF' -@echo off -:loop -echo tick>>beat.log -ping -n 2 127.0.0.1 >NUL -goto loop -EOF - # `start /b` returns immediately, so the command mcpp starts EXITS and the - # writer is left behind it — the job object is the only thing that can - # still reach it. - writer_with_grandchild="start /b cmd /c writer.cmd" -else - cat > writer.sh <<'EOF' -while :; do echo tick>>beat.log; sleep 0.2; done -EOF - # The shell mcpp starts forks the writer and waits, so the writer is a - # GRANDCHILD. This is the case `kill(pid)` misses and `killpg` catches, and - # it is the only assertion that tells the fix from the bug. - writer_with_grandchild="sh writer.sh & wait" -fi - -beat_is_frozen() { # the file grew, and then stopped growing - local before after - before=$(wc -c < beat.log 2>/dev/null || echo 0) - sleep 1 - after=$(wc -c < beat.log 2>/dev/null || echo 0) - [[ "$before" != "0" && "$before" == "$after" ]] -} - -: > hooks.log -rm -f beat.log -write_manifest < during-build.log 2>&1 || { - cat during-build.log; echo "FAIL: build with during_build failed"; exit 1; } -beat_is_frozen || { - echo "FAIL: during_build kept running after the build (or never ran)" - wc -c beat.log 2>/dev/null; exit 1; } -# The interval closed BEFORE the terminal hook, which is the ordering that -# keeps a "build finished" sound from playing over the music it replaces. -expect_log hooks.log finished - -# ── `loop` restarts a command that ends before its interval does ──────── -rm -f runs.log -write_manifest < loop-on.log 2>&1 || { - cat loop-on.log; echo "FAIL: looped during_build failed the build"; exit 1; } -looped=$(wc -l < runs.log | tr -d ' ') -[[ "$looped" -ge 2 ]] || { - cat loop-on.log; echo "FAIL: loop=true ran $looped time(s), expected >= 2"; exit 1; } - -# Without `loop`, the same command runs exactly once — the denominator that -# makes the count above mean something. -rm -f runs.log -write_manifest < loop-off.log 2>&1 || { - cat loop-off.log; echo "FAIL: unlooped during_build failed the build"; exit 1; } -once=$(wc -l < runs.log | tr -d ' ') -[[ "$once" -eq 1 ]] || { - cat loop-off.log; echo "FAIL: loop=false ran $once time(s), expected 1"; exit 1; } - -# ── A command that cannot stay up stops, rather than spinning ─────────── -# -# `loop` on a typo is a fork bomb otherwise. The bound is five consecutive -# runs that end unsuccessfully within a second. -# -# The build must stay open long enough for the supervisor to reach that bound. -# Reaching it costs five spawns, four restart delays of 250ms, and up to 50ms -# of poll granularity after each exit: 1.25s before a single spawn is counted. -# Under a two-second pause that left 150ms per spawn, and a loaded macOS -# runner exceeded it (main, 2026-09-05: the build finished, the warning was -# never printed, and the test reported the bound as missing). Five seconds -# leaves 750ms per spawn; the property is then decided by the code and not by -# the runner's load. -write_manifest < loop-giveup.log 2>&1 || { - cat loop-giveup.log - echo "FAIL: a during_build that never ran changed the build result"; exit 1; } -grep -q "warning: hook 'during_build' failed to stay up" loop-giveup.log || { - cat loop-giveup.log; echo "FAIL: giving up was not reported"; exit 1; } - -# ── `loop` on a self-closing event is refused, not ignored ────────────── -write_manifest <<'EOF' -[package] -name = "hookprobe" -version = "0.1.0" - -[hooks] -build_start = { cmd = "echo start", loop = true } -EOF -rc=0 -"$MCPP" build > loop-misplaced.log 2>&1 || rc=$? -[[ $rc -ne 0 ]] || { - cat loop-misplaced.log; echo "FAIL: loop on build_start was accepted"; exit 1; } -grep -q "during_build" loop-misplaced.log || { - cat loop-misplaced.log - echo "FAIL: the diagnostic does not name the event that supports loop"; exit 1; } - -# ── An interrupted build leaves nothing running ───────────────────────── -# -# POSIX only, and the skip is printed rather than silent: sending Ctrl-C to -# another process on Windows needs a helper this suite does not have, and a -# test that cannot fail there would read as coverage. -if [[ $HOST_WINDOWS -eq 1 ]]; then - echo "SKIP(within test): Ctrl-C cleanup — no way to signal another process here" -else - rm -f beat.log - write_manifest < interrupted.log 2>&1 & - build_pid=$! - sleep 3 - kill -INT "$build_pid" 2>/dev/null || true - wait "$build_pid" 2>/dev/null || true - beat_is_frozen || { - echo "FAIL: Ctrl-C left during_build running"; exit 1; } -fi - -# ── A DEPENDENCY's hooks never run ────────────────────────────────────── -# -# Every manifest mcpp parses carries a `[hooks]` field, a dependency's -# included. Only the root project's is ever invoked, and that is the whole -# reason `mcpp add` of a third-party package is not "run their shell command -# on my next build". A comment cannot hold that; this can. -DEP="$TMP/dep" -DEP_HOST="$(host_path "$DEP")" -mkdir -p "$DEP/src" -cat > "$DEP/src/dep.cppm" <<'EOF' -export module dep; -export int dep_answer() { return 0; } -EOF -cat > "$DEP/mcpp.toml" <<'EOF' -[package] -name = "dep" -version = "0.1.0" - -[targets.dep] -kind = "lib" - -[hooks] -build_start = "echo dependency>>dependency-hook.log" -build_finished = "echo dependency>>dependency-hook.log" -EOF -: > hooks.log -rm -f "$DEP/dependency-hook.log" dependency-hook.log -write_manifest < src/main.cpp <<'EOF' -import dep; -int main() { return dep_answer(); } -EOF -"$MCPP" build > dependency-hooks.log 2>&1 || { - cat dependency-hooks.log; echo "FAIL: build with a hooked dependency failed"; exit 1; } -expect_log hooks.log start finished -[[ ! -e "$DEP/dependency-hook.log" && ! -e dependency-hook.log ]] || { - echo "FAIL: a dependency's hooks ran"; exit 1; } - -# ── A workspace runs each MEMBER's hooks, in that member's root ───────── -# -# Members are separate packages, so their hooks are separate too — and the -# workspace root's own `[hooks]` belongs to a node that builds nothing. -WS="$TMP/ws" -mkdir -p "$WS/alpha/src" "$WS/beta/src" -cat > "$WS/mcpp.toml" <<'EOF' -[workspace] -members = ["alpha", "beta"] - -[hooks] -build_start = "echo root>>root.log" -build_finished = "echo root>>root.log" -EOF -for member in alpha beta; do - cat > "$WS/$member/src/main.cpp" <<'EOF' -int main() { return 0; } -EOF - cat > "$WS/$member/mcpp.toml" < ws-build.log 2>&1 || { - cat ws-build.log; echo "FAIL: workspace build with hooks failed"; exit 1; } -expect_log alpha/hooks.log alpha-start alpha-finished -expect_log beta/hooks.log beta-start beta-finished -[[ ! -e root.log ]] || { - cat root.log; echo "FAIL: the workspace root's own hooks fired"; exit 1; } -[[ ! -e hooks.log ]] || { - cat hooks.log; echo "FAIL: a member hook ran in the workspace root"; exit 1; } - -echo "OK" diff --git a/tests/e2e/31_transitive_deps.sh b/tests/e2e/31_transitive_deps.sh deleted file mode 100755 index 7690f6073..000000000 --- a/tests/e2e/31_transitive_deps.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# requires: musl -# 31_transitive_deps.sh — transitive dependency walker: -# * a path-dep that itself declares a path-dep is fully resolved -# (consumer doesn't need to list the grandchild explicitly) -# * the grandchild's [build].include_dirs propagate so its headers -# are visible while compiling its parent. -set -e -source "$(dirname "$0")/_host_path.sh" - -TMP=$(mktemp -d) -TMP_HOST="$(host_path "$TMP")" -trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" -MCPP_INHERIT_CONFIG=0 MCPP_INHERIT_SUBOS=0 source "$(dirname "$0")/_inherit_toolchain.sh" - -# The musl payload is the one linked from the developer's home, whichever -# release that is. The probe named 15.1.0 until 2026-11-12, the runners -# installed a later one, and the script skipped itself on every runner. -source "$(dirname "$0")/_toolchain_env.sh" -MUSL_PAYLOAD="$MUSL_GCC_ROOT/bin/x86_64-linux-musl-g++" -if [[ ! -x "$MUSL_PAYLOAD" ]]; then - echo "SKIP: no reusable musl-gcc xpkg payload" - echo "OK" - exit 77 -fi - -# ── 1. Grandchild: a header-providing C lib whose `[build].include_dirs` -# is what consumers care about. Plays the role of mbedtls in the -# llmapi → tinyhttps → mbedtls chain. -mkdir -p "$TMP/grandchild" && cd "$TMP/grandchild" -"$MCPP" new gc > /dev/null -cd gc -mkdir -p include/gc -cat > include/gc/gc.h <<'EOF' -#pragma once -inline int gc_answer(void) { return 42; } -EOF -rm -f src/main.cpp -cat > src/gc.cppm <<'EOF' -export module gc; -EOF -cat > mcpp.toml <<'EOF' -[package] -name = "gc" -version = "0.1.0" -[build] -include_dirs = ["include"] -[targets.gc] -kind = "lib" -EOF - -# ── 2. Child: depends on grandchild via path; its own .cppm pulls -# , which can only work if gc's include_dirs reach the -# child's compile rule (transitive include propagation). -mkdir -p "$TMP/child" && cd "$TMP/child" -"$MCPP" new ch > /dev/null -cd ch -rm -f src/main.cpp -cat > src/ch.cppm <<'EOF' -module; -#include -export module ch; -export int ch_answer() { return gc_answer(); } -EOF -cat > mcpp.toml < /dev/null -cd top -cat > src/main.cpp <<'EOF' -import std; -import ch; -int main() { - std::println("answer={}", ch_answer()); - return ch_answer() == 42 ? 0 : 1; -} -EOF -cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "transitive build failed"; exit 1; } -out="$("$MCPP" run 2>&1 | tail -1)" -[[ "$out" == "answer=42" ]] || { echo "unexpected output: $out"; exit 1; } - -# ── 4. Same dep referenced through two parallel paths is allowed -# (no version conflict — same path, same package). -mkdir -p "$TMP/top2" && cd "$TMP/top2" -"$MCPP" new top2 > /dev/null -cd top2 -cat > src/main.cpp <<'EOF' -import std; -import ch; -int main() { std::println("answer={}", ch_answer()); return ch_answer() == 42 ? 0 : 1; } -EOF -cat > mcpp.toml < build-top2.log 2>&1 || { cat build-top2.log; echo "duplicate-but-consistent dep failed"; exit 1; } - -echo "OK" diff --git a/tests/e2e/321_workspace_inheritance.sh b/tests/e2e/321_workspace_inheritance.sh deleted file mode 100755 index def60a8e6..000000000 --- a/tests/e2e/321_workspace_inheritance.sh +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 321_workspace_inheritance.sh — [workspace.package] / [workspace.build] (#527). -# -# THE ASSERTION IS ON THE COMPILE COMMAND, NOT ON BUILD SUCCESS. A member that -# inherits nothing still builds; only the flags say whether inheritance -# happened, and only they say WHICH value won when both sides declared one. -# -# Four members, chosen so that each one falsifies a different wrong -# implementation: -# -# silent declares nothing → inherits standard and cxxflags -# pinned declares standard = "c++23" → KEEPS it under a c++26 workspace. -# Fails if declaredness was faked -# by comparing against the default, -# since the pin IS the default. -# adds declares its own cxxflags → gets BOTH, workspace first -# inside built from inside its own dir → the second inheritance site; -# an assertion on the first proves -# nothing about it -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p silent/src pinned/src adds/src shared/inc -printf '#define SHARED_HEADER_FOUND 1\n' > shared/inc/shared.h - -cat > mcpp.toml <<'EOF' -[workspace] -members = ["silent", "pinned", "adds"] - -[workspace.package] -standard = 26 -version = "0.4.2" -license = "Apache-2.0" - -[workspace.build] -cxxflags = ["-DFROM_WORKSPACE=1"] -include_dirs = ["shared/inc"] -EOF - -# `version` is deliberately absent from every member: it is a required field, -# and the workspace supplying it is the half of this feature that the parser -# cannot enforce on its own. -printf '[package]\nname = "silent"\n' > silent/mcpp.toml -printf '[package]\nname = "pinned"\nstandard = "c++23"\n' > pinned/mcpp.toml -printf '[package]\nname = "adds"\n\n[build]\ncxxflags = ["-DFROM_MEMBER=1"]\n' > adds/mcpp.toml -for m in silent pinned adds; do - # The include also proves the workspace-relative path was anchored to the - # WORKSPACE ROOT and not to each member: `shared/inc` lives at - # `/shared/inc`, and resolving it per-member would look for - # `/shared/inc` and fail with a missing header three members deep, - # naming neither the manifest that declared it nor the root it meant. - printf '#include \n#if !defined(FROM_WORKSPACE) || !defined(SHARED_HEADER_FOUND)\n#error "workspace [build] did not reach the member"\n#endif\nint main(){return 0;}\n' > "$m/src/main.cpp" -done - -# The workspace keeps one compile database per configuration at its root -# (workspace design 2026-09-29 §7.1); a member's command is its entry there. -entry_of() { # $1 = member dir: the member's main.cpp entry, one word per line - python3 -c ' -import json, sys -for e in json.load(open("compile_commands.json")): - if e["file"].replace(chr(92), "/").endswith(sys.argv[1] + "/src/main.cpp"): - print("\n".join(e.get("arguments") or e["command"].split())) - break -' "$1" -} -flags_of() { # $1 = member dir - grep -oE '\-std=c\+\+[0-9a-z]+|\-DFROM_[A-Z]+=1' "$1.entry" | sort -u | tr '\n' ' ' -} - -for m in silent pinned adds; do - "$MCPP" build -p "$m" > "build_$m.log" 2>&1 || { cat "build_$m.log"; exit 1; } - entry_of "$m" > "$m.entry" -done - -# ── silent: inherits both ─────────────────────────────────────────────────── -got=$(flags_of silent) -case "$got" in - *"-DFROM_WORKSPACE=1"*) ;; - *) echo "FAIL: member 'silent' did not inherit [workspace.build] cxxflags" - echo " got: $got"; exit 1 ;; -esac -case "$got" in - *"-std=c++26"*) ;; - *) echo "FAIL: member 'silent' did not inherit [workspace.package] standard" - echo " got: $got"; exit 1 ;; -esac -grep -q '"version": *"0.4.2"' target/*/*/resolution.json 2>/dev/null \ - || grep -q "0.4.2" build_silent.log \ - || { echo "FAIL: member 'silent' did not inherit [workspace.package] version" - cat build_silent.log; exit 1; } - -# ── pinned: its own declaration wins ──────────────────────────────────────── -# -# THE ONE THAT CATCHES A FAKED DECLAREDNESS BIT. c++23 is also the default, so -# an implementation that decided "did the member declare it?" by comparing -# against the default would overwrite this pin with c++26 and every other -# assertion in this file would still pass. -got=$(flags_of pinned) -case "$got" in - *"-std=c++23"*) ;; - *) echo "FAIL: member 'pinned' declared standard = c++23 and did not get it" - echo " got: $got"; exit 1 ;; -esac -case "$got" in - *"-DFROM_WORKSPACE=1"*) ;; - *) echo "FAIL: member 'pinned' should still inherit [workspace.build]" - echo " got: $got"; exit 1 ;; -esac - -# ── adds: both, workspace first ───────────────────────────────────────────── -got=$(flags_of adds) -for want in "-DFROM_WORKSPACE=1" "-DFROM_MEMBER=1"; do - case "$got" in - *"$want"*) ;; - *) echo "FAIL: member 'adds' is missing $want"; echo " got: $got"; exit 1 ;; - esac -done -# Order is load-bearing: later wins on a compiler command line, so a member's -# own flag has to come after the workspace's. The CDB is pretty-printed with one -# argument per line, so the question is which line number comes first — matching -# both on a single line would silently never fire. -ws_at=$(grep -n 'FROM_WORKSPACE=1' adds.entry | head -1 | cut -d: -f1) -mem_at=$(grep -n 'FROM_MEMBER=1' adds.entry | head -1 | cut -d: -f1) -[ -n "$ws_at" ] && [ -n "$mem_at" ] || { - echo "FAIL: could not locate both flags in the compile command"; exit 1; } -[ "$ws_at" -lt "$mem_at" ] || { - echo "FAIL: workspace cxxflags must precede the member's own" - echo " workspace at line $ws_at, member at line $mem_at"; exit 1; } - -# ── inside: the second inheritance site ───────────────────────────────────── -( cd silent && "$MCPP" build > ../build_inside.log 2>&1 ) \ - || { cat build_inside.log; exit 1; } -entry_of silent > silent.entry -got=$(flags_of silent) -case "$got" in - *"-DFROM_WORKSPACE=1"*"-std=c++26"* | *"-std=c++26"*"-DFROM_WORKSPACE=1"*) ;; - *) echo "FAIL: inheritance did not happen when the command was issued" - echo " inside the member directory (the second site)" - echo " got: $got"; exit 1 ;; -esac - -# ── a member with no version and no workspace value is still refused ──────── -# -# THE DENOMINATOR. Deferring the required-field check must not delete it: with -# the workspace value removed, the same member must fail, or this feature has -# quietly made `version` optional for everyone. -sed -i 's/^version = "0.4.2"$//' mcpp.toml -rm -rf silent/target -if ( cd silent && "$MCPP" build > ../novers.log 2>&1 ); then - echo "FAIL: a member with no version and no [workspace.package] version built" - cat novers.log - exit 1 -fi -grep -q "package.version" novers.log || { - echo "FAIL: the refusal does not name the missing field" - cat novers.log - exit 1 -} - -# ── a SIBLING member reached as a `path` dependency inherits too ──────────── -# -# THE ORDINARY WORKSPACE SHAPE, AND THE ONE THAT WAS MISSED. Inheritance runs -# where the command's own manifest is loaded, so `mcpp build -p consumer` gave -# the consumer the workspace flags and gave the sibling none — while compiling -# both in the same command. `[workspace.package] standard` hid the gap, because -# the standard is imposed graph-wide from the root for BMI compatibility and -# reached the sibling anyway. -# -# The negative is in the same fixture on purpose: a `path` dependency that is -# NOT a member (a vendored copy, an example) must not acquire the flags, and a -# fix that inherited to every path dependency would pass the positive alone. -cat > mcpp.toml <<'EOF' -[workspace] -members = ["silent", "pinned", "adds", "consumer"] - -[workspace.package] -standard = 26 -version = "0.4.2" - -[workspace.build] -cxxflags = ["-DFROM_WORKSPACE=1"] -EOF -mkdir -p consumer/src vendored/src -printf '[package]\nname = "consumer"\n\n[dependencies]\nadds = { path = "../adds" }\nvend = { path = "../vendored" }\n' > consumer/mcpp.toml -printf 'import addslib;\nimport vend;\nint main(){ return addslib()+vend_v()==3 ? 0 : 1; }\n' > consumer/src/main.cpp -# `adds` becomes a library so it can be imported; its own source asserts it saw -# the workspace flag while being built as somebody else's dependency. -printf '[package]\nname = "adds"\n\n[targets.adds]\nkind = "lib"\n\n[build]\ncxxflags = ["-DFROM_MEMBER=1"]\n' > adds/mcpp.toml -rm -f adds/src/main.cpp -printf '#ifndef FROM_WORKSPACE\n#error "a SIBLING member built as a path dependency did not inherit"\n#endif\nexport module addslib;\nexport int addslib(){ return 1; }\n' > adds/src/addslib.cppm -printf '[package]\nname = "vend"\nversion = "0.1.0"\n\n[targets.vend]\nkind = "lib"\n' > vendored/mcpp.toml -printf '#ifdef FROM_WORKSPACE\n#error "a NON-member path dependency must not acquire workspace flags"\n#endif\nexport module vend;\nexport int vend_v(){ return 2; }\n' > vendored/src/vend.cppm - -"$MCPP" build -p consumer > sibling.log 2>&1 || { cat sibling.log; exit 1; } - -echo "PASS: 321_workspace_inheritance" diff --git a/tests/e2e/322_dialect_flag_refusal.sh b/tests/e2e/322_dialect_flag_refusal.sh deleted file mode 100755 index 203d24819..000000000 --- a/tests/e2e/322_dialect_flag_refusal.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 322_dialect_flag_refusal.sh — a dialect flag that misses the std BMI (#527). -# -# `[build] cxxflags = ["-fno-exceptions"]` reaches every translation unit and -# not the precompiled `import std` BMI, so every importer fails inside a file -# mcpp generated, with a message that names the mechanism and not the key that -# fixes it. The build cannot succeed, which is why this is refused rather than -# warned about — contrast the host-dependence diagnostics, which fire on builds -# that work. -# -# FIVE CASES, AND THREE OF THEM MUST STAY GREEN. A check that refuses whenever -# it sees `-fno-exceptions` would pass the first two and has stopped testing -# the condition it claims to test. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" -mkdir -p src - -writes_manifest() { # $1 = the [build] body - printf '[package]\nname = "dia"\nversion = "0.1.0"\nstandard = "c++23"\n\n[build]\n%s\n' "$1" > mcpp.toml -} -uses_import_std() { - printf 'import std;\nint main(){ std::println("ok"); return 0; }\n' > src/main.cpp -} -no_import_std() { - printf '#include \nint main(){ std::printf("ok\\n"); return 0; }\n' > src/main.cpp -} - -# ── 1. refused, and the message names the key that fixes it ───────────────── -uses_import_std -writes_manifest 'cxxflags = ["-fno-exceptions"]' -rm -rf target -if "$MCPP" build > r1.log 2>&1; then - echo "FAIL: -fno-exceptions in cxxflags + import std should be refused" - cat r1.log; exit 1 -fi -grep -q 'dialect_cxxflags' r1.log || { - echo "FAIL: the refusal does not name \`dialect_cxxflags\`" - cat r1.log; exit 1 -} -# Refused BEFORE compiling: a refusal that arrives after the compiler has -# already produced the confusing error has added a line, not removed one. -# -# NOT `grep 'language dialect differs'`. The refusal QUOTES that phrase, so -# the assertion matched its own message and failed on a correct implementation. -# The signal that no compile ran is the absence of run_build_plan's "Compiling" -# banner, which is printed after prepare_build returns. -grep -q '^ *Compiling' r1.log && { - echo "FAIL: the compiler ran — the check fired too late" - cat r1.log; exit 1 -} -[ ! -d target ] || [ -z "$(find target -name '*.o' 2>/dev/null)" ] || { - echo "FAIL: objects were produced before the refusal" - exit 1 -} - -# ── 2. -fno-rtti is the same axis, so the list is exercised past entry one ── -writes_manifest 'cxxflags = ["-fno-rtti"]' -rm -rf target -if "$MCPP" build > r2.log 2>&1; then - echo "FAIL: -fno-rtti in cxxflags + import std should be refused"; cat r2.log; exit 1 -fi - -# ── 3. the effective flag set, not one table ──────────────────────────────── -# -# The same flag arrives from a profile and from a `[target...]` block, and both -# reach the compile line while neither reaches the prebuild. Reading only -# `[build] cxxflags` would be silent here. -printf '[package]\nname = "dia"\nversion = "0.1.0"\nstandard = "c++23"\n\n[profile.dev]\ncxxflags = ["-fno-exceptions"]\n' > mcpp.toml -rm -rf target -if "$MCPP" build > r3.log 2>&1; then - echo "FAIL: a dialect flag in [profile.dev] cxxflags should be refused too" - cat r3.log; exit 1 -fi - -# ── 4. GREEN: the correct spelling builds ─────────────────────────────────── -writes_manifest 'dialect_cxxflags = ["-fno-exceptions"]' -rm -rf target -"$MCPP" build > g1.log 2>&1 || { - echo "FAIL: dialect_cxxflags is the documented fix and must build" - cat g1.log; exit 1 -} - -# ── 5. GREEN: an AUTO-PROMOTED dialect flag in cxxflags is silent ─────────── -# -# `-D_GLIBCXX_USE_CXX11_ABI=` is already promoted into the prebuild by -# `dialect_flags()`, so it does reach the BMI. A check keyed on "is this flag -# dialect-class" instead of "did it reach the prebuild" would refuse here. -writes_manifest 'cxxflags = ["-D_GLIBCXX_USE_CXX11_ABI=0"]' -rm -rf target -"$MCPP" build > g2.log 2>&1 || { - echo "FAIL: an auto-promoted dialect flag must still build" - cat g2.log; exit 1 -} - -# ── 6. GREEN: no `import std` in the graph, so there is nothing to disagree ─ -# -# THE DENOMINATOR. -no_import_std -writes_manifest 'cxxflags = ["-fno-exceptions"]' -rm -rf target -"$MCPP" build > g3.log 2>&1 || { - echo "FAIL: without import std, -fno-exceptions is an ordinary per-unit flag" - cat g3.log; exit 1 -} - -echo "PASS: 322_dialect_flag_refusal" diff --git a/tests/e2e/323_post_link_record_durability.sh b/tests/e2e/323_post_link_record_durability.sh deleted file mode 100755 index 189b5e9f1..000000000 --- a/tests/e2e/323_post_link_record_durability.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -# requires: elf -# 323_post_link_record_durability.sh — the post-link verdicts survive an -# invocation, and survive the OTHER command (#529). -# -# Both post-link ELF passes were written with a read-back: an artifact whose -# stat did not move keeps the verdict already on file. It never fired across -# invocations, because the file it read was `resolution.json` and -# `prepare_build` rewrites that from an empty object at the start of every run. -# Measured before the fix: 1.36 s of a 1.94 s warm `mcpp test`. -# -# WHY A SINGLE-COMMAND TEST CANNOT SEE ANY OF THIS. Within one invocation -# the read-back already worked — the second ninja drive paid nothing. And -# repeating one command hides the second half: `mcpp build` and `mcpp test` -# share an output directory with DIFFERENT link-unit sets, and pruning the -# record against the current plan makes each delete the other's verdicts. So -# the assertions below are across processes AND across commands. -# -# The timing is deliberately not asserted — it is a machine property. What is -# asserted is the record: its CONTENT is what makes the memo correct, and a -# memo that is correct is what makes the build fast. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p src tests -printf '[package]\nname = "dur"\nversion = "0.1.0"\nstandard = "c++23"\n' > mcpp.toml -printf 'int main(){ return 0; }\n' > src/main.cpp -for t in one two; do printf 'int main(){ return 0; }\n' > "tests/t_$t.cpp"; done - -"$MCPP" build > b1.log 2>&1 || { cat b1.log; exit 1; } -OUT=$(dirname "$(find target -name build.ninja -not -path '*/.build-mcpp/*' | head -1)") -[ -n "$OUT" ] || { echo "FAIL: no build.ninja"; exit 1; } - -REC="$OUT/.mcpp-runtime-verdicts.json" -RES="$OUT/resolution.json" - -paths_in() { # $1 = file, $2 = record name - sed -n "/\"$2\"/,/^ *\]/p" "$1" 2>/dev/null \ - | grep -oE 'bin/[A-Za-z_0-9]+' | sort -u | tr '\n' ' ' -} - -# ── the record must survive `prepare_build`, which is the whole defect ────── -[ -f "$REC" ] || { echo "FAIL: no durable record was written"; exit 1; } -grep -q 'post_link_key' "$REC" || { - echo "FAIL: the record carries no invalidation key — a stale SubOS farm or" - echo " a flipped MCPP_ALLOW_HOST_LIBS would be answered from cache" - exit 1 -} - -after_build=$(paths_in "$REC" loader_tags) -case "$after_build" in - *bin/dur*) ;; - *) echo "FAIL: the package binary is not in the record after a build" - echo " got: $after_build"; exit 1 ;; -esac - -# ── `mcpp test` must ADD to it, not replace it ────────────────────────────── -# -# THE ASSERTION WITH A DENOMINATOR. `mcpp test`'s plan contains the test -# binaries and NOT `bin/dur`; a record pruned against the current plan would -# come back holding only the tests, and "the pass was skipped" would then be -# satisfied by "the pass had nothing to look at". -"$MCPP" test > t1.log 2>&1 || { cat t1.log; exit 1; } -after_test=$(paths_in "$REC" loader_tags) -for want in bin/dur bin/t_one bin/t_two; do - case "$after_test" in - *"$want"*) ;; - *) echo "FAIL: after 'mcpp test' the record lost or never gained $want" - echo " got: $after_test"; exit 1 ;; - esac -done - -sp_after_test=$(paths_in "$REC" symbol_provision) -for want in bin/dur bin/t_one bin/t_two; do - case "$sp_after_test" in - *"$want"*) ;; - *) echo "FAIL: symbol_provision record is missing $want" - echo " got: $sp_after_test"; exit 1 ;; - esac -done - -# ── and a following `mcpp build` must not delete the test verdicts ────────── -"$MCPP" build > b2.log 2>&1 || { cat b2.log; exit 1; } -after_build2=$(paths_in "$REC" loader_tags) -for want in bin/dur bin/t_one bin/t_two; do - case "$after_build2" in - *"$want"*) ;; - *) echo "FAIL: 'mcpp build' pruned the test binaries out of the record." - echo " The two commands share one output directory; pruning" - echo " against the current plan makes each erase the other." - echo " got: $after_build2"; exit 1 ;; - esac -done - -# ── resolution.json keeps publishing both, on every build ─────────────────── -# -# The sidecar is authoritative; `resolution.json` is the documented place to -# read the verdicts (docs/05, `mcpp why runtime`, e2e 214 and 307). Publishing -# only when the content changed left it empty on every warm build — the record -# correct and the place people look for it empty. -for name in loader_tags symbol_provision; do - grep -q "\"$name\"" "$RES" || { - echo "FAIL: resolution.json does not publish $name after a warm build" - exit 1 - } - published=$(paths_in "$RES" "$name") - case "$published" in - *bin/dur*) ;; - *) echo "FAIL: resolution.json's $name is empty on a warm build" - echo " got: '$published'"; exit 1 ;; - esac -done - -# ── an artifact that leaves the DISK leaves the record ────────────────────── -# -# The pruning still has to happen, or the file grows forever. What changed is -# the predicate: gone from disk, not absent from this command's plan. -rm -f "$OUT/bin/t_two" -"$MCPP" build > b3.log 2>&1 || { cat b3.log; exit 1; } -final=$(paths_in "$REC" loader_tags) -case "$final" in - *bin/t_two*) echo "FAIL: a deleted artifact stayed in the record" - echo " got: $final"; exit 1 ;; -esac -case "$final" in - *bin/dur*) ;; - *) echo "FAIL: pruning removed more than the deleted artifact" - echo " got: $final"; exit 1 ;; -esac - -echo "PASS: 323_post_link_record_durability" diff --git a/tests/e2e/324_fast_path_sees_path_deps.sh b/tests/e2e/324_fast_path_sees_path_deps.sh deleted file mode 100755 index fd32100fa..000000000 --- a/tests/e2e/324_fast_path_sees_path_deps.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 324_fast_path_sees_path_deps.sh — a new file in a `path` dependency is not -# invisible to the fast path. -# -# The staleness sweep was rooted at the project being built, so a source tree -# reached through `path = "../dep"` was outside all of it. An EDIT to an -# existing file was still caught, but not by the sweep: ninja rebuilt the object -# and the relink made the fast path abandon afterwards. A NEW FILE has no edge -# at all — nothing in the graph mentions it and every recorded timestamp is -# unmoved — so the fast path replayed a build.ninja that predated it. Measured -# before the fix: -# -# $ mcpp build -# Finished dev in 0.00s # and the module was never compiled -# -# This is #359's shape ("a glob input changes without any existing file's mtime -# changing") in a directory that fix did not reach. It matters most where it is -# hardest to notice: workspace members depend on each other by `path`. -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p dep/src app/src -printf '[package]\nname = "dep"\nversion = "0.1.0"\nstandard = "c++23"\n[targets.dep]\nkind = "lib"\n' > dep/mcpp.toml -printf 'export module dep;\nexport int one(){ return 1; }\n' > dep/src/dep.cppm -printf '[package]\nname = "app"\nversion = "0.1.0"\nstandard = "c++23"\n[dependencies]\ndep = { path = "../dep" }\n' > app/mcpp.toml -printf 'import dep;\nint main(){ return one()==1 ? 0 : 1; }\n' > app/src/main.cpp - -cd app -"$MCPP" build > b1.log 2>&1 || { cat b1.log; exit 1; } -# Second build records the dependency source roots and arms the fast path. -"$MCPP" build > b2.log 2>&1 || { cat b2.log; exit 1; } - -# The fast path must actually be in play, or the rest of this test is asserting -# Whether the last build planned: the full path writes build.ninja (or moves -# its time when the text is unchanged), and the fast path replays it untouched. -# A `Compiling` line no longer tells the two apart: it states that a package's -# steps ran, on either path (build progress design 2026-09-29). -planned_since() { find target -name build.ninja -not -path '*/.build-mcpp/*' -newer "$1" 2>/dev/null | grep -q .; } -# on a path it never took. -touch .before-warm; sleep 1 -"$MCPP" build > warm.log 2>&1 || { cat warm.log; exit 1; } -if planned_since .before-warm; then - echo "NOTE: the fast path was not taken on a warm build; the assertion below" - echo " still holds but tests less than it means to." - cat warm.log -fi - -count_gcm() { find . ../dep -name '*.gcm' 2>/dev/null | wc -l | tr -d ' '; } -before=$(count_gcm) - -# ── the case that was invisible ───────────────────────────────────────────── -sleep 1 -printf 'export module dep.extra;\nexport int two(){ return 2; }\n' > ../dep/src/extra.cppm -"$MCPP" build > add.log 2>&1 || { cat add.log; exit 1; } - -if ! find . ../dep -name 'dep.extra.gcm' | grep -q .; then - echo "FAIL: a new source file in a path dependency was never compiled," - echo " and the build reported success:" - cat add.log - exit 1 -fi - -# THE DENOMINATOR. "It rebuilt" is satisfied by a full rebuild triggered for an -# unrelated reason; the count going up by exactly one says the new unit is what -# was added. -after=$(count_gcm) -[ "$after" -gt "$before" ] || { - echo "FAIL: the module-interface count did not grow ($before -> $after)" - exit 1 -} - -# ── and the dependency's MANIFEST is watched too ──────────────────────────── -# -# A member that gains a target or a glob changes what the graph should be, and -# none of that is visible from its source files' timestamps. -"$MCPP" build > steady.log 2>&1 -sleep 1 -printf '\n[build]\ncxxflags = ["-DDEP_TOUCHED=1"]\n' >> ../dep/mcpp.toml -"$MCPP" build > toml.log 2>&1 || { cat toml.log; exit 1; } -grep -q "DEP_TOUCHED" compile_commands.json || { - echo "FAIL: editing the path dependency's mcpp.toml did not re-plan" - cat toml.log - exit 1 -} - -echo "PASS: 324_fast_path_sees_path_deps" diff --git a/tests/e2e/325_system_toolchain_refused.sh b/tests/e2e/325_system_toolchain_refused.sh deleted file mode 100755 index 912de19a2..000000000 --- a/tests/e2e/325_system_toolchain_refused.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 325_system_toolchain_refused.sh — mcpp builds only with toolchains it -# manages (#527 Bug 1). -# -# THE POLICY, AND WHY IT IS NOT UNIFORM ACROSS AXES. -# -# LIBRARIES are the program's business. A project may link a host library or -# its own `.so`; mcpp says what that costs and does not refuse, because the -# developer owns the artifact and guarantees it. -# -# THE TOOLCHAIN is mcpp's own contract. Everything mcpp promises — that -# `import std` is available, that the runtime closure is computable, that two -# machines and CI produce the same build — is a statement about a compiler -# mcpp resolved and can identify. A compiler picked off PATH makes every one -# of those unverifiable, so `[toolchain] … = "system"` is refused. -# -# `msvc@system` is the single exception and is a different spelling: it names a -# FAMILY whose installation mcpp locates, on the one platform where the -# compiler cannot be redistributed. -# -# What this replaces: the same configuration used to die as -# `posix_spawnp('') failed (error 2)` as soon as the project had a build.mcpp, -# because the resolved compiler path sat in `tc->binaryPath` and was never -# handed to the build.mcpp closure. A refusal that arrives as a crash three -# layers down is not a policy, it is a bug wearing one. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" -mkdir -p src -printf '#include \nint main(){ std::printf("hi\\n"); return 0; }\n' > src/main.cpp - -manifest() { # $1 = extra body - printf '[package]\nname = "systc"\nversion = "0.1.0"\nstandard = "c++23"\n%s' "$1" > mcpp.toml -} - -expect_refusal() { # $1 = log file, $2 = what was being built - grep -q 'toolchains it manages' "$1" || { - echo "FAIL: $2 was not refused on the toolchain policy's own terms" - cat "$1"; exit 1; } - # A refusal has to be actionable, or it is only a complaint. Three things - # make it so: what to write instead, where to see the choices, and the one - # exception — a Windows user reading a blanket "system is not supported" - # would otherwise conclude `msvc@system` had been removed too. - grep -q 'gcc@16.1.0' "$1" || { - echo "FAIL: the refusal does not show what to write instead"; cat "$1"; exit 1; } - grep -q 'mcpp toolchain' "$1" || { - echo "FAIL: the refusal does not name the command that lists the choices" - cat "$1"; exit 1; } - grep -q 'msvc@system' "$1" || { - echo "FAIL: the refusal does not name the one supported exception" - cat "$1"; exit 1; } - # And it must not read as a ban on host libraries, which are a different - # axis with the opposite answer. - grep -qi 'librar' "$1" || { - echo "FAIL: the refusal does not distinguish the library axis" - cat "$1"; exit 1; } -} - -# ── refused without a build.mcpp ──────────────────────────────────────────── -manifest ' -[toolchain] -linux = "system" -macos = "system" -windows = "system" -' -rm -rf target -if "$MCPP" build > plain.log 2>&1; then - echo "FAIL: [toolchain] system built; mcpp builds only with managed toolchains" - cat plain.log; exit 1 -fi -expect_refusal plain.log "a plain project" - -# ── refused WITH a build.mcpp, and not as a spawn failure ─────────────────── -# -# THE ORIGINAL DEFECT. The refusal has to reach the user before anything tries -# to compile the build program, or they get exit 127 and an empty program name. -printf '#include \nint main(){ return 0; }\n' > build.mcpp -rm -rf target -if "$MCPP" build > bp.log 2>&1; then - echo "FAIL: [toolchain] system with a build.mcpp built" - cat bp.log; exit 1 -fi -expect_refusal bp.log "a project with a build.mcpp" -grep -q "posix_spawnp" bp.log && { - echo "FAIL: still spawning an empty compiler path instead of refusing" - cat bp.log; exit 1; } -grep -qE "^ *build\.mcpp " bp.log && { - echo "FAIL: the refusal arrived after the build program had started" - cat bp.log; exit 1; } - -# ── the same via the environment side channel ─────────────────────────────── -rm -f build.mcpp -manifest '' -rm -rf target -if MCPP_TOOLCHAIN=system "$MCPP" build > env.log 2>&1; then - echo "FAIL: MCPP_TOOLCHAIN=system built; the policy must not have two answers" - cat env.log; exit 1 -fi -expect_refusal env.log "MCPP_TOOLCHAIN=system" - -# ── THE DENOMINATOR: a managed toolchain still builds ─────────────────────── -# -# Without this, "everything is refused" satisfies every assertion above. -manifest '' -rm -rf target -"$MCPP" build > managed.log 2>&1 || { - echo "FAIL: a project with no [toolchain] at all must still build" - cat managed.log; exit 1 -} -grep -q 'toolchains it manages' managed.log && { - echo "FAIL: the refusal fired for a project that did not ask for system" - cat managed.log; exit 1; } - -echo "PASS: 325_system_toolchain_refused" diff --git a/tests/e2e/326_dependency_standard_floor.sh b/tests/e2e/326_dependency_standard_floor.sh deleted file mode 100755 index ba317a4b9..000000000 --- a/tests/e2e/326_dependency_standard_floor.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc -# 326_dependency_standard_floor.sh — a dependency that DECLARED a higher -# standard than the graph is built at (#527 RFC 3). -# -# A C++ module graph has one standard: cross-level BMIs are hard incompatible, -# so the root's level is imposed graph-wide and a dependency's `standard` is -# parsed and then discarded. That is correct. The defect was the silence — a -# package declaring c++26 because it needs c++26 is compiled at whatever the -# consumer says, and fails, if it fails at all, inside a translation unit the -# user does not own. -# -# SCOPED TO AUTHOR-OWNED MANIFESTS, AND THE NEGATIVE IS THE HARD HALF. -# `standardDeclared` says the key was present, not that a human meant it: every -# index descriptor with an mcpp segment declares `language` (measured: 782 of -# 782 in the local registry, 756 of those 774 packages being C libraries with -# `import_std = false` carrying a boilerplate "c++23"). A check that trusted -# declaredness everywhere would fire against the whole index for any root at -# c++20 — which is exactly why the cpp20 design doc deferred it. -# -# DEGRADED, NOT AN ERROR: a package declaring c++26 compiles fine at c++23 -# whenever it does not use a C++26 construct, and that is a green build today. -set -e - -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p lib/src app/src -printf '[package]\nname = "flib"\nversion = "0.1.0"\nstandard = "c++26"\n[targets.flib]\nkind = "lib"\n' > lib/mcpp.toml -printf 'export module flib;\nexport int f(){ return 1; }\n' > lib/src/flib.cppm -printf '[package]\nname = "fapp"\nversion = "0.1.0"\nstandard = "c++23"\n[dependencies]\nflib = { path = "../lib" }\n' > app/mcpp.toml -printf 'import flib;\nint main(){ return f()==1 ? 0 : 1; }\n' > app/src/main.cpp - -cd app - -# ── it fires, and the build still succeeds ────────────────────────────────── -"$MCPP" build > warn.log 2>&1 || { - echo "FAIL: the floor check must be degraded, not an error" - cat warn.log; exit 1 -} -grep -q 'flib' warn.log && grep -q 'c++26' warn.log || { - echo "FAIL: the diagnostic does not name the package and the level" - cat warn.log; exit 1 -} -grep -q 'workspace.package' warn.log || { - echo "FAIL: the hint does not name the one-place fix" - cat warn.log; exit 1 -} - -# ── --strict promotes it, in the one place that policy lives ──────────────── -rm -rf target -if "$MCPP" build --strict > strict.log 2>&1; then - echo "FAIL: --strict did not promote the degradation" - cat strict.log; exit 1 -fi - -# ── SILENT when the dependency did not declare one ────────────────────────── -# -# THE DENOMINATOR for "declared". Same graph, same levels, the key removed. -printf '[package]\nname = "flib"\nversion = "0.1.0"\n[targets.flib]\nkind = "lib"\n' > ../lib/mcpp.toml -rm -rf target -"$MCPP" build > silent.log 2>&1 || { cat silent.log; exit 1; } -grep -q 'declares standard' silent.log && { - echo "FAIL: fired for a dependency that declared nothing" - cat silent.log; exit 1 -} - -# ── SILENT when the dependency declares a level at or below the graph's ───── -printf '[package]\nname = "flib"\nversion = "0.1.0"\nstandard = "c++23"\n[targets.flib]\nkind = "lib"\n' > ../lib/mcpp.toml -rm -rf target -"$MCPP" build > equal.log 2>&1 || { cat equal.log; exit 1; } -grep -q 'declares standard' equal.log && { - echo "FAIL: fired for a dependency at the graph's own level" - cat equal.log; exit 1 -} - -# ── and raising the consumer silences it, which is what the hint promises ─── -printf '[package]\nname = "flib"\nversion = "0.1.0"\nstandard = "c++26"\n[targets.flib]\nkind = "lib"\n' > ../lib/mcpp.toml -printf '[package]\nname = "fapp"\nversion = "0.1.0"\nstandard = "c++26"\n[dependencies]\nflib = { path = "../lib" }\n' > mcpp.toml -rm -rf target -"$MCPP" build > raised.log 2>&1 || { cat raised.log; exit 1; } -grep -q 'declares standard' raised.log && { - echo "FAIL: the documented fix does not silence the diagnostic" - cat raised.log; exit 1 -} - -echo "PASS: 326_dependency_standard_floor" diff --git a/tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh b/tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh deleted file mode 100755 index 25edb92d6..000000000 --- a/tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# 327_declared_xlings_deps_report_their_own_failure.sh — #540. -# -# #531 made `[xlings] deps` provision on first build. The call site tested -# `if (!r)` on `xlings::call`, which returns `expected` and -# is in the VALUE state whenever the child ran at all — a capability's own -# status arrives INSIDE CallResult, because xlings exits 0 once it has spoken -# the NDJSON protocol. So every failure xlings can report was read as success: -# -# $ mcpp build # deps = ["definitely-not-a-real-package"] -# Provisioning [xlings] deps (definitely-not-a-real-package) -# Finished dev [unoptimized + debuginfo] in 0.12s -# $ cat .mcpp/.xlings-deps.stamp -# definitely-not-a-real-package ← recorded as DONE -# -# #531's own comment says the defect it fixed was "the declaration looked -# accepted and did nothing — the worst shape a config key can have". Unread, -# the fix reproduced that shape and the stamp made it permanent. -# -# Two properties, and the second is the one a single run cannot show: -# (1) a provisioning that fails, fails the build; -# (2) it is NOT recorded, so the NEXT build tries again instead of -# reporting success. -# -# Plus the auto-install gate `[toolchain]` has always had and this path did -# not — `MCPP_OFFLINE` and `MCPP_NO_AUTO_INSTALL`, naming the knob that fired. -# Those two legs need no network and no index, which is why they run first. -# -# requires: unix-shell -set -e - -MCPP="${MCPP:-mcpp}" -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -mkdir -p proj/src -cat > proj/mcpp.toml <<'EOF' -[package] -name = "provproj" -version = "0.1.0" - -[xlings] -deps = ["definitely-not-a-real-package-540"] -EOF -echo 'int main() { return 0; }' > proj/src/main.cpp -cd proj - -# ── (1) MCPP_NO_AUTO_INSTALL: refuse, and name THAT knob ──────────────────── -# The knob that fired, not a fixed one. Telling someone who exported -# MCPP_NO_AUTO_INSTALL to drop --offline sends them looking for a variable they -# never set; the toolchain gate solved this years ago and this path is copying -# its shape, so the test holds the shape too. -set +e -MCPP_NO_AUTO_INSTALL=1 "$MCPP" build > noauto.log 2>&1 -rc=$? -set -e -[ "$rc" -ne 0 ] || { cat noauto.log; echo "FAIL: MCPP_NO_AUTO_INSTALL did not refuse"; exit 1; } -grep -q "MCPP_NO_AUTO_INSTALL" noauto.log || { - cat noauto.log; echo "FAIL: the refusal does not name MCPP_NO_AUTO_INSTALL"; exit 1; } -grep -q "definitely-not-a-real-package-540" noauto.log || { - cat noauto.log; echo "FAIL: the refusal does not name the package to install"; exit 1; } -grep -q "Provisioning" noauto.log && { - cat noauto.log; echo "FAIL: it announced provisioning and then refused"; exit 1; } -echo " ok MCPP_NO_AUTO_INSTALL refuses, names the knob and the package" - -# ── (2) MCPP_OFFLINE: same refusal, the other knob ────────────────────────── -set +e -MCPP_OFFLINE=1 "$MCPP" build > offline.log 2>&1 -rc=$? -set -e -[ "$rc" -ne 0 ] || { cat offline.log; echo "FAIL: MCPP_OFFLINE did not refuse"; exit 1; } -grep -q "MCPP_OFFLINE\|--offline" offline.log || { - cat offline.log; echo "FAIL: the refusal does not name the offline knob"; exit 1; } -echo " ok MCPP_OFFLINE refuses and names the offline knob" - -# ── (3) A declared package that cannot be provisioned fails the build ─────── -# …and, the half a single run cannot show, is not recorded as done. -# -# THE DENOMINATOR IS TWO INVOCATIONS. The stamp is what made the original -# defect permanent, and a stamp written for a failure is invisible in the run -# that wrote it — the build that mattered already reported success. Only the -# SECOND build distinguishes "failed" from "failed and remembered as done". -# -# Asserted on behaviour rather than on the stamp's path: where the record lives -# is an implementation detail that has already moved once (project → registry), -# and a test keyed to the path would have passed through the move while the -# property it exists for was broken. -set +e -"$MCPP" build > first.log 2>&1 -rc1=$? -"$MCPP" build > second.log 2>&1 -rc2=$? -set -e -[ "$rc1" -ne 0 ] || { cat first.log; echo "FAIL: an unprovisionable [xlings] dep built successfully"; exit 1; } -[ "$rc2" -ne 0 ] || { - cat second.log - echo "FAIL: the SECOND build succeeded — the failed provisioning was recorded as done" - exit 1; } -for f in first.log second.log; do - grep -q "definitely-not-a-real-package-540" "$f" || { - cat "$f"; echo "FAIL: $f does not name the package that could not be provisioned"; exit 1; } -done -echo " ok a failed provisioning fails the build, twice — it is not stamped as done" - -# ── (4) relocating the record must not refuse a build that worked before ──── -# The stamp moved from `/.mcpp/.xlings-deps.stamp` into the registry, -# because it records a GLOBAL effect and lived in the project. Every project -# that had already provisioned therefore reads as un-provisioned on its first -# build after upgrading. Online that costs one round trip and self-corrects; -# with auto-install off, leg (1) above would refuse a build that worked -# yesterday, for packages that are in fact installed. -# -# THE LEGACY STAMP IS EVIDENCE ONLY HERE. The release that wrote it did not -# read the provisioning result, so it means "attempted", not "succeeded" — -# trusting it anywhere else would carry that defect across the upgrade that -# fixes it. This leg pins the one place it is allowed to count. -cd "$TMP" -mkdir -p legacy/src/.keep && rm -rf legacy/src/.keep -mkdir -p legacy/src legacy/.mcpp -cat > legacy/mcpp.toml <<'EOF' -[package] -name = "legacyproj" -version = "0.1.0" - -[xlings] -deps = ["definitely-not-a-real-package-540"] -EOF -echo 'int main() { return 0; }' > legacy/src/main.cpp -printf 'definitely-not-a-real-package-540\n' > legacy/.mcpp/.xlings-deps.stamp -cd legacy -set +e -MCPP_NO_AUTO_INSTALL=1 "$MCPP" build > legacy.log 2>&1 -rc=$? -set -e -[ "$rc" -eq 0 ] || { - cat legacy.log - echo "FAIL: moving the stamp refused a build that a pre-upgrade mcpp allowed" - exit 1; } -echo " ok a pre-upgrade stamp keeps an auto-install-off build working" - -# …and it is NOT promoted into the registry: nothing verified anything, so the -# next build with the network available must still check. -cd "$TMP/proj" -set +e -MCPP_NO_AUTO_INSTALL=1 "$MCPP" build > again.log 2>&1 -rc=$? -set -e -[ "$rc" -ne 0 ] || { - cat again.log - echo "FAIL: a project with no legacy stamp was let through by another project's" - exit 1; } -echo " ok the legacy stamp is not promoted, and does not leak between projects" - -echo "PASS: 327 declared [xlings] deps report their own failure" diff --git a/tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh b/tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh deleted file mode 100755 index 00fe257af..000000000 --- a/tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env bash -# 328_layer_predicates_apply_and_unknown_ones_say_so.sh — #540 / #494. -# -# docs/14 documents a package adapting to the C library it was built over: -# -# [target.'cfg(c-abi = "musl")'.build] -# std-module-flags = ["-D_GNU_SOURCE"] # wrong for picolibc -# -# and #494 moved those keys onto BuildInputs FOR this, its member comment -# saying membership "is what makes the cfg axis carry it". Nothing evaluated -# the predicate: `cfgpred::Ctx` was built from the target triple alone, so -# `match_kv` knew only os/arch/family/env and every layer section was dropped — -# in SILENCE, because a predicate that answers false and a predicate that was -# never understood produced the same nothing. Measured on 2026.8.30.2: a -# `cfg(c-abi = "glibc")` section contributed no define and emitted no warning. -# -# Three properties: -# (1) a layer predicate that matches APPLIES; -# (2) a layer predicate that does not match does NOT apply — otherwise (1) -# is satisfied by a pass that fires unconditionally; -# (3) a key outside the vocabulary is REPORTED, and is an error under -# --strict. -# -# requires: gcc unix-shell -set -e -# The project-root copy of the compile database is opt-in since -# 2026.10.26.1 ([build] compile_commands); this test reads it. -export MCPP_COMPILE_COMMANDS=compile_commands.json - -MCPP="${MCPP:-mcpp}" -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -# The resolved c-abi for this build, read from mcpp's own report rather than -# assumed. The report prints layers whose origin is not the compiler payload -# by default, so the zero-config case needs MCPP_VERBOSE to list all five. -mkdir -p probe/src -cat > probe/mcpp.toml <<'EOF' -[package] -name = "probe" -version = "0.1.0" -EOF -echo 'int main() { return 0; }' > probe/src/main.cpp -( cd probe && MCPP_VERBOSE=1 "$MCPP" build > report.log 2>&1 ) || { - cat probe/report.log; echo "FAIL: the probe build errored"; exit 1; } -CABI=$(sed -n 's/.*c-abi *\([A-Za-z0-9_+-]*\).*/\1/p' probe/report.log | head -1) -[ -n "$CABI" ] || { cat probe/report.log; echo "FAIL: could not read the resolved c-abi"; exit 1; } -echo " .. this host resolves c-abi = '$CABI'" - -# A NAME NO LAYER CAN TAKE, for the negative leg. Deriving it (rather than -# hardcoding "musl") keeps the leg meaningful on a musl host, where a hardcoded -# counter-example would be the TRUE case and the test would assert nothing. -NOTCABI="not-${CABI}" - -# ── (1) + (2) a matching layer predicate applies; a non-matching one does not ── -# -# THE TRIPLE HALF OF THE COMBINED PREDICATE IS any(unix, windows), NOT unix. -# The point of that leg is that a triple key and a layer key COMBINE, so its -# triple half has to be true everywhere this test runs. Written as bare unix it -# was true on Linux and macOS and false on Windows, where all(...) then -# correctly evaluated false and the fixture's #error fired. CI caught it; the -# feature was right and the test was not. -# -# AND THESE NOTES LIVE OUTSIDE THE HEREDOCS. The fixtures below interpolate -# $CABI, so their heredocs are unquoted — which makes backticks in a comment -# command substitution. The first version of this note sat inside one and the -# suite printed `syntax error: unexpected end of file` while still passing. -mkdir -p layers/src -cat > layers/mcpp.toml < layers/src/main.cpp <<'EOF' -#ifndef PROBE_MATCHED -#error "a matching cfg(c-abi = ...) section did not apply" -#endif -#ifdef PROBE_MUST_NOT_APPLY -#error "a NON-matching cfg(c-abi = ...) section applied" -#endif -#ifndef PROBE_COMBINED -#error "cfg(all(unix, c-abi = ...)) did not apply" -#endif -int main() { return 0; } -EOF -( cd layers && "$MCPP" build > b.log 2>&1 ) || { - cat layers/b.log; echo "FAIL: layer predicates did not behave"; exit 1; } -echo " ok a matching layer predicate applies, a non-matching one does not" - -# EXACTLY ONCE, NOT AT LEAST ONCE. The pass that evaluates layer predicates -# runs AFTER the triple-only merge, and `append()` is additive — so a predicate -# with a triple leg (`any(unix, c-abi = ...)`) would be matched by both passes -# and contribute twice. Counting the flag is the only way to see that; a -# preprocessor check cannot tell one -D from two. -mkdir -p once/src -cat > once/mcpp.toml < once/src/main.cpp -( cd once && "$MCPP" build > b.log 2>&1 ) || { - cat once/b.log; echo "FAIL: the mixed-predicate build errored"; exit 1; } -# Both spellings: a Windows self-host may drive MSVC, which takes `/D`. -n=$(grep -oE '[-/]DPROBE_ONCE=1' once/compile_commands.json | wc -l | tr -d ' ') -[ "$n" -eq 1 ] || { - echo "FAIL: a predicate with both a triple leg and a layer leg contributed $n times, expected 1" - grep -o '\-DPROBE_ONCE=1' once/compile_commands.json - exit 1; } -echo " ok a predicate naming both a triple key and a layer applies exactly once" - -# ── (2b) …and it reaches a DEPENDENCY, which is the motivating case ───────── -# -# EVERY LEG ABOVE IS SATISFIED BY A PASS THAT ONLY PATCHES packages[0]. -# docs/14 writes this feature for "a package supplying a layer [that] frequently -# supports several implementations of the layer beneath it" — a LIBRARY, reached -# as someone's dependency. The build.mcpp tail that shares this pass's window -# patches the root alone, correctly for its own purpose, so copying that shape -# would have left the one package this feature exists for unserved and every -# root-only assertion still green. -mkdir -p dep/app/src dep/lib/src -cat > dep/lib/mcpp.toml < dep/lib/src/layerlib.cppm <<'EOF' -export module layerlib; -#ifndef LIB_MATCHED -#error "a DEPENDENCY's matching cfg(c-abi = ...) section did not apply" -#endif -#ifdef LIB_MUST_NOT_APPLY -#error "a DEPENDENCY's non-matching cfg(c-abi = ...) section applied" -#endif -export int lib_answer() { return 42; } -EOF -cat > dep/app/mcpp.toml <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -layerlib = { path = "../lib" } -EOF -cat > dep/app/src/main.cpp <<'EOF' -import layerlib; -int main() { return lib_answer() == 42 ? 0 : 1; } -EOF -( cd dep/app && "$MCPP" build > b.log 2>&1 ) || { - cat dep/app/b.log; echo "FAIL: layer predicates did not reach a dependency"; exit 1; } -echo " ok a layer predicate reaches a dependency, and its negative leg holds" - -# ── (3) an unknown key is reported, and --strict makes it an error ────────── -mkdir -p unknown/src -cat > unknown/mcpp.toml <<'EOF' -[package] -name = "unknown" -version = "0.1.0" - -[target.'cfg(no-such-key = "x")'.build] -defines = ["NEVER"] -EOF -echo 'int main() { return 0; }' > unknown/src/main.cpp -( cd unknown && "$MCPP" build > w.log 2>&1 ) || { - cat unknown/w.log; echo "FAIL: an unknown cfg key must warn, not fail"; exit 1; } -grep -q "no-such-key" unknown/w.log || { - cat unknown/w.log; echo "FAIL: the unknown cfg key was not named"; exit 1; } -# The message must list the vocabulary it checked against — built FROM that -# list, so it cannot drift from the check the way three other key lists did. -grep -q "c-abi" unknown/w.log || { - cat unknown/w.log; echo "FAIL: the message does not list the supported keys"; exit 1; } -echo " ok an unknown cfg key is named, with the vocabulary it was checked against" - -set +e -( cd unknown && "$MCPP" build --strict > s.log 2>&1 ) -rc=$? -set -e -[ "$rc" -ne 0 ] || { cat unknown/s.log; echo "FAIL: --strict did not turn the warning into an error"; exit 1; } -echo " ok --strict makes it an error" - -# ── (4) a layer predicate cannot select dependencies, and says so ─────────── -# -# A layer is resolved FROM the dependency graph, so a dependency chosen by one -# would decide the answer it is asking for — docs/14 states the limit. The limit -# is not the interesting part; being told about it is. By the time the layer -# pass runs, dependency resolution is over, so such a section would otherwise be -# dropped in exactly the silence this whole test exists to end. -# -# AND THE BUILD INPUTS UNDER THE SAME PREDICATE MUST STILL APPLY. A warning -# that quietly disabled the rest of the section would trade one silent drop for -# another. -mkdir -p depcond/src -cat > depcond/mcpp.toml < depcond/src/main.cpp <<'EOF' -#ifndef INPUTS_STILL_APPLY -#error "the warning about dependencies also suppressed the build inputs" -#endif -int main() { return 0; } -EOF -( cd depcond && "$MCPP" build > d.log 2>&1 ) || { - cat depcond/d.log; echo "FAIL: a layer-conditioned dependency must warn, not fail"; exit 1; } -grep -q "conditions dependencies on a target-side layer" depcond/d.log || { - cat depcond/d.log; echo "FAIL: no diagnostic for a layer-conditioned dependency"; exit 1; } -echo " ok a layer-conditioned dependency is reported, and the build inputs still apply" - -echo "PASS: 328 layer predicates apply, and unknown ones say so" diff --git a/tests/e2e/329_exit_codes_match_the_contract.sh b/tests/e2e/329_exit_codes_match_the_contract.sh deleted file mode 100755 index 666517eb3..000000000 --- a/tests/e2e/329_exit_codes_match_the_contract.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# 329_exit_codes_match_the_contract.sh — #540, SPEC-003. -# -# The machine-output chapter shipped an exit-code table carrying 0 / 2 / 70 / -# 127 — which is the usage/internal half of what the protocol design record -# (§R4) asked for. The runtime half was never written down, so `1` — the code -# `mcpp xpkg parse` returns for every descriptor it rejects — was undocumented, -# and an audit reading that table concluded the missing code was `4`. It is not: -# no enveloped command can return 4, because `self env --format json` is built -# specifically to avoid the `load_or_init` path that produces it. -# -# docs/specs/exit-codes.md is now the contract. This holds it. -# -# ASSERTS THE CODE, NOT "STDERR IS NON-EMPTY". A criterion whose "no" is also -# what a crash produces cannot tell the two apart — and the whole point of the -# table is that a client can distinguish a rejection from a broken mcpp. -# -# requires: unix-shell -set -e - -MCPP="${MCPP:-mcpp}" -TMP=$(mktemp -d) -trap "rm -rf $TMP" EXIT -cd "$TMP" - -run_rc() { # run_rc