diff --git a/.agents/docs/2026-11-08-ecosystem-885-supplement-design.md b/.agents/docs/2026-11-08-ecosystem-885-supplement-design.md new file mode 100644 index 000000000..09f35f186 --- /dev/null +++ b/.agents/docs/2026-11-08-ecosystem-885-supplement-design.md @@ -0,0 +1,242 @@ +--- +subject: ecosystem +status: active +--- + +# #885 收尾补充方案:目标角色、平台事实、系统接口与版本书写 + +**日期:** 2026-11-08 +**状态:** 第三版,待审查(Q1–Q4、Q6 已确认;Q5 改为"并存",见 S3) +**上游文档:** [2026-11-07-ecosystem-885-closeout-design.md](2026-11-07-ecosystem-885-closeout-design.md)(D1–D20) +**相关:** mcpp#903、mcpp#904、mcpp-language-server#80 + +本文记录 #885 收尾过程中出现的七项发现(F1–F7),给出补充决定(S1–S6),以多个视角审查这些决定(第 3 节),并列出归属、版本与待审查的问题。被考虑而未采用的方案见附录 A。 + +## 0. 决定摘要 + +| 编号 | 决定 | 归属 | +|---|---|---| +| S1 | openkal 与原生目标不分等级,分"发布形态"与"构建目标"两种角色 | mcpp 文档;mcpp tier 评估 | +| S2 | os 提供"编译期能否 + 运行时是否"的两层平台判断,以及只接受泛型 lambda 的 `on

` | mcpplibs.os | +| S3 | 平台模块在能回答的地方定义;映像中的平台事实由 os 经可移植通道实现,openkal 不变;`is_<平台>` 与两层判断并存,各有确切含义 | mcpplibs.os;mcppls 过渡日志 | +| S4 | 生态的源文件与测试不包含系统头文件;系统头文件只在 os 的平台实现中;本轮并入 #52、#5 | mcpp-plugins 0.21.0、mcpp-ide 1.2.0、mcpp | +| S5 | 依赖写在一张表中:身份键、精确版本、一种拼写;锁文件的 `targets` 只随所规划的配置变化 | 生态各仓库(已完成);mcpp#903、#904 | +| S6 | 原生引擎一律链接 zlib;darwin 的比值超限先由探针定位,上限不放宽 | mcppls-clangd 0.0.21 | +| S7 | mcpp 定义测试协议(用例的列出、选择、报告与上下文),测试框架以适配方式实现;`mcpp.core` 提供测试用的上下文注入 | mcpp 下一版本(提案,待审查) | + +## 1. 发现 + +### F1 三层对 openkal 目标的定位不一致 + +| 层 | 依据 | openkal 的位置 | +|---|---|---| +| mcpp 目标表 | docs/21:tier 表示产物经过构建与运行 | `x86_64-openkal`、`aarch64-openkal` 为 `preview`;主要原生行为 `verified` | +| 生态应用的发布 | D18:mcppls-clangd、mcppls、mcpp-ide 只发布 ok 映像,原生构建只在手动对比运行中出现 | 唯一发布形态 | +| mcpplibs.os 接口 | README R3:编译期平台常量为首要接口;清单:映像中不定义任何平台模块 | 次要形态 | + +实际发布的形态在 mcpp 中为 `preview`,在 os 中缺少平台模块。F2 是这一错位的直接后果。 + +### F2 映像程序静默失去平台事实 + +ok 映像的 `PLATFORM` 为 `openkal`,`is_windows`、`is_linux` 恒为 `false`。mcppls 的两处平台判断因此在全部发布产物中被丢弃: + +- `src/orchestrator/workspace.cpp:706`:`if constexpr (os::is_windows) … os::windows::on_dev_drive(…)`,即 0.0.14 E1 的 Dev Drive 提示; +- `modules/toolrun/src/toolrun.cpp:229`:`if constexpr (os::is_linux) … os::linux::translator()`,即 PRoot 识别。 + +编译器与测试均不报告这一丢弃。第二处不只影响日志:`toolrun::sandbox()` 决定 PRoot 下模块构建在进程内进行(`src/engine/clangd.cpp:1968`),映像在 PRoot 下因此按普通内核的方式使用 worker。原生构建的 mcpp 不受影响,其 10 处平台模块调用(`windows::code_page` ×4、`console_output_code_page`、`long_paths_enabled` ×2、`machine_registry_path`、`writable_fixed_drive`、`posix::set_creation_mask`)依赖编译期分派。 + +### F3 系统头文件 + +| 仓库 | 状态 | +|---|---| +| mcppls | 0 处(`253aff8` 删除 test_archive 中针对已修复 Windows 映像故障的 `open`/`fstat` 探针;`devtools check os-surface` 报告任何 `#include`) | +| mcpp-plugins | 全局模块片段中包含 ``(`dist/*.cppm`、`rules/*.cppm` 共 39 处,用于 `popen`)、``、``;#52 的 `docs/plugin-development.md` §4 规定"只包含 ISO C 之外的平台函数头文件" | +| mcpp-ide | 测试使用本地头文件 `support.hpp` 及 ``、``、``、`` | + +mcpplibs.os 0.3.1 已提供对应能力:`process::run`(含输出捕获与有界读取)、`library::open`、`spawn_options` 中的子进程环境。mcpplibs.mwrap.libc 只覆盖 ISO C,不含 POSIX。tests 下的 Qt、Android 头文件属于测试用的 C/C++ 使用方工程,不在此列。 + +### F4 依赖的书写与精确性 + +本轮将 mcppls、mcpp-ide、mcpp-plugins、mcppls-clangd 的依赖改为单表、不带引号的身份键与精确版本。mcpp 解析中,不带运算符的版本即精确地址(`try_merge_semver` 将其规范为 `=`),因此 `"0.3.1"` 与 `"=0.3.1"` 等价。mcpp 本体仍使用带引号的旧式键与按命名空间分立的子表,`os` 写作 `"=0.3.1"`(mcpp#904)。mcpplibs.os 自身以范围依赖 openkal 家族(`openkal = "^0.24.0"` 等),其精确性由平台版本组(D12)给出。 + +### F5 锁文件的 `targets` 随命令变化 + +`mcpp test --workspace --no-run` 删除未规划配置的目标,`mcpp test -p … --target …` 为原本无 `targets` 的条目写入目标。锁文件因此在不改变解析的命令下改写(mcpp#903)。 + +### F6 对比运行(mcppls-clangd run `42369457883`) + +- **win32-x64 分片交换失败(image → native)。** 原生 Windows 引擎读取映像写出的分片时报告 `Compressed string table, but zlib is unavailable` 并重建全部 12 个单元。`ci/scripts/configure.sh` 在 Windows 上未提供 zlib,LLVM 的 `LLVM_ENABLE_ZLIB` 在找不到 zlib 时关闭。0.0.21 CHANGELOG 中"原生 clangd 同样链接 zlib"一句对 win32 不成立。 +- **darwin-arm64 的比值超限。** 该测量有效(四对,一对在离散界外,政策允许一对)而未达限:冷启动 `ready_seconds` 的映像/原生中位比值为 1.204,上限 1.15。按 `ci/performance-policy.md`,有效而未达限的测量即为结果,不重复;其后进行的一次重跑违反此条,结果(冷启动 1.141,热启动 `ready_seconds` 1.177、`cpu_seconds` 1.244)只作诊断参考。0.0.20 的同一测量为冷启动 0.963、热启动 `cpu_seconds` 1.081。 + +### F7 复用答案时的编辑范围(mcppls#80,已修复) + +clangd 的 include 补全替换光标后编辑器自动补出的右引号。mcppls 复用早先答案的三条路径(同词完整答案、迟到答案、答案层)均把编辑终点改为光标,导致 `"foo.h""`。修复保留越过旧光标的部分(在光标后的行文本不变时),且答案层不回答 include 行。 + +## 2. 补充决定 + +### S1 目标的角色 + +openkal 与原生目标之间不设等级,按角色区分(Q1 已确认): + +- **发布形态:** 生态中的应用以 ok 映像作为唯一发布物,一份产物覆盖全部系统,测试在每个真实系统上运行映像。适用于只需文件、进程、网络与终端的程序:mcppls-clangd、mcppls、mcpp-ide 的后端。 +- **构建目标:** 原生目标(`*-linux-gnu`、`*-linux-musl`、`*-windows-msvc`、`aarch64-macos`、Android、iOS、`wasm32-emscripten`、`*-none-elf`)是完整支持的构建目标,mcpp 与 mcpplibs.os 对其提供与映像同等的保证。下列情形只能以原生目标构建: + - mcpp 本体:按系统发布原生二进制,以编译期分派调用 `os::windows::*`、`os::posix::*`; + - 库:mcpplibs.cmdline、tinyhttps、mcpplibs.os 本身,随使用者选择的目标编译进使用者的程序; + - 链接系统或厂商库的程序:Qt、Vulkan/Metal、CUDA/SYCL、Android 的 `native_activity`、iOS 应用(mcpp-plugins 的测试工程即此类); + - 映像不覆盖的平台:Android、iOS、wasm、裸机。 + +按具体系统构建时,os 按 `[target..build]` 只编译该系统的实现文件(`src/linux/*.cpp`、`src/posix/*.cpp`、`src/windows/*.cpp`),平台模块 `mcpplibs.os.<平台>` 只在其平台的构建中有定义;这一机制不因 S2、S3 改变。 + +mcpp 依 docs/21 的 tier 定义评估两行 openkal 的升级:两行已在四个系统上构建并运行,满足 `verified` 的条件。角色划分写入 mcpp docs/24(openkal)与 docs/35(平台)。 + +### S2 mcpplibs.os:两层平台判断 + +R5 依赖一条语言规则:被丢弃的语句(discarded statement)中出现的函数不构成 ODR 使用,因而 Linux 原生构建引用 `os::windows::*` 不要求其定义。普通的 `if` 不具备这一性质,即使条件是常量;映像中又只有运行时判断可用。os 因此提供两层判断: + +```cpp +// 本构建能否运行在 p 上:原生构建为 PLATFORM == p;映像为 p 属于 {linux, macos, windows} +constexpr bool can_run_on(platform p) noexcept; + +if constexpr (os::can_run_on(os::platform::windows)) // 原生的其他平台:整段丢弃,零开销 + if (os::host() == os::platform::windows) // 原生为常量,映像在加载时判断 + cacheOnDevDrive = os::windows::on_dev_drive(dir); +``` + +映像中,运行时判断是主要机制:`os::host()` 返回加载映像的系统,`os::conventions()`(可执行文件后缀、路径列表分隔符、名称是否大小写无关)与 `os::dirs` 已在运行时作答。两层判断使同一源码在原生构建与映像构建中都正确。 + +简写为 `os::on

(f)`,`f` 必须是泛型 lambda: + +```cpp +os::on([&](auto) { cacheOnDevDrive = os::windows::on_dev_drive(dir); }); +``` + +泛型 lambda 的函数体是模板,未实例化时不构成 ODR 使用;普通 lambda 的函数体不是被丢弃的语句,引用其他平台的符号属于"不合规、无须诊断",因此 `on

` 以约束拒绝非泛型的可调用对象。os 的文档给出常见写法(两层判断、`on

`、`conventions()`、`dirs`)及其适用场景;语法糖只以合乎标准 C++ 的库接口提供,不提供构建插件(Q4 已确认)。 + +### S3 mcpplibs.os:映像中的平台事实 + +- **R5 修订:** 平台模块的函数在能回答的地方定义——自身平台的原生构建,以及映像。其他平台的原生构建仍不定义,误用仍为链接错误。现有签名不变,mcpp 的调用不受影响。 +- **映像能做与不能做的:** 映像只经由 openkal 的 `kal_*` 接口与宿主交互。它不能调用 Win32、sysctl 等原生接口,也不能加载宿主的库(openkal SPEC 第 12 条;os 0.3.1 的 `src/openkal/library.cpp` 对 `library::open` 返回 `not_supported`);一个映像服务全部系统,因而没有编译期的平台分支。它能做的是运行时判断(`host()`)与经由可移植通道取得事实:读文件、读环境、启动宿主程序并解析其输出(`process::run`)。 +- **事实的三类与各自的实现:** + + | 类 | 函数 | 映像中的实现 | + |---|---|---| + | okrun 自身的事务 | `windows::code_page`、`console_output_code_page`、`long_paths_enabled`、`machine_registry_path`、`writable_fixed_drive` | 不提供(误用为链接错误)。代码页与长路径约束原生 Win32 程序;映像的输入输出全部经由 okrun,由 okrun 处理。后两者服务 mcpp 自身的安装与工具链定位,不属于映像程序 | + | 经由可移植通道可得 | `linux::translator()` | 读取 `/proc/self/status`、追踪者的 `/proc//comm` 与环境,复用原生实现的 `classify_translator` | + | | `macos::translated()` | 恒为 `false`,不启动进程:macOS 上运行映像的 okrun 只有 aarch64 版本(`x86_64-macos` 在目标表中为 `planned`),映像因此总以本机架构运行。日后有 x86-64 的 okrun 时,判断为"映像为 x86-64 且 `hw.optional.arm64` 为 1";不读 `sysctl.proc_translated`,它回答的是被启动的 sysctl 进程自己 | + | | `windows::elevated()` | `%SystemRoot%\System32\whoami.exe /groups` 的输出中含 SID `S-1-16-12288`(高完整性级别)。以 SID 而非组名判断,与系统语言无关;子进程继承 okrun 的令牌 | + | | `windows::on_dev_drive()` | 经由 `fsutil` 查询所在卷,返回值已是 `std::optional`。是否需要管理员权限、输出是否随语言变化,实施前实测;不能可靠回答时返回空 | + | | `posix::effective_user()` | `/usr/bin/id -u`(Linux 也可读 `/proc/self/status` 的 `Uid:`) | + | 只有原生接口能回答、无宿主程序可用 | — | 目前未见实例 | + + 这些实现位于 os 的 `src/openkal/` 下,由 os 清单的 `cfg(os = "openkal")` 选择,与原生实现并列。宿主程序以绝对路径启动(不经 PATH 搜索),以有界读取与超时运行;失败时返回空(`optional`)或保守值(`elevated()` 为 `false`)。结果在进程内缓存,每个会话只付一次进程启动的代价,且只在调用时发生。全部工作在 mcpplibs.os 内完成,openkal 不变(Q3)。 +- **`is_<平台>` 与两层判断并存(Q5)。** 二者回答不同的问题,都保留,都不标 deprecated: + - `is_windows`、`is_linux`、`is_macos`、`is_posix`:本构建是否为该平台的原生构建。编译期常量,映像中为 `false`。它是"只属于原生构建"的代码的正确写法,例如设置控制台代码页:映像的输入输出由 okrun 处理,这段代码在映像中本就不应执行。 + - `can_run_on(p)` 与 `host()`:本构建能否运行在、是否正运行在该平台上。一段代码要在映像中也按平台生效时用它们。 + + 第一版拟在映像构建中把 `is_<平台>` 标为 deprecated。审查发现这会误报上面那类合法用法,而 deprecated 警告只能以编译器的诊断指令(宏形式)逐处抑制,与项目规则相悖;因此不采用(附录 A.5)。F2 这类错误由两处防止:os 的文档按"原生专属 / 随平台生效"说明两种写法的选择;以映像为发布形态的应用在自身的检查中禁止 `is_<平台>`(mcppls 的 `devtools check os-surface`、mcpp-ide 的等价检查),因为它们的每一段平台代码都应在映像中生效。 + +mcppls 在 os 发布该版本后改用 S2 的写法,两项功能随之恢复;在此之前,映像在 Windows 与 Linux 上运行时于日志中说明不检测 Dev Drive 与 PRoot(mcppls 0.0.21 `e34cca0`,docs/50-troubleshooting.md),不再静默跳过。 + +### S4 系统接口的书写规则 + +生态中的源文件与测试不包含系统头文件:操作系统经由 mcpplibs.os,C 库经由 `std`(宏经由 mcpplibs.mwrap.libc)。系统头文件只出现在 os 自身按平台选择的实现文件中,这是 os 的边界。原始 POSIX 或 Win32 接口不作为 os 的模块提供;确有需要的项目以独立的 mwrap 包(例如 `mcpplibs.mwrap.posix`)承载,os 保持语义接口。 + +此规则对原生构建同样成立:插件在 Linux 上原生构建时,`os::process::run` 编译为 os 的 `src/posix/exec.cpp`,在 Windows 上编译为 `src/windows/exec.cpp`;插件源码不再随平台变化。 + +本轮并入 mcpp-plugins #52(0.21.0)与 mcpp-ide #5(1.2.0)(Q2)。 + +- **mcpp-plugins:** + - 七个模块(`dist/apk`、`rules/cuda`、`metal`、`slang`、`spirv`、`swift`、`sycl`)各自以 `popen`/`_popen` 与 `#if defined(_WIN32)` 取一条命令的输出。改为 `mcpp.plugins.tool` 中的一个函数:以 argv 调用 `os::process::run`(不经 shell,因而不再拼接 `2>/dev/null`、`2>nul` 与引号),返回退出状态与输出;七处调用改用它,预处理平台分支随之消失。 + - `rules/cuda` 以 `dlopen`/`dlsym` 读取驱动版本,改为 `os::library::open` 与 `handle::function<>`。 + - `src/testing.cppm` 本就为每个用例启动一次自身(`--mcpp-plugins-testing-case=`),此前以 `setenv`/`_putenv_s` 改写本进程的环境、经 `std::system` 与 shell 重定向把上下文传给子进程,并以 `environ`/`_NSGetEnviron` 枚举环境。改为以 `os::process::run` 启动,用例的上下文写在 `spawn_options.env` 中(本进程的环境不再被改写),环境由 `os::env::variables()` 枚举。设计不变,平台分支与头文件随之消失(Q7 因此不再成立)。 + - os 由 `plugins-progress` 的依赖变为插件的基础依赖,`docs/plugin-development.md` §4 改为"不包含系统头文件",CI 增加与 mcppls `os-surface` 等价的检查(报告 `#include` 与平台预处理分支;`tests/` 下的使用方工程除外)。os 成为基础依赖对构建程序首次构建时间的影响,实施时测量并记录在 PR 中。 + - 实施中发现 GCC 16 的模块缺陷(mcpp#905):内联函数体经由 mcpplibs.os 时,导入方读不回模块("Bad file data")或链接时缺 `std::optional>` 的成员。规避为"经由 os 的辅助函数不内联",写入 `docs/plugin-development.md` §4(`e77b920`)。`capture` 的标准错误以独立管道读取后追加(`stdio::output` 不是合并到子进程的标准输出管道,`6305d89`)。 +- **mcpp-ide:** `tests/support.hpp` 改为测试模块 `mcppide.testsupport`(`testsupport/`,`mcppide.testing` 已是后端的测试框架模块),各测试改为 `import std` 与该模块;`mcpp-ide-dev check-sources` 在 CI 中报告头文件、预处理分支与 `os::is_<平台>`(`f5686ac`)。 +- **mcpp:** CI 增加等价检查,与 mcpp#904 一并处理。 + +### S5 依赖书写与锁文件 + +- 依赖写在一张表中,键为不带引号的身份(命名空间在前),版本为精确版本,精确版本只用一种拼写(不带运算符)。`[workspace.package] mcpp = ">=…"` 声明构建所需的最低 mcpp,不属于依赖,保留范围。 +- mcpp 本体按此改写清单与 `examples/02-with-deps`,并对带引号的点分键给出带改写提示的警告(mcpp#904)。 +- 锁条目的 `targets` 为解析到达该包的配置集合,命令只更新其规划的配置,不删除也不新增其他配置(mcpp#903)。 + +### S6 mcppls-clangd 0.0.21 的对比运行 + +- **原生引擎一律链接 zlib**(`80daf27`)。`ci/scripts/configure.sh` 对全部原生平台设 `-DLLVM_ENABLE_ZLIB=FORCE_ON`,找不到 zlib 即停止;Windows 取 vcpkg 的 `x64-windows-static`(与 `/MT` 一致)。原生与映像双向读取对方的分片,CHANGELOG 的 zlib 陈述随之成立。 +- **darwin 的比值超限先定位,上限不放宽。** 0.0.21 的映像相对 0.0.20 新增 zlib;Linux 两行(原生使用发行版的普通 zlib)通过,只有 macOS(原生使用系统优化的 libz)未达限,热启动读取分片时的解压是首要嫌疑。探针分支 `probe/darwin-zlib-off` 以 `LLVM_ENABLE_ZLIB=OFF` 构建映像,只对 darwin-arm64 做对比运行(run `42386660489`)。比值回到 0.0.20 的水平即确认,处理为映像使用带 NEON 与 CRC 指令的 deflate 实现(Q6):aarch64 基线即有 NEON,分片格式与行为不变;实现以 mcpp-index 的包提供,原生构建不受影响。另一可选项"映像写出不压缩的分片"不采用(附录 A.6)。比值不回落则 zlib 被排除,按装载与页错误继续定位(冷启动 `page_faults` 比值 2.74–2.87)。0.0.21 在 darwin 达限之前不发布;mcppls 0.0.21 锁定该引擎,随之等待。 +- 新提交上的完整对比运行(run `42388978321`;Windows 的 zlib 以头文件目录与库文件指定)通过后,CHANGELOG 记录 `Comparison run: `,按 D18 发布。 + +### S7 mcpp 的测试协议(已实现于 mcpp 2026.11.8.1,见顶层方案 §9.2) + +**现状。** 生态中有四种互不相通的测试方式:mcpp 本体用 GoogleTest;mcppls 有自己的模块 `mcppls.testing`(无宏,`"名称"_test = [] {}`);mcpp-plugins 有 `mcpp.plugins.testing`(插件逻辑的用例,经改写进程环境注入上下文);mcpp-ide 有 `mcppide.testsupport`。mcpp 对测试的契约只有"一个 `.cpp` 一个程序,以退出码判定"(docs/08),用例是框架内部的事;mcpp-ide 因此在 `spec/test-frameworks.json` 与 `mcppide.testing` 中逐个框架编码列出用例的参数、报告格式与宏名,才能给出用例树、单个用例的运行与失败位置。每增加一个框架或一个消费方(IDE、CI、agent),这份知识就要再写一遍。 + +**提案。** mcpp 以规范定义测试程序与运行器之间的协议,mcpp 只实现协议的运行器一侧,框架以适配实现另一侧,开发者自选框架。这与构建插件的关系相同:mcpp 给出契约,具体实现属于包。 + +- **能力声明。** 提供测试入口的框架包已以 `kind = "entry"` 声明入口、由 `[test] main = { from = "fw/main" }` 选用(docs/08);入口再声明它实现的协议版本(例如 `test-protocol = 1`)。未声明的程序照旧是一个以退出码判定的用例,现有测试无须改动。 +- **列出。** 运行器以协议规定的参数启动程序,程序输出用例的结构化清单:标识、显示名、源位置、标签。 +- **选择。** 运行器以用例标识选择要运行的用例;转义与通配由协议规定,不再是各框架的命令行语法。 +- **报告。** 程序把每个用例的开始、结束、状态(通过、失败、跳过)、耗时、失败位置与输出写到运行器给出的通道(NDJSON),mcpp 汇总到 `mcpp test --message-format json`。mcpp-ide、CI 与 agent 只读这一种格式。 +- **上下文。** 协议允许运行器为单个用例给出上下文(环境与工作目录),并规定用例在同一进程或各自的进程中运行由运行器决定。`mcpp.core` 的访问器经由一个可在测试中按作用域替换的上下文读取引擎的值,替代对 `std::getenv` 的直接读取;插件逻辑的测试因此在进程内设置上下文,S4 中保留的 `setenv` 随之删除。 + +**框架与头文件。** GoogleTest 成为协议的一种实现:一个适配包以 GoogleTest 的事件监听接口实现列出、选择与报告,使用者仍写 `TEST`、`EXPECT_EQ`,因而仍包含其头文件——宏无法经由模块导出,这是 GoogleTest 自身的形态。消除头文件的途径是一个模块原生的框架:以 `std::source_location` 记录位置、以用户定义字面量或函数注册用例、不定义宏(`mcppls.testing` 已是此形态)。协议使它与 GoogleTest 处于同等地位;生态的四种测试方式收敛为这一个框架包,项目仍可选择 GoogleTest、doctest、Catch2 的适配。 + +**归属。** 规范与运行器属于 mcpp 的下一版本(新 SPEC 与 docs/08);测试库、引擎与适配为通用测试系统 mtest(`mcpp-community/mtest`,设计见 [mtest .agents/docs/2026-11-08-mtest-design.md](https://github.com/mcpp-community/mtest/blob/main/.agents/docs/2026-11-08-mtest-design.md));mcpp-ide 改为读取协议的报告并以 `spec/test-frameworks.json` 作为未声明协议的程序的后备。不并入 #885 本轮。 + +## 3. 多视角自我审查 + +| 视角 | 审查所见 | 处理 | +|---|---|---| +| 架构 | 第一版的 S3 以 openkal 的"宿主事实查询"为前提,把一系统的产品特性压到内核抽象层 | 改为 os 经可移植通道实现;openkal 不变,`openkal.host` 只作为热路径需求出现时的扩展(附录 A.3) | +| 架构 | `can_run_on` 若在映像中恒为 `true`,会把 `emscripten` 也算作映像可运行的平台 | 定义为映像中 `p ∈ {linux, macos, windows}` | +| 稳定性 | 从映像启动宿主程序:PATH 可被替换、输出随语言变化、程序可能挂起 | 绝对路径启动;以 SID、数值等与语言无关的字段判断;有界读取与超时;失败返回空或保守值 | +| 稳定性 | `linux::translator()` 在映像中读取 `/proc`,取决于 openkal 在 Linux 上是否能访问 `/proc` | 实施前在 Linux 的映像中实测;不可读时返回空并在日志中说明 | +| 性能 | 进程启动在 Windows 上约数十毫秒,若在启动路径上会拖慢 mcppls 的就绪 | 结果缓存、只在调用时发生;mcppls 的 Dev Drive 检查移出启动的关键路径 | +| 性能 | darwin 的比值超限(F6) | 探针定位,上限不放宽(S6) | +| 用户体验 | 在 os 发布之前,映像用户失去 Dev Drive 提示与 PRoot 识别 | 0.0.21 的日志与故障排查文档明确说明(`e34cca0`);os 发布后恢复 | +| 兼容性 | 第二版拟在映像构建中把 `is_<平台>` 标为 deprecated:会误报"只属于原生构建"的合法用法,抑制只能用宏形式的诊断指令;已有的映像构建在 `--strict` 下转为错误 | 改为并存:`is_<平台>` 表示原生构建,两层判断表示运行平台;防止 F2 的检查放在以映像为发布形态的应用中(S3);os 0.4.0 只增加接口,不改变已有接口的行为 | +| 兼容性 | plugins 改用 argv 调用后,原先依赖 shell 的命令(重定向、管道)行为可能不同 | 七处命令逐一核对,只取标准输出、丢弃标准错误由 `process::run` 的选项表达;插件逻辑用例与各 rules 的使用方工程在三个系统上验证 | +| 跨平台 | 不经 shell 后,Windows 上以 `.bat`/`.cmd` 提供的工具(例如 Android SDK 的若干脚本)不能直接作为程序启动 | 实施时核对七处命令所启动的程序;脚本形式的工具经 `cmd.exe /d /c` 显式启动,并在该函数中注明 | +| 架构 | 第三、四版对 plugins 测试的分析有误:测试工具本已每个用例一个子进程,`setenv` 只是传递上下文的手段 | 改为在 `spawn_options.env` 中陈述上下文;`mcpp.core` 的上下文注入仍属 S7,用于进程内测试 | +| 兼容性 | R5 修订只增加定义,不删减;签名不变 | mcpp 的原生调用不受影响 | +| 跨平台 | `macos::translated()` 读 `sysctl.proc_translated` 会得到 sysctl 进程自己的答案;且 macOS 上只有 aarch64 的 okrun | 映像中恒为 `false`;有 x86-64 的 okrun 时改用"映像架构 + `hw.optional.arm64`" | +| 一致性 | 精确版本有 `"0.3.1"` 与 `"=0.3.1"` 两种拼写(mcpp 本体与生态) | 统一为不带运算符(mcpp#904) | +| 一致性 | 生态的头文件规则只在 mcppls 有检查 | plugins、ide、mcpp 各自增加等价检查(S4) | +| 无感升级 | 依赖精确固定后,os 的新版本不会被静默带入 | 升级随各仓库的版本 PR 显式进行,CI 在升级时报告 deprecated | +| 测试覆盖 | 映像中的平台事实若只在原生 CI 测试,会重演 F2 | os 的 CI 以映像在四个真实系统上运行这些函数:Windows 运行器为管理员,`elevated()` 断言为真;Linux 运行器在 PRoot 下运行映像,断言 `translator()` 为 `proot`;macOS 断言 `translated()` 为假 | +| 测试覆盖 | 非管理员的 Windows 路径在 CI 中不易得到 | 以受限令牌启动(`runas /trustlevel` 或等价方式)的测试在实施时评估;不可得时在文档中记录覆盖缺口 | +| 过程 | 对 darwin 有效测量的重跑违反性能政策 | 在 F6 记录;只作诊断参考,结论以第一次测量为准 | + +## 4. 归属与版本 + +| 项 | 仓库 | 版本 | 状态 | +|---|---|---|---| +| F7 修复 | mcppls | 0.0.21(#79) | 已提交 `aaf7174` | +| F3 mcppls 头文件、`os-surface` 检查 | mcppls | 0.0.21(#79) | 已提交 `253aff8` | +| S3 末段:mcppls 过渡日志 | mcppls | 0.0.21(#79) | 已提交 `e34cca0` | +| S5 生态清单改写 | mcppls、mcpp-ide、mcpp-plugins、mcppls-clangd | 各自本轮 PR | 已提交 | +| S6 zlib | mcppls-clangd | 0.0.21(#18) | 已提交 `80daf27`、`becb991`,对比运行进行中 | +| S6 darwin | mcppls-clangd | 0.0.21(#18) | 探针进行中 | +| S4 mcpp-plugins | mcpp-plugins | 0.21.0(#52) | 已提交 `8d0afd7`(52 个插件逻辑用例、all-rules-compile 本地通过) | +| S4 mcpp-ide | mcpp-ide | 1.2.0(#5) | 已提交 `f5686ac`(`testsupport/` 模块、`check-sources`) | +| S7 | mcpp、mcpplibs、mcpp-ide | mcpp 下一版本 | 提案,独立设计 | +| S1 文档与 tier 评估 | mcpp | 下一版本 | 新 issue | +| S5 mcpp 本体 | mcpp | 下一版本 | mcpp#903、mcpp#904 | +| S2、S3 | mcpplibs.os | 0.4.0(只增加接口) | 新 issue;mcppls、mcpp-ide 随后迁移 | +| openkal | — | — | 不变 | + +## 5. 待审查的问题 + +- **Q1、Q3、Q4、Q6** 已确认。 +- **Q2** 已确认:S4 并入 #52、#5。 +- **Q5** 已确认:并存(S3),`is_<平台>` 不标 deprecated;防止 F2 的检查放在以映像为发布形态的应用中。 +- **Q7** 不再成立:plugins 测试工具本已每个用例一个子进程,改为以 `spawn_options.env` 陈述上下文(`8d0afd7`)。 +- **Q8** S7(mcpp 测试协议、模块原生框架、GoogleTest 适配、`mcpp.core` 上下文注入)作为 mcpp 下一版本的独立设计推进,是否同意。 + +## 附录 A 被考虑而未采用的方案 + +- **A.1 构建插件提供平台语法糖。** 插件在构建时同样不知道映像的加载系统;改写源码形成 clangd 与 IDE 无法解析的方言;宏形式与项目规则相悖。 +- **A.2 os 提供原始系统接口的映射(`os.unix`、`os.windows` 下的 POSIX/Win32 镜像)。** 生态中唯一的直接 POSIX 调用是已删除的诊断探针,未见用例;原始接口是一部分系统的表面,与 os 的语义接口和 R5 相悖。确有需要时以独立的 mwrap 包承载。 +- **A.3 openkal 收录平台事实。** 逐项进入 ABI 会使 ABI 的版本随各系统的新特性变化。热路径上确需零开销时,合乎现有机制的形式是可选接口 `openkal.host`:okrun 作为原生程序在宿主侧作答,映像以弱导入使用(`examples/weak`),调用代价与其他 `kal_*` 调用相同;未提供该接口的运行器使弱导入为空,os 回退到可移植通道。目前 libokrun 只绑定规范的接口面(`run/src/bindings.cpp` 的静态表),不提供登记额外名字的接口;libokld 不在运行时提供服务。本轮所涉事实均为每会话一次的查询,不引入该接口。 +- **A.4 映像中无法回答的事实定义为返回空值。** 会改变 `bool`、`unsigned` 等现有签名,影响 mcpp 的原生调用;不提供(链接错误)使误用在构建时可见。 +- **A.5 映像构建中把 `is_<平台>` 标为 deprecated。** 误报"只属于原生构建"的合法用法,逐处抑制需要宏形式的诊断指令;以并存与应用侧的检查代替(S3)。 +- **A.6 映像写出不压缩的分片。** 分片体积增大,映像与原生写出的分片在格式上不同;以更快的 deflate 实现代替(S6)。 diff --git a/.agents/docs/2026-11-08-ecosystem-top-level-design.md b/.agents/docs/2026-11-08-ecosystem-top-level-design.md new file mode 100644 index 000000000..2a907166a --- /dev/null +++ b/.agents/docs/2026-11-08-ecosystem-top-level-design.md @@ -0,0 +1,448 @@ +--- +subject: ecosystem +status: active +--- + +# 生态顶层方案:#885 收尾之后的工作流、原则与依赖 + +**日期:** 2026-11-08 +**状态:** 第六版,实施中(T1–T5、U1 已确认;第 8 节为实施计划,第 9 节为实施记录) +**范围:** mcpp、mcpplibs.os、openkal、mcpp-plugins、mcppls、mcppls-clangd、mcpp-ide、mcpp-index、xim-pkgindex、mtest + +本文是生态层面的顶层方案。它汇总三份设计与三个新 issue,给出贯穿各仓库的原则、全部工作流及其依赖、尚未收敛的事项,并从全局与生态的视角审查。各项的细节以所引设计为准。 + +| 文档 | 内容 | +|---|---| +| [2026-11-07-ecosystem-885-closeout-design.md](2026-11-07-ecosystem-885-closeout-design.md) | #885 收尾:D1–D20(mcpp 2026.11.7.1/.2、openkal 级联、IDE、mcppls、插件) | +| [2026-11-08-ecosystem-885-supplement-design.md](2026-11-08-ecosystem-885-supplement-design.md) | 补充:S1 目标角色、S2/S3 平台判断与映像中的平台事实、S4 系统接口、S5 依赖书写、S6 对比运行、S7 测试协议 | +| [mtest .agents/docs/2026-11-08-mtest-design.md](https://github.com/mcpp-community/mtest/blob/main/.agents/docs/2026-11-08-mtest-design.md) | 通用测试系统 mtest | +| mcpp#903 | 锁文件的 `targets` 随命令变化 | +| mcpp#904 | mcpp 本体的依赖书写 | +| mcpp#905 | GCC 16 模块:内联函数体经由 mcpplibs.os 时导入方失败(upstream-bug,已有规避) | +| mcpp#874 | 工作区的 `target/` 占用与 `mcpp test --workspace` 的重复编译(开放;主体已在 2026.10.31.3 实现,待在报告者的工作区验证) | + +## 1. 贯穿生态的原则 + +| 编号 | 原则 | 出处 | +|---|---|---| +| P1 | **核心只定契约,实现在包中。** 构建插件、平台、测试协议皆如此:mcpp 给出规范与运行器一侧,具体实现属于包 | 既有原则;S7、mtest §2 | +| P2 | **两种角色,不分等级。** 生态应用以 ok 映像为唯一发布形态;原生目标是完整支持的构建目标 | S1 | +| P3 | **系统经由模块。** 源文件与测试不包含系统头文件、不含预处理平台分支;操作系统经由 mcpplibs.os,C 库经由 `std` 与 mcpplibs.mwrap.libc;原始 POSIX/Win32 不进入 os | S4 | +| P4 | **平台判断分两种。** `is_<平台>` 表示原生构建(编译期);`can_run_on` 与 `host()` 表示运行平台(编译期能否、运行时是否);发布映像的应用在自身的检查中禁止 `is_<平台>` | S2、S3 | +| P5 | **平台事实在 os 中,openkal 不收录产品特性。** 映像中的平台事实由 os 经可移植通道回答;热路径确有需要时以可选接口 `openkal.host` 扩展 | S3、附录 A.3 | +| P6 | **依赖一张表、身份键、精确版本、一种拼写** | S5 | +| P7 | **政策由工具执行。** 测量的有效性、能否重复、能否重试,由工具依声明判定,不依赖记忆 | S6;mtest §4.4 | +| P8 | **面向 Agent 的输出。** 结构化、带 Schema 与版本、简短、给出复现命令 | mtest §6 | +| P9 | **自身是自身的用户。** mtest 以自身测试自身;生态的应用以映像测试,与发布物一致 | mtest §9;D18 | +| P10 | **每个仓库每个版本一个 PR;确定的事项不拆到以后;编译器缺陷以 mcpp 的 upstream-bug issue 记录并在实现中规避** | 既有原则 | +| P11 | **底层与基础设施的变动符合规范,并经严格审查。** openkal 家族、openkal-platform、mcpplibs.os、mwrap 包装、okrun 与 okld 的每一处变动,对应其规范条款(openkal SPEC、mcpp SPEC),说明兼容性,经下游使用者的候选验证;mtest 落地后,规范一致性套件、接口面变化检测与审查记录成为它们的发布门禁 | mtest §3.2 | +| P12 | **一个仓库多个组件时,版本按系列表达,CI 按组件触发。** `X.Y` 为系列,由服务端(或后端)与全部插件共享;`Z` 为各组件自己的补丁号。改动所属的组件由一张声明的表判定,只运行该组件及依赖它的组件的检查;未声明的路径按全部组件处理 | W6;mcpp-ide 1.2.0 的版本规则 | + +## 2. 工作流 + +### W1 #885 本轮(进行中) + +| 仓库 | 版本 / PR | 状态 | 阻塞 | +|---|---|---|---| +| mcpp | 2026.11.7.2 | 已发布、镜像、入索引 | — | +| mcppls-clangd | 0.0.21(#18) | PR CI 通过;对比运行 `42395068307` 验证 win32 原生 zlib(`zs.lib`);映像改用 compat.zlib-ng(本地提交,待索引发布后推送) | W5:compat.zlib-ng 2.3.3 已合入索引(mcpp-index#727),待发布 | +| mcppls | 0.0.21(#79) | #80 修复、头文件清零、过渡日志、依赖改写、mcpp 2026.11.7.2;CI 除 ux-xlings 外通过(U4-cold 冷阶段仅 2 轮,引擎占比 0.88,按无效测量重跑) | 引擎锁需 clangd 0.0.21 发布;W6 的采用时机(T5) | +| mcpp-ide | 1.2.0(#5) | 测试模块、`check-sources`、CLion 公开 API;CI 除 CLion 2026.2.4.1 外通过 | U3:窗口在动作后为 `visible=false`、所选内容为 Module Graph;测试已改为记录窗口可见性的时间线与窗口类型(`d5ced7a`) | +| mcpp-plugins | 0.21.0(#52) | S4 完成(`8d0afd7`、`6305d89`、`e77b920`);本地 52 个用例与各消费者通过;CI 全部通过 | — | +| mcpp-index | — | #727(compat.zlib-ng 2.3.3)已合入;其余待 plugins 0.21.0 发布后的更新 | plugins 发布 | +| xim-pkgindex | — | 待 mcppls、mcpp-ide 发布后的 bump | 各自发布 | + +收尾:生态级全局审查 → 各 PR squash 合入 → 发布与 GitCode 镜像 → 索引 → xlings 全生态验证 → 以 speak-agent 关闭 issue(#896、#897、#839、#840、#843、#803、mcppls-clangd#17、#887、#885 总账)与综合汇报。 + +**xlings 全生态验证(T2 已确认)。** 在一个新建的临时目录中进行,与本机的全局环境隔离: +- 环境:`HOME`、`XLINGS_HOME`、`MCPP_HOME` 与 `XDG_DATA_HOME`、`XDG_CONFIG_HOME`、`XDG_CACHE_HOME`、`XDG_STATE_HOME` 指向该目录之下;`env -u` 去除会话中的 `XLINGS_PROJECT_DIR`、`XLINGS_ACTIVE_SUBOS`、`XLINGS_SUBOS_MODE`、`XLINGS_SUBOS_LIB`、`XLINGS_BIN`、`XLINGS_SHIM_DEPTH`;工作目录为中性目录;不使用 shim。 +- 步骤:`xlings update`;不带版本安装 `mcpp`、`mcppls`、`mcpp-ide`、`mcpp-ide-nvim`(必须解析到新版本,GLOBAL 与 CN 两个镜像各一次);以安装的 mcpp 新建项目并 `build`、`run`、`run --print-launch`(第二次走快路径)、`test`、`--target x86_64-openkal` 的构建与运行;mcppls 的 `--version` 与一次 `diagnostics`;mcpp-ide 后端以 okrun 启动并完成初始化握手;nvim 插件在无界面模式下载入。 +- 记录:每一步的版本、耗时与结果写入一份报告,附在 #885 的总账中。 + +### W2 mcpp 下一版本 + +| 项 | 来源 | +|---|---| +| 锁文件 `targets` 只随所规划的配置变化 | #903 | +| mcpp 本体依赖改写;带引号的点分键给出改写提示;`examples/02-with-deps`;源文件检查(头文件、预处理分支) | #904、S4、S5 | +| 目标角色写入 docs/24 与 docs/35;openkal 两行按 tier 定义评估为 `verified`。评估所见:mcpp 自身的 CI 只在 Linux 上运行映像(942、960、961、964 声明 `llvm`,而只有 Linux 分片提供它),不满足 docs/21 对 `verified` 的定义。为此新增测试 1033(以平台的全部层构建本机架构的映像并运行、测试,另一架构只构建),并由 `tests/e2e/host-requirements.tsv` 与 `check_e2e_coverage.py` 要求它在 Linux、macOS、Windows 上通过 | S1 | +| 测试协议的运行器一侧;`[test]` 的协议声明;`mcpp.core` 的上下文注入;受影响选择所需的构建图查询;`mcpp new` 默认使用 mtest | S7、mtest §11 | +| GCC 16 模块缺陷的跟踪与精简复现 | #905 | +| 构建在项目根目录写下 `.mcpp/compiler-runs//`(编译生成的锁,`src/build/schedule/detach_codegen.cppm`,有意置于 `target/` 之外以免被 clean 删除),而该目录不被版本控制忽略:os 0.4.0 的 PR 曾误提交其中的锁文件。mcpp 创建 `.mcpp/` 时在其中写入内容为 `*` 的 `.gitignore`,使目录自我忽略 | 本轮发现 | +| 工作区的空间与重用:在报告者的工作区(Sunrisepeak/mui,112 个成员)以当前 mcpp 复测,按 issue 的表格给出各区域大小、一次 `mcpp test --workspace` 中重复构建的边数与时间、宿主工具每个键的变体数;与 issue 的期望逐条对照,未实现的部分在本版本实现:`[workspace] target-budget`(或等价的环境变量)、低空间警告以"上一次构建该配置写入的字节数"为阈值(当前为固定的 2 GiB)、std 模块构建的收集。后者在本机已复现:`~/.mcpp/build-cache/v1/std` 有 159 份构建(4.9 GiB,最早 2026-11-01),`mcpp cache prune --older-than 2d` 与 `cache gc --older-than 2d` 均未收集任何一份(gc 只覆盖包与宿主工具)。复测数据与差距写入 issue 后关闭 | #874 | + +### W3 mcpplibs.os 0.4.0 + +`can_run_on`、`on

`(泛型 lambda)、R5 修订(平台模块在能回答的地方定义)、映像中的平台事实实现(`linux::translator`、`windows::elevated`、`windows::on_dev_drive`、`posix::effective_user`;`macos::translated` 恒为 `false`)、文档中的写法选择;os 的 CI 以映像在三个系统上运行这些函数。只增加接口。随后 mcppls 与 mcpp-ide 迁移,Dev Drive 提示与 PRoot 识别在映像中恢复。 + +**`is_image` 改名为 `is_openkal`(本轮讨论确定)。** `os::is_image` 的含义是"本构建以 openkal 目标构建,产物为 ok 映像",即 `PLATFORM == platform::openkal`,并不是"内核层为 openkal"(os 的全部构建都在 openkal 之上)。原名有两处不清楚: +- 它是 `is_<平台>` 家族中唯一不以枚举值命名的成员(`is_linux` 对应 `platform::linux`,而 `is_image` 对应 `platform::openkal`),而 mcpp 清单对同一事实写作 `cfg(os = "openkal")`; +- "image" 单独出现时可指容器或磁盘映像。 + +处理: +- os 0.4.0 以 `is_openkal` 为正式名,定义为 `PLATFORM == platform::openkal`,注释写明"以 openkal 目标构建,即 ok 映像(openkal SPEC clause 12),不是'在 openkal 之上'"; +- `is_image` 保留为 `[[deprecated("use is_openkal")]]` 的别名,现有用户(mcppls `workspace.cpp:708`)得到迁移提示而不中断,在 mcppls 迁移到 os 0.4.0 时改写; +- README、CHANGELOG 与 `checks/discarded` 的两层写法示例同步改名。 + +### W4 mtest + +`mcpp-community/mtest`(包 `mcpplibs.mtest`):规范、引擎(ok 映像)、C++ 模块原生库、适配、C ABI 报告库(`libmtest.okso`)、宿主集成、前端;以自身测试自身;首批迁移五个项目;目标为 PR CI 墙钟中位数减半、发布 CI 关键路径缩短 30%。 + +### W5 映像中的 deflate(已确认,实施中) + +探针确认了原因。darwin-arm64 上映像相对原生(链接 macOS 带 NEON 的 libz)的热启动处理器时间比值,四对逐一为:以 compat.zlib 构建 1.332、1.236、1.214、1.252(运行 `42369457883`);不带 zlib 构建 0.973、1.050、1.107、1.088(运行 `42386660489`)。两组不相交,差距远大于 0.03 的离散界,故差距来自分片的解压。 + +实现为 mcpp-index 的 `compat.zlib-ng` 2.3.3(#727):zlib 兼容形态,即 CMake 的 `WITH_RUNTIME_CPU_DETECTION=OFF` 构建,各函数在编译期绑定到架构基线保证的实现,aarch64 为 NEON(inflate_fast、adler32、chunkmemset、slide_hash、compare256、longest_match),x86_64 为 SSE2。映像无法询问处理器(openkal 不提供处理器特性),所以只构建基线之内的路径。Armv8 的 CRC32 指令不属于 aarch64 基线,需要整个程序的编译选项或运行时检测,因此不构建;它只影响 gzip 流,LLVM 写的 zlib 流以 adler32 校验。Q6 所说的"CRC"据此限定为不启用。验证: +- 索引 CI 的四条工作区腿(Linux GCC、Linux LLVM、macOS arm64、Windows)通过; +- 本地 `x86_64-openkal` 测试通过; +- `aarch64-openkal` 构建中 `inflate` 调用 `inflate_fast_neon`; +- 测试读取 madler zlib 写出的流。 + +mcppls-clangd 的映像依赖改为 `compat.zlib-ng = "2.3.3"`,对比运行给出结论后写入 CHANGELOG。 + +### W6 mcppls 的组件版本与按目录的 CI(T5 已确认:并入本轮;已实现) + +**实现记录。** +- mcppls #79:`fd87271` 版本与系列检查(devtools `--series`/`--bump`/`--print --component`;`mcppls.base.series` 与 `initialize` 的拒绝;VS Code、CLion 的启动前检查;Zed 发送 `initializationOptions.plugin`);`b0f1c18`、`9f51d1b` 发布(资产注明组件,`release check --component/--from`,`release.yml` 的 `component` 输入与组件标签,发布器按标签筛选);`fcfd472`、`0a4f140` CI(`.github/components.tsv` 的 `@core`/`@flag`,`changes.sh` 与其用例,`server-digest` 标记与借用运行,`invariants` 与 `ci` 汇总作业)。 +- mcpp-ide #5:`91b1437`,同一分类脚本,`@core backend`,`backend-digest` 标记,`checks` 与 `ci` 汇总作业。 +- 两个仓库的 `main` 均无分支保护规则,`ci` 作为唯一必需检查在启用保护时设置。 + +**借用的实现选择。** 设计稿写的是以 Actions 缓存保存负载。实现改为借用 main 运行的产物:main 在构建负载之后上传名为 `server-digest-<摘要>` 的标记产物;插件 PR 按名称查询标记,得到该运行的 id,插件作业以 `run-id` 下载其 payload、stage 与 kit(payload action 已有此能力,夜间运行即以此测试 PR 的字节)。这样不复制数据,也不受缓存容量限制。mcppls 的产物保留 7 天;mcpp-ide 的后端产物保留 90 天。 + +**现状。** mcppls 的服务端与全部插件共用一个版本(`mcpp.toml` 的 `0.0.21`,`version --check` 使 VS Code、CLion、Zed、Claude Code 插件与之相同),每个 PR 都运行整个 CI。#79 的一次运行用时约 3 小时(`42385557959`,15:16–18:18),其中服务端、负载、kit、一致性与 ux 占大部分。CLion 或 Zed 的修复只改动 `editors/<插件>/`,也要重建服务端与负载,并等待全部检查。mcpp-ide 已采用系列版本(README "Versions"),但 CI 同样不按组件区分。 + +**版本(P12)。** 版本为 `X.Y.Z`: +- `X.Y` 为系列,由服务端与全部插件共享;`Z` 为各组件自己的补丁号。 +- 组件为:服务端(`mcppls`:单文件程序、映像、xlings 包),以及插件 VS Code、CLion、Zed、Claude Code。Neovim 插件与 Copilot CLI 的配置片段没有版本,随系列发布。 +- 系列发布的标签为 `vX.Y.0`,发布全部组件。组件补丁的标签为 `server-vX.Y.Z`、`vscode-vX.Y.Z`、`clion-vX.Y.Z`、`zed-vX.Y.Z`、`claude-code-vX.Y.Z`,只发布该组件。 +- 兼容规则:插件接受同一系列的任何服务端,拒绝其他系列,并在提示中给出两个版本与匹配的安装命令。VS Code 与 CLion 内带负载,只有在用户指定服务端路径时才会出现不匹配;Zed 与 Neovim 在 `PATH` 上找到 `mcppls` 时检查系列。插件补丁内带其系列中最新的服务端补丁,所带服务端的版本记录在包内并显示于状态报告。 +- 引擎(mcppls-clangd)保持自己的版本,服务端以精确版本锁定它;服务端补丁可以改变这一锁定。 +- 工具:`mcppls-devtools version` 支持 `--series X.Y`(全部组件置为 `X.Y.0`)与 `--bump <组件>`(只增加该组件的 `Z`);`--check` 检查全部组件同一 `X.Y`、二进制报告的常量与 `mcpp.toml` 一致。各组件的 `Z` 各自递增,所以每个市场(VS Code Marketplace、Open VSX、JetBrains、Zed)看到的版本都只增不减。 +- 发布:`packaging/release.manifest.json` 为每项资产注明所属组件。系列发布检查全部资产,组件发布只检查该组件的资产;CHANGELOG 的组件补丁小节写在系列小节之下,并注明组件。 +- 迁移:mcppls 的第 N 次发布 `0.0.N` 对应系列 `0.N`。`0.0.21` 以 `0.21.0` 发布,比 0.0.20 大,在各市场中合法;下一系列为 `0.22.0`。 + +**CI(P12)。** +- **组件表。** 仓库根的一张表声明组件与路径: + - 服务端:`src/**`、`modules/**`、`mcpp.toml`、`mcpp.lock`、`vendor/**`、`packaging/**`、`conformance/**`、`tests/**`; + - 各插件:`editors/vscode/**`、`editors/clion/**`、`editors/zed/**`、`editors/nvim/**`,Agent 集成为 `editors/{claude-code,copilot-cli,agents}/**`; + - 文档:`docs/**`、`*.md`; + - `.github/**`,以及表中未列出的路径,按全部组件处理。 +- **分类。** 第一个作业按表把改动的文件分到组件,输出每个组件是否受影响。分类有自己的单元测试(以文件列表为输入的样例),与 mcpp 的 `classify_changes.py` 相同。 +- **依赖传递。** 服务端受影响时运行全部作业,因为插件以新的服务端测试。只有插件受影响时,只运行该插件的构建与端到端测试,以及版本与仓库不变量的检查;只有文档受影响时,只运行文档检查。 +- **负载的重用。** 负载以服务端输入的内容摘要为键,存入 Actions 缓存;main 的运行写入,PR 可读。只改插件的 PR 按摘要取回与 main 相同的负载,不重建服务端。缓存未命中则照常构建,结果正确,只是变慢。重用以内容摘要证明负载相同,与分类是否正确无关。 +- **全量运行的场合。** 推送到 main、手动触发、夜间与发布运行全部作业,不做分类,使 main 的缓存保持有效、覆盖保持完整。 +- **汇总作业。** 一个汇总作业作为分支保护的唯一必需检查:分类要求的作业全部成功、其余作业是因分类而跳过时,汇总作业成功。 +- **性能政策。** 政策(P7)不变:被跳过的测量不算通过;发布前的对比运行仍是全量。 + +**mcpp-ide。** 版本规则已经如此。它的 CI 以同一张表的形式区分后端(`src/**`、`protocol/**`、`spec/**`、`webui/**`、`testsupport/**`、`tests/**`、`mcpp.toml`)与 `mide-plugins/<插件>/**`,并以同样的方式重用后端的映像包。 + +**审查。** + +| 视角 | 所见 | 处理 | +|---|---|---| +| 兼容性 | 市场、xlings 与 mcpp-ide 对版本的依赖 | `0.21.0` 比已发布的全部版本都大;xim 按 SemVer 比较,`mcppls@0.21.0` 是新的最高版本;mcpp-ide 的 CLion 插件以插件 id 依赖 mcppls 的 CLion 插件,不约束版本 | +| 稳定性 | 重用的负载可能与源码不符 | 缓存键为服务端全部输入的内容摘要,加上 `.github/versions.env` 与运行器系统;负载内记录其摘要,测试开始时核对 | +| 稳定性 | 分类漏掉一个依赖,插件 PR 就会在旧服务端上通过 | 未声明的路径按全部组件处理;main 每次推送全量运行,漏判在合入后的第一次运行中暴露;组件表的覆盖由一项检查保证:仓库中的每个文件至少属于一个组件 | +| 用户体验 | 系列不匹配 | 插件给出两个版本与安装命令,不静默降级 | +| 一致性 | mcppls 与 mcpp-ide 的规则 | 同一原则(P12)、同样的标签形式与汇总作业;两者的组件表各自声明 | +| 测试覆盖 | 只改插件的 PR 不运行一致性与 ux | 这两组只测服务端与负载,而负载与 main 相同(由摘要证明);插件自身的端到端测试照常运行 | + +**预期。** 只改插件的 PR 不再构建服务端、负载与 kit,也不运行一致性与 ux,墙钟从约 3 小时降到该插件作业的时间(CLion 约 10 分钟,Zed 与 VS Code 约 20–30 分钟)。实施后以 mtest 的 CI 汇总测量(W4 §10.2)。 + +### W7 API 的生命周期(讨论确定) + +**现状。** C++ 的 `[[deprecated("…")]]` 已使编译器在每个使用处警告,并随模块的 BMI 传递。它缺少四样东西: +- 版本信息:自哪一版弃用、哪一版移除、以什么替代; +- 汇总:依赖内部的使用同样告警,信息噪声大,`-Werror` 下直接失败; +- 机器可读的数据:IDE、Agent 与 `mcpp update` 无从知道升级会破坏什么; +- 更多等级:没有"实验性"一级,也无法表达整个包的弃用。 + +mcpp 对自身清单键已有完整的同类机制:`kDeprecations` 注册表、docs/51、带替代写法的警告、`mcpp why compat`、`--strict`,以及到期即失败的测试。W7 把它推广到包与包的 API。 + +**设计**(清单优先、核心最小:核心给出契约,编译器做编译器的事): +1. **声明级,源码为唯一来源。** 使用标准属性,并约定 mcpp 能识别的消息语法:`[[deprecated("since 0.4.0, removed in 0.6.0: use is_openkal")]]`。编译器照常告警;mcpp 已有的模块扫描读取结构化数据。数据不写两份,因而不会漂移。 +2. **包级与模块级,在清单中。** + - `[package] status = "experimental" | "stable" | "deprecated"`,可带 `replaced-by`; + - `[lib]` 下的模块可各自声明 `status`; + - 实验性 API 需要使用方显式允许(`allow-experimental = ["<包>"]`)。 +3. **构建时的使用方体验。** + - 依赖内部的弃用告警不再重复; + - 使用方自身代码的使用在构建末尾汇总一次("3 处使用了 mcpplibs.os 的 2 个弃用 API,0.6.0 移除;`mcpp why deprecated`"); + - `--strict` 下转为错误; + - 诊断以编译器的结构化格式读取(GCC JSON、Clang 与 MSVC 的 SARIF),不做文本匹配。 +4. **升级时。** `mcpp update` 或锁文件的变化跨过移除版本时,在构建失败之前列出将被移除的 API 与替代。 +5. **库作者的发布门禁。** + - `mcpp publish` 在到达移除版本而声明仍在时拒绝; + - 移除版本不晚于弃用版本时拒绝; + - 移除一个此前未弃用的导出声明时拒绝(以模块扫描得到的导出清单与上一个已发布版本比较); + - 发布说明可由数据生成。 +6. **数据的消费方。** `--message-format json`、`mcpp why deprecated`,以及 mcppls 中的删除线与"改用 X"的快速修复。 +7. **mcpp 自身。** `kDeprecations` 改为同一机制的一个使用者,docs/51 的表与检查保持不变。 + +归属:mcpp 下一版本之后的独立版本,规范写入新 SPEC 与 docs/05、docs/51;W3 的 `is_openkal` 别名是它的第一个使用者。 + +### W8 测试迁移到 mtest 与 CI 时间(T6 已确认;并入本轮) + +**测量(2026-11-08)。** mcpp 一次 PR 74 个作业、627 runner 分钟、墙钟 56 分钟;关键路径为 mcpp 构建(约 13 分钟)之后的 Linux 单元测试(43 分钟;16 核本机 6 分钟,构建占 210 秒)。e2e 720 个脚本在 Linux 累计约 94 分钟,按提交的耗时表分片,分片内串行(本轮一张过时的表使一个分片超时)。mcppls 一次 PR 88 个作业、446 runner 分钟、墙钟 92 分钟,关键路径为服务端与负载(约 70 分钟)之后的 stability。mtest 引擎已有 `--changed`(由程序的 `sources` 与用例的 `file:` 产物选择)、历史耗时、`resources` 互斥、`--failed`、`bisect`、`hot` 与 `path`。 + +**设计。** + +| 仓库 | 内容 | +|---|---| +| mcpp | e2e 为 mtest 的 set:每个脚本一个用例,`# requires:` 为标签,`tests/e2e/host-requirements.tsv` 为 set 的过滤,独占点(`MCPP_HOME`、网络、端口)为 `resources`;分片按存储的历史耗时以最长优先分配,分片内按资源并行,耗时表不再提交。`mcpp test --changed ` 由构建图选择测试:测试记录的 `sources` 与传递导入覆盖变更即受影响。测试程序合成选项:一个目录的测试文件为一个程序,链接数随之减少。e2e 的覆盖映射:main 上定期以插桩的 mcpp 运行全部,得到用例到源文件的映射,存为用例的 `file:` 产物;PR 选择与变更相交的用例和冒烟集,无映射或映射过期的用例视为受影响。CI:PR 运行受影响集与冒烟集,main 与发布运行全部;单元测试按历史耗时分到多台机器 | +| mcppls | 一致性与 ux 场景为 mtest 用例;性能与稳定性测量声明 `serial:perf`,不与其他用例并行;失败只重跑失败项(`--failed`);与 W6 的组件表与负载借用一致 | +| mtest | 静态用例清单与清单属性:`mcpplibs.mtest.manifest` 从源文本读出 `"name"_test` 字面量、属性、命名套件与嵌套用例,`mtest list --static` 不构建而列出;属性写在 `[cases]` 或宿主 mcpp.toml 的 `[package.metadata.mtest.cases]`,优先于程序的列举。不采用构建插件:mcpp 保存工具的 `[package.metadata]`,引擎与编辑器读同一份文本,不必每次构建运行构建程序。覆盖映射(`mtest-coverage/1`:用例、源文件、提交与时间,由 GCC 的 `.gcda` 计数测得);`[discover.]` 组与 `--shard`;存储以 main 的产物在运行之间共享 | + +**判据。** 既有 e2e 保持一个脚本一个用例;新增与改动的 e2e 以 Bash 适配的 `mtest_case` 写出各判据,失败定位到判据并可由 `--case` 单独运行。 + +**探针(先行)。** 一个临时 PR 测三项后定参数:分片内 2–3 路并行的墙钟与 runner 分钟;按目录合成测试程序后单元测试的时间;以 main 的 `target/` 复用时实际重跑的边数(本轮之前测得 830 条边全部重跑,其时 build 与 test 尚为两个配置)。 + +**探针结果(#909)。** + +| 项 | 测得 | 决定 | +|---|---|---| +| 分片内并行(Linux 一个分片 81 个测试) | 一次一个 424 s;两个 220、310、384、395 s;三个 225 s。两个并行时 955 失败两次(`self env --target x86_64-openkal --format json` 的答复缺 `data`,测试已改为留下该答复) | `E2E_JOBS = 2`,三个无收益 | +| build 之后的 test(mcpp 自身,release) | 同一检出内 test 重编 637 个目标:`trim_paths` 不作用于测试,测试是另一个配置 | `trim_paths` 作用于被测代码,测试程序自己的文件保留路径;build 与 test 为同一配置,重编 0 个 | +| 另一检出接手 `target/` | 975 条边、255 个目标重跑(检出使源文件的时间晚于目标);源文件时间回拨后 0 条 | 不缓存 `target/`:仓库缓存的 10 GB 上限已由 bootstrap 的缓存占用(R3),收益只在源文件时间被恢复时成立 | +| 按目录合成单元测试程序(mcpp 的 165 个单元测试,4 核 runner,冷构建) | 每文件一个程序:墙钟 2506 s,其中构建 1590 s、运行 20 s;每目录一个程序:2455 s,构建 1504 s、运行 50 s。链接不是瓶颈,编译是 | mcpp 自身保持每文件一个程序(失败定位到测试文件);`[test] programs = "directory"` 作为选项保留。单元测试的关键路径改由 `--since` 缩短:PR 只构建受影响的测试 | + +### W9 用例的类型、作用域与 mtest 的布局(并入本轮) + +**问题。** mtest 0.2.0 的属性只能写在单个用例上:`suite` 只有名字与函数体,一个一致性文件的每个用例都写 `[kind::cts]`,一组独占机器的用例只能逐个写 `[resource{"machine"}]` 或退回 `mtest.toml` 的 `[cases."glob"]`。各类别的模块(`mtest.cts`、`mtest.e2e`、`mtest.bench`)只提供函数,与类别无关:bench 用例漏写 `[kind::bench]` 即按 unit 计。共享的准备只能写在套件体内,而套件体在列举时也运行,昂贵的准备因此在 `list` 与未选中时发生。静态清单只读 `kind` 与 `tag`。用例没有自己的时限。mcppls 的一致性以 131 个目录与 6 张 CI 清单表达,变体、平台、资源与分组分散在 ci.yml、脚本与 `mtest.toml` 中。 + +**原则。** 代码即测试,也是测试的配置:每个最小用例在代码中声明它是什么、需要什么、如何运行,runner 据此列举、选择、调度与复用,并记录所做的(资源、时间、共享对象、诊断)。`mtest.toml` 是可选的声明方式,词汇与代码的属性相同,用于不能修改源码的程序;两者冲突时以代码为准。 + +**设计(mtest 0.3.0,mtest#4)。** + +| 项 | 内容 | +|---|---| +| 对象即用例 | `mtest::Suite` 为通用形式,`mtest::UTest`、`mtest::CTS`、`mtest::E2E`、`mtest::Bench` 以类型表示类别;对象取名字、属性与函数体。创建对象只记录配置;runner 将其列为一个用例,按属性调度,选中时运行函数体。函数体声明的用例在其名下、继承其属性并可单独选择;函数体的其他语句(其用例共享的准备)只在其中某个用例运行时执行。因此一个对象可以是单个测试、一组测试或二者兼有,一个文件中的多个对象各自受控。只因其下的用例被选中而运行的对象是该用例的作用域,只在其自身语句失败时报告(P3.4)。`suite` 保留为 0.2 的写法 | +| 作用域属性 | `tag`、`clause`、`resource`、`artifact` 取并集,`isolate` 向内传递;`kind`、`skip_if`、`expect_failure`、`known_gap`、`timeout`、`retries`、`diagnose` 取最内层 | +| 运行方式的属性 | `timeout{}`(按用例终止进程)、`isolate{}`(独立进程)、`retries{n}`(失败后单独重跑,之后通过为 flaky)、`diagnose{.env, .limit}`(失败后以该环境再运行一次并保存输出,不作判定,`mtest explain` 指出其位置);`Bench` 默认持有 `machine` | +| 共享与追踪 | `shared`:第一个使用它的用例运行时构造,至多一次,随函数体结束析构;`fixture` 事件记录由哪个用例构造及耗时。`--report-json` 的 `timeline` 记录每个进程的开始、耗时、状态与持有的资源 | +| 协议 | 版本 1 内只增字段(P9.2):P3.3 的 `timeout`、`isolation`、`retries`、`diagnose`,未声明类别的用例取其程序的类别;P3.4 的 `begin` 可带 `kind`、`tags`、`clauses`,以及作用域规则;P5.2 的 `fixture` | +| 类别的次序 | 代码的声明(类型或 `kind::`)、`mtest.toml`、程序所在目录(`tests/unit`、`tests/e2e`、`tests/cts`、`tests/bench`,docs/layout.md)、unit;`mtest list`、`list --static`、`run` 给出相同的类别 | +| `mtest.toml` | `[cases."glob"]` 取库的属性词汇:`kind`、`tags`、`clauses`、`resources`、`artifacts`、`timeout`、`isolation`、`retries`、`diagnose`、`skip`、`expect_fail`;程序声明的优先,列表取并集(0.2 中文件优先) | +| 静态清单 | 独立的包 `mcpplibs.mtest.manifest`:读测试的工具(引擎、编辑器)依赖它而不链接测试程序的库。识别对象类型、文件自己从中派生的类型(`class Fixture : public CTS`)、对象的属性与 `resource`、`clause`、`artifact`;运行时才可判断的(`skip_if` 的条件、时限、派生类型在构造中添加的属性)留给程序的列举 | +| 适配器 | Bash(`--timeout`、`--isolate`、`--retries`、`--diagnose-env`、`--diagnose-timeout`)与 C ABI 表达相同的属性,未声明类别时不再写出 unit | + +**宿主与编辑器。** mcpp(#906):`mcpp new` 的骨架以 `UTest` 写出;`--case` 在列举的用例之下选择其声明的用例(P3.4)——不含通配符的名字原样传给程序,含通配符的模式运行该列举的用例整体(`test_protocol::selection`,e2e 1038 F)。mcpp-ide 后端 1.2.1:mtest 的用例以 `mcpplibs.mtest.manifest` 读出,删去后端自己的读取,0.3 的对象在构建前即可见。 + +**mcppls 的一致性(#79)。** + +- 一致性为工作区成员 `tests/cts`(程序 `mcppls-cts`,与运行器一同进入 `stage/`):一个 fixture 或一个变体为一个 `Fixture` 对象(`CTS` 的派生类型,带服务端日志的 `diagnose`),名字中 `@` 之后为客户端变体,平台以自定义属性 `on{...}`(标签 `on:<平台>`),计时的 fixture 以 `resource{"machine"}`;用例以 `mcppls-conformance run` 运行其 fixture,读取其度量文件,每个未通过的检查为一个 `expect` 失败,带检查的编号与原因。运行器保持为单个 fixture 的命令行,服务端模块不链接测试库。 +- 用例 `the cases and the fixtures agree` 以静态清单读 `conformance.cpp`:每个用例命名一个存在的 fixture;检查限定答复时间(`answer-within`、`within-ms`、`within-seconds`、`within`、`budget`、`initialize-within`,或 stress、typing、tokens-latency)的 fixture,其用例未持有 `machine` 即失败并说明。 +- fixture 移至 `tests/cts/fixtures`;`scenario.json` 保留 fixture 自身的内容(项目、prepare、`requires-mcpp`、`requires-cmake`、检查序列);变体、资源、平台与分组只在代码中。`requires-cmake` 使 prepare 步骤先找到所需的 CMake,服务端保持主机的 PATH,`msvc-bdb.env` 不再需要。 +- 单元测试移至 `tests/unit`,以 mtest 写出(`mcppls-testing` 删除,各成员的测试同样迁移):仓库中只有一套测试框架。 +- CI:一致性矩阵的每一行为 `mtest run --isolate --jobs 2 --tag on:<平台> [--shard i/2]`;行内其余步骤(工具链、xmake、mcpp 的数据库校验)读 `mtest plan` 给出的该行用例;首次运行导入 main 上一次成功运行的该平台各部分的存储,两个部分以相同的历史划分;第二次尝试只重跑失败的用例。`mtest.toml`、`.github/scripts/run-fixture.sh` 与 6 张清单表删除。以特定条件运行单个 fixture 的作业(共享缓存的冷热启动、PRoot、发布的负载)直接调用运行器。 + +**依赖。** mtest 0.3.0 → mcpp-index(#732 增加 `mcpplibs.mtest`、`.manifest`、`.gtest`、`.capi` 0.3.0 的条目)→ mcpp `kMtestRelease = "0.3.0"`(#906)、mcppls #79、mcpp-ide 后端 1.2.1。mcppls 的运行器模块化与目录迁移不依赖新接口,在 mtest 的 CI 期间进行。 + +**判据。** 既有的 mtest 程序不改即构建并给出相同的列举;同一用例的静态清单与运行时列举的类别、资源一致(mtest 的测试比较二者);mcppls 的一致性在 CI 中只由代码与 fixture 目录描述,仓库中不再有用例的清单文件。 + +### W10 mtest 的范式与 mcppls 的测试树(并入本轮) + +**要求。** mcppls 的单元测试、一致性与基准收敛到 `tests/`,全面以 mtest 写出;代码树、语义表达、可读性、性能、功能、数据与分析均超过原有的自建形式;mtest 本身给出一个范式:开发者以最少的代码写出用例,自然得到选择、调度、记录与分析;基于 mtest 的测试从目录、工程到代码都带有可辨认的 mtest 特征。 + +**mtest 的范式(mtest docs/layout.md、lib/docs/writing-tests.md)。** + +| 层次 | 范式 | +|---|---| +| 目录 | `tests/<类别>/<主题>.cpp`:`unit/`、`cts/`、`e2e/`、`bench/`;目录即类别,数据放在类别之下(`tests/cts/fixtures/`、`tests/bench/tasks/`) | +| 工程 | 单元测试一个文件一个程序(失败定位到文件;W8 的测量表明链接不是瓶颈),或以 `[test] programs = "directory"` 一个目录一个程序;需要服务端负载等外部输入的类别为工作区成员(`tests/cts`、`tests/bench`),其程序与运行器一同进入 `stage/` | +| 代码 | 一个文件一个主题;一个对象一个被测行为或场景(`UTest`、`CTS`、`E2E`、`Bench`),名字之后写它需要什么(资源、时限、隔离、重试、诊断、度量的界限);共享的准备是函数体内的局部对象或 `shared`;不写 `main`(mtest 的入口),需要自举子进程的文件写 `main` 并在其末尾 `return run(argc, argv)` | +| 数据 | 用例的度量以 `metric()` 与 `bench::measure()` 报告;文件以 `produced()` 报告;共享对象的构造、进程的时间线、资源与诊断由 runner 记录;存储在运行之间以 main 的产物延续 | +| 分析 | `mtest explain`(失败的位置、原因、复现命令、诊断输出)、`mtest analyze`(最慢、波动、失败、基准)、`mtest gate`(集合与策略)、`--report-json` 的时间线、junit;编辑器经静态清单与列举得到同一棵用例树 | + +**mtest 0.4.0 补足的能力。** 原有形式中由脚本判定的界限(启动时间、令牌延迟、评审的精确率与召回率)改为代码中的声明:属性 `at_most{"ready", 15}` 与 `at_least{"recall", 0.9}`(度量名与上界或下界,单位由度量给出)随用例列出(P6.5:`case` 事件与列举的 `bounds`),库在用例结束时判定,未报告或越界的度量使用例失败,失败信息给出度量与界限;`mtest.toml` 的 `[cases]` 以同名键声明,由引擎判定;与基线的比值仍由策略(P6.2)判定。度量在存储中逐次保留,`mtest analyze trend` 给出各度量跨运行的值、界限与余量。 + +**mcppls 的测试树。** + +``` +tests/ + unit/ 服务端的单元测试:每个主题一个文件、一个程序,UTest 对象为被测单元;自举子进程的文件写 main + cts/ 成员 mcppls-cts:cts.cppm(套件类型 Fixture)与按主题的用例文件 mcpp.cpp、targets.cpp、cmake.cpp、 + xmake.cpp、inferred.cpp、engine.cpp、editing.cpp;一个夹具或其变体一行(@polling、@plain、@vscode、 + @neovim、@zed、@warm 第二次会话复用第一次的缓存、@linked 经符号链接到达工作区);fixtures/、traceability.json + bench/ 成员 mcppls-bench:startup.cpp(冷/热启动,首次跳转以 at_most 界定;release 标签的五次中位数与 + Windows 大型模块工程)、tokens.cpp、review.cpp(每个审查夹具一个用例,规则的精确率与召回率以 + at_least 界定)、tasks.cpp(任务数据的检验为 UTest,代理运行为 Bench,未指定代理时 skip)、 + ux.cpp(真实项目的各阶段为用例);tasks/、review/ 为数据 + harness/ 成员 mcppls-harness:用例驱动服务端的方式(mcppls.harness:一致性运行器的会话与判定、环境约定), + 以及基准的任务、补丁、审查与代理运行模块,连同其单元测试 +``` + +`tools/bench/` 与 `mcppls-devtools bench` 的子命令移入 `tests/bench` 与 `tests/harness`;devtools 不再有 bench, +`measure summary` 删除。CI 的冷/热启动、审查夹具、缓存模型、符号链接工作区等附加步骤成为用例;`bench-validate` +作业改为三平台的 `bench` 作业(`mtest run --tag on:<平台> --exclude-tag release --exclude-tag ux`),release-checks +的性能作业运行 `--tag 'on:<平台>&release'`,`ux` 作业与 nightly 的 ux-systems 运行 `--case <场景>`;各作业保存存储并 +取 main 上一次的存储,`mtest analyze trend` 在步骤摘要中给出各度量的历史与界限。运行器的 `--navigation-budget` 与 +`timing_evidence.py` 删除,界限由用例声明。nightly 的 ux 轮次(候选与基线交替 A B B A、重复编辑阶段、双核轮次)是 +校准预算与比较发布的实验,其统计(分布、合并离散度下的劣化判定)由 `measure budgets`、`measure compare` 在度量文件上 +给出,不属于用例的判定。 + +实现中补足的 mtest 0.4.1:用例体不得返回值(编译期拒绝,由 `main` 改写为对象时的 `return 1;` 暴露);静态清单跨文件 +读取项目的套件类型(`types_of`,`list --static` 先读项目全部 C++ 源文件);标签选择 `a&b` 表示同时具有两个标签。 +mcpp 2026.11.8.1 补足:既是依赖又是开发依赖的框架只提供一次测试入口。mcpp-ide 1.2.1 以项目 tests/ 中声明的套件类型 +读取用例。 + +**与原有形式的比较(判据)。** + +| 维度 | 原有形式 | mtest 形式 | +|---|---|---| +| 代码树 | 测试散在 `tests/`、`conformance/`、`tools/bench/`、`modules/testing/` 与 devtools | `tests/{unit,cts,bench}`,一个类别一处 | +| 语义表达 | `main` 中按顺序执行的 `_test`;fixture 的元数据在 CI 清单与脚本中 | 对象即用例,类型即类别,需要什么写在名字之后 | +| 可读性与清晰 | 用例的平台、资源、客户端变体与预算需读 ci.yml 与 Python | 一个对象一行,平台、资源、变体、界限都在代码中 | +| 性能 | 一致性逐个串行;基准每作业重建 | 按资源并行、按历史分片、失败用例单独重跑 | +| 功能 | 以退出码判定;无列举、无按用例选择、无重试与诊断 | 列举、选择、隔离、时限、重试、诊断、共享对象、时间线 | +| 数据 | 各自的 JSON 文件,格式各异 | 协议事件与存储,度量、产物、时间线统一 | +| 分析 | 人读日志;预算由脚本比较 | `explain`、`analyze`、`gate`、junit、编辑器用例树 | + +判据:mcppls 仓库中不再有测试框架、用例清单或预算脚本的自建实现;`mtest list` 列出全部用例且与静态清单一致;CI 的一致性、单元、基准作业均经 mtest 运行并保存存储。 + +## 3. 依赖 + +``` +W1: clangd-0.0.21 ──(engine lock)──▶ mcppls-0.0.21 ──▶ xim bump(mcppls) + ▲ ▲ + │ └── W5 deflate (若探针确认) + └── win32 zlib 验证 + plugins-0.21.0 ──▶ mcpp-index ──▶ close #887 + mcpp-ide-1.2.0 ──▶ xim bump(mcpp-ide, mcpp-ide-nvim) + 全部发布 ──▶ xlings 全生态验证 ──▶ issue 关闭与汇报 + +W2 (mcpp next) ──┬─▶ W4 mtest 的 mcpp 集成与 `mcpp new` 模板 + └─▶ #903/#904 后,生态清单与锁不再需要手工修正 +W3 (os 0.4.0) ──▶ mcppls / mcpp-ide 迁移(S2 写法、检查禁止 is_<平台>) +W4 (mtest) ──▶ 首批五个项目迁移 ──▶ CI 时间目标的测量 +``` + +W2、W3、W4 互不阻塞,可在 W1 的 CI 等待期间并行设计与实现;W4 的 mcpp 集成依赖 W2 的协议运行器。 + +## 4. 尚未收敛的事项 + +| 编号 | 事项 | 状态 | +|---|---|---| +| U1 | Q5:`is_<平台>` 与两层判断并存 | 已确认 | +| U2 | darwin 比值超限的原因与修法 | 已确认为 zlib;W5 实施中 | +| U3 | CLion 2026.2 无 JCEF 的说明不可见 | 第一轮诊断:`visible=false selected=Module Graph`;第二轮记录可见性时间线、窗口类型与自动隐藏 | +| U4 | xlings 全生态验证的方式 | 已确认:临时目录中重定向 `HOME` 等变量(W1 的验证步骤) | +| U5 | mcpp-vscode 的归档说明 | 先前约定只在确认后执行 | +| U6 | mcppls-clangd 的 CHANGELOG 记录哪次对比运行 | 已确定(维护者决定):记录运行 42464678787;darwin-arm64 三次测量均因 runner 离散度无效,以与 0.0.20 的比较放行(darwin 绝对冷启动与处理器时间不高于 0.0.20;本机两版镜像交替四对,各量差异不超过 3%),并在 CHANGELOG 与 performance-policy 中注明;CI 产物经固定的 draft release `ci-artifacts` 供本地分析 | +| U8 | `os::is_image` 的语义不明确 | 已确定:改名 `is_openkal`,`is_image` 为弃用别名(W3) | +| U9 | API 生命周期的声明与提示 | 已确定:W7 | +| U7 | mcpplibs/os#19 的 hosted 行 `effective_user()` 为 1000 而 `id -u` 为 1001 | 已修复(`ee1d71e`):hosted 安排中 C 库为 openkal-musl,其 `geteuid` 为常量 1000、无 `umask`;Linux 与 macOS 改为内核调用(与描述符、信号相同的 `src/posix/kernel.h`);`unshare -r` 下旧实现为 1000/0,新实现为 0/0;CI 全部通过 | + +## 5. 全局与生态视角的审查 + +| 视角 | 审查所见 | 处理 | +|---|---|---| +| 一致性 | 原则 P3–P6 刚在四个应用仓库落地,mcpp 本体与 os 尚未(mcpp 仍有带引号的键与 `"=0.3.1"`;os 依赖 openkal 用范围) | W2 统一 mcpp 本体;os 的 openkal 范围由平台版本组给出精确性(D12),在 os 文档中写明,不改为精确版本以免与组冲突 | +| 一致性 | 生态有四套测试方式与三套自写测试工具 | W4 收敛为 mtest;迁移在各项目的版本 PR 中整体完成,不保留双套 | +| 架构 | 平台判断、平台事实、系统接口三条规则分属 os、应用检查与插件文档 | 规则的权威文本在 os 文档(P4、P5)与 mcpp docs(P3);各仓库的检查引用同一文本 | +| 架构 | mtest 的通用性可能被 mcpp 的需求带偏 | 规范与引擎不引用 mcpp 的概念;mcpp 是宿主适配之一 | +| 稳定性 | GCC 16 的模块缺陷已多次出现(#905;早先的 BMI 次序、MSVC 的 path 迭代器) | 规避写入插件开发规则("经由 os 的函数不内联");W4 的 mtest 引擎同样遵循;#905 跟踪上游 | +| 稳定性 | 映像为唯一发布形态,任何映像特有的缺陷都直接到达全部用户 | 测试以映像在三个系统上运行(D18);对比运行在发布前度量映像与原生的差距(S6);P7 使政策不被绕过 | +| 性能 | 映像相对原生的冷启动与解压差距(F6) | 上限不放宽;W5 在映像中提供与系统 libz 相当的 deflate | +| 兼容性与无感升级 | os 0.4.0、mcpp 下一版本、mtest 的引入都触及生态的公共写法 | 只增加接口;精确版本使升级显式;L0 使现有测试无须改动;mcpp 的现有 `mcpp test` 行为保持 | +| 跨平台 | Windows 的进程启动开销影响 mtest 的隔离与 os 的可移植通道 | 隔离按声明;平台事实的查询结果缓存、只在调用时发生、移出启动关键路径 | +| 用户体验 | 映像用户在 W3 之前失去 Dev Drive 提示与 PRoot 识别 | mcppls 0.0.21 在日志与文档中说明;W3 后恢复 | +| Agent 可操作性 | 本轮 CI 的观察依赖手工轮询与日志检索 | W4 的 `mtest ci`、`mtest explain` 直接针对此项;在此之前,各 PR 的失败以结构化产物(如 CLion 的诊断文件)保存 | +| 测试覆盖 | 映像中的平台事实、插件的 os 接入、IDE 的无 JCEF 路径都是本轮才被测试覆盖的路径 | os 的映像测试(W3);插件 CI 的源文件检查与各消费者;CLion 两个版本的产物诊断 | +| 过程 | 本轮出现过违反性能政策的重跑与未确认即发布的上游 issue | 已记录;P7 使前者由工具阻止;后者已有规则(只在 mcpp 内以 upstream-bug 记录) | +| 范围 | W2–W4 的范围大于 #885 | 三者各有独立设计与评审,不并入 W1;W1 只包含已确定的修复与收尾 | + +## 6. 第二轮审查(多视角) + +| 视角 | 审查所见 | 处理 | +|---|---|---| +| 依赖 | W5 的条件尚未判定:探针若排除 zlib,darwin 的修法未知,W1 的关键路径因此不确定 | 探针结果出来即判定:确认则实施 W5;排除则按冷启动的装载与页错误继续定位(冷启动 `page_faults` 比值 2.74–2.87),不放宽上限,并以新的探针缩小范围 | +| 依赖 | mcppls 0.0.21 依赖 clangd 0.0.21 的引擎锁,clangd 的阻塞会传递给 mcppls 与其 xim bump | 两者的其余部分先完成并通过 CI;锁的更新是最后一个提交 | +| 一致性 | #874 的主体在 2026.10.31.3 已实现,issue 仍开放,生态无人复测 | W2 以报告者的工作区复测并关闭;复测需要 10–25 GB,在 CI 运行器上进行(本机的磁盘余量不足,见下) | +| 资源 | 本机磁盘在本轮中一度只余 4 GB(`/tmp` 100%)。清理了已完成的临时目录(探针、已推送的 mwrap 仓库、研究目录中的构建产物)、各工作树只保留最近一次构建、`cache gc --older-than 2d`(2.6 GiB)与两天前的 std 构建(约 4.3 GiB),回到约 21 GB;其他会话的项目未触及 | 继续遵守"一次一个重任务";大的复测(#874、对比运行)放在 CI;W4 的 mtest 存储设保留期 | +| 稳定性 | 验证环境若泄漏会话的 shim 变量,会把旧版本误判为索引问题 | W1 的验证步骤明确 `env -u` 的变量与中性工作目录 | +| 用户体验 | 验证只在 Linux 上进行,Windows 与 macOS 的安装路径没有真实覆盖 | 发布 CI 的 `ci-fresh-install` 覆盖三个系统;本机验证补 Linux 的完整链路与两个镜像 | +| 测试覆盖 | 本轮的修复(插件的 os 接入、IDE 的 `check-sources`、mcppls 的 #80)只在各自 CI 中验证 | 全局审查时按仓库核对:每项修复有对应的测试或检查,并在 PR 描述中列出 | +| 过程 | 主工作树曾被一条命令置为分离 HEAD(已恢复到 `main` 并快进) | 只在专用工作树中执行检出类命令 | + +## 7. 待审查的问题 + +- **T4** 已确认:#874 归属本方案(W2),方案确定后与其他未收敛事项一并重新拆分实施计划。 +- **T5** 已确认:W6 并入本轮,mcppls #79 以 `0.21.0` 发布,mcpp-ide #5 加入 CI 分类,各一次发布。原问题:W6 的采用时机。建议并入本轮:mcppls #79 以 `0.21.0` 发布(版本规则、devtools、发布清单、插件的系列检查、组件表与 CI 分类、负载重用),mcpp-ide #5 加入同样的 CI 分类。这符合"确定的事项不拆到以后",代价是两个 PR 再增加一轮 CI。另一做法是作为 mcppls 0.22.0 与 mcpp-ide 1.3.0 的内容,本轮保持 `0.0.21`。 + +- **T6** 已确认:W8 并入本轮,以追加提交进入 mcpp #906、mcppls #79,mtest 以 0.2.0(mtest#3)发布;既有 e2e 不改写,新增与改动的 e2e 以 `mtest_case` 写判据;PR 运行受影响集与冒烟集,main 与发布运行全部;参数由先行的探针测定。方案见 W8。 + +## 8. 实施计划 + +### 8.1 每个仓库每个版本一个 PR + +| 仓库 | 版本 | PR / 分支 | 内容 | 工作流 | +|---|---|---|---|---| +| mcppls-clangd | 0.0.21 | #18 | 现有内容;win32 zlib;W5(若探针确认);CHANGELOG 记录对比运行 | W1、W5 | +| mcppls | 0.0.21 | #79 | 现有内容;引擎锁为 clangd 0.0.21(最后一个提交) | W1 | +| mcpp-ide | 1.2.0 | #5 | 现有内容;CLion 无 JCEF 测试 | W1 | +| mcpp-plugins | 0.21.0 | #52 | 现有内容(CI 已通过) | W1 | +| mcpp-index | — | 一个 PR | plugins 0.21.0;W5 的 deflate 包(若需要);mcpplibs.os 0.4.0;mcpplibs.mtest 0.1.0 | W1、W3、W4、W5 | +| mcpplibs/os | 0.4.0 | `release/0.4.0` | S2(`can_run_on`、`on

`)、S3(R5 修订与映像中的平台事实)、文档、映像测试 | W3 | +| mcpp-community/mtest | 0.1.0 | 新仓库 | 规范 v1、C++ 库(unit、bench、cts、e2e)、引擎(运行、存储、查询、explain、plan、ci、拓扑导出)、C ABI 报告库、GoogleTest 与脚本适配;自身以 mtest 测试;ok 映像发布 | W4 | +| mcpp | 2026.11.8.1 | #906(草稿)`release/2026.11.8.1` | #903、#904(含源文件检查)、S1 文档与 tier 评估、#874 的复测与剩余期望、S7 的协议运行器与 `mcpp.core` 上下文注入与构建图查询、`mcpp new` 默认使用 mtest、三份设计文档 | W2 | +| xim-pkgindex | — | 各发布的 bump PR | mcpp、mcppls、mcpp-ide、mcpp-ide-nvim | 各工作流 | +| mtest | 0.2.0 | mtest#3 | W8 的 mtest 部分;mcpp #906 与 mcppls #79 的 W8 部分以追加提交进入;探针 #909 先行 | W8 | +| mtest | 0.4.0 | `release/0.4.0` | W10:`at_most`/`at_least` 与 P6.5、`analyze trend`、范式文档(已发布);mcppls #79 随之更新 | W10 | +| mtest | 0.3.0 | mtest#4 | W9:对象即用例、类型即类别、作用域属性、运行方式的属性、`shared` 与追踪、P3.3/P3.4/P5.2、`mtest.toml` 的同一词汇、静态清单、布局;mcpp-index #732、mcpp #906、mcppls #79 随之更新 | W9 | + +首批迁移(mcppls、mcpp-ide、mcpp-plugins、mcppls-clangd 迁移到 os 0.4.0 与 mtest)是这些项目各自的下一个版本,依赖 os 0.4.0 与 mtest 0.1.0 的发布;迁移前以 mtest 的 CI 汇总测量正式基线(W4 §10.2)。 + +### 8.2 依赖与波次 + +``` +波次 1(进行中) plugins 0.21.0 ─▶ index ─▶ close #887 + mcpp-ide 1.2.0 ─▶ xim bump + clangd 0.0.21 ◀─ W5(条件)◀─ darwin 探针 + └─▶ mcppls 0.0.21 ─▶ xim bump +波次 2(与波次 1 并行) + os 0.4.0 ─────────────┐ + mtest 0.1.0 ──────────┼─▶ index ─▶ mcpp 2026.11.8.1(`mcpp new` 模板引用已发布的 mtest) + mcpp 2026.11.8.1 的其余部分(#903、#904、S1、#874、S7 运行器)可先行实现 +波次 3 基线测量 ─▶ 首批迁移(各项目下一版本)─▶ 迁移后测量(M3) +收尾 各波次发布 ─▶ xlings 全生态验证 ─▶ issue 关闭 ─▶ 汇报 +``` + +### 8.3 次序约束 + +- mtest 0.1.0 以已发布的 mcpp 2026.11.7.2 构建与测试(L0 与其自身引擎),不依赖 mcpp 2026.11.8.1;mcpp 2026.11.8.1 的协议运行器以 mtest 规范 v1 为准,两者在同一波次设计。 +- os 0.4.0 只增加接口;mtest 引擎使用其 `can_run_on` 与 `host()`,因此 mtest 0.1.0 依赖 os 0.4.0 的发布。 +- 每次发布后:取消 main CI、发布、GitCode 镜像(本地 gtc)、重新触发 main CI、索引或 bump PR 合入、CDN 生效后再进行依赖它的发布。 + +## 9. 实施记录(第六版) + +### 9.1 新的发现与处理 + +| 发现 | 处理 | 版本 | +|---|---|---| +| Windows 的线程以 x87 双精度(控制字 0x027F)开始,okrun 进入映像时未建立 System V 的浮点初始状态;映像中 openkal-musl 的 `strtod("0.4")` 得到 `0x1.99999999998p-2`,mtest 一致性套件 P6.1 只在 Windows 失败 | SPEC 12.2 增加"浮点状态"一行、12.3 写明 loader 的义务;okrun 在入口、初始化与终结函数和每个上下文中建立该状态,openkal-bind 的调用在其中进行并恢复调用方的状态;`fpstate` 与 `bind` 示例在四个系统上检验 | openkal#63、openkal-image 0.5.3、openkal-platform 0.24.4、okrun 0.5.3(xim) | +| 平台组发布一个新成员后,早于 2026.11.7.1 的 mcpp 按范围取最新成员再以版本组拒绝(os 与 clangd 的 CI 用 2026.11.5.1) | 生态 CI 使用 mcpp 2026.11.7.2(在版本组内解析) | os#19、clangd#18、mcpp-index#730 | +| mcpp 的三个原有缺陷:依赖的 entry 导入 `std` 时 std 模块不构建;有起点的测试仍链接 dev-dependency 之下的 `gtest_main.cc`;共享库形式的 dev-dependency 不链接 | 均在 mcpp 2026.11.8.1 修复(e2e 1037) | #906 | +| 父包与其命名空间下的包在同一张表中(`mcpplibs.mtest` 与 `mcpplibs.mtest.gtest`)只能以引号键写出,而 #904 把带引号的点分键列为弃用 | 唯一写法的引号键不报弃用(e2e 1032、docs/05、docs/51) | #906 | +| mcppls 的日志状态在进程退出时被析构,退出期间写日志的分离线程在 Linux arm64 映像中 SIGSEGV | 日志状态不析构 | mcppls#79 | +| Linux e2e 的耗时表已过时(重命名的测试按中位数计),一个分片超出 22 分钟 | 由各分片报告重新测得三张耗时表;Linux 改为 8 个分片 | #906 | +| `mcpp new` 以 mtest 为 dev-dependency 之后,`mcpp build` 与 `mcpp test` 成为两个配置:mtest 经 mcpplibs.os 到达 openkal 的内核接口,配置因此带上 `__OPENKAL__`;dev-dependency 的包含目录进入包自己源文件的命令。结果是被测试的代码与发布的代码命令不同、两个构建目录各编译一遍、构建之后的编译数据库没有测试条目(e2e 77、783 失败),在 build 与 test 之间切换时重编包自己的源文件 | dev-dependency 只到达测试单元(`testBuild`);只经 dev 边到达的内核接口只给 dev-only 包与测试单元,配置不变;C 库与 C++ 运行时仍属配置。`mcpp dyndep` 不改写未变的输出,规则的 `restat` 由此生效 | #906(docs/08、e2e 1037 K) | +| 测试计划修剪依赖中定义 `main` 的源文件时覆盖了全部依赖,openkal-musl 的映像入口 `okm_image.c` 声明 `main` 而被去掉,`--target x86_64-openkal` 的测试映像缺少运行时符号(e2e 1033、951) | 只修剪 dev-dependency 及其之下的包 | #906 | +| mcpp-ide 的发布运行 `startup_failure`:ci.yml 的作业请求 `actions: read`,被调用工作流的权限不能超过调用方 | release.yml 的 `ci` 作业授予 `actions: read` | mcpp-ide#6 | +| mcpp-plugins 0.21.0 仍精确依赖 mcpplibs.os 0.3.1,与 mcpp、mcppls、mcpp-ide 的 0.4.0 不一致 | 改为 0.4.0 后发布 | mcpp-plugins#52 | + +### 9.2 S7 与 W4 的实现 + +- **mcpp 2026.11.8.1(#906)**: + - 测试协议运行器:`mcpp test` 的用例记录、`--case`、`--cases`、`--seed`;未实现协议的程序收不到协议变量;`--no-run` 的记录带程序与源文件;特性 `test-protocol`。 + - 入口键 `test-protocol`、`test-main`、`test-guide`:不定义 `main` 的测试以提供 `test-main` 的入口为起点,由文件的导入在多个提供方之间选择;`gtest_main.cc` 照旧作为测试起点。 + - `mcpp test --guide`:列出测试框架及其指南与仓库,以及用例相关的命令;失败的运行指向它(协议 P2.5)。 + - `mcpp::context`;`mcpp new` 的测试使用 mtest,`--template lib` 生成模块、它导出的分区及分区的实现。 +- **mtest 0.1.0**:已发布(mcpp-community/mtest#1);引擎以 ok 映像的通用包发布(xim `mtest`);`mcpplibs.mtest`、`.gtest`、`.capi` 进入 mcpp-index;协议增加 P2.5(指南)。 +- **mcpp-ide 1.2.0(#5)**:实现协议的程序由 `mcpp test --cases` 列出、由 `--case` 运行,结果取自 mcpp 的记录;mtest 的用例在构建前即由源码中的 `"name"_test` 字面量给出;未实现协议的框架仍按 `spec/test-frameworks.json`。 +- **已确认**:mtest 构建插件的语法糖(静态用例清单与清单属性优先,其余能力各自定稿),归入 W8 的 mtest 一项。 + +### 9.3 W3 的完成 + +mcpplibs.os 0.4.0 已发布并进入 mcpp-index;mcppls、mcpp-ide、mtest 与 mcpp 迁移到 0.4.0。mcppls 的映像经 `os::on

` 询问 Dev Drive 与 PRoot,此前映像对二者均不作判断。 + diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 83417d657..91f26e835 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 --- ``` -353 records. +355 records. ## By subject @@ -75,6 +75,8 @@ Records that declare one. Everything else is listed by date below. ### ecosystem +- [生态顶层方案:#885 收尾之后的工作流、原则与依赖](2026-11-08-ecosystem-top-level-design.md) — active +- [#885 收尾补充方案:目标角色、平台事实、系统接口与版本书写](2026-11-08-ecosystem-885-supplement-design.md) — active - [生态 #885 收尾设计:核心、插件、openkal、语言服务与编辑器的一轮闭合](2026-11-07-ecosystem-885-closeout-design.md) — active - [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — active - [控制契约、并发与性能、单一 C++ 运行时,以及 IDE 生态(生态级,#838、#839)](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md) — active @@ -177,6 +179,8 @@ Records that declare one. Everything else is listed by date below. ### 2026-11 +- [生态顶层方案:#885 收尾之后的工作流、原则与依赖](2026-11-08-ecosystem-top-level-design.md) — active +- [#885 收尾补充方案:目标角色、平台事实、系统接口与版本书写](2026-11-08-ecosystem-885-supplement-design.md) — active - [生态 #885 收尾设计:核心、插件、openkal、语言服务与编辑器的一轮闭合](2026-11-07-ecosystem-885-closeout-design.md) — active - [Launch configuration and named test runners](2026-11-06-launch-configuration-and-named-test-runners.md) — active - [Compiler attempt lifetime and output recovery](2026-11-06-compiler-attempt-recovery.md) — active diff --git a/.github/actions/e2e-mtest/action.yml b/.github/actions/e2e-mtest/action.yml new file mode 100644 index 000000000..4a52676c7 --- /dev/null +++ b/.github/actions/e2e-mtest/action.yml @@ -0,0 +1,92 @@ +name: e2e-mtest +description: > + mtest for the e2e suite (design 2026-11-08 W8; tests/e2e/mtest.toml): the + release this action pins, its sha256 checked, and the stores main's last + successful CI run kept for this host, taken into tests/e2e/.mtest so that + `--shard` divides the suite by measured time and a run orders its tests + longest first. Exports E2E_MTEST for run_all.sh. A run with no store of main + divides by count, and its own store becomes the next run's history. + +inputs: + host: + description: the host's name in the stores' artifact names (linux, macos, windows) + required: true + version: + description: the mtest release + required: false + default: '0.4.0' + sha256: + description: sha256 of mtest--openkal-bundle-all.tar.gz + required: false + default: 'b62e4ac7cd76b20dbdc48a5d041d377444e8815ee7a4ff1e7686145d13acc925' + token: + description: a token that reads this repository's actions + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: mtest + shell: bash + env: + MTEST_VERSION: ${{ inputs.version }} + MTEST_SHA256: ${{ inputs.sha256 }} + run: | + set -euo pipefail + archive="mtest-$MTEST_VERSION-openkal-bundle-all.tar.gz" + temp="$RUNNER_TEMP"; python=python3 + if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi + curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o "$temp/$archive" \ + "https://github.com/mcpp-community/mtest/releases/download/v$MTEST_VERSION/$archive" + actual=$("$python" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$temp/$archive") + [ "$actual" = "$MTEST_SHA256" ] || { echo "::error::$archive: sha256 $actual, the action pins $MTEST_SHA256"; exit 1; } + mkdir -p "$temp/mtest" && tar xzf "$temp/$archive" -C "$temp/mtest" + launcher="$temp/mtest/mtest-$MTEST_VERSION-openkal-bundle-all/bin/mtest" + [ "$RUNNER_OS" = Windows ] && launcher="$launcher.exe" + "$launcher" --version + echo "E2E_MTEST=$launcher" >> "$GITHUB_ENV" + # The pull request's change, for the `pr` set: the base's tip and HEAD's + # parents, so that `git diff ...HEAD` has its merge base. When they + # cannot be fetched, mtest selects every test and says why. + - name: The base of the change + if: github.base_ref != '' + shell: bash + run: | + git fetch --no-tags --depth=1 origin "+refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" || true + git fetch --no-tags --deepen=1 origin || true + - name: The stores of main's last CI run + 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' 2>/dev/null || true) + if [ -z "$run" ]; then echo "no successful run of main: the suite is divided by count"; exit 0; fi + rm -rf "$RUNNER_TEMP/e2e-stores" + if ! gh run download "$run" -R "$GITHUB_REPOSITORY" -p "e2e-store-$HOST-*" -D "$RUNNER_TEMP/e2e-stores" 2>/dev/null; then + echo "main's run $run kept no store for $HOST: the suite is divided by count"; exit 0 + fi + for store in "$RUNNER_TEMP"/e2e-stores/*/; do + (cd tests/e2e && "$E2E_MTEST" store import --from "$store") + done + echo "history of main's run $run: $(ls "$RUNNER_TEMP/e2e-stores" | wc -l) store(s)" + # The coverage map of the last nightly measurement (e2e-coverage.yml), on + # the host it was measured on: the sources each test's run executed there. + # 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' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow e2e-coverage.yml --status success --limit 1 \ + --json databaseId -q '.[0].databaseId' 2>/dev/null || true) + if [ -z "$run" ]; then echo "no coverage map: every test is affected"; exit 0; fi + rm -rf "$RUNNER_TEMP/e2e-coverage" + gh run download "$run" -R "$GITHUB_REPOSITORY" -n e2e-coverage-linux -D "$RUNNER_TEMP/e2e-coverage" || exit 0 + (cd tests/e2e && "$E2E_MTEST" coverage import --from "$RUNNER_TEMP/e2e-coverage/coverage.json") diff --git a/.github/tools/check_e2e_coverage.py b/.github/tools/check_e2e_coverage.py index c98c9869b..fae518366 100644 --- a/.github/tools/check_e2e_coverage.py +++ b/.github/tools/check_e2e_coverage.py @@ -30,9 +30,15 @@ An exception for a test that ran, or for a test that does not exist, also fails the check, so that the list cannot outlive the reason it records. +Some claims need a test to pass on particular hosts, not on some host: docs/21 +calls a target row `verified` when CI builds and runs its artefact, and the +`*-openkal` rows claim that for every system. tests/e2e/host-requirements.tsv +lists such a test with the hosts its result must come from; a listed test that +did not pass on one of them, or that does not exist, fails the check. + With --timings-out DIR it writes `.tsv`, the measured duration of every test that ran, merged across that host's shards, in the format run_all.sh reads -from E2E_TIMINGS; refreshing tests/e2e/timings/ is copying those files. A report +from E2E_TIMINGS (a run without mtest; CI's shards divide by mtest's history). A report file is named `e2e-report--.tsv`. Usage: @@ -64,6 +70,19 @@ def read_reports(directory: Path): yield m["host"], int(m["shard"]), parts[0], parts[1], ms, parts[3] if len(parts) > 3 else "" +def read_host_requirements(path: Path) -> dict[str, set[str]]: + """`\t ...\t` lines: the hosts a test must pass on.""" + out: dict[str, set[str]] = {} + if not path.exists(): + return out + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.startswith("#"): + continue + parts = line.split("\t") + out[parts[0].strip()] = set(parts[1].split()) if len(parts) > 1 else set() + return out + + def read_exceptions(path: Path) -> dict[str, str]: out: dict[str, str] = {} if not path.exists(): @@ -109,8 +128,10 @@ def main() -> int: workflows = workflow_tokens([p.read_text(encoding="utf-8") for p in sorted((root / ".github" / "workflows").glob("*.yml"))]) exceptions = read_exceptions(root / "tests" / "e2e" / "coverage-exceptions.tsv") + required = read_host_requirements(root / "tests" / "e2e" / "host-requirements.tsv") ran: dict[str, set[str]] = defaultdict(set) + passed: dict[str, set[str]] = defaultdict(set) skipped: dict[str, set[str]] = defaultdict(set) shard_ms: dict[tuple[str, int], int] = defaultdict(int) timings: dict[str, dict[str, int]] = defaultdict(dict) @@ -119,6 +140,8 @@ def main() -> int: reports += 1 if status in RAN: ran[test].add(host) + if status == "pass": + passed[test].add(host) shard_ms[(host, shard)] += ms timings[host][test] = ms elif status == "skip": @@ -143,6 +166,15 @@ def main() -> int: for test in exceptions: if test not in tests: stale.append(f"{test} is excused but does not exist") + unmet = [] + for test, hosts in sorted(required.items()): + if test not in tests: + stale.append(f"{test} is required on hosts but does not exist") + continue + for host in sorted(hosts - passed[test]): + why = [d for d in skipped[test] if d.startswith(f"{host}: ")] + unmet.append(f"{test} did not pass on {host}" + f"{' (' + '; '.join(why) + ')' if why else (' (it ran and did not pass)' if host in ran[test] else ' (no report of it there)')}") print(f"{len(tests)} tests: {counts['ran']} ran on a shard, {counts['job']} run by a " f"dedicated job, {counts['excused']} excused, {len(uncovered)} uncovered") @@ -152,6 +184,8 @@ def main() -> int: print(f"UNCOVERED: {line}") for line in stale: print(f"STALE EXCEPTION: {line}") + for line in unmet: + print(f"REQUIRED ON HOST: {line}") if args.timings_out: out = Path(args.timings_out) @@ -159,7 +193,7 @@ def main() -> int: for host, table in timings.items(): (out / f"{host}.tsv").write_text( "".join(f"{t}\t{ms}\n" for t, ms in sorted(table.items())), encoding="utf-8") - return 1 if uncovered or stale else 0 + return 1 if uncovered or stale or unmet else 0 if __name__ == "__main__": diff --git a/.github/tools/check_include_regions.sh b/.github/tools/check_include_regions.sh index f7570fb12..f16279000 100755 --- a/.github/tools/check_include_regions.sh +++ b/.github/tools/check_include_regions.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# check_include_regions.sh — headers are included only where a system has no -# module for what is needed (design 2026-11-07 D10). +# check_include_regions.sh — headers are included, and the preprocessor +# chooses, only where a system has no module for what is needed (design +# 2026-11-07 D10; design 2026-11-08 S4). # # mcpp imports the standard library (`import std`), its dependencies as modules # (nlohmann.json, mcpplibs.os, the mwrap wrappers of zlib, zstd, xz and of the @@ -10,6 +11,11 @@ # directive anywhere else is a header a module should provide: depend on the # package that wraps it, or wrap it (mcpp-index docs/mwrap.md). # +# The same holds for a conditional directive (`#if`, `#ifdef`, `#ifndef`, +# `#elif`) and a macro definition (`#define`): a platform is chosen by +# `if constexpr` over mcpplibs.os and by the per-system sources of mcpp.toml, +# never by the preprocessor. +# # Text that only looks like a directive is not one: a raw string (the source # mcpp writes for a launcher or a template) and a comment are skipped. Tests # (`tests/`, a module's `tests/`) are outside the rule. @@ -20,10 +26,11 @@ import re, sys from pathlib import Path ALLOWED = {Path("src/native/posix/lock.cpp"), Path("src/native/windows/lock.cpp")} +DIRECTIVE = re.compile(r'#\s*(include|if|ifdef|ifndef|elif|elifdef|elifndef|define)\b') RAW = re.compile(r'(?:u8|u|U|L)?R"([^()\\ ]{0,16})\(') def directives(text): - """Line numbers of `#include` directives outside comments and raw strings.""" + """(line, directive) of each preprocessor directive outside comments and raw strings.""" out, i, n, line = [], 0, len(text), 1 at_line_start = True while i < n: @@ -45,7 +52,8 @@ def directives(text): j += 2 if text[j] == '\\' else 1 i = j + 1; at_line_start = False; continue if at_line_start and c == '#': - if re.match(r'#\s*include\b', text[i:]): out.append(line) + d = DIRECTIVE.match(text, i) + if d: out.append((line, d.group(1))) if not c.isspace(): at_line_start = False i += 1 return out @@ -54,8 +62,9 @@ bad = 0 for path in sorted(list(Path("src").rglob("*.cpp*")) + list(Path("modules").rglob("*.cpp*"))): if "tests" in path.parts or "test" in path.parts or path in ALLOWED: continue - for line in directives(path.read_text(encoding="utf-8", errors="replace")): - print(f"{path}:{line}: #include outside the system lock files; import a module instead") + for line, d in directives(path.read_text(encoding="utf-8", errors="replace")): + hint = "import a module instead" if d == "include" else "choose with if constexpr or a per-system source" + print(f"{path}:{line}: #{d} outside the system lock files; {hint}") bad += 1 for path in ALLOWED: if not path.exists(): diff --git a/.github/tools/check_modules_wiring.sh b/.github/tools/check_modules_wiring.sh index 29d385002..70e2139a3 100755 --- a/.github/tools/check_modules_wiring.sh +++ b/.github/tools/check_modules_wiring.sh @@ -10,7 +10,8 @@ # when they are forgotten: # # 1. modules//mcpp.toml the package itself -# 2. mcpp.toml [dependencies.mcpp] what makes it importable +# 2. mcpp.toml [dependencies] `mcpp. = { path = "modules/" }`, +# what makes it importable # 3. mcpp.toml [workspace] members what makes `-p` and `mcpp test` see it # 4. the two xmake source lists the bootstrap and the benchmark, which # have no package manager and compile the @@ -62,8 +63,9 @@ for d in "${dirs[@]}"; do || bad "$d/mcpp.toml has namespace = \"${ns:-}\"; modules of this project use \"mcpp\" so they do not squat bare registry names" # (2) importable - grep -qE "^[[:space:]]*$name[[:space:]]*=[[:space:]]*\{[[:space:]]*path[[:space:]]*=[[:space:]]*\"modules/$name\"" mcpp.toml \ - || bad "mcpp.toml [dependencies.mcpp] has no entry for '$name' — it will not be importable" + grep -qE "^[[:space:]]*mcpp\.$name[[:space:]]*=[[:space:]]*\{[[:space:]]*path[[:space:]]*=[[:space:]]*\"modules/$name\"" \ + <(sed -n '/^\[dependencies\]/,/^\[/p' mcpp.toml) \ + || bad "mcpp.toml [dependencies] has no 'mcpp.$name = { path = \"modules/$name\" }' — it will not be importable" # (3) addressable grep -qF "\"modules/$name\"" <(sed -n '/^\[workspace\]/,/^\[/p' mcpp.toml) \ diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 8c14dbc46..25403bbe7 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. Six shards, assigned by measured -# duration (tests/e2e/timings/linux.tsv, rule R4 of the 2026-10-02 CI record): -# the table holds about 72 minutes of tests (2026-10-30, with the target-set -# and packaging tests of 2026.10.30.1), about 12 a shard, +# 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, # 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). @@ -17,16 +17,21 @@ on: jobs: e2e: - name: e2e ${{ matrix.shard }}/6 (linux x86_64, self-host) + name: e2e ${{ matrix.shard }}/8 (linux x86_64, self-host) runs-on: ubuntu-24.04 timeout-minutes: 45 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6] + shard: [1, 2, 3, 4, 5, 6, 7, 8] env: - E2E_SHARD: ${{ matrix.shard }}/6 - E2E_TIMINGS: tests/e2e/timings/linux.tsv + E2E_SHARD: ${{ matrix.shard }}/8 + # 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_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 @@ -79,6 +84,10 @@ jobs: XLINGS_HOME="$MCPP_HOME/registry" "$MCPP_VENDORED_XLINGS" install \ mcpplibs:cmdline@0.0.1 mcpplibs:cmdline@0.0.2 -y + - uses: ./.github/actions/e2e-mtest + with: + host: linux + - name: E2E suite # About twice the shard's budget of ten minutes (R4): reached only by a # hang. The per-test 600 s limit in run_all.sh names the test that hung. @@ -98,6 +107,18 @@ jobs: "$MCPP" self config bash tests/e2e/run_all.sh + # The shard's store: main's are the history the next runs divide the suite by. + - name: Upload the shard's store + if: always() + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: e2e-store-linux-${{ matrix.shard }} + path: tests/e2e/.mtest + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 + # One file, no glob: the report the e2e-coverage job of ci.yml reads. - name: Upload the shard's report if: always() diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index ac180b1c3..8fa3f4650 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -48,9 +48,20 @@ jobs: with: host: linux-x86_64 + # A pull request runs the tests its change affects (`mcpp test --since`, + # 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 != '' + 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_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, @@ -81,7 +92,7 @@ jobs: echo "members: $(echo $members | tr '\n' ' ')" for m in $members; do echo "::group::mcpp test -p $m" - "$MCPP_FRESH" test -p "$m" + "$MCPP_FRESH" test -p "$m" ${MCPP_TEST_SINCE:+--since "$MCPP_TEST_SINCE"} echo "::endgroup::" done diff --git a/.github/workflows/ci-macos-e2e.yml b/.github/workflows/ci-macos-e2e.yml index 0d552eb39..abde77a9b 100644 --- a/.github/workflows/ci-macos-e2e.yml +++ b/.github/workflows/ci-macos-e2e.yml @@ -2,7 +2,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 -# (tests/e2e/timings/macos.tsv, rule R4 of the 2026-10-02 CI record), each +# (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. # @@ -55,7 +55,9 @@ jobs: # 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_TIMINGS: tests/e2e/timings/macos.tsv + # 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_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). @@ -76,6 +78,10 @@ jobs: with: host: macos-arm64 + - uses: ./.github/actions/e2e-mtest + with: + host: ${{ matrix.image == 'macos-15' && 'macos' || matrix.image }} + - name: E2E suite # Twice a shard's budget of about nine minutes (R4); the per-test 600 s # limit in run_all.sh names a test that hangs. @@ -98,6 +104,18 @@ jobs: set -o pipefail bash tests/e2e/run_all.sh 2>&1 | tee "$RUNNER_TEMP/e2e-suite.log" + # The shard's store: main's are the history the next runs divide the suite by. + - name: Upload the shard's store + if: always() + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: e2e-store-${{ matrix.image == 'macos-15' && 'macos' || matrix.image }}-${{ matrix.shard }} + path: tests/e2e/.mtest + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 + # One file, no glob: the report the e2e-coverage job of ci.yml reads. A # glob upload once hung a macOS job for ten minutes, and cancelling it lost # the job's whole log. diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 64de87fa6..0de9957de 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -286,9 +286,20 @@ jobs: with: host: macos-arm64 + # A pull request runs the tests its change affects (`mcpp test --since`, + # 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 != '' + 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_FRESH" test ${MCPP_TEST_SINCE:+--since "$MCPP_TEST_SINCE"} # 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-windows-e2e.yml b/.github/workflows/ci-windows-e2e.yml index de186f00f..a058e32f3 100644 --- a/.github/workflows/ci-windows-e2e.yml +++ b/.github/workflows/ci-windows-e2e.yml @@ -2,7 +2,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 -# (tests/e2e/timings/windows.tsv, rule R4 of the 2026-10-02 CI record): about +# (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 # the one binary build.yml produced (use-built-mcpp, rule R1) and writes the @@ -26,7 +26,12 @@ jobs: MCPP_HOME: C:\Users\runneradmin\.mcpp # Round-robin slice of tests/e2e (see run_all.sh). E2E_SHARD: ${{ matrix.shard }}/3 - E2E_TIMINGS: tests/e2e/timings/windows.tsv + # 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_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 @@ -93,6 +98,10 @@ jobs: test -n "$found" || { echo "FAIL: mingw installed but not where run_all.sh looks"; exit 1; } echo "mingw payload: $found" + - uses: ./.github/actions/e2e-mtest + with: + host: windows + - name: E2E suite shell: bash # About twice the shard's budget of fifteen minutes (R4): reached only by @@ -113,6 +122,18 @@ jobs: # Measurement legs print READING lines (#646 F2 across PE images, #649 E10 # the llvm row's recorded CRT); collected where a reader finds them. + # The shard's store: main's are the history the next runs divide the suite by. + - name: Upload the shard's store + if: always() + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: e2e-store-windows-${{ matrix.shard }} + path: tests/e2e/.mtest + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 + # One file, no glob: the report the e2e-coverage job of ci.yml reads. - name: Upload the shard's report if: always() diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 8c9f8567e..20300efce 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -39,11 +39,22 @@ jobs: shell: bash run: MCPP="$MCPP_SELF" python .github/tools/check_windows_home_menu.py + # A pull request runs the tests its change affects (`mcpp test --since`, + # 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 != '' + 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 shell: bash run: | export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") - "$MCPP_SELF" test + "$MCPP_SELF" test ${MCPP_TEST_SINCE:+--since "$MCPP_TEST_SINCE"} - name: Home and runner-cache paths preserve Unicode under a legacy Windows code page shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2922f9b98..079f941b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ permissions: contents: read # `changes` lists the files of the pull request. pull-requests: read + # The e2e shards read the stores of main's last run (.github/actions/e2e-mtest). + actions: read jobs: changes: @@ -100,7 +102,7 @@ jobs: - name: Check modules/ wiring run: bash .github/tools/check_modules_wiring.sh - - name: Check that headers are included only in the system lock files + - name: Check that headers and conditional directives appear only in the system lock files run: bash .github/tools/check_include_regions.sh - name: Check src/build/prepare* file lengths @@ -276,8 +278,8 @@ jobs: - name: Every test ran, is run by a dedicated job, or is excused run: python3 .github/tools/check_e2e_coverage.py --reports reports --timings-out timings - # The measured durations, merged per host, in the format of - # tests/e2e/timings/; refreshing those tables is copying these files. + # The measured durations, merged per host, in the format run_all.sh reads + # from E2E_TIMINGS (a run without mtest). - name: Upload the measured durations if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/e2e-coverage.yml b/.github/workflows/e2e-coverage.yml new file mode 100644 index 000000000..0ba383919 --- /dev/null +++ b/.github/workflows/e2e-coverage.yml @@ -0,0 +1,72 @@ +name: e2e-coverage + +# The coverage map of the e2e suite (design 2026-11-08 W8; mtest's docs/coverage.md): +# mcpp built with GCC's arc counters ([profile.coverage]), the whole suite run +# by mtest under `--coverage`, and for each test the sources its run executed. +# A pull request's Linux shards read the last map (.github/actions/e2e-mtest) +# and run the tests whose runs executed a file the change touches, with the +# smoke and regression sets; a test the map does not know, a map older than +# mtest.toml's max_age, and a change mtest cannot read select every test. Main +# and releases run every test. + +on: + schedule: + - cron: "17 19 * * *" + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + measure: + name: e2e coverage (linux x86_64) + runs-on: ubuntu-24.04 + timeout-minutes: 300 + env: + MCPP_HOME: /home/runner/.mcpp + E2E_JOBS: '2' + E2E_COVERAGE: '1' + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/bootstrap-mcpp + - name: mcpp with arc counters + run: | + set -euo pipefail + "$MCPP" build --profile coverage 2>&1 | tail -2 + bin=$(ls -t "$PWD"/target/*/*/bin/mcpp | head -1) + dir=$(dirname "$(dirname "$bin")") + echo "MCPP_COVERED=$bin" >> "$GITHUB_ENV" + printf '\n[coverage]\nobjects = "%s/obj"\ndatabase = "%s/compile_commands.json"\n' "$dir" "$dir" >> tests/e2e/mtest.toml + tail -4 tests/e2e/mtest.toml + - uses: ./.github/actions/e2e-mtest + with: + host: linux + - name: The toolchains the suite probes + timeout-minutes: 15 + run: | + set -euo pipefail + "$MCPP_COVERED" toolchain install gcc 16.1.0 + "$MCPP_COVERED" toolchain default gcc@16.1.0 + "$MCPP_COVERED" toolchain install gcc 16.1.0-musl + "$MCPP_COVERED" toolchain install llvm 23.1.3 + "$MCPP_COVERED" toolchain install mingw-cross 16.1.0 + XLINGS_HOME="$MCPP_HOME/registry" "$XLINGS_BIN" install xim:nasm -y + XLINGS_HOME="$MCPP_HOME/registry" "$XLINGS_BIN" install mcpplibs:cmdline@0.0.1 mcpplibs:cmdline@0.0.2 -y + - name: The suite under --coverage + timeout-minutes: 240 + run: | + set -euo pipefail + export MCPP="$MCPP_COVERED" MCPP_BOOT="$MCPP" MCPP_VENDORED_XLINGS="$XLINGS_BIN" + export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL + "$MCPP" self config + bash tests/e2e/run_all.sh || true + (cd tests/e2e && "$E2E_MTEST" coverage) + - uses: actions/upload-artifact@v4 + with: + name: e2e-coverage-linux + path: tests/e2e/.mtest/coverage.json + include-hidden-files: true + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ef965d2..832353891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,140 @@ > 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.8.1] - 2026-11-08 + +### Added + +- `mcpp test` runs the runner's side of the mtest test protocol, version 1 + (docs/08 "Cases"). A test program whose entry states `test-protocol = 1`, or + whose file carries the marker `mtest-protocol=1`, is told the protocol's + variables and reports its cases: the record names the protocol and each + case's result and failures, a case a crash left open failed, and a failing + program's report names its failed cases with their places. `--case PATTERN` + runs the matching cases -- a name below a listed case selects the case that + case declares, which is reported when it runs (P3.4), and a pattern below it + runs the listed case whole --, `--cases` lists them, `--seed N` states the seed. A + program that implements no protocol is one case and receives none of the + variables. `--no-run` records each built test with its program and the files + it is compiled from; `--print-launch` states the protocol; the feature + `test-protocol` names all of it (e2e 1035). +- `mcpp new` scaffolds tests with mtest 0.4.0: the skeleton's test is a file of + mtest's cases with no `main` (`UTest smoke{"...", [] { ... }}`: an object is a + case, its type its kind), with the dev-dependency `mcpplibs.mtest`, and + `--template lib` scaffolds a library -- a module, a partition it exports and + the partition's implementation, named after the package -- with its test + (docs/01; e2e 1037). +- An entry with `test-main = true` begins a test file that defines no `main`. + The file's imports choose among offers, so in one package a file of mtest's + cases and a file of GoogleTest's begin each at its own; a dev-dependency's + source that defines `main` (`gtest_main.cc`) begins a test as before, and two + beginnings left for one test are refused, naming them; a framework that is + a dependency as well as a dev-dependency offers its beginning once + (docs/08; e2e 1037). +- `mcpp test --guide` names the package's test frameworks, each with the guide + its entry states (`test-guide`) and its repository, and the commands that + list, select and explain cases; a failed run points to it and a record + carries `guide` (the protocol's P2.5; docs/08 "Writing a test"; e2e 1037). +- `[test] programs = "directory"`: the files of one directory below a discover + glob's fixed directory are one test program, named by the directory, so a + suite of many files is linked once (docs/08; e2e 1038). +- `mcpp test --since ` and `--changed ` build and run the tests a + change affects, read from the plan: a test built from a changed file (its own + files, the modules it imports transitively, its units of no module), a test + whose files name a changed file; a manifest, lock, build program or header + affects every test (docs/08; e2e 1038). +- `mcpp::context` states the engine's values to the build-program interface in + one process: every accessor reads the innermost context's values, then the + environment, so a test of a build program's or a plugin's logic needs no + change of its environment (docs/30; e2e 1036). +- `[build] target_budget` keeps `target/` within a size: after a build or a test + run, the directories no configuration records as current are removed whatever + their age, and a build whose directories in use exceed it names the three + largest (#874; e2e 1031). + +### Changed + +- The built-in platform of the two ok-image targets is openkal-platform 0.24.4, + whose okrun enters an image in its convention's floating-point state (openkal + SPEC 12.2): on Windows a thread began with the x87 at double precision, under + which an image's C library read a decimal number otherwise. +- Both openkal rows are `verified`: mcpp's CI builds an ok image over every layer + of the platform and runs it on Linux, macOS and Windows (e2e 1033, required on + each host by `tests/e2e/host-requirements.tsv`). docs/24 states the two roles, + an image as a release form and a native row as a build target. +- A dev-dependency reaches the test units and nothing else of its package: its + include directories and definitions are given to the files under `tests/`, + and a kernel interface only a dev-dependency reaches is given to the + dev-dependencies and the test units. The package's own sources compile under + `mcpp test` with the commands `mcpp build` gives them, so `mcpp build` and + `mcpp test` share one build directory and one compile database and switching + between them compiles nothing again (docs/08; e2e 77, 783, 1037 K). Before, + a dev-dependency over mcpplibs.os (mtest's) gave the package's sources + `__OPENKAL__` and made the test a configuration of its own. +- A program that carries the protocol's marker and writes no `program` event + -- a file of plain `main` linked beside mtest's library -- is one case, judged + by its exit status, when run and when listed (e2e 1038). +- A failed test whose file imports a module no package of the build provides + names the module and the dev-dependency a framework is. +- A product an earlier build left where this one places a program's directory, + or a directory where it places a program, is removed before the build. +- `trim_paths` applies to what `mcpp test` compiles as it does to what `mcpp + build` compiles, but for a test program's own files, which keep their paths: + under a shipping profile (`release`, mcpp's own default) build and test are + one configuration, where a test compiled the whole graph again in a + configuration of its own (637 of mcpp's objects; docs/04; e2e 1038 E). +- `mcpp dyndep` leaves an unchanged output untouched, so a unit whose module + edges are what they were is not compiled again when the rule's command + changed (#874). +- The low-space warning's threshold is the larger of 2 GiB and 1.25 times what + the configuration's last build wrote (#874; e2e 1030). +- mcpp's own dependencies are named by identity in one table, each by one + spelling of an exact release (#904), and so are those of the launchers `mcpp + pack` generates; mcpp, its modules and the launchers name mcpplibs.os 0.4.0. + +### Deprecated + +- A quoted dotted dependency key (`"mcpplibs.cmdline" = "0.0.2"`) is the reading + `quoted-dotted-dependency-key`, reported with the unquoted key that replaces + it; removed in 2027.2. A quoted key that is the only spelling of a package + beside its parent or a child of its namespace in one table (`mcpplibs.mtest` + and `"mcpplibs.mtest.gtest"`) is not deprecated (#904; e2e 1032). + +### Fixed + +- Two builds that find a std module missing compile it once. The std module of a + toolchain and flag set is one entry of the home's build cache, and two + processes that found it missing compiled into the same files at once: one + compiler read a BMI the other was rewriting (a bus error) or found its + output gone, and a build went on with no std object. The entry is built + under its own lock; a second build waits, naming the first, and finds it + built (e2e 1039). +- A dependency's source whose `main` is compiled only under a switch no command + defines -- zlib's `crc32.c` (`#ifdef MAKECRCH`), QuickJS's `libregexp.c` + (`#ifdef TEST`) -- is linked into a test that has its own `main`. Which + sources begin a program is read from the branches the preprocessor may + compile: a name the compiler or platform predefines is unknown, any other is + undefined; 2026.11.7.2 left such a source out, and the test's link failed + with undefined references (measured in Sunrisepeak/mui, #874; e2e 1037 L). +- The warning on a quoted dotted dependency key names the key to write, + `xrender.graph = ...`, not an example (e2e 1032). +- A configuration group's lock keeps the entries of the other groups (#903; + e2e 1027). +- A project's state directory `.mcpp/` ignores itself in a repository (e2e 1028). +- `mcpp cache prune --older-than` and `cache gc --older-than` collect a std + module unused for that long (#874; e2e 1029). +- A test that has a beginning -- its own `main`, `[test] main` or an offered + entry -- links no dependency's source that defines `main`, a direct + dev-dependency's or one beneath it: `gtest_main.cc` of compat.gtest beneath + the GoogleTest adapter was linked beside the adapter's `main` (e2e 1037). +- A test links its package's dev-dependency that is a shared library by the + library, as a dependency is; mtest's C ABI was left unresolved (e2e 1037). +- A dependency's entry that imports `std` builds the std module for the + program that begins at it, though nothing else of the graph imports it; the + entry failed with "failed to read compiled module" (e2e 1037). +- Under `--workspace`, a member's test finds the member's programs as + `MCPP_BIN_`, as under `-p` (e2e 1034). + ## [2026.11.7.2] - 2026-11-07 ### Changed diff --git a/README.md b/README.md index 262681b8b..9c3c8f01f 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ list` reports for this machine): | `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 23 — Cortex-M4/M7 soft float, M23, M33F/M55F ² | preview | | `aarch64-linux-gnu` | `llvm@23.1.3` | preview | | `riscv64-linux-musl` · `x86_64-macos` | — | planned | -| `x86_64-openkal` · `aarch64-openkal` | llvm 23 — an ok image: one artifact per architecture, run by `okrun` on Linux, macOS and Windows; the C library and C++ run-time inside it are packages ([24](docs/24-openkal-cross.md)) | preview | +| `x86_64-openkal` · `aarch64-openkal` | llvm 23 — an ok image: one artifact per architecture, run by `okrun` on Linux, macOS and Windows; the C library and C++ run-time inside it are packages ([24](docs/24-openkal-cross.md)) | verified | | `wasm32-emscripten` | `emsdk@6.0.9` — Emscripten ships its own sysroot and its own libc++ module surface; `mcpp run` executes the module with the `node` the payload declares (`xim:node`), not one found on PATH | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370` — bionic from the NDK, one payload for both ABIs; ran on an API 24 x86_64 emulator image | verified | | `aarch64-linux-android` | the same payload and the same build; ran under qemu-user over the system image's own bionic, which the platform emulator cannot do from an x86_64 host | verified | diff --git a/README.zh-CN.md b/README.zh-CN.md index b11b7542b..d2673588d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -403,7 +403,7 @@ mcpp 的身份模型有两条正交的轴:**工具链**是 `family@version`( | `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 23;Cortex-M4/M7 软浮点、M23、M33F/M55F ² | preview | | `aarch64-linux-gnu` | `llvm@23.1.3` | preview | | `riscv64-linux-musl` · `x86_64-macos` | — | planned | -| `x86_64-openkal` · `aarch64-openkal` | llvm 23;ok 映像:每种架构一个产物,由 `okrun` 在 Linux、macOS 与 Windows 上运行;映像内的 C 库与 C++ 运行时是包([24](docs/zh/24-openkal-cross.md)) | preview | +| `x86_64-openkal` · `aarch64-openkal` | llvm 23;ok 映像:每种架构一个产物,由 `okrun` 在 Linux、macOS 与 Windows 上运行;映像内的 C 库与 C++ 运行时是包([24](docs/zh/24-openkal-cross.md)) | verified | | `wasm32-emscripten` | `emsdk@6.0.9`;Emscripten 自带 sysroot 与 libc++ 模块接口;`mcpp run` 使用 payload 声明的 `node`(`xim:node`)运行模块,不使用 PATH 上的 `node` | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370`;bionic 来自 NDK,一个 payload 服务两个 ABI;已在 API 24 的 x86_64 模拟器镜像上运行 | verified | | `aarch64-linux-android` | 同一个 payload、同样的构建;已在 qemu-user 上配合系统镜像自带的 bionic 运行,平台模拟器无法在 x86_64 宿主上做到这一点 | verified | diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index c70fc9e2f..ce92416d4 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -101,6 +101,40 @@ int main() { } ``` +The test is a file of [mtest](https://github.com/mcpp-community/mtest)'s cases +with no `main`, and the manifest names it a dev-dependency. Each object is one +case; its type is its kind (`UTest`, `E2E`, `CTS`, `Bench`), and the attributes +after its name state how it runs (resources, time limit, isolation, retries): + +```cpp +import mcpplibs.mtest; +using namespace mcpplibs::mtest; + +UTest smoke{"the project builds and its test runs", [] { expect(eq(1 + 1, 2)); }}; +``` + +### Creating a library + +```bash +mcpp new acme.greeting --template lib +``` + +``` +acme.greeting/ +├── mcpp.toml +├── src/ +│ ├── greeting.cppm ← export module acme.greeting; export import :hello; +│ ├── hello.cppm ← export module acme.greeting:hello; the declaration +│ └── hello.cpp ← module acme.greeting; the definition +└── tests/ + └── hello.cpp ← mtest's cases for hello +``` + +The module is the package's qualified name, with `-` written `_`; a consumer +writes `import acme.greeting;`. The partition holds one function's declaration +and the implementation unit its definition; more functions are more partitions +the module exports. + ### Creating from a package template `mcpp new --template` uses the same exact package-selector style as `mcpp add`: diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index b69bbb94c..f8d6e28b2 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -297,7 +297,8 @@ main = { from = "openmui/player" } # "/" `extern "C"`: a declaration attached to a named module cannot be defined outside it. - An entry target states `main` (a file), and may state `start`, - `windows_entry`, `cflags`, `cxxflags`, `defines` and `required_features`. + `windows_entry`, `cflags`, `cxxflags`, `defines`, `required_features` and + `test-protocol`. Its `windows_entry` is inherited by the program that selects it; a program stating another is refused. Its `required_features` must be active: a selection otherwise is refused with the dependency line that enables them. @@ -509,6 +510,9 @@ required_features = ["gui"] # only built when feature `gui` is | `windows_subsystem` *(2026.9.12.2+)* | The PE subsystem of an executable: `"console"` (the default) or `"windows"`, a GUI program that starts without a console. Reaches this target's link and no other, and renders nothing on a target that is not PE. See the section above. | | `windows_entry` *(2026.9.12.2+)* | The entry function the program defines: `"main"` (the default), `"wmain"`, `"WinMain"` or `"wWinMain"`. See the section above. An entry target states the function its translation unit defines, and the program that selects it inherits it. | | `start` *(2026.10.26.2+)* | The symbol the image is entered at, on a freestanding target. See `start` above. | +| `test-guide` *(2026.11.8.1+)* | Of an entry target: a file of the package, its guide to writing the tests it begins, which `mcpp test --guide` names ([08](08-testing.md#writing-a-test)). Refused on any other target. | +| `test-main` *(2026.11.8.1+)* | Of an entry target: `true` offers it as the beginning of a test file that defines no `main` in a package that depends on this one (or is this one) and states no `[test] main` ([08](08-testing.md#where-a-test-program-begins)). Refused on any other target. | +| `test-protocol` *(2026.11.8.1+)* | Of an entry target: the version of the test protocol a test program that begins here implements, `1`; `mcpp test` then lists, selects and reports the program's cases ([08](08-testing.md#cases-the-test-protocol)). Refused on any other target. | | `windows_code_page` *(2026.9.26.1+)* | The ANSI code page of an executable on Windows: `"utf-8"`, embedded as an application manifest that Windows 10 version 1903 and later honour, or `"legacy"`, the system's code page. A program target that writes neither runs in the system's code page, except a program built as a host tool (§2.14), which defaults to `"utf-8"`. Refused on a library target; renders nothing on a target that is not PE. See *Paths and text encoding* in §2.3. | | `windows_long_paths` *(2026.10.16.1+)* | `true` embeds `longPathAware` in the executable's application manifest: the process may use paths longer than 260 characters where Windows enables them (`LongPathsEnabled`). `false` embeds nothing. A program target that writes neither embeds nothing, except a program built as a host tool (§2.14), which defaults to `true`. Refused on a library target; renders nothing on a target that is not PE. | | `linkage` *(2026.9.15.2+)* | A library target's **default** link form, `"static"` or `"shared"`: the form a consumer that writes no `linkage` receives. Unlike `kind = "shared"` it is not a constraint, so a consumer's explicit statement is honoured. Refused beside `kind = "shared"` and on a program target. See [`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision). | @@ -1676,9 +1680,11 @@ anything compiles. checkouts of one tree then embed identical paths. A unit's map holds the fixed roots and the roots of its own package's dependency closure, so a dependency's command line is the same whichever workspace members a build selects (2026.10.26.2+). It applies to - what `build`, `run` - and `pack` produce, not to `mcpp test`: a test binary runs where it was built and - commonly finds its fixtures through `__FILE__`. A build with debug information + what `build`, `run`, `pack` and `mcpp test` compile, but for a test program's own + files: a test binary runs where it was built and commonly finds its fixtures through + `__FILE__`, and the code under test is the code that ships, so `mcpp build` and + `mcpp test` are one configuration (2026.11.8.1+; before, a test of a shipping profile + was a configuration of its own and compiled the whole graph again). A build with debug information also writes `source-map.gdbinit` and `source-map.lldbinit` beside its outputs, which map the paths back for a debugger. cl.exe has no documented equivalent and the build says so once. Write `ndebug = false` or `trim_paths = false` under the profile to keep @@ -1716,6 +1722,12 @@ anything compiles. 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 + `target_budget = "20GiB"` in `mcpp.toml` (or config.toml): after every build + or test run whose `target/` holds more, the directories no configuration + records as current are removed whatever their age, and when the directories + in use still hold more, the build warns, naming the three largest. Nothing in + use is removed (mcpp#874). ### 2.10 `[build] cache` — The Global Dependency Cache @@ -2141,6 +2153,7 @@ discover = ["tests/**/*.cpp"] # the default | `windows_long_paths` | `true` or `false` | whether every test program on Windows is long-path aware, in the meaning of the target key of the same name; the default is `false` *(2026.10.16.1+)* | | `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+)* | The globs use the vocabulary of `[build] sources`. A test's name is its path relative to the fixed directory of the first glob that matched it, without the diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index dc9b7a322..5e0db2e78 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -56,6 +56,21 @@ glfw = "3.4" # Explicit namespace; no fallback search +A quoted key with dots, `"mcpplibs.cmdline" = "0.0.2"`, is one TOML key whose +name contains dots. mcpp reads it as the selector those dots spell, warns that +the spelling is deprecated (since 2026.11.8.1, removed from 2027.2; docs/51) +and names the unquoted key that replaces it: `mcpplibs.cmdline = "0.0.2"`. +A package and a package of its namespace in one table, `mcpplibs.mtest` and +`mcpplibs.mtest.gtest`, cannot both be written unquoted -- one name would be a +value and a table -- so the quoted key of one of them is their spelling and is +not deprecated: + +```toml +[dev-dependencies] +mcpplibs.mtest = "0.4.0" +"mcpplibs.mtest.gtest" = "0.4.0" +``` + ```toml # Path dependency (local development) [dependencies] @@ -430,7 +445,7 @@ Run any command with `-v` to see the decision for each dependency and why. gtest = "1.15.2" ``` -`mcpp build` ignores these; `mcpp test` resolves and uses them. `mcpp test` automatically discovers `tests/**/*.cpp` and compiles them into test binaries. The runner is framework-agnostic: each file is an independent binary judged by exit code — a bare `main`, gtest (via `[dev-dependencies]` + `gtest_main`), or any other framework all work identically, and `-- args` are forwarded to every test binary (e.g. `-- --gtest_filter=...`). Note: synthesized test target names may contain `/` (`tests/00-a/0.cpp` → `00-a/0`), unlike `[targets.*]` names — the two namespaces are intentionally separate (test targets never enter the manifest or publishing). Tests are named by their `tests/`-relative path (`tests/00-a/0.cpp` → `00-a/0`), each test compiles in isolation (a broken test fails alone; package/dep breakage is reported as a build error instead), and `mcpp test ` / `--message-format json` filter and machine-format the run. +`mcpp build` ignores these; `mcpp test` resolves and uses them, for the test units only: the package's own sources compile under `mcpp test` as under `mcpp build` (2026.11.8.1+, [08](08-testing.md#tests-that-reach-packages-the-artifact-does-not)). `mcpp test` automatically discovers `tests/**/*.cpp` and compiles them into test binaries. The runner is framework-agnostic: each file is an independent binary judged by exit code — a bare `main`, gtest (via `[dev-dependencies]` + `gtest_main`), or any other framework all work identically, and `-- args` are forwarded to every test binary (e.g. `-- --gtest_filter=...`). Note: synthesized test target names may contain `/` (`tests/00-a/0.cpp` → `00-a/0`), unlike `[targets.*]` names — the two namespaces are intentionally separate (test targets never enter the manifest or publishing). Tests are named by their `tests/`-relative path (`tests/00-a/0.cpp` → `00-a/0`), each test compiles in isolation (a broken test fails alone; package/dep breakage is reported as a build error instead), and `mcpp test ` / `--message-format json` filter and machine-format the run. ## `[build-dependencies]` — Build-Time Dependencies (mcpp 2026.8.29.1+) diff --git a/docs/08-testing.md b/docs/08-testing.md index b8b9a7c41..03bb0160c 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -33,8 +33,10 @@ There is no framework and no registration. A test may use one — `[dev-dependen is how it reaches it — but the contract mcpp holds is the exit code, which is also why a test written for another framework needs no adapter. -`mcpp new` scaffolds `tests/test_smoke.cpp` so a project starts with the -directory in place. +`mcpp new` scaffolds `tests/test_smoke.cpp`, a file of +[mtest](https://github.com/mcpp-community/mtest)'s cases with no `main`, and the +dev-dependency `mcpplibs.mtest` (2026.11.8.1+); `mcpp new --template lib` +scaffolds a library and its test the same way ([01](01-getting-started.md)). ### Where tests are @@ -53,7 +55,17 @@ the extension, so the default names `tests/unit/test_span.cpp` `unit/test_span`. `discover = []` discovers no test. Two files whose names coincide are refused, naming both. -A suite compiled from several sources is a package of its own: a workspace +`[test] programs = "directory"` (2026.11.8.1+) makes the files of one directory +below a glob's fixed directory one program, named by the directory +(`tests/unit/*.cpp` is the program `unit`): each file is a translation unit of +it, its cases are all of theirs, and it begins at the file that defines `main` +when one does, else as a file that defines none does. Two files of one directory +that define `main` are refused, naming both; a file directly in the fixed +directory stays a program of its own. 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. + +A suite compiled from several sources of its own is also a package: a workspace member whose `[build] sources` carries the suite and whose one test program drives it, selected with `mcpp test -p `. @@ -75,6 +87,17 @@ no `main`. The package's own name selects its own entry, which is how a framewor tests its entry with the callbacks a test file defines. On a freestanding target, `[test] start` states the symbol every test image is entered at. +An entry may offer itself with `test-main = true` (2026.11.8.1+): a test file that +defines no `main`, of a package that states no `[test] main`, then begins at it, +and a file that defines `main` keeps its own. mtest's entry and its GoogleTest +adapter's offer themselves, so a test needs only the dev-dependency. The choice +is the file's: an offer whose package provides modules is this test's when the +file imports one of them and is set aside when it imports none, so in one +package a file of mtest's cases begins at mtest's entry and a file of +GoogleTest's at GoogleTest's. A dependency's source that defines `main` +(`gtest_main.cc`) begins a test as it always has. Where two beginnings remain +for one test, the build is refused, naming them, and `[test] main` chooses. + ## Running them ```bash @@ -107,6 +130,25 @@ 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 tests a change affects + +`mcpp test --since ` and `--changed ` (2026.11.8.1+) build and run +the tests a change affects, read from the plan before anything is built: + +- a test is affected by a change of a file it is built from: its own files, the + module units it imports and the units those import in turn, and every unit + 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; +- another file affects the tests whose own files name it (a fixture), and no + other test. + +`--since` reads the change from git: the paths committed since the commit, +changed in the working tree, and new and not ignored. `Affected N of M tests` +states the selection, with what each changed path decided; under +`--message-format json` an `affected` record lists each test with its reason. +The selection narrows what a pattern or `-p` names and adds nothing to it. + **A test finds the package's programs by name** (2026.10.27.1+). `mcpp test` builds the package's programs first, as `mcpp build` does, and gives each test `MCPP_BIN_`: the path of program ``, upper-cased, every character @@ -149,6 +191,18 @@ else: it is not in the artifact, and a consumer of the package never sees it. That is the difference between a test's dependency and the package's own, and it is why a test framework does not become part of what a library ships. +A dev-dependency reaches the test units and nothing else of the package +(2026.11.8.1+). Its include directories and interface definitions are given to +the files under `tests/`; the package's own sources are compiled under +`mcpp test` with the commands `mcpp build` gives them, so the code under test is +the code that ships, and `mcpp build` and `mcpp test` share one build directory +and one compile database. A kernel interface that only a dev-dependency reaches +(`mcpplibs.mtest` over `mcpplibs.os` reaches openkal's) is given to the +dev-dependencies and the test units alone: they see `__OPENKAL__` and openkal's +headers, and the package's own sources do not. A C library or a C++ runtime that +a dev-dependency supplies is a property of every object a test links, and it +remains the configuration's. + [`examples/11-features`](../examples/11-features/) declares one and uses it. ## Testing on a target this machine cannot run @@ -238,6 +292,70 @@ workers reaching for it interleave rather than fail. The package that owns the resource states this about itself, and `mcpp test` then serialises those tests. A project never has to remember `-j1`. +## Cases: the test protocol + +A test program may consist of cases. mcpp does not provide a framework for +them; it implements the runner's side of a protocol that frameworks implement: +the [mtest test protocol](https://github.com/mcpp-community/mtest/blob/main/spec/protocol.md), +version 1 (mcpp 2026.11.8.1+). A program implements it when its entry states it, + +```toml +[targets.main] +kind = "entry" +main = "src/main.cpp" +test-protocol = 1 +``` + +or when its file carries the marker `mtest-protocol=1`, as a program built +with mtest's libraries does. A program that carries the marker and writes no +`program` event spoke no protocol: a file of plain `main` linked beside a +framework's library is one case, judged by its exit status, run and listed. mcpp then gives the program the protocol's +variables (`MTEST_PROTOCOL`, `MTEST_MODE`, `MTEST_REPORT`, `MTEST_TMPDIR`, and +`MTEST_SELECT` and `MTEST_SEED` when they apply), reads the report it writes, +and states each case's result: + +```bash +mcpp test # each case's result in the report and the record +mcpp test --case 'fs/*' # of each such program, the cases a pattern matches +mcpp test --cases # each program's cases, running none +mcpp test --seed 42 # the seed of anything random in the cases +``` + +- The program's exit status still decides its verdict. A report whose cases + failed fails the program though it exited zero; a case that began and did + not end failed, with the program's exit status as its reason. +- A failing program's report names each failed case with the place of its + first failure: `case fs/read failed at tests/fs.cpp:12: ...`. +- A pattern of `--case` matches with `*` and `?`; a name without either + matches itself and the cases below it (`fs` matches `fs/read`). A case that + a listed case declares is reported when it runs and need not be listed + (protocol P3.4; mtest's `UTest fs{"fs", [] { "read"_test = ...; }}` lists + `fs`): a name below a listed case selects that one case, and a pattern below + it runs the listed case whole. Under `--case`, a program with no matching + case, and every program that implements no protocol, does not run. +- A program that implements no protocol is one case, judged by its exit status, + and receives none of the variables, even when mcpp's own environment has + them. + +The records of `--message-format json` carry the protocol, the cases and their +failures ([50](50-machine-output.md)); `--print-launch` states the protocol +for a debugger that starts the program. + +## Writing a test + +```bash +mcpp test --guide # the frameworks, their guides, and the commands for cases +``` + +A framework states its guide to writing tests on its entry, `test-guide = ""` +(2026.11.8.1+; the protocol's P2.5): a file of its package, read from the installed +package and of its version. `mcpp test --guide` names each test framework of the +package with its guide and repository, and the commands that list, select and +explain cases; with none, it states the contract above and how to take mtest. +A failed run ends with `how these tests are written: mcpp test --guide (...)`, +and a record of `--message-format json` carries `guide`. A person or an agent +about to write a test, or reading why one failed, starts there. + ## Reporting to a program ```bash @@ -258,9 +376,10 @@ runner, the environment the run adds and the arguments after `--` are in it. ## Current limitations -- A test is one `.cpp` producing one program. mcpp does not discover cases - inside a file, so a framework's per-case selection happens inside the program, - through arguments after `--`. +- A test is one `.cpp` producing one program. The cases inside it are the + program's: mcpp lists and selects them only for a program that implements + the test protocol; for any other, a framework's selection happens inside the + program, through arguments after `--`. - `mcpp test --list` over a manifest that does not load lists `tests/**/*.cpp`, not the `[test] discover` set it cannot read. - `--build-timeout` is POSIX-only. diff --git a/docs/09-commands-by-scenario.md b/docs/09-commands-by-scenario.md index 371c4fb66..91e375f63 100644 --- a/docs/09-commands-by-scenario.md +++ b/docs/09-commands-by-scenario.md @@ -99,7 +99,11 @@ default 30, `0` turns it off) is removed. A hit marks an entry used. A `path` host tool's entry is replaced when its tree changes: the entry built from the same tree under the previous stamp is removed as the new one is written (2026.10.31.3+). Before a build, mcpp warns when the file system it writes to -has less than 2 GiB free. +has less free space than the larger of 2 GiB and 1.25 times what the +configuration's last build wrote (recorded in `/.mcpp-written`); the +warning states both (2026.11.8.1+). `mcpp cache prune` and `mcpp cache gc +--older-than` collect `std` module entries by age as well; `gc --max-size` +budgets packages and host tools, not `std` modules (2026.11.8.1+). ## The versions a package publishes diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index f98c89485..42f91e62b 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -544,8 +544,8 @@ other's rows. | `aarch64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `x86_64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `wasm32-emscripten` | verified | `emsdk@6.0.9` | payload | payload | payload | payload | -| `x86_64-openkal` | preview | `llvm@23.1.3` | payload | payload | payload | payload | -| `aarch64-openkal` | preview | `llvm@23.1.3` | payload | payload | payload | payload | +| `x86_64-openkal` | verified | `llvm@23.1.3` | payload | payload | payload | payload | +| `aarch64-openkal` | verified | `llvm@23.1.3` | payload | payload | payload | payload | `payload` a toolchain payload here produces it · `graph` no payload, but a dependency can supply the system · `system` located on the machine, not @@ -589,6 +589,15 @@ works from an x86_64 host -- the platform emulator refuses a foreign guest (`QEMU2 emulator does not support arm64 CPU architecture`). A tier states that an artefact was built and RUN; it does not state which emulator ran it. +**Both `*-openkal` rows are `verified` (2026.11.8.1).** Every host builds both +rows, and each system runs the image of its own architecture: Linux and Windows +x86_64 run `x86_64-openkal`, macOS arm64 runs `aarch64-openkal`, with the +platform's C library and C++ run-time (`import std`). Test 1033 does this, and +`tests/e2e/host-requirements.tsv` requires it to pass on Linux, macOS and +Windows, so the claim fails CI when a system stops running images. Before, the +rows' run tests declared `llvm`, which only the Linux shards grant, and mcpp's +own CI ran images on Linux alone. + On these two rows, `kind = "app"` links as a shared library rather than as an executable ([04 §2.2](04-mcpp-toml.md)) — the form `application_form` answers for `env == "android"`, the same test `is_android()` uses. diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 2caeebe36..3cbc29287 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -440,6 +440,30 @@ anything compiles, saying where the layer was switched off. requirements note would be refused by every loader; the refusal (`platform-tool-absent`) names the package that builds it. +### Two Roles, No Ranking (mcpp 2026.11.8.1) + +The image rows and the native rows are not tiers of one another; they play two +roles. + +- **A release form.** A program that needs only files, processes, the network + and a terminal can ship one image per architecture as its only release, and + test that image on every real system. The ecosystem's applications do: + mcppls-clangd, mcppls and the mcpp-ide backend. +- **A build target.** The native rows (`*-linux-gnu`, `*-linux-musl`, + `*-windows-msvc`, `aarch64-macos`, Android, iOS, `wasm32-emscripten`, + `*-none-elf`) are fully supported targets with the same guarantees. Only a + native row serves: + - a program released per system that calls system interfaces chosen at + compile time (mcpp itself); + - a library, which is compiled into its consumer for the consumer's target; + - a program that links a system's or a vendor's library (Qt, Vulkan or Metal, + CUDA or SYCL, Android's `native_activity`, an iOS application); + - a platform no image reaches (Android, iOS, the Web, bare metal). + +Both image rows are `verified` ([21](21-the-target-triple.md)): every host +builds them, and Linux, macOS and Windows each run the image of their +architecture in mcpp's CI. + ### An Image Of Another Architecture (mcpp 2026.10.29.1) `okrun` runs an image of its own architecture (`host = "same-arch"`). On a diff --git a/docs/25-home-indices-and-store.md b/docs/25-home-indices-and-store.md index c7347f63e..ce89587dd 100644 --- a/docs/25-home-indices-and-store.md +++ b/docs/25-home-indices-and-store.md @@ -29,10 +29,17 @@ stated on the build's own command lines. build-cache/ cache/ provisioned/ … /.mcpp/ + .gitignore `*`: the directory ignores itself (2026.11.8.1+) + compiler-runs// the locks of a build's generated sources indices// the data of the project's own [indices] xpkgs/ the payloads they serve ``` +`/.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 +uses it, so version control ignores it in a project whose own ignore file does +not name it. + Ownership follows the index. An index mcpp serves keeps its data and its payloads in mcpp's home; the provider's own indexes (`xim` and the ones it adds) stay under `registry/`. A local path index (`path = "…"`) is read where it is. @@ -104,8 +111,12 @@ a project's `target/` is its own. Each has a retention, in config.toml: |---|---|---| | `[cache] unused_days` | 30 | after a build, at most once a day: cache entries (packages, `std` modules, host tools) no build used for that many days | | `[build] keep_days` | 3 | after `mcpp build` or `mcpp test`, at most once a day per project: build directories no configuration used for that many days | +| `[build] target_budget` | none | after every `mcpp build` or `mcpp test` whose `target/` holds more than this size (`"20GiB"`, `"512MiB"`): every build directory no configuration records as current, whatever its age (2026.11.8.1+) | -`0` keeps everything. `mcpp self config --cache-unused-days ` and +`0` keeps everything. `target_budget` is read from the project's (or +workspace root's) `mcpp.toml`, else from config.toml. When the directories still +in use hold more than the budget, the build warns and names the three largest; +nothing in use is removed. `mcpp self config --cache-unused-days ` and `--keep-days ` write them; `mcpp self config` shows them. A cache entry's files never change after they are written, and a file system diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index da98c93a7..044ea940e 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -1378,6 +1378,23 @@ These values are folded into the re-run key **unconditionally** — changing the target, profile, or feature set re-runs the program without any `rerun-if-env-changed` declaration. +### Testing a program's logic: `mcpp::context` (mcpp 2026.11.8.1+) + +Every typed reader reads through one function: within a `mcpp::context`, the +values the context states, the innermost first; a name it does not state from +the context around it, then from the environment. A test of a build program's +or a plugin's logic states the engine's values in its own process instead of +changing its environment: + +```cpp +mcpp::context engine({{"MCPP_TARGET_OS", "windows"}, {"MCPP_FEATURE_FAST", "1"}}); +expect(choose_flags() == "/O2"); +``` + +A context belongs to the thread that made it and ends with its scope. It holds +up to 64 values and does not copy the strings it names, which outlive it +(literals do). + ### Text is UTF-8 in both directions (mcpp 2026.9.26.1+) Every path and value mcpp passes to the program is UTF-8, and mcpp reads every diff --git a/docs/35-platforms.md b/docs/35-platforms.md index 3b535ae03..01af845d5 100644 --- a/docs/35-platforms.md +++ b/docs/35-platforms.md @@ -22,7 +22,9 @@ the tools the build runs on this machine, the compile and link conventions, the name and seal of an artifact, and the runner. A project states none of it. The target table selects `mcpplibs.openkal-platform` for `x86_64-openkal` and `aarch64-openkal` (2026.10.27.1+), which is what built-in support for those two -targets is. +targets is. An image is a release form, and a native row is a build target with +the same guarantees; neither ranks above the other ([24](24-openkal-cross.md), +"Two Roles, No Ranking"). ## The shortest run @@ -186,8 +188,8 @@ index change resolves the same either way. ## The built-in platform is one release The target table selects one openkal-platform release for the two ok-image -targets, the one this mcpp was qualified with: 0.24.3 for mcpp 2026.11.7.2 -(2026.11.7.1 selected the series `^0.24`). A project that states no platform +targets, the one this mcpp was qualified with: 0.24.4 for mcpp 2026.11.8.1, +0.24.3 for 2026.11.7.2 (2026.11.7.1 selected the series `^0.24`). A project that states no platform builds the same versions at any time, with or without `mcpp.lock`; when the lock records another release, the build takes the default and says which release the lock recorded. A newer platform needs no new mcpp: a project selects it in its diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 8f66fbb0d..0dfe54ca2 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -246,6 +246,7 @@ envelope says when either has changed since. | `pkg-format-list` | 2026.10.31.1 (meaning from 2026.10.30.1) | `--pkg-format tar,okb` writes each container of one tree (SPEC-014 §5.4) | an IDE's pack form | | `test-launch` | 2026.10.31.2 | `mcpp test --print-launch` prints the `mcpp.launch` of one test (below) | an IDE debugging a test | | `launch-stream` | 2026.11.7.2 | `mcpp run --print-launch --message-format json` streams the build's events, then the `mcpp.launch` envelope (below) | an IDE's Run, showing the build as it goes | +| `test-protocol` | 2026.11.8.1 | a test program that implements the test protocol has its cases listed (`--cases`), selected (`--case`) and reported in the records of `mcpp test --message-format json` (§8) | an IDE's test tree, a case run from the editor | ### How a tool runs mcpp for a project @@ -528,7 +529,7 @@ installation, which is located on the machine rather than installed by mcpp. A target row is `{target, note, toolchain, pin, status, default}`, and `status` is one of `installed` / `available` / `via dependency graph` / `planned`. A target row also carries `platform` (2026.10.27.1+): the platform package the -target table selects for it (`mcpplibs.openkal-platform@0.24.3` from 2026.11.7.2; the series `^0.24` in 2026.11.7.1), or `null`. +target table selects for it (`mcpplibs.openkal-platform@0.24.4` from 2026.11.8.1, `@0.24.3` in 2026.11.7.2; the series `^0.24` in 2026.11.7.1), or `null`. **`toolchain` and `pin` are not the same field twice.** `toolchain` is what the row is associated with — the installed payload on an installed row, the @@ -790,7 +791,8 @@ mcpp test [pattern] [--workspace] --message-format json ``` This stream predates the envelope of §2 and is not wrapped in it: it is NDJSON, -one record per test as each finishes, then one summary record per member. A +one record per test as each finishes (under `--no-run` and `--cases`, as each is +built; from 2026.11.8.1), then one summary record per member. A `--workspace` run ends with one `workspace_summary` record. A test over several members adds one `group_build` record per group, before the group's first test record. The §7 guarantees apply to it — fields are added and never removed, and @@ -811,6 +813,12 @@ Per test: | `timed_out` | `true` when `--timeout` killed it (`run_fail`) | | `compile_output`, `run_output` | captured diagnostics | | `reason` | `not_run` only: why, in one sentence; `""` otherwise | +| `protocol` | the version of the test protocol the program implements, or `null` *(2026.11.8.1+, [08](08-testing.md#cases-the-test-protocol))* | +| `cases` | a program that implements it and ran: each case's `id`, `status` (`pass`, `fail`, `skip`, `xfail`, `xpass`, `invalid`), `duration_ms`, `reason` when there is one, and `failures` (`file`, `line`, `message`, `expected` and `actual` when stated) *(2026.11.8.1+)* | +| `missing` | the identifiers a selection named that the program has no case for *(2026.11.8.1+)* | +| `listed` | under `--cases`: each case's `id`, `name`, `file`, `line`, and `kind`, `tags`, `expect` when stated *(2026.11.8.1+)* | +| `guide` | the guide of the framework the test is written with, a file, when its framework states one *(2026.11.8.1+)* | +| `program`, `sources` | a test built and not run (`--no-run`, `--cases`): the program, and the files it is compiled from -- its own and those of the libraries it links -- for a runner that selects tests by change *(2026.11.8.1+)* | Summary record, `{"summary": {...}}`: @@ -903,6 +911,12 @@ A tree staged by a mode that bundles nothing (`system`, `static`) carries no places libraries itself reads these lines rather than inferring the closure from the files under `lib/`. +`mcpp test --guide --message-format json` *(2026.11.8.1+)* prints one object, +`{"guide": {...}}`: `frameworks` (each `package`, `version`, `entry`, `protocol` +or `null`, `guide` or `null`, `repo`), `commands` (`list`, `one`, `records`, +`debug`), `docs`, and `suggest` (the dev-dependency line and repository of mtest) +when the package has no framework. + ## 9. The build stream and the result record *(mcpp 2026.10.26.1+)* Every chain that builds a project — a person at a terminal, an agent with a @@ -1030,7 +1044,8 @@ mcpp test --print-launch [-- ] The same for one test (feature `test-launch`, 2026.10.31.2): the test `` names is built as `mcpp test` builds it and described as `mcpp test` would start it -- its runner, the environment the run adds, and the arguments after `--` -- with -`test` in `data` naming it. A name that several tests contain is resolved to +`test` in `data` naming it and `protocol` stating the test protocol the program +implements, or `null` (2026.11.8.1+). A name that several tests contain is resolved to the one named exactly; a pattern that names several is refused, as are `--list`, `--no-run`, `--archive`, `--message-format json`, several legs and several members. A debugger starts what the envelope describes. diff --git a/docs/51-supported-versions.md b/docs/51-supported-versions.md index fef07b562..16d3e35c6 100644 --- a/docs/51-supported-versions.md +++ b/docs/51-supported-versions.md @@ -83,6 +83,7 @@ the rows of this table (mcpp 2026.10.11.1+). | `pack-universal`: `[pack] universal` -- `true` refused, `false` ignored; a pack's targets are its legs | 2026.10.30.1 | 2027.1 | `[pack] targets = ["openkal"]` (every architecture), or one `--target` | | `platform-universal`: a platform's `[platform] universal`, ignored; a pack's targets are its legs | 2026.10.30.1 | 2027.1 | nothing in the platform; the pack names its targets (`--target openkal`) | | `no-universal-flag`: `mcpp pack --no-universal`, which a pack of one target no longer needs (refused with a target set) | 2026.10.30.1 | 2027.1 | `--target -` (one leg) | +| `quoted-dotted-dependency-key`: a quoted dependency key with dots (`"mcpplibs.cmdline" = "0.0.2"`), one TOML key read as the selector its dots spell; not where it is the only spelling of a package beside its parent or child (docs/05) | 2026.11.8.1 | 2027.2 | the selector unquoted, `mcpplibs.cmdline = "0.0.2"` | ## Reproducing a build diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index 2675a3e41..7c2f966b8 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -114,6 +114,17 @@ the ones in use. A script that requires a capability no CI job provides **never runs anywhere**, and its greenness means nothing — check that some job supplies what a new script asks for. +**CI runs them by [mtest](https://github.com/mcpp-community/mtest)** (2026.11.8.1+): +`tests/e2e/mtest.toml` makes each script a program, its `# requires:` line its +tags and a `# resources:` line what it holds while it runs (`machine`: nothing +runs beside it; a script that changes the shared home's configuration says so). +`run_all.sh` with `E2E_MTEST=` runs the suite through it: `E2E_SHARD` +divides by the measured history of the store (`tests/e2e/.mtest`, which CI takes +from main's last run), `E2E_JOBS` runs that many scripts at once, and +`E2E_SET=pr` runs the scripts a change affects by the nightly coverage map +(`.github/workflows/e2e-coverage.yml`). `mtest explain` in `tests/e2e` says where +a script failed and how to run it again. + ## Writing a check that measures something The most transferable rule in this repository, and the one that fails silently diff --git a/docs/specs/platforms.md b/docs/specs/platforms.md index 44877aca1..01885a671 100644 --- a/docs/specs/platforms.md +++ b/docs/specs/platforms.md @@ -391,7 +391,7 @@ run_under = ["qemu-aarch64"] ## 14. 内置平台与目标表 宿主目标(Linux、macOS、Windows、Android、iOS、Emscripten)的约定留在引擎中,无平台包。目标表有平台列: -`x86_64-openkal`、`aarch64-openkal` 的值为一个确切的平台版本 `mcpplibs.openkal-platform@0.24.3`,即当前引擎验证过的版本, +`x86_64-openkal`、`aarch64-openkal` 的值为一个确切的平台版本,即当前引擎验证过的版本(mcpp 2026.11.8.1 为 `mcpplibs.openkal-platform@0.24.4`), 也是这两个目标的内置支持;选择来源报告为 `built-in`。项目可以以 `[target.] platform` 选择另一个版本或系列。 锁中记录的版本不满足内置版本时不被采用,报告写出采用的版本与锁中记录的版本。使用内置版本的构建失败、且已同步的索引中 有更新版本时,构建以一条 `tip` 写出两个版本与选择它的写法(mcpp >= 2026.11.7.2;2026.11.7.1 的值为系列 `^0.24`)。**已实现** diff --git a/docs/zh/01-getting-started.md b/docs/zh/01-getting-started.md index c0dc0830d..ab9c994aa 100644 --- a/docs/zh/01-getting-started.md +++ b/docs/zh/01-getting-started.md @@ -99,6 +99,37 @@ int main() { } ``` +测试是一个不定义 `main` 的 [mtest](https://github.com/mcpp-community/mtest) 用例文件,manifest 把 mtest +列为 dev-dependency。每个对象是一个用例:类型即类别(`UTest`、`E2E`、`CTS`、`Bench`),名字之后的属性说明它如何 +运行(资源、时限、隔离、重试): + +```cpp +import mcpplibs.mtest; +using namespace mcpplibs::mtest; + +UTest smoke{"the project builds and its test runs", [] { expect(eq(1 + 1, 2)); }}; +``` + +### 创建库 + +```bash +mcpp new acme.greeting --template lib +``` + +``` +acme.greeting/ +├── mcpp.toml +├── src/ +│ ├── greeting.cppm ← export module acme.greeting; export import :hello; +│ ├── hello.cppm ← export module acme.greeting:hello; 声明 +│ └── hello.cpp ← module acme.greeting; 定义 +└── tests/ + └── hello.cpp ← hello 的 mtest 用例 +``` + +模块名是包的限定名(`-` 写作 `_`);使用者写 `import acme.greeting;`。分区放一个函数的声明,实现单元放它的 +定义;更多函数即模块导出的更多分区。 + ### 从包模板创建 `mcpp new --template` 与 `mcpp add` 使用完全相同的包 selector 语法: diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 567ae063e..c2df23c3f 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -291,7 +291,7 @@ main = { from = "openmui/player" } # "<依赖>/<入口>" - 两者之间的契约只经符号与运行时数据。由程序定义的回调,提供方以 `extern "C++"` 或 `extern "C"` 声明:附着于命名模块的声明不能在模块之外定义。 - 入口目标陈述 `main`(文件),可以陈述 `start`、`windows_entry`、`cflags`、`cxxflags`、 - `defines` 与 `required_features`。`windows_entry` 由选择它的程序继承,程序陈述不同的值 + `defines`、`required_features` 与 `test-protocol`。`windows_entry` 由选择它的程序继承,程序陈述不同的值 被拒绝;`required_features` 必须已开启,否则拒绝并给出开启它们的依赖写法。描述产物的键 (`soname`、`exports`、`linkage`、程序的 `windows_*` 键)属于程序,在入口目标上被拒绝。 - 使用 `main = { from }` 的程序目标不陈述 `cflags`、`cxxflags`、`defines`:这些标志作用于 @@ -482,6 +482,9 @@ required_features = ["gui"] # only built when feature `gui` is | `windows_subsystem` *(2026.9.12.2+)* | 可执行文件的 PE subsystem:`"console"`(默认)或 `"windows"`,一个启动时没有控制台的 GUI 程序。只到达这个目标的链接,别处不受影响,在非 PE 的目标上不渲染任何东西。见上一节。 | | `windows_entry` *(2026.9.12.2+)* | 程序定义的入口函数:`"main"`(默认)、`"wmain"`、`"WinMain"` 或 `"wWinMain"`。见上一节。入口目标陈述其翻译单元定义的函数,由选择它的程序继承。 | | `start` *(2026.10.26.2+)* | 独立目标上镜像的入口符号。见上文 `start`。 | +| `test-guide` *(2026.11.8.1+)* | 入口目标的键:包中的一个文件,即编写以它为起点的测试的指南,由 `mcpp test --guide` 列出([08](08-testing.md#编写测试))。在其他目标上被拒绝。 | +| `test-main` *(2026.11.8.1+)* | 入口目标的键:`true` 表示它可作为依赖本包(或本包自身)且未陈述 `[test] main` 的包中、不定义 `main` 的测试文件的起点([08](08-testing.md#测试程序的起点))。在其他目标上被拒绝。 | +| `test-protocol` *(2026.11.8.1+)* | 入口目标的键:以此为起点的测试程序实现的测试协议版本,`1`;`mcpp test` 随之列出、选择并报告程序的用例([08](08-testing.md#用例测试协议))。在其他目标上被拒绝。 | | `windows_code_page` *(2026.9.26.1+)* | 可执行文件在 Windows 上的 ANSI 代码页:`"utf-8"`,以 Windows 10 1903 及以后版本会遵从的应用程序清单嵌入;或 `"legacy"`,即系统代码页。两者都不写的程序目标运行在系统代码页里,作为 host 工具构建的程序(§2.14)除外,它默认为 `"utf-8"`。写在库目标上会被拒绝;在非 PE 目标上不产生任何东西。见 §2.3 的*路径与文本编码*。 | | `windows_long_paths` *(2026.10.16.1+)* | `true` 在可执行文件的应用程序清单中嵌入 `longPathAware`:在 Windows 启用长路径(`LongPathsEnabled`)时,进程可以使用超过 260 个字符的路径。`false` 不嵌入。两者都不写的程序目标不嵌入,作为 host 工具构建的程序(§2.14)除外,它默认为 `true`。写在库目标上会被拒绝;在非 PE 目标上不产生任何东西。 | | `linkage` *(2026.9.15.2+)* | 一个库目标的**默认**链接形态,`"static"` 或 `"shared"`:不写 `linkage` 的消费者得到的形态。与 `kind = "shared"` 不同,它不是约束,因此消费者的显式陈述会被遵从。与 `kind = "shared"` 同写,或写在程序目标上,都会被拒绝。见[`dependency_linkage`](#dependency_linkage--静态还是动态由消费者决定)。 | @@ -1543,8 +1546,9 @@ ldflags = [] `app@1.2.0/src/main.cpp`),存储、home、构建目录、工具链与 sysroot 分别映射为 `/mcpp/store`、 `/mcpp/home`、`/mcpp/build`、`/mcpp/toolchain` 与 `/mcpp/sysroot`。同一棵树的两个检出因此嵌入 相同的路径。一个单元的映射只含固定根与它所属包的依赖闭包中各包的根,所以无论构建选择哪些工作空间成员, - 依赖的命令行都相同(2026.10.26.2+)。它作用于 `build`、`run` 与 `pack` 的产物,不作用于 `mcpp test`:测试二进制在构建处运行, - 且常借 `__FILE__` 找到它的夹具。带调试信息的构建还在输出旁写出 `source-map.gdbinit` 与 `source-map.lldbinit`, + 依赖的命令行都相同(2026.10.26.2+)。它作用于 `build`、`run`、`pack` 与 `mcpp test` 编译的代码,测试程序自己的文件除外:测试二进制 + 在构建处运行,且常借 `__FILE__` 找到它的夹具;被测试的代码就是发布的代码,因此 `mcpp build` 与 `mcpp test` + 是同一配置(2026.11.8.1+;此前发布类 profile 的测试是单独的配置,会把整个图再编译一遍)。带调试信息的构建还在输出旁写出 `source-map.gdbinit` 与 `source-map.lldbinit`, 供调试器把路径映射回来。cl.exe 没有文档化的等价开关,构建会报告一次。在 profile 下写 `ndebug = false` 或 `trim_paths = false` 可保留以前的产物;升级后第一次发布档构建会提示一次。 - **调试信息(2026.10.31.3+)。** 带调试信息、目标为 ELF 时(Linux、交叉 Linux 目标、ok 映像), @@ -1566,6 +1570,10 @@ ldflags = [] 同一谱系的新指纹接管该谱系最近使用的目录,由 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/` 超过该大小,没有任何配置 + 记录为当前的目录不论年龄一律删除;仍在使用的目录依然超过预算时,构建给出警告并列出最大的三个。 + 仍在使用的目录不会被删除(mcpp#874)。 ### 2.10 `[build] cache` —— 依赖的全局构建缓存 @@ -1961,6 +1969,7 @@ discover = ["tests/**/*.cpp"] # the default | `windows_long_paths` | `true` 或 `false` | Windows 上每个测试程序是否支持长路径,含义与同名的 target 键相同;默认 `false` *(2026.10.16.1+)* | | `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+)* | 这些 glob 使用与 `[build] sources` 相同的词汇。一个测试的名字,是它 相对第一条匹配到它的 glob 所在的固定目录的路径,去掉扩展名。两个 diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 3e83b4057..249b29ecf 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -53,6 +53,19 @@ glfw = "3.4" # Explicit namespace; no fallback search +带点的加引号键 `"mcpplibs.cmdline" = "0.0.2"` 是一个名字中含点的 TOML 键。 +mcpp 把它读作这些点所拼写的选择器,警告该拼法已弃用(自 2026.11.8.1 起, +2027.2 起移除;docs/51),并给出替代它的不加引号的键: +`mcpplibs.cmdline = "0.0.2"`。 +同一张表中的一个包与其命名空间下的包(`mcpplibs.mtest` 与 `mcpplibs.mtest.gtest`)不能都不加引号——一个名字 +会既是值又是表——因此其中一个的加引号键就是这对包的写法,不属于弃用: + +```toml +[dev-dependencies] +mcpplibs.mtest = "0.4.0" +"mcpplibs.mtest.gtest" = "0.4.0" +``` + ```toml # Path dependency (local development) [dependencies] @@ -377,7 +390,8 @@ mcpp index status # local state: state, age and revision gtest = "1.15.2" ``` -`mcpp build` 忽略这些依赖;`mcpp test` 解析并使用它们。`mcpp test` 自动 +`mcpp build` 忽略这些依赖;`mcpp test` 解析并使用它们,且只用于测试单元:包自己的源文件 +在 `mcpp test` 下与 `mcpp build` 下编译方式相同(2026.11.8.1+,见 [08](08-testing.md))。`mcpp test` 自动 发现 `tests/**/*.cpp` 并把它们编译为测试二进制。运行器与框架无关:每个文件 是一个独立的二进制,由退出码判定 —— 裸 `main`、gtest(经 `[dev-dependencies]` 加 `gtest_main`)或任何其他框架,行为完全一致,`-- args` 会转发给每一个测试 diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 04dde53a5..5668a216b 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -30,7 +30,9 @@ myproject/ 没有框架,也不需要注册。测试可以使用一个框架 —— `[dev-dependencies]` 是取到框架的 途径 —— 但 mcpp 持有的契约只是退出码,这也是为什么为其他框架写的测试不需要适配层。 -`mcpp new` 生成 `tests/test_smoke.cpp`,使工程从一开始就带有这个目录。 +`mcpp new` 生成 `tests/test_smoke.cpp`(一个不定义 `main` 的 [mtest](https://github.com/mcpp-community/mtest) +用例文件)与 dev-dependency `mcpplibs.mtest`(2026.11.8.1+);`mcpp new --template lib` +以同样方式生成一个库及其测试([01](01-getting-started.md))。 ### 测试的位置 @@ -48,7 +50,14 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] `unit/test_span`。`discover = []` 不发现任何测试。两个名字相同的文件会被拒绝,并 点出这两者。 -由多个源文件编译而成的测试套件本身是一个独立的包:一个工作区成员,它的 +`[test] programs = "directory"`(2026.11.8.1+)使 glob 固定目录之下同一目录的文件成为 +一个程序,以目录命名(`tests/unit/*.cpp` 为程序 `unit`):每个文件是它的一个翻译单元, +它的用例是这些文件的全部用例;有文件定义 `main` 时以该文件为起点,否则按不定义 `main` +的文件的规则开始。同一目录中两个定义 `main` 的文件会被拒绝并点出两者;直接位于固定 +目录中的文件仍各自是一个程序。多文件的程序只链接一次,而每文件一个程序时每个文件链接 +一次,后者正是由许多小文件组成的套件在构建上花费的时间。 + +由自身多个源文件编译而成的测试套件也可以是一个包:一个工作区成员,它的 `[build] sources` 承载整套套件,其唯一的测试程序驱动套件,用 `mcpp test -p ` 选中。 @@ -69,6 +78,13 @@ main = { from = "fw/main" } 框架借此以测试文件定义的回调测试它自己的入口。在独立目标上,`[test] start` 陈述每个测试 镜像的入口符号。 +入口可以以 `test-main = true` 提供自己(2026.11.8.1+):未陈述 `[test] main` 的包中,不定义 `main` +的测试文件以它为起点,定义了 `main` 的文件保留自己的。mtest 的入口与其 GoogleTest 适配的入口都提供 +自己,测试只需写 dev-dependency。选择以文件为单位:包提供模块的入口,在文件导入其中某个模块时属于该测试, +一个也未导入时被搁置;因此同一个包中,mtest 用例的文件以 mtest 的入口为起点,GoogleTest 用例的文件以 +GoogleTest 的为起点。依赖中定义 `main` 的源文件(`gtest_main.cc`)照旧作为测试的起点。同一测试仍有两个 +起点时,构建被拒绝并指出它们,由 `[test] main` 选择。 + ## 运行测试 ```bash @@ -99,6 +115,20 @@ mcpp test -- --verbose # everything after `--` goes to each test binary `mcpp test 2>&1 | tee test.log` 两者都收集 (见 [09 —— 按场景选命令](09-commands-by-scenario.md#输出流))。 +### 变更影响的测试 + +`mcpp test --since ` 与 `--changed `(2026.11.8.1+)只构建并运行一次变更 +影响的测试,在构建之前由计划读出: + +- 测试受其构建所用文件的变更影响:它自己的文件、它导入的模块单元及这些单元再导入的 + 单元,以及链接中不属于任何模块的单元; +- 清单、锁文件、构建程序、头文件或计划中没有的源文件的变更影响所有测试; +- 其他文件只影响自己的文件中写出该文件的测试(测试数据),不影响其他测试。 + +`--since` 从 git 读取变更:自该提交以来提交的、工作区中修改的、新增且未被忽略的路径。 +`Affected N of M tests` 给出选择结果及每个变更路径的判定;`--message-format json` 下 +`affected` 记录列出每个测试及其原因。这一选择只缩小模式或 `-p` 指定的范围,不增加测试。 + **测试按名字找到包的程序**(2026.10.27.1+)。`mcpp test` 先按 `mcpp build` 的方式构建包的程序,并给每个测试 `MCPP_BIN_`:程序 `` 的路径,名字转为大写,字母与数字之外的字符写作 `_`(`my-tool` 为 `MCPP_BIN_MY_TOOL`)。运行被测程序的测试从这里读取它。 @@ -133,6 +163,13 @@ counters = { path = "../counters" } 也永远看不到它。这就是「测试的依赖」与「包自己的依赖」之间的区别,也是测试框架不会 成为一个库所发布内容之一部分的原因。 +dev 依赖只到达测试单元,不到达包的其他部分(2026.11.8.1+)。它的包含目录与接口定义只给 +`tests/` 下的文件;包自己的源文件在 `mcpp test` 下以 `mcpp build` 给出的命令编译,因此被 +测试的代码就是发布的代码,`mcpp build` 与 `mcpp test` 共用一个构建目录和一份编译数据库。 +只有 dev 依赖到达的内核接口(`mcpplibs.mtest` 经 `mcpplibs.os` 到达 openkal 的接口)只给 +dev 依赖与测试单元:它们看到 `__OPENKAL__` 与 openkal 的头文件,包自己的源文件看不到。 +dev 依赖提供的 C 库或 C++ 运行时是测试所链接的每个目标文件的属性,仍属于整个配置。 + [`examples/11-features`](../../examples/11-features/) 声明了一个这样的依赖并使用它。 ## 在本机跑不了的目标上测试 @@ -209,6 +246,56 @@ mcpp test --archive tests.tar.gz # 在检出中运 拥有该资源的包对此做出声明,`mcpp test` 随后把这些测试串行化。工程方永远不需要 记得加上 `-j1`。 +## 用例:测试协议 + +一个测试程序可以由若干用例组成。mcpp 不为用例提供框架,而是实现一份由框架实现的协议的运行器一侧: +[mtest 测试协议](https://github.com/mcpp-community/mtest/blob/main/spec/protocol.md) 第 1 版 +(mcpp 2026.11.8.1+)。程序的入口陈述它时,程序实现该协议: + +```toml +[targets.main] +kind = "entry" +main = "src/main.cpp" +test-protocol = 1 +``` + +程序文件中带有标记 `mtest-protocol=1` 时同样如此,以 mtest 的库构建的程序即带有该标记。 +带有标记却没有写出 `program` 事件的程序没有使用协议:与框架的库链接在一起的普通 `main` +文件是一个用例,按退出状态判定,运行与列出时都是如此。 +mcpp 随之把协议的变量(`MTEST_PROTOCOL`、`MTEST_MODE`、`MTEST_REPORT`、`MTEST_TMPDIR`, +以及适用时的 `MTEST_SELECT`、`MTEST_SEED`)交给程序,读取程序写出的报告,并给出每个用例的结果: + +```bash +mcpp test # 报告与记录中给出每个用例的结果 +mcpp test --case 'fs/*' # 每个这类程序中与模式匹配的用例 +mcpp test --cases # 列出每个程序的用例,不运行 +mcpp test --seed 42 # 用例中随机性的种子 +``` + +- 程序的退出码仍决定它的结论。报告中有失败用例时,程序即使以零退出也判为失败;开始而未结束的用例判为失败, + 原因是程序的退出码。 +- 失败程序的报告列出每个失败的用例及其第一处失败的位置:`case fs/read failed at tests/fs.cpp:12: ...`。 +- `--case` 的模式以 `*` 与 `?` 匹配;不含二者的名字匹配它自身及其下的用例(`fs` 匹配 `fs/read`)。 + 已列举的用例所声明的用例在运行时报告,可以不被列举(协议 P3.4;mtest 的 `UTest fs{"fs", [] { "read"_test = ...; }}` + 只列举 `fs`):已列举用例之下的名字选中该用例,之下的模式则完整运行该已列举用例。 + 使用 `--case` 时,没有匹配用例的程序与所有未实现协议的程序不运行。 +- 未实现协议的程序是一个用例,以退出码判定,并且收不到任何协议变量,即使 mcpp 自身的环境中有它们。 + +`--message-format json` 的记录带有协议、用例及其失败([50](50-machine-output.md));`--print-launch` +为启动程序的调试器给出协议。 + +## 编写测试 + +```bash +mcpp test --guide # 框架、它们的指南,以及用例相关的命令 +``` + +框架在其入口上陈述编写测试的指南,`test-guide = "<文件>"`(2026.11.8.1+;协议的 P2.5):包中的一个 +文件,从已安装的包读取,与其版本一致。`mcpp test --guide` 列出本包的每个测试框架及其指南与仓库,以及列出、 +选择、解释用例的命令;没有框架时,给出上文的契约与接入 mtest 的写法。失败的运行以 +`how these tests are written: mcpp test --guide (...)` 结尾,`--message-format json` 的记录带有 `guide`。 +将要编写测试、或要读懂测试为何失败的人或 agent,从这里开始。 + ## 报告给程序 ```bash @@ -228,8 +315,8 @@ mcpp test unit/math --print-launch -- --gtest_filter=Math.Adds ## 当前边界 -- 一个测试是一个 `.cpp` 产出一个程序。mcpp 不发现文件内部的用例,因此框架的逐用例 - 选择发生在程序内部,经由 `--` 之后的参数传递。 +- 一个测试是一个 `.cpp` 产出一个程序。其中的用例属于程序:mcpp 只对实现测试协议的程序列出并选择用例; + 对其他程序,框架的选择发生在程序内部,经由 `--` 之后的参数传递。 - manifest 加载不了时,`mcpp test --list` 列出的是 `tests/**/*.cpp`,而不是它读不到的 `[test] discover` 集合。 - `--build-timeout` 只在 POSIX 上有效。 diff --git a/docs/zh/09-commands-by-scenario.md b/docs/zh/09-commands-by-scenario.md index 23245ec25..ed8d3fab9 100644 --- a/docs/zh/09-commands-by-scenario.md +++ b/docs/zh/09-commands-by-scenario.md @@ -83,7 +83,10 @@ host 工具条目。一份 `std` BMI 被机器上每个工程共享,实现以" 构建成功后,每天至多一次,移除 `[cache] unused_days` 天(config.toml;默认 30, `0` 关闭)内没有构建使用过的条目,包、host 工具与 `std` 均在其列;命中即记为使用。 `path` host 工具的源树变化时,写入新条目的同时移除同一源树在旧 stamp 下的条目 -(2026.10.31.3+)。构建前,若所写入的文件系统可用空间不足 2 GiB,mcpp 给出警告。 +(2026.10.31.3+)。构建前,若所写入的文件系统可用空间少于 2 GiB 与"该配置上一次构建写入量的 1.25 倍"中的较大者 +(记录在 `<输出目录>/.mcpp-written`),mcpp 给出警告并说明两者(2026.11.8.1+)。`mcpp cache prune` +与 `mcpp cache gc --older-than` 也按年龄收集 `std` 模块条目;`gc --max-size` 只对包与宿主工具做 +预算(2026.11.8.1+)。 ## 一个包已发布的版本 diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index 1c2d8e0c1..b8a0f3f1c 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -509,8 +509,8 @@ docs/22。 | `aarch64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `x86_64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `wasm32-emscripten` | verified | `emsdk@6.0.9` | 载荷 | 载荷 | 载荷 | 载荷 | -| `x86_64-openkal` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | -| `aarch64-openkal` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | +| `x86_64-openkal` | verified | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | +| `aarch64-openkal` | verified | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | `载荷` 这里有工具链 payload 产出它 · `图` 没有 payload,但依赖能供给 system · `系统` 在机器上被找到,不是 mcpp 安装的 · `SDK` 平台自己的 · @@ -553,6 +553,13 @@ system · `系统` 在机器上被找到,不是 mcpp 安装的 · `SDK` 平台 architecture`)。一个档位断言的是产物**被构建并运行**过;它不断言是 哪个模拟器运行的。 +**两个 `*-openkal` 行都是 `verified`(2026.11.8.1)。** 每台宿主构建两行,每个系统运行 +自身架构的映像:Linux 与 Windows x86_64 运行 `x86_64-openkal`,macOS arm64 运行 +`aarch64-openkal`,映像带平台的 C 库与 C++ 运行时(`import std`)。测试 1033 完成这件事, +`tests/e2e/host-requirements.tsv` 要求它在 Linux、macOS 与 Windows 上通过,因此一旦某个 +系统不再运行映像,CI 即失败。此前这两行的运行测试声明了 `llvm`,而只有 Linux 分片提供它, +mcpp 自身的 CI 只在 Linux 上运行映像。 + 在这两行上,`kind = "app"` 链接成一个共享库而不是可执行文件(见 [04 §2.2](04-mcpp-toml.md))——由 `application_form` 对 `env == "android"` 给出这个形态,与 `is_android()` 所用的是同一个判据。 diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 3d6f91319..d13ff5a69 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -383,6 +383,25 @@ OS 0.3 使用 OpenKal 0.24 依赖组。保留 OS 0.2.9 的工程显式选择匹 **缺少 `okld` 是拒绝而非警告。** 没有需求 note 的映像会被每个加载器拒绝;拒绝(`platform-tool-absent`) 指名构建它的包。 +### 两种角色,不分等级(mcpp 2026.11.8.1) + +映像行与原生行不是彼此的等级,而是两种角色。 + +- **发布形态。** 只需要文件、进程、网络与终端的程序,可以把每种架构一份映像作为唯一的 + 发布物,并在每个真实系统上测试这份映像。生态的应用即如此:mcppls-clangd、mcppls 与 + mcpp-ide 的后端。 +- **构建目标。** 原生行(`*-linux-gnu`、`*-linux-musl`、`*-windows-msvc`、`aarch64-macos`、 + Android、iOS、`wasm32-emscripten`、`*-none-elf`)是完整支持的目标,保证与映像相同。 + 只有原生行适用于: + - 按系统发布、以编译期选择调用系统接口的程序(mcpp 本身); + - 库,它随使用者的目标编译进使用者; + - 链接系统或厂商库的程序(Qt、Vulkan 或 Metal、CUDA 或 SYCL、Android 的 + `native_activity`、iOS 应用); + - 映像不覆盖的平台(Android、iOS、Web、裸机)。 + +两个映像行都是 `verified`([21](21-the-target-triple.md)):每台宿主都构建它们,Linux、 +macOS 与 Windows 在 mcpp 的 CI 中各自运行本架构的映像。 + ### 另一架构的映像(mcpp 2026.10.29.1) `okrun` 运行其自身架构的映像(`host = "same-arch"`)。在另一架构的机器上,`mcpp run` 与 `mcpp test` diff --git a/docs/zh/25-home-indices-and-store.md b/docs/zh/25-home-indices-and-store.md index b759ea7ce..e74d6b18f 100644 --- a/docs/zh/25-home-indices-and-store.md +++ b/docs/zh/25-home-indices-and-store.md @@ -22,10 +22,15 @@ mcpp 经由其包提供者 xlings 安装工具链与包。payload(一个包的 build-cache/ cache/ provisioned/ … /.mcpp/ + .gitignore `*`:目录忽略自身(2026.11.8.1+) + compiler-runs// 构建所生成源文件的锁 indices// 项目自己的 [indices] 的数据 xpkgs/ 它们所服务的 payload ``` +`/.mcpp/` 是本机关于该项目的状态,不是项目的源文件。mcpp 在创建或使用它时写入只含一行 `*` 的 +`.gitignore`,所以即使项目自己的忽略文件没有列出它,版本控制也会忽略它。 + 归属按索引划分。mcpp 所服务的索引,其数据与 payload 在 mcpp 的 home 中;提供者自己的索引(`xim` 及其附带的索引)留在 `registry/` 下。本地路径索引(`path = "…"`)在原处读取。 @@ -85,8 +90,10 @@ Windows 首次显式 `self init` 时,若 D 是可写的本地固定磁盘, |---|---|---| | `[cache] unused_days` | 30 | 构建后每天至多一次:该天数内没有构建使用过的缓存条目(包、`std` 模块、host 工具) | | `[build] keep_days` | 3 | `mcpp build` 或 `mcpp test` 后,每个工程每天至多一次:该天数内没有配置使用过的构建目录 | +| `[build] target_budget` | 无 | 每次 `mcpp build` 或 `mcpp test` 后,`target/` 超过该大小(`"20GiB"`、`"512MiB"`)时:没有任何配置记录为当前的构建目录,不论其年龄(2026.11.8.1+) | -`0` 表示全部保留。`mcpp self config --cache-unused-days ` 与 `--keep-days ` 写入它们, +`0` 表示全部保留。`target_budget` 从工程(或工作区根)的 `mcpp.toml` 读取,否则从 config.toml +读取。仍在使用的目录超过预算时,构建给出警告并列出最大的三个;仍在使用的目录不会被删除。`mcpp self config --cache-unused-days ` 与 `--keep-days ` 写入它们, `mcpp self config` 显示它们。 缓存条目的文件写入后不再改变,以 `noatime` 或 `relatime` 挂载的文件系统也不记录读取,因此由 mcpp diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 3e666e48b..5012d956c 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -1179,6 +1179,20 @@ mcpp 会把它自己构建时用的**同一份** std 模块暂存过来,缓存 这些契约值**无条件**折入重跑键——换 target、换 profile、开关 feature 都会触发重跑, 不需要任何 `rerun-if-env-changed` 声明。 +### 测试程序的逻辑:`mcpp::context`(mcpp 2026.11.8.1+) + +每个类型化读取函数都经由同一个函数读取:在 `mcpp::context` 之内,取上下文陈述的值,最内层优先;上下文 +未陈述的名字取外层上下文的值,再取环境。构建程序或插件逻辑的测试因此在自己的进程中陈述引擎的值,而不改变 +进程的环境: + +```cpp +mcpp::context engine({{"MCPP_TARGET_OS", "windows"}, {"MCPP_FEATURE_FAST", "1"}}); +expect(choose_flags() == "/O2"); +``` + +上下文属于创建它的线程,随其作用域结束。一个上下文最多容纳 64 个值,不复制它所命名的字符串,这些字符串的 +生存期长于上下文(字面量即是如此)。 + ### 两个方向上的文本都是 UTF-8(mcpp 2026.9.26.1+) mcpp 传给程序的每个路径与值都是 UTF-8,程序打印的每条指令也按 UTF-8 读取。 diff --git a/docs/zh/35-platforms.md b/docs/zh/35-platforms.md index e9fcce3ee..ff5140f99 100644 --- a/docs/zh/35-platforms.md +++ b/docs/zh/35-platforms.md @@ -15,6 +15,7 @@ **平台**是一个带版本的包,陈述一类目标所需的全部内容:工具链、目标侧各层的提供包、构建在本机运行的 工具、编译与链接约定、产物的命名与封装,以及运行器。工程一项都不必写。目标表为 `x86_64-openkal` 与 `aarch64-openkal` 选择 `mcpplibs.openkal-platform`(2026.10.27.1+),这两个目标的内置支持即是如此。 +映像是一种发布形态,原生行是保证相同的构建目标,二者不分高低([24](24-openkal-cross.md),"两种角色,不分等级")。 ## 最短可跑 @@ -157,7 +158,7 @@ error: openkal-musl is requested as '^0.27.0' by app, and the platform openkal-p ## 内置平台是一个版本 -目标表为两个 ok 映像目标选择一个 openkal-platform 版本,即当前 mcpp 验证过的版本:mcpp 2026.11.7.2 为 0.24.3 +目标表为两个 ok 映像目标选择一个 openkal-platform 版本,即当前 mcpp 验证过的版本:mcpp 2026.11.8.1 为 0.24.4,2026.11.7.2 为 0.24.3 (2026.11.7.1 选择系列 `^0.24`)。不写平台的工程在任何时候、有无 `mcpp.lock` 都构建同一组版本;锁中记录了另一个版本时, 构建采用默认版本并写出锁中记录的版本。更新的平台不需要新的 mcpp:工程在自己的清单中选择, diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 1bab6f3e3..54eaf655d 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -209,6 +209,7 @@ effects 采用具名的方式而不是一个 `destructive: true|false`,因为 | `pkg-format-list` | 2026.10.31.1(语义自 2026.10.30.1) | `--pkg-format tar,okb` 写出一棵树的各个容器(SPEC-014 §5.4) | IDE 的打包界面 | | `test-launch` | 2026.10.31.2 | `mcpp test --print-launch` 打印一个测试的 `mcpp.launch`(见下) | 调试测试的 IDE | | `launch-stream` | 2026.11.7.2 | `mcpp run --print-launch --message-format json` 先流式输出构建事件,再输出 `mcpp.launch` 信封(见下) | IDE 的运行,边构建边显示 | +| `test-protocol` | 2026.11.8.1 | 实现测试协议的测试程序,其用例在 `mcpp test --message-format json` 的记录中被列出(`--cases`)、选择(`--case`)并报告(§8) | IDE 的测试树、从编辑器运行一个用例 | ### 工具为项目运行 mcpp 的方式 @@ -464,7 +465,7 @@ mcpp toolchain list --format json 的形状是 `{target, note, toolchain, pin, status, default}`,`status` 取 `installed` / `available` / `via dependency graph` / `planned` 之一。 目标行另有 `platform`(2026.10.27.1+):目标表为该目标选择的平台包 -(2026.11.7.2 起为 `mcpplibs.openkal-platform@0.24.3`;2026.11.7.1 为系列 `^0.24`),或 `null`。 +(2026.11.8.1 起为 `mcpplibs.openkal-platform@0.24.4`,2026.11.7.2 为 `@0.24.3`;2026.11.7.1 为系列 `^0.24`),或 `null`。 **`toolchain` 与 `pin` 不是把同一个字段写了两遍。** `toolchain` 表示这一行 关联到什么 —— 已装的行是装好的载荷,词表行是它自己的约定。`pin` 只承载目标 @@ -701,7 +702,8 @@ mcpp pack [target] [--pkg-format ] [--target ...] --message-format js mcpp test [pattern] [--workspace] --message-format json ``` -这条流早于 §2 的信封,也不被它包裹:它是 NDJSON,每个测试结束时一条记录,随后 +这条流早于 §2 的信封,也不被它包裹:它是 NDJSON,每个测试结束时一条记录(`--no-run` 与 `--cases` +时每个测试构建完成时一条,2026.11.8.1 起),随后 每个成员一条汇总记录。`--workspace` 运行以一条 `workspace_summary` 记录 结束。对多个成员的测试会在每个组的第一条测试记录之前增加一条 `group_build` 记录。 §7 的保证对它同样成立 —— 字段只增不减,字段含义永不改变 —— 下表是 @@ -721,6 +723,12 @@ mcpp test [pattern] [--workspace] --message-format json | `timed_out` | 被 `--timeout` 杀掉时为 `true`(`run_fail`) | | `compile_output`、`run_output` | 捕获到的诊断输出 | | `reason` | 仅 `not_run` 时:一句话说明原因;其余情况为 `""` | +| `protocol` | 程序实现的测试协议版本,或 `null` *(2026.11.8.1+,[08](08-testing.md#用例测试协议))* | +| `cases` | 实现协议并运行过的程序:每个用例的 `id`、`status`(`pass`、`fail`、`skip`、`xfail`、`xpass`、`invalid`)、`duration_ms`、有原因时的 `reason`,以及 `failures`(`file`、`line`、`message`,陈述时还有 `expected` 与 `actual`) *(2026.11.8.1+)* | +| `missing` | 选择中命名而程序没有对应用例的标识 *(2026.11.8.1+)* | +| `listed` | `--cases` 时:每个用例的 `id`、`name`、`file`、`line`,陈述时还有 `kind`、`tags`、`expect` *(2026.11.8.1+)* | +| `guide` | 测试所用框架陈述了指南时,该指南文件 *(2026.11.8.1+)* | +| `program`、`sources` | 构建而未运行的测试(`--no-run`、`--cases`):程序,以及它由之编译的文件(它自己的与它链接的库的),供按改动选择测试的运行器使用 *(2026.11.8.1+)* | 汇总记录,`{"summary": {...}}`: @@ -800,6 +808,10 @@ mcpp test [pattern] [--workspace] --message-format json 出的树不带 `needs` 行,由更早版本的 mcpp 暂存出的树同样不带;自行放置库的 读者应当读取这些行,而不是从 `lib/` 下的文件推断闭包。 +`mcpp test --guide --message-format json` *(2026.11.8.1+)* 输出一个对象 `{"guide": {...}}`:`frameworks` +(每项 `package`、`version`、`entry`、`protocol` 或 `null`、`guide` 或 `null`、`repo`)、`commands`(`list`、 +`one`、`records`、`debug`)、`docs`,以及包没有框架时的 `suggest`(mtest 的 dev-dependency 写法与仓库)。 + ## 9. 构建事件流与结果记录 *(mcpp 2026.10.26.1+)* 构建一个工程的每条调用链 —— 终端里的人、只有 shell 的 agent、IDE、CI 任务 —— @@ -911,7 +923,7 @@ mcpp test --print-launch [-- <参数>] 对一个测试作同样的描述(特性 `test-launch`,2026.10.31.2):`` 指定的测试按 `mcpp test` 的方式构建,并按 `mcpp test` 启动它的方式描述——它的运行器、运行所加的环境、`--` 之后的参数——`data` -另以 `test` 给出测试名。多个测试名包含该模式时取名字完全相同的那个;仍指向多个测试时拒绝,`--list`、 +另以 `test` 给出测试名,以 `protocol` 给出程序实现的测试协议或 `null`(2026.11.8.1+)。多个测试名包含该模式时取名字完全相同的那个;仍指向多个测试时拒绝,`--list`、 `--no-run`、`--archive`、`--message-format json`、多条腿与多个成员同样拒绝。调试器启动信封描述的程序。 ## 10. 拒绝副作用 —— `--deny` *(mcpp 2026.10.26.1+)* diff --git a/docs/zh/51-supported-versions.md b/docs/zh/51-supported-versions.md index aefe888fb..8ecd143e0 100644 --- a/docs/zh/51-supported-versions.md +++ b/docs/zh/51-supported-versions.md @@ -70,6 +70,7 @@ schema,那是 [50 —— 机器可读输出](50-machine-output.md)。 | `pack-universal`:`[pack] universal`——`true` 被拒绝,`false` 被忽略;打包的目标即其腿 | 2026.10.30.1 | 2027.1 | `[pack] targets = ["openkal"]`(全部架构),或一个 `--target` | | `platform-universal`:平台的 `[platform] universal`,被忽略;打包的目标即其腿 | 2026.10.30.1 | 2027.1 | 平台中不写;由打包命名目标(`--target openkal`) | | `no-universal-flag`:`mcpp pack --no-universal`,单目标打包不再需要它(与目标集合同用时拒绝) | 2026.10.30.1 | 2027.1 | `--target -`(一条腿) | +| `quoted-dotted-dependency-key`:带点的加引号依赖键(`"mcpplibs.cmdline" = "0.0.2"`),即一个 TOML 键,按其中的点所拼写的选择器读取;它是一个包与其父包或子包并列时的唯一写法时除外(docs/05) | 2026.11.8.1 | 2027.2 | 不加引号的选择器 `mcpplibs.cmdline = "0.0.2"` | ## 复现一次构建 diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index a2fa4019d..ba766fbdf 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -114,6 +114,14 @@ MCPP= bash tests/e2e/02_new_build_run.sh 地方都不会跑**,它的绿色什么都不说明——新增脚本时要核对确有 job 供给 它所要的能力。 +**CI 由 [mtest](https://github.com/mcpp-community/mtest) 运行它们**(2026.11.8.1+): +`tests/e2e/mtest.toml` 使每个脚本成为一个程序,`# requires:` 行是它的标签,`# resources:` +行是它运行时占有的资源(`machine`:没有别的脚本与它并行;改动共享 home 配置的脚本这样声明)。 +`run_all.sh` 在 `E2E_MTEST=` 下经由它运行整套:`E2E_SHARD` 按存储(`tests/e2e/.mtest`, +CI 取自 main 的上一次运行)中测得的历史划分,`E2E_JOBS` 同时运行多少个脚本,`E2E_SET=pr` +按夜间的覆盖映射(`.github/workflows/e2e-coverage.yml`)运行变更影响的脚本。在 `tests/e2e` +中执行 `mtest explain` 可得知脚本在哪里失败以及如何再次运行。 + ## 写一条真的在测东西的判据 本仓库最可迁移的一条规则,也是被跳过时会静默失效的那一条: diff --git a/examples/02-with-deps/README.md b/examples/02-with-deps/README.md index 956fda448..c0a5bb587 100644 --- a/examples/02-with-deps/README.md +++ b/examples/02-with-deps/README.md @@ -10,5 +10,6 @@ mcpp run -- --name "C++23 modules" # Hello, C++23 modules! ``` -`mcpp.toml` 里的 SemVer 约束(`^0.0.1`)告诉 mcpp 自动选最高匹配版本。 -本地结果会写到 `mcpp.lock` 锁版本。 +`mcpp.toml` 以身份键 `mcpplibs.cmdline`(命名空间 `mcpplibs`,包 `cmdline`) +和精确版本 `0.0.2` 声明依赖;解析结果写入 `mcpp.lock`。版本范围(`^0.0.2`、 +`>=0.0.1, <0.1.0`)的写法见 docs/05-dependencies.md。 diff --git a/examples/02-with-deps/mcpp.toml b/examples/02-with-deps/mcpp.toml index aaaa0612e..66b69da5e 100644 --- a/examples/02-with-deps/mcpp.toml +++ b/examples/02-with-deps/mcpp.toml @@ -3,4 +3,4 @@ name = "with-deps" version = "0.1.0" [dependencies] -"mcpplibs.cmdline" = "^0.0.1" +mcpplibs.cmdline = "0.0.2" diff --git a/mcpp.toml b/mcpp.toml index 314bb184e..e505fe556 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.11.7.2" +version = "2026.11.8.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] @@ -47,6 +47,16 @@ files = ["docs/res/mcpp.rc"] # The unit tests run in the code page mcpp.exe runs in, so a result does not # depend on the region setting of the machine that runs them (2026.10.5.2). An # engine older than the key reports it as unsupported and ignores it. +# The e2e coverage map (design 2026-11-08 W8; .github/workflows/e2e-coverage.yml): mcpp built with +# GCC's arc counters, so that each e2e test's run records the sources it executed (mtest's +# `run --coverage`). Not shipped; unoptimized, so that a line's counter is the line's. +[profile.coverage] +opt = 0 +debug = false +cflags = ["--coverage"] +cxxflags = ["--coverage"] +ldflags = ["--coverage"] + [test] windows_code_page = "utf-8" @@ -88,31 +98,20 @@ linkage = "static" [dependencies] mcpplibs.cmdline = "0.0.1" # JSON for Modern C++, as the index's module package (no copied header). -"nlohmann.json" = "3.12.0" +nlohmann.json = "3.12.0" # The C library's macros (`stdout`, `stderr`, `EOF`, `errno` …) as C++ names: # `import std` carries no macro (design 2026-11-07 D10). -"mcpplibs.mwrap.libc" = "17.0.0" +mcpplibs.mwrap.libc = "17.0.0" # The codecs of a package's container (SPEC-014 §5): gzip and raw DEFLATE, # zstd, xz. The reference libraries, through their mwrap modules, so an archive # is written the same way on every host and nothing is looked for on PATH. -"mcpplibs.mwrap.zlib" = "1.3.2" -"mcpplibs.mwrap.zstd" = "1.5.7" -"mcpplibs.mwrap.xz" = "5.8.3" +mcpplibs.mwrap.zlib = "1.3.2" +mcpplibs.mwrap.zstd = "1.5.7" +mcpplibs.mwrap.xz = "5.8.3" # The operating system is mcpplibs.os's (design 2026-10-26 §25): mcpp keeps no # layer of its own. -[dependencies.mcpplibs] -os = "=0.3.1" - - -[target.linux.build] -sources = ["!src/native/windows/**"] - -[target.macos.build] -sources = ["!src/native/windows/**"] - -[target.windows.build] -sources = ["!src/native/posix/**"] +mcpplibs.os = "0.4.0" # `modules/` holds what gets linked into this binary; `src/` is what has not # been separated yet, and shrinking it is the direction. Each entry is an @@ -129,17 +128,25 @@ sources = ["!src/native/posix/**"] # `[workspace] members` below is what makes `-p` and `mcpp test` address them; # it is NOT what makes them importable. That is these path dependencies, and # needing both is easy to get wrong because either one alone looks sufficient. -[dependencies.mcpp] -invocation = { path = "modules/invocation" } -libs = { path = "modules/libs" } -log = { path = "modules/log" } -versioning = { path = "modules/versioning" } -source-kind = { path = "modules/source-kind" } -graph = { path = "modules/graph" } -dyndep = { path = "modules/dyndep" } -manifest = { path = "modules/manifest" } -toolchain-model = { path = "modules/toolchain-model" } -buildmcpp = { path = "modules/buildmcpp" } +mcpp.invocation = { path = "modules/invocation" } +mcpp.libs = { path = "modules/libs" } +mcpp.log = { path = "modules/log" } +mcpp.versioning = { path = "modules/versioning" } +mcpp.source-kind = { path = "modules/source-kind" } +mcpp.graph = { path = "modules/graph" } +mcpp.dyndep = { path = "modules/dyndep" } +mcpp.manifest = { path = "modules/manifest" } +mcpp.toolchain-model = { path = "modules/toolchain-model" } +mcpp.buildmcpp = { path = "modules/buildmcpp" } + +[target.linux.build] +sources = ["!src/native/windows/**"] + +[target.macos.build] +sources = ["!src/native/windows/**"] + +[target.windows.build] +sources = ["!src/native/posix/**"] [workspace] members = [ @@ -157,8 +164,8 @@ members = [ # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. # Namespace omission means exactly mcpplibs, so the compat wrapper is explicit. -[dev-dependencies.compat] -gtest = "1.15.2" +[dev-dependencies] +compat.gtest = "1.15.2" # 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 21ca123a0..30dad4d63 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -32,14 +32,13 @@ license = "Apache-2.0" # contract change; one that breaks only the global test is a consumer that had # been relying on something the contract never promised. [dependencies] -"nlohmann.json" = "3.12.0" +nlohmann.json = "3.12.0" -[dependencies.mcpp] -invocation = { path = "../invocation" } -libs = { path = "../libs" } -manifest = { path = "../manifest" } -source-kind = { path = "../source-kind" } -toolchain-model = { path = "../toolchain-model" } +mcpp.invocation = { path = "../invocation" } +mcpp.libs = { path = "../libs" } +mcpp.manifest = { path = "../manifest" } +mcpp.source-kind = { path = "../source-kind" } +mcpp.toolchain-model = { path = "../toolchain-model" } -[dev-dependencies.compat] -gtest = "1.15.2" +[dev-dependencies] +compat.gtest = "1.15.2" diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index da2ce4e56..5d25978dc 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -16,5 +16,5 @@ version = "0.1.0" description = "Algorithms over index-addressed directed graphs: topological order, levels, closure" license = "Apache-2.0" -[dev-dependencies.compat] -gtest = "1.15.2" +[dev-dependencies] +compat.gtest = "1.15.2" diff --git a/modules/invocation/mcpp.toml b/modules/invocation/mcpp.toml index e36e88afc..bb2ca60f9 100644 --- a/modules/invocation/mcpp.toml +++ b/modules/invocation/mcpp.toml @@ -12,5 +12,5 @@ version = "0.1.0" description = "The environment of one invocation of mcpp: statements over the started environment" license = "Apache-2.0" -[dependencies.mcpplibs] -os = "=0.3.1" +[dependencies] +mcpplibs.os = "0.4.0" diff --git a/modules/log/mcpp.toml b/modules/log/mcpp.toml index e3be63d68..22e76234d 100644 --- a/modules/log/mcpp.toml +++ b/modules/log/mcpp.toml @@ -11,8 +11,7 @@ version = "0.1.0" description = "Leveled logging for mcpp" license = "Apache-2.0" -[dependencies.mcpp] -invocation = { path = "../invocation" } - [dependencies] -"mcpplibs.mwrap.libc" = "17.0.0" +mcpp.invocation = { path = "../invocation" } + +mcpplibs.mwrap.libc = "17.0.0" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index 6a04878cd..80892ae6b 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -17,18 +17,17 @@ version = "0.1.0" description = "Manifest and descriptor parsing for mcpp" license = "Apache-2.0" -[dependencies.mcpp] -invocation = { path = "../invocation" } -libs = { path = "../libs" } -source-kind = { path = "../source-kind" } -toolchain-model = { path = "../toolchain-model" } -versioning = { path = "../versioning" } +[dependencies] +mcpp.invocation = { path = "../invocation" } +mcpp.libs = { path = "../libs" } +mcpp.source-kind = { path = "../source-kind" } +mcpp.toolchain-model = { path = "../toolchain-model" } +mcpp.versioning = { path = "../versioning" } + +mcpplibs.os = "0.4.0" # Subsystem-level tests. They see only this package, which is what lets them # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. -[dependencies.mcpplibs] -os = "=0.3.1" - -[dev-dependencies.compat] -gtest = "1.15.2" +[dev-dependencies] +compat.gtest = "1.15.2" diff --git a/modules/manifest/src/compat/legacy.cppm b/modules/manifest/src/compat/legacy.cppm index 628177e4f..f6ead8678 100644 --- a/modules/manifest/src/compat/legacy.cppm +++ b/modules/manifest/src/compat/legacy.cppm @@ -1,18 +1,18 @@ // mcpp.pm.compat.legacy - legacy dependency-key compatibility. // // COMPAT: This module exists only to keep pre-namespace dependency syntax -// working during migration: COMPAT(internal:legacy-dotted-keys), which no -// deprecation registry row schedules yet. +// working during migration: COMPAT(quoted-dotted-dependency-key), reported +// where the dependency is resolved (src/build/prepare/graph.cpp). // // Deprecated: // // [dependencies] // "mcpplibs.cmdline" = "0.0.2" // -// Canonical: +// Canonical (docs/05-dependencies.md): // -// [dependencies.mcpplibs] -// cmdline = "0.0.2" +// [dependencies] +// mcpplibs.cmdline = "0.0.2" export module mcpp.pm.compat.legacy; diff --git a/modules/manifest/src/compat/registry.cppm b/modules/manifest/src/compat/registry.cppm index 177447b11..bbfd34d06 100644 --- a/modules/manifest/src/compat/registry.cppm +++ b/modules/manifest/src/compat/registry.cppm @@ -93,6 +93,14 @@ inline constexpr Deprecation kDeprecations[] = { "nothing in the platform; the pack names its targets (`--target openkal`)", "manifest/deprecated", "docs/35-platforms.md", }, + { + "quoted-dotted-dependency-key", "2026.11.8.1", "2027.2", + "a quoted dependency key with dots (`\"mcpplibs.cmdline\" = \"0.0.2\"`): one TOML key, " + "read as the selector its dots spell; not where it is the only spelling of a package " + "beside its parent or child (docs/05)", + "the selector unquoted, `mcpplibs.cmdline = \"0.0.2\"`", + "manifest/deprecated", "docs/05-dependencies.md", + }, { "no-universal-flag", "2026.10.30.1", kRemovalFloor, "`mcpp pack --no-universal`, which a pack of one target no longer needs (refused with a " diff --git a/modules/manifest/src/dep_spec.cppm b/modules/manifest/src/dep_spec.cppm index 6e1b2cff6..d89b7dcfe 100644 --- a/modules/manifest/src/dep_spec.cppm +++ b/modules/manifest/src/dep_spec.cppm @@ -149,6 +149,10 @@ struct DependencySpec { bool inheritWorkspace = false; // .workspace = true bool legacyDottedKey = false; // parsed from legacy "ns.name" flat key + // The quoted key cannot be written otherwise: a package and a package of + // its namespace in one table (`mcpplibs.mtest`, `mcpplibs.mtest.gtest`), + // whose unquoted keys would make one name both a value and a table. + bool quotingRequired = false; // One-release migration marker for the former unquoted dotted-selector // search (`capi.lua` tried mcpplibs.capi before capi). New exact selectors // written as namespace subtables never set this bit. diff --git a/modules/manifest/src/key_registry.cppm b/modules/manifest/src/key_registry.cppm index d5f33a4ef..72cd5d617 100644 --- a/modules/manifest/src/key_registry.cppm +++ b/modules/manifest/src/key_registry.cppm @@ -55,6 +55,8 @@ inline constexpr KeyClass kKeyRegistry[] = { {"build", "cache", KeyScope::Configuration, KeyMerge::Scalar, false, false}, {"build", "cflags", KeyScope::Package, KeyMerge::List, true, true }, {"build", "compile_commands", KeyScope::Metadata, KeyMerge::Scalar, false, false}, + // The bytes target/ may hold (mcpp#874); read by the sweep after a build. + {"build", "target_budget", KeyScope::Metadata, KeyMerge::Scalar, false, false}, {"build", "cxxflags", KeyScope::Package, KeyMerge::List, true, true }, {"build", "cxx_runtime", KeyScope::Configuration, KeyMerge::Scalar, true, false}, {"build", "default-profile", KeyScope::Configuration, KeyMerge::Scalar, false, false}, diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index e9c96ae29..bb4ff76ac 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -2050,6 +2050,35 @@ std::expected parse_document(const t::Document& documen t.start = sit->second.as_string(); t.startDeclaredBy = std::format("[targets.{}] start = \"{}\"", tname, t.start); } + // `test-protocol` (docs/08): the version of the test protocol a test + // program that begins at this entry implements. Version 1 is the one + // this mcpp runs; a program of another version runs as a plain test. + if (auto pit = tt.find("test-protocol"); pit != tt.end()) { + if (t.kind != Target::Entry) + return std::unexpected(error(origin, std::format( + "targets.{}.test-protocol states what a test program that begins at an " + "entry implements, and `{}` is not a `kind = \"entry\"` target", tname, tname))); + if (!pit->second.is_int() || pit->second.as_int() < 1) + return std::unexpected(error(origin, std::format( + "targets.{}.test-protocol is the protocol's version, a positive integer " + "(`test-protocol = 1`)", tname))); + t.testProtocol = static_cast(pit->second.as_int()); + } + // `test-main` (docs/08): this entry begins a test program of a package + // that depends on this one when the test's file defines no `main`. + if (auto mit = tt.find("test-main"); mit != tt.end()) { + if (t.kind != Target::Entry || !mit->second.is_bool()) + return std::unexpected(error(origin, std::format( + "targets.{}.test-main is `true` or `false`, on a `kind = \"entry\"` target", tname))); + t.testMain = mit->second.as_bool(); + } + // `test-guide` (docs/08): the framework's guide to writing its tests. + if (auto git = tt.find("test-guide"); git != tt.end()) { + if (t.kind != Target::Entry || !git->second.is_string() || git->second.as_string().empty()) + return std::unexpected(error(origin, std::format( + "targets.{}.test-guide is a file of the package, on a `kind = \"entry\"` target", tname))); + t.testGuide = git->second.as_string(); + } // An entry target is a translation unit and not an artifact: the keys // that describe an artifact (its ABI name, its exports, its form, its // PE subsystem and manifest) belong to the program that selects it. @@ -2241,7 +2270,7 @@ std::expected parse_document(const t::Document& documen "kind", "linkage", "main", "soname", "exports", "windows_auto_export", "cflags", "cxxflags", "defines", "required_features", "windows_entry", "windows_subsystem", "windows_code_page", "windows_long_paths", - "start", + "start", "test-protocol", "test-main", "test-guide", }; for (auto& [key, _] : tt) { bool known = false; @@ -2820,12 +2849,25 @@ std::expected parse_document(const t::Document& documen } return {}; }; + // A quoted dotted key that names a package beside its parent or a child of + // its namespace in one table is the only spelling of the pair (mcpp#904). + auto mark_required_quoting = [](std::map& deps) { + for (auto& [key, spec] : deps) { + if (!spec.legacyDottedKey) continue; + spec.quotingRequired = std::ranges::any_of(deps, [&](auto const& other) { + return other.first != key + && (other.first.starts_with(key + ".") || key.starts_with(other.first + ".")); + }); + } + }; auto load_deps = [&](std::string_view section, std::map& out) -> std::expected { auto* tt = doc->get_table(section); if (!tt) return {}; - return load_deps_table(section, *tt, out); + auto r = load_deps_table(section, *tt, out); + if (r) mark_required_quoting(out); + return r; }; if (auto r = load_deps("dependencies", m.dependencies); !r) return std::unexpected(r.error()); if (auto r = load_deps("dev-dependencies", m.devDependencies); !r) return std::unexpected(r.error()); @@ -3615,10 +3657,18 @@ std::expected parse_document(const t::Document& documen m.testStart = value.as_string(); continue; } + if (key == "programs") { + if (!value.is_string() || (value.as_string() != "file" && value.as_string() != "directory")) + 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)")); + m.testPrograms = value.as_string(); + continue; + } if (key != "discover") { m.schemaWarnings.push_back(std::format( "[test] has unsupported key '{}' (ignored). Supported keys: " - "discover, main, start, windows_code_page, windows_long_paths.", key)); + "discover, main, programs, start, windows_code_page, windows_long_paths.", key)); continue; } bool ok = value.is_array(); @@ -4660,10 +4710,13 @@ std::expected parse_document(const t::Document& documen auto read_deps = [&](const char* key, std::map& out) -> std::expected { - if (auto f = body.find(key); f != body.end() && f->second.is_table()) - return load_deps_table( + if (auto f = body.find(key); f != body.end() && f->second.is_table()) { + auto r = load_deps_table( std::format("target.{}.{}", toml_header_key(triple), key), f->second.as_table(), out, key); + if (r) mark_required_quoting(out); + return r; + } return {}; }; if (auto r = read_deps("dependencies", cc.dependencies); !r) return std::unexpected(r.error()); diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 219dded23..503d308d9 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -220,6 +220,9 @@ struct Target { // Where `main` was stated, for diagnostics: `[targets.app] main`, or a // row's header. std::string mainDeclaredBy; + // Of a test program of several files (`[test] programs = "directory"`): + // its translation units besides `main`, relative to the package root. + std::vector testSources; // The files this target names as `main` on OTHER rows (and the one a // matching row replaced). Each is a beginning, never an ordinary source // of an image, whichever row is built (design 2026-10-26 §24.3). @@ -229,6 +232,17 @@ struct Target { // as an artifact target is: a program that selects it is refused, naming // the features (design 2026-10-26 §24.4 item 3). std::vector inactiveFeatures; + // Of an entry target: `test-protocol = 1`, the version of the mtest test + // protocol a test program that begins here implements (mtest + // spec/protocol.md P2.3, docs/08); 0 when the entry states none. + int testProtocol = 0; + // Of an entry target: `test-main = true`, the beginning of a test program + // of a package that depends on this one and whose file defines no `main` + // (docs/08), without `[test] main`. + bool testMain = false; + // Of an entry target: `test-guide = ""`, the package's guide to + // writing its tests, relative to the package root (`mcpp test --guide`). + std::string testGuide; // Whether this target is a PROGRAM -- something `mcpp run` can be asked // for and `mcpp pack` treats as the application pipeline's subject -- @@ -2222,6 +2236,10 @@ struct Manifest { // image is entered at. std::string testMainFrom; std::string testStart; + // `[test] programs`: "file" (a test program per discovered file) or + // "directory" (the files of one directory below a discover glob's fixed + // directory are one program, named by the directory). Empty: "file". + std::string testPrograms; // Set once a workspace root's own package has received `[workspace.package]` // and `[workspace.build]` (`mcpp::project::inherit_as_root_package`). The // manifest is read on two paths before it is planned, and an appended diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index 480996c7f..a2bf5ff90 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -19,15 +19,14 @@ version = "0.1.0" description = "Toolchain vocabulary: triples, dialects, fingerprints, link model" license = "Apache-2.0" -[dependencies.mcpp] -invocation = { path = "../invocation" } -versioning = { path = "../versioning" } +[dependencies] +mcpp.invocation = { path = "../invocation" } +mcpp.versioning = { path = "../versioning" } + +mcpplibs.os = "0.4.0" # Subsystem-level tests. They see only this package, which is what lets them # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. -[dependencies.mcpplibs] -os = "=0.3.1" - -[dev-dependencies.compat] -gtest = "1.15.2" +[dev-dependencies] +compat.gtest = "1.15.2" diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index 66ca3979d..58856106b 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -905,10 +905,10 @@ inline constexpr TargetInfo kKnownTargets[] = { // name, its runner, its C library and C++ run-time -- is the platform's, // `openkal-platform` (SPEC-013), pinned here. The two architectures the // loader relocates. - { "x86_64-openkal", "preview", "ok", "llvm@23.1.3","", true, - "mcpplibs.openkal-platform@0.24.3" }, - { "aarch64-openkal", "preview", "ok", "llvm@23.1.3","", true, - "mcpplibs.openkal-platform@0.24.3" }, + { "x86_64-openkal", "verified", "ok", "llvm@23.1.3","", true, + "mcpplibs.openkal-platform@0.24.4" }, + { "aarch64-openkal", "verified", "ok", "llvm@23.1.3","", true, + "mcpplibs.openkal-platform@0.24.4" }, }; inline std::span known_targets() { return kKnownTargets; } diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index 1c39d0165..88ea1d8b6 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -24,5 +24,5 @@ license = "Apache-2.0" # Subsystem-level tests. They see only this package, which is what lets them # 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.compat] -gtest = "1.15.2" +[dev-dependencies] +compat.gtest = "1.15.2" diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index e31e3b9a2..3b234ff55 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.7.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.11.8.1"; } // namespace mcpp diff --git a/src/bmi_cache/maintenance.cppm b/src/bmi_cache/maintenance.cppm index a1d35222d..957c149e0 100644 --- a/src/bmi_cache/maintenance.cppm +++ b/src/bmi_cache/maintenance.cppm @@ -511,7 +511,12 @@ export int cache_prune(const std::string& v) { int removed = 0; std::uintmax_t freed = 0; - for (auto& e : walk_collectable()) { + // Every kind, std modules included: an entry unused for the age asked is + // unused whatever it costs to build again (the post-build collection takes + // the same set; mcpp#874). + auto entries = walk_collectable(); + for (auto& e : walk_std_entries()) entries.push_back(std::move(e)); + for (auto& e : entries) { if (e.accessed != 0 && e.accessed >= cutoff) continue; auto n = remove_entry(e); if (n || !std::filesystem::exists(e.dir)) { @@ -616,11 +621,14 @@ export int cache_gc(const std::string& maxSizeArg, const std::string& olderThanA } } - // Package and tool entries. A std BMI is shared by every project on the - // machine and costs ~30 s to rebuild; evicting one to hit a size target - // trades a lot of time for a little disk. `cache clean --std` remains the - // explicit way, and the collection by last use after a build covers it. + // Package and tool entries for the size budget. A std BMI is shared by + // every project on the machine and costs ~30 s to rebuild; evicting one to + // hit a size target trades a lot of time for a little disk. By AGE a std + // module is collected like any entry (`--older-than`; mcpp#874): one + // unused that long is unused. `cache clean --std` remains the explicit way. auto entries = walk_collectable(); + if (olderThan) + for (auto& e : walk_std_entries()) entries.push_back(std::move(e)); std::ranges::sort(entries, [](const Entry& a, const Entry& b) { return a.accessed < b.accessed; // oldest first }); @@ -628,7 +636,7 @@ export int cache_gc(const std::string& maxSizeArg, const std::string& olderThanA int removed = 0; std::uintmax_t freed = 0; std::uintmax_t live = 0; - for (auto& e : entries) live += e.size; + for (auto& e : entries) if (e.kind != "std") live += e.size; auto now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); @@ -636,12 +644,12 @@ export int cache_gc(const std::string& maxSizeArg, const std::string& olderThanA for (auto& e : entries) { bool tooOld = olderThan && e.accessed != 0 && (now - e.accessed) > *olderThan; - bool overBudget = maxSize && live > *maxSize; + bool overBudget = maxSize && live > *maxSize && e.kind != "std"; if (!tooOld && !overBudget) continue; remove_entry(e); ++removed; freed += e.size; - live -= e.size; + if (e.kind != "std") live -= e.size; mcpp::ui::status("Collected", std::format("{} ({}, last used {})", e.label, human_bytes(e.size), format_age(e.accessed))); diff --git a/src/build/execute.cppm b/src/build/execute.cppm index cf7f96e80..d1c01c8c2 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -15,6 +15,8 @@ import mcpp.pack.pipeline; // #622 A10: build_and_pack, for `run --forma import mcpp.pack.compress; // the test archive's tar (SPEC-015 §4) 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.diag; import mcpp.build.plan; import mcpp.toolchain.triple; @@ -1767,16 +1769,26 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, std::filesystem::remove_all(ctx.outputDir, ec); } // 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`). + // space (mcpp#874: `final link failed: No space left on device`). The + // threshold is what this configuration's last build took from the file + // system (`.mcpp-written`, the free space before it less the free space + // after it), a quarter more, and never less than 2 GiB: a workspace whose + // build writes 20 GB is told before it starts, not at its link. + std::uintmax_t freeBefore = 0; { std::error_code sec; - const auto free = std::filesystem::space(ctx.outputDir.parent_path(), sec).available; - if (!sec && free < (2ull << 30)) + freeBefore = std::filesystem::space(ctx.outputDir.parent_path(), sec).available; + std::uintmax_t lastWritten = 0; + if (std::ifstream in(ctx.outputDir / ".mcpp-written"); in) in >> lastWritten; + const std::uintmax_t threshold = std::max(2ull << 30, lastWritten + lastWritten / 4); + if (!sec && freeBefore < threshold) mcpp::ui::warning(std::format( - "{} free where this build writes ({}). `mcpp clean --stale` removes the build " - "directories no configuration uses, and `mcpp cache gc --older-than 30d` the " - "cache entries none has used", - mcpp::bmi_cache::human_bytes(free), ctx.outputDir.parent_path().string())); + "{} free where this build writes ({}), and its last build took {}. `mcpp clean " + "--stale` removes the build directories no configuration uses, and `mcpp cache gc " + "--older-than 30d` the cache entries none has used", + mcpp::bmi_cache::human_bytes(freeBefore), ctx.outputDir.parent_path().string(), + mcpp::bmi_cache::human_bytes(lastWritten))); + if (sec) freeBefore = 0; } // The generated `*link:` spec lives in the output directory, and the line @@ -1929,6 +1941,13 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, mcpp::build::progress::finished( ctx.profile, mcpp::build::profile_descriptor(ctx.plan.manifest.buildConfig)); report_freestanding_size(ctx); + // What this build took from the file system, for the next one's warning. + if (freeBefore) { + std::error_code sec; + const auto freeAfter = std::filesystem::space(ctx.outputDir.parent_path(), sec).available; + if (!sec && freeAfter < freeBefore) + std::ofstream(ctx.outputDir / ".mcpp-written", std::ios::trunc) << (freeBefore - freeAfter) << '\n'; + } mcpp::bmi_cache::collect_unused_entries(); return 0; } @@ -3832,6 +3851,10 @@ export enum class TestMessageFormat { Human, Json }; export struct TestOptions { std::string filter; // substring match on the path-based test name; empty = all + // `--since ` and `--changed `: only the tests the change + // affects (docs/08 "The tests a change affects"). + std::string since; + std::vector changed; TestMessageFormat format = TestMessageFormat::Human; bool list = false; // enumerate only, no build/run // `--no-runner`: run the test binaries directly, ignoring a declared @@ -3884,6 +3907,20 @@ export struct TestOptions { // written to it, with what each needs beside it. Without: nothing is // built, and the tests it holds run here. std::filesystem::path archive; + // THE CASES OF A PROGRAM THAT IMPLEMENTS THE TEST PROTOCOL (docs/08 + // "Cases"; mtest spec/protocol.md). `--case PATTERN` runs, of each such + // program, the cases a pattern matches, and no program that has none or + // does not implement the protocol; `--cases` builds the tests and lists + // each program's cases instead of running them; `--seed N` is the seed a + // case's randomness takes (MTEST_SEED). + std::vector cases; + bool listCases = false; + // `--guide`: how this package's tests are written and run -- its test + // frameworks, each with its guide and repository, and the commands that + // list, select and explain cases -- for a person or an agent about to + // write a test (docs/08 "Writing a test"). Plans; builds and runs nothing. + bool guide = false; + std::optional seed; }; // What one member's `run_tests` actually did. `--workspace` fans out over @@ -3987,6 +4024,16 @@ struct TestResult { // its link edge and its main unit's compile edge in `.ninja_log`, 0 when // neither was rebuilt (`build_ms`, 2026.10.1.1+). long long buildMs = 0; + // The test protocol the program implements (0: none), and what its report + // said: each case's result, or its listing under `--cases`. + int protocol = 0; + mcpp::build::test_protocol::Report report; + // 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; + std::vector sources; + // The guide of the framework the test is written with, when known. + std::string guide; }; // Streaming NDJSON: one record per test, emitted as it finishes — a @@ -3998,18 +4045,36 @@ static void emit_test_json(const std::string& memberName, const TestResult& r) { : r.status == TestResult::St::NotRun ? "not_run" : r.status == TestResult::St::Built ? "built" : "run_fail"; - std::string signal = (r.exitCode > 128 && r.exitCode < 128 + 65) - ? std::to_string(r.exitCode - 128) : "null"; - std::println("{{\"member\":\"{}\",\"test\":\"{}\",\"status\":\"{}\"," - "\"exit_code\":{},\"signal\":{}," - "\"duration_ms\":{},\"timed_out\":{}," - "\"compile_output\":\"{}\",\"run_output\":\"{}\"," - "\"reason\":\"{}\",\"build_ms\":{}}}", - test_json_escape(memberName), - test_json_escape(r.name), st, r.exitCode, signal, r.durationMs, - r.timedOut ? "true" : "false", - test_json_escape(r.compileOutput), test_json_escape(r.runOutput), - test_json_escape(r.reason), r.buildMs); + nlohmann::ordered_json record = { + {"member", memberName}, + {"test", r.name}, + {"status", st}, + {"exit_code", r.exitCode}, + {"signal", (r.exitCode > 128 && r.exitCode < 128 + 65) ? nlohmann::ordered_json(r.exitCode - 128) + : nlohmann::ordered_json(nullptr)}, + {"duration_ms", r.durationMs}, + {"timed_out", r.timedOut}, + {"compile_output", r.compileOutput}, + {"run_output", r.runOutput}, + {"reason", r.reason}, + {"build_ms", r.buildMs}, + // The test protocol (docs/50 §8, 2026.11.8.1+): the version the + // program implements, or null; its cases' results; the identifiers a + // selection named and the program does not have; its listing. + {"protocol", r.protocol ? nlohmann::ordered_json(r.protocol) : nlohmann::ordered_json(nullptr)}, + }; + if (r.protocol) { + if (!r.report.listed.empty()) + record["listed"] = mcpp::build::test_protocol::listing_json(r.report); + if (!r.report.cases.empty()) record["cases"] = mcpp::build::test_protocol::cases_json(r.report); + if (!r.report.missing.empty()) record["missing"] = r.report.missing; + } + if (!r.program.empty()) { + record["program"] = r.program; + record["sources"] = r.sources; + } + if (!r.guide.empty()) record["guide"] = r.guide; + std::println("{}", record.dump(-1, ' ', false, nlohmann::ordered_json::error_handler_t::replace)); std::fflush(libc::stdout()); } @@ -4057,12 +4122,62 @@ static std::vector ninja_edges_since(const std::filesystem::path& log return edges; } +// HOW THIS PACKAGE'S TESTS ARE WRITTEN (`mcpp test --guide`, docs/08): its +// test frameworks with their guides, and the commands a writer of a test +// uses. With no framework, the contract mcpp holds and how to take one. +inline constexpr std::string_view kTestingDocs = + "https://github.com/mcpp-community/mcpp/blob/main/docs/08-testing.md"; +using mcpp::build::test_protocol::kMtestRelease; +using mcpp::build::test_protocol::kMtestRepo; + +static void print_test_guide(const BuildContext& ctx, bool json) { + const auto& frameworks = ctx.plan.testFrameworks; + if (json) { + nlohmann::ordered_json list = nlohmann::ordered_json::array(); + for (auto const& f : frameworks) + list.push_back({{"package", f.package}, {"version", f.version}, {"entry", f.entry}, + {"protocol", f.protocol ? nlohmann::ordered_json(f.protocol) : nlohmann::ordered_json(nullptr)}, + {"guide", f.guide.empty() ? nlohmann::ordered_json(nullptr) : nlohmann::ordered_json(f.guide)}, + {"repo", f.repo}}); + nlohmann::ordered_json g = { + {"frameworks", list}, + {"commands", {{"list", "mcpp test --cases"}, {"one", "mcpp test --case "}, + {"records", "mcpp test --message-format json"}, {"debug", "mcpp test --print-launch"}}}, + {"docs", kTestingDocs}, + }; + if (frameworks.empty()) g["suggest"] = {{"dev-dependency", std::format("mcpplibs.mtest = \"{}\"", kMtestRelease)}, {"repo", kMtestRepo}}; + std::println("{}", nlohmann::ordered_json{{"guide", g}}.dump()); + std::fflush(libc::stdout()); + return; + } + if (frameworks.empty()) { + std::println("No test framework: a test is a program under tests/, judged by its exit status."); + std::println("A framework of cases (mtest): [dev-dependencies] mcpplibs.mtest = \"{}\" ({})", kMtestRelease, kMtestRepo); + } else { + std::println("Test frameworks:"); + for (auto const& f : frameworks) { + std::println(" {} {} ({}{})", f.package, f.version, f.entry, + f.protocol ? std::format(", test protocol {}", f.protocol) : std::string{}); + if (!f.guide.empty()) std::println(" guide {}", f.guide); + if (!f.repo.empty()) std::println(" repo {}", f.repo); + } + } + std::println("Commands:"); + std::println(" mcpp test --cases each test's cases"); + std::println(" mcpp test --case the cases an identifier or a pattern names"); + std::println(" mcpp test --message-format json each case's result, a failure's place and both sides"); + std::println(" mcpp test --print-launch how the test starts, for a debugger"); + std::println("Testing in mcpp: {}", kTestingDocs); +} + // A planned test build, and what the tests run against it need: the plan, the // backend that drives it, how long planning and the build took, and how the // test binaries are executed. One per configuration group of a `mcpp test` // over several members; `run_tests` holds one for its one member. struct TestBuild { std::optional ctx; + // The tests a change affects (`--since`, `--changed`); none: every test. + std::optional> affected; std::unique_ptr backend; long long prepareMs = 0; // Phase A, the bulk pass, and any attribution drive. @@ -4073,6 +4188,10 @@ struct TestBuild { // MCPP_BIN_ for each program of the package, built before the // tests (L5). std::vector> programEnv; + // In a workspace plan, the same per member (the member's package name): + // a member's tests are told its own programs, and two members may each + // have a program of one name. + std::map>> memberProgramEnv; // How the test binaries are executed, resolved once for the build // (#544): every test of one build shares a target. @@ -4248,6 +4367,39 @@ static std::optional test_phase_a(TestBuild& tb, const TestOptions& // is non-zero the loop runs as before and each failure still names its // own test. // `keep` says which test binaries are goals. +// `--since`/`--changed`: the tests of this plan the change affects, told +// before they are built (docs/08). 2 when the change cannot be read. +static int select_by_change(TestBuild& tb, const TestOptions& testOpts, bool json) { + if (testOpts.since.empty() && testOpts.changed.empty()) return 0; + std::vector paths = testOpts.changed; + if (!testOpts.since.empty()) { + auto since = mcpp::build::test_impact::changed_since(tb.ctx->projectRoot, testOpts.since); + if (!since) { + mcpp::ui::error(std::format("--since {}: {}", testOpts.since, since.error())); + return 2; + } + paths.insert(paths.end(), since->begin(), since->end()); + } + for (auto& p : paths) std::ranges::replace(p, '\\', '/'); + const auto a = mcpp::build::test_impact::affected_tests(tb.ctx->plan, paths); + tb.affected.emplace(); + for (auto const& [name, _] : a.tests) tb.affected->insert(name); + std::size_t total = 0; + for (auto const& lu : tb.ctx->plan.linkUnits) total += lu.kind == LinkUnit::TestBinary; + if (json) { + std::string tests; + for (auto const& [name, why] : a.tests) + tests += std::format("{}{{\"test\":\"{}\",\"why\":\"{}\"}}", tests.empty() ? "" : ",", + test_json_escape(name), test_json_escape(why)); + std::println("{{\"affected\":[{}],\"of\":{},\"changed\":{}}}", tests, total, paths.size()); + } else { + mcpp::ui::info("Affected", std::format("{} of {} test{} by {} changed path{}", a.tests.size(), total, + total == 1 ? "" : "s", paths.size(), paths.size() == 1 ? "" : "s")); + for (auto const& n : a.notes) mcpp::ui::plain(std::format(" {}", n)); + } + return 0; +} + template static void test_bulk(TestBuild& tb, const TestOptions& testOpts, Keep&& keep) { auto* ctx = &*tb.ctx; @@ -4257,7 +4409,7 @@ static void test_bulk(TestBuild& tb, const TestOptions& testOpts, Keep&& keep) { bulk.keepGoing = true; bulk.buildTimeoutSecs = static_cast(testOpts.buildTimeoutSecs); for (auto& lu : ctx->plan.linkUnits) - if (keep(lu)) + if (keep(lu) && (!tb.affected || tb.affected->contains(lu.targetName))) bulk.ninjaTargets.push_back(lu.output.generic_string()); if (!bulk.ninjaTargets.empty()) { auto tBulk = std::chrono::steady_clock::now(); @@ -4337,6 +4489,36 @@ static void test_resolve_runner(TestBuild& tb, const TestOptions& testOpts, bool } } +// A failed test whose file imports a module no package of this build provides: +// the compiler says only that the module was not built. A test's framework is a +// `[dev-dependencies]` entry (docs/08), which a manifest written over the +// skeleton's leaves out. +void print_unprovided_test_imports(const BuildPlan& plan, const std::vector& failures) { + auto primary = [](std::string_view n) { return std::string(n.substr(0, n.find(':'))); }; + std::set provided{"std", "std.compat"}; + std::map unitOf; + for (auto const& cu : plan.compileUnits) { + if (!cu.providesModule.empty()) provided.insert(primary(cu.providesModule)); + unitOf.emplace(cu.source, &cu); + } + std::vector lines; + for (auto const& lu : plan.linkUnits) { + if (lu.kind != LinkUnit::TestBinary || !lu.entryMain + || std::ranges::find(failures, lu.targetName) == failures.end()) + continue; + auto it = unitOf.find(*lu.entryMain); + if (it == unitOf.end()) continue; + for (auto const& name : it->second->imports) + if (!name.empty() && name.front() != '<' && name.front() != '"' && !provided.contains(primary(name))) + lines.push_back(std::format("{} imports '{}', which no package of this build provides", + lu.entryMain->filename().generic_string(), primary(name))); + } + if (lines.empty()) return; + std::println(""); + for (auto const& l : lines) std::println("{}", l); + std::println("a test's framework is a [dev-dependencies] entry, e.g. mcpplibs.mtest = \"{}\" (docs/08)", kMtestRelease); +} + // One member's tests, against a build that exists: the per-test builds that // the bulk pass did not answer, then the runs, then the member's summary. // `owner` is the member's package name in the plan, which picks its test @@ -4366,6 +4548,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, auto filter_match = [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary && (owner.empty() || lu.memberOf == owner) + && (!tb.affected || tb.affected->contains(lu.targetName)) && (testOpts.filter.empty() || lu.targetName.find(testOpts.filter) != std::string::npos); }; @@ -4439,6 +4622,14 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, std::vector> env; long long buildMs = 0; // this test's own edges in .ninja_log std::filesystem::path program; // the test's own artifact + // The test protocol the program implements (0: none, P1.1), and the + // files of its run under it: the report, the selection, the cases' + // directory (docs/08 "Cases"). + int protocol = 0; + std::filesystem::path protocolDir; + std::optional select; + std::vector sources; + std::string guide; // its framework's guide (docs/08 "Writing a test") }; std::vector runnable; @@ -4450,6 +4641,44 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // untidy — it makes a failing assertion unattributable, and the whole // reason the per-test loop exists is attribution. + // A run of a program under the protocol: its directory emptied, the + // variables of `mode` (P2.1), the report beside them. + auto protocol_run = [&](const Runnable& r, std::string_view mode) { + std::error_code ec; + const auto reportName = mode == "list" ? "list.ndjson" : "report.ndjson"; + std::filesystem::remove(r.protocolDir / reportName, ec); + std::filesystem::remove_all(r.protocolDir / "tmp", ec); + std::filesystem::create_directories(r.protocolDir / "tmp", ec); + return mcpp::build::test_protocol::variables(mode, r.protocolDir / reportName, + mode == "list" ? std::nullopt : r.select, + r.protocolDir / "tmp", testOpts.seed); + }; + // The variables of a run, stated whole: those mcpp states for it, and the + // others removed, so that an outer runner's (mcpp test run by mtest, by + // an e2e suite) do not reach the program (P2.2). + auto state_protocol = [&](const Runnable& r, std::string_view mode, mcpp::run::Options& o) { + auto stated = protocol_run(r, mode); + for (auto v : mcpp::build::test_protocol::kVariables) + if (std::ranges::none_of(stated, [&](const auto& kv) { return kv.first == v; })) + o.unset.emplace_back(v); + for (auto& kv : stated) o.env.push_back(std::move(kv)); + }; + auto print_failed_cases = [](const mcpp::build::test_protocol::Report& report) { + for (const auto& c : report.cases) { + if (c.status != "fail" && c.status != "xpass") continue; + if (c.failures.empty()) { + mcpp::ui::plain(std::format(" case {} {}{}", c.id, + c.status == "xpass" ? "passed, expected to fail" : "failed", + c.reason.empty() ? std::string{} : ": " + c.reason)); + continue; + } + const auto& f = c.failures.front(); + mcpp::ui::plain(std::format(" case {} failed at {}:{}: {}", c.id, f.file, f.line, f.message)); + } + for (const auto& id : report.missing) + mcpp::ui::plain(std::format(" case {}: the program has no such case", id)); + }; + auto run_tests_now = [&](std::vector& list) { if (list.empty()) return; const bool capture = json || list.size() > 1; @@ -4521,19 +4750,50 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, int spawnErr = 0; std::string spawnDetail; std::string runOutput; - if (capture) { - auto rr = mcpp::run::capture_exec_deadline( - r.argv, r.env, deadline, &timedOut, {}, &spawnErr); - exitCode = rr.exit_code; + // The protocol's variables reach a program that implements + // it, stated for this run, and no other program (P1.2, P2.2). + mcpp::run::Options runOpts; + runOpts.env = r.env; + runOpts.input = mcpp::run::Input::inherit; + runOpts.streams = capture ? mcpp::run::Streams::merged : mcpp::run::Streams::inherit; + runOpts.total = deadline; + runOpts.report_spawn_failure = false; + if (r.protocol) { + state_protocol(r, "run", runOpts); + } else { + for (auto v : mcpp::build::test_protocol::kVariables) runOpts.unset.emplace_back(v); + } + if (!capture) mcpp::ui::status("Running", std::format("bin/{}", r.name)); + { + auto rr = mcpp::run::execute(r.argv, runOpts); + exitCode = rr.exit_code; + timedOut = rr.timed_out; + spawnErr = rr.spawn_error; runOutput = std::move(rr.output); spawnDetail = std::move(rr.spawn_detail); - } else { - mcpp::ui::status("Running", std::format("bin/{}", r.name)); - exitCode = mcpp::run::run_exec_deadline( - r.argv, r.env, deadline, &timedOut, &spawnErr); } auto ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - tStart).count(); + // What the program reported of its cases (P5); a report whose + // cases failed is a failure though the program exited zero + // (P5.5). + mcpp::build::test_protocol::Report report; + std::string casesNote; + int spoken = r.protocol; + if (r.protocol && spawnErr == 0) { + report = mcpp::build::test_protocol::read_report(r.protocolDir / "report.ndjson", exitCode); + std::error_code ec; + std::filesystem::remove_all(r.protocolDir / "tmp", ec); + if (!report.spoke && !timedOut) { + // The marker without the protocol: one case, its exit status. + report = {}; + spoken = 0; + } else { + casesNote = std::format(", {} case{}", report.cases.size(), + report.cases.size() == 1 ? "" : "s"); + if (exitCode == 0 && !timedOut && report.failed()) exitCode = 1; + } + } std::scoped_lock lock(reportMutex); if (spawnErr != 0) { @@ -4573,22 +4833,29 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, runOutput, ms, true, {}, r.buildMs}); } else if (exitCode == 0) { if (!json) mcpp::ui::plain(std::format( - "{} ... ok ({:.2f}s)", r.name, static_cast(ms) / 1000.0)); + "{} ... ok ({:.2f}s{})", r.name, static_cast(ms) / 1000.0, casesNote)); results.push_back({r.name, TestResult::St::Pass, 0, {}, runOutput, ms, false, {}, r.buildMs}); } else { if (!json) mcpp::ui::plain(std::format( - "{} ... FAIL (exit {}, {:.2f}s)", r.name, exitCode, - static_cast(ms) / 1000.0)); + "{} ... FAIL (exit {}, {:.2f}s{})", r.name, exitCode, + static_cast(ms) / 1000.0, casesNote)); results.push_back({r.name, TestResult::St::RunFail, exitCode, {}, runOutput, ms, false, {}, r.buildMs}); } + results.back().protocol = spoken; + results.back().report = std::move(report); + results.back().guide = r.guide; // The captured output belongs directly under its own line, or // it is attributable to nothing. if (!json && capture && !runOutput.empty()) { std::fputs(runOutput.c_str(), libc::stdout()); if (runOutput.back() != '\n') std::fputc('\n', libc::stdout()); } + // Of a failing program that reports its cases, the ones that + // failed, each with its first failure's place. + if (!json && results.back().status == TestResult::St::RunFail) + print_failed_cases(results.back().report); std::fflush(libc::stdout()); emit_json(results.back()); } @@ -4621,6 +4888,47 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, read_edges(); std::map objectOf; for (auto& cu : ctx->plan.compileUnits) objectOf[cu.source] = cu.object; + // The files a test program is compiled from (docs/50 §8 `sources`): the + // sources of its objects, and of the objects of each static library it + // links. What a runner that selects tests by change reads. + std::map sourceOf; + for (auto& cu : ctx->plan.compileUnits) sourceOf[cu.object] = cu.source; + std::map archiveOf; + for (auto& unit : ctx->plan.linkUnits) + if (unit.kind == mcpp::build::LinkUnit::StaticLibrary) archiveOf[unit.output] = &unit; + auto sources_of = [&](const mcpp::build::LinkUnit& unit) { + std::set files; + auto add_objects = [&](const std::vector& objects) { + for (auto& o : objects) + if (auto it = sourceOf.find(o); it != sourceOf.end()) + files.insert(it->second.generic_string()); + }; + add_objects(unit.objects); + for (auto& input : unit.implicitInputs) + if (auto a = archiveOf.find(input); a != archiveOf.end()) add_objects(a->second->objects); + return std::vector(files.begin(), files.end()); + }; + // The guide of the framework a test is written with: its entry's package, + // else -- a protocol program with its own `main` -- the graph's one + // framework that states a guide. + auto guide_of = [&](const mcpp::build::LinkUnit& unit, int protocol) { + std::vector guided; + for (auto const& f : ctx->plan.testFrameworks) { + if (f.guide.empty()) continue; + if (!unit.testFramework.empty() && f.package == unit.testFramework) return f.guide; + guided.push_back(&f); + } + return unit.testFramework.empty() && protocol && guided.size() == 1 ? guided.front()->guide + : std::string{}; + }; + // Where a protocol program's report, selection and case directory are: + // one directory per test under the configuration's output. + auto protocol_dir = [&](std::string_view testName) { + std::string leaf(testName); + for (auto& ch : leaf) + if (!std::isalnum(static_cast(ch)) && ch != '-' && ch != '.') ch = '_'; + return ctx->outputDir / "test-protocol" / leaf; + }; for (auto& lu : ctx->plan.linkUnits) { if (!filter_match(lu)) continue; @@ -4665,6 +4973,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, } results.push_back({lu.targetName, TestResult::St::CompileFail, 0, b.error().diagnosticOutput, {}, test_ms()}); + results.back().guide = guide_of(lu, lu.testProtocol); emit_json(results.back()); continue; } @@ -4691,8 +5000,11 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // A GL test that cannot find a driver fails the same way a GL program // does, so it must be told the same things. for (auto& kv : subosEnv) childEnv.push_back(kv); - // ...and where the package's own programs are (L5). + // ...and where the package's own programs are (L5): a workspace + // member's, for a test of that member. for (auto& kv : tb.programEnv) childEnv.push_back(kv); + if (auto mp = tb.memberProgramEnv.find(lu.memberOf); mp != tb.memberProgramEnv.end()) + for (auto& kv : mp->second) childEnv.push_back(kv); // ...and, through a runner, the files the test carries with it, as // `mcpp run` hands them over (see `runtime_files_for`). Written here, // in the single-threaded pass, one list per test program. @@ -4730,6 +5042,18 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, runnable.push_back({lu.targetName, std::move(argv), std::move(childEnv), buildMsOfTest, exe}); + // The protocol it implements: what its entry states, else the marker + // its file carries (P2.3). A version this mcpp does not speak runs as + // a program that implements none. + auto& added = runnable.back(); + const int version = lu.testProtocol ? lu.testProtocol + : mcpp::build::test_protocol::marker_version(exe); + if (version == mcpp::build::test_protocol::kVersion) { + added.protocol = version; + added.protocolDir = protocol_dir(lu.targetName); + } + added.sources = sources_of(lu); + added.guide = guide_of(lu, version); } // `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` @@ -4760,6 +5084,9 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, env.data = mcpp::build::result::to_json(launch); env.data["program"] = chosen->program.generic_string(); env.data["test"] = chosen->name; + // The test protocol the program implements, or null (docs/08 "Cases"): + // a debugger that starts it states the variables it wants. + env.data["protocol"] = chosen->protocol ? nlohmann::json(chosen->protocol) : nlohmann::json(nullptr); env.data["configuration"] = launch_configuration(*ctx); emit_launch(env); return 0; @@ -4771,10 +5098,92 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // `runnable` compiled and linked; a test that did not is already a // `CompileFail` in `results` and keeps that status, so this path reports // what was built without also reporting anything about what it does. + auto built_result = [&](const Runnable& r) { + TestResult t{r.name, TestResult::St::Built, 0, {}, {}, 0, false, {}, r.buildMs}; + t.protocol = r.protocol; + t.guide = r.guide; + t.program = r.program.generic_string(); + t.sources = r.sources; + return t; + }; if (testOpts.noRun || tb.builtNotRunHere) { - for (auto& r : runnable) - results.push_back({r.name, TestResult::St::Built, 0, {}, {}, 0, - false, {}, r.buildMs}); + for (auto& r : runnable) { + results.push_back(built_result(r)); + emit_json(results.back()); + } + } else if (testOpts.listCases || !testOpts.cases.empty()) { + // THE CASES (P3): each protocol program lists its cases without + // running one. `--cases` reports the listings; `--case` runs, of each + // program, the cases its patterns match (P4), and no program with + // none or that implements no protocol. + std::vector selected; + int notSelected = 0; + for (auto& r : runnable) { + if (!r.protocol) { + if (testOpts.listCases) { + results.push_back(built_result(r)); + if (!json) mcpp::ui::plain(std::format("{}: one case, its exit status (no test protocol)", r.name)); + emit_json(results.back()); + } else { + ++notSelected; + } + continue; + } + mcpp::run::Options listOpts; + listOpts.env = r.env; + 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 && listed.spawn_error == 0 && !report.spoke) { + // The marker without the protocol (a file of plain `main` beside a + // framework's library): one case, as a program of no protocol. + r.protocol = 0; + if (testOpts.listCases) { + results.push_back(built_result(r)); + if (!json) mcpp::ui::plain(std::format("{}: one case, its exit status (no test protocol)", r.name)); + emit_json(results.back()); + } else { + ++notSelected; + } + continue; + } + if (listed.exit_code != 0 || listed.spawn_error != 0 || !report.done) { + if (!json) mcpp::ui::plain(std::format("{} ... FAIL (listing its cases: exit {})", r.name, listed.exit_code)); + if (!json && !listed.output.empty()) mcpp::ui::plain(listed.output); + results.push_back({r.name, TestResult::St::RunFail, listed.exit_code, {}, listed.output, 0, listed.timed_out, + {}, r.buildMs}); + results.back().protocol = r.protocol; + emit_json(results.back()); + continue; + } + if (testOpts.listCases) { + results.push_back(built_result(r)); + if (!json) { + mcpp::ui::plain(std::format("{}: {} case{}", r.name, report.listed.size(), + report.listed.size() == 1 ? "" : "s")); + for (const auto& c : report.listed) + mcpp::ui::plain(std::format(" {} {}:{}", c.id, c.file, c.line)); + } + results.back().report = std::move(report); + emit_json(results.back()); + continue; + } + std::string lines; + for (const auto& id : mcpp::build::test_protocol::selection(report.listed, testOpts.cases)) + lines += id + "\n"; + if (lines.empty()) { ++notSelected; continue; } + r.select = r.protocolDir / "select.txt"; + std::ofstream(*r.select, std::ios::binary) << lines; + selected.push_back(std::move(r)); + } + if (!json && notSelected && !testOpts.listCases) + mcpp::ui::info("note", std::format( + "--case: {} test program{} with no matching case, or implementing no test protocol, did not run", + notSelected, notSelected == 1 ? "" : "s")); + if (!testOpts.listCases) run_tests_now(selected); } else { // THE FULL REASON, ONCE (SPEC-013 §18, criterion 7). It carries the key // that changes it (`run_under`); it was recorded silently and the @@ -4868,6 +5277,15 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, for (auto& n : failures) std::println(" {}", n); // (Each compile failure's diagnostics already printed inline under its // FAIL line in Phase B — the summary stays a compact name list.) + print_unprovided_test_imports(ctx->plan, failures); + // Where to read how these tests are written (docs/08). + if (!ctx->plan.testFrameworks.empty()) { + std::string names; + for (auto const& f : ctx->plan.testFrameworks) + names += std::format("{}{} {}", names.empty() ? "" : ", ", f.package, f.version); + std::println(""); + std::println("how these tests are written: mcpp test --guide ({})", names); + } } return rc; } @@ -5226,6 +5644,7 @@ export int run_tests(std::span passthrough, TestBuild tb; tb.programEnv = std::move(programEnv); tb.ctx.emplace(std::move(*prepared)); + if (int rc = select_by_change(tb, testOpts, json)) return rc; tb.backend = mcpp::build::make_ninja_backend(); tb.ninjaLog = tb.ctx->outputDir / ".ninja_log"; { @@ -5257,6 +5676,11 @@ export int run_tests(std::span passthrough, } } + if (testOpts.guide) { + print_test_guide(*ctx, json); + return 0; + } + // 4. "Compiling test_X (test)" lines for the test binaries. test_announce(*ctx); // List test binaries. @@ -5390,6 +5814,7 @@ export int run_test_legs(std::span passthrough, discovered->targets, std::move(o)); if (!prepared) { if (int rc = refused(prepared.error())) return rc; continue; } leg->tb.ctx.emplace(std::move(*prepared)); + if (int rc = select_by_change(leg->tb, testOpts, json)) return rc; leg->tb.backend = mcpp::build::make_ninja_backend(); leg->tb.ninjaLog = leg->tb.ctx->outputDir / ".ninja_log"; std::error_code ec; @@ -5613,6 +6038,8 @@ export void run_workspace_tests(std::span passthrough, tb->prepareMs = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); tb->ctx.emplace(std::move(*prepared)); + if (select_by_change(*tb, testOpts, json) != 0) + return std::unexpected(std::format("--since {}: the change could not be read", testOpts.since)); tb->backend = mcpp::build::make_ninja_backend(); tb->ninjaLog = tb->ctx->outputDir / ".ninja_log"; std::error_code ec; @@ -5722,6 +6149,47 @@ export void run_workspace_tests(std::span passthrough, slots[i].packageOutputShown = true; } } + // EACH MEMBER'S PROGRAMS, BUILT BEFORE ITS TESTS AND NAMED TO THEM (L5), + // as for one package. A workspace's test plan holds a member's test + // programs only, so its own programs are built by the member's plan; + // without this a member's test found no MCPP_BIN_ under + // `--workspace` and found it under `-p` (found by mtest's conformance + // suite). + for (auto i : who) { + if (slots[i].packageFailed || slots[i].session < 0) continue; + auto m = mcpp::manifest::load(wsRoot / members[i].path / "mcpp.toml", {.insideWorkspace = true}); + if (!m || std::ranges::none_of(m->targets, [](auto const& t) { + return t.kind == mcpp::manifest::Target::Binary + || t.kind == mcpp::manifest::Target::Application; })) + continue; + BuildOverrides po = base; + po.package_filter.clear(); + po.project_root = wsRoot; + po.will_run = false; + po.workspace_members = { members[i].path }; + po.workspace_request = { members[i].path }; + po.member_targets.clear(); + auto binCtx = prepare_build(/*print_fp=*/false, /*includeDevDeps=*/false, {}, std::move(po)); + if (!binCtx) { + slots[i].packageFailed = true; + slots[i].packageOutput = binCtx.error(); + continue; + } + if (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; + } + 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()); + } + } // The test goals of the members that can run. std::set runnable; for (auto i : who) @@ -5954,14 +6422,70 @@ export std::int64_t configured_keep_days() { days = doc->get_int("build.keep_days").value_or(days); return days; } +// `[build] target_budget` (mcpp#874): the bytes a project's `target/` may hold, +// from the project's mcpp.toml (the workspace root's), else config.toml; none +// when neither states one. `"20GiB"`, `"500MiB"`, or a number of bytes. +std::optional target_budget(const std::filesystem::path& root) { + for (auto const& file : {root / "mcpp.toml", mcpp::home::root() / "config.toml"}) { + std::error_code ec; + if (!std::filesystem::exists(file, ec)) continue; + auto doc = mcpp::libs::toml::parse_file(file); + if (!doc) continue; + if (auto v = doc->get_string("build.target_budget")) + if (auto n = mcpp::bmi_cache::parse_size(*v)) return n; + } + return std::nullopt; +} + +std::uintmax_t tree_bytes(const std::filesystem::path& dir) { + std::uintmax_t total = 0; + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it(dir, ec), end; it != end; it.increment(ec)) { + std::error_code fe; + if (it->is_regular_file(fe) && !it->is_symlink(fe)) total += it->file_size(fe); + } + return total; +} + +// OVER BUDGET, AFTER EVERY BUILD: the directories no configuration uses go at +// once, whatever their age; if the ones in use still exceed the budget, the +// build says so and names the largest. Only when a budget is stated: the +// measurement walks target/. +void keep_target_budget(const std::filesystem::path& root) { + namespace fs = std::filesystem; + const auto budget = target_budget(root); + std::error_code ec; + if (!budget || !fs::is_directory(root / "target", ec)) return; + if (tree_bytes(root / "target") <= *budget) return; + clean_stale(false, 0, true); + const auto now = tree_bytes(root / "target"); + if (now <= *budget) return; + std::vector> dirs; + for (fs::directory_iterator t(root / "target", ec), end; t != end; t.increment(ec)) { + if (!t->is_directory() || t->path().filename().string().starts_with(".")) continue; + std::error_code fec; + for (fs::directory_iterator f(t->path(), fec), fend; f != fend; f.increment(fec)) + if (f->is_directory()) + dirs.emplace_back(tree_bytes(f->path()), f->path().lexically_relative(root).generic_string()); + } + std::ranges::sort(dirs, std::greater{}); + std::string largest; + for (std::size_t i = 0; i < dirs.size() && i < 3; ++i) + largest += std::format("{}{} ({})", i ? ", " : "", dirs[i].second, mcpp::bmi_cache::human_bytes(dirs[i].first)); + mcpp::ui::warning(std::format( + "target/ holds {}, over [build] target_budget = {}, in configurations still in use: {}", + mcpp::bmi_cache::human_bytes(now), mcpp::bmi_cache::human_bytes(*budget), largest)); +} + export void sweep_after_build() { namespace fs = std::filesystem; const std::int64_t days = configured_keep_days(); std::error_code ec; - if (days <= 0) return; auto root = mcpp::project::find_manifest_root(fs::current_path()); if (!root) return; if (auto ws = mcpp::project::find_workspace_root(*root); !ws.empty()) root = ws; + keep_target_budget(*root); + if (days <= 0) return; const auto stamp = *root / "target" / ".mcpp-swept"; if (fs::exists(stamp, ec)) { const auto age = std::chrono::duration_cast( @@ -6233,8 +6757,11 @@ export int clean_stale(bool dryRun, std::int64_t keepWithinSecs, bool quiet) { target.string(), kept, kept == 1 ? "y" : "ies", window())); } else { if (quiet) - mcpp::ui::status("Swept", std::format("{} build director{} no configuration used for {} days ({}; [build] keep_days in config.toml)", - removed, removed == 1 ? "y" : "ies", keepWithinSecs / 86400, mcpp::bmi_cache::human_bytes(bytes))); + mcpp::ui::status("Swept", keepWithinSecs == 0 + ? std::format("{} build director{} no configuration uses ({}; over [build] target_budget)", + removed, removed == 1 ? "y" : "ies", mcpp::bmi_cache::human_bytes(bytes)) + : std::format("{} build director{} no configuration used for {} days ({}; [build] keep_days in config.toml)", + removed, removed == 1 ? "y" : "ies", keepWithinSecs / 86400, mcpp::bmi_cache::human_bytes(bytes))); line(std::format("{} {} director{} ({})", dryRun ? "Would remove" : "Removed", removed, removed == 1 ? "y" : "ies", mcpp::bmi_cache::human_bytes(bytes))); } diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 4fef5e6dc..2b17a310c 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -38,6 +38,7 @@ namespace fs = std::filesystem; inline constexpr std::string_view kMcppModuleSource = R"CPP(module; #include #include +#include @MODULE@ mcpp; export namespace mcpp { inline void cxxflag(const char* flag) { std::printf("mcpp:cxxflag=%s\n", flag); } @@ -486,7 +487,56 @@ inline void rerun_if_changed_glob(const char* pattern) { } inline void rerun_if_env_changed(const char* var) { std::printf("mcpp:rerun-if-env-changed=%s\n", var); } // ── environment contract (read side; values injected by the engine) ───── -inline const char* env_or(const char* n) { const char* v = std::getenv(n); return v ? v : ""; } +// +// THE VALUES IN SCOPE (2026.11.8.1+, docs/30 "Testing a program's logic"). +// Every accessor reads the engine's values through `value_of`: within a +// `mcpp::context`, the values it states, the innermost context first; else the +// environment the engine gave the program. A test of a build program's or a +// plugin's logic states the engine's values in its own process, +// +// mcpp::context engine({{"MCPP_TARGET_OS", "windows"}, {"MCPP_PROFILE", "release"}}); +// expect(choose_flags() == ...); +// +// instead of changing its environment. A context belongs to the thread that +// made it and ends with its scope. +// +// A context holds up to 64 values; it does not copy the strings it names, +// which outlive it (literals do). +struct value { const char* name; const char* text; }; +class context { +public: + template + context(const value (&values)[N]) : outer_(innermost()) { + static_assert(N <= 64, "a mcpp::context holds up to 64 values"); + for (unsigned long i = 0; i < N; ++i) values_[i] = values[i]; + count_ = N; + innermost() = this; + } + context(const context&) = delete; + context& operator=(const context&) = delete; + ~context() { innermost() = outer_; } + // The value this context states for `name`, or null. + const char* find(const char* name) const { + for (unsigned long i = 0; i < count_; ++i) + if (std::strcmp(values_[i].name, name) == 0) return values_[i].text ? values_[i].text : ""; + return nullptr; + } + const context* outer() const { return outer_; } + static const context*& innermost() { + static thread_local const context* current = nullptr; + return current; + } +private: + value values_[64] = {}; + unsigned long count_ = 0; + const context* outer_ = nullptr; +}; +inline const char* value_of(const char* n) { + for (const context* c = context::innermost(); c; c = c->outer()) + if (const char* v = c->find(n)) return v; + return std::getenv(n); +} +inline const char* env_or(const char* n) { const char* v = value_of(n); return v ? v : ""; } inline const char* target() { return env_or("MCPP_TARGET"); } inline const char* target_os() { return env_or("MCPP_TARGET_OS"); } inline const char* target_arch() { return env_or("MCPP_TARGET_ARCH"); } @@ -790,7 +840,7 @@ inline bool has_feature(const char* name) { : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; } buf[o] = 0; - return std::getenv(buf) != nullptr; + return value_of(buf) != nullptr; } // mcpp#241: resolved install dir of a declared dependency (by its package // name), or "" if not found. Same sanitize as has_feature; wraps @@ -816,7 +866,7 @@ inline const char* dep_dir(const char* name) { // Said on standard output, which the engine prints when the program exits // non-zero; `std::fprintf(stderr, ...)` here would bind `FILE` in this // module and collide with a build program's own . - if (const char* which = std::getenv(ambiguous)) { + if (const char* which = value_of(ambiguous)) { std::printf("build.mcpp: dep_dir(\"%s\") names more than one dependency (%s); ask by one of " "those names\n", name, which); std::exit(2); diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index d8f5b432e..cf423df27 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -1537,6 +1537,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // A unit's own flags: its package's, then its package's `trim_paths` map // (mcpp#844), so that nothing outside the package's closure reaches it. auto unit_trim = [&](const CompileUnit& cu) -> std::string_view { + if (cu.testUnit) return {}; // a test's own file keeps its paths auto it = flags.trimByPackage.find(cu.packageName); return it == flags.trimByPackage.end() ? std::string_view{} : std::string_view(it->second); }; @@ -4750,6 +4751,23 @@ std::expected NinjaBackend::build(const BuildPlan& plan mcpp::build::dist::stream_init_shim_source()); } + // A program's directory that an earlier build left as a file: a test + // named `e2e` before a later `e2e/c` (`[test] programs` changed, or a file + // became a directory). The file is a product this plan no longer makes, + // and ninja cannot write below it. + for (auto const& lu : plan.linkUnits) { + std::error_code fec; + // And the other way: a program where an earlier build placed others below it. + if (!lu.output.empty() && std::filesystem::is_directory(plan.outputDir / lu.output, fec)) + std::filesystem::remove_all(plan.outputDir / lu.output, fec); + for (auto dir = (plan.outputDir / lu.output).parent_path(); + dir != plan.outputDir && dir.has_relative_path(); dir = dir.parent_path()) + if (std::filesystem::is_regular_file(dir, fec)) { + std::filesystem::remove(dir, fec); + break; + } + } + if (opts.dryRun) { BuildResult r; r.exitCode = 0; diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 00d4de9f1..7d4b44d53 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -68,6 +68,10 @@ struct CompileUnit { // provides nothing, and on a unit built by hand, which then takes the name. std::string bmiFile; std::vector imports; // logical names imported + // A file of a test program (its `main` file, or another of its directory): + // the one kind of unit `trim_paths` leaves its paths, since a test finds + // its fixtures through `__FILE__` and runs where it was built. + bool testUnit = false; // The key of this unit's module map in `BuildPlan::moduleScopes`: the BMI // of every module the unit imports, directly or through another module, // and of the module it provides. Empty when every such BMI lies at its @@ -203,6 +207,9 @@ struct LinkUnit { std::string soname; // ABI name for shared libraries std::vector runtimeAliases; // relative aliases, e.g. bin/libfoo.so.1 std::optional entryMain; // src path of main.cpp for bin + // Of a test: the files that are its own (its `main` file and, under + // `[test] programs = "directory"`, its directory's others). + std::vector ownSources; // `windows_subsystem` / `windows_entry` of the target this unit links // (#618), carried as the declared words. Rendering them needs the linker // the emitter addresses, which the plan does not know, so the backend does @@ -217,6 +224,13 @@ struct LinkUnit { std::string entryFrom; std::string start; std::string startFrom; + // The test protocol the selected entry states it implements + // (`test-protocol`, docs/08); 0 when none does. `mcpp test` also reads a + // program's marker for it (mtest spec/protocol.md P2.3). + int testProtocol = 0; + // The package whose entry begins this test, when one does: the framework + // whose guide `mcpp test --guide` names (BuildPlan::testFrameworks). + std::string testFramework; }; // One Windows resource script compiled into one linkable resource artifact @@ -399,6 +413,17 @@ struct ModuleScope { std::vector arguments; }; +// A package of the graph whose entry states how its tests are written or run +// (`test-guide`, `test-protocol`, `test-main`): a test framework. +struct TestFramework { + std::string package; // qualified name + std::string version; + std::string entry; // `/` + int protocol = 0; + std::string guide; // absolute path of its guide, or empty + std::string repo; +}; + struct BuildPlan { mcpp::manifest::Manifest manifest; // Every package of the graph, as the report names it; the backend writes @@ -575,6 +600,8 @@ struct BuildPlan { std::vector compileUnits; // topologically sorted std::vector linkUnits; + // The test frameworks of the graph (docs/08 "Writing a test"). + std::vector testFrameworks; // Build-graph nodes declared by build programs (`mcpp:action=`). Paths are // absolute and engine variables already substituted by the time they get // here, so the backend only has to spell edges. @@ -1162,6 +1189,40 @@ expand_manifest_include_entry(const std::filesystem::path& root, return expanded; } +// The other translation units of a test program of several files (`[test] +// programs = "directory"`): each compiled as the program's `main` unit is, +// with its own imports. +std::vector test_program_units(const CompileUnit& mainUnit, const mcpp::manifest::Target& t, + const std::filesystem::path& root, + const mcpp::ExtensionTable& extTable) { + std::vector out; + for (auto const& source : t.testSources) { + CompileUnit cu = mainUnit; + cu.source = root / source; + cu.kind = mcpp::classify(cu.source, extTable); + cu.object.clear(); + const auto scanned = mcpp::modgraph::scan_entry_file(cu.source, cu.packageName, extTable); + cu.imports.clear(); + for (auto const& req : scanned.requires_) cu.imports.push_back(req.logicalName); + cu.declaration = scanned.provides ? mcpp::modgraph::ModuleDeclaration::Unknown : scanned.declaration; + out.push_back(std::move(cu)); + } + return out; +} + +// A test unit sees its package's own usage and then its dev-dependencies' +// (`PackageRoot::testBuild`); the package's other units see the first only. +void with_test_usage(CompileUnit& cu, const mcpp::modgraph::PackageRoot& pkg) { + auto add = [](auto& to, auto const& from) { + for (auto const& x : from) + if (std::ranges::find(to, x) == to.end()) to.push_back(x); + }; + add(cu.localIncludeDirs, pkg.testBuild.includeDirs); + add(cu.localIncludeDirsAfter, pkg.testBuild.includeDirsAfter); + add(cu.packageCflags, pkg.testBuild.cflags); + add(cu.packageCxxflags, pkg.testBuild.cxxflags); +} + std::vector local_include_dirs_for_manifest(const std::filesystem::path& root, const mcpp::manifest::Manifest& manifest) @@ -1548,6 +1609,204 @@ bool path_is_under_any(const std::filesystem::path& p, return false; } +// A preprocessor condition's value as far as a source's text decides it: true, +// false, or unknown (nullopt). A name the compiler or the platform predefines +// (one beginning with `_`) is unknown; every other name is not defined, as no +// command defines the switches a library guards its own tools with (zlib's +// MAKECRCH, QuickJS's TEST). +namespace main_scan { + +struct Expr { + std::string_view text; + std::size_t at = 0; + bool failed = false; + + void blank() { while (at < text.size() && (text[at] == ' ' || text[at] == '\t')) ++at; } + bool take(std::string_view op) { + blank(); + if (text.substr(at, op.size()) != op) return false; + at += op.size(); + return true; + } + std::string name() { + blank(); + std::size_t from = at; + while (at < text.size() && (std::isalnum(static_cast(text[at])) || text[at] == '_')) ++at; + return std::string(text.substr(from, at - from)); + } + static bool reserved(std::string_view n) { return !n.empty() && n.front() == '_'; } + + std::optional primary() { + blank(); + if (take("(")) { + auto v = ternary(); + if (!take(")")) failed = true; + return v; + } + if (take("!")) { + auto v = primary(); + return v ? std::optional(!*v) : std::nullopt; + } + if (take("-")) { + auto v = primary(); + return v ? std::optional(-*v) : std::nullopt; + } + if (at < text.size() && std::isdigit(static_cast(text[at]))) { + long long v = 0; + const bool hex = text.substr(at, 2) == "0x" || text.substr(at, 2) == "0X"; + if (hex) at += 2; + const auto from = at; + while (at < text.size() && std::isxdigit(static_cast(text[at]))) ++at; + std::from_chars(text.data() + from, text.data() + at, v, hex ? 16 : 10); + while (at < text.size() && std::isalpha(static_cast(text[at]))) ++at; // 1L, 2u + return v; + } + const auto n = name(); + if (n.empty()) { failed = true; return std::nullopt; } + if (n == "defined") { + const bool paren = take("("); + const auto m = name(); + if (paren && !take(")")) failed = true; + return reserved(m) ? std::nullopt : std::optional(0); + } + blank(); + if (at < text.size() && text[at] == '(') { // a function-like macro: its value is the macro's + int depth = 0; + do { + depth += text[at] == '(' ? 1 : text[at] == ')' ? -1 : 0; + ++at; + } while (at < text.size() && depth > 0); + return std::nullopt; + } + return reserved(n) ? std::nullopt : std::optional(0); + } + template + std::optional binary(Next next, std::initializer_list ops, Apply apply) { + auto lhs = next(); + for (bool more = true; more && !failed;) { + more = false; + for (auto op : ops) { + blank(); + // `&&` is not `&`, `<=` is not `<`: the longer operators are listed first. + if (!take(op)) continue; + lhs = apply(op, lhs, next()); + more = true; + break; + } + } + return lhs; + } + static std::optional arith(std::string_view op, std::optional a, std::optional b) { + if (!a || !b) return std::nullopt; + if (op == "*") return *a * *b; + if (op == "/") return *b ? std::optional(*a / *b) : std::nullopt; + if (op == "+") return *a + *b; + if (op == "-") return *a - *b; + if (op == "<=") return *a <= *b; + if (op == ">=") return *a >= *b; + if (op == "<") return *a < *b; + if (op == ">") return *a > *b; + if (op == "==") return *a == *b; + return *a != *b; + } + std::optional product() { return binary([&] { return primary(); }, {"*", "/"}, arith); } + std::optional sum() { return binary([&] { return product(); }, {"+", "-"}, arith); } + std::optional relation() { return binary([&] { return sum(); }, {"<=", ">=", "<", ">"}, arith); } + std::optional equality() { return binary([&] { return relation(); }, {"==", "!="}, arith); } + std::optional conjunction() { + return binary([&] { return equality(); }, {"&&"}, [](auto, auto a, auto b) -> std::optional { + if ((a && !*a) || (b && !*b)) return 0; + if (a && b) return 1; + return std::nullopt; + }); + } + std::optional disjunction() { + return binary([&] { return conjunction(); }, {"||"}, [](auto, auto a, auto b) -> std::optional { + if ((a && *a) || (b && *b)) return 1; + if (a && b) return 0; + return std::nullopt; + }); + } + std::optional ternary() { + auto c = disjunction(); + if (!take("?")) return c; + auto t = ternary(); + if (!take(":")) failed = true; + auto f = ternary(); + if (!c) return t == f ? t : std::nullopt; + return *c ? t : f; + } +}; + +// The value of `#if `; unknown when the text is not an expression this reads. +std::optional condition(std::string_view text) { + Expr e{text}; + auto v = e.ternary(); + e.blank(); + if (e.failed || e.at != text.size() || !v) return std::nullopt; + return *v != 0; +} + +// One `#if` group: whether its enclosing region may be compiled, whether a +// branch of it was taken for certain, and whether one may have been. +struct Group { + bool parent = true; + bool certainlyTaken = false; + bool current = true; // the current branch may be compiled +}; + +// The code of a source the preprocessor may compile: the lines of branches +// whose conditions are not false, directives dropped. +std::string compiled_lines(std::string_view code) { + std::string out; + std::vector groups; + auto active = [&] { return groups.empty() || (groups.back().parent && groups.back().current); }; + auto open = [&](std::optional c) { + const bool parent = active(); + groups.push_back({parent, c.value_or(false), c.value_or(true)}); + }; + auto next_branch = [&](std::optional c) { + if (groups.empty()) return; + auto& g = groups.back(); + g.current = !g.certainlyTaken && c.value_or(true); + g.certainlyTaken = g.certainlyTaken || c.value_or(false); + }; + std::size_t at = 0; + while (at < code.size()) { + auto end = code.find('\n', at); + if (end == std::string_view::npos) end = code.size(); + std::string line(code.substr(at, end - at)); + at = end + 1; + while (!line.empty() && line.back() == '\\' && at < code.size()) { // a continued directive + line.pop_back(); + end = code.find('\n', at); + if (end == std::string_view::npos) end = code.size(); + line += code.substr(at, end - at); + at = end + 1; + } + const auto first = line.find_first_not_of(" \t"); + if (first == std::string::npos || line[first] != '#') { + if (active()) out += line + '\n'; + continue; + } + Expr d{std::string_view(line).substr(first + 1)}; + const auto word = d.name(); + const auto rest = std::string_view(line).substr(first + 1 + d.at); + if (word == "if") open(condition(rest)); + else if (word == "ifdef" || word == "ifndef") { + Expr n{rest}; + const auto m = n.name(); + std::optional isDefined = Expr::reserved(m) ? std::nullopt : std::optional(false); + open(word == "ifdef" ? isDefined : (isDefined ? std::optional(!*isDefined) : std::nullopt)); + } else if (word == "elif") next_branch(condition(rest)); + else if (word == "else") next_branch(true); + else if (word == "endif" && !groups.empty()) groups.pop_back(); + } + return out; +} + +} // namespace main_scan + bool source_defines_main(const std::filesystem::path& src) { std::ifstream is(src); if (!is) return false; @@ -1587,6 +1846,7 @@ bool source_defines_main(const std::filesystem::path& src) { break; } } + code = main_scan::compiled_lines(code); auto isws = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; }; @@ -2402,20 +2662,15 @@ make_plan(const mcpp::manifest::Manifest& manifest, // A file a target names as `main` on another row is a beginning too // (design 2026-10-26 §24.3): it is in no image of this row. std::set entryFilesAcrossTargets; - for (auto& t : manifest.targets) { - if (!t.main.empty()) { - entryFilesAcrossTargets.insert(projectRoot / t.main); - } - for (auto const& other : t.otherMains) entryFilesAcrossTargets.insert(projectRoot / other); - } - for (auto const& p : packages) { - for (auto const& t : p.manifest.targets) { - if (!t.main.empty()) { - entryFilesAcrossTargets.insert(p.root / t.main); - } - for (auto const& other : t.otherMains) entryFilesAcrossTargets.insert(p.root / other); - } - } + // A test program's other files are its own as well (`[test] programs`). + auto own_files = [&](const std::filesystem::path& root, const mcpp::manifest::Target& t) { + if (!t.main.empty()) entryFilesAcrossTargets.insert(root / t.main); + for (auto const& other : t.otherMains) entryFilesAcrossTargets.insert(root / other); + for (auto const& source : t.testSources) entryFilesAcrossTargets.insert(root / source); + }; + for (auto& t : manifest.targets) own_files(projectRoot, t); + for (auto const& p : packages) + for (auto const& t : p.manifest.targets) own_files(p.root, t); struct SharedDepTarget { std::size_t packageIndex = 0; @@ -2512,10 +2767,30 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } std::set depEntryMainSources; + // Every source beneath the dev-dependencies that defines `main`, a direct + // dev-dependency's or one beneath it (gtest_main.cc of compat.gtest beneath + // a GoogleTest adapter): none is linked into a program that has a beginning + // of its own. Only the tests' packages: a C library's start-up file names + // `main` and belongs to every program. + std::set devOnlyPackages; + for (auto const& p : packages) + if (p.devOnly) devOnlyPackages.insert(qualified_package_name(p.manifest)); + std::set testPackages = devDepPackages; + testPackages.insert(devOnlyPackages.begin(), devOnlyPackages.end()); + std::set depMainSources; + auto has_tests = [](const mcpp::manifest::Manifest& m) { + return std::ranges::any_of(m.targets, [](auto const& t) { + return t.kind == mcpp::manifest::Target::TestBinary; + }); + }; + const bool testPlan = has_tests(manifest) || std::ranges::any_of(packages, [&](auto const& p) { + return p.selectedMember && has_tests(p.manifest); + }); for (auto& cu : plan.compileUnits) { - if (!devDepPackages.contains(cu.packageName)) continue; - if (!is_implementation_source(cu.kind)) continue; - if (source_defines_main(cu.source)) depEntryMainSources.insert(cu.source); + if (!testPlan || !testPackages.contains(cu.packageName) || !is_implementation_source(cu.kind)) continue; + if (!source_defines_main(cu.source)) continue; + depMainSources.insert(cu.source); + if (devDepPackages.contains(cu.packageName)) depEntryMainSources.insert(cu.source); } std::map> directPackageDeps; @@ -2544,16 +2819,24 @@ make_plan(const mcpp::manifest::Manifest& manifest, // edges. auto const& edges = i == 0 ? manifest.dependencies : packages[i].manifest.dependencies; - for (auto const& [depName, spec] : edges) { - for (auto const& candidate : dependency_name_candidates(depName, spec)) { - auto it = packageIndexByName.find(candidate); - if (it == packageIndexByName.end() || it->second == i) continue; + auto add_edges = [&](const std::map& table) { + for (auto const& [depName, spec] : table) { + auto const names = dependency_name_candidates(depName, spec); + auto hit = std::ranges::find_if(names, [&](auto const& n) { + auto it = packageIndexByName.find(n); + return it != packageIndexByName.end() && it->second != i; + }); + if (hit == names.end()) continue; auto& deps = directPackageDeps[i]; - if (std::find(deps.begin(), deps.end(), it->second) == deps.end()) - deps.push_back(it->second); - break; + const auto depIndex = packageIndexByName.at(*hit); + if (std::find(deps.begin(), deps.end(), depIndex) == deps.end()) deps.push_back(depIndex); } - } + }; + add_edges(edges); + // A test links its package's dev-dependencies: a shared one by its + // library, as a dependency is (mcpplibs.mtest.capi's libmtest). + if (testPlan && (i == 0 || packages[i].selectedMember)) + add_edges(i == 0 ? manifest.devDependencies : packages[i].manifest.devDependencies); } // WHERE A STATIC PACKAGE UNDER A SHARED LIBRARY IS LINKED (#646 F1). @@ -3086,6 +3369,103 @@ make_plan(const mcpp::manifest::Manifest& manifest, return EntrySelection{ *idx, entry, t.mainFrom }; }; + // THE DEFAULT BEGINNING OF A TEST (docs/08): a test whose file defines no + // `main`, of a package that states no `[test] main`, begins at the entry + // a dependency (or the package itself) offers with `test-main = true`. An + // offer whose package provides modules none of which the test imports is + // not this test's framework and is set aside, so a file of mtest's cases + // and a file of GoogleTest's begin each at its own. A dev-dependency's + // source that defines `main` (gtest_main.cc) begins a test as it always + // has; two beginnings left for one test are refused, naming them. + struct offer { std::string spelling; std::size_t idx; }; + std::set entryMainFiles; // an entry's own file is no such source + auto test_main_entries = [&](std::size_t idx, const std::string& depKey, + std::vector& offers) { + auto const& provider = idx == 0 ? manifest : packages[idx].manifest; + auto const& providerRoot = idx == 0 ? projectRoot : packages[idx].root; + for (auto const& candidate : provider.targets) { + if (candidate.kind != mcpp::manifest::Target::Entry) continue; + entryMainFiles.insert(providerRoot / candidate.main); + if (candidate.testMain) offers.push_back({depKey + "/" + candidate.name, idx}); + } + }; + // How an offer fits the test: the file imports a module its package + // provides (decisive), its package provides none (a header framework's), + // or it provides modules the file does not import (not this test's). + enum class fit { imported, headers, other }; + auto offer_fits = [&](const offer& o, const std::vector& imports) { + const auto owner = qualified_package_name(o.idx == 0 ? manifest : packages[o.idx].manifest); + bool provides = false; + for (auto const& cu : plan.compileUnits) { + if (cu.packageName != owner || cu.providesModule.empty()) continue; + provides = true; + if (std::ranges::contains(imports, cu.providesModule)) return fit::imported; + } + return provides ? fit::other : fit::headers; + }; + auto test_main_default = [&](std::size_t consumerIdx, const mcpp::manifest::Manifest& consumer, + const mcpp::manifest::Target& t, const LinkUnit& lu, + const std::set& mainSources) + -> std::expected, std::string> { + if (t.kind != mcpp::manifest::Target::TestBinary || !t.mainFrom.empty() || !lu.entryMain + || source_defines_main(*lu.entryMain)) + return std::nullopt; + std::vector offers; + // A package is one provider however many tables name it (a dependency that is also a + // dev-dependency offers its beginning once). + std::set providers{consumerIdx}; + auto look = [&](const std::map& deps) { + for (auto const& [depName, spec] : deps) { + auto const names = dependency_name_candidates(depName, spec); + auto hit = std::ranges::find_if(names, [&](auto const& n) { return packageIndexByName.contains(n); }); + if (hit != names.end() && providers.insert(packageIndexByName.at(*hit)).second) + test_main_entries(packageIndexByName.at(*hit), depName, offers); + } + }; + test_main_entries(consumerIdx, consumer.package.name, offers); + look(consumer.devDependencies); + look(consumer.dependencies); + if (offers.empty()) return std::nullopt; + // The program's imports: its `main` file's, and its other files' (`[test] programs`). + std::vector imports; + auto const& consumerRoot = consumerIdx == 0 ? projectRoot : packages[consumerIdx].root; + std::vector files{*lu.entryMain}; + for (auto const& source : t.testSources) files.push_back(consumerRoot / source); + for (auto const& file : files) + for (auto const& req : mcpp::modgraph::scan_entry_file(file, qualified_package_name(consumer), + rootExtTable).requires_) + imports.push_back(req.logicalName); + std::erase_if(offers, [&](const offer& o) { return offer_fits(o, imports) == fit::other; }); + // An offer the file imports from decides; else every remaining + // beginning competes. + const auto imported = std::ranges::count_if(offers, [&](const offer& o) { + return offer_fits(o, imports) == fit::imported; + }); + if (imported == 1) + std::erase_if(offers, [&](const offer& o) { return offer_fits(o, imports) != fit::imported; }); + std::vector beginnings; + for (auto const& o : offers) beginnings.push_back(std::format("the entry '{}' (test-main)", o.spelling)); + if (imported != 1) + for (auto const& source : mainSources) + if (!entryMainFiles.contains(source)) + beginnings.push_back(std::format("{} (a dev-dependency's source that defines `main`)", + source.filename().generic_string())); + if (offers.empty()) return std::nullopt; + if (beginnings.size() > 1) { + std::string list; + for (auto const& b : beginnings) list += (list.empty() ? "" : "; ") + b; + return std::unexpected(std::format( + "the test '{}' defines no `main`, and {} could begin it: {}. State one with " + "[test] main = {{ from = \"{}\" }}, define `main` in the test, or remove the " + "dependency whose beginning it does not use", + t.name, beginnings.size(), list, offers.front().spelling)); + } + auto withEntry = t; + withEntry.mainFrom = offers.front().spelling; + withEntry.mainDeclaredBy = std::format("the test-main entry '{}'", offers.front().spelling); + return withEntry; + }; + // Apply a selected entry to a link unit: its object, the PE entry // function its translation unit defines (inherited unless the program // states another, which is refused), and where it came from. @@ -3105,6 +3485,8 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.windowsEntry = sel.target->windowsEntry; } lu.entryFrom = sel.name; + lu.testProtocol = sel.target->testProtocol; + lu.testFramework = qualified_package_name(sel.packageIndex == 0 ? manifest : packages[sel.packageIndex].manifest); return entry_object(sel.packageIndex, entry, *sel.target); }; @@ -3162,6 +3544,22 @@ make_plan(const mcpp::manifest::Manifest& manifest, return {}; }; + // The test frameworks of the graph: each package's entry that states its + // guide, its protocol or that it begins a test (docs/08). + for (std::size_t i = 0; i < std::max(packages.size(), 1); ++i) { + auto const& m = i == 0 ? manifest : packages[i].manifest; + auto const& dir = i == 0 ? projectRoot : packages[i].root; + for (auto const& entry : m.targets) { + if (entry.kind != mcpp::manifest::Target::Entry) continue; + if (entry.testGuide.empty() && !entry.testProtocol && !entry.testMain) continue; + plan.testFrameworks.push_back({ + qualified_package_name(m), m.package.version, + std::format("{}/{}", m.package.name, entry.name), entry.testProtocol, + entry.testGuide.empty() ? std::string{} : (dir / entry.testGuide).lexically_normal().generic_string(), + m.package.repo}); + } + } + // 4. Link units (one per [targets.X]) // When any TestBinary target exists, skip Binary/Library/SharedLibrary // targets — `mcpp test` only cares about the test binaries, and pulling @@ -3241,8 +3639,11 @@ make_plan(const mcpp::manifest::Manifest& manifest, // The beginning a dependency's entry target supplies (§24.3). std::optional selectedEntry; - if (!t.mainFrom.empty() && lu.kind != LinkUnit::StaticLibrary) { - auto sel = select_entry(0, manifest, t); + auto defaulted = test_main_default(0, manifest, t, lu, depEntryMainSources); + if (!defaulted) return std::unexpected(defaulted.error()); + const auto& begins = *defaulted ? **defaulted : t; + if (!begins.mainFrom.empty() && lu.kind != LinkUnit::StaticLibrary) { + auto sel = select_entry(0, manifest, begins); if (!sel) return std::unexpected(sel.error()); selectedEntry = *sel; } @@ -3272,6 +3673,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, CompileUnit main_cu; main_cu.source = *lu.entryMain; main_cu.packageName = qualified_package_name(manifest); + main_cu.testUnit = lu.kind == LinkUnit::TestBinary; // Synthesized outside the scanner, so it has no SourceUnit to copy // from — classify it here with the ROOT package's table (an entry // main is resolved against projectRoot by definition). @@ -3281,6 +3683,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, main_cu.localIncludeDirsAfter = packages[0].privateBuild.includeDirsAfter; main_cu.packageCflags = packages[0].privateBuild.cflags; main_cu.packageCxxflags = packages[0].privateBuild.cxxflags; + if (lu.kind == LinkUnit::TestBinary) with_test_usage(main_cu, packages[0]); } else { main_cu.localIncludeDirs = local_include_dirs_for_manifest(projectRoot, manifest); main_cu.localIncludeDirsAfter = @@ -3332,6 +3735,15 @@ make_plan(const mcpp::manifest::Manifest& manifest, entryObject = main_cu.object; } lu.objects.push_back(entryObject); + if (lu.kind == LinkUnit::TestBinary) lu.ownSources.push_back(main_cu.source); + for (auto& extra : test_program_units(main_cu, t, projectRoot, rootExtTable)) { + lu.ownSources.push_back(extra.source); + extra.object = object_for(extra.source, extra.packageName, + std::filesystem::relative(extra.source, projectRoot), + /*owner=*/0).object; + lu.objects.push_back(extra.object); + plan.compileUnits.push_back(std::move(extra)); + } // Per-target entry-scoped flags (issue #131). Applied to the compile // unit that actually builds this target's entry — which may be the @@ -3343,7 +3755,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, // to -D on both the C and C++ entry compile. if (!t.defines.empty() || !t.cflags.empty() || !t.cxxflags.empty()) { for (auto& cu : plan.compileUnits) { - if (cu.source != main_cu.source) continue; + if (cu.source != main_cu.source + && std::ranges::none_of(t.testSources, [&](auto const& s) { return cu.source == projectRoot / s; })) + continue; for (auto const& d : t.defines) { const auto element = mcpp::manifest::flag_element("-D" + d); cu.packageCflags.push_back(element); @@ -3351,7 +3765,6 @@ make_plan(const mcpp::manifest::Manifest& manifest, } for (auto const& f : t.cflags) cu.packageCflags.push_back(f); for (auto const& f : t.cxxflags) cu.packageCxxflags.push_back(f); - break; } } } @@ -3399,7 +3812,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // main, including it would be `duplicate symbol: main`; without one, // it supplies the entry (gtest-style). Works on every linker — the // object is linked directly, never relying on archive member pulling. - if (entryDefinesMain && depEntryMainSources.contains(cu.source)) continue; + if (entryDefinesMain && depMainSources.contains(cu.source)) continue; lu.objects.push_back(cu.object); } @@ -3593,6 +4006,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // dev-dependencies, which are its own and are not walked further // than their own dependencies. std::set closureIdx{mi}; + std::set ownDevDeps; { std::vector work{mi}; auto push = [&](std::size_t j) { @@ -3604,6 +4018,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, it != packageIndexByName.end() && it->second != mi) { memberDevDepPackages.insert( qualified_package_name(packages[it->second].manifest)); + ownDevDeps.insert(qualified_package_name(packages[it->second].manifest)); push(it->second); break; } @@ -3659,6 +4074,13 @@ make_plan(const mcpp::manifest::Manifest& manifest, pkg.manifest.buildConfig.moduleExtensions, pkg.manifest.buildConfig.deviceExtensions); std::vector units; + // The sources of the member's dev-dependencies that define `main` + // (gtest_main.cc): a beginning of a test that defines none. + std::set memberMainSources; + for (auto const& cu : plan.compileUnits) + if (ownDevDeps.contains(cu.packageName) && is_implementation_source(cu.kind) + && source_defines_main(cu.source)) + memberMainSources.insert(cu.source); for (auto const& t : pkg.manifest.targets) { if (memberTestMode && t.kind != mcpp::manifest::Target::TestBinary) continue; if (t.kind == mcpp::manifest::Target::Entry) continue; // §24.3 @@ -3726,11 +4148,13 @@ make_plan(const mcpp::manifest::Manifest& manifest, main_cu.source = entry; main_cu.packageName = owner; main_cu.kind = mcpp::classify(entry, memberExtTable); + main_cu.testUnit = lu.kind == LinkUnit::TestBinary; if (pkg.usageResolved) { main_cu.localIncludeDirs = pkg.privateBuild.includeDirs; main_cu.localIncludeDirsAfter = pkg.privateBuild.includeDirsAfter; main_cu.packageCflags = pkg.privateBuild.cflags; main_cu.packageCxxflags = pkg.privateBuild.cxxflags; + if (lu.kind == LinkUnit::TestBinary) with_test_usage(main_cu, pkg); } else { main_cu.localIncludeDirs = local_include_dirs_for_manifest(pkg.root, pkg.manifest); main_cu.localIncludeDirsAfter = @@ -3751,11 +4175,25 @@ make_plan(const mcpp::manifest::Manifest& manifest, entryObject = main_cu.object; } lu.objects.push_back(*entryObject); + if (lu.kind == LinkUnit::TestBinary) lu.ownSources.push_back(entry); + for (auto const& source : t.testSources) lu.ownSources.push_back(pkg.root / source); + if (!t.testSources.empty()) { + auto base = std::ranges::find(plan.compileUnits, entry, &CompileUnit::source); + const CompileUnit entryUnit = *base; + for (auto& extra : test_program_units(entryUnit, t, pkg.root, memberExtTable)) { + extra.object = object_for(extra.source, owner, + std::filesystem::relative(extra.source, pkg.root), mi).object; + lu.objects.push_back(extra.object); + plan.compileUnits.push_back(std::move(extra)); + } + } // Per-target entry-scoped flags (issue #131), on the unit that // builds this target's entry, as for a root's target. if (!t.defines.empty() || !t.cflags.empty() || !t.cxxflags.empty()) { for (auto& cu : plan.compileUnits) { - if (cu.source != entry) continue; + if (cu.source != entry + && std::ranges::none_of(t.testSources, [&](auto const& s) { return cu.source == pkg.root / s; })) + continue; for (auto const& d : t.defines) { const auto element = mcpp::manifest::flag_element("-D" + d); cu.packageCflags.push_back(element); @@ -3763,15 +4201,17 @@ make_plan(const mcpp::manifest::Manifest& manifest, } for (auto const& f : t.cflags) cu.packageCflags.push_back(f); for (auto const& f : t.cxxflags) cu.packageCxxflags.push_back(f); - break; } } } // A dependency's entry as this program's beginning (§24.3), as // for a root's target. std::optional selectedEntry; - if (!t.mainFrom.empty() && lu.kind != LinkUnit::StaticLibrary) { - auto sel = select_entry(mi, pkg.manifest, t); + auto defaulted = test_main_default(mi, pkg.manifest, t, lu, memberMainSources); + if (!defaulted) return std::unexpected(defaulted.error()); + const auto& begins = *defaulted ? **defaulted : t; + if (!begins.mainFrom.empty() && lu.kind != LinkUnit::StaticLibrary) { + auto sel = select_entry(mi, pkg.manifest, begins); if (!sel) return std::unexpected(sel.error()); selectedEntry = *sel; auto object = begin_with_entry(lu, t, *selectedEntry); @@ -3799,9 +4239,10 @@ make_plan(const mcpp::manifest::Manifest& manifest, if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; if (entryFilesAcrossTargets.contains(cu.source)) continue; - // A dev-dependency's own main-providing object (gtest_main.o) - // only for a test with no main of its own, as for a root. - if (entryDefinesMain && memberDevDepPackages.contains(cu.packageName) + // A main-providing object beneath the dev-dependencies + // (gtest_main.o) only for a test with no main of its own, as + // for a root. + if (entryDefinesMain && (ownDevDeps.contains(cu.packageName) || devOnlyPackages.contains(cu.packageName)) && source_defines_main(cu.source)) continue; lu.objects.push_back(cu.object); } diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp index 4af60bac5..d55e58190 100644 --- a/src/build/prepare/config.cpp +++ b/src/build/prepare/config.cpp @@ -882,12 +882,16 @@ bool graph_or_targets_import(const mcpp::modgraph::Graph& graph, // packages whose targets make_plan compiles are the root and, in a // workspace plan, every selected member (a member whose only sources are // its tests is the case the root alone misses). + // A dependency's entry targets too: an entry is compiled into the program + // that begins at it (a test framework's `main`), and it is in no package + // scan, so its `import std` was seen by nothing that builds the std module. auto targets_import_std = [&](const mcpp::manifest::Manifest& m, - const std::filesystem::path& root) { + const std::filesystem::path& root, bool entriesOnly = false) { const auto extTable = mcpp::extension_table_for(m.buildConfig.moduleExtensions, m.buildConfig.deviceExtensions); for (auto& t : m.targets) { if (t.main.empty()) continue; + if (entriesOnly && t.kind != mcpp::manifest::Target::Entry) continue; const auto entry = mcpp::modgraph::scan_entry_file(root / t.main, m.package.name, extTable); for (auto const& req : entry.requires_) @@ -897,7 +901,7 @@ bool graph_or_targets_import(const mcpp::modgraph::Graph& graph, }; if (targets_import_std(manifest, projectRoot)) return true; for (auto const& pkg : packages) - if (pkg.selectedMember && targets_import_std(pkg.manifest, pkg.root)) return true; + if (targets_import_std(pkg.manifest, pkg.root, /*entriesOnly=*/!pkg.selectedMember)) return true; return false; } diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 3dc95c397..4d0b75e64 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -231,12 +231,37 @@ PackageInstances package_instances(const PrepareState& state) { return out; } +// The packages the shipped graph reaches: the root's and the selected +// members' ordinary edges and theirs, no `[build-dependencies]` and no +// `[dev-dependencies]` edge. A target instance outside it serves the tests only. +static std::vector shipped_packages(const PrepareState& state) { + const auto n = state.packages.size(); + std::vector shipped(n, false); + std::vector stack; + for (std::size_t i = 0; i < n; ++i) + if (i == 0 || state.packages[i].selectedMember) { shipped[i] = true; stack.push_back(i); } + while (!stack.empty()) { + const auto p = stack.back(); + stack.pop_back(); + for (auto const& e : state.dependencyEdges) { + if (e.consumerPackageIndex != p || e.buildTable || e.devTable) continue; + const auto d = e.dependencyPackageIndex; + if (d >= n || shipped[d]) continue; + shipped[d] = true; + stack.push_back(d); + } + } + return shipped; +} + static void step6_instances(PrepareState& state) { const auto n = state.packages.size(); if (n == 0) return; const auto [target, host] = package_instances(state); + const auto shipped = shipped_packages(state); for (std::size_t d = 1; d < n; ++d) { state.packages[d].hostInstance = host[d]; + state.packages[d].devOnly = target[d] && !shipped[d]; // A package no edge mentions is a bookkeeping gap, not a build // dependency: it keeps its target instance. state.packages[d].buildTimeOnly = !target[d] && host[d]; diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index c1c56a2d3..4f34bfa3d 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -668,6 +668,12 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { mcpp::pm::compat::normalize_nested_namespace( spec.namespace_, spec.shortName, spec.legacyDottedKey); if (spec.legacyDottedKey) { + // COMPAT(quoted-dotted-dependency-key): one TOML key, read as the selector its dots spell (mcpp#904); + // not reported where the quoted key is the only spelling of a package beside its parent or child. + if (!spec.quotingRequired) + state.reportCompatUseInstead("quoted-dotted-dependency-key", packageIndexForConsumer(item.consumerDepIndex), + std::format("{} \"{}\"", spec.declaredIn, name), + std::format("the key unquoted, `{} = ...`", name)); spec.candidates = {{ .namespace_ = spec.namespace_, .shortName = spec.shortName, @@ -2244,66 +2250,7 @@ step4b_define_provisioning_closures(PrepareState& state) { } - state.computeUsageRequirements = [&] { - bool changed = true; - while (changed) { - changed = false; - for (auto const& edge : state.dependencyEdges) { - if (edge.consumerPackageIndex >= state.packages.size() - || edge.dependencyPackageIndex >= state.packages.size()) { - continue; - } - auto& consumer = state.packages[edge.consumerPackageIndex]; - auto const& dependency = state.packages[edge.dependencyPackageIndex]; - // A build dependency serves the build program, which is planned - // in its own graph: its include directories and interface - // defines do not reach this consumer's target compile (mcpp#797). - if (edge.buildTable) continue; - // A package of programs publishes no usage requirements to its - // consumers (#649 E6): nothing of it is compiled or linked here. - if (edge.dependencyPackageIndex > 0 - && state.isProgramOnlyPackage(dependency.manifest)) continue; - - if (edge.visibility == mcpp::modgraph::DependencyVisibility::Private - || edge.visibility == mcpp::modgraph::DependencyVisibility::Public) { - changed = state.appendUniquePaths(consumer.privateBuild.includeDirs, - dependency.publicUsage.includeDirs) - || changed; - // #249: after-dirs ride the same edges but keep their - // after-ness — consumers receive them as -idirafter, - // never upgraded to -I. - changed = state.appendUniquePaths(consumer.privateBuild.includeDirsAfter, - dependency.publicUsage.includeDirsAfter) - || changed; - // Interface defines (a dependency's active-feature `defines`) - // ride the same edges as include dirs: they must reach the - // consumer's own TUs so header-only switches like - // EIGEN_USE_BLAS take effect where the headers are used. - changed = state.appendUniqueFlags(consumer.privateBuild.cflags, - dependency.publicUsage.cflags) - || changed; - changed = state.appendUniqueFlags(consumer.privateBuild.cxxflags, - dependency.publicUsage.cxxflags) - || changed; - } - if (edge.visibility == mcpp::modgraph::DependencyVisibility::Public - || edge.visibility == mcpp::modgraph::DependencyVisibility::Interface) { - changed = state.appendUniquePaths(consumer.publicUsage.includeDirs, - dependency.publicUsage.includeDirs) - || changed; - changed = state.appendUniquePaths(consumer.publicUsage.includeDirsAfter, - dependency.publicUsage.includeDirsAfter) - || changed; - changed = state.appendUniqueFlags(consumer.publicUsage.cflags, - dependency.publicUsage.cflags) - || changed; - changed = state.appendUniqueFlags(consumer.publicUsage.cxxflags, - dependency.publicUsage.cxxflags) - || changed; - } - } - } - }; + define_usage_requirements(state); return {}; } diff --git a/src/build/prepare/graph_closures.cpp b/src/build/prepare/graph_closures.cpp index b4fecb344..b8c7cf25a 100644 --- a/src/build/prepare/graph_closures.cpp +++ b/src/build/prepare/graph_closures.cpp @@ -107,7 +107,8 @@ void step4b_define_lookup_closures(PrepareState& state) { return !mcpp::build::path_is_under_any(pkg.root, state.storeRoots) && !mcpp::build::path_is_under_any(pkg.root, std::vector{mcpp::home::root()}); }; - state.reportCompatUse = [&](std::string_view id, std::size_t i, std::string detail) { + state.reportCompatUseInstead = [&](std::string_view id, std::size_t i, std::string detail, + std::string instead) { auto const* d = mcpp::pm::compat::find_deprecation(id); if (!d) return; const bool warn = state.editablePackage(i); @@ -119,7 +120,10 @@ void step4b_define_lookup_closures(PrepareState& state) { if (!warn) return; mcpp::diag::warning(d->domain, std::format( "{}: {} is {}. Write {} instead.", where, detail, mcpp::pm::compat::deprecation_clause(*d), - d->canonical)); + instead.empty() ? std::string(d->canonical) : instead)); + }; + state.reportCompatUse = [&](std::string_view id, std::size_t i, std::string detail) { + state.reportCompatUseInstead(id, i, std::move(detail), {}); }; diff --git a/src/build/prepare/graph_edges.cpp b/src/build/prepare/graph_edges.cpp index e3178e2c0..71c7dcd98 100644 --- a/src/build/prepare/graph_edges.cpp +++ b/src/build/prepare/graph_edges.cpp @@ -1,6 +1,6 @@ // graph_edges.cpp -- P4b: the edges the dependency worklist records, one per // consumer, dependency, visibility and table, with what each edge asks of its -// dependency. Split from graph.cpp at the boundary of what an edge is, when the +// dependency, and the usage requirements the edges carry. Split from graph.cpp at the boundary of what an edge is, when the // edge learned to carry `embed' and `exclude' (design 2026-11-02 §14.1). module mcpp.build.prepare; @@ -21,7 +21,7 @@ static mcpp::modgraph::DependencyVisibility parseVisibility(std::string_view vis return mcpp::modgraph::DependencyVisibility::Public; } -static std::size_t packageIndexForConsumer(std::size_t consumerDepIndex) { +std::size_t packageIndexForConsumer(std::size_t consumerDepIndex) { if (consumerDepIndex == kMainConsumer) return std::size_t{0}; return consumerDepIndex + 1; } @@ -85,11 +85,13 @@ void recordDependencyEdge( // one consumer is two edges and two instances (mcpp#797, R2): merging // them made the build-program request read as "not in the target". const auto visibility = parseVisibility(spec.visibility); + const bool devTable = !buildTable && spec.declaredIn.ends_with("dev-dependencies]"); auto same = [&](const DependencyEdge& edge) { return edge.consumerPackageIndex == consumerPackageIndex && edge.dependencyPackageIndex == dependencyPackageIndex && edge.visibility == visibility - && edge.buildTable == buildTable; + && edge.buildTable == buildTable + && edge.devTable == devTable; }; auto it = std::find_if(state.dependencyEdges.begin(), state.dependencyEdges.end(), same); if (it != state.dependencyEdges.end()) { @@ -126,6 +128,7 @@ void recordDependencyEdge( .requestedTools = spec.tools, .requestedArtifacts = spec.artifacts, .buildTable = buildTable, + .devTable = devTable, .wantsLibrary = wantsLibrary, .reexport = spec.reexport, .embed = spec.embed, @@ -133,4 +136,88 @@ void recordDependencyEdge( }); } +// The usage requirements every edge carries to its consumer: a dependency's +// public usage reaches the consumer's own units (Private, Public), its users +// (Public, Interface), or, on a dev edge, the consumer's tests only. A fixpoint +// over `dependencyEdges`, run again whenever a pass changed what a package +// publishes. +void define_usage_requirements(PrepareState& state) { + state.computeUsageRequirements = [&state] { + bool changed = true; + while (changed) { + changed = false; + for (auto const& edge : state.dependencyEdges) { + if (edge.consumerPackageIndex >= state.packages.size() + || edge.dependencyPackageIndex >= state.packages.size()) { + continue; + } + auto& consumer = state.packages[edge.consumerPackageIndex]; + auto const& dependency = state.packages[edge.dependencyPackageIndex]; + // A build dependency serves the build program, which is planned + // in its own graph: its include directories and interface + // defines do not reach this consumer's target compile (mcpp#797). + if (edge.buildTable) continue; + // A package of programs publishes no usage requirements to its + // consumers (#649 E6): nothing of it is compiled or linked here. + if (edge.dependencyPackageIndex > 0 + && state.isProgramOnlyPackage(dependency.manifest)) continue; + + // A dev-dependency serves the consumer's tests: its usage + // reaches the test units and neither the consumer's own + // sources nor the consumer's users, so those sources compile + // under `mcpp test` as under `mcpp build`. + if (edge.devTable) { + auto& tests = consumer.testBuild; + changed = state.appendUniquePaths(tests.includeDirs, dependency.publicUsage.includeDirs) + || changed; + changed = state.appendUniquePaths(tests.includeDirsAfter, + dependency.publicUsage.includeDirsAfter) + || changed; + changed = state.appendUniqueFlags(tests.cflags, dependency.publicUsage.cflags) || changed; + changed = state.appendUniqueFlags(tests.cxxflags, dependency.publicUsage.cxxflags) || changed; + continue; + } + + if (edge.visibility == mcpp::modgraph::DependencyVisibility::Private + || edge.visibility == mcpp::modgraph::DependencyVisibility::Public) { + changed = state.appendUniquePaths(consumer.privateBuild.includeDirs, + dependency.publicUsage.includeDirs) + || changed; + // #249: after-dirs ride the same edges but keep their + // after-ness — consumers receive them as -idirafter, + // never upgraded to -I. + changed = state.appendUniquePaths(consumer.privateBuild.includeDirsAfter, + dependency.publicUsage.includeDirsAfter) + || changed; + // Interface defines (a dependency's active-feature `defines`) + // ride the same edges as include dirs: they must reach the + // consumer's own TUs so header-only switches like + // EIGEN_USE_BLAS take effect where the headers are used. + changed = state.appendUniqueFlags(consumer.privateBuild.cflags, + dependency.publicUsage.cflags) + || changed; + changed = state.appendUniqueFlags(consumer.privateBuild.cxxflags, + dependency.publicUsage.cxxflags) + || changed; + } + if (edge.visibility == mcpp::modgraph::DependencyVisibility::Public + || edge.visibility == mcpp::modgraph::DependencyVisibility::Interface) { + changed = state.appendUniquePaths(consumer.publicUsage.includeDirs, + dependency.publicUsage.includeDirs) + || changed; + changed = state.appendUniquePaths(consumer.publicUsage.includeDirsAfter, + dependency.publicUsage.includeDirsAfter) + || changed; + changed = state.appendUniqueFlags(consumer.publicUsage.cflags, + dependency.publicUsage.cflags) + || changed; + changed = state.appendUniqueFlags(consumer.publicUsage.cxxflags, + dependency.publicUsage.cxxflags) + || changed; + } + } + } + }; +} + } // namespace mcpp::build diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 27c35ed69..556a08b3f 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -462,8 +462,11 @@ static std::expected step13_make_plan(PrepareState& state, Bu if (!ctx.plan.trimMap.empty()) { const auto n = state.packages.size(); std::vector> deps(n); + // Not along a dev edge: a dev-dependency reaches the test units, which + // keep their paths, so the package's own map is the one `mcpp build` + // gives it and its commands are the same under `mcpp test`. for (auto const& e : state.dependencyEdges) - if (e.consumerPackageIndex < n && e.dependencyPackageIndex < n) + if (e.consumerPackageIndex < n && e.dependencyPackageIndex < n && !e.devTable) deps[e.consumerPackageIndex].push_back(e.dependencyPackageIndex); for (std::size_t i = 0; i < n; ++i) { std::vector closure(n, 0); diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index 4a3f82414..69958d1b4 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -322,7 +322,11 @@ std::expected step13_lockfile(PrepareState& state, BuildConte // A workspace's lock is at its root and records every member's // resolution (§15). A plan of some of its members updates the entries // it resolved and keeps the others; a plan of all of them writes the - // whole record. + // whole record. "This plan" is the members it resolves, not the members + // the command asked for: a command over several configurations plans + // one group at a time, and a group that took the request for its own + // took every entry of another group's members from this configuration + // (mcpp#903). bool partialWorkspace = false; if (state.workspacePlan() && state.wsManifest) { std::set all(state.wsManifest->workspace.members.begin(), @@ -330,7 +334,6 @@ std::expected step13_lockfile(PrepareState& state, BuildConte if (!state.wsManifest->package.name.empty()) all.insert("."); std::set planned(state.selectedMemberPaths.begin(), state.selectedMemberPaths.end()); - for (auto const& mp : state.overrides.workspace_request) planned.insert(mp); partialWorkspace = !std::ranges::includes(planned, all); } // EACH ENTRY NAMES THE CONFIGURATIONS THAT RESOLVE IT (SPEC-013 diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index baa009280..b795b50a5 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -150,6 +150,11 @@ constexpr std::size_t kMainConsumer = static_cast(-1); // On an ordinary edge the library is what the edge means, and the // field is not read. bool buildTable = false; + // `devTable`: the declaration is in a `[dev-dependencies]` table of the + // root or a selected member. The dependency serves the consumer's + // tests: its usage reaches the test units (`PackageRoot::testBuild`), + // not the consumer's own sources. + bool devTable = false; bool wantsLibrary = false; bool reexport = false; // `embed = true` and its `exclude` (design 2026-11-02 §14.1): the @@ -750,6 +755,10 @@ struct PrepareState { // recorded for `mcpp why compat` either way. std::function reportCompatUse; + // The same, naming what to write instead in the use's own words (the + // registry's replacement is an example). + std::function + reportCompatUseInstead; // Whether the manifest of package `i` is one the person running the build // can edit (SPEC-008 §4: a diagnostic is given to the package that can act // on it). @@ -903,6 +912,10 @@ struct PrepareState { std::vector cxxIslands; std::optional cxxLayerProviderIndex; bool targetSideResolved = false; + // The kernel interface is supplied by a package only the tests reach + // (`PackageRoot::devOnly`): it serves the dev-only packages and the test + // units, and the configuration of the shipped graph keeps none. + bool kernelAbiForTestsOnly = false; mcpp::modgraph::UsageRequirements targetSideUsage; // The same, with the engine's predefines and the realised C environment: // what the build's contract states (BuildPlan::contractTargetSide). @@ -956,12 +969,17 @@ std::expected phase1_toolchain_spec_and_axes(PrepareState& st std::expected phase2_define_toolchain_resolver(PrepareState& state); std::expected phase3_xlings_before_graph(PrepareState& state); std::expected phase4a_graph_load(PrepareState& state); +// graph_edges.cpp: the package index of a work item's consumer +// (`WorkItem::consumerDepIndex`). +std::size_t packageIndexForConsumer(std::size_t consumerDepIndex); // graph_edges.cpp: the edge one request adds to the graph, or the one it // merges with. void recordDependencyEdge(PrepareState& state, std::size_t consumerDepIndex, std::size_t dependencyPackageIndex, const mcpp::manifest::DependencySpec& spec, bool buildTable, const std::string& writtenKey); +// graph_edges.cpp: defines `PrepareState::computeUsageRequirements`. +void define_usage_requirements(PrepareState& state); std::expected phase4b_graph_worklist(PrepareState& state); // graph.cpp: takes the link flags package `p` added to the root's pooled list // back out of it (a package that serves the build alone; an embedded one). diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 6dd7aa510..995bcaef7 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -429,6 +429,24 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { // about whether `kal_*` may be called from it. if (state.tc) state.tc->kernelAbiIsOpenkal = state.resolvedTargetSide.kernelAbi.interfaceName == "openkal"; + // A KERNEL INTERFACE ONLY THE TESTS REACH. A dev-dependency that + // reaches the layer (mcpplibs.mtest over mcpplibs.os) serves the tests; + // the package's own sources compile under `mcpp test` as under `mcpp + // build`, so the configuration -- its fingerprint and the units of the + // shipped graph -- does not take the layer, and `__OPENKAL__` and the + // layer's headers reach the dev-only packages and the test units + // (docs/08 "Dev-dependencies"). A layer of the C environment or the + // C++ runtime is a property of every object a test links and stays + // the configuration's. + if (state.tc && state.tc->kernelAbiIsOpenkal && state.resolvedTargetSide.kernelAbi.fromGraph()) { + auto it = gather.byLayer.find(static_cast(tsd::CapLayer::KernelAbi)); + if (it != gather.byLayer.end() && !it->second.empty() + && it->second.front().index < state.packages.size() + && state.packages[it->second.front().index].devOnly) { + state.kernelAbiForTestsOnly = true; + state.tc->kernelAbiIsOpenkal = false; + } + } // [c-abi] REALISATION — design §3.2-§3.4. Everything below is // skipped, and every command line unchanged, for the graph this @@ -669,6 +687,20 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { return {}; } +// What a layer only the tests reach gives: the dev-only packages' own units and +// the test units of the root and the selected members (`testBuild`). +static void for_tests(PrepareState& state, const mcpp::modgraph::UsageRequirements& usage) { + for (std::size_t i = 0; i < state.packages.size(); ++i) { + auto& p = state.packages[i]; + auto& to = p.devOnly ? p.privateBuild : p.testBuild; + if (!p.devOnly && i != 0 && !p.selectedMember) continue; + state.appendUniquePaths(to.includeDirs, usage.includeDirs); + state.appendUniquePaths(to.includeDirsAfter, usage.includeDirsAfter); + state.appendUniqueFlags(to.cflags, usage.cflags); + state.appendUniqueFlags(to.cxxflags, usage.cxxflags); + } +} + static std::expected step9_target_side_include_broadcast(PrepareState& state, TargetSideGather& gather) { namespace tsd = mcpp::targetside; @@ -722,7 +754,11 @@ step9_target_side_include_broadcast(PrepareState& state, TargetSideGather& gathe } }; note_layer(tsd::CapLayer::CompilerRuntime, state.resolvedTargetSide.compilerRuntime); - note_layer(tsd::CapLayer::KernelAbi, state.resolvedTargetSide.kernelAbi); + if (!state.kernelAbiForTestsOnly) + note_layer(tsd::CapLayer::KernelAbi, state.resolvedTargetSide.kernelAbi); + else if (auto it = gather.byLayer.find(static_cast(tsd::CapLayer::KernelAbi)); + it != gather.byLayer.end() && !it->second.empty()) + for_tests(state, state.packages[it->second.front().index].publicUsage); note_layer(tsd::CapLayer::CAbi, state.resolvedTargetSide.cAbi); note_layer(tsd::CapLayer::CxxAbi, state.resolvedTargetSide.cxx); @@ -822,6 +858,15 @@ step9_target_side_include_broadcast(PrepareState& state, TargetSideGather& gathe state.appendUniqueFlags(p.privateBuild.cflags, engineDefines); state.appendUniqueFlags(p.privateBuild.cxxflags, engineDefines); } + if (state.kernelAbiForTestsOnly) { + mcpp::modgraph::UsageRequirements kernel; + for (auto const& d : mcpp::toolchain::predefines::define_tokens(targetOs, true)) + if (std::ranges::find(engineDefines, d) == engineDefines.end()) { + kernel.cflags.push_back(d); + kernel.cxxflags.push_back(d); + } + for_tests(state, kernel); + } } // THE CONTRACT'S TARGET SIDE: everything above that every unit receives, diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index b14766acb..c8b7d17cd 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -271,14 +271,17 @@ static std::expected step1_define_early_toolchain_closures(Pr // a developer debugs. A custom profile states what it wants. const bool shipping = pname == "release" || pname == "dist"; state.m->buildConfig.ndebug = pr.ndebugDeclared ? pr.ndebug : shipping; - // NOT FOR THE TEST SURFACE. A test binary runs where it was built, - // is never shipped, and commonly finds its fixtures through - // `__FILE__` -- measured on mcpp's own unit tests under its release - // default, which then looked for `workspace@0.0.0/src`. What - // `mcpp test` (and the configure pass that mirrors it) builds keeps - // its paths; what build, run and pack produce is trimmed. - state.m->buildConfig.trimPaths = (pr.trimPathsDeclared ? pr.trimPaths : shipping) - && !state.includeDevDeps; + // THE SAME FOR THE TEST SURFACE, BUT FOR THE TESTS' OWN FILES. A test + // binary runs where it was built and commonly finds its fixtures + // through `__FILE__` -- measured on mcpp's own unit tests under its + // release default, which then looked for `workspace@0.0.0/src` -- so + // a test program's own files keep their paths (`CompileUnit:: + // testUnit`). The code under test is trimmed as `mcpp build` trims + // it: it is the code that ships, and `mcpp build` and `mcpp test` + // are one configuration, so switching between them compiles nothing + // again (before 2026.11.8.1 a release test was a configuration of its + // own, and compiled the whole graph again). + state.m->buildConfig.trimPaths = pr.trimPathsDeclared ? pr.trimPaths : shipping; // `split_debuginfo` (design 2026-10-31 §4.1 (b)): unpacked for the // profiles a developer debugs. A debugger finds a unit's `.dwo` through // the build directory the skeleton names, which `trim_paths` replaces. diff --git a/src/build/schedule/detach_codegen.cppm b/src/build/schedule/detach_codegen.cppm index 143eb7bb7..efd3406b6 100644 --- a/src/build/schedule/detach_codegen.cppm +++ b/src/build/schedule/detach_codegen.cppm @@ -669,6 +669,7 @@ std::expected RunScope::begin(const std::filesystem::path const auto work = std::filesystem::weakly_canonical( target.filename() == "target" ? target.parent_path() : spelled.parent_path(), ec); if (ec) return std::unexpected("cannot identify the compiler-run work directory"); + mcpp::home::mark_project_state(work / ".mcpp"); state->root = work / ".mcpp" / "compiler-runs" / mcpp::toolchain::hash_string(mcpp::home::spelling(canonical)); std::filesystem::create_directories(state->root, ec); diff --git a/src/build/test_impact.cppm b/src/build/test_impact.cppm new file mode 100644 index 000000000..410c6fce9 --- /dev/null +++ b/src/build/test_impact.cppm @@ -0,0 +1,196 @@ +// mcpp.build.test_impact -- the tests a change affects (docs/08 "The tests a +// change affects"), read from the plan: a test is affected by a change of a +// 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 test that a pattern or `-p` left out. +export module mcpp.build.test_impact; + +import std; +import mcpp.build.plan; +import mcpp.modgraph.graph; +import mcpplibs.os; + +export namespace mcpp::build::test_impact { + +struct Affected { + // The affected tests, each with the first reason found. + std::map tests; + // Set when every test is affected, with the reason. + std::optional everything; + // What a changed path decided, for the run's report. + std::vector notes; +}; + +// `changed`: paths relative to `plan.projectRoot`, in forward slashes. +Affected affected_tests(const BuildPlan& plan, const std::vector& changed); + +// The paths below `root` that differ from `base`: committed since it, changed +// in the working tree, and new and not ignored (`git diff --name-only +// --relative ` and `git ls-files --others --exclude-standard`). +std::expected, std::string> +changed_since(const std::filesystem::path& root, std::string_view base); + +} // namespace mcpp::build::test_impact + +namespace mcpp::build::test_impact { + +namespace { + +std::string primary(std::string_view name) { + return std::string(name.substr(0, name.find(':'))); +} + +bool decides_the_build(const std::filesystem::path& p) { + const auto name = p.filename().string(); + return name == "mcpp.toml" || name == "mcpp.lock" || p.extension() == ".mcpp"; +} + +bool is_header(const std::filesystem::path& p) { + static const std::set kinds{".h", ".hh", ".hpp", ".hxx", ".h++", ".inc", + ".inl", ".ipp", ".tcc", ".tpp", ".def"}; + return kinds.contains(p.extension().string()); +} + +bool is_source(const std::filesystem::path& p) { + static const std::set kinds{".cpp", ".cc", ".cxx", ".c++", ".c", ".cppm", + ".ixx", ".mpp", ".mxx", ".cu", ".m", ".mm", + ".s", ".S", ".asm"}; + return kinds.contains(p.extension().string()); +} + +std::string read_text(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::binary); + return {std::istreambuf_iterator(in), {}}; +} + +// The module the unit belongs to: the one it provides (an interface or a +// partition), or, of an implementation unit (`module M;`), M. +std::string module_of(const CompileUnit& cu) { + if (!cu.providesModule.empty()) return primary(cu.providesModule); + if (cu.declaration == mcpp::modgraph::ModuleDeclaration::Implementation && !cu.imports.empty()) + return primary(cu.imports.front()); + return {}; +} + +// The files a test is built from. +std::set closure_of(const BuildPlan& plan, const LinkUnit& lu, + const std::map>& units, + const std::map& byObject) { + std::set files; + std::vector work; + std::set modules; + auto visit = [&](const CompileUnit* cu) { + if (files.insert(cu->source.lexically_normal()).second) work.push_back(cu); + }; + for (auto const& object : lu.objects) { + auto it = byObject.find(object); + if (it == byObject.end()) continue; + // A unit of no module is linked whether or not anything imports it. + if (module_of(*it->second).empty()) visit(it->second); + } + for (auto const& own : lu.ownSources) + if (auto it = std::ranges::find(plan.compileUnits, own, &CompileUnit::source); it != plan.compileUnits.end()) + visit(&*it); + while (!work.empty()) { + const auto* cu = work.back(); + work.pop_back(); + for (auto const& name : cu->imports) { + if (!modules.insert(primary(name)).second) continue; + if (auto it = units.find(primary(name)); it != units.end()) + std::ranges::for_each(it->second, visit); + } + } + return files; +} + +// The test's own file that names `rel` (or its file name), if one does. +std::optional naming_file(const LinkUnit& lu, const std::string& rel, const std::string& name) { + for (auto const& own : lu.ownSources) { + const auto text = read_text(own); + if (text.find(rel) != std::string::npos || text.find(name) != std::string::npos) return own; + } + return std::nullopt; +} + +} // namespace + +Affected affected_tests(const BuildPlan& plan, const std::vector& changed) { + Affected out; + std::map> units; + std::map byObject, bySource; + for (auto const& cu : plan.compileUnits) { + if (auto m = module_of(cu); !m.empty()) units[m].push_back(&cu); + byObject.emplace(cu.object, &cu); + bySource.emplace(cu.source.lexically_normal(), &cu); + } + std::vector>> tests; + for (auto const& lu : plan.linkUnits) + 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)); }; + 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) + || (is_source(path) && !bySource.contains(path))) { + out.everything = std::format("{} {}", rel, + decides_the_build(path) ? "decides how the tests are built" + : is_header(path) ? "is a header, and which units include it is not known" + : "is a source this build does not plan"); + out.notes.push_back(*out.everything); + break; + } + std::size_t hits = 0; + for (auto const& [lu, files] : tests) + if (files.contains(path)) { + affect(lu->targetName, std::format("it is built from {}", rel)); + ++hits; + } + if (bySource.contains(path)) { + out.notes.push_back(std::format("{}: {} test{}", rel, hits, hits == 1 ? "" : "s")); + continue; + } + // Another file: the tests whose own files name it. + const auto name = path.filename().generic_string(); + for (auto const& [lu, _] : tests) + if (auto own = naming_file(*lu, rel, name)) { + affect(lu->targetName, std::format("{} names {}", own->filename().generic_string(), rel)); + ++hits; + } + out.notes.push_back(std::format("{}: {} test{} name{} it", rel, hits, hits == 1 ? "" : "s", + hits == 1 ? "s" : "")); + } + if (out.everything) + for (auto const& [lu, _] : tests) out.tests.emplace(lu->targetName, *out.everything); + return out; +} + +std::expected, std::string> +changed_since(const std::filesystem::path& root, std::string_view base) { + std::set paths; + auto git = [&](std::vector args) -> std::expected { + const auto command = args.front(); + mcpplibs::os::process::spawn_options o; + o.program = "git"; + o.args = std::move(args); + o.cwd = root; + o.in = mcpplibs::os::process::stdio::null; + auto r = mcpplibs::os::process::run(std::move(o)); + if (!r || !r->status.success()) + return std::unexpected(std::format("git {} did not answer{}", command, + r ? ": " + r->err : std::string{})); + std::istringstream lines(r->out); + for (std::string line; std::getline(lines, line);) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (!line.empty()) paths.insert(line); + } + return {}; + }; + if (auto r = git({"diff", "--name-only", "--relative", std::string(base)}); !r) return std::unexpected(r.error()); + if (auto r = git({"ls-files", "--others", "--exclude-standard"}); !r) return std::unexpected(r.error()); + return std::vector(paths.begin(), paths.end()); +} + +} // namespace mcpp::build::test_impact diff --git a/src/build/test_protocol.cppm b/src/build/test_protocol.cppm new file mode 100644 index 000000000..74ce73559 --- /dev/null +++ b/src/build/test_protocol.cppm @@ -0,0 +1,342 @@ +// mcpp.build.test_protocol — the runner's side of the mtest test protocol, +// version 1 (mtest spec/protocol.md; docs/08 "Cases"). mcpp implements the +// protocol and not a framework: a test program that implements it (its entry +// states `test-protocol = 1`, or its file carries the marker, P2.3) is told the +// protocol's variables (P2.1), lists its cases (P3), runs a selection (P4) and +// reports each case (P5); every other program remains one case judged by its +// exit status (P1.1), and is told none of the variables (P1.2). + +export module mcpp.build.test_protocol; + +import std; +import nlohmann.json; + +export namespace mcpp::build::test_protocol { + +// The version this mcpp speaks. +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.4.0"; +inline constexpr std::string_view kMtestRepo = "https://github.com/mcpp-community/mtest"; + +// The protocol's variables, removed from the environment a program inherits +// so that an outer runner's do not reach a program mcpp starts (P2.2). +inline constexpr std::array kVariables = { + "MTEST_PROTOCOL", "MTEST_MODE", "MTEST_REPORT", "MTEST_SELECT", + "MTEST_ISOLATION", "MTEST_TMPDIR", "MTEST_SEED", +}; + +// The version the marker in a program's file states (P2.3): the bytes +// `mtest-protocol=` and a version followed by a byte that is not a digit; 0 +// when the file carries none or cannot be read. +int marker_version(const std::filesystem::path& program); + +// What a program's case is, as its listing states it (P3.3). +struct Case { + std::string id; + std::string name; + std::string file; + int line = 0; + std::string kind; + std::vector tags; + std::string expect; +}; + +// A failure an expectation of a case reported (P5.2). +struct Failure { + std::string file; + int line = 0; + std::string message; + std::string expected; + std::string actual; +}; + +// One case's result (P5.3), with its failures. +struct CaseResult { + std::string id; + std::string status; // pass, fail, skip, xfail, xpass, invalid + double ms = 0; + std::string reason; + std::vector failures; +}; + +// What one program's report says. +struct Report { + int level = 0; // the `program` event's; 0 when none came + // A `program` event came. A program that carries the marker and writes + // none spoke no protocol (a file of plain `main` linked beside a + // framework's library): it is one case, judged by its exit status. + bool spoke = false; + std::string framework; + bool done = false; // the `done` event came + std::vector listed; // `case` events (list mode) + std::vector cases; // in the order they began + std::vector missing; // P4.1 + // A case that began and did not end failed, with the program's exit + // status as its reason (P5.4). + bool failed() const; + std::map counts() const; +}; + +// The report a program appended to `file`; `exitStatus` is the program's, for +// the cases a crash left without an end (P5.4). Lines that are not JSON +// objects, and fields and events this version does not know, are ignored +// (P5.4). +Report read_report(const std::filesystem::path& file, int exitStatus); + +// The variables a run states (P2.1): `list` or `run`, the report, the selection +// file when there is one, the directory the cases may use, and the seed. +std::vector> +variables(std::string_view mode, const std::filesystem::path& report, + const std::optional& select, + const std::filesystem::path& tmpdir, std::optional seed); + +// Whether a case identifier matches a pattern of `mcpp test --case`: `*` any +// run of characters, `?` one; a pattern without either matches the identifier +// it equals or the identifiers it is a `/`-prefix of (`fs` matches `fs/read`). +bool matches(std::string_view id, std::string_view pattern); + +// The identifiers `mcpp test --case` passes a program (P4.1), from its listing: +// each listed case a pattern matches; for a pattern under a listed case -- a +// case that case declares, which the program reports when it runs and need not +// list (P3.4) -- the pattern itself when it names one case, else the listed +// case, all of whose cases then run. +std::vector selection(const std::vector& listed, + const std::vector& patterns); + +// The JSON of a report in a `mcpp test --message-format json` record +// (docs/50): `cases` and `missing`. +nlohmann::json cases_json(const Report& r); +nlohmann::json listing_json(const Report& r); + +} // namespace mcpp::build::test_protocol + +namespace mcpp::build::test_protocol { + +int marker_version(const std::filesystem::path& program) { + std::ifstream in(program, std::ios::binary); + if (!in) return 0; + static constexpr std::string_view marker = "mtest-protocol="; + // Read in blocks, keeping the marker's length minus one and a version's + // digits across a block boundary. + constexpr std::size_t block = 1 << 20; + std::string window; + std::vector buffer(block); + while (in) { + in.read(buffer.data(), static_cast(buffer.size())); + const auto got = static_cast(in.gcount()); + if (got == 0) break; + window.append(buffer.data(), got); + for (std::size_t at = window.find(marker); at != std::string::npos; + at = window.find(marker, at + 1)) { + std::size_t end = at + marker.size(); + while (end < window.size() && std::isdigit(static_cast(window[end]))) + ++end; + if (end == window.size() && in) break; // the version continues in the next block + if (end == at + marker.size()) continue; + int version = 0; + std::from_chars(window.data() + at + marker.size(), window.data() + end, version); + if (version > 0) return version; + } + const std::size_t keep = marker.size() + 8; + if (window.size() > keep) window.erase(0, window.size() - keep); + } + return 0; +} + +bool Report::failed() const { + if (!missing.empty()) return true; + return std::ranges::any_of(cases, [](const CaseResult& c) { + return c.status == "fail" || c.status == "xpass"; + }); +} + +std::map Report::counts() const { + std::map out; + for (const auto& c : cases) ++out[c.status]; + return out; +} + +namespace { + +std::string text(const nlohmann::json& e, const char* key) { + auto it = e.find(key); + return it != e.end() && it->is_string() ? it->get() : std::string{}; +} + +int number(const nlohmann::json& e, const char* key) { + auto it = e.find(key); + return it != e.end() && it->is_number() ? it->get() : 0; +} + +std::vector strings(const nlohmann::json& e, const char* key) { + std::vector out; + auto it = e.find(key); + if (it == e.end() || !it->is_array()) return out; + for (const auto& v : *it) + if (v.is_string()) out.push_back(v.get()); + return out; +} + +// One event into the report it belongs to. +void take(Report& r, std::map& open, const nlohmann::json& e) { + const auto kind = text(e, "e"); + const auto id = text(e, "id"); + if (kind == "program") { + r.spoke = true; + r.level = number(e, "level"); + 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")}); + } else if (kind == "begin") { + open[id] = r.cases.size(); + r.cases.push_back({id, "", 0, "", {}}); + } else if (kind == "failure") { + auto it = open.find(id); + if (it == open.end()) { + open[id] = r.cases.size(); + r.cases.push_back({id, "", 0, "", {}}); + it = open.find(id); + } + r.cases[it->second].failures.push_back({text(e, "file"), number(e, "line"), + text(e, "message"), text(e, "expected"), + text(e, "actual")}); + } else if (kind == "end") { + auto it = open.find(id); + if (it == open.end()) { + r.cases.push_back({id, "", 0, "", {}}); + it = open.emplace(id, r.cases.size() - 1).first; + } + auto& c = r.cases[it->second]; + c.status = text(e, "status"); + if (auto ms = e.find("ms"); ms != e.end() && ms->is_number()) c.ms = ms->get(); + c.reason = text(e, "reason"); + } else if (kind == "missing") { + r.missing.push_back(id); + } else if (kind == "done") { + r.done = true; + } +} + +} // namespace + +Report read_report(const std::filesystem::path& file, int exitStatus) { + Report r; + std::ifstream in(file, std::ios::binary); + std::map open; + for (std::string line; in && std::getline(in, line);) { + auto e = nlohmann::json::parse(line, nullptr, false); + if (e.is_discarded() || !e.is_object()) continue; + take(r, open, e); + } + for (auto& c : r.cases) + if (c.status.empty()) { + c.status = "fail"; + c.reason = std::format("the program ended with status {} before the case ended", + exitStatus); + } + return r; +} + +std::vector> +variables(std::string_view mode, const std::filesystem::path& report, + const std::optional& select, + const std::filesystem::path& tmpdir, std::optional seed) { + std::vector> env = { + {"MTEST_PROTOCOL", std::to_string(kVersion)}, + {"MTEST_MODE", std::string(mode)}, + {"MTEST_REPORT", report.string()}, + {"MTEST_TMPDIR", tmpdir.string()}, + }; + if (select) env.emplace_back("MTEST_SELECT", select->string()); + if (seed) env.emplace_back("MTEST_SEED", std::to_string(*seed)); + return env; +} + +namespace { + +bool glob(std::string_view text, std::string_view pattern) { + std::size_t t = 0, p = 0, star = std::string_view::npos, mark = 0; + while (t < text.size()) { + if (p < pattern.size() && (pattern[p] == '?' || pattern[p] == text[t])) { + ++t; + ++p; + } else if (p < pattern.size() && pattern[p] == '*') { + star = p++; + mark = t; + } else if (star != std::string_view::npos) { + p = star + 1; + t = ++mark; + } else { + return false; + } + } + while (p < pattern.size() && pattern[p] == '*') ++p; + return p == pattern.size(); +} + +} // namespace + +bool matches(std::string_view id, std::string_view pattern) { + if (pattern.find_first_of("*?") != std::string_view::npos) return glob(id, pattern); + return id == pattern || (id.starts_with(pattern) && id.size() > pattern.size() + && id[pattern.size()] == '/'); +} + +std::vector selection(const std::vector& listed, + const std::vector& patterns) { + std::vector out; + auto add = [&](const std::string& id) { + if (std::ranges::find(out, id) == out.end()) out.push_back(id); + }; + for (const auto& p : patterns) { + bool found = false; + for (const auto& c : listed) + if (matches(c.id, p)) { add(c.id); found = true; } + if (found) continue; + const auto under = std::ranges::find_if(listed, [&](const Case& c) { + return p.size() > c.id.size() && p.starts_with(c.id) && p[c.id.size()] == '/'; + }); + if (under == listed.end()) continue; + add(p.find_first_of("*?") == std::string::npos ? p : under->id); + } + return out; +} + +nlohmann::json cases_json(const Report& r) { + nlohmann::json cases = nlohmann::json::array(); + for (const auto& c : r.cases) { + nlohmann::json one = {{"id", c.id}, {"status", c.status}, {"duration_ms", c.ms}}; + if (!c.reason.empty()) one["reason"] = c.reason; + if (!c.failures.empty()) { + one["failures"] = nlohmann::json::array(); + for (const auto& f : c.failures) { + nlohmann::json failure = {{"file", f.file}, {"line", f.line}, {"message", f.message}}; + if (!f.expected.empty() || !f.actual.empty()) { + failure["expected"] = f.expected; + failure["actual"] = f.actual; + } + one["failures"].push_back(std::move(failure)); + } + } + cases.push_back(std::move(one)); + } + return cases; +} + +nlohmann::json listing_json(const Report& r) { + nlohmann::json cases = nlohmann::json::array(); + for (const auto& c : r.listed) { + nlohmann::json one = {{"id", c.id}, {"name", c.name}, {"file", c.file}, {"line", c.line}}; + if (!c.kind.empty()) one["kind"] = c.kind; + if (!c.tags.empty()) one["tags"] = c.tags; + if (!c.expect.empty()) one["expect"] = c.expect; + cases.push_back(std::move(one)); + } + return cases; +} + +} // namespace mcpp::build::test_protocol diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index 7b0b37c3e..732959ed1 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -7,6 +7,7 @@ import mcpp.manifest; import mcpp.modgraph.glob; import mcpp.modgraph.scanner; import mcpp.project; +import mcpp.build.plan; // source_defines_main export namespace mcpp::build { @@ -86,6 +87,45 @@ discover_test_targets(const std::filesystem::path& manifestRoot, globHits.emplace_back(hits.begin(), hits.end()); } + // `[test] programs = "directory"`: the files of one directory below a + // glob's fixed directory are one program, named by the directory; its + // `main` is the file that defines one, else the first, and the others + // are its translation units. Two files of one directory that define + // `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") { + std::map, std::vector> groups; + for (auto const& [file, base] : testFiles) { + const auto dir = file.parent_path(); + if (dir.lexically_normal() == base.lexically_normal()) continue; + groups[{dir, base}].push_back(file); + } + std::set grouped; + std::vector> merged; + for (auto& [key, files] : groups) { + std::vector mains; + std::ranges::copy_if(files, std::back_inserter(mains), source_defines_main); + if (mains.size() > 1) + return std::unexpected(std::format( + "[test] programs = \"directory\": '{}' and '{}' both define `main`, and the files of " + "'{}' are one program with one beginning", + mains[0].lexically_relative(packageRoot).generic_string(), + mains[1].lexically_relative(packageRoot).generic_string(), + key.first.lexically_relative(packageRoot).generic_string())); + const auto main = mains.empty() ? files.front() : mains.front(); + for (auto const& f : files) { + grouped.insert(f); + if (f != main) sourcesOf[main].push_back(f); + } + // The directory names the program: the main file's name is replaced + // by the directory's when the name is formed below. + merged.emplace_back(main, key.second); + } + std::erase_if(testFiles, [&](auto const& fb) { return grouped.contains(fb.first); }); + testFiles.insert(testFiles.end(), merged.begin(), merged.end()); + } + result.targets.reserve(testFiles.size()); std::map seenNames; for (auto const& [file, discoverBase] : testFiles) { @@ -108,7 +148,11 @@ discover_test_targets(const std::filesystem::path& manifestRoot, auto mainRelative = lexical_relative(packageRoot, "package root"); if (!mainRelative) return std::unexpected(mainRelative.error()); - auto name = testRelative->replace_extension("").generic_string(); + const auto merged = sourcesOf.find(file); + const bool directoryProgram = packageManifest && packageManifest->testPrograms == "directory" + && file.parent_path().lexically_normal() != discoverBase.lexically_normal(); + auto name = 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( "duplicate test name '{}': '{}' and '{}' map to the same name " @@ -121,6 +165,9 @@ discover_test_targets(const std::filesystem::path& manifestRoot, target.name = name; target.kind = mcpp::manifest::Target::TestBinary; target.main = mainRelative->string(); + if (merged != sourcesOf.end()) + for (auto const& f : merged->second) + target.testSources.push_back(f.lexically_relative(packageRoot).string()); if (packageManifest) { target.windowsCodePage = packageManifest->testWindowsCodePage; target.windowsLongPaths = packageManifest->testWindowsLongPaths; diff --git a/src/cli.cppm b/src/cli.cppm index f43223c01..7a025a8c9 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -680,7 +680,7 @@ int run(int argc, char** argv) { // (cmd_new validates presence for project creation itself). .arg(cl::Arg("name").help("Package directory name")) .option(cl::Option("template").short_name('t').takes_value().value_name("SPEC") - .help("bin (default) | [ns.]pkg[@ver][:template] — exact package template")) + .help("bin (default) | lib | [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))) @@ -865,6 +865,18 @@ int run(int argc, char** argv) { .help("Output format: human (default) | json (NDJSON, one record per test)")) .option(cl::Option("list") .help("List (filtered) tests without building or running them")) + .option(cl::Option("case").takes_value().multiple().value_name("PATTERN") + .help("Of each test program that implements the test protocol, run the cases PATTERN matches (`*`, `?`; a name without them matches itself and the cases below it); repeatable")) + .option(cl::Option("cases") + .help("Build the tests and list the cases of each program that implements the test protocol, running none")) + .option(cl::Option("guide") + .help("How this package's tests are written and run: its test frameworks, each with its guide and repository, and the commands for cases; builds nothing")) + .option(cl::Option("since").takes_value().value_name("COMMIT") + .help("Only the tests a change since COMMIT affects (committed, uncommitted and new files): a test built from a changed file, or whose files name it; a manifest, lock, build program or header affects every test")) + .option(cl::Option("changed").takes_value().multiple().value_name("PATH") + .help("Only the tests a change of PATH affects (relative to the project root); repeatable")) + .option(cl::Option("seed").takes_value().value_name("N") + .help("The seed of anything random in a protocol program's cases (MTEST_SEED)")) .option(cl::Option("no-runner") .help("Run test binaries directly, ignoring any [target.].runner (a host that runs them natively)")) .option(cl::Option("runner").takes_value().value_name("NAME") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index a4e0a6a32..ccfb3e5b0 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -1215,6 +1215,29 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, return 2; } to.noRun = parsed.is_flag_set("no-run"); + // The test protocol's cases (docs/08 "Cases"). + if (auto c = parsed.option("case")) to.cases = c->get().values; + to.listCases = parsed.is_flag_set("cases"); + if (auto since = parsed.value("since")) to.since = *since; + if (auto c = parsed.option("changed")) to.changed = c->get().values; + to.guide = parsed.is_flag_set("guide"); + if (to.guide && (parsed.is_flag_set("workspace") || parsed.option_or_empty("package").values.size() > 1)) { + mcpp::ui::error("--guide describes one package's tests; name it with -p"); + return 2; + } + if (auto sd = parsed.value("seed")) { + std::uint64_t seed = 0; + auto [p, ec] = std::from_chars(sd->data(), sd->data() + sd->size(), seed); + if (ec != std::errc{} || p != sd->data() + sd->size()) { + mcpp::ui::error(std::format("invalid --seed '{}' (a decimal number)", *sd)); + return 2; + } + to.seed = seed; + } + if (to.listCases && (!to.cases.empty() || to.noRun)) { + mcpp::ui::error("--cases lists every case and runs none; it is not combined with --case or --no-run"); + return 2; + } // The two read alike and mean opposite things: `--no-runner` says to run // the binaries WITHOUT the declared runner, `--no-run` says not to run // them at all. Asking for both is not a preference to resolve. @@ -1709,9 +1732,17 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { std::println(libc::stderr(), "error: {}", body.error()); return 1; } + // AN UNCHANGED BINDING IS LEFT UNTOUCHED. The rule restats its output, so + // a unit whose module edges are what they were is not compiled again when + // another part of the graph changed the rule's command (a test graph + // binds the modules of its dev-dependencies; mcpp#874). std::error_code ec; std::filesystem::create_directories(outPath.parent_path(), ec); - std::ofstream os(outPath); + { + std::ifstream in(outPath, std::ios::binary); + if (in && std::string(std::istreambuf_iterator(in), {}) == *body) return 0; + } + std::ofstream os(outPath, std::ios::binary); os << *body; return os ? 0 : 1; } diff --git a/src/cli/cmd_new.cppm b/src/cli/cmd_new.cppm index d575cfc87..0dab4e535 100644 --- a/src/cli/cmd_new.cppm +++ b/src/cli/cmd_new.cppm @@ -53,7 +53,7 @@ export int cmd_new(const mcpplibs::cmdline::ParsedArgs& parsed) { } // `--template` exact package SPEC: - // builtin registry (frozen: bin; gui = transitional alias), else a + // builtin registry (bin, lib; gui = transitional alias), else a // package template: [ns.]pkg | [ns.]pkg:tmpl | [ns.]pkg@ver:tmpl. std::string tmpl = "bin"; if (auto t = parsed.value("template")) tmpl = *t; @@ -62,7 +62,7 @@ export int cmd_new(const mcpplibs::cmdline::ParsedArgs& parsed) { "--template gui is deprecated; use `--template imgui` " "(the template then ships with — and version-tracks — the library)"); } - if (tmpl != "bin" && tmpl != "gui") { + if (tmpl != "bin" && tmpl != "gui" && tmpl != "lib") { auto spec = mcpp::scaffold::parse_template_spec(tmpl); if (!spec) { mcpp::ui::error(spec.error().message); @@ -78,7 +78,8 @@ export int cmd_new(const mcpplibs::cmdline::ParsedArgs& parsed) { return mcpp::scaffold::new_from_package_template(*project, *spec); } return mcpp::scaffold::create_builtin_project( - *project, /*gui=*/tmpl == "gui"); + *project, tmpl == "lib" ? mcpp::scaffold::builtin::lib + : tmpl == "gui" ? mcpp::scaffold::builtin::gui : mcpp::scaffold::builtin::bin); } } // namespace mcpp::cli diff --git a/src/config.cppm b/src/config.cppm index a80b0f520..5712e42d2 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -1377,7 +1377,7 @@ bool ensure_project_index_dir( if (customRepos.empty() && penv.empty()) return false; // nothing to do auto dotMcpp = projectDir / ".mcpp"; - std::filesystem::create_directories(dotMcpp, ec); + mcpp::home::mark_project_state(dotMcpp); // Seed .xlings.json with the custom index entries. mcpp::provider::Location env; diff --git a/src/home.cppm b/src/home.cppm index 60e5bed18..5c08b281f 100644 --- a/src/home.cppm +++ b/src/home.cppm @@ -84,6 +84,13 @@ std::filesystem::path cache_root(); // hardcoded at the call sites so "where is the old cache" has one answer too. std::filesystem::path legacy_bmi_root(); +// A project's own state directory (`/.mcpp/`: the indices it names, +// their payloads, its SubOS, the locks of its compiler runs) is the machine's, +// never the project's sources. Created by any part of mcpp, it carries a +// `.gitignore` of `*`, so that it ignores itself in a repository whatever the +// project's own ignore file says. +void mark_project_state(const std::filesystem::path& dir); + } // namespace mcpp::home namespace mcpp::home { @@ -367,4 +374,12 @@ std::filesystem::path legacy_bmi_root() { return root() / "bmi"; } +void mark_project_state(const std::filesystem::path& dir) { + std::error_code ec; + std::filesystem::create_directories(dir, ec); + const auto ignore = dir / ".gitignore"; + if (ec || std::filesystem::exists(ignore, ec)) return; + std::ofstream(ignore, std::ios::binary) << "# The machine's state of this project (mcpp); never its sources.\n*\n"; +} + } // namespace mcpp::home diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 75919b5d2..2bde38fe1 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -150,6 +150,11 @@ struct PackageRoot { UsageRequirements privateBuild; UsageRequirements publicUsage; UsageRequirements linkUsage; + // What the package's TESTS see beyond `privateBuild`: the public usage of + // its `[dev-dependencies]`, under `mcpp test`. A dev-dependency reaches the + // test units and never the package's own sources, so those compile under + // `mcpp test` as under `mcpp build` (docs/08 "Dev-dependencies"). + UsageRequirements testBuild; bool usageResolved = false; // No TARGET INSTANCE (mcpp#797): no chain of ordinary edges reaches it // from the root. It is resolved here -- one version per package, and its @@ -159,6 +164,11 @@ struct PackageRoot { // A BUILD-PROGRAM INSTANCE: reached through a `[build-dependencies]` edge // and then any edges. A package may have both instances (mcpp#797, R2). bool hostInstance = false; + // A TEST-ONLY INSTANCE: reached from the root and the selected members + // only through a `[dev-dependencies]` edge. A target-side layer it supplies + // serves the tests and does not change the configuration of the shipped + // graph (docs/08 "Dev-dependencies"). + bool devOnly = false; // A selected workspace member that the plan builds (workspace design // 2026-09-29 §15), and the directory below `bin/` its products are placed // in (empty: `bin/` itself). False on the plan's root and on every diff --git a/src/pack/distribution.cppm b/src/pack/distribution.cppm index f58189785..e620f8d5e 100644 --- a/src/pack/distribution.cppm +++ b/src/pack/distribution.cppm @@ -1076,7 +1076,7 @@ std::string exe_launcher_manifest(std::string_view name, std::string_view versio "[targets.{0}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" "# The OS library as one release, the one this mcpp was qualified with, so a\n" "# pack is the same at any later time (design 2026-11-07, D20).\n" - "[dependencies.mcpplibs]\nos = \"=0.3.1\"\n", + "[dependencies]\nmcpplibs.os = \"0.4.0\"\n", name, version.empty() ? std::string_view("0.0.0") : version); } @@ -1422,8 +1422,7 @@ std::string launcher_manifest(std::string_view name, std::string_view version) { "[package]\nname = \"{0}\"\nversion = \"{1}\"\n\n" "[targets.{0}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" "[build]\nsources = [\"src/main.cpp\", \"src/carried.S\"]\n\n" - "[dependencies.mcpplibs]\nos = \"=0.3.1\"\n\n" - "[dependencies]\n\"mcpplibs.mwrap.zstd\" = \"1.5.7\"\n", + "[dependencies]\nmcpplibs.os = \"0.4.0\"\nmcpplibs.mwrap.zstd = \"1.5.7\"\n", name, version.empty() ? std::string_view("0.0.0") : version); } diff --git a/src/scaffold/create.cppm b/src/scaffold/create.cppm index bd3b28094..f333bf8df 100644 --- a/src/scaffold/create.cppm +++ b/src/scaffold/create.cppm @@ -16,6 +16,7 @@ import mcpp.pm.resolver; import mcpp.scaffold; import mcpp.scaffold.project_name; import mcpp.ui; +import mcpp.build.test_protocol; // the release of mtest the skeleton's tests use namespace mcpp::scaffold { @@ -295,9 +296,53 @@ export int new_from_package_template( return 0; } -// Builtin `mcpp new` skeleton (bin, plus the transitional gui alias). +// The builtin skeletons of `mcpp new`: a program (bin, and the transitional +// gui alias) and a library (lib). +export enum class builtin { bin, gui, lib }; + +// What a project's library is named in C++: its module, the qualified name +// with `-` written `_` (`acme.my-lib` -> `acme.my_lib`), and its namespace +// (`acme::my_lib`). Empty when a component does not begin with a letter or +// `_`, which no module name may. +struct cxx_names { + std::string module; + std::string space; +}; +std::optional cxx_names_of(std::string_view qualified) { + cxx_names out; + for (auto part : std::views::split(qualified, '.')) { + std::string component(part.begin(), part.end()); + std::ranges::replace(component, '-', '_'); + if (component.empty() || !(std::isalpha(static_cast(component.front())) + || component.front() == '_')) + return std::nullopt; + out.module += (out.module.empty() ? "" : ".") + component; + out.space += (out.space.empty() ? "" : "::") + component; + } + return out; +} + +// The test framework a skeleton's tests use (docs/08): mtest, whose entry +// begins a test file that defines no `main`. +std::string test_dependency() { + return std::format("\n[dev-dependencies]\nmcpplibs.mtest = \"{}\"\n", + mcpp::build::test_protocol::kMtestRelease); +} + export int create_builtin_project( - const mcpp::scaffold::PortableProjectName& project, bool gui) { + const mcpp::scaffold::PortableProjectName& project, builtin kind) { + const bool gui = kind == builtin::gui; + const bool lib = kind == builtin::lib; + std::optional names; + if (lib) { + names = cxx_names_of(project.qualifiedName); + if (!names) { + mcpp::ui::error(std::format( + "a library's module is named after the package, and '{}' has a part that does not " + "begin with a letter or '_'", project.qualifiedName)); + return 2; + } + } auto transaction = mcpp::scaffold::ScaffoldTransaction::begin( std::filesystem::current_path(), project.directoryName); if (!transaction) { @@ -325,6 +370,7 @@ export int create_builtin_project( // and the GL runtime is closed by the ecosystem (compat.glx-runtime). manifest += "\n[dependencies]\nimgui = \"0.0.5\"\n"; } + manifest += test_dependency(); if (auto written = mcpp::scaffold::write_text_file( root / "mcpp.toml", manifest); !written) { mcpp::ui::error(written.error()); @@ -333,7 +379,7 @@ export int create_builtin_project( } // src/main.cpp — rendered once; values are never rescanned as template // source. - { + if (!lib) { std::string_view source = gui ? R"GUI(// {{project.name}} — generated by `mcpp new --template gui` // Tier-0 zero-boilerplate window via the imgui.app facade. No #include. import imgui.core; @@ -363,6 +409,8 @@ int main(int argc, char* argv[]) { .projectNamespace = project.namespace_, .projectQualifiedName = project.qualifiedName, .templateName = gui ? "gui" : "bin", + .projectModule = {}, + .projectCxxNamespace = {}, }; auto body = mcpp::scaffold::render_tokens(source, vars); if (!body) { @@ -375,7 +423,64 @@ int main(int argc, char* argv[]) { return 1; } } - // tests/test_smoke.cpp — bundled smoke test (`mcpp test` works out-of-the-box). + // A library: its module, a partition it exports and the partition's + // implementation, each rendered once. + if (lib) { + constexpr std::array, 3> files {{ + {"{{project.name}}.cppm", R"(// The module {{project.module}}: what a consumer imports (`import {{project.module}};`). +export module {{project.module}}; + +export import :hello; +)"}, + {"hello.cppm", R"(// The partition {{project.module}}:hello, which the module exports. +export module {{project.module}}:hello; + +import std; + +export namespace {{project.cxxNamespace}} { + +// A greeting for `name`. +std::string hello(std::string_view name); + +} // namespace {{project.cxxNamespace}} +)"}, + {"hello.cpp", R"(// The implementation of hello, a unit of the module {{project.module}}. +module {{project.module}}; + +import std; + +namespace {{project.cxxNamespace}} { + +std::string hello(std::string_view name) { + return std::format("Hello, {}!", name); +} + +} // namespace {{project.cxxNamespace}} +)"}, + }}; + mcpp::scaffold::RenderVars vars{ + .projectName = project.name, + .projectNamespace = project.namespace_, + .projectQualifiedName = project.qualifiedName, + .templateName = "lib", + .projectModule = names->module, + .projectCxxNamespace = names->space, + }; + for (const auto& [name, text] : files) { + auto fileName = mcpp::scaffold::render_tokens(name, vars); + auto body = mcpp::scaffold::render_tokens(text, vars); + if (!fileName || !body) { + mcpp::ui::error(!fileName ? fileName.error().message : body.error().message); + return 1; + } + if (auto written = mcpp::scaffold::write_text_file(root / "src" / *fileName, *body); !written) { + mcpp::ui::error(written.error()); + return 1; + } + } + } + // tests/ — the skeleton's test, a file of mtest's cases with no `main`: + // `mcpp test` lists, selects and reports each case (docs/08). { std::filesystem::create_directories(root / "tests", ec); if (ec) { @@ -383,18 +488,41 @@ int main(int argc, char* argv[]) { "cannot create scaffold test directory: {}", ec.message())); return 1; } - constexpr std::string_view smoke = R"(// Smoke test — verifies the project compiles + a binary runs. -// Add more tests as tests/test_*.cpp files; mcpp test discovers them -// automatically (one binary per file). + constexpr std::string_view smoke = R"(// A test of {{project.name}}: each object is a case `mcpp test` lists, selects and reports. +// More tests are more objects, or more files under tests/; a file defines no `main`. +import mcpplibs.mtest; + +using namespace mcpplibs::mtest; + +UTest smoke{"the project builds and its test runs", [] { expect(eq(1 + 1, 2)); }}; +)"; + constexpr std::string_view greeting = R"(// The cases of {{project.module}}: each object is one `mcpp test` lists, selects and reports. +// More tests are more objects, or more files under tests/; a file defines no `main`. import std; +import mcpplibs.mtest; +import {{project.module}}; -int main() { - std::println("test_smoke: ok"); - return 0; -} +using namespace mcpplibs::mtest; + +UTest greeting{"hello greets by name", [] { + expect(eq({{project.cxxNamespace}}::hello("mcpp"), std::string("Hello, mcpp!"))); +}}; )"; + mcpp::scaffold::RenderVars vars{ + .projectName = project.name, + .projectNamespace = project.namespace_, + .projectQualifiedName = project.qualifiedName, + .templateName = lib ? "lib" : gui ? "gui" : "bin", + .projectModule = names ? names->module : std::string{}, + .projectCxxNamespace = names ? names->space : std::string{}, + }; + auto body = mcpp::scaffold::render_tokens(lib ? greeting : smoke, vars); + if (!body) { + mcpp::ui::error(body.error().message); + return 1; + } if (auto written = mcpp::scaffold::write_text_file( - root / "tests" / "test_smoke.cpp", smoke); !written) { + root / "tests" / (lib ? "hello.cpp" : "test_smoke.cpp"), *body); !written) { mcpp::ui::error(written.error()); return 1; } @@ -428,10 +556,11 @@ int main() { return 1; } - mcpp::ui::line(std::format("Created {} package '{}' at {}", gui ? "gui" : "bin", + mcpp::ui::line(std::format("Created {} package '{}' at {}", lib ? "lib" : gui ? "gui" : "bin", project.qualifiedName, tx.final_path().string())); - mcpp::ui::line(std::format("Next: cd {} && mcpp build && mcpp run (or `mcpp test`)", - project.directoryName)); + mcpp::ui::line(lib ? std::format("Next: cd {} && mcpp build && mcpp test", project.directoryName) + : std::format("Next: cd {} && mcpp build && mcpp run (or `mcpp test`)", + project.directoryName)); return 0; } diff --git a/src/scaffold/template.cppm b/src/scaffold/template.cppm index 967e18918..615c17242 100644 --- a/src/scaffold/template.cppm +++ b/src/scaffold/template.cppm @@ -318,6 +318,9 @@ struct RenderVars { std::string templatePackageSelector; std::string templatePackageVersion; std::string templateName; + // A library skeleton's module and C++ namespace (`mcpp new --template lib`). + std::string projectModule; + std::string projectCxxNamespace; }; struct RenderError { @@ -331,6 +334,9 @@ render_tokens(std::string_view input, const RenderVars& vars) { if (token == "project.name") return vars.projectName; if (token == "project.namespace") return vars.projectNamespace; if (token == "project.qualifiedName") return vars.projectQualifiedName; + if (token == "project.module" && !vars.projectModule.empty()) return vars.projectModule; + if (token == "project.cxxNamespace" && !vars.projectCxxNamespace.empty()) + return vars.projectCxxNamespace; if (token == "template.package.namespace") return vars.templatePackageNamespace; if (token == "template.package.name") return vars.templatePackageName; diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm index c4352851a..9dd40b4af 100644 --- a/src/toolchain/stdmod.cppm +++ b/src/toolchain/stdmod.cppm @@ -31,7 +31,9 @@ export module mcpp.toolchain.stdmod; import std; import mcpp.home; -import mcpp.home.files; // mark_used +import mcpp.home.files; // mark_used, FileLock +import mcpp.build.dirlock; +import mcpp.ui; import nlohmann.json; import mcpplibs.os.platform; import mcpp.build.command_line; @@ -473,6 +475,24 @@ std::expected ensure_built( const auto& compatCommands = desc.compatCommands; const auto& metadata = derivation->metadata; auto metaPath = metadata_path(sm.cacheDir); + // ONE BUILDER PER ENTRY. The entry is shared by every build of this home, and 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. The check and the build happen under the entry's + // lock, so a second process waits and then finds the entry built. + { + std::error_code ec; + std::filesystem::create_directories(sm.cacheDir, ec); + } + namespace dirlock = mcpp::build::dirlock; + auto entryLock = mcpp::home::FileLock::acquire( + sm.cacheDir, ".mcpp-build.lock", dirlock::owner_record(), std::chrono::milliseconds(300), + [&](std::string_view record) { + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + mcpp::ui::status("Waiting", std::format("for the std module, compiled by {}", + dirlock::describe(dirlock::parse_holder(record), now))); + }); bool std_cached = std::filesystem::exists(sm.bmiPath) && std::filesystem::exists(sm.objectPath) && metadata_matches(metaPath, metadata); diff --git a/src/wire.cppm b/src/wire.cppm index 98886381f..9d641f7cc 100644 --- a/src/wire.cppm +++ b/src/wire.cppm @@ -260,11 +260,12 @@ inline nlohmann::json to_json(const Diagnostic& in) { // exist, which a client turns something on or off by. A name is added when a // client gates on it, is never renamed and never removed; docs/50 names the // release that added it and the client that reads it. -inline constexpr std::array kFeatures{{ +inline constexpr std::array kFeatures{{ "target-sets", // SPEC-015 §1: `--target openkal` is the set's legs (an IDE's target picker) "pkg-format-list", // SPEC-014 §5.4: `--pkg-format tar,okb` writes each container (an IDE's pack form) "test-launch", // docs/50 §6: `mcpp test --print-launch` (an IDE debugging a test) "launch-stream", // docs/50 §6: `mcpp run --print-launch --message-format json` (an IDE's Run) + "test-protocol", // docs/08 "Cases": a test program's cases, listed, selected and reported (an IDE's test tree) }}; // The same list as JSON: `--protocol-version` and every envelope's `mcpp` field diff --git a/tests/e2e/100_feature_sources_test_mode.sh b/tests/e2e/100_feature_sources_test_mode.sh index 4accb3652..31049ee7b 100755 --- a/tests/e2e/100_feature_sources_test_mode.sh +++ b/tests/e2e/100_feature_sources_test_mode.sh @@ -23,6 +23,8 @@ 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' 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 index 1333e7d2b..d1ac40977 100755 --- 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 @@ -1,5 +1,6 @@ #!/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 diff --git a/tests/e2e/1025_the_default_platform_is_one_release.sh b/tests/e2e/1025_the_default_platform_is_one_release.sh index 48fc29638..57394a18e 100755 --- a/tests/e2e/1025_the_default_platform_is_one_release.sh +++ b/tests/e2e/1025_the_default_platform_is_one_release.sh @@ -7,9 +7,9 @@ # # Criteria: # A. A project that states no platform builds `x86_64-openkal` over -# openkal-platform 0.24.3, from the target table, and mcpp.lock records it. +# openkal-platform 0.24.4, 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.24.3. +# says which it takes and which the lock recorded, and records 0.24.4. # C. A platform the project states is taken as it is (a series here). set -e TMP=$(mktemp -d) @@ -30,20 +30,20 @@ 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.24.3' a.log || fail "A: the build does not use openkal-platform 0.24.3" a.log -[[ "$(locked)" == "0.24.3" ]] || fail "A: mcpp.lock does not record 0.24.3" mcpp.lock +grep -q 'Platform *openkal-platform 0.24.4' a.log || fail "A: the build does not use openkal-platform 0.24.4" a.log +[[ "$(locked)" == "0.24.4" ]] || fail "A: mcpp.lock does not record 0.24.4" mcpp.lock # B $PY - <<'PY' import re text = open("mcpp.lock").read() -text = re.sub(r'(\[package\."openkal-platform"\][^\[]*?version\s*=\s*")0\.24\.3(")', r'\g<1>0.24.2\2', text, count=1) +text = re.sub(r'(\[package\."openkal-platform"\][^\[]*?version\s*=\s*")0\.24\.4(")', r'\g<1>0.24.3\2', text, count=1) open("mcpp.lock", "w").write(text) PY -[[ "$(locked)" == "0.24.2" ]] || fail "B: the fixture could not edit mcpp.lock" mcpp.lock +[[ "$(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.2" b.log || fail "B: the build does not say which release it takes over the lock's" b.log -[[ "$(locked)" == "0.24.3" ]] || fail "B: mcpp.lock was not rewritten to 0.24.3" mcpp.lock +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.24.4" ]] || fail "B: mcpp.lock was not rewritten to 0.24.4" mcpp.lock # C cat >> mcpp.toml <<'EOT' 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 new file mode 100755 index 000000000..36267beaf --- /dev/null +++ b/tests/e2e/1027_a_configuration_group_keeps_the_other_groups_lock_entries.sh @@ -0,0 +1,108 @@ +#!/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 new file mode 100755 index 000000000..7550ab073 --- /dev/null +++ b/tests/e2e/1028_a_projects_state_directory_ignores_itself.sh @@ -0,0 +1,29 @@ +#!/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 0; } +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 0; } +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 new file mode 100755 index 000000000..4cea80e0f --- /dev/null +++ b/tests/e2e/1029_an_unused_std_module_is_collected_by_age.sh @@ -0,0 +1,50 @@ +#!/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 new file mode 100755 index 000000000..4424e2511 --- /dev/null +++ b/tests/e2e/1030_the_low_space_warning_knows_what_the_last_build_took.sh @@ -0,0 +1,34 @@ +#!/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 0; } +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 new file mode 100755 index 000000000..db153d9c5 --- /dev/null +++ b/tests/e2e/1031_target_keeps_within_its_budget.sh @@ -0,0 +1,44 @@ +#!/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 new file mode 100755 index 000000000..6d02a82e2 --- /dev/null +++ b/tests/e2e/1032_a_quoted_dotted_dependency_key_is_deprecated.sh @@ -0,0 +1,52 @@ +#!/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/1033_an_ok_image_runs_on_the_system_that_built_it.sh b/tests/e2e/1033_an_ok_image_runs_on_the_system_that_built_it.sh new file mode 100755 index 000000000..7b79e2135 --- /dev/null +++ b/tests/e2e/1033_an_ok_image_runs_on_the_system_that_built_it.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# 1033 -- an ok image runs on the system that built it, on every system mcpp is released for. +# +# docs/21 calls a row `verified` when CI builds AND runs its artefact. The two +# `*-openkal` rows were built on every host, but run by mcpp's own CI only on +# Linux: 942 and its neighbours declare `llvm`, which run_all.sh grants on Linux +# alone, so macOS and Windows skipped them. This test needs nothing the +# platform does not bring, and tests/e2e/host-requirements.tsv requires it to +# pass on Linux, macOS and Windows: an image of this machine's architecture, +# with the platform's C library and C++ run-time (`import std`, as the +# ecosystem's applications use them), is built, run by okrun and tested; the +# other architecture's row builds too. +# +# Criteria: +# A. `mcpp run --target -openkal` runs the image: its arguments and its +# environment arrive, and std::format and a std::thread work. +# B. `mcpp test --target -openkal` builds and runs a test image. +# C. The other architecture's row builds an image (without the platform's +# layers, so that the test stays within one bound). +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; } + +case "$(uname -m)" in + x86_64|amd64) HOST_ARCH=x86_64; OTHER_ARCH=aarch64 ;; + aarch64|arm64) HOST_ARCH=aarch64; OTHER_ARCH=x86_64 ;; + *) echo "SKIP: no ok-image row for $(uname -m)"; exit 0 ;; +esac + +mkdir -p "$TMP/app/src" "$TMP/app/tests" +cd "$TMP/app" +cat > mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" +TOML +cat > src/main.cpp <<'CPP' +import std; + +int main(int argc, char** argv) { + std::string args; + for (int i = 1; i < argc; ++i) args += std::format("[{}]", argv[i]); + const char* v = std::getenv("MCPP_E2E_1033"); + int sum = 0; + std::thread t([&] { for (int i = 1; i <= 10; ++i) sum += i; }); + t.join(); + std::println("args={} env={} sum={}", args, v ? v : "(none)", sum); + return sum == 55 ? 0 : 1; +} +CPP +cat > tests/smoke.cpp <<'CPP' +import std; +int main() { return std::format("{}-{}", 1, 2) == "1-2" ? 0 : 1; } +CPP + +MCPP_E2E_1033=here "$MCPP" run --target "$HOST_ARCH-openkal" -- one "two words" > run.log 2>&1 \ + || fail "A: mcpp run --target $HOST_ARCH-openkal" run.log +grep -q 'args=\[one\]\[two words\] env=here sum=55' run.log || fail "A: the image's output" run.log +echo "ok: A, the $HOST_ARCH-openkal image runs here" + +"$MCPP" test --target "$HOST_ARCH-openkal" > test.log 2>&1 || fail "B: mcpp test --target $HOST_ARCH-openkal" test.log +grep -q 'smoke \.\.\. ok' test.log || fail "B: the test image did not pass" test.log +echo "ok: B, a test image runs here" + +mkdir -p "$TMP/other/src" +cd "$TMP/other" +cat > mcpp.toml <<'TOML' +[package] +name = "other" +version = "0.1.0" + +[targets.other] +kind = "bin" +main = "src/main.c" + +[target.'cfg(platform = "openkal")'.layers] +c-abi = false +"c++-abi" = false +compiler-runtime = false +TOML +printf 'int main(void) { return 0; }\n' > src/main.c +"$MCPP" build --target "$OTHER_ARCH-openkal" > other.log 2>&1 || fail "C: mcpp build --target $OTHER_ARCH-openkal" other.log +ls target/"$OTHER_ARCH"-openkal/*/bin/other.oke > /dev/null 2>&1 || fail "C: no image for $OTHER_ARCH-openkal" other.log +echo "ok: C, the $OTHER_ARCH-openkal row builds here" +echo "PASS: 1033 an ok image runs on the system that built it" 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 new file mode 100755 index 000000000..d5776f334 --- /dev/null +++ b/tests/e2e/1034_a_workspace_members_test_finds_its_programs.sh @@ -0,0 +1,44 @@ +#!/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 new file mode 100755 index 000000000..c5b8bae80 --- /dev/null +++ b/tests/e2e/1035_a_test_program_reports_its_cases.sh @@ -0,0 +1,187 @@ +#!/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 -q "case math/fails failed at t.cpp:5" a.log || fail "A: the human report does not name the failed case" 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 new file mode 100755 index 000000000..f0403aebf --- /dev/null +++ b/tests/e2e/1036_a_context_states_the_engine_values_in_process.sh @@ -0,0 +1,50 @@ +#!/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 new file mode 100755 index 000000000..b6f90ceec --- /dev/null +++ b/tests/e2e/1037_a_skeleton_tests_with_mtest_and_a_test_needs_no_main.sh @@ -0,0 +1,231 @@ +#!/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 new file mode 100644 index 000000000..6e48b757d --- /dev/null +++ b/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh @@ -0,0 +1,137 @@ +#!/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; 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 +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 new file mode 100644 index 000000000..fa3c59e9c --- /dev/null +++ b/tests/e2e/1039_two_builds_that_find_the_std_module_missing_compile_it_once.sh @@ -0,0 +1,46 @@ +#!/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/104_asm_sources_gas.sh b/tests/e2e/104_asm_sources_gas.sh index 7ad4ff664..203cecb8a 100755 --- a/tests/e2e/104_asm_sources_gas.sh +++ b/tests/e2e/104_asm_sources_gas.sh @@ -50,6 +50,8 @@ cat > mcpp.toml <<'EOF' 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; } diff --git a/tests/e2e/106_feature_gated_sources_toml.sh b/tests/e2e/106_feature_gated_sources_toml.sh index 2dab88ecd..57893ea18 100755 --- a/tests/e2e/106_feature_gated_sources_toml.sh +++ b/tests/e2e/106_feature_gated_sources_toml.sh @@ -38,6 +38,8 @@ version = "0.1.0" 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). diff --git a/tests/e2e/132_freestanding_test_and_artifacts.sh b/tests/e2e/132_freestanding_test_and_artifacts.sh index c042edb12..b4e4c1648 100755 --- a/tests/e2e/132_freestanding_test_and_artifacts.sh +++ b/tests/e2e/132_freestanding_test_and_artifacts.sh @@ -40,6 +40,8 @@ cd "$TMP" # ── the board-support package (same shape as 131) ─────────────────────────── "$MCPP" new board > /dev/null cd board +# The scaffold's tests use mtest; this test's do not. +perl -0pi -e 's/\n\[dev-dependencies\]\nmcpplibs\.mtest = "[^"]*"\n//' mcpp.toml rm -f src/main.cpp tests/*.cpp 2>/dev/null || true cat > mcpp.toml <<'EOF' @@ -100,6 +102,8 @@ EOF cd "$TMP" "$MCPP" new fw > /dev/null cd fw +# The scaffold's tests use mtest; this test's do not. +perl -0pi -e 's/\n\[dev-dependencies\]\nmcpplibs\.mtest = "[^"]*"\n//' mcpp.toml rm -f tests/*.cpp 2>/dev/null || true cat > src/main.cpp <<'EOF' diff --git a/tests/e2e/139_build_program_advisory.sh b/tests/e2e/139_build_program_advisory.sh index 69d4e16c1..c4b96e1e4 100755 --- a/tests/e2e/139_build_program_advisory.sh +++ b/tests/e2e/139_build_program_advisory.sh @@ -1,5 +1,6 @@ #!/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. diff --git a/tests/e2e/146_feature_flags.sh b/tests/e2e/146_feature_flags.sh index fcf86cf52..d4b6b26fa 100755 --- a/tests/e2e/146_feature_flags.sh +++ b/tests/e2e/146_feature_flags.sh @@ -67,6 +67,8 @@ 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; } diff --git a/tests/e2e/170_bmi_staging_no_cascade.sh b/tests/e2e/170_bmi_staging_no_cascade.sh index 628cd542b..2ca0ed3e9 100755 --- a/tests/e2e/170_bmi_staging_no_cascade.sh +++ b/tests/e2e/170_bmi_staging_no_cascade.sh @@ -1,5 +1,6 @@ #!/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: diff --git a/tests/e2e/177_cpp20_msvc.sh b/tests/e2e/177_cpp20_msvc.sh index 7315e6933..fa1cbc422 100755 --- a/tests/e2e/177_cpp20_msvc.sh +++ b/tests/e2e/177_cpp20_msvc.sh @@ -1,5 +1,6 @@ #!/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 diff --git a/tests/e2e/180_msvc_build_mcpp.sh b/tests/e2e/180_msvc_build_mcpp.sh index c63fab6f8..1f6e3b80d 100755 --- a/tests/e2e/180_msvc_build_mcpp.sh +++ b/tests/e2e/180_msvc_build_mcpp.sh @@ -1,5 +1,6 @@ #!/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 diff --git a/tests/e2e/205_root_local_subos.sh b/tests/e2e/205_root_local_subos.sh index 8806a8c6e..68ca10766 100755 --- a/tests/e2e/205_root_local_subos.sh +++ b/tests/e2e/205_root_local_subos.sh @@ -1,5 +1,6 @@ #!/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. diff --git a/tests/e2e/219_runtime_search_farm_is_last.sh b/tests/e2e/219_runtime_search_farm_is_last.sh index fbc073089..831cc1dbe 100755 --- a/tests/e2e/219_runtime_search_farm_is_last.sh +++ b/tests/e2e/219_runtime_search_farm_is_last.sh @@ -1,5 +1,6 @@ #!/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. # diff --git a/tests/e2e/231_jobs_option.sh b/tests/e2e/231_jobs_option.sh index 469832168..0042f0556 100755 --- a/tests/e2e/231_jobs_option.sh +++ b/tests/e2e/231_jobs_option.sh @@ -21,7 +21,7 @@ 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 0; } - case "$MCPP" in /*) ;; *) MCPP="$_root/$MCPP" ;; esac + case "$MCPP" in /*|?:[/\\]*) ;; *) MCPP="$_root/$MCPP" ;; esac export MCPP fi diff --git a/tests/e2e/233_bench_matrix.sh b/tests/e2e/233_bench_matrix.sh index a2a12a5ef..ccbf3ffdd 100755 --- a/tests/e2e/233_bench_matrix.sh +++ b/tests/e2e/233_bench_matrix.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: python3 +# resources: machine # 233_bench_matrix.sh — bench/matrix.json is the ONE place the benchmark matrix # is written down, and this checks that it stays that way. # diff --git a/tests/e2e/234_bmi_schedule_on.sh b/tests/e2e/234_bmi_schedule_on.sh index 0295e0d86..c05246336 100755 --- a/tests/e2e/234_bmi_schedule_on.sh +++ b/tests/e2e/234_bmi_schedule_on.sh @@ -1,5 +1,6 @@ #!/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 @@ -39,7 +40,7 @@ fi # 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 +case "$MCPP" in /*|?:[/\\]*) ;; *) MCPP="$_root/$MCPP" ;; esac [ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } export MCPP diff --git a/tests/e2e/239_msvc_managed_toolset.sh b/tests/e2e/239_msvc_managed_toolset.sh index a922b095a..942bbba55 100755 --- a/tests/e2e/239_msvc_managed_toolset.sh +++ b/tests/e2e/239_msvc_managed_toolset.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: msvc xlings-msvc python3 +# resources: machine # 239_msvc_managed_toolset.sh — `xim:msvc@`: the package the manifest # names is the one that compiles, regardless of what this machine has. # diff --git a/tests/e2e/28_target_static.sh b/tests/e2e/28_target_static.sh index 8111c371e..07b86a2cf 100755 --- a/tests/e2e/28_target_static.sh +++ b/tests/e2e/28_target_static.sh @@ -1,5 +1,6 @@ #!/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). diff --git a/tests/e2e/614_feature_xlings_reaches_xpkg_dir.sh b/tests/e2e/614_feature_xlings_reaches_xpkg_dir.sh index 608cf2639..33aefb1dd 100755 --- a/tests/e2e/614_feature_xlings_reaches_xpkg_dir.sh +++ b/tests/e2e/614_feature_xlings_reaches_xpkg_dir.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: gcc unix-shell +# resources: machine # A tool declared under `[feature-xlings.]` is answerable through # `mcpp::xpkg_dir` when that feature is active. # diff --git a/tests/e2e/675_android_test_programs_carry_their_cxx_runtime.sh b/tests/e2e/675_android_test_programs_carry_their_cxx_runtime.sh index d4f3f2b2b..18145042c 100644 --- a/tests/e2e/675_android_test_programs_carry_their_cxx_runtime.sh +++ b/tests/e2e/675_android_test_programs_carry_their_cxx_runtime.sh @@ -49,6 +49,9 @@ chmod +x "$TMP/ok-runner.sh" # ── 1 and 2. test programs ──────────────────────────────────────────────── "$MCPP" new droid > /dev/null cd droid +# The scaffold's tests use mtest; this test's do not. +perl -0pi -e 's/\n\[dev-dependencies\]\nmcpplibs\.mtest = "[^"]*"\n//' mcpp.toml +rm -f tests/test_smoke.cpp cat > tests/strings.cpp <<'EOF' #include #include diff --git a/tests/e2e/78_test_main_combinations.sh b/tests/e2e/78_test_main_combinations.sh index 5e1112508..64f38b7ad 100755 --- a/tests/e2e/78_test_main_combinations.sh +++ b/tests/e2e/78_test_main_combinations.sh @@ -20,6 +20,8 @@ trap "rm -rf $TMP" EXIT cd "$TMP" "$MCPP" new combo > /dev/null cd combo +# The scaffold's tests use mtest; this test's do not. +perl -0pi -e 's/\n\[dev-dependencies\]\nmcpplibs\.mtest = "[^"]*"\n//' mcpp.toml cat >> mcpp.toml <<'EOF' diff --git a/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh b/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh index 730b49a47..c015eeb20 100755 --- a/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh +++ b/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: unix-shell +# resources: machine # 822_a_package_states_the_oldest_mcpp_it_supports.sh -- #734 E9 and E12. # # `[package] mcpp = ">=V"` states the oldest mcpp release a package supports. diff --git a/tests/e2e/931_a_suggested_command_quotes_its_path.sh b/tests/e2e/931_a_suggested_command_quotes_its_path.sh index c720bcd6d..a8072b657 100755 --- a/tests/e2e/931_a_suggested_command_quotes_its_path.sh +++ b/tests/e2e/931_a_suggested_command_quotes_its_path.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: unix-shell fresh-sandbox +# resources: machine # A command printed for a person to paste quotes the paths in it (mcpp#832, # R1). A home under `/tmp/mc probe` printed # `mcpp self init --force --home /tmp/mc probe/home`, which selects `/tmp/mc` diff --git a/tests/e2e/936_the_control_contract.sh b/tests/e2e/936_the_control_contract.sh index 88f7aa024..2e72ffc2b 100644 --- a/tests/e2e/936_the_control_contract.sh +++ b/tests/e2e/936_the_control_contract.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: python3 +# resources: machine # 936 -- the control contract (design 2026-10-26 §5): what a program, an # agent or an editor reads of a build without a terminal. # diff --git a/tests/e2e/951_an_ok_image_needs_no_declaration.sh b/tests/e2e/951_an_ok_image_needs_no_declaration.sh index 437ce2216..084678f94 100755 --- a/tests/e2e/951_an_ok_image_needs_no_declaration.sh +++ b/tests/e2e/951_an_ok_image_needs_no_declaration.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: llvm python3 +# resources: machine # 951 -- `-openkal` needs no declaration: the target's platform supplies # the C library, the C++ run-time, the tools and the runner (SPEC-013; design # 2026-10-28 §7, §10). diff --git a/tests/e2e/955_the_openkal_set_is_one_tree_of_every_architecture.sh b/tests/e2e/955_the_openkal_set_is_one_tree_of_every_architecture.sh index c751b1b62..91fd5cd2a 100755 --- a/tests/e2e/955_the_openkal_set_is_one_tree_of_every_architecture.sh +++ b/tests/e2e/955_the_openkal_set_is_one_tree_of_every_architecture.sh @@ -70,9 +70,9 @@ PY echo "ok: one package holds both architectures" # B -okrun=$("$MCPP" self env --target "$T" --format json 2>/dev/null \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["runner"]["program"])') -[ -x "$okrun" ] || fail "B: the platform's runner was not located ($okrun)" +"$MCPP" self env --target "$T" --format json > env.json 2> env.err || fail "B: self env" env.json env.err +okrun=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["data"]["runner"]["program"])' env.json 2>/dev/null) +[ -x "$okrun" ] || fail "B: the platform's runner was not located ($okrun)" env.json env.err PATH="$(dirname "$okrun"):$PATH" "$P/bin/uni" one two > b.log 2>&1 || fail "B: the launcher" b.log grep -q "universal 3 args" b.log || fail "B: the image's output through the launcher" b.log echo "ok: the launcher runs this machine's image through the runner" diff --git a/tests/e2e/95_msvc_system_toolchain.sh b/tests/e2e/95_msvc_system_toolchain.sh index 12dd5e6f2..02d230b55 100755 --- a/tests/e2e/95_msvc_system_toolchain.sh +++ b/tests/e2e/95_msvc_system_toolchain.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: msvc +# resources: machine # 95_msvc_system_toolchain.sh — msvc@system detection & selection (Windows): # - `toolchain default msvc` locates + identifies the system MSVC and # persists the stable spec msvc@system diff --git a/tests/e2e/96_msvc_unavailable_nonwindows.sh b/tests/e2e/96_msvc_unavailable_nonwindows.sh index 41e07d068..9cd69e04f 100755 --- a/tests/e2e/96_msvc_unavailable_nonwindows.sh +++ b/tests/e2e/96_msvc_unavailable_nonwindows.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: unix-shell +# resources: machine # 96_msvc_unavailable_nonwindows.sh — msvc@system is Windows-only: # - `toolchain default/install msvc` fail with a host error (no network, # no sandbox mutation) diff --git a/tests/e2e/97_mingw_toolchain.sh b/tests/e2e/97_mingw_toolchain.sh index c47406108..8e32f8156 100755 --- a/tests/e2e/97_mingw_toolchain.sh +++ b/tests/e2e/97_mingw_toolchain.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: windows +# resources: machine # 97_mingw_toolchain.sh — Windows-native MinGW-w64 GCC via the xlings # ecosystem (xim:mingw-gcc, winlibs GCC 16.1.0 UCRT): # - `toolchain install mingw 16.1.0` resolves + installs the xpkg diff --git a/tests/e2e/99_msvc_native_build.sh b/tests/e2e/99_msvc_native_build.sh index 6485d3b7b..eb4c92cb6 100755 --- a/tests/e2e/99_msvc_native_build.sh +++ b/tests/e2e/99_msvc_native_build.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # requires: msvc +# resources: machine # 99_msvc_native_build.sh — native cl.exe builds (0.0.90): # - toolchain default msvc → new → build → run (import std via std.ixx/.ifc) # - a named module (.cppm through /interface /TP + /ifcOutput) diff --git a/tests/e2e/host-requirements.tsv b/tests/e2e/host-requirements.tsv new file mode 100644 index 000000000..d47c5247d --- /dev/null +++ b/tests/e2e/host-requirements.tsv @@ -0,0 +1,5 @@ +# Tests whose result must come from particular hosts, read by +# .github/tools/check_e2e_coverage.py. One `\t\t` line +# each; the hosts are the names the shards' reports carry (linux, macos, +# windows). The check fails when a listed test did not pass on one of them. +1033_an_ok_image_runs_on_the_system_that_built_it.sh linux macos windows docs/21: the x86_64-openkal and aarch64-openkal rows are verified, built and run on every system mcpp is released for diff --git a/tests/e2e/mtest.toml b/tests/e2e/mtest.toml new file mode 100644 index 000000000..1177d9e99 --- /dev/null +++ b/tests/e2e/mtest.toml @@ -0,0 +1,19 @@ +# The e2e tests as mtest programs (design 2026-11-08 W8; run_all.sh with E2E_MTEST). Each script is +# a program run by bash, whose one case is judged by its exit status. Line 2's `# requires:` gives +# its capabilities as tags (`requires:gcc`), which a machine that lacks one excludes; a +# `# resources:` line gives what it holds while it runs (`machine`: nothing else runs beside it). +# The store keeps each run, and its history gives the parts of `--shard` and the order of a run. + +[project] +store = ".mtest" + +[discover.e2e] +globs = ["[0-9]*.sh"] +command = ["bash", "{path}"] +kind = "e2e" +# A script is level 0, whatever its text says: several name the protocol's marker in their comments, +# and a script told the protocol's variables would pass them to what it runs. +level = 0 +timeout = "10m" +tags_from = { "# requires:" = "requires:" } +resources_from = ["# resources:"] diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index b76f535e3..a3da4debe 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -417,11 +417,12 @@ SHARD_POS=0 # CI record). Round-robin balances counts, not time: measured on 2026-10-01 the # two Windows shards differed by 3.6 minutes on average, and about fifteen tests # of over thirty seconds took 58-64 percent of a shard. E2E_TIMINGS names a -# table of `\t` lines (tests/e2e/timings/.tsv); the -# tests are assigned longest first, each to the shard with the least time so -# far, and a test the table does not know counts as the table's median. The -# assignment depends only on the file list and the table, both in the -# repository, so a shard's membership is reproducible from the commit, and it +# table of `\t` lines (the e2e-timings artifact of a CI +# run; CI itself divides by mtest's history, E2E_MTEST above); the tests are +# assigned longest first, each to the shard with the least time so far, and a +# test the table does not know counts as the table's median. The assignment +# depends only on the file list and the table, so a shard's membership is +# reproducible from them, and it # changes which shard runs a test, never whether one does. SHARD_MEMBERS="" if (( SHARD_TOTAL > 1 )) && [[ -n "${E2E_TIMINGS:-}" ]]; then @@ -488,10 +489,69 @@ if [[ "${E2E_LIST:-}" == "1" ]]; then exit 0 fi +# BY MTEST (design 2026-11-08 W8) when E2E_MTEST names the mtest program: the +# tests are the programs of tests/e2e/mtest.toml, their `# requires:` tags and +# `# resources:` resources. The capabilities this machine lacks exclude their +# tests, E2E_SHARD selects a part by the store's history (mtest's `--shard`, the +# longest first to the part with the least expected time) in place of the +# timing table, and E2E_JOBS runs that many at once. E2E_SET names a set of +# mtest (`pr`: the tests the change affects, by the store's coverage map, with +# the smoke and regression sets; a test the map does not know is affected). +# E2E_ONLY and E2E_EXCLUDE select as below. Each result is reported as the +# serial run reports it. +run_by_mtest() { + local args=(run --jobs "${E2E_JOBS:-1}" --timeout "${E2E_TEST_TIMEOUT}s" --report-json "$1") + local cap + for cap in "${KNOWN_CAPS[@]}"; do + [[ " ${CAPS[*]} " == *" $cap "* ]] || args+=(--exclude-tag "requires:$cap") + done + (( SHARD_TOTAL > 1 )) && args+=(--shard "$SHARD_IDX/$SHARD_TOTAL") + [[ -n "${E2E_SET:-}" ]] && args+=(--set "$E2E_SET") + # E2E_COVERAGE: the run measures the coverage map ([coverage] of mtest.toml). + [[ -n "${E2E_COVERAGE:-}" ]] && args+=(--coverage) + [[ -n "${E2E_ONLY:-}" ]] && args+=(--program "$E2E_ONLY") + [[ -n "${E2E_EXCLUDE:-}" ]] && args+=(--exclude-program "$E2E_EXCLUDE") + (cd "$HERE" && MCPP="$MCPP" "$E2E_MTEST" "${args[@]}") +} +if [[ -n "${E2E_MTEST:-}" ]]; then + record="$(mktemp)" + run_by_mtest "$record" || true + # One ` ` line per case of the run's record. + py=python3; command -v python3 >/dev/null 2>&1 || py=python + results="$("$py" - "$record" <<'PY' +import json, sys +try: + run = json.load(open(sys.argv[1])) +except (OSError, ValueError): + sys.exit(0) +for c in run.get("cases", []): + status = {"pass": "pass", "flaky": "pass", "timeout": "timeout"}.get(c.get("status"), "fail") + print(status, int(c.get("ms", 0)), c.get("program", c.get("id", ""))) +PY +)" + rm -f "$record" + while read -r status ms name; do + name="${name%$'\r'}" # Windows' Python ends a printed line with CR LF + [[ -n "$name" ]] || continue + TIMINGS+=("$ms $name") + case "$status" in + pass) ((PASS++)); report pass "$name" "$ms" "" ;; + timeout) ((FAIL++)); FAILED_TESTS+=("$name (TIMEOUT)"); TIMED_OUT_TESTS+=("$name") + report timeout "$name" "$ms" "exceeded ${E2E_TEST_TIMEOUT}s" ;; + *) ((FAIL++)); FAILED_TESTS+=("$name"); report fail "$name" "$ms" "failed" ;; + esac + done <<<"$results" + if [[ $((PASS + FAIL)) -eq 0 ]]; then + echo "FATAL: mtest reported no case (E2E_MTEST=$E2E_MTEST)" + exit 1 + fi +fi + # Optional name filter: E2E_ONLY="" runs just the matching tests. # Used by the workflows that own ONE subject (see ci-windows-msvc-xlings.yml) # so the job name and the thing it runs cannot drift apart. for test in "$HERE"/[0-9]*.sh; do + [[ -z "${E2E_MTEST:-}" ]] || break if [[ -n "${E2E_ONLY:-}" ]]; then # shellcheck disable=SC2053 — glob match is the point [[ "$(basename "$test")" == $E2E_ONLY ]] || continue diff --git a/tests/e2e/timings/linux.tsv b/tests/e2e/timings/linux.tsv deleted file mode 100644 index a889e40bd..000000000 --- a/tests/e2e/timings/linux.tsv +++ /dev/null @@ -1,615 +0,0 @@ -00_fixture_path_hygiene.sh 2480 -01_help_and_version.sh 11 -02_new_build_run.sh 1310 -03_multi_module.sh 1960 -04_incremental.sh 3680 -05_errors.sh 1430 -06_emit_xpkg.sh 17 -07_static_library.sh 145 -08_shared_library.sh 288 -09_path_dependency.sh 2410 -100_cppfly_reflection.sh 8130 -100_feature_sources_test_mode.sh 3330 -101_cppfly_llvm_soft.sh 3210 -103_target_vocabulary.sh 5780 -104_asm_sources_gas.sh 2780 -105_asm_sources_nasm.sh 6700 -106_feature_gated_sources_toml.sh 7850 -107_generated_files_toml.sh 1610 -108_cfg_conditional_sources.sh 1110 -109_per_glob_flags.sh 1300 -10_env_command.sh 94 -110_build_mcpp_env_contract.sh 6020 -111_dep_build_mcpp.sh 2350 -112_build_mcpp_cross.sh 6820 -113_scanner_mcpp_dir_prune.sh 151 -114_run_scan_scope.sh 1550 -115_manifest_syntax_aot_brace.sh 1120 -116_include_family_and_define_spaces.sh 1480 -117_object_path_collision.sh 1290 -118_purview_include_rebuild.sh 7220 -119_dep_cfg_sources.sh 1230 -11_index_list.sh 9700 -120_ws_root_indices.sh 680 -121_default_ns_redirect.sh 619 -122_run_member.sh 1700 -123_same_named_main_across_dep.sh 939 -124_gen_source_out_of_root_object_path.sh 2420 -125_build_mcpp_dep_dir.sh 1340 -126_default_features_opt_out.sh 496 -127_transitive_default_features_optout.sh 2310 -128_feature_forwarding.sh 507 -12_add_command.sh 558 -134_target_sysroot_and_queries.sh 5130 -135_diagnostic_promise_resolves.sh 5640 -136_aarch64_zero_libc_target.sh 1150 -137_x86_64_zero_libc_target.sh 1390 -138_feature_sources_gate_vs_provider.sh 640 -139_build_program_advisory.sh 925 -13_toolchain_pin.sh 5 -141_include_dirs_after.sh 1030 -142_generated_files_incremental.sh 2470 -143_build_mcpp_source_directive.sh 2420 -144_build_mcpp_include_dir.sh 3840 -145_root_build_mcpp_dep_dirs.sh 1740 -146_feature_flags.sh 9390 -147_per_os_features_xpkg.sh 540 -148_wide_include_list.sh 1230 -149_conditional_glob_flags.sh 1370 -14_toolchain_fallback.sh 8130 -150_clang_module_operator_template.sh 390 -151_index_url_artifact_migration.sh 107 -152_test_subdir_names.sh 147 -153_test_isolation.sh 377 -154_test_filter.sh 173 -155_test_json.sh 202 -156_nested_ld_library_path.sh 112 -157_test_glob_flags.sh 136 -158_test_signal_and_duration.sh 277 -159_test_list.sh 17 -15_test_passing.sh 2160 -160_test_timeout.sh 4650 -161_xpkg_name_form.sh 63 -162_bare_name_namespace_scope.sh 769 -163_identity_first_resolution.sh 18500 -164_ninja_goal_command_length.sh 659 -165_bare_name_cross_namespace_wire_address.sh 5590 -166_run_env_no_private_glibc.sh 365 -167_build_defines_module_scan.sh 551 -168_build_mcpp_musl_host_static.sh 4970 -169_semver_project_index.sh 516 -16_test_failing.sh 2470 -170_bmi_staging_no_cascade.sh 1100 -171_bmi_staging_locked_dest.sh 2420 -172_build_cache_cross_project.sh 6820 -173_build_profile_isolation.sh 981 -173_index_refresh_policy.sh 920 -174_cache_modes_and_commands.sh 13830 -175_cpp20_import_std.sh 6700 -176_cpp20_std_bmi_isolation.sh 7490 -178_test_observability.sh 9480 -179_spaced_paths.sh 1390 -17_test_no_tests.sh 14 -181_build_mcpp_import_std.sh 5940 -183_cross_no_relink.sh 1230 -183_cxx_runtime_contract.sh 3570 -184_build_cache_object_layout.sh 11480 -185_index_floor_degrades.sh 5430 -186_build_mcpp_protocol_and_bound.sh 8029 -187_dep_host_tool.sh 4070 -188_build_actions.sh 8710 -189_host_module_rules.sh 2820 -18_devdeps_isolation.sh 1120 -190_link_rspfile_newlines.sh 686 -191_link_scale.sh 16149 -192_install_path_namespace.sh 3370 -193_provision_reexport.sh 9050 -194_build_mcpp_glob_input.sh 1780 -195_target_cfg_feature_deps.sh 437 -196_version_identity_and_lock.sh 13010 -198_windows_resources_cross.sh 3810 -199_resources_validation.sh 509 -19_bmi_cache_reuse.sh 6260 -200_subos_env_reaches_program.sh 1700 -201_gcc_no_specs_pollution.sh 10430 -202_machine_output_contract.sh 7620 -203_exact_selector_lock_migration.sh 482 -204_new_transactional_scaffold.sh 6840 -205_root_local_subos.sh 709 -206_runtime_binding_physics.sh 2850 -207_runtime_contract_provenance.sh 380 -208_private_libc_stays_in_the_binary.sh 246 -209_runtime_verdict_never_reruns_green.sh 4560 -20_p1689_scanner.sh 4420 -210_local_index_addressing_on_every_host.sh 262 -211_configure_only_cdb.sh 612 -212_cached_dep_std_is_ordered.sh 5090 -213_build_after_test_is_not_the_test_graph.sh 451 -214_executable_carries_dt_rpath.sh 1460 -215_pack_has_no_build_machine_paths.sh 2660 -216_selfcontained_refuses_host_capability.sh 521 -217_module_extensions.sh 5440 -218_module_extensions_graph_shape.sh 5000 -219_runtime_search_farm_is_last.sh 320 -21_ninja_dyndep.sh 5310 -220_farm_only_library_runs.sh 908 -221_subos_without_info_still_builds.sh 189 -222_shared_library_cxx_runtime_contract.sh 1810 -22_doctor_cache_publish.sh 4850 -230_bench_harness.sh 53150 -231_jobs_option.sh 2630 -232_workflow_syntax.sh 202 -233_bench_matrix.sh 161 -234_bmi_schedule_on.sh 4950 -235_std_object_only_when_needed.sh 968 -236_module_extensions_default_toolchain.sh 2520 -237_default_subos_without_info.sh 218 -238_c_only_unit_links_with_c_driver.sh 3660 -23_remove_update.sh 16010 -240_pack_pe_zip_cross.sh 379 -242_pack_library_interface_and_headers.sh 923 -243_pack_library_interface_closure.sh 808 -244_pack_library_gate.sh 555 -245_pack_library_fat_target_selection.sh 794 -246_explicit_empty_sources.sh 307 -247_bare_triple_conditional_native.sh 455 -248_pack_library_fat_pe_leg.sh 609 -249_pack_workspace_root_unchanged.sh 186 -24_git_dependency.sh 9150 -250_pack_names_the_target.sh 239 -251_pack_library_shared.sh 597 -252_pack_library_old_client.sh 346 -253_pack_library_undetermined_partition.sh 662 -254_pack_library_platform_coverage.sh 501 -25_convention_mode.sh 4770 -260_module_extension_is_configuration.sh 223 -261_pack_follows_the_module_extension.sh 445 -263_lib_root_follows_the_extension.sh 1470 -264_pack_library_is_relocatable.sh 494 -265_pack_strips_but_stays_usable.sh 832 -267_target_requested_is_empty_for_a_native_build.sh 554 -268_target_side_resolution.sh 312 -269_openkal_llvm_spelling_still_resolves.sh 1080 -26_c_language_support.sh 1290 -26_toolchain_management.sh 28430 -27_namespace_dependencies.sh 8430 -27_self_contained_home.sh 8290 -280_target_side_layers.sh 2750 -281_target_side_rules.sh 294 -282_target_is_a_request.sh 138 -283_run_target_flag_owns_its_cache_slot.sh 361 -284_env_segment_is_optional_everywhere.sh 2690 -285_kernel_abi_from_graph_keeps_the_payload_c_library.sh 4110 -286_the_openkal_stack_still_builds.sh 41110 -287_the_openkal_stack_crosses_to_aarch64.sh 26910 -289_one_host_reaches_every_openkal_target.sh 65430 -28_target_static.sh 3 -290_the_declaration_puts_an_environment_in_front.sh 2000 -291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh 30790 -292_a_package_that_names_a_layer_does_not_lose_the_targets_compiler.sh 1080 -293_the_requested_target_and_the_resolved_one_name_one_os.sh 4430 -294_the_list_answers_what_can_be_built_not_what_has_a_payload.sh 53 -295_naming_the_host_target_changes_nothing.sh 1850 -296_what_the_report_names_is_what_the_link_line_uses.sh 21920 -297_a_capability_pin_is_not_a_preference.sh 414 -298_overriding_a_convention_requires_replacing_it.sh 20860 -299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh 295 -29_toolchain_partial_versions.sh 9020 -300_a_registered_family_is_not_reported_unknown.sh 56 -301_the_graphs_compiler_is_taken_and_nothing_is_written.sh 544 -302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh 131 -303_the_query_gives_one_answer_for_the_c_library.sh 351 -304_the_target_side_reaches_every_unit.sh 167 -305_built_from_is_not_published.sh 224 -306_dependency_linkage_axis.sh 498 -307_symbol_provision.sh 595 -308_dependency_required_features.sh 258 -309_host_module_identity.sh 1610 -30_dev_binary_home.sh 16 -30_pack_modes.sh 5360 -310_build_dependencies.sh 1050 -311_rule_build_dependencies.sh 2400 -312_build_rules_example.sh 2810 -313_check_stamp_on_every_platform.sh 807 -314_dependency_action_generated_header.sh 2710 -315_blocking_check_gates_compilation.sh 4860 -316_link_unit_with_no_inputs_is_refused.sh 225 -317_project_build_hooks.sh 26560 -31_transitive_deps.sh 118 -321_workspace_inheritance.sh 812 -322_dialect_flag_refusal.sh 10330 -323_post_link_record_durability.sh 684 -324_fast_path_sees_path_deps.sh 3510 -325_system_toolchain_refused.sh 193 -326_dependency_standard_floor.sh 782 -327_declared_xlings_deps_report_their_own_failure.sh 4620 -328_layer_predicates_apply_and_unknown_ones_say_so.sh 1190 -329_exit_codes_match_the_contract.sh 25 -32_semver_merge.sh 5420 -330_runner_hosted_targets.sh 1680 -331_xlings_workspace_one_table.sh 1380 -333_named_runners_locked_and_sbom.sh 4290 -334_dependency_declared_tool_is_found.sh 764 -335_xlings_tool_tiers.sh 59 -337_run_takes_features_and_profile.sh 1790 -339_run_reports_the_program_status.sh 1380 -33_multi_version_mangling.sh 10440 -340_no_orphan_survives_a_killed_mcpp.sh 11170 -35_workspace.sh 1930 -36_llvm_toolchain.sh 873 -37_llvm_import_std.sh 2650 -38_llvm_modules.sh 4010 -38_self_config_mirror.sh 20 -39_llvm_incremental.sh 4740 -39_xlings_index_migration.sh 169 -40_llvm_bmi_cache.sh 3790 -41_llvm_std_compat.sh 1980 -42_custom_local_index.sh 9900 -43_indices_lockfile.sh 9400 -44_indices_e2e_integration.sh 7680 -45_install_platform_mapping.sh 11 -46_self_config_mirror_no_bootstrap.sh 13 -47_cdb_prebuilt_module_path_abs.sh 1400 -47_llvm_atomic_link.sh 1000 -48_build_error_output.sh 971 -49_bmi_cache_nested_custom_index.sh 5970 -50_package_owned_build_flags.sh 575 -51_package_generated_files.sh 375 -52_local_path_namespaced_index.sh 468 -53_namespaced_cache_label.sh 713 -54_package_owned_ldflags.sh 907 -55_dependency_shared_artifact.sh 440 -56_transitive_shared_artifact.sh 416 -57_static_dep_shared_artifact.sh 153 -58_preinstall_mcpp_deps_for_hooks.sh 523 -59_cpp_standard_config.sh 6250 -600_accel_variant_selection.sh 392 -601_exclusive_capability.sh 175 -603_version_floor.sh 293 -604_offline_skips_sandbox_bootstrap.sh 131 -605_probe_channel_and_accel_reach_the_build_program.sh 1560 -606_constrained_source_globs_narrow_to_the_build.sh 805 -607_chained_actions_form_the_device_link.sh 917 -608_object_actions_reach_a_static_library.sh 547 -609_clean_stale.sh 4230 -609_shader_sources_are_device_sources.sh 1150 -60_stale_xpkg_cache_reinstall.sh 722 -610_feature_controlled_rule_collection.sh 3770 -611_variant_switch_is_not_replayed.sh 511 -612_build_program_file_input_fast_path.sh 704 -613_sycl_sources_are_device_sources.sh 1180 -614_feature_xlings_reaches_xpkg_dir.sh 705 -615_feature_deps_shared_library_is_linked.sh 1140 -616_examples_and_their_index_agree.sh 246 -617_build_program_knows_the_cxx_stdlib.sh 942 -618_dependency_feature_tools_reach_its_build_program.sh 26010 -619_a_tool_may_not_be_conditioned_on_a_layer.sh 44 -61_dependency_visibility.sh 600 -620_link_flag_reaches_the_link_line.sh 600 -621_exports_narrows_the_symbol_surface.sh 12360 -622_rule_declared_payload_reaches_the_consumers_build_program.sh 54630 -623_device_sources_must_reach_an_action.sh 651 -624_a_missing_host_module_names_the_key.sh 585 -625_a_payload_can_be_gated_on_the_accelerator.sh 44636 -626_naming_a_subset_of_backends_is_not_a_mismatch.sh 838 -627_one_package_one_version.sh 53560 -628_a_pin_below_a_stated_floor_is_refused.sh 26940 -629_a_version_range_is_answered_by_xpkg_dir.sh 27460 -62_dotted_dependency_selector_priority.sh 669 -62_runtime_library_dirs.sh 2160 -630_a_rule_brings_its_own_environment.sh 50180 -631_action_depfile_tracks_a_runtime_discovered_include.sh 2020 -632_a_build_program_is_told_its_package_name.sh 638 -633_host_modules_are_ordered_by_what_they_import.sh 1250 -634_a_private_module_fragment_is_not_a_partition.sh 167 -635_a_byte_order_mark_does_not_hide_a_module.sh 415 -636_a_module_extension_file_need_not_provide_a_module.sh 192 -637_a_conditional_runtime_table_alone_is_applied.sh 617 -638_pack_format_dispatch.sh 3900 -639_the_scanner_does_not_read_inside_a_comment.sh 1640 -63_bare_dependency_peer_root_priority.sh 822 -640_a_capability_pin_explains_its_own_row.sh 5820 -641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh 35480 -643_windows_subsystem_cross.sh 1270 -644_windows_keys_are_inert_elsewhere.sh 995 -645_the_fast_path_compares_the_toolchain_request.sh 498 -646_runtime_deploy_places_files_in_a_directory.sh 1680 -647_abi_threads_is_one_switch_for_the_graph.sh 620 -648_an_install_hook_sees_the_build_target.sh 547 -649_pack_carries_runtime_deploy_files.sh 2820 -64_shared_soname_runtime_alias.sh 792 -650_wasm_row_names_its_launcher.sh 25 -651_a_build_program_deploys_what_it_generated.sh 1370 -652_an_application_is_a_kind.sh 579 -653_exceptions_is_the_second_abi_member.sh 5 -654_a_requirement_on_the_target_axis.sh 875 -656_run_hands_the_distributable_to_the_runner.sh 1020 -657_a_build_program_under_the_web_row_uses_the_host_compiler.sh 3 -659_an_action_declaration_has_no_size_limit.sh 1590 -65_toolchain_runtime_dirs_for_run.sh 922 -660_an_action_names_the_engine_through_mcpp_self.sh 1400 -661_one_identity_two_declarations.sh 707 -662_pack_stages_declared_files_before_the_closure.sh 5200 -663_a_graph_libcxx_over_the_payloads_c_library.sh 13860 -665_a_host_tool_follows_its_source.sh 8580 -669_an_elf_host_pack_states_its_closure.sh 1130 -66_runtime_provides.sh 1080 -670_an_rpath_loader_token_is_left_as_written.sh 181 -671_toolchain_is_an_option_of_run_test_and_pack.sh 3890 -672_a_runner_receives_the_files_the_artifact_carries.sh 8109 -673_test_discover_names_where_tests_are.sh 1440 -674_a_format_is_run_by_the_runner_named_after_it.sh 1600 -677_a_conditional_dependency_replaces_the_unconditional_one.sh 816 -678_a_row_states_a_library_form.sh 890 -679_a_path_dependency_takes_its_manifests_identity.sh 633 -67_features_strict.sh 7740 -681_index_repos_reach_an_existing_home.sh 283 -682_why_deps_prints_the_resolved_graph.sh 232 -683_a_build_program_reads_the_payload_pkg_config_view.sh 705 -684_a_runner_supplied_twice_is_refused.sh 838 -685_a_nested_test_loads_a_graph_shared_library.sh 438 -686_an_activated_xlings_subos_does_not_redirect_the_registry.sh 10470 -687_the_vendored_xlings_probe_is_an_argument_vector.sh 343 -688_emit_build_database.sh 5420 -689_pack_takes_features.sh 2370 -68_profile_passthrough.sh 7130 -690_a_shared_library_over_a_graph_cxx_runtime.sh 18760 -692_a_package_states_its_default_link_form.sh 1800 -693_the_root_build_program_reads_dependency_link_forms.sh 4470 -696_a_cxx_layer_provider_keeps_its_own_standard.sh 25180 -698_a_tool_source_under_its_dependency_has_a_bounded_address.sh 4590 -69_package_templates.sh 11350 -700_a_program_over_a_cxx_shared_library_has_one_cxx_runtime.sh 928 -701_symbol_provision_does_not_report_what_the_build_shares_by_construction.sh 1360 -702_a_static_package_under_a_shared_library_is_linked_into_it.sh 752 -70_per_target_flags.sh 176 -710_a_forward_reaches_every_dependency_table.sh 580 -711_a_feature_deps_restatement_names_one_source.sh 4610 -712_a_tool_package_contributes_nothing_to_its_consumer.sh 6270 -713_a_git_dependency_selects_a_repository_member.sh 5270 -714_features_names_a_dependency_feature.sh 6070 -71_required_features_gate.sh 509 -720_the_root_build_program_reads_the_resolved_graph.sh 1670 -722_pack_strips_what_the_graph_built.sh 5890 -723_pack_reports_what_it_produced.sh 1110 -72_test_profile_features.sh 612 -730_bare_compat_dependency_is_not_a_refresh_miss.sh 105 -731_planning_children_do_not_inherit_the_callers_pipe.sh 583 -732_xlings_refresh_is_bounded_and_owned.sh 4180 -733_envelope_reports_observed_network_access.sh 312 -734_auto_refresh_false_governs_first_index_sync.sh 148 -735_offline_missing_package_has_its_own_code.sh 82 -736_compile_flag_words_reach_the_compiler_and_the_databases.sh 436 -737_the_declared_runtime_payload_is_installed.sh 58840 -739_a_private_feature_dep_does_not_reach_the_consumer.sh 328 -73_issue131_per_target_cxxflag.sh 252 -740_gcc_over_a_graph_c_library_is_degraded_not_refused.sh 155 -741_c_abi_environment_declared_by_the_c_library.sh 462 -742_gcc_accepted_when_the_realisation_is_empty.sh 178 -743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh 285 -744_a_declared_absence_explains_the_link_that_asked_for_it.sh 176 -745_no_run_builds_the_tests_and_says_so.sh 536 -746_macos_deployment_target_is_target_keyed_not_host_keyed.sh 397 -74_run_no_loader_env_leak.sh 1140 -75_index_status_offline.sh 8119 -765_a_consumer_include_directory_stays_in_the_consumer.sh 694 -76_compile_commands_generated.sh 1310 -76_qualified_custom_ns_noncanonical_filename.sh 490 -770_workspace_member_as_dependency.sh 1040 -772_a_published_member_is_self_contained.sh 4520 -773_commands_outside_the_build_read_the_effective_manifest.sh 938 -774_an_index_member_inherits_its_archive_workspace.sh 685 -775_a_host_tool_merges_its_conditional_sections_once.sh 4260 -776_a_path_with_no_utf8_spelling_is_named.sh 389 -777_c_standard_applies_to_the_package_that_declares_it.sh 186 -778_a_graph_link_searches_no_host_directory.sh 31510 -779_a_build_program_declares_a_runtime_search_dir.sh 1840 -77_cdb_preserves_test_entries.sh 4330 -780_a_passing_check_moves_its_stamp_past_a_changed_input.sh 1720 -781_deleted_root_cdb_is_restored.sh 2200 -782_foreign_root_cdb_entries_are_replaced.sh 3300 -783_cdb_switches_whole_with_the_configuration.sh 3270 -784_cdb_replays_from_its_directory.sh 443 -785_cdb_interface_flag_module_extensions.sh 368 -786_std_unit_in_the_database_and_build_id.sh 786 -787_emit_plans_every_member_on_its_own.sh 216 -788_emit_defers_an_unbuilt_host_tool.sh 1520 -789_emit_describes_a_member_past_a_failed_build_program.sh 655 -78_test_main_combinations.sh 10910 -790_a_prepare_action_populates_an_unknown_directory.sh 2080 -791_a_prepare_action_that_creates_nothing_fails.sh 1030 -792_a_rerun_input_inside_a_prepare_directory_is_warned.sh 1680 -793_an_unknown_action_role_is_refused.sh 2530 -795_a_link_flag_reaches_the_linker_as_written.sh 1050 -798_a_rule_applies_through_a_source_it_claims.sh 10570 -799_an_action_runs_with_its_env_and_cwd.sh 1090 -79_gtest_regular_dep_feature_main.sh 11370 -800_a_feature_provides_its_host_tools.sh 1050 -801_a_dependency_program_is_shipped_with_the_consumer.sh 1090 -802_a_host_build_applies_its_host_row.sh 1400 -803_a_recorded_payload_that_is_gone_is_refused_offline.sh 80 -804_a_path_host_tool_builds_with_its_chosen_toolchain.sh 2140 -805_rooted_workspace_reaches_its_own_path_dependency.sh 598 -806_dash_p_resolves_the_package_first.sh 861 -807_a_lib_root_imports_its_own_package.sh 777 -808_device_source_is_not_a_compile_unit.sh 13580 -809_failed_build_program_hides_no_device_source.sh 433 -80_feature_defines.sh 270 -810_two_packages_deploy_the_same_bytes_to_one_name.sh 1100 -812_an_index_floor_is_a_closing_tip.sh 214 -813_conditional_dialect_cxxflags_root_only.sh 22660 -815_the_database_describes_what_a_rule_generates.sh 1200 -816_a_git_dependency_reports_its_fetch.sh 197 -817_emit_writes_no_xlings_json_under_private_work_dir.sh 252 -818_a_declared_deploy_outranks_a_search_dir_dll_cross.sh 1570 -819_the_crt_is_placed_by_its_rule_not_by_search_order.sh 303 -81_capability_binding.sh 593 -821_an_edge_states_its_advice_on_both_paths.sh 1620 -822_a_package_states_the_oldest_mcpp_it_supports.sh 266 -823_mcpp_core_and_mcpp_name_one_interface.sh 856 -824_a_build_program_reads_the_resolved_toolchain.sh 791 -826_plugin_diagnostics_features_and_names.sh 1020 -827_many_placements_run_as_one_process.sh 2650 -828_a_library_states_its_interface.sh 697 -829_pack_takes_a_member.sh 341 -82_feature_optional_deps.sh 316 -831_a_path_dependency_host_module_outside_src.sh 5000 -832_the_fast_path_resumes_after_an_edit.sh 2630 -833_a_workspace_is_one_graph_per_configuration.sh 1780 -834_a_workspace_plans_once_and_replays_in_one_check.sh 872 -835_a_shared_library_is_placed_by_a_link.sh 297 -836_a_members_own_statements_reach_its_build.sh 1440 -837_a_members_resources_reach_its_image.sh 155 -838_a_program_shipped_through_artifacts_links_its_own_closure.sh 425 -839_a_workspaces_build_programs_are_named_ordered_and_cached.sh 1610 -83_feature_defines_propagate.sh 166 -840_a_workspace_database_describes_each_package_once.sh 850 -841_pack_summarizes_many_outputs.sh 1510 -842_the_build_reports_each_step_once.sh 7560 -843_a_terminal_names_the_action_that_runs.sh 4550 -844_a_planned_build_keeps_one_lock_entry_per_identity.sh 943 -845_play_game_restores_the_terminal.sh 11710 -846_the_vendored_xlings_is_replaced_from_the_newest_source.sh 380 -847_a_module_name_is_unique_within_a_program.sh 1980 -849_a_staged_bmi_waits_for_a_module_compiled_here.sh 1870 -84_runtime_dll_deploy.sh 322 -850_a_selected_member_declares_as_the_root.sh 1000 -851_a_shared_member_is_compiled_once_per_configuration.sh 744 -852_a_repeated_dash_p_selects_every_member_it_names.sh 256 -853_run_keeps_one_member_and_exclude_removes_members.sh 1330 -854_a_test_over_several_members_plans_and_builds_once.sh 1760 -855_a_shared_test_build_is_reported_once_per_group.sh 329 -856_a_test_over_several_members_continues_past_a_failing_one.sh 1540 -857_a_host_module_several_members_import_is_compiled_once.sh 878 -858_the_engines_module_is_compiled_once_per_home.sh 4400 -859_a_host_module_entry_is_reused_while_its_inputs_agree.sh 4110 -85_target_cfg_build_flags.sh 155 -860_a_workspaces_programs_compile_at_the_same_time.sh 13460 -861_concurrent_compiles_plan_what_serial_compiles_plan.sh 1390 -862_run_q_writes_exactly_the_programs_stdout.sh 399 -863_a_run_whose_build_failed_exits_101.sh 992 -864_a_builds_status_is_on_standard_error.sh 888 -865_mcpp_test_reports_its_steps_on_stderr_and_its_results_on_stdout.sh 742 -866_both_streams_on_one_pipe_keep_their_order_and_json_stays_clean.sh 4080 -867_a_pack_over_several_members_plans_builds_and_runs_programs_once.sh 3340 -868_a_pack_over_several_members_is_refused_before_anything_is_compiled.sh 4040 -869_a_pack_over_several_members_names_each_member_in_its_report.sh 1470 -86_llvm_hermetic_link.sh 2690 -86_target_cfg_dependencies.sh 285 -870_a_pack_over_several_members_resolves_and_fails_per_member.sh 2700 -871_a_pack_states_its_build_and_every_drive_takes_the_job_count.sh 10380 -872_a_member_compiles_once_whatever_the_selection.sh 2570 -873_payload_overrides.sh 1700 -874_payload_on_request.sh 2890 -875_toolchain_by_path.sh 634 -876_toolchain_phase.sh 1610 -877_why_sources.sh 133 -878_an_upgraded_engine_does_not_replay_the_old_graph.sh 2110 -879_a_providers_module_extension_reaches_the_fast_path.sh 4900 -87_build_default_profile.sh 631 -880_a_members_executable_keeps_its_own_implementation.sh 2210 -882_parent_directory_glob_inputs.sh 7430 -883_run_hands_the_terminal_to_the_program.sh 3380 -884_a_shared_library_links_with_its_own_closure.sh 1370 -885_workspace_profiles_and_the_root_package.sh 977 -886_std_compat_is_built_when_imported.sh 6050 -88_xlings_environment.sh 259 -890_a_toolchain_declared_for_an_unservable_target_is_still_installed.sh 22 -891_a_host_header_is_written_not_searched.sh 558 -893_the_workspace_prefix_carries_the_meaning.sh 819 -894_the_position_reading_is_kept_and_reported.sh 898 -895_conditional_configuration_rows_count_as_they_evaluate.sh 821 -896_an_action_output_has_one_writer.sh 5630 -897_each_configuration_has_its_own_generated_output.sh 4980 -898_module_units_in_cpp_files.sh 10390 -899_toolchain_flag_replacing_a_declared_toolchain_says_so.sh 894 -89_build_mcpp.sh 2680 -900_publish_writes_the_workspace_layer_into_the_member.sh 342 -901_the_toolchain_library_path_stays_with_the_tools.sh 3020 -902_a_build_dependency_library_is_compiled_as_its_package.sh 5490 -903_one_package_in_both_tables_has_two_instances.sh 9580 -904_a_build_dependency_feature_stays_out_of_the_target.sh 4850 -905_a_build_program_links_its_libraries_whole.sh 5230 -906_a_build_program_is_compiled_when_its_graph_changes.sh 8070 -907_the_library_interface_is_stated_once.sh 728 -908_what_a_build_dependency_asks_for.sh 5000 -909_deprecated_readings_are_registered_and_kept.sh 5660 -90_workspace_test.sh 789 -910_a_build_plugin_is_a_package_of_the_build_program.sh 8590 -911_a_workspace_states_each_table_once.sh 971 -912_two_configuration_groups_share_the_build_programs_graph.sh 5780 -913_the_build_database_describes_the_build_programs.sh 6370 -914_an_upstream_home_is_linked_never_written.sh 15410 -915_the_system_home_is_maintained_and_read.sh 169 -916_a_home_from_before_keeps_working.sh 228 -918_an_older_packaging_is_used_not_replaced.sh 12410 -919_a_home_selection_is_remembered_once.sh 144 -91_target_bare_alias.sh 365 -920_the_doctor_checks_resources_without_bootstrap.sh 2160 -921_emit_watches_inputs_not_host_outputs.sh 1020 -923_cross_dependency_images_keep_their_resources.sh 802 -924_visible_imports_resolve_transitive_modules.sh 517 -925_a_stated_sysroot_is_the_c_library.sh 21350 -926_the_hosts_own_root_is_a_base.sh 8260 -927_the_runner_contract.sh 677 -928_an_apple_sdk_is_located_on_any_host.sh 298 -929_release_trims_paths_and_defines_ndebug.sh 3230 -92_build_mcpp_import.sh 285 -930_a_toolchain_phase_states_the_base.sh 3900 -931_a_suggested_command_quotes_its_path.sh 17310 -932_a_fresh_home_with_a_space_and_non_ascii_builds.sh 40640 -933_one_cxx_runtime_per_process.sh 2460 -934_llvm_with_a_graph_supplied_libstdcxx.sh 3680 -935_two_builds_of_one_configuration.sh 31770 -936_the_control_contract.sh 17950 -937_the_root_compile_database_is_opt_in.sh 4400 -938_a_relink_never_exposes_a_partial_file.sh 2009 -939_a_lock_written_meanwhile_is_not_overwritten.sh 1270 -93_xpkg_parse.sh 66 -940_an_indirect_import_is_found_whatever_map_its_importer_has.sh 599 -941_a_closed_reader_ends_mcpp_and_no_child_inherits_the_ignore.sh 365 -942_an_ok_image_is_built_once_and_run_by_okrun.sh 61130 -943_a_transitive_git_branch_is_locked.sh 623 -944_a_root_checkout_answers_a_version_the_index_lacks.sh 432 -945_a_miss_anywhere_in_the_graph_asks_for_a_refresh.sh 390 -946_a_program_begins_in_a_dependency_entry.sh 7450 -947_an_image_is_entered_at_one_stated_symbol.sh 2230 -948_an_upgraded_dependency_is_restaged_at_the_same_size.sh 6990 -949_a_version_the_index_lacks_refreshes_at_install.sh 4220 -950_a_kernel_abi_package_over_the_system_c_library_is_in_the_shared_image.sh 397 -951_an_ok_image_needs_no_declaration.sh 157570 -952_a_platform_package_states_a_class_of_targets.sh 1840 -953_the_host_graph_runs_build_programs_as_images.sh 30120 -954_pack_has_three_axes.sh 910 -955_a_universal_package_holds_every_architecture.sh 360000 -956_a_single_file_runs_without_unpacking.sh 117250 -957_packages_merge_and_describe_themselves.sh 93760 -958_a_build_program_runs_again_when_its_output_is_gone.sh 1260 -959_a_package_is_keyed_by_every_module_it_imports.sh 25000 -960_an_image_of_another_architecture_runs_through_its_execution_host.sh 150000 -961_a_one_target_package_carries_the_image_in_a_program_of_its_architecture.sh 120000 -962_a_webassembly_package_is_neither_an_xlings_entry_nor_a_single_file.sh 1000 -963_a_target_set_builds_every_member_and_runs_where_it_is_reached.sh 58687 -964_tests_built_once_run_from_an_archive.sh 89194 -96_msvc_unavailable_nonwindows.sh 38 -980_an_ambiguous_dependency_name_is_no_packages.sh 2257 -981_a_web_link_has_the_profiles_level_and_the_wasm_exception_model.sh 5 -982_an_action_reads_the_builds_contract.sh 815 -983_a_pack_holds_the_legs_it_names.sh 155041 -984_a_set_of_programs_is_a_package_per_leg.sh 16305 -985_a_format_list_writes_each_container_of_one_tree.sh 240000 -986_single_files_cover_the_legs_and_systems.sh 240000 -987_the_universal_spellings_are_migrated.sh 107535 -988_the_windows_launcher_finds_its_package_from_a_windows_path.sh 5 -98_reflection_import_std.sh 7170 -990_a_lock_key_does_not_depend_on_the_selected_members.sh 20000 -991_a_prebuilt_archive_in_ldflags_is_a_link_input.sh 30000 -992_a_polyglot_file_runs_on_every_system_it_holds.sh 240000 -993_a_test_describes_its_launch.sh 30000 -994_the_contract_names_the_target_side.sh 60000 -995_the_windows_launcher_builds_over_any_platform_release.sh 240000 -996_an_ok_image_waits_for_a_prepare_action.sh 15000 -997_a_members_dev_dependencies_stay_out_of_a_host_tool.sh 60000 -998_a_build_keeps_its_bytes_once.sh 120000 -999_debug_information_is_stored_once.sh 30000 -1000_a_configuration_keeps_one_directory.sh 40000 diff --git a/tests/e2e/timings/macos.tsv b/tests/e2e/timings/macos.tsv deleted file mode 100644 index f931f8124..000000000 --- a/tests/e2e/timings/macos.tsv +++ /dev/null @@ -1,262 +0,0 @@ -00_fixture_path_hygiene.sh 7000 -01_help_and_version.sh 0 -02_new_build_run.sh 5000 -04_incremental.sh 8000 -05_errors.sh 4000 -06_emit_xpkg.sh 0 -100_feature_sources_test_mode.sh 7000 -101_cppfly_llvm_soft.sh 5000 -10_env_command.sh 1000 -117_object_path_collision.sh 2000 -118_purview_include_rebuild.sh 15000 -11_index_list.sh 8000 -122_run_member.sh 4000 -126_default_features_opt_out.sh 5000 -128_feature_forwarding.sh 3000 -12_add_command.sh 2000 -138_feature_sources_gate_vs_provider.sh 7000 -139_build_program_advisory.sh 9000 -13_toolchain_pin.sh 0 -148_wide_include_list.sh 2000 -149_conditional_glob_flags.sh 4000 -14_toolchain_fallback.sh 6000 -150_clang_module_operator_template.sh 1000 -151_index_url_artifact_migration.sh 1000 -152_test_subdir_names.sh 2000 -153_test_isolation.sh 3000 -154_test_filter.sh 3000 -155_test_json.sh 2000 -156_nested_ld_library_path.sh 0 -157_test_glob_flags.sh 1000 -158_test_signal_and_duration.sh 3000 -159_test_list.sh 0 -15_test_passing.sh 3000 -160_test_timeout.sh 7000 -161_xpkg_name_form.sh 0 -162_bare_name_namespace_scope.sh 9000 -163_identity_first_resolution.sh 15000 -164_ninja_goal_command_length.sh 3000 -165_bare_name_cross_namespace_wire_address.sh 7000 -166_run_env_no_private_glibc.sh 2000 -167_build_defines_module_scan.sh 6000 -16_test_failing.sh 3000 -170_bmi_staging_no_cascade.sh 3000 -171_bmi_staging_locked_dest.sh 4000 -178_test_observability.sh 17000 -179_spaced_paths.sh 4000 -17_test_no_tests.sh 0 -181_build_mcpp_import_std.sh 9000 -183_cxx_runtime_contract.sh 11000 -18_devdeps_isolation.sh 2000 -190_link_rspfile_newlines.sh 4000 -191_link_scale.sh 43000 -195_target_cfg_feature_deps.sh 4000 -199_resources_validation.sh 6000 -19_bmi_cache_reuse.sh 12000 -202_machine_output_contract.sh 3000 -204_new_transactional_scaffold.sh 6000 -210_local_index_addressing_on_every_host.sh 1000 -211_configure_only_cdb.sh 9000 -221_subos_without_info_still_builds.sh 3000 -230_bench_harness.sh 60000 -231_jobs_option.sh 18000 -232_workflow_syntax.sh 0 -233_bench_matrix.sh 1000 -234_bmi_schedule_on.sh 3000 -236_module_extensions_default_toolchain.sh 4000 -23_remove_update.sh 16000 -242_pack_library_interface_and_headers.sh 8000 -243_pack_library_interface_closure.sh 8000 -244_pack_library_gate.sh 7000 -246_explicit_empty_sources.sh 4000 -247_bare_triple_conditional_native.sh 5000 -249_pack_workspace_root_unchanged.sh 2000 -24_git_dependency.sh 12000 -250_pack_names_the_target.sh 3000 -251_pack_library_shared.sh 2000 -252_pack_library_old_client.sh 4000 -253_pack_library_undetermined_partition.sh 5000 -254_pack_library_platform_coverage.sh 6000 -259_shared_library_macho.sh 9000 -25_convention_mode.sh 11000 -260_module_extension_is_configuration.sh 3000 -261_pack_follows_the_module_extension.sh 4000 -263_lib_root_follows_the_extension.sh 7000 -266_pack_reads_a_macho_program_without_running_it.sh 4000 -267_target_requested_is_empty_for_a_native_build.sh 5000 -268_target_side_resolution.sh 8000 -269_openkal_llvm_spelling_still_resolves.sh 5000 -27_namespace_dependencies.sh 8000 -280_target_side_layers.sh 4000 -299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh 6000 -300_a_registered_family_is_not_reported_unknown.sh 0 -301_the_graphs_compiler_is_taken_and_nothing_is_written.sh 2000 -302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh 2000 -303_the_query_gives_one_answer_for_the_c_library.sh 5000 -304_the_target_side_reaches_every_unit.sh 2000 -305_built_from_is_not_published.sh 2000 -313_check_stamp_on_every_platform.sh 3000 -317_project_build_hooks.sh 42000 -327_declared_xlings_deps_report_their_own_failure.sh 7000 -328_layer_predicates_apply_and_unknown_ones_say_so.sh 10000 -329_exit_codes_match_the_contract.sh 1000 -32_semver_merge.sh 6000 -330_runner_hosted_targets.sh 13000 -331_xlings_workspace_one_table.sh 2000 -35_workspace.sh 3000 -36_llvm_toolchain.sh 3000 -37_llvm_import_std.sh 4000 -38_llvm_modules.sh 5000 -38_self_config_mirror.sh 0 -39_llvm_incremental.sh 8000 -39_xlings_index_migration.sh 0 -40_llvm_bmi_cache.sh 7000 -41_llvm_std_compat.sh 4000 -42_custom_local_index.sh 10000 -43_indices_lockfile.sh 8000 -44_indices_e2e_integration.sh 10000 -45_install_platform_mapping.sh 0 -46_self_config_mirror_no_bootstrap.sh 0 -47_cdb_prebuilt_module_path_abs.sh 2000 -47_llvm_atomic_link.sh 0 -48_build_error_output.sh 3000 -601_exclusive_capability.sh 2000 -603_version_floor.sh 4000 -604_offline_skips_sandbox_bootstrap.sh 3000 -609_clean_stale.sh 7000 -616_examples_and_their_index_agree.sh 2000 -628_a_pin_below_a_stated_floor_is_refused.sh 78000 -645_the_fast_path_compares_the_toolchain_request.sh 7000 -646_runtime_deploy_places_files_in_a_directory.sh 8000 -659_an_action_declaration_has_no_size_limit.sh 7000 -660_an_action_names_the_engine_through_mcpp_self.sh 5000 -666_a_macho_program_reaches_a_dispatched_format_with_its_tree.sh 7000 -668_a_macho_program_is_staged_with_its_closure.sh 4000 -66_runtime_provides.sh 3000 -671_toolchain_is_an_option_of_run_test_and_pack.sh 7000 -672_a_runner_receives_the_files_the_artifact_carries.sh 22000 -673_test_discover_names_where_tests_are.sh 2000 -674_a_format_is_run_by_the_runner_named_after_it.sh 27000 -679_a_path_dependency_takes_its_manifests_identity.sh 6000 -67_features_strict.sh 13000 -681_index_repos_reach_an_existing_home.sh 1000 -682_why_deps_prints_the_resolved_graph.sh 3000 -683_a_build_program_reads_the_payload_pkg_config_view.sh 3000 -684_a_runner_supplied_twice_is_refused.sh 9000 -686_an_activated_xlings_subos_does_not_redirect_the_registry.sh 6000 -687_the_vendored_xlings_probe_is_an_argument_vector.sh 1000 -688_emit_build_database.sh 15000 -68_profile_passthrough.sh 8000 -698_a_tool_source_under_its_dependency_has_a_bounded_address.sh 21000 -69_package_templates.sh 11000 -702_a_static_package_under_a_shared_library_is_linked_into_it.sh 5000 -704_cxx_identity_across_macho_images.sh 7000 -720_the_root_build_program_reads_the_resolved_graph.sh 8000 -721_an_android_row_links_on_a_macos_host.sh 50000 -732_xlings_refresh_is_bounded_and_owned.sh 6000 -733_envelope_reports_observed_network_access.sh 1000 -734_auto_refresh_false_governs_first_index_sync.sh 0 -735_offline_missing_package_has_its_own_code.sh 0 -736_compile_flag_words_reach_the_compiler_and_the_databases.sh 4000 -739_a_private_feature_dep_does_not_reach_the_consumer.sh 6000 -746_macos_deployment_target_is_target_keyed_not_host_keyed.sh 4000 -75_index_status_offline.sh 9000 -765_a_consumer_include_directory_stays_in_the_consumer.sh 7000 -76_compile_commands_generated.sh 2000 -770_workspace_member_as_dependency.sh 8000 -774_an_index_member_inherits_its_archive_workspace.sh 2000 -775_a_host_tool_merges_its_conditional_sections_once.sh 8000 -77_cdb_preserves_test_entries.sh 7000 -780_a_passing_check_moves_its_stamp_past_a_changed_input.sh 5000 -781_deleted_root_cdb_is_restored.sh 4000 -782_foreign_root_cdb_entries_are_replaced.sh 7000 -787_emit_plans_every_member_on_its_own.sh 2000 -78_test_main_combinations.sh 9000 -799_an_action_runs_with_its_env_and_cwd.sh 6000 -79_gtest_regular_dep_feature_main.sh 13000 -809_failed_build_program_hides_no_device_source.sh 4000 -80_feature_defines.sh 3000 -812_an_index_floor_is_a_closing_tip.sh 2000 -815_the_database_describes_what_a_rule_generates.sh 8000 -816_a_git_dependency_reports_its_fetch.sh 3000 -817_emit_writes_no_xlings_json_under_private_work_dir.sh 1000 -819_the_crt_is_placed_by_its_rule_not_by_search_order.sh 1000 -81_capability_binding.sh 8000 -821_an_edge_states_its_advice_on_both_paths.sh 5000 -822_a_package_states_the_oldest_mcpp_it_supports.sh 5000 -823_mcpp_core_and_mcpp_name_one_interface.sh 9000 -824_a_build_program_reads_the_resolved_toolchain.sh 5000 -826_plugin_diagnostics_features_and_names.sh 13000 -827_many_placements_run_as_one_process.sh 10000 -828_a_library_states_its_interface.sh 8000 -829_pack_takes_a_member.sh 5000 -82_feature_optional_deps.sh 3000 -831_a_path_dependency_host_module_outside_src.sh 12000 -832_the_fast_path_resumes_after_an_edit.sh 8000 -833_a_workspace_is_one_graph_per_configuration.sh 11000 -834_a_workspace_plans_once_and_replays_in_one_check.sh 13000 -836_a_members_own_statements_reach_its_build.sh 6000 -839_a_workspaces_build_programs_are_named_ordered_and_cached.sh 12000 -83_feature_defines_propagate.sh 2000 -840_a_workspace_database_describes_each_package_once.sh 13000 -841_pack_summarizes_many_outputs.sh 10000 -842_the_build_reports_each_step_once.sh 19000 -843_a_terminal_names_the_action_that_runs.sh 8000 -845_play_game_restores_the_terminal.sh 20000 -846_the_vendored_xlings_is_replaced_from_the_newest_source.sh 1000 -847_a_module_name_is_unique_within_a_program.sh 11000 -849_a_staged_bmi_waits_for_a_module_compiled_here.sh 7000 -84_runtime_dll_deploy.sh 2000 -851_a_shared_member_is_compiled_once_per_configuration.sh 7000 -852_a_repeated_dash_p_selects_every_member_it_names.sh 5000 -853_run_keeps_one_member_and_exclude_removes_members.sh 9000 -854_a_test_over_several_members_plans_and_builds_once.sh 9000 -855_a_shared_test_build_is_reported_once_per_group.sh 4000 -856_a_test_over_several_members_continues_past_a_failing_one.sh 19000 -857_a_host_module_several_members_import_is_compiled_once.sh 6000 -858_the_engines_module_is_compiled_once_per_home.sh 21000 -859_a_host_module_entry_is_reused_while_its_inputs_agree.sh 17000 -85_target_cfg_build_flags.sh 2000 -860_a_workspaces_programs_compile_at_the_same_time.sh 23000 -861_concurrent_compiles_plan_what_serial_compiles_plan.sh 9000 -862_run_q_writes_exactly_the_programs_stdout.sh 1000 -863_a_run_whose_build_failed_exits_101.sh 10000 -864_a_builds_status_is_on_standard_error.sh 9000 -865_mcpp_test_reports_its_steps_on_stderr_and_its_results_on_stdout.sh 7000 -866_both_streams_on_one_pipe_keep_their_order_and_json_stays_clean.sh 10000 -867_a_pack_over_several_members_plans_builds_and_runs_programs_once.sh 20000 -868_a_pack_over_several_members_is_refused_before_anything_is_compiled.sh 29000 -869_a_pack_over_several_members_names_each_member_in_its_report.sh 22000 -86_llvm_hermetic_link.sh 1000 -86_target_cfg_dependencies.sh 1000 -870_a_pack_over_several_members_resolves_and_fails_per_member.sh 21000 -872_a_member_compiles_once_whatever_the_selection.sh 28000 -878_an_upgraded_engine_does_not_replay_the_old_graph.sh 11000 -879_a_providers_module_extension_reaches_the_fast_path.sh 22000 -87_build_default_profile.sh 6000 -880_a_members_executable_keeps_its_own_implementation.sh 17000 -882_parent_directory_glob_inputs.sh 23000 -883_run_hands_the_terminal_to_the_program.sh 6000 -885_workspace_profiles_and_the_root_package.sh 9000 -88_xlings_environment.sh 2000 -890_a_toolchain_declared_for_an_unservable_target_is_still_installed.sh 72000 -89_build_mcpp.sh 10000 -902_a_build_dependency_library_is_compiled_as_its_package.sh 6000 -90_workspace_test.sh 8000 -911_a_workspace_states_each_table_once.sh 11000 -912_two_configuration_groups_share_the_build_programs_graph.sh 11000 -913_the_build_database_describes_the_build_programs.sh 10000 -915_the_system_home_is_maintained_and_read.sh 1000 -916_a_home_from_before_keeps_working.sh 8000 -918_an_older_packaging_is_used_not_replaced.sh 13000 -919_a_home_selection_is_remembered_once.sh 0 -91_target_bare_alias.sh 2000 -920_the_doctor_checks_resources_without_bootstrap.sh 41000 -921_emit_watches_inputs_not_host_outputs.sh 8000 -924_visible_imports_resolve_transitive_modules.sh 3000 -928_an_apple_sdk_is_located_on_any_host.sh 1000 -92_build_mcpp_import.sh 3000 -931_a_suggested_command_quotes_its_path.sh 16000 -93_xpkg_parse.sh 0 -94_macos_test_stdlib.sh 2000 -96_msvc_unavailable_nonwindows.sh 2000 diff --git a/tests/e2e/timings/windows.tsv b/tests/e2e/timings/windows.tsv deleted file mode 100644 index 8eb6380c1..000000000 --- a/tests/e2e/timings/windows.tsv +++ /dev/null @@ -1,182 +0,0 @@ -00_fixture_path_hygiene.sh 33892 -01_help_and_version.sh 184 -04_incremental.sh 9490 -05_errors.sh 2616 -06_emit_xpkg.sh 211 -100_feature_sources_test_mode.sh 10409 -101_cppfly_llvm_soft.sh 63285 -10_env_command.sh 7244 -117_object_path_collision.sh 11002 -118_purview_include_rebuild.sh 14229 -11_index_list.sh 5582 -122_run_member.sh 2332 -126_default_features_opt_out.sh 2747 -128_feature_forwarding.sh 2165 -12_add_command.sh 2435 -13_toolchain_pin.sh 112 -148_wide_include_list.sh 4911 -149_conditional_glob_flags.sh 2369 -14_toolchain_fallback.sh 8046 -150_clang_module_operator_template.sh 497 -151_index_url_artifact_migration.sh 10016 -152_test_subdir_names.sh 874 -153_test_isolation.sh 1594 -154_test_filter.sh 1385 -155_test_json.sh 1233 -156_nested_ld_library_path.sh 86 -157_test_glob_flags.sh 887 -159_test_list.sh 282 -15_test_passing.sh 1669 -161_xpkg_name_form.sh 632 -162_bare_name_namespace_scope.sh 10735 -163_identity_first_resolution.sh 38467 -164_ninja_goal_command_length.sh 1749 -165_bare_name_cross_namespace_wire_address.sh 10356 -166_run_env_no_private_glibc.sh 978 -167_build_defines_module_scan.sh 3560 -16_test_failing.sh 1861 -170_bmi_staging_no_cascade.sh 1813 -171_bmi_staging_locked_dest.sh 4057 -177_cpp20_msvc.sh 9276 -179_spaced_paths.sh 3756 -17_test_no_tests.sh 235 -180_msvc_build_mcpp.sh 22389 -183_cxx_runtime_contract.sh 9771 -18_devdeps_isolation.sh 1459 -190_link_rspfile_newlines.sh 3424 -191_link_scale.sh 27144 -195_target_cfg_feature_deps.sh 3223 -197_windows_resources.sh 8404 -199_resources_validation.sh 1740 -19_bmi_cache_reuse.sh 68995 -202_machine_output_contract.sh 2952 -204_new_transactional_scaffold.sh 9113 -210_local_index_addressing_on_every_host.sh 1074 -211_configure_only_cdb.sh 7706 -221_subos_without_info_still_builds.sh 2254 -230_bench_harness.sh 71394 -231_jobs_option.sh 9198 -232_workflow_syntax.sh 304 -233_bench_matrix.sh 653 -234_bmi_schedule_on.sh 4292 -236_module_extensions_default_toolchain.sh 3768 -23_remove_update.sh 20063 -241_windows_ucrt_runtime_identity.sh 18807 -242_pack_library_interface_and_headers.sh 4069 -243_pack_library_interface_closure.sh 8029 -244_pack_library_gate.sh 8759 -246_explicit_empty_sources.sh 1734 -247_bare_triple_conditional_native.sh 2950 -249_pack_workspace_root_unchanged.sh 1248 -250_pack_names_the_target.sh 1736 -251_pack_library_shared.sh 1204 -252_pack_library_old_client.sh 2007 -253_pack_library_undetermined_partition.sh 3015 -254_pack_library_platform_coverage.sh 3324 -255_pack_library_msvc_archiver.sh 2483 -256_pack_library_fat_windows.sh 5554 -258_shared_library_msvc_auto_def.sh 7734 -25_convention_mode.sh 5300 -260_module_extension_is_configuration.sh 1544 -261_pack_follows_the_module_extension.sh 2876 -262_pack_consumed_by_native_cl.sh 2052 -263_lib_root_follows_the_extension.sh 7435 -280_target_side_layers.sh 3141 -313_check_stamp_on_every_platform.sh 5557 -317_project_build_hooks.sh 27463 -327_declared_xlings_deps_report_their_own_failure.sh 10378 -328_layer_predicates_apply_and_unknown_ones_say_so.sh 4863 -329_exit_codes_match_the_contract.sh 305 -35_workspace.sh 2263 -36_llvm_toolchain.sh 114148 -38_self_config_mirror.sh 64649 -39_xlings_index_migration.sh 66344 -42_custom_local_index.sh 8209 -43_indices_lockfile.sh 11686 -44_indices_e2e_integration.sh 12795 -45_install_platform_mapping.sh 206 -46_self_config_mirror_no_bootstrap.sh 7828 -47_cdb_prebuilt_module_path_abs.sh 1915 -48_build_error_output.sh 63119 -601_exclusive_capability.sh 1806 -603_version_floor.sh 2585 -604_offline_skips_sandbox_bootstrap.sh 1823 -609_clean_stale.sh 6691 -628_a_pin_below_a_stated_floor_is_refused.sh 49452 -642_windows_subsystem.sh 5247 -645_the_fast_path_compares_the_toolchain_request.sh 110810 -646_runtime_deploy_places_files_in_a_directory.sh 5219 -659_an_action_declaration_has_no_size_limit.sh 11091 -660_an_action_names_the_engine_through_mcpp_self.sh 2681 -66_runtime_provides.sh 2221 -671_toolchain_is_an_option_of_run_test_and_pack.sh 3428 -673_test_discover_names_where_tests_are.sh 2238 -679_a_path_dependency_takes_its_manifests_identity.sh 3719 -67_features_strict.sh 8619 -681_index_repos_reach_an_existing_home.sh 10633 -682_why_deps_prints_the_resolved_graph.sh 1655 -683_a_build_program_reads_the_payload_pkg_config_view.sh 1509 -687_the_vendored_xlings_probe_is_an_argument_vector.sh 64285 -688_emit_build_database.sh 74035 -68_profile_passthrough.sh 5954 -698_a_tool_source_under_its_dependency_has_a_bounded_address.sh 13584 -69_package_templates.sh 18548 -703_the_llvm_row_on_the_msvc_abi_records_its_static_crt.sh 6359 -705_cxx_identity_across_pe_images.sh 5435 -720_the_root_build_program_reads_the_resolved_graph.sh 5906 -735_offline_missing_package_has_its_own_code.sh 640 -736_compile_flag_words_reach_the_compiler_and_the_databases.sh 2679 -739_a_private_feature_dep_does_not_reach_the_consumer.sh 2267 -75_index_status_offline.sh 6268 -760_msvc_toolset_is_chosen_once.sh 8601 -765_a_consumer_include_directory_stays_in_the_consumer.sh 68276 -76_compile_commands_generated.sh 1575 -770_workspace_member_as_dependency.sh 4873 -774_an_index_member_inherits_its_archive_workspace.sh 65596 -775_a_host_tool_merges_its_conditional_sections_once.sh 75532 -77_cdb_preserves_test_entries.sh 4293 -780_a_passing_check_moves_its_stamp_past_a_changed_input.sh 2885 -781_deleted_root_cdb_is_restored.sh 2778 -782_foreign_root_cdb_entries_are_replaced.sh 4060 -787_emit_plans_every_member_on_its_own.sh 1293 -78_test_main_combinations.sh 10826 -794_a_windows_program_finds_a_dll_through_runtime_search_dir.sh 2965 -799_an_action_runs_with_its_env_and_cwd.sh 2956 -79_gtest_regular_dep_feature_main.sh 11982 -809_failed_build_program_hides_no_device_source.sh 919 -80_feature_defines.sh 1649 -811_place_dlls_never_overwrites_a_declared_deploy.sh 1912 -814_the_llvm_row_defaults_to_the_dynamic_crt.sh 12482 -819_the_crt_is_placed_by_its_rule_not_by_search_order.sh 2294 -81_capability_binding.sh 4393 -820_a_windows_program_takes_its_runtime_from_one_resolver.sh 11452 -825_a_build_program_reads_the_msvc_toolset.sh 3484 -82_feature_optional_deps.sh 5090 -836_a_members_own_statements_reach_its_build.sh 3069 -839_a_workspaces_build_programs_are_named_ordered_and_cached.sh 4317 -83_feature_defines_propagate.sh 1073 -840_a_workspace_database_describes_each_package_once.sh 7573 -841_pack_summarizes_many_outputs.sh 8552 -842_the_build_reports_each_step_once.sh 15936 -846_the_vendored_xlings_is_replaced_from_the_newest_source.sh 73849 -847_a_module_name_is_unique_within_a_program.sh 8333 -848_msvc_binds_a_module_name_per_program.sh 1399 -849_a_staged_bmi_waits_for_a_module_compiled_here.sh 71571 -84_runtime_dll_deploy.sh 961 -857_a_host_module_several_members_import_is_compiled_once.sh 72667 -858_the_engines_module_is_compiled_once_per_home.sh 235616 -859_a_host_module_entry_is_reused_while_its_inputs_agree.sh 74384 -85_target_cfg_build_flags.sh 942 -860_a_workspaces_programs_compile_at_the_same_time.sh 85295 -86_target_cfg_dependencies.sh 1033 -872_a_member_compiles_once_whatever_the_selection.sh 76821 -87_build_default_profile.sh 3359 -88_xlings_environment.sh 2315 -89_build_mcpp.sh 6883 -90_workspace_test.sh 3360 -91_target_bare_alias.sh 915 -92_build_mcpp_import.sh 1371 -93_xpkg_parse.sh 685 -95_msvc_system_toolchain.sh 3086 -97_mingw_toolchain.sh 11239 -99_msvc_native_build.sh 8995 diff --git a/tests/scripts/test_check_e2e_coverage.py b/tests/scripts/test_check_e2e_coverage.py index e27bd6447..ab570ae4e 100644 --- a/tests/scripts/test_check_e2e_coverage.py +++ b/tests/scripts/test_check_e2e_coverage.py @@ -77,6 +77,27 @@ def test_an_exception_for_a_test_that_ran_fails(self) -> None: self.assertEqual(r.returncode, 1) self.assertIn("STALE EXCEPTION: 30_excused.sh is excused but ran on macos", r.stdout) + def test_a_test_required_on_hosts_must_pass_on_each(self) -> None: + (self.root / "tests" / "e2e" / "host-requirements.tsv").write_text( + "# test\thosts\treason\n10_runs.sh\tlinux macos windows\tverified rows\n") + (self.reports / "e2e-report-macos-1.tsv").write_text("pass\t10_runs.sh\t10\t\n") + (self.reports / "e2e-report-windows-1.tsv").write_text( + "skip\t10_runs.sh\t0\tmissing capability: llvm\n") + r = self.run_check() + self.assertEqual(r.returncode, 1) + self.assertIn("REQUIRED ON HOST: 10_runs.sh did not pass on windows (windows: missing capability: llvm)", + r.stdout) + self.assertNotIn("did not pass on linux", r.stdout) + self.assertNotIn("did not pass on macos", r.stdout) + (self.reports / "e2e-report-windows-1.tsv").write_text("pass\t10_runs.sh\t10\t\n") + self.assertEqual(self.run_check().returncode, 0) + + def test_a_host_requirement_for_a_missing_test_fails(self) -> None: + (self.root / "tests" / "e2e" / "host-requirements.tsv").write_text("99_gone.sh\tlinux\tx\n") + r = self.run_check() + self.assertEqual(r.returncode, 1) + self.assertIn("STALE EXCEPTION: 99_gone.sh is required on hosts but does not exist", r.stdout) + def test_an_exception_for_a_missing_test_fails(self) -> None: with (self.root / "tests" / "e2e" / "coverage-exceptions.tsv").open("a") as f: f.write("99_gone.sh\tremoved\n") diff --git a/tests/unit/test_pack_distribution.cpp b/tests/unit/test_pack_distribution.cpp index 77e0b972e..7f2112f74 100644 --- a/tests/unit/test_pack_distribution.cpp +++ b/tests/unit/test_pack_distribution.cpp @@ -261,7 +261,7 @@ TEST(PackDistribution, TheWindowsLauncherIsAProgram) { EXPECT_NE(src.find("(\"windows-\" + arch)"), std::string::npos) << src; EXPECT_NE(src.find("os::process::exec"), std::string::npos) << src; const auto manifest = d::exe_launcher_manifest("hello", "1.0.0"); - EXPECT_NE(manifest.find("os = \"=0.3.1\""), std::string::npos) << manifest; + EXPECT_NE(manifest.find("mcpplibs.os = \"0.4.0\""), std::string::npos) << manifest; EXPECT_NE(manifest.find("[targets.hello]"), std::string::npos) << manifest; } diff --git a/tests/unit/test_process_run_exec.cpp b/tests/unit/test_process_run_exec.cpp index 85c9e8cde..024b2df86 100644 --- a/tests/unit/test_process_run_exec.cpp +++ b/tests/unit/test_process_run_exec.cpp @@ -86,11 +86,15 @@ TEST(RunExec, StreamCompletionAndChildWaitKeepTheirBounds) { int idle = 0; bool stop = false; int exit = -1; + // When `should_stop` begins to answer true. An exited child is waited + // for however soon it is asked to stop; the case of one that exits by + // itself gives the shell a start no runner is slower than. + int stopAfter = 50; }; const std::vector cases{ {"exit", "exit 7", 0, 0, false, 7}, {"signal", "kill -TERM $$", 0, 0, false, 143}, - {"already-exited-bounded", "exit 7", 3000, 0, true, 7}, + {"already-exited-bounded", "exit 7", 3000, 0, true, 7, 2000}, {"open-stop", "sleep 2", 0, 0, true}, {"both-EOF-stop", "exec 1>&- 2>&-; sleep 2", 0, 0, true}, {"stdout-EOF-stop", "exec 1>&-; sleep 2", 0, 0, true}, @@ -105,7 +109,7 @@ TEST(RunExec, StreamCompletionAndChildWaitKeepTheirBounds) { options.total = std::chrono::milliseconds(test.total); options.idle = std::chrono::milliseconds(test.idle); if (test.stop) options.should_stop = [&] { - return std::chrono::steady_clock::now() - started > std::chrono::milliseconds(50); + return std::chrono::steady_clock::now() - started > std::chrono::milliseconds(test.stopAfter); }; const bool producer = std::string_view(test.command) == "@stderr-producer"; if (producer) options.env.emplace_back("MCPP_TEST_OUTPUT_PRODUCER", "1"); diff --git a/tests/unit/test_test_protocol.cpp b/tests/unit/test_test_protocol.cpp new file mode 100644 index 000000000..68b9b7ac1 --- /dev/null +++ b/tests/unit/test_test_protocol.cpp @@ -0,0 +1,112 @@ +#include + +import std; +import mcpp.build.test_protocol; + +// The runner's side of the mtest test protocol, version 1 (docs/08 "Cases"): +// the marker a program's file carries (P2.3), the report it writes (P5), a +// crash's open cases (P5.4) and the patterns of `--case`. + +namespace tp = mcpp::build::test_protocol; + +namespace { + +std::filesystem::path write_file(std::string_view name, std::string_view content) { + auto dir = std::filesystem::temp_directory_path() / "mcpp-unit-test-protocol"; + std::filesystem::create_directories(dir); + auto path = dir / name; + std::ofstream(path, std::ios::binary) << content; + return path; +} + +TEST(TestProtocol, TheMarkerStatesTheVersion) { + EXPECT_EQ(tp::marker_version(write_file("a", std::string("\x7f" "ELF....mtest-protocol=1\0rest", 30))), 1); + EXPECT_EQ(tp::marker_version(write_file("b", "no marker here")), 0); + // The version is followed by a byte that is not a digit; a marker with no + // version is none. + EXPECT_EQ(tp::marker_version(write_file("c", "mtest-protocol=x mtest-protocol=12;")), 12); + EXPECT_EQ(tp::marker_version("/nonexistent/program"), 0); +} + +TEST(TestProtocol, TheMarkerAcrossABlockBoundary) { + std::string bytes((1 << 20) - 5, 'a'); + bytes += "mtest-protocol=1;"; + EXPECT_EQ(tp::marker_version(write_file("d", bytes)), 1); +} + +TEST(TestProtocol, AReportGivesEachCaseAndItsFailures) { + auto r = tp::read_report(write_file("r.ndjson", + "{\"e\":\"program\",\"protocol\":1,\"level\":3,\"framework\":\"f\"}\n" + "not json\n" + "{\"e\":\"begin\",\"id\":\"a\",\"t\":0}\n" + "{\"e\":\"end\",\"id\":\"a\",\"status\":\"pass\",\"ms\":1.5}\n" + "{\"e\":\"begin\",\"id\":\"b\",\"t\":2}\n" + "{\"e\":\"failure\",\"id\":\"b\",\"file\":\"t.cpp\",\"line\":9,\"message\":\"m\",\"expected\":\"1\",\"actual\":\"2\"}\n" + "{\"e\":\"end\",\"id\":\"b\",\"status\":\"fail\",\"ms\":3}\n" + "{\"e\":\"future\",\"id\":\"b\"}\n" + "{\"e\":\"missing\",\"id\":\"c\"}\n" + "{\"e\":\"done\",\"status\":\"fail\",\"counts\":{}}\n"), 1); + EXPECT_EQ(r.level, 3); + EXPECT_EQ(r.framework, "f"); + EXPECT_TRUE(r.done); + ASSERT_EQ(r.cases.size(), 2u); + EXPECT_EQ(r.cases[0].status, "pass"); + EXPECT_DOUBLE_EQ(r.cases[0].ms, 1.5); + ASSERT_EQ(r.cases[1].failures.size(), 1u); + EXPECT_EQ(r.cases[1].failures[0].line, 9); + EXPECT_EQ(r.cases[1].failures[0].actual, "2"); + EXPECT_EQ(r.missing, std::vector{"c"}); + EXPECT_TRUE(r.failed()); + EXPECT_EQ(r.counts().at("fail"), 1); +} + +TEST(TestProtocol, ACaseACrashLeftOpenFailed) { + auto r = tp::read_report(write_file("crash.ndjson", + "{\"e\":\"program\",\"protocol\":1,\"level\":1}\n" + "{\"e\":\"begin\",\"id\":\"x\",\"t\":0}\n"), 139); + EXPECT_FALSE(r.done); + ASSERT_EQ(r.cases.size(), 1u); + EXPECT_EQ(r.cases[0].status, "fail"); + EXPECT_NE(r.cases[0].reason.find("status 139"), std::string::npos); +} + +TEST(TestProtocol, APatternMatchesIdentifiers) { + struct Row { std::string_view id, pattern; bool matches; }; + constexpr Row rows[] = { + {"fs/read", "fs/read", true}, + {"fs/read", "fs", true}, // a prefix of whole names + {"fsx/read", "fs", false}, + {"fs/read", "fs/*", true}, + {"fs/read", "*read", true}, + {"fs/read", "fs/rea?", true}, + {"fs/read", "fs/?", false}, + {"fs/read", "net/*", false}, + {"a b/c", "a b/*", true}, + }; + for (const auto& row : rows) + EXPECT_EQ(tp::matches(row.id, row.pattern), row.matches) << row.id << " ~ " << row.pattern; +} + +TEST(TestProtocol, ASelectionReachesTheCasesAListedCaseDeclares) { + // P3.4: `fields` is listed; the cases its body declares are reported when it runs. + const std::vector listed = {{"fields"}, {"empty"}}; + using V = std::vector; + EXPECT_EQ(tp::selection(listed, {"fields"}), V{"fields"}); + EXPECT_EQ(tp::selection(listed, {"fields/one"}), V{"fields/one"}); // passed as it is + EXPECT_EQ(tp::selection(listed, {"fields/o*"}), V{"fields"}); // the listed case, all of it + EXPECT_EQ(tp::selection(listed, {"e*", "fields/one", "fields"}), (V{"empty", "fields/one", "fields"})); + EXPECT_EQ(tp::selection(listed, {"other/one"}), V{}); +} + +TEST(TestProtocol, TheVariablesOfARun) { + auto env = tp::variables("run", "/r", std::filesystem::path("/s"), "/t", 7); + std::map m(env.begin(), env.end()); + EXPECT_EQ(m.at("MTEST_PROTOCOL"), "1"); + EXPECT_EQ(m.at("MTEST_MODE"), "run"); + EXPECT_EQ(m.at("MTEST_SELECT"), std::filesystem::path("/s").string()); + EXPECT_EQ(m.at("MTEST_SEED"), "7"); + auto list = tp::variables("list", "/r", std::nullopt, "/t", std::nullopt); + EXPECT_TRUE(std::ranges::none_of(list, [](auto& kv) { return kv.first == "MTEST_SELECT" || kv.first == "MTEST_SEED"; })); +} + +} // namespace diff --git a/tests/unit/test_wire.cpp b/tests/unit/test_wire.cpp index dd3679976..87d53dab6 100644 --- a/tests/unit/test_wire.cpp +++ b/tests/unit/test_wire.cpp @@ -150,6 +150,7 @@ TEST(WireProtocol, NamesTheFeaturesAClientGatesOn) { EXPECT_NE(std::ranges::find(names, "pkg-format-list"), names.end()); EXPECT_NE(std::ranges::find(names, "test-launch"), names.end()); EXPECT_NE(std::ranges::find(names, "launch-stream"), names.end()); + EXPECT_NE(std::ranges::find(names, "test-protocol"), names.end()); } TEST(WireProtocol, IsItselfSelfIdentifying) {