From 85c9037edc5f9982519173d019cf15da5d608bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:46:54 +0000 Subject: [PATCH 1/4] Fix #704 #705 #707 #708 #710 #712 #713 #714 #715 #716 #712 An `[xlings]` address resolves the payload xlings selected. A new module, mcpp.xpkg_version, ports xlings' version grammar (N segments, alpha segments, written-prefix equality for three or more segments, prefix ranges for one or two, operator ranges), and xpkg_payload_at selects among installed directories with it. `libglvnd@1.7` finds 1.7.0.1. #716 A provisioning stamp counts only while every address still resolves to a payload. A removed payload is re-provisioned online and refused offline, naming it. The build cache records the payload directories a build read, so the fast path declines when one is gone. xlings reports what each request resolved to (install_targets, protocol 1.1); mcpp records that per address and answers from the record first. #705 A host tool's tree stamp skips directories that hold their own mcpp.toml, and hashes paths as UTF-8. #710 The requesting build decides a host tool's toolchain once -- `--toolchain`, else the tool package's own (its workspace's, for a member), else the requesting build's host toolchain -- passes it to the sub-build as an override, and records it in the store key. A member built as a tool inherits the workspace root's root-position keys. #713 A member inherits the workspace root's `[xlings.workspace]` entries, conditional rows included; its own declaration of a package wins. #714 An unresolved `x.workspace = true` is refused at every position, naming the entry. `[build] sources = []` infers no library target. A rooted workspace resolves its own `workspace = true` entries. #715 A rule reaches a package's synthesised build program only through a device source whose extension it declared. #704 A host build applies its own `[target.]` row (toolchain, linkage, cxx_runtime), as `--target ` did. #707 `emit build-database` builds no host tool; one missing from the store is deferred with the note MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED (SPEC-005 v1.4 R2.5). #708 `mcpp::action::env(name, value)` and `cwd(dir)` (protocol 13), applied by the engine's action wrapper in its named form `__action`. An action with neither keeps the `__action-stamp` command line byte for byte. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5 --- docs/50-machine-output.md | 17 +- docs/specs/build-database.md | 21 +- docs/zh/50-machine-output.md | 13 +- modules/buildmcpp/src/directives.cppm | 30 ++ modules/buildmcpp/src/program_protocol.cppm | 10 +- modules/buildmcpp/src/tool_store.cppm | 22 +- modules/manifest/src/toml.cppm | 14 +- modules/manifest/src/types.cppm | 11 + modules/versioning/src/xpkg_version.cppm | 325 ++++++++++++++++ .../versioning/tests/test_xpkg_version.cpp | 59 +++ src/build/execute.cppm | 47 ++- src/build/hostprogram.cppm | 48 ++- src/build/ninja_backend.cppm | 35 +- src/build/prepare.cppm | 355 +++++++++++++----- src/cli.cppm | 83 +++- src/project.cppm | 118 +++++- src/xlings/xlings.cppm | 225 +++++++---- ...> 788_emit_defers_an_unbuilt_host_tool.sh} | 56 ++- ...rule_applies_through_a_source_it_claims.sh | 74 ++++ ...799_an_action_runs_with_its_env_and_cwd.sh | 72 ++++ tests/unit/test_build_directives.cpp | 49 ++- tests/unit/test_freestanding.cpp | 46 ++- tests/unit/test_manifest.cpp | 35 ++ tests/unit/test_tool_store.cpp | 19 + 24 files changed, 1528 insertions(+), 256 deletions(-) create mode 100644 modules/versioning/src/xpkg_version.cppm create mode 100644 modules/versioning/tests/test_xpkg_version.cpp rename tests/e2e/{788_emit_host_tool_unbuilt_is_a_warning.sh => 788_emit_defers_an_unbuilt_host_tool.sh} (53%) create mode 100755 tests/e2e/798_a_rule_applies_through_a_source_it_claims.sh create mode 100755 tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 4c7f2040c..5c0a5fc7c 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -489,20 +489,21 @@ program's directives: the manifest's own configuration, the toolchain, the module graph and the standard-library units are described as usual, and one `error` diagnostic, `MCPP_BUILD_DATABASE_PROGRAM_FAILED`, names it, with `path` naming its `build.mcpp`. A later failure that follows from the missing -directives fails the whole member instead, under the rule above. A host tool -a package requested that fails to build is a warning instead, -`MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`, naming the tool, its package and the -first line of the failure; planning continues, and a build program that only -names the tool configures as it would after a successful build. `mcpp build` -is unaffected by either: a build program or a host tool that fails there -still fails the build. +directives fails the whole member instead, under the rule above. The command +builds no host tool (mcpp 2026.9.27.1+): a requested tool already in the +global tool store is used, and one that is not is deferred, reported as the +note `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED` naming the tool and its package. +The build program that requested it receives the path the tool will be +published at, and configures as it would after the tool was built. `mcpp build` +is unaffected: it builds the tool, and a build program or a host tool that +fails there still fails the build. | code | severity | | |---|---|---| | `MCPP_LOCK_WOULD_CHANGE` | warning | the resolution differs from the project's `mcpp.lock`, which the command does not write | | `MCPP_GENERATED_FILE_NOT_MATERIALIZED` | warning | a root `[build] generated_files` entry is missing or stale on disk, and the command does not write it | | `MCPP_BUILD_DATABASE_STD_UNIT_UNDESCRIBED` | warning | no standard-library build command names its module source, so that unit is not listed | -| `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT` | warning | a requested host tool failed to build; the tool is still built and its `check` actions still run | +| `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED` | note | a requested host tool is not in the tool store and is not built by the command; the plan names the path it will be published at (2026.9.27.1+; replaces the 2026.9.26.2 warning `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`) | | `MCPP_BUILD_DATABASE_PROGRAM_FAILED` | error | a build program failed; its package is described without its directives | `--protocol-version` declares `init-mcpp-home`, `read-project`, `network`, diff --git a/docs/specs/build-database.md b/docs/specs/build-database.md index 82db5b509..aa3ff1b4f 100644 --- a/docs/specs/build-database.md +++ b/docs/specs/build-database.md @@ -4,12 +4,12 @@ |---|---| | 规范编号 | SPEC-005 | | 标题 | mcpp 输出的构建数据库:内容、取值规则与不写工程目录的保证 | -| 状态 | 评审中 v1.3 | -| 版本 | 1.3 | +| 状态 | 评审中 v1.4 | +| 版本 | 1.4 | | 最后修改 | 2026-09-26 | -| 对应实现 | mcpp >= 2026.9.15.1;v1.3 修改的 R2.5、R3.7、R3.8、R4.1、R5.2 为 mcpp >= 2026.9.26.2 | +| 对应实现 | mcpp >= 2026.9.15.1;v1.3 修改的 R2.5、R3.7、R3.8、R4.1、R5.2 为 mcpp >= 2026.9.26.2;v1.4 修改的 R2.5 为 mcpp >= 2026.9.27.1 | | 相关设计文档 | `.agents/docs/2026-09-14-636-build-database-and-the-latest-xlings.md`
`.agents/docs/2026-09-26-compile-database-and-issue-699-design.md` | -| 相关 issue | #636, #648, #655, #699, #702 | +| 相关 issue | #636, #648, #655, #699, #702, #707 | | 依据的外部规范 | S1「C++ Build Database: IDE Profile」profile 0.2.0 与 S2 0.2.0 §3.4,取自 https://github.com/Sunrisepeak/lsp-mcpp-private 提交 `b82859d`(schema 自提交 `28ecd6e` 起未变);S2 0.3.0 §3.4 的部分回答(S2-3.4-12、S2-3.4-13,Sunrisepeak/mcpp-language-server#25);JSON Compilation Database | ## 0. 适用范围 @@ -52,11 +52,13 @@ Database 定义,本规范不重复它们的字段定义,只规定 mcpp 作为生 - **R2.4** 根包 `[build] generated_files` 中缺失或内容与声明不一致的文件不被写入, 每个输出一条警告 `MCPP_GENERATED_FILE_NOT_MATERIALIZED`。**已实现** - **R2.5** 构建程序照常运行,工作目录为包根,与 `mcpp build` 相同;构建程序在 - `MCPP_OUT_DIR` 之外写入的内容不在本保证之内。依赖提供的宿主工具照常构建到全局 - 工具库,它声明的 `check` 动作照常运行。构建失败的宿主工具在本命令下降级为 - 警告 `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`,消息点名工具、其所属包与失败信息 - 的第一行;规划继续,请求该工具的构建程序收到的是该工具本应发布到的路径。 - `mcpp build` 不受影响,宿主工具构建失败在其中仍使目标失败。**已实现** + `MCPP_OUT_DIR` 之外写入的内容不在本保证之内。命令不构建依赖提供的宿主工具 + (R2.2):全局工具库中已有的工具照常使用;库中没有的工具被推迟,请求它的构建 + 程序收到该工具将被发布到的路径,命令输出说明 `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`, + 消息点名工具与其所属包。被推迟的工具的包不被规划,它声明的动作不运行。构建程序 + 若在配置期执行该路径,遇到的情形与工具构建失败时相同(SPEC-007 R5.3)。 + `mcpp build` 不受影响:它构建宿主工具,构建失败仍使目标失败。**已实现** + (mcpp >= 2026.9.27.1) - **R2.6** `mcpp --protocol-version` 为这条命令声明 `init-mcpp-home`、`read-project`、 `network`、`write-global-cache` 与 `exec-build-script`,不声明 `write-project`。 **已实现** @@ -200,3 +202,4 @@ mcpp 输出的 S1 文档满足 S1 等级 2,不输出 `ide.options`。等级 3 | 1.1 | 2026-09-16 | R5.2 增加离线诊断码 `MCPP_OFFLINE_DOWNLOAD_REQUIRED`;R5.3 的 `network` 按观测列出;新增 R5.4(子进程不继承调用方描述符,xlings 子进程有期限并随 mcpp 结束)(#648)。 | | 1.2 | 2026-09-17 | R3.7 陈述 `arguments` 的每一项是编译器收到的参数,单元 flag 按 SPEC-004 §8 的词列出(#655)。 | | 1.3 | 2026-09-26 | R2.5:`emit` 下构建失败的宿主工具是警告。R3.7:`work-directory` 是输出目录,模块接口单元的 `arguments` 带语言 flag。R3.8:标准库单元的 `provides` 指向 std 缓存中的 BMI,工具链带 `build-id`。R4.1:compile-commands 文档包含标准库单元(S1-12-1)。R5.2:成员各自规划,构建程序失败的包不带其指令地被描述(#699,#702)。 | +| 1.4 | 2026-09-26 | R2.5:命令不构建宿主工具;工具库中没有的工具被推迟,输出说明 `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`,取代 1.3 的警告 `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`(#707)。 | diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 206316593..caae0be3a 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -452,18 +452,19 @@ mcpp emit build-database [--spec s1|compile-commands] --format json 构建程序失败的包(#699 第 2 项)会被描述为不含该程序产生的指令:清单自身 的那部分配置、工具链、模块图与标准库单元仍照常描述,另附一条 `error` 诊断 `MCPP_BUILD_DATABASE_PROGRAM_FAILED` 点名它,`path` 为它的 `build.mcpp`。若 -后续失败是由缺失的指令引起的,则按上面的规则使整个成员失败。包请求的宿主 -工具构建失败则降级为警告 `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`,点名工具、 -所属包与失败信息的第一行;规划继续进行,只点名该工具而不运行它的构建程序 -会像该工具构建成功时一样完成配置。这两者都不影响 `mcpp build`:构建程序或 -宿主工具在其中失败仍然会使构建失败。 +后续失败是由缺失的指令引起的,则按上面的规则使整个成员失败。这条命令 +不构建宿主工具(mcpp 2026.9.27.1+):全局工具库中已有的被请求工具照常使用, +库中没有的被推迟,以说明 `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED` 报告,点名 +工具与所属包。请求它的构建程序收到该工具将被发布到的路径,并像工具已构建时 +一样完成配置。`mcpp build` 不受影响:它构建该工具,构建程序或宿主工具在其中 +失败仍然会使构建失败。 | 诊断码 | 严重级别 | | |---|---|---| | `MCPP_LOCK_WOULD_CHANGE` | 警告 | 解析结果与项目的 `mcpp.lock` 不一致,命令不写这个文件 | | `MCPP_GENERATED_FILE_NOT_MATERIALIZED` | 警告 | 根包 `[build] generated_files` 中的某个文件缺失或内容已过期,命令不写这个文件 | | `MCPP_BUILD_DATABASE_STD_UNIT_UNDESCRIBED` | 警告 | 没有任何标准库构建命令点名它的模块源文件,该单元因此不被列出 | -| `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT` | 警告 | 被请求的宿主工具构建失败;该工具仍会被构建,它的 `check` 动作仍会运行 | +| `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED` | 说明 | 被请求的宿主工具不在工具库中,命令不构建它;计划给出它将被发布到的路径(2026.9.27.1+;取代 2026.9.26.2 的警告 `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`) | | `MCPP_BUILD_DATABASE_PROGRAM_FAILED` | 错误 | 构建程序失败;它所属的包被描述为不含它产生的指令 | `--protocol-version` 为这条命令声明 `init-mcpp-home`、`read-project`、 diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index a1fd4b13e..04aace313 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -1135,6 +1135,14 @@ std::optional decode_action(std::string_view payloa // is identical either way, so a cache entry written before this field // existed replays as one that never set it. a.outputDir = j.value("output_dir", std::string{}); + // mcpp#708, protocol 13. Omitted by an action that sets neither, so a + // payload written before the fields existed decodes as one that never + // set them. An `env` entry is `NAME=value` with a non-empty name. + arr("env", a.env); + for (auto const& e : a.env) + if (auto eq = e.find('='); eq == std::string::npos || eq == 0) + return std::nullopt; + a.cwd = j.value("cwd", std::string{}); if (a.command.empty() || a.outputs.empty()) return std::nullopt; // Prepare only: `output_dir` is the whole point of the role (R3.2, // R3.3) -- a `prepare` action with none declared would have a stamp @@ -1268,6 +1276,23 @@ std::string action_error(const Directives& d) { " payload: {}", *role, payload); } if (decode_action(payload)) continue; + // Named before the general message, like the role above: an `env` + // entry that is not `NAME=value` is the one thing wrong with an + // otherwise complete declaration. + try { + auto j = nlohmann::json::parse(payload); + if (auto it = j.find("env"); it != j.end() && it->is_array()) + for (auto const& e : *it) { + const auto v = e.is_string() ? e.get() : std::string{}; + if (auto eq = v.find('='); eq == std::string::npos || eq == 0) + return std::format( + "build.mcpp declared an action whose environment entry " + "\"{}\" is not NAME=value.\n" + " mcpp::action::env(name, value) sets one variable " + "for the action's command; the name may not be empty.\n" + " payload: {}", v, payload); + } + } catch (...) {} // A malformed action is a hard error, never a skip: an action that // silently does not exist produces a build missing generated sources, // and the user is left staring at a "no such file" three edges away. @@ -1326,6 +1351,11 @@ void prepare_actions(std::vector& actions, // package root it was written against. if (!a.outputDir.empty() && a.outputDir.find("${mcpp.") == std::string::npos) a.outputDir = abs_against(pkgRoot, a.outputDir); + // The command's directory (mcpp#708), anchored the same way: a + // relative spelling names a directory of the package that declared + // the action (SPEC-007 R2.2), never the build directory. + if (!a.cwd.empty() && a.cwd.find("${mcpp.") == std::string::npos) + a.cwd = abs_against(pkgRoot, a.cwd); if (a.role != mcpp::manifest::BuildAction::Role::Source) continue; for (auto const& o : a.outputs) { if (o.find("${mcpp.") != std::string::npos) continue; diff --git a/modules/buildmcpp/src/program_protocol.cppm b/modules/buildmcpp/src/program_protocol.cppm index c712789d3..a4a7a9c8a 100644 --- a/modules/buildmcpp/src/program_protocol.cppm +++ b/modules/buildmcpp/src/program_protocol.cppm @@ -96,7 +96,15 @@ export namespace mcpp::build::program_protocol { // same reason `deploy` and `runtime_search_dir` do -- a program that uses // them fails to COMPILE on an older engine, naming the missing symbol, // rather than reaching that engine as a string it misreads. -inline constexpr int kProtocolVersion = 12; +// v13 (mcpp#708): adds `mcpp::action::env(name, value)` and +// `mcpp::action::cwd(dir)` -- the environment and the directory of an +// action's command, which an argv with no shell cannot express. They reach +// the engine as the `env` and `cwd` fields of the `action` payload and are +// omitted when unset, so an action that uses neither serialises to the bytes +// it did under v12 and no cached entry changes meaning. Same cost as v5's: a +// package calling `env()` fails on an older engine at the build.mcpp COMPILE, +// because that engine's bundled module has no such method. +inline constexpr int kProtocolVersion = 13; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/modules/buildmcpp/src/tool_store.cppm b/modules/buildmcpp/src/tool_store.cppm index eb363d1d8..a7c4078c9 100644 --- a/modules/buildmcpp/src/tool_store.cppm +++ b/modules/buildmcpp/src/tool_store.cppm @@ -117,7 +117,9 @@ nlohmann::json to_json(const Key& k); // and moves in both directions -- an edit and its reversal each produce a new // key, which is what the criterion demands. Build products, the version // control directory and the engine's own scratch are excluded, since they -// change without the sources changing. +// change without the sources changing. So is any directory below the root +// that holds its own mcpp.toml: that is another package, keyed by its own +// stamp when the tool depends on it and no input otherwise (#705). std::string tree_stamp(const std::filesystem::path& root); // /tool//@// @@ -213,14 +215,26 @@ std::string tree_stamp(const fs::path& root) { for (; it != fs::recursive_directory_iterator(); it.increment(ec)) { if (ec) break; const auto& p = it->path(); - const auto name = p.filename().string(); + const auto name = p.filename(); if (it->is_directory(ec)) { - if (name == "target" || name == ".git" || name == ".mcpp") it.disable_recursion_pending(); + // A directory holding its own mcpp.toml is ANOTHER PACKAGE (#705). + // If the tool depends on it, that package is in the key through + // `upstreamKeys` with its own stamp; if not, nothing in it is an + // input of this tool. The case that forced this is a consumer + // nested inside the tool's tree -- a fixture, an example, a + // workspace member: every build of the consumer wrote under its + // own directory and so rebuilt the tool it was building with. + if (name == "target" || name == ".git" || name == ".mcpp" + || fs::is_regular_file(p / "mcpp.toml", ec)) + it.disable_recursion_pending(); continue; } if (!it->is_regular_file(ec)) continue; if (name == "compile_commands.json") continue; - const auto rel = p.lexically_relative(root).generic_string(); + // UTF-8, not the code page: this is a walk of a tree mcpp does not + // control, and a stamp is an identity (check_narrow_conversions.sh). + const auto rel8 = p.lexically_relative(root).generic_u8string(); + const std::string rel(rel8.begin(), rel8.end()); const auto sz = fs::file_size(p, ec); const auto mt = fs::last_write_time(p, ec).time_since_epoch().count(); rows.push_back(std::format("{}|{}|{}", rel, sz, mt)); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 0eac98d79..44d86b1c0 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -4270,6 +4270,18 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { } } const bool hasModuleInterface = !moduleInterfaceExt.empty(); + // `sources = []` states that the default build compiles nothing, so + // an interface under `src/` is not a library of that build (#714). It + // is the shape of a build-logic package: its module is reachable only + // through a feature that host-module consumers request. Inferring a + // library anyway made every build of the package -- a bare + // `mcpp build`, `emit build-database` -- link an archive with no + // inputs and refuse. A package whose features add a library's sources + // declares that target in `[targets]`. An accidentally empty glob + // (`sources = ["srcs/**"]`) is not this case and is still refused as an + // empty link (#533). + const bool declaredNothing = + m.buildConfig.sourcesDeclared && m.buildConfig.sources.empty(); if (hasMain) { // #622 A3: inference stays `Binary`, deliberately. `app` is a @@ -4286,7 +4298,7 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { m.targetsInferred = true; m.inferredNotes.push_back( std::format("target {} (bin from src/main.cpp)", m.package.name)); - } else if (hasModuleInterface) { + } else if (hasModuleInterface && !declaredNothing) { Target t; t.name = m.package.name; t.kind = Target::Library; diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 88f35a432..b8045c92a 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -589,6 +589,17 @@ struct BuildAction { // to still exist after a successful build and has itself just removed. std::string depfile; std::string description; + // Environment variables set for the COMMAND, each `NAME=value`, in the + // order declared (mcpp#708). Added to the environment the build already + // passes on, never replacing it: `PATH`, `MCPP_OFFLINE` and the rest reach + // the command as they reach every other edge (SPEC-007 R3.7). No name has + // a meaning of its own here, `PATH` included. + std::vector env; + // The directory the command runs in (mcpp#708). Empty means the build + // directory, which is where every action ran before this field. Absolute + // once `prepare_actions` has run; a relative spelling names a directory + // of the declaring package (SPEC-007 R2.2). + std::string cwd; }; // `[resources]` — metadata and assets compiled INTO the produced artifact diff --git a/modules/versioning/src/xpkg_version.cppm b/modules/versioning/src/xpkg_version.cppm new file mode 100644 index 000000000..74733484b --- /dev/null +++ b/modules/versioning/src/xpkg_version.cppm @@ -0,0 +1,325 @@ +// mcpp.xpkg_version — the version grammar of xpkg package keys, as xlings +// resolves them. +// +// WHY A SECOND GRAMMAR. `mcpp.version_req` is the grammar of mcpp's own +// dependencies (Cargo's: a bare "1.2.3" means ^1.2.3). An `[xlings]` address is +// not read by mcpp at all: it goes to xlings, and xlings resolves it with ITS +// grammar. Where mcpp has to answer "which installed payload did that address +// select" -- `mcpp::xpkg_dir`, the run PATH, the sysroot fallback -- it must +// give xlings' answer, and the two grammars disagree on the case that matters +// most (mcpp#712): the bare two-segment `1.7` is a caret range to Cargo and a +// prefix range [1.7, 1.8) to xlings, and a four-segment key such as `1.7.0.1` +// is outside Cargo's grammar entirely, so it was never a candidate. +// +// THIS IS A FALLBACK, NOT THE AUTHORITY. xlings reports what it resolved each +// request to (the `install_targets` interface event, protocol 1.1), and mcpp +// records that; this grammar answers only when no record exists (an older +// xlings, or a payload installed by hand). It cannot see one input xlings uses: +// a version already active in the subos that satisfies the request is chosen +// over a higher one. With several installed versions and no record, the +// answer here is the highest match, which is what a fresh xlings install +// selects. +// +// SOURCE: xlings src/core/semver.cppm (generalized grammar, 2026.8.9.2 and +// later), ported rule for rule. The conformance vectors xlings publishes with +// its resolver are replayed against this module in tests/, so a change on +// either side that the other does not make fails a unit test instead of +// resolving a different payload. +// +// Grammar: +// version = field ('.' field)* ('-' prerelease)? ('-' needs a digit before it) +// field = [0-9A-Za-z]+, split at digit/alpha boundaries into segments +// '+' and everything after it is build metadata and is dropped. +// A string with no numeric segment ("latest") is a name, not a version. +// Ordering: numeric segments numerically, alpha lexicographically, numeric +// above alpha, a missing segment is 0, a prerelease is below its release. +// Requests: +// "1.2.3", "2.15.0.1" written-prefix equality, at least three segments wide +// (1.2.3 matches 1.2.3 and 1.2.3.4, never 1.2.4) +// "1", "1.7" prefix range: [1, 2), [1.7, 1.8) +// "1.0.0-rc1" exact +// ">=a", ">a", "<=a", " segs; + int components = 0; // dot-fields as written + std::string prerelease; // "" = a release +}; + +enum class Op { Eq, Gt, Gte, Lt, Lte }; + +struct Constraint { + Op op; + Version ver; +}; + +// All constraints must hold. +struct Range { + std::vector constraints; +}; + +std::optional parse(std::string_view s); +std::strong_ordering compare(const Version& a, const Version& b); +std::optional parse_range(std::string_view expr); +bool satisfies(const Version& v, const Range& r); + +// Order two keys: a parseable key outranks an unparseable one, two +// unparseable keys order lexicographically. Returns <0, 0, >0. +int compare_keys(std::string_view a, std::string_view b); + +// The highest key in `available` that `request` selects, or nullopt. +// "latest" and unparseable keys are never selected. +std::optional +select_best(std::span available, std::string_view request); + +} // namespace mcpp::xpkg_version + +namespace mcpp::xpkg_version { + +namespace { + +Segment number(unsigned long long v) { return Segment{true, v, {}}; } + +std::strong_ordering compare_segment(const Segment& a, const Segment& b) { + if (a.isNum && b.isNum) return a.num <=> b.num; + if (a.isNum != b.isNum) + return a.isNum ? std::strong_ordering::greater : std::strong_ordering::less; + return a.text <=> b.text; +} + +unsigned long long num_at(const Version& v, std::size_t i) { + return i < v.segs.size() && v.segs[i].isNum ? v.segs[i].num : 0; +} + +Version num_version(std::initializer_list nums) { + Version v; + for (auto n : nums) v.segs.push_back(number(n)); + v.components = static_cast(v.segs.size()); + return v; +} + +std::string_view trim(std::string_view s) { + while (!s.empty() && s.front() == ' ') s.remove_prefix(1); + while (!s.empty() && s.back() == ' ') s.remove_suffix(1); + return s; +} + +// Equality is written-prefix, floored at three segments: "15.1.0" matches +// 15.1.0 and 15.1.0.5 but not 15.1.1, and "1.2" as an Eq token matches 1.2 and +// 1.2.0 but not 1.2.5. The ordering operators compare the whole version. +bool check(const Version& v, const Constraint& c) { + if (c.op == Op::Eq) { + const auto width = std::max(c.ver.segs.size(), 3); + static const Segment zero = number(0); + for (std::size_t i = 0; i < width; ++i) { + const auto& l = i < v.segs.size() ? v.segs[i] : zero; + const auto& r = i < c.ver.segs.size() ? c.ver.segs[i] : zero; + if (compare_segment(l, r) != 0) return false; + } + return v.prerelease == c.ver.prerelease; + } + const auto cmp = compare(v, c.ver); + switch (c.op) { + case Op::Gt: return cmp > 0; + case Op::Gte: return cmp >= 0; + case Op::Lt: return cmp < 0; + case Op::Lte: return cmp <= 0; + case Op::Eq: break; + } + return false; +} + +// [lo, next of the first or second segment) +Range prefix_range(const Version& lo, bool firstSegment) { + Range r; + auto hi = firstSegment ? num_version({num_at(lo, 0) + 1}) + : num_version({num_at(lo, 0), num_at(lo, 1) + 1}); + r.constraints.push_back({Op::Gte, lo}); + r.constraints.push_back({Op::Lt, std::move(hi)}); + return r; +} + +std::optional parse_token(std::string_view tok) { + tok = trim(tok); + if (tok.empty()) return std::nullopt; + Op op = Op::Eq; + if (tok.starts_with(">=")) { op = Op::Gte; tok.remove_prefix(2); } + else if (tok.starts_with(">")) { op = Op::Gt; tok.remove_prefix(1); } + else if (tok.starts_with("<=")) { op = Op::Lte; tok.remove_prefix(2); } + else if (tok.starts_with("<")) { op = Op::Lt; tok.remove_prefix(1); } + auto v = parse(trim(tok)); + if (!v) return std::nullopt; + return Constraint{op, std::move(*v)}; +} + +} // namespace + +std::optional parse(std::string_view s) { + s = trim(s); + if (s.empty()) return std::nullopt; + if (auto plus = s.find('+'); plus != std::string_view::npos) { + s = s.substr(0, plus); + if (s.empty()) return std::nullopt; + } + std::string_view numpart = s, prepart; + if (auto dash = s.find('-'); dash != std::string_view::npos) { + bool digitBefore = false; + for (std::size_t i = 0; i < dash; ++i) + if (s[i] >= '0' && s[i] <= '9') { digitBefore = true; break; } + if (digitBefore) { numpart = s.substr(0, dash); prepart = s.substr(dash + 1); } + } + auto is_digit = [](char c) { return c >= '0' && c <= '9'; }; + auto is_alpha = [](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); }; + + Version v; + bool sawNumeric = false; + std::size_t start = 0; + while (start <= numpart.size()) { + const auto dot = numpart.find('.', start); + const auto field = numpart.substr( + start, dot == std::string_view::npos ? numpart.size() - start : dot - start); + if (field.empty()) return std::nullopt; + ++v.components; + std::size_t i = 0; + while (i < field.size()) { + std::size_t j = i; + if (is_digit(field[i])) { + while (j < field.size() && is_digit(field[j])) ++j; + if (j - i > 19) return std::nullopt; + unsigned long long n = 0; + for (auto c : field.substr(i, j - i)) n = n * 10 + static_cast(c - '0'); + v.segs.push_back(number(n)); + sawNumeric = true; + } else if (is_alpha(field[i])) { + while (j < field.size() && is_alpha(field[j])) ++j; + v.segs.push_back(Segment{false, 0, std::string(field.substr(i, j - i))}); + } else { + return std::nullopt; + } + i = j; + } + if (dot == std::string_view::npos) break; + start = dot + 1; + } + if (v.segs.empty() || !sawNumeric) return std::nullopt; + v.prerelease = std::string(prepart); + return v; +} + +std::strong_ordering compare(const Version& a, const Version& b) { + static const Segment zero = number(0); + const auto n = std::max(a.segs.size(), b.segs.size()); + for (std::size_t i = 0; i < n; ++i) { + const auto& l = i < a.segs.size() ? a.segs[i] : zero; + const auto& r = i < b.segs.size() ? b.segs[i] : zero; + if (auto c = compare_segment(l, r); c != 0) return c; + } + if (!a.prerelease.empty() && !b.prerelease.empty()) return a.prerelease <=> b.prerelease; + if (a.prerelease.empty() != b.prerelease.empty()) + return a.prerelease.empty() ? std::strong_ordering::greater + : std::strong_ordering::less; + return std::strong_ordering::equal; +} + +bool satisfies(const Version& v, const Range& r) { + return std::ranges::all_of(r.constraints, + [&](const Constraint& c) { return check(v, c); }); +} + +std::optional parse_range(std::string_view expr) { + expr = trim(expr); + if (expr.empty()) return std::nullopt; + + if (expr.starts_with("^")) { + auto v = parse(expr.substr(1)); + if (!v) return std::nullopt; + std::size_t k = 0; + while (k + 1 < v->segs.size() && v->segs[k].isNum && v->segs[k].num == 0) ++k; + Version hi; + for (std::size_t i = 0; i < k; ++i) hi.segs.push_back(number(num_at(*v, i))); + hi.segs.push_back(number(num_at(*v, k) + 1)); + hi.components = static_cast(hi.segs.size()); + Range r; + r.constraints.push_back({Op::Gte, *v}); + r.constraints.push_back({Op::Lt, std::move(hi)}); + return r; + } + if (expr.starts_with("~")) { + auto v = parse(expr.substr(1)); + if (!v) return std::nullopt; + return prefix_range(*v, /*firstSegment=*/false); + } + if (auto star = expr.find('*'); star != std::string_view::npos) { + auto prefix = expr.substr(0, star); + while (!prefix.empty() && prefix.back() == '.') prefix.remove_suffix(1); + if (prefix.empty()) return std::nullopt; + auto v = parse(prefix); + if (!v) return std::nullopt; + return prefix_range(*v, /*firstSegment=*/v->components == 1); + } + if (expr.starts_with(">") || expr.starts_with("<")) { + Range r; + std::size_t pos = 0; + while (pos < expr.size()) { + while (pos < expr.size() && expr[pos] == ' ') ++pos; + if (pos >= expr.size()) break; + const auto start = pos; + while (pos < expr.size() && (expr[pos] == '>' || expr[pos] == '<' || expr[pos] == '=')) ++pos; + while (pos < expr.size() && expr[pos] == ' ') ++pos; + while (pos < expr.size() && expr[pos] != ' ') ++pos; + auto c = parse_token(expr.substr(start, pos - start)); + if (!c) return std::nullopt; + r.constraints.push_back(std::move(*c)); + } + if (r.constraints.empty()) return std::nullopt; + return r; + } + auto v = parse(expr); + if (!v) return std::nullopt; + if (v->components >= 3 || !v->prerelease.empty()) { + Range r; + r.constraints.push_back({Op::Eq, *v}); + return r; + } + return prefix_range(*v, /*firstSegment=*/v->components == 1); +} + +int compare_keys(std::string_view a, std::string_view b) { + auto va = parse(a), vb = parse(b); + if (va && vb) { + const auto c = compare(*va, *vb); + return c < 0 ? -1 : (c > 0 ? 1 : 0); + } + if (va && !vb) return 1; + if (!va && vb) return -1; + return a < b ? -1 : (a > b ? 1 : 0); +} + +std::optional +select_best(std::span available, std::string_view request) { + auto range = parse_range(request); + if (!range) return std::nullopt; + std::optional best; + for (auto const& key : available) { + if (key == "latest") continue; + auto v = parse(key); + if (!v || !satisfies(*v, *range)) continue; + if (!best || compare_keys(key, *best) > 0) best = key; + } + return best; +} + +} // namespace mcpp::xpkg_version diff --git a/modules/versioning/tests/test_xpkg_version.cpp b/modules/versioning/tests/test_xpkg_version.cpp new file mode 100644 index 000000000..3fca9fe39 --- /dev/null +++ b/modules/versioning/tests/test_xpkg_version.cpp @@ -0,0 +1,59 @@ +#include + +import std; +import mcpp.xpkg_version; + +// SUBSYSTEM-LEVEL: the grammar xlings resolves an `[xlings]` address with, +// stated on its own. The engine-level consequence (which payload directory +// `mcpp::xpkg_dir` answers) is in tests/unit/test_freestanding.cpp. + +namespace xv = mcpp::xpkg_version; + +namespace { +std::string best(std::initializer_list keys, std::string_view req) { + std::vector v(keys.begin(), keys.end()); + return xv::select_best(v, req).value_or(""); +} +} + +TEST(XpkgVersion, FourSegmentKeysAreOrdinaryVersions) { + auto v = xv::parse("1.7.0.1"); + ASSERT_TRUE(v.has_value()); + EXPECT_EQ(v->components, 4); + EXPECT_GT(xv::compare_keys("1.7.0.1", "1.7.0"), 0); + EXPECT_GT(xv::compare_keys("0.0.100", "0.0.11"), 0); +} + +TEST(XpkgVersion, ABareVersionOfOneOrTwoSegmentsIsAPrefixRange) { + EXPECT_EQ(best({"1.7.0.1", "1.8.0"}, "1.7"), "1.7.0.1"); + EXPECT_EQ(best({"1.2.0", "1.2.5", "1.3.0"}, "1.2"), "1.2.5"); + EXPECT_EQ(best({"15.1.0", "15.2.0", "16.1.0"}, "15"), "15.2.0"); +} + +TEST(XpkgVersion, ABareVersionOfThreeOrMoreSegmentsIsWrittenPrefixEquality) { + EXPECT_EQ(best({"12.9.1.4", "12.9.10"}, "12.9.1"), "12.9.1.4"); + EXPECT_EQ(best({"1.2.0", "1.2.5"}, "1.2.0"), "1.2.0"); + EXPECT_EQ(best({"1.8.13", "1.9.0"}, "1.8.12"), ""); + EXPECT_EQ(best({"2.15.0.1", "2.15.0.2"}, "2.15.0.1"), "2.15.0.1"); +} + +TEST(XpkgVersion, OperatorsAndConjunctionsCompareTheWholeVersion) { + EXPECT_EQ(best({"8.0.0", "8.5.0", "8.7.1"}, ">=8.5.0"), "8.7.1"); + EXPECT_EQ(best({"8.0.0", "8.5.0", "8.7.1"}, ">=8.0.0 <8.7.0"), "8.5.0"); + EXPECT_EQ(best({"2.15.0", "2.15.0.1"}, ">=2.15.0.1"), "2.15.0.1"); + EXPECT_EQ(best({"1.2.3", "1.9.0", "2.0.0"}, "^1.2.3"), "1.9.0"); + EXPECT_EQ(best({"1.2.3", "1.2.9", "1.3.0"}, "~1.2.3"), "1.2.9"); + EXPECT_EQ(best({"1.2.3", "1.2.9", "1.3.0"}, "1.2.*"), "1.2.9"); +} + +TEST(XpkgVersion, APrereleaseIsExactAndRanksBelowItsRelease) { + EXPECT_EQ(best({"1.0.0-rc1", "1.0.0"}, "1.0.0-rc1"), "1.0.0-rc1"); + EXPECT_LT(xv::compare_keys("1.0.0-rc1", "1.0.0"), 0); +} + +TEST(XpkgVersion, NamesNeverWinAndBuildMetadataIsIgnored) { + EXPECT_EQ(best({"latest", "nightly", "1.0.0"}, ">=0.1"), "1.0.0"); + EXPECT_FALSE(xv::parse("nightly").has_value()); + EXPECT_GT(xv::compare_keys("1.0.0", "nightly"), 0); + EXPECT_EQ(best({"25.0.4+7"}, "25.0.4"), "25.0.4+7"); +} diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 476c25345..24ff538be 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -237,6 +237,14 @@ struct BuildCacheEntry { // not match a request whose inputs it never saw. std::string toolchainRequest; bool toolchainRecorded = false; + // The payload directory of every `[xlings]` address the build resolved + // (#716). The fast path skips the pass that notices a removed payload -- + // `xlings remove`, a pruned cache -- so it checks the directories still + // exist and declines otherwise; the full path then re-provisions or, with + // auto-install off, refuses naming what is missing. Recorded apart from + // the list for the reason `depSourceRootsRecorded` is. + std::vector xlingsPayloads; + bool xlingsPayloadsRecorded = false; }; std::vector read_build_cache(const std::filesystem::path& projectRoot) { @@ -364,6 +372,16 @@ std::vector read_build_cache(const std::filesystem::path& proje e.toolchainRecorded = true; haveNextLine = static_cast(std::getline(f, line)); } + // Count-prefixed, as `depSourceRoots=` is: absent means the entry + // predates the field and the fast paths decline it once. + if (haveNextLine && line.starts_with("xlingsPayloads=")) { + std::size_t n = 0; + try { n = std::stoul(line.substr(15)); } catch (...) { n = 0; } + for (std::size_t i = 0; i < n && std::getline(f, line); ++i) + e.xlingsPayloads.push_back(line); + e.xlingsPayloadsRecorded = true; + haveNextLine = static_cast(std::getline(f, line)); + } entries.push_back(std::move(e)); if (!haveNextLine || line.empty()) break; } @@ -411,7 +429,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, bool runnerDeclared = false, bool runTierPending = false, const std::string& features = {}, - const std::string& toolchainRequest = {}) { + const std::string& toolchainRequest = {}, + std::vector xlingsPayloads = {}) { auto path = projectRoot / kBuildCacheFile; auto entries = read_build_cache(projectRoot); @@ -437,6 +456,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, newEntry.features = features; newEntry.toolchainRequest = toolchainRequest; newEntry.toolchainRecorded = true; + newEntry.xlingsPayloads = std::move(xlingsPayloads); + newEntry.xlingsPayloadsRecorded = true; entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -481,6 +502,8 @@ void write_build_cache_entries(const std::filesystem::path& path, f << "runtier=" << (e.runTierPending ? 1 : 0) << '\n'; f << "features=" << e.features << '\n'; f << "toolchain=" << e.toolchainRequest << '\n'; + f << "xlingsPayloads=" << e.xlingsPayloads.size() << '\n'; + for (auto& p : e.xlingsPayloads) f << p << '\n'; } } @@ -1022,7 +1045,15 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, normalize_features(ctx.activeFeatureRequest), // The toolchain request, so a later `--toolchain` or // a changed machine default declines the fast path. - toolchain_request_identity()); + toolchain_request_identity(), + // The xlings payloads it read, so a removed one + // declines the fast path (#716). + [&] { + std::vector v; + for (auto const& p : ctx.xlingsPayloads) + v.push_back(p.generic_string()); + return v; + }()); } // The one place the --strict policy is settled. Degradations reported by @@ -1434,6 +1465,16 @@ void restore_root_compile_commands(const std::filesystem::path& projectRoot, } } +// Every xlings payload the entry's build read is still installed (#716). A +// cache written before the field was recorded declines once. +bool xlings_payloads_present(const BuildCacheEntry& e) { + if (!e.xlingsPayloadsRecorded) return false; + std::error_code ec; + return std::ranges::all_of(e.xlingsPayloads, [&](const std::string& p) { + return std::filesystem::is_directory(std::filesystem::path(p), ec); + }); +} + export std::optional try_fast_build(const std::filesystem::path& projectRoot, bool verbose, bool no_cache, std::string_view currentTarget = "") { @@ -1541,6 +1582,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo if (!match->depSourceRootsRecorded) return std::nullopt; if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable)) return std::nullopt; + if (!xlings_payloads_present(*match)) return std::nullopt; auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( @@ -1694,6 +1736,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, if (!match->depSourceRootsRecorded) return std::nullopt; if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable)) return std::nullopt; + if (!xlings_payloads_present(*match)) return std::nullopt; auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 5d47b8d1f..f17f7e241 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -263,6 +263,19 @@ struct action { // `prepare` action with no `output_dir` is refused (`action_error`) as a // `check` that forgot to declare what it built. action& output_dir(const char* p) { outputDir_ = p; return *this; } + // An environment variable for the COMMAND (protocol 13, mcpp#708), added + // to the environment the build already passes on. The command is an argv + // with no shell, so `NAME=value cmd` is not available to write; this is. + // Changing a value changes the edge's command, so the action re-runs. + action& env(const char* name, const char* value) { + add(env_, name, value); + return *this; + } + // The directory the COMMAND runs in (protocol 13, mcpp#708). Relative to + // this package's root; the default is the build directory. Declared + // inputs and outputs are unaffected: they keep naming files the way they + // always have. + action& cwd(const char* dir) { cwd_ = dir; return *this; } void submit() const { std::printf("mcpp:action={\"id\":"); esc(id); std::printf(",\"role\":"); esc(role); @@ -280,6 +293,10 @@ struct action { // that never calls `output_dir()` serialises to the same bytes it did // before the method existed. if (outputDir_[0]) { std::printf(",\"output_dir\":"); esc(outputDir_); } + // Same omission rule again: an action that sets neither serialises to + // the bytes it did before protocol 13. + if (env_.len) std::printf(",\"env\":[%s]", env_.c_str()); + if (cwd_[0]) { std::printf(",\"cwd\":"); esc(cwd_); } // Set only when the process could not allocate memory for a list. // A declaration cut short would otherwise be INVALID rather than // obviously wrong -- the engine turns this marker into a diagnostic @@ -342,10 +359,11 @@ private: len = o.len; } }; - list inputs_, outputs_, command_, provides_, imports_, targets_; + list inputs_, outputs_, command_, provides_, imports_, targets_, env_; // `prepare` only: see `output_dir()` above. A plain `const char*`, not a // `list`: it is one directory, never a JSON array. const char* outputDir_ = ""; + const char* cwd_ = ""; mutable bool overflow_ = false; static void esc(const char* s) { std::putchar('"'); @@ -366,21 +384,27 @@ private: // here: that revision escaped `"` and `\\` and passed control characters // through, and a control character passed through was not JSON, so no // payload the engine accepted contained one. - bool add(list& l, const char* s) { + // `value`, when given, is appended to `s` after an `=`, inside the same + // string literal: one `env` entry is one `NAME=value` string. + bool add(list& l, const char* s, const char* value = nullptr) { bool ok = true; if (l.len) ok = ok && l.put(','); ok = ok && l.put('"'); - for (const char* p = s; ok && *p; ++p) { - unsigned char c = (unsigned char)*p; - if (c == '"' || c == '\\') { ok = l.put('\\') && l.put((char)c); continue; } - if (c < 0x20) { - static const char hex[] = "0123456789abcdef"; - ok = l.put('\\') && l.put('u') && l.put('0') && l.put('0') - && l.put(hex[c >> 4]) && l.put(hex[c & 0xf]); - continue; + auto body = [&](const char* text) { + for (const char* p = text; ok && *p; ++p) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { ok = l.put('\\') && l.put((char)c); continue; } + if (c < 0x20) { + static const char hex[] = "0123456789abcdef"; + ok = l.put('\\') && l.put('u') && l.put('0') && l.put('0') + && l.put(hex[c >> 4]) && l.put(hex[c & 0xf]); + continue; + } + ok = l.put((char)c); } - ok = l.put((char)c); - } + }; + body(s); + if (value) { ok = ok && l.put('='); body(value); } ok = ok && l.put('"'); if (!ok) overflow_ = true; return ok; diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 9eeb6c1c4..c1abc0040 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -3052,9 +3052,19 @@ std::string emit_ninja_string(const BuildPlan& plan) { // action with none at parse time — and is kept because this loop reads // as if it could see one, and a wrapper with no stamp to write would // be a command that swallows its own exit code. - if ((a.role == mcpp::manifest::BuildAction::Role::Check + const bool stamped = + (a.role == mcpp::manifest::BuildAction::Role::Check || a.role == mcpp::manifest::BuildAction::Role::Prepare) - && !a.outputs.empty()) { + && !a.outputs.empty(); + // `env` and `cwd` (mcpp#708) go through the same wrapper, in its named + // form, whatever the role: the command is an argv with no shell + // assumed (SPEC-007 R3.1), so neither `NAME=value cmd` nor `cd dir &&` + // is available, and the engine is the one program on every platform + // that can set both before running it. An action that declares + // neither keeps the positional `__action-stamp` form, byte for byte, + // so upgrading changes no existing edge's command and re-runs nothing. + const bool named = !a.env.empty() || !a.cwd.empty(); + if (stamped || named) { // `mcpp_exe_path()`, not `self_exe_path()` directly: this file // already has one spelling of "where am I" and a second would be // the same decision derived twice. @@ -3064,19 +3074,22 @@ std::string emit_ninja_string(const BuildPlan& plan) { // is in the fingerprint); moving the binary without changing its // version would leave a stale path here, exactly as it would for // the compiler. - std::string wrapped = - shell_quote_arg(escape_ninja_chars(mcpp_exe_path().string())) - + " __action-stamp"; + const auto q = [](const std::string& v) { + return shell_quote_arg(escape_ninja_chars(v)); + }; + std::string wrapped = q(mcpp_exe_path().string()) + + (named ? " __action" : " __action-stamp"); + for (auto const& e : a.env) wrapped += " --env " + q(e); + if (!a.cwd.empty()) wrapped += " --cwd " + q(a.cwd); // Before the stamp list, so the wrapper can tell the flag from a // stamp path without an allowlist of extensions. `directives.cppm` // refuses a `prepare` action with no `output_dir`, so this is // reached with a non-empty directory whenever the role is Prepare. - if (a.role == mcpp::manifest::BuildAction::Role::Prepare) { - wrapped += " --require-dir " - + shell_quote_arg(escape_ninja_chars(a.outputDir)); - } - for (auto const& o : a.outputs) - wrapped += " " + shell_quote_arg(escape_ninja_chars(o)); + if (a.role == mcpp::manifest::BuildAction::Role::Prepare) + wrapped += " --require-dir " + q(a.outputDir); + if (stamped) + for (auto const& o : a.outputs) + wrapped += (named ? " --stamp " : " ") + q(o); wrapped += " -- " + cmd; cmd = std::move(wrapped); } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index aa79fd470..b32a0e99e 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -791,6 +791,7 @@ inherit_as_workspace_member(mcpp::manifest::Manifest& member, mcpp::project::inherit_workspace_package(member, workspace); mcpp::project::merge_workspace_deps(member, workspace, workspaceRoot); mcpp::project::inherit_workspace_build(member, workspace, workspaceRoot); + mcpp::project::inherit_workspace_xlings(member, workspace); return mcpp::project::workspace_inheritance_error(member, memberDir); } @@ -1187,6 +1188,9 @@ export struct BuildContext { // declared but not installed contributes nothing, and the lookup then // continues to PATH. std::vector xlingsDepBinDirs; + // The payload directory of every `[xlings]` address resolved above, for + // the fast path's presence check (#716). See BuildCacheEntry::xlingsPayloads. + std::vector xlingsPayloads; // True when the graph declared a `when = "run"` tool that THIS invocation // did not provision, because it was not going to execute anything. The // build cache records it so `mcpp run`'s fast path declines an entry a @@ -1376,6 +1380,12 @@ export struct BuildOverrides { // outermost first. A request for one of them is the tool's own build asking // for itself, refused at its first repetition (#649 E6). std::vector tool_chain_sources; + // The toolchain spec a host-tool sub-build uses, decided by the build that + // requested the tool and recorded in the tool's store key (#710). Beats + // every other source, `--toolchain` included, because the requesting build + // already took `--toolchain` into account when it decided. Empty for every + // user-facing invocation. + std::string toolchain; // Use THIS manifest instead of reading `/mcpp.toml`. // // Required for a `compat`-style registry package (Form B), which ships no @@ -1553,6 +1563,26 @@ sysroot_override(const mcpp::manifest::Manifest& m, return (e && e->sysrootDeclared) ? &e->sysroot : nullptr; } +// The toolchain a host tool's package chose for itself, read the way its own +// build reads it (#710): the package's manifest with the root-position keys of +// the workspace that lists it (`inherit_workspace_root_position`), then its +// host row's `[target.] toolchain`, then `[toolchain]`. nullopt when none +// names one. +std::optional +host_tool_declared_toolchain(const mcpp::manifest::Manifest& tool, + const std::filesystem::path& toolRoot, + std::string_view platform) { + auto effective = tool; + if (const auto wsRoot = mcpp::project::find_workspace_root(toolRoot); !wsRoot.empty()) + if (auto ws = mcpp::manifest::load(wsRoot / "mcpp.toml"); + ws && mcpp::project::is_workspace_member(*ws, wsRoot, toolRoot)) + mcpp::project::inherit_workspace_root_position(effective, *ws, wsRoot); + if (auto* row = find_target_entry(effective, mcpp::toolchain::triple::host_triple()); + row && !row->toolchain.empty()) + return row->toolchain; + return effective.toolchain.for_platform(platform); +} + // THE MSVC TOOLSET A CLANG `*-windows-msvc` BUILD COMPILES AGAINST. // // On an MSVC-ABI row the compiler is the toolchain and the MSVC toolset -- its @@ -1959,10 +1989,15 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, // the project, because the installation is shared: two projects // declaring the same packages should pay for it once. // - // This still does not survive a user's `xlings remove`. No - // stamp does; the honest fix is a presence check, and it is - // blocked on `resolve_xpkg_path` requiring `@` - // while a manifest is entitled to name a package unpinned. + // A STAMP IS NOT A PRESENCE CHECK (#716). It records that the + // list was installed once; a payload removed since -- `xlings + // remove`, a pruned cache, a deleted directory -- left the stamp + // claiming it, the build skipped provisioning and succeeded with + // `xpkg_dir` answering "". So the stamp counts only while every + // address still resolves to a payload, answered by the same lookup + // `xpkg_dir` uses. That costs one record read or directory scan per + // address, and it was blocked until the lookup could answer an + // unpinned or two-segment address the way xlings resolved it. const auto stampDir = mcpp::home::root() / "provisioned"; // `std::uint64_t`, not `std::size_t`: the offset basis below is // a 64-bit constant and a 32-bit host would truncate it, giving @@ -2018,8 +2053,38 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, legacy.assign(std::istreambuf_iterator(in), {}); return legacy == want; }; - bool needProvision = (have != want); - if (needProvision) { + const auto xlEnv = mcpp::config::make_xlings_env(cfg); + std::vector missing; + if (have == want) + for (auto const& d : declaredDeps) + if (!mcpp::xlings::paths::xpkg_payload( + xlEnv, mcpp::xlings::paths::parse_xpkg_ref(d))) + missing.push_back(d); + if (!missing.empty()) { + std::string list; + for (auto const& m : missing) list += (list.empty() ? "" : ", ") + m; + mcpp::log::verbose("xlings", std::format( + "{}: recorded as provisioned in {}, but no payload is " + "installed for: {}", label, stamp.string(), list)); + if (mcpp::platform::env::offline_mode() + || mcpp::platform::env::no_auto_install()) { + std::string_view release = + mcpp::platform::env::offline_mode() + ? "drop --offline / unset MCPP_OFFLINE" + : "unset MCPP_NO_AUTO_INSTALL"; + refusal::record(refusal::Code::OfflineDownloadRequired); + return std::unexpected(std::format( + "{} are recorded as provisioned, but these payloads " + "are not installed: {}\n" + " record: {}\n" + " install them yourself with:\n" + " xlings install {}\n" + " or {} to let mcpp do it.", + label, list, stamp.string(), join_deps(" "), release)); + } + } + bool needProvision = (have != want) || !missing.empty(); + if (needProvision && missing.empty()) { // THE AUTO-INSTALL GATE, WHICH THIS PATH DID NOT HAVE. // // `[toolchain]` is the precedent this whole mechanism cites @@ -2136,8 +2201,7 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, mcpp::fetcher::InstallProgressHandler progress; auto r = mcpp::xlings::call( - mcpp::config::make_xlings_env(cfg), "install_packages", - args.dump(), &progress); + xlEnv, "install_packages", args.dump(), &progress); // `if (!r)` IS NOT THE FAILURE TEST, AND TESTING ONLY // IT MADE THIS PATH REPORT SUCCESS FOR EVERY FAILURE XLINGS // CAN REPORT. @@ -2190,6 +2254,14 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, " xlings install {}", label, why, join_deps(" "))); } + // What xlings resolved each address to, where it says so + // (protocol 1.1). Recorded per address so every later lookup + // -- the root's, a member's, a dependency's build program -- + // gets xlings' answer rather than a re-derivation of it. + for (auto const& e : r->dataEvents) + if (e.dataKind == "install_targets") + mcpp::xlings::paths::record_resolutions(xlEnv, + mcpp::xlings::paths::parse_install_targets(e.payloadJson)); // Written only on success, for the same reason the check // above exists: a stamp is a record that the effect // happened, and recording an effect that did not is worse @@ -2529,7 +2601,10 @@ prepare_build(bool print_fingerprint, targetMember = m->workspace.members.back(); } } - // else: rooted workspace with [package] — build root normally. + // else: rooted workspace with [package] — build root normally. Its own + // `x.workspace = true` entries name its own [workspace.dependencies]. + else if (m->workspace.present) + mcpp::project::merge_workspace_deps(*m, *m, *root); if (!targetMember.empty()) { auto memberDir = *root / targetMember; @@ -2574,9 +2649,20 @@ prepare_build(bool print_fingerprint, wsManifest = std::move(*wsm); } } + // A preloaded manifest was inherited at its dependency load site, + // which gives a member everything but the root-position keys. This + // build IS rooted at it (a host-tool sub-build), so it takes those + // too, from the workspace that lists it (#710). + if (wsManifest + && mcpp::project::is_workspace_member(*wsManifest, runtimeWorkspaceRoot, *root)) + mcpp::project::inherit_workspace_root_position( + *m, *wsManifest, runtimeWorkspaceRoot); } } + if (auto bad = mcpp::project::unresolved_workspace_dependency_error(*m, *root)) + return std::unexpected(*bad); + mcpp::xlings::runtime::RuntimeSelection runtimeSelection; if (overrides.inherited_runtime_selection) { runtimeSelection = *overrides.inherited_runtime_selection; @@ -3013,7 +3099,11 @@ prepare_build(bool print_fingerprint, // a value from the command line credited to a manifest key sends the // reader to a file that does not contain it. bool tcFromCommandLine = false; + bool tcFromConsumer = false; auto tcSpecSource = [&]() -> std::string { + if (tcOrigin == TcOrigin::ManifestToolchain && tcFromConsumer) + return std::format("the toolchain chosen for this host tool by {}", + overrides.tool_chain); if (tcOrigin == TcOrigin::ManifestToolchain && tcFromCommandLine) return "--toolchain"; switch (tcOrigin) { @@ -3044,6 +3134,11 @@ prepare_build(bool print_fingerprint, tcOrigin = TcOrigin::ManifestToolchain; tcFromCommandLine = true; } + if (!overrides.toolchain.empty()) { + tcSpec = overrides.toolchain; + tcOrigin = TcOrigin::ManifestToolchain; + tcFromConsumer = true; + } if (!tcSpec.has_value()) { auto cfg = get_cfg(); if (cfg && !(*cfg)->defaultToolchain.empty()) { @@ -3114,6 +3209,23 @@ prepare_build(bool print_fingerprint, } } + // `[target.]`'s build-shaping keys, applied the same way whichever + // path found the row (#704). The section's toolchain is a statement about + // this row the author wrote down, so it replaces `[toolchain]` and the + // global default; `--toolchain` and a consumer's decision for a host tool + // are statements about THIS invocation and keep precedence over it. + auto apply_target_section = [&](const mcpp::manifest::TargetEntry& e) { + if (!e.toolchain.empty() && !tcFromCommandLine && !tcFromConsumer) { + tcSpec = e.toolchain; + tcOrigin = TcOrigin::TargetSection; + } + if (!e.linkage.empty()) m->buildConfig.linkage = e.linkage; + // #336: a per-target C++ runtime contract overrides the project + // default, so "self-contained everywhere except this triple" is + // expressible without touching the cfg() input channel. + if (!e.cxxRuntime.empty()) m->buildConfig.cxxRuntime = e.cxxRuntime; + }; + // ─── --target / --static overrides ────────────────────────────────── // Target-axis default resolution when no --target flag was passed: // [build] target (project default, ≙ cargo build.target) > @@ -3437,18 +3549,7 @@ prepare_build(bool print_fingerprint, // target/ output directory all see one spelling. if (parsed) overrides.target_triple = parsed->str(); - if (hasExplicitSection) { - if (!it->second.toolchain.empty()) { - tcSpec = it->second.toolchain; - tcOrigin = TcOrigin::TargetSection; - } - if (!it->second.linkage.empty()) m->buildConfig.linkage = it->second.linkage; - // #336: a per-target C++ runtime contract overrides the project - // default, so "self-contained everywhere except this triple" is - // expressible without touching the cfg() input channel. - if (!it->second.cxxRuntime.empty()) - m->buildConfig.cxxRuntime = it->second.cxxRuntime; - } + if (hasExplicitSection) apply_target_section(it->second); // Convention from the vocabulary table (triple.cppm): the target's // pinned toolchain (host-awareness — native musl-gcc vs triple-named // cross, winlibs mingw vs Linux-hosted cross — lives in the payload @@ -3608,6 +3709,17 @@ prepare_build(bool print_fingerprint, if (known && known->defaultStatic && m->buildConfig.linkage.empty()) m->buildConfig.linkage = "static"; } + // A HOST BUILD READS ITS OWN ROW (#704). `[target.]` is looked up + // by the triple the build produces, and a build without `--target` + // produces the host's. Before this the row applied only when a triple was + // named: `[target.x86_64-linux-gnu] cxx_runtime` shaped `--target + // x86_64-linux-gnu` and was ignored by `mcpp build` on that same machine, + // while the row's `.build` table and its `sysroot` already applied to both. + // The triple is not written into `overrides.target_triple`: that would make + // the host build a target build and turn the row's env segment into a + // requested C library. + else if (auto* hostRow = find_target_entry(*m, mcpp::toolchain::triple::host_triple())) + apply_target_section(*hostRow); if (overrides.force_static) m->buildConfig.linkage = "static"; // #254: everything compiled INTO this build is resolved for the TARGET — @@ -4827,6 +4939,20 @@ prepare_build(bool print_fingerprint, // the --target axis), lazily and only when a build.mcpp actually exists // (root or dependency). std::optional> hostTcCache; + // The spec `host_tc_for_build_program` resolves, as text: the build's own + // spec on a native build; on a cross build the spec the target row's pin + // replaced, or the platform's native first-run default when it replaced + // nothing (#622, see the cross branch below). Also what a host-tool + // sub-build is handed when its package names no toolchain (#710), so the + // tool is built by the compiler the store key records. + auto host_spec_for_build_program = [&]() -> std::string { + if (!tcSpec) return {}; + if (overrides.target_triple.empty()) return *tcSpec; + return (tcOrigin == TcOrigin::TargetPin && hostSpecBeforeRowPin.has_value() + && !hostSpecBeforeRowPin->empty() && *hostSpecBeforeRowPin != "system") + ? *hostSpecBeforeRowPin + : (tcOrigin == TcOrigin::TargetPin ? native_first_run_spec() : *tcSpec); + }; auto host_tc_for_build_program = [&]() -> std::expected< std::pair, std::string> { // A HOST TOOLCHAIN'S C LIBRARY IS THE PAYLOAD'S, WHATEVER THE @@ -4929,11 +5055,7 @@ prepare_build(bool print_fingerprint, // is that exact selection (declared once, above, and used by the // first-run installer itself), reused rather than re-derived so the // two cannot silently drift apart. - const std::string hostSpecText = - (tcOrigin == TcOrigin::TargetPin && hostSpecBeforeRowPin.has_value() - && !hostSpecBeforeRowPin->empty() && *hostSpecBeforeRowPin != "system") - ? *hostSpecBeforeRowPin - : (tcOrigin == TcOrigin::TargetPin ? native_first_run_spec() : *tcSpec); + const std::string hostSpecText = host_spec_for_build_program(); auto spec = mcpp::toolchain::parse_toolchain_spec(hostSpecText); if (!spec || spec->version.empty()) { return std::unexpected(std::format( @@ -6322,6 +6444,9 @@ prepare_build(bool print_fingerprint, return std::unexpected(std::format( "dependency '{}': {}", depName, *bad)); } + if (auto bad = mcpp::project::unresolved_workspace_dependency_error( + *dm, mcppToml.parent_path())) + return std::unexpected(std::format("dependency '{}': {}", depName, *bad)); manifest = std::move(*dm); effRoot = mcppToml.parent_path(); return {}; @@ -8420,6 +8545,9 @@ prepare_build(bool print_fingerprint, return std::unexpected(*bad); } } + if (auto bad = mcpp::project::unresolved_workspace_dependency_error( + *dep_manifest, dep_root)) + return std::unexpected(std::format("dependency '{}': {}", name, *bad)); // #229: path/git-dep half of the L1 cfg funnel — mirrors the // loadVersionDep call site above (loadFrom's L1 cfg merge, ~1740 // lines up). Before this fix, path/git deps never ran this merge @@ -9604,6 +9732,14 @@ prepare_build(bool print_fingerprint, // `.slang` to the built-in table required an mcpp release and a version // bump in the rule package's CI before its rule could route one file; // a language arriving this way needs neither. + // What each package's active rules claim, kept until its device + // sources are known (#715, the filter below). + struct RuleClaim { + std::string module; + std::vector extensions; + std::string provider; // ":", for the report + }; + std::vector> ruleClaims(packages.size()); for (std::size_t ci = 0; ci < packages.size(); ++ci) { std::vector collected; std::vector ruleModules; @@ -9631,14 +9767,10 @@ prepare_build(bool print_fingerprint, mit != dep.manifest.featureRuleModule.end() && std::ranges::find(ruleModules, mit->second) == ruleModules.end()) { ruleModules.push_back(mit->second); - // Said out loud, for the same reason the resolved - // toolchain is: the manifest states the intent and the - // build states what that came to. Without this line a - // reader of a terse manifest could not tell which rules - // ran. - mcpp::ui::info("Rules", std::format("{} ({}:{})", mit->second, - dep.manifest.package.namespace_, - dep.manifest.package.name)); + ruleClaims[ci].push_back(RuleClaim{ + mit->second, it->second, + std::format("{}:{}", dep.manifest.package.namespace_, + dep.manifest.package.name)}); } } } @@ -9793,6 +9925,43 @@ prepare_build(bool print_fingerprint, deviceSourcesByPackage[pkg.root.string()] = std::move(device); } } + + // ── A rule applies to a package through a source it claims (#715) ── + // + // A feature activates a rule for the consumer; whether the rule has + // anything to do there is answered by the consumer's sources. The + // synthesised build program used to be written for every active rule, + // so a package that only imports Qt -- no `.ui`, `.qrc` or `.ts`, no + // `build.mcpp` -- compiled and ran a program that could only report + // "nothing to do", on every configure. A rule now reaches the program + // only when one of the package's device sources has an extension the + // rule declared, classified by the same table the source scan uses. A + // device source no active rule claims is still the orphan refused + // below, and a package that wants a rule to run without claimed + // sources writes its own `build.mcpp`. + for (std::size_t ci = 0; ci < packages.size(); ++ci) { + if (ruleClaims[ci].empty()) continue; + auto& bc = packages[ci].manifest.buildConfig; + const auto dit = deviceSourcesByPackage.find(packages[ci].root.string()); + std::vector applies; + for (auto const& claim : ruleClaims[ci]) { + const auto table = mcpp::extension_table_for(bc.moduleExtensions, + claim.extensions); + const bool claimed = dit != deviceSourcesByPackage.end() + && std::ranges::any_of(dit->second, [&](const std::string& rel) { + return mcpp::classify(std::filesystem::path(rel), table) + == mcpp::SourceKind::Device; + }); + if (!claimed) continue; + applies.push_back(claim.module); + // Said out loud, for the same reason the resolved toolchain is: + // the manifest states the intent and the build states what that + // came to. + mcpp::ui::info("Rules", std::format("{} ({})", claim.module, claim.provider)); + } + if (ci == 0) m->buildConfig.ruleModules = applies; + bc.ruleModules = std::move(applies); + } activeFeaturesByPackage.resize(packages.size()); // ── The GRAPH's `[xlings.workspace]`, provisioned BEFORE build.mcpp ── @@ -10335,8 +10504,35 @@ prepare_build(bool print_fingerprint, std::vector feats = tgt->requiredFeatures; auto closure = feature_closure(depPkg.manifest, feats, true); - auto hostTc = host_tc_for_build_program(); - if (!hostTc) return std::unexpected(hostTc.error()); + // WHICH COMPILER BUILDS THE TOOL IS DECIDED HERE, ONCE + // (#710). The key used to record this build's host + // toolchain while the sub-build chose its own -- the tool + // package's `[toolchain]`, else the global default -- so an + // entry could name gcc 15.1 over a binary gcc 16.1 had + // produced, and a member tool built for a consumer used a + // different compiler than `mcpp build -p `. The + // choice is `--toolchain` when given, else the tool + // package's own (its workspace's, for a member), else the + // compiler this build compiles its build programs with. It + // is handed to the sub-build as an override and recorded in + // the key, so the two cannot disagree. + std::string toolTcSpec; + if (const char* e = std::getenv("MCPP_TOOLCHAIN"); e && *e) + toolTcSpec = e; + else if (auto own = host_tool_declared_toolchain( + depPkg.manifest, depPkg.root, kCurrentPlatform)) + toolTcSpec = *own; + std::string compilerIdentity; + if (toolTcSpec.empty()) { + auto hostTc = host_tc_for_build_program(); + if (!hostTc) return std::unexpected(hostTc.error()); + toolTcSpec = host_spec_for_build_program(); + compilerIdentity = std::format("{}|{}|{}", + hostTc->second.label(), hostTc->second.version, + hostTc->first.string()); + } else { + compilerIdentity = "spec|" + toolTcSpec; + } mcpp::build::tool_store::Key key; key.indexName = depIdx >= 1 && depIdx - 1 < dep_cache_identities.size() @@ -10367,9 +10563,7 @@ prepare_build(bool print_fingerprint, key.version = source_keyed_version(depIdx); key.targetName = toolName; key.hostTriple = mcpp::toolchain::triple::host_triple().str(); - key.compilerIdentity = std::format("{}|{}|{}", - hostTc->second.label(), hostTc->second.version, - hostTc->first.string()); + key.compilerIdentity = compilerIdentity; key.profile = "release"; key.features = closure; std::ranges::sort(key.features); @@ -10397,25 +10591,31 @@ prepare_build(bool print_fingerprint, continue; } - // #699 item 2 (E2): under `emit build-database` - // (`plan_only`), a host tool that fails to build is a - // warning, not a refusal that costs the whole plan — the - // requesting member is still worth describing, and its - // build program receives the path the tool would have - // been published at (`binOut`, fixed above before any of - // this runs). `mcpp build` is unchanged below: it still - // returns `std::unexpected` and the target fails. - auto host_tool_unbuilt = [&](std::string_view failure) { - // The first line only: a nested build's message can - // run to several, and the warning names the tool and - // its package, not the whole log. - const auto first = failure.substr(0, failure.find('\n')); - planNotes.push_back({"MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT", - std::format("host tool '{}' of package '{}' did not " - "build: {}", toolName, depName, first), - mcpp::wire::Severity::Warning}); + // PLANNING BUILDS NO TOOL (SPEC-005 R2.5, v1.4; #707). + // `emit build-database` describes a build; it does not + // perform one (R2.2), and a tool sub-build is a whole + // compile of another package, with its own prepare + // actions -- measured on a fresh store, a single `emit` + // compiled the tool and ran the tool package's `prepare` + // action. A tool already in the store is used as above. One + // that is not is deferred: the build program receives the + // path the tool will be published at (`binOut`, fixed + // before anything is built), which is the answer it gets + // after a successful build, and a note names the tool. A + // build program that must RUN the tool while configuring + // meets the same missing file it meets when the tool fails + // to build (SPEC-007 R5.3), so no new contract follows. + if (overrides.plan_only) { + planNotes.push_back({"MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED", + std::format("host tool '{}' of package '{}' is not in " + "the tool store and is not built while " + "planning; the plan names the path it will " + "be published at: {}", + toolName, depName, binOut.string()), + mcpp::wire::Severity::Note}); record(binOut); - }; + continue; + } mcpp::ui::status("Building", std::format( "host tool {}:{} from {} v{} (once per package source and " @@ -10446,6 +10646,7 @@ prepare_build(bool print_fingerprint, sub.work_dir = mcpp::build::tool_store::scratch_dir( cacheRoot, entry, workRoot); sub.target_triple = ""; // HOST — the whole point + sub.toolchain = toolTcSpec; sub.profile = "release"; sub.cache_mode = overrides.cache_mode; sub.tool_depth = overrides.tool_depth + 1; @@ -10506,10 +10707,6 @@ prepare_build(bool print_fingerprint, /*includeDevDeps=*/false, /*extraTargets=*/{}, sub); if (!subCtx) { - if (overrides.plan_only) { - host_tool_unbuilt(subCtx.error()); - continue; - } return std::unexpected(std::format( "building host tool '{}:{}' failed: {}{}", depName, toolName, subCtx.error(), subContext())); @@ -10523,12 +10720,6 @@ prepare_build(bool print_fingerprint, if (lu.targetName == toolName) { goal = lu.output; break; } } if (goal.empty()) { - if (overrides.plan_only) { - host_tool_unbuilt("produced no link unit — its " - "required_features may not be satisfiable on " - "this platform"); - continue; - } return std::unexpected(std::format( "host tool '{}:{}' produced no link unit — its " "required_features may not be satisfiable on this " @@ -10547,10 +10738,6 @@ prepare_build(bool print_fingerprint, bopt.verbose = true; auto br = be->build(subCtx->plan, bopt); if (!br) { - if (overrides.plan_only) { - host_tool_unbuilt(br.error().message); - continue; - } auto diag = br.error().diagnosticOutput; if (diag.empty()) diag = "(the inner build produced no diagnostic " @@ -10561,11 +10748,6 @@ prepare_build(bool print_fingerprint, subContext(), diag)); } if (br->exitCode != 0) { - if (overrides.plan_only) { - host_tool_unbuilt(std::format( - "build exited with {}", br->exitCode)); - continue; - } return std::unexpected(std::format( "building host tool '{}:{}' failed (exit {}){}", depName, toolName, br->exitCode, subContext())); @@ -10577,11 +10759,6 @@ prepare_build(bool print_fingerprint, std::error_code cpEc; auto produced = subCtx->plan.outputDir / goal; if (!std::filesystem::exists(produced, cpEc)) { - if (overrides.plan_only) { - host_tool_unbuilt(std::format( - "built but '{}' is missing", produced.string())); - continue; - } return std::unexpected(std::format( "host tool '{}:{}' built but '{}' is missing", depName, toolName, produced.string())); @@ -10593,11 +10770,6 @@ prepare_build(bool print_fingerprint, std::filesystem::copy_file(produced, tmp, std::filesystem::copy_options::overwrite_existing, cpEc); if (cpEc) { - if (overrides.plan_only) { - host_tool_unbuilt(std::format( - "staging failed: {}", cpEc.message())); - continue; - } return std::unexpected(std::format( "staging host tool '{}:{}' failed: {}", depName, toolName, cpEc.message())); @@ -10609,11 +10781,6 @@ prepare_build(bool print_fingerprint, std::filesystem::perm_options::add, cpEc); std::filesystem::rename(tmp, binOut, cpEc); if (cpEc) { - if (overrides.plan_only) { - host_tool_unbuilt(std::format( - "publishing failed: {}", cpEc.message())); - continue; - } return std::unexpected(std::format( "publishing host tool '{}:{}' failed: {}", depName, toolName, cpEc.message())); @@ -13742,6 +13909,7 @@ prepare_build(bool print_fingerprint, for (auto const& spec : xlingsSpecs) { auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) { + ctx.xlingsPayloads.push_back(*dir); // `bin/`, then the payload root. The measurement that // added the second entry is recorded with the rule, in // runner_lookup::payload_search_dirs. @@ -14363,6 +14531,11 @@ prepare_build(bool print_fingerprint, // deliberately left a `${mcpp.` depfile untouched for // exactly this phase to resolve. if (!a.depfile.empty()) a.depfile = sub(a.depfile); + // The same vocabulary for the command's environment and + // directory (mcpp#708): `OUT=${mcpp.out_dir}/gen` is the value + // an environment-configured generator most often wants. + for (auto& x : a.env) x = sub(x); + if (!a.cwd.empty()) a.cwd = sub(a.cwd); // THE DEPENDENCY IS IMPLIED BY THE USE, so a member author // cannot forget it. Without this the edge is dirty only when a // link output changes, and a staged set that grew a dependency's diff --git a/src/cli.cppm b/src/cli.cppm index 8bf13376b..238033923 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1013,39 +1013,90 @@ int run(int argc, char** argv) { // command writes nothing -- clang-tidy, an installer run through // `mcpp-deps` -- re-ran on every build after its first input change, // because its output stayed older than that input forever. - if (std::string_view(argv[1]) == "__action-stamp") { + // `mcpp __action [--env NAME=VALUE]... [--cwd ] [--require-dir ] + // [--stamp ]... -- ...` is the same wrapper with every part + // named (mcpp#708): an action that declares `env` or `cwd` is run through + // it whatever its role. An action that declares neither keeps the + // positional `__action-stamp` spelling above, so its command line -- and + // ninja's command hash for its edge -- is the one an earlier engine wrote, + // and upgrading re-runs no check and no `prepare`. + if (std::string_view(argv[1]) == "__action-stamp" + || std::string_view(argv[1]) == "__action") { + const bool named = std::string_view(argv[1]) == "__action"; int i = 2; // `prepare` only: the directory its command populates - // (`mcpp::action::output_dir`). Parsed before the stamp list, which - // is otherwise everything up to `--`, so this flag cannot be mistaken - // for a stamp path. + // (`mcpp::action::output_dir`). A flag in both spellings, so it cannot + // be mistaken for a positional stamp path. std::string requireDir; - if (i < argc && std::string_view(argv[i]) == "--require-dir") { + std::string cwd; + std::vector> env; + std::vector stamps; + for (; i < argc && std::string_view(argv[i]) != "--"; ++i) { + const std::string_view a = argv[i]; + const bool takesValue = a == "--require-dir" + || (named && (a == "--env" || a == "--cwd" || a == "--stamp")); + if (!takesValue) { + if (named) { + std::println(stderr, "error: __action: unknown option '{}'", a); + return 2; + } + stamps.emplace_back(a); + continue; + } if (i + 1 >= argc) { - std::println(stderr, "error: --require-dir requires a directory"); + std::println(stderr, "error: {} requires a value", a); return 2; } - requireDir = argv[i + 1]; - i += 2; + const std::string v = argv[++i]; + if (a == "--require-dir") requireDir = v; + else if (a == "--cwd") cwd = v; + else if (a == "--stamp") stamps.push_back(v); + else { + const auto eq = v.find('='); + if (eq == std::string::npos || eq == 0) { + std::println(stderr, "error: --env requires NAME=VALUE, got '{}'", v); + return 2; + } + env.emplace_back(v.substr(0, eq), v.substr(eq + 1)); + } } - std::vector stamps; - for (; i < argc && std::string_view(argv[i]) != "--"; ++i) - stamps.emplace_back(argv[i]); - if (i >= argc || stamps.empty()) { + if (i >= argc || (!named && stamps.empty())) { std::println(stderr, - "error: __action-stamp requires ... -- ..."); + "error: {} requires ... -- ...", argv[1]); return 2; } std::vector cmd; for (++i; i < argc; ++i) cmd.emplace_back(argv[i]); if (cmd.empty()) { - std::println(stderr, "error: __action-stamp has no command to run"); + std::println(stderr, "error: {} has no command to run", argv[1]); return 2; } - // `run_exec`: no shell, stdio inherited. The analyser's own output has + // Stamps and the required directory are named relative to the build + // directory, where ninja started this process. Anchored before the + // command's own directory is entered, so `cwd` moves the command and + // nothing else. + { + std::error_code aec; + for (auto& st : stamps) + st = std::filesystem::absolute(std::filesystem::path{st}, aec).string(); + if (!requireDir.empty()) + requireDir = std::filesystem::absolute( + std::filesystem::path{requireDir}, aec).string(); + } + if (!cwd.empty()) { + std::error_code cec; + std::filesystem::current_path( + mcpp::platform::fs::extended_length(std::filesystem::path{cwd}), cec); + if (cec) { + std::println(stderr, "error: cannot enter the action's directory '{}': {}", + cwd, cec.message()); + return 1; + } + } + // `run_exec`: no shell, stdio inherited. The analyser's own output has // to reach the terminal unchanged — a check that fails is read by a // human, and capturing would either swallow it or reprint it wrapped. - const int r = mcpp::platform::process::run_exec(cmd); + const int r = mcpp::platform::process::run_exec(cmd, env); // The stamps are written ONLY on success. Writing them anyway would // make ninja consider the edge satisfied, so the next build would skip // a check (or a `prepare`) that had never passed. diff --git a/src/project.cppm b/src/project.cppm index ec8b14fd4..c7738791e 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -199,17 +199,94 @@ export void inherit_workspace_package(mcpp::manifest::Manifest& member, // `wsRoot` anchors relative paths: an `[indices].path` or a // `[workspace.dependencies] path` was written against the WORKSPACE ROOT, and // re-anchoring it to the member directory is #224. -export void inherit_workspace_config(mcpp::manifest::Manifest& member, - const mcpp::manifest::Manifest& workspace, - const std::filesystem::path& wsRoot) { - merge_workspace_deps(member, workspace, wsRoot); +// The workspace root's `[xlings.workspace]`, for a member (#713). +// +// An xlings entry describes the environment a build runs in, the same class of +// declaration as `[toolchain]` and `[target.*]`, so it is inherited implicitly +// rather than through an explicit opt-in: only dependencies, which are graph +// edges, need `.workspace = true` (`merge_workspace_deps`). Before this a member +// saw none of the root's entries: they were installed for the workspace, and +// `mcpp::xpkg_dir` in the member's build program still answered "" for them. +// +// The root's entries come first and a member's own declaration of the same +// package wins, which is the "nearer the artifact" rule of SPEC-004 §4.5 -- +// identity is `(namespace, name)`, decided by `package_key`. Conditional +// `[target..xlings.workspace]` rows travel as conditional rows, so they are +// still decided by the selector at merge time. Feature-gated entries do not +// travel: a feature belongs to the package that declares it. Neither does the +// emitter's per-platform view (`workspaceByPlatform`), so a published member's +// descriptor states only what the member itself declared; the `subos` is the +// root's choice already (`select_runtime`). +export void inherit_workspace_xlings(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace) { + // `(namespace, name)`, the identity `mcpp.xlings.address_set` defines, + // spelled here from the same parser rather than imported. Importing that + // module here makes GCC 16.1 fail with an internal compiler error + // (segmentation fault) at `import mcpp.cli;` in src/main.cpp. Measured. + auto package_key = [](std::string_view address) { + const auto e = mcpp::manifest::parse_address(address); + return (e.ns.empty() ? std::string("xim") : e.ns) + ":" + e.target; + }; + std::set own; + for (auto const& a : member.xlings.deps) own.insert(package_key(a)); + for (auto const& cc : member.conditionalConfigs) + for (auto const& a : cc.xlings.deps) own.insert(package_key(a)); + + // Copies the entries of `from` whose package the member does not declare, + // with the pin and the tier each address carries. + auto take = [&](const mcpp::manifest::XlingsConfig& from, + mcpp::manifest::XlingsConfig& to) { + std::vector taken; + for (auto const& a : from.deps) { + if (own.contains(package_key(a))) continue; + taken.push_back(a); + const auto target = mcpp::manifest::parse_address(a).target; + if (auto pin = from.workspace.find(target); pin != from.workspace.end()) + to.workspace.try_emplace(pin->first, pin->second); + if (auto w = from.depWhen.find(a); w != from.depWhen.end()) + to.depWhen.try_emplace(a, w->second); + } + to.deps.insert(to.deps.begin(), taken.begin(), taken.end()); + }; + take(workspace.xlings, member.xlings); + + std::vector rows; + for (auto const& cc : workspace.conditionalConfigs) { + if (cc.xlings.deps.empty()) continue; + mcpp::manifest::ConditionalConfig row; + row.predicate = cc.predicate; + take(cc.xlings, row.xlings); + if (!row.xlings.deps.empty()) rows.push_back(std::move(row)); + } + member.conditionalConfigs.insert(member.conditionalConfigs.begin(), + std::make_move_iterator(rows.begin()), + std::make_move_iterator(rows.end())); +} +// The keys a member inherits only where it is the ROOT of a build: `[toolchain]`, +// `[target.]` and `[indices]`. They choose the compiler, the target +// rows and the indices for the whole graph, so a member reached as somebody's +// dependency takes them from that build's root instead. A member built as a +// host tool is the root of its own sub-build, which is the second caller +// (#710): without it, `mcpp build -p tool` used the workspace's compiler and +// the same tool built for a consumer used the global default. +export void inherit_workspace_root_position(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace, + const std::filesystem::path& wsRoot) { if (member.toolchain.byPlatform.empty()) member.toolchain = workspace.toolchain; for (auto& [triple, entry] : workspace.targetOverrides) if (!member.targetOverrides.contains(triple)) member.targetOverrides[triple] = entry; inherit_workspace_indices(member, workspace, wsRoot); +} + +export void inherit_workspace_config(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace, + const std::filesystem::path& wsRoot) { + merge_workspace_deps(member, workspace, wsRoot); + inherit_workspace_root_position(member, workspace, wsRoot); + inherit_workspace_xlings(member, workspace); // The two halves, each with a second caller of its own: a member reached // as a sibling.s `path` dependency needs both, at two different points. @@ -315,6 +392,39 @@ export std::optional workspace_inheritance_error( return std::nullopt; } +// A `x.workspace = true` entry that no workspace resolved (#714). +// +// Inheritance replaces the entry with the workspace's declaration; an entry +// still marked afterwards names nothing. It used to fall through as a version +// dependency with an empty version, refused far downstream as "SemVer +// constraint '' ... run `mcpp index update`" -- an instruction about the index +// for a mistake in the manifest. Asked once, after inheritance, at each place a +// manifest enters a build: the root, a member built with `-p`, and every +// dependency load site. The two ways out are the two ways inheritance happens. +export std::optional +unresolved_workspace_dependency_error(const mcpp::manifest::Manifest& m, + const std::filesystem::path& manifestDir) { + auto first = [](const std::map& deps) + -> std::optional { + for (auto const& [name, spec] : deps) + if (spec.inheritWorkspace) return name; + return std::nullopt; + }; + std::optional name; + std::string_view table; + if ((name = first(m.dependencies))) table = "dependencies"; + else if ((name = first(m.devDependencies))) table = "dev-dependencies"; + else if ((name = first(m.buildDependencies))) table = "build-dependencies"; + if (!name) return std::nullopt; + return std::format( + "{}: [{}] {} = {{ workspace = true }}, but no workspace declares '{}'.\n" + " `workspace = true` is resolved against the [workspace.dependencies] " + "of the workspace whose `members` list this package.\n" + " fix: list this package in that workspace's [workspace] members, " + "or state the dependency's version, path or git source here.", + (manifestDir / "mcpp.toml").string(), table, *name, *name); +} + // THE EFFECTIVE MANIFEST OF A PROJECT DIRECTORY, FOR EVERY READER OUTSIDE // `prepare_build`. // diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm index a80079a7c..489dcc102 100644 --- a/src/xlings/xlings.cppm +++ b/src/xlings/xlings.cppm @@ -20,7 +20,8 @@ import mcpp.pm.index_snapshot; import mcpp.platform; import mcpp.log; import mcpp.home; -import mcpp.version_req; +import mcpp.xpkg_version; +import mcpp.libs.json; export namespace mcpp::xlings { @@ -157,29 +158,58 @@ namespace paths { // Where that package's payload is, or nullopt if it is not installed. // - // A PINNED ref resolves to exactly its version and to nothing else. A - // build that asked for 1.8.12 and silently got 1.9.0 is the kind of answer - // that is only discovered later, in the artifact. An unpinned ref takes - // the highest version present — compared by numeric segments, because a - // plain string sort puts "0.4.11" before "0.4.9". - // - // A CONSTRAINED ref (`>=8.5.0`, `^1.2`) takes the highest INSTALLED version - // satisfying it. The version position of an xlings address has accepted - // range expressions all along — xlings resolves one when it installs — but - // this lookup treated the whole position as a directory name, so a range - // installed a payload and then answered that nothing was installed. That - // asymmetry is what made `[feature-xlings]` floors unusable: a rule package - // could declare `>=8.5.0`, get it installed, and still see `xpkg_dir` - // return "". + // The answer is the one xlings gave when it resolved the address. A + // recorded resolution (below) answers first; without one, the request + // selects among the installed directories under xlings' own version + // grammar (mcpp.xpkg_version): a literal directory, then written-prefix + // equality for three or more segments (1.8.12 matches 1.8.12.4 and never + // 1.9.0), a prefix range for one or two (1.7 matches 1.7.0.1, #712), and + // ranges for operators. An unpinned ref takes the highest version present. std::optional xpkg_payload(const Env& env, const XpkgRef& ref); - // The same resolution against an explicit xpkgs base. The Env form - // delegates here; this one exists so the rule ("pinned means exactly that - // version") is testable without constructing a home. + // The grammar half of that resolution against an explicit xpkgs base, + // without the record. Exists so the rule is testable without a home. std::optional xpkg_payload_at(const std::filesystem::path& xpkgsBase, const XpkgRef& ref); + // ── What xlings resolved each requested address to ────────────────── + // + // xlings >= 2026.9.27.1 reports, for every address an install was asked + // for, the version it selected and where the payload is (the + // `install_targets` interface event, protocol 1.1), on every path + // including "everything was already installed". mcpp keeps one record per + // (xpkgs base, address) under /provisioned/resolved/, so the + // question "which payload did `libglvnd@1.7` select" has xlings' answer + // wherever it is asked -- by the root, by a member, by a dependency's build + // program. The grammar above answers only when no record exists: an older + // xlings, or a payload installed outside mcpp. + // + // The capability is detected by the event's presence, not by a version + // number: an xlings that does not emit it simply leaves no record. + struct ResolvedTarget { + std::string request; // the address as it was sent + std::string ns; + std::string name; + std::string version; // what the request resolved to + int revision = 0; + std::string status; // installed | already_present | failed + std::filesystem::path payloadDir; + }; + + // The targets of one `install_targets` payload (`{"targets":[...]}`). + std::vector parse_install_targets(std::string_view payloadJson); + + // Record every resolved target whose payload exists. Written through a + // temporary file and a rename, so concurrent builds never read half a + // record. + void record_resolutions(const Env& env, std::span targets); + + // The recorded payload for that address, if the record exists and its + // directory is still the package's payload under this env's store. + std::optional + recorded_payload(const Env& env, const XpkgRef& ref); + // From compiler binary, climb parent dirs to find "xpkgs" directory. // Replaces 3 duplicate implementations in flags.cppm, ninja_backend.cppm, // stdmod.cppm. @@ -923,13 +953,23 @@ XpkgRef parse_xpkg_ref(std::string_view spec) { // Where that package's payload is, or nullopt if it is not installed. // -// A PINNED ref resolves to exactly its version and to nothing else. A build -// that asked for 1.8.12 and silently got 1.9.0 is the kind of answer that is -// only discovered later, in the artifact. An unpinned ref takes the highest -// version present — compared by numeric segments, because a plain string sort -// puts "0.4.11" before "0.4.9" and picking the wrong payload is silent. +// THE ANSWER IS XLINGS' ANSWER. The address was resolved by xlings, so the +// payload it selected is found with xlings' grammar (mcpp.xpkg_version), not +// with the Cargo grammar mcpp reads its own dependencies with. They disagree +// where it matters (#712): `libglvnd@1.7` installed `1.7.0.1`, and the Cargo +// reading of `1.7` could not see a four-segment directory at all, so +// `mcpp::xpkg_dir` answered "" for a payload that was on disk. +// +// A literal directory is tried first for every spelling: an installed version +// whose name does not parse -- `8.0.RC1` is a real one -- is addressable only +// that way. After that the request selects among the installed directories +// exactly as xlings would among index keys: a bare version of three or more +// segments is written-prefix equality (1.8.12 matches 1.8.12.x and never +// 1.9.0), one or two segments are a prefix range, operators are ranges. With +// no version the highest installed one is taken, ordered by the same grammar. std::optional xpkg_payload(const Env& env, const XpkgRef& ref) { + if (auto recorded = recorded_payload(env, ref)) return recorded; return xpkg_payload_at(xpkgs_base(env), ref); } @@ -939,54 +979,113 @@ xpkg_payload_at(const std::filesystem::path& xpkgsBase, const XpkgRef& ref) { const auto root = xpkgsBase / std::format("{}-x-{}", ref.ns, ref.name); std::error_code ec; if (!ref.version.empty()) { - // THE LITERAL DIRECTORY IS TRIED FIRST, AND IT IS TRIED FOR EVERY - // SPELLING. An installed version whose name does not parse as a SemVer - // — `8.0.RC1` is a real one — is addressable only this way, and a - // version that both names a directory and reads as a constraint (`=` - // is not part of any directory name, but a future operator might be) - // must resolve to the directory it names. auto p = root / ref.version; if (std::filesystem::is_directory(p, ec)) return p; - // A pinned version that is absent is NOT "some other version". - if (!mcpp::version_req::is_constraint(ref.version)) return std::nullopt; - auto req = mcpp::version_req::parse_req(ref.version); - if (!req) return std::nullopt; - if (!std::filesystem::is_directory(root, ec)) return std::nullopt; - std::optional pick; - std::optional pickV; - for (auto const& e : std::filesystem::directory_iterator(root, ec)) { - if (!e.is_directory(ec)) continue; - // A directory whose name does not parse cannot be TESTED against a - // requirement, so it is not a candidate for one. It remains - // addressable by the exact spelling above. - auto v = mcpp::version_req::parse_version(e.path().filename().string()); - if (!v) continue; - if (!mcpp::version_req::matches(*req, *v)) continue; - if (!pickV || *pickV < *v) { pick = e.path(); pickV = *v; } - } - return pick; } if (!std::filesystem::is_directory(root, ec)) return std::nullopt; - auto key_of = [](const std::string& s) { - std::vector k; - long long cur = 0; bool any = false; - for (char c : s) { - if (c >= '0' && c <= '9') { cur = cur * 10 + (c - '0'); any = true; } - else { if (any) k.push_back(cur); cur = 0; any = false; } - } - if (any) k.push_back(cur); - return k; + std::vector installed; + for (auto const& e : std::filesystem::directory_iterator(root, ec)) + if (e.is_directory(ec)) installed.push_back(e.path().filename().string()); + std::optional pick; + if (!ref.version.empty()) { + pick = mcpp::xpkg_version::select_best(installed, ref.version); + } else { + for (auto const& k : installed) + if (!pick || mcpp::xpkg_version::compare_keys(k, *pick) > 0) pick = k; + } + if (!pick) return std::nullopt; + return root / *pick; +} + + +// ─── Resolution records ──────────────────────────────────────────── + +namespace { + +// One file per (xpkgs base, address). The address is canonicalised through +// parse_xpkg_ref, so `libglvnd@1.7` and `xim:libglvnd@1.7` share a record, and +// the base is part of the key, so two homes never answer for each other. +std::filesystem::path record_path(const Env& env, const XpkgRef& ref) { + std::uint64_t h = 1469598103934665603ull; // FNV-1a + const auto key = std::format("{}\n{}:{}@{}", + xpkgs_base(env).generic_string(), ref.ns, ref.name, ref.version); + for (unsigned char ch : key) { h ^= ch; h *= 1099511628211ull; } + return mcpp::home::root() / "provisioned" / "resolved" + / std::format("{:016x}.json", h); +} + +} // namespace + +std::vector parse_install_targets(std::string_view payloadJson) { + std::vector out; + auto j = nlohmann::json::parse(payloadJson, nullptr, /*allow_exceptions=*/false); + if (!j.is_object() || !j.contains("targets") || !j["targets"].is_array()) return out; + auto str = [](const nlohmann::json& o, const char* k) { + return o.contains(k) && o[k].is_string() ? o[k].get() : std::string{}; }; - std::optional best; - std::vector bestKey; - for (auto const& e : std::filesystem::directory_iterator(root, ec)) { - if (!e.is_directory(ec)) continue; - auto k = key_of(e.path().filename().string()); - if (!best || k > bestKey) { best = e.path(); bestKey = k; } + for (auto const& t : j["targets"]) { + if (!t.is_object()) continue; + ResolvedTarget r; + r.request = str(t, "request"); + r.ns = str(t, "namespace"); + r.name = str(t, "name"); + r.version = str(t, "version"); + r.status = str(t, "status"); + r.payloadDir = str(t, "payload_dir"); + if (t.contains("revision") && t["revision"].is_number_integer()) + r.revision = t["revision"].get(); + if (!r.request.empty()) out.push_back(std::move(r)); } - return best; + return out; } +void record_resolutions(const Env& env, std::span targets) { + for (auto const& t : targets) { + if (t.status == "failed" || t.payloadDir.empty()) continue; + std::error_code ec; + if (!std::filesystem::is_directory(t.payloadDir, ec)) continue; + const auto path = record_path(env, parse_xpkg_ref(t.request)); + std::filesystem::create_directories(path.parent_path(), ec); + nlohmann::json j; + j["request"] = t.request; + j["namespace"] = t.ns; + j["name"] = t.name; + j["version"] = t.version; + j["revision"] = t.revision; + j["payload_dir"] = t.payloadDir.generic_string(); + auto tmp = path; + tmp += std::format(".{}.tmp", + std::chrono::steady_clock::now().time_since_epoch().count()); + { + std::ofstream o{tmp, std::ios::binary | std::ios::trunc}; + if (!o) continue; + o << j.dump(); + } + std::filesystem::rename(tmp, path, ec); + if (ec) std::filesystem::remove(tmp, ec); + } +} + +std::optional +recorded_payload(const Env& env, const XpkgRef& ref) { + if (ref.name.empty()) return std::nullopt; + std::ifstream in{record_path(env, ref), std::ios::binary}; + if (!in) return std::nullopt; + std::string text{std::istreambuf_iterator(in), {}}; + auto j = nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false); + if (!j.is_object() || !j.contains("payload_dir") || !j["payload_dir"].is_string()) + return std::nullopt; + const std::filesystem::path dir = j["payload_dir"].get(); + // A record answers only for a payload that is still there and still this + // package's, in this env's store. Anything else -- the payload was + // removed, the home moved -- falls through to the grammar, and the + // provisioning check treats "no answer" as not installed (#716). + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) return std::nullopt; + const auto root = xpkgs_base(env) / std::format("{}-x-{}", ref.ns, ref.name); + if (dir.parent_path().lexically_normal() != root.lexically_normal()) return std::nullopt; + return dir; +} std::optional xpkgs_from_compiler(const std::filesystem::path& compilerBin) { diff --git a/tests/e2e/788_emit_host_tool_unbuilt_is_a_warning.sh b/tests/e2e/788_emit_defers_an_unbuilt_host_tool.sh similarity index 53% rename from tests/e2e/788_emit_host_tool_unbuilt_is_a_warning.sh rename to tests/e2e/788_emit_defers_an_unbuilt_host_tool.sh index d589b2711..e03f12949 100755 --- a/tests/e2e/788_emit_host_tool_unbuilt_is_a_warning.sh +++ b/tests/e2e/788_emit_defers_an_unbuilt_host_tool.sh @@ -1,20 +1,21 @@ #!/usr/bin/env bash # requires: gcc python3 -# 788 -- under `emit build-database`, a host tool that fails to build is a -# warning, not a refusal that costs the plan (mcpp-community/mcpp#699 item 2, -# design 2026-09-26 §4.5). +# 788 -- `emit build-database` builds no host tool (SPEC-005 R2.5 v1.4, +# mcpp-community/mcpp#707; before it, #699 item 2). # # `user` requests the host tool `t` of package `tool`, whose build carries a -# blocking `check` action that always fails (docs/30's `dep_bin` pattern, and -# e2e 315's fixture for a blocking check). Before this fix `emit` in `user` -# reported `MCPP_BUILD_DATABASE_PLAN_FAILED` with no `data` at all (measured -# in the design record, Appendix A.3) -- the same failure that correctly ends -# `mcpp build`, which does not plan around missing tools. Criteria: -# A. `emit --format json` in `user`: exit 0, `data` present with `user`'s -# set, and exactly one warning `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT` -# naming the tool, its package and the first line of the failure. -# B. `mcpp build` in `user` still exits non-zero: the tool's build itself, -# and its blocking check, are unchanged. +# blocking `check` action that fails (docs/30's `dep_bin` pattern, and e2e 315's +# fixture for a blocking check). Planning describes a build and performs none +# (R2.2), and a tool sub-build is a whole compile of another package with its +# own actions: on a fresh store, one `emit` used to compile the tool and run +# its check. Criteria: +# A. `emit --format json` in `user` with the tool not in the store: exit 0, +# `data` present with `user`'s set, exactly one note +# `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED` naming the tool and its +# package, and neither the tool's build nor its check ran. +# B. `mcpp build` in `user` still exits non-zero on the failing check: the +# tool's build itself is unchanged. +# C. Once the tool is in the store, `emit` uses it and reports nothing. set -e TMP=$(mktemp -d) @@ -41,7 +42,7 @@ exit 1 EOF chmod +x "$TMP/tool/check.sh" # Same shape as e2e 315's blocking-check fixture: a `check` action, marked -# `blocking = true`, that always fails. +# `blocking = true`, that fails. cat > "$TMP/tool/build.mcpp" <<'EOF' #include import mcpp; @@ -84,11 +85,14 @@ assert sets == ["user"], sets diags = e["diagnostics"] assert len(diags) == 1, diags diag = diags[0] -assert diag["code"] == "MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT", diag -assert diag["severity"] == "warning", diag -assert "t" in diag["message"] and "tool" in diag["message"], diag["message"] +assert diag["code"] == "MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED", diag +assert diag["severity"] == "note", diag +assert "'t'" in diag["message"] and "'tool'" in diag["message"], diag["message"] EOF -echo "ok: A, emit succeeds with user's set and one host-tool warning" +if grep -q "Building.*host tool\|the check says no" a.err; then + fail "A: planning built the tool or ran its check" a.err +fi +echo "ok: A, emit defers the tool and builds nothing" # ── B ────────────────────────────────────────────────────────────────────── set +e @@ -99,4 +103,18 @@ set -e grep -q "the check says no" build.log || fail "B: the check's own failure is not on the build's output" build.log echo "ok: B, mcpp build still fails on the same blocking check" -echo "PASS: 788_emit_host_tool_unbuilt_is_a_warning" +# ── C ────────────────────────────────────────────────────────────────────── +printf '#!/usr/bin/env bash\nexit 0\n' > "$TMP/tool/check.sh" +"$MCPP" build > build2.log 2>&1 || fail "C: the build with a passing check failed" build2.log +set +e +"$MCPP" emit build-database --format json > c.json 2> c.err +rc=$? +set -e +[ "$rc" = 0 ] || fail "C: emit exited $rc, expected 0" c.err c.json +"$PY" - c.json <<'EOF' || fail "C: the envelope" c.json +import json, sys +e = json.load(open(sys.argv[1])) +assert e["diagnostics"] == [], e["diagnostics"] +EOF +echo "ok: C, a stored tool is used and nothing is reported" +echo "PASS: 788_emit_defers_an_unbuilt_host_tool" diff --git a/tests/e2e/798_a_rule_applies_through_a_source_it_claims.sh b/tests/e2e/798_a_rule_applies_through_a_source_it_claims.sh new file mode 100755 index 000000000..45187c8ea --- /dev/null +++ b/tests/e2e/798_a_rule_applies_through_a_source_it_claims.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# requires: gcc +# 798_a_rule_applies_through_a_source_it_claims.sh — mcpp#715. +# +# A feature activates a rule for a consumer; whether the rule has anything to +# do there is answered by the consumer's sources. Until 2026.9.27.1 the engine +# synthesised a build program for every active rule, so a package that enabled +# a rule feature only to import its module -- no device source, no +# `build.mcpp` -- compiled and ran a program that could only report "nothing +# to do", and printed `Rules` for it, on every configure. +# +# Two legs over examples/12's rule package, which is the engine's own +# reference rule and needs no network: +# 1. a consumer with a `.toy` device source and no `build.mcpp`: the rule is +# reported, the synthesised program runs it, and the kernel reaches the +# program (the direction the fix must not break); +# 2. a consumer that activates the same feature with no `.toy` source: no +# `Rules` line and no synthesised program. +set -e + +SRC="$(cd "$(dirname "$0")/../.." && pwd)/examples/12-a-new-device-language" +[[ -d "$SRC" ]] || { echo "FAIL: $SRC is missing"; exit 1; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cp -r "$SRC" "$TMP/ex" +find "$TMP/ex" -maxdepth 3 -type d -name target -exec rm -rf {} + 2>/dev/null || true +find "$TMP/ex" -maxdepth 3 -name mcpp.lock -delete 2>/dev/null || true + +# ── 1. a claimed source: the rule runs through the synthesised program ────── +cd "$TMP/ex/app" +rm -f build.mcpp +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: leg 1 did not build"; exit 1; } +grep -q 'Rules.*example.rules.toy' b1.log || { + cat b1.log; echo "FAIL: leg 1 did not report the rule it applied"; exit 1; } +find target -name build.mcpp | grep -q . || { + echo "FAIL: leg 1 synthesised no build program"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == *"= 42"* ]] || { + echo "FAIL: leg 1: the kernel did not reach the program: '$out'"; exit 1; } + +# ── 2. no claimed source: nothing is synthesised ─────────────────────────── +mkdir -p "$TMP/ex/importonly/src" +cd "$TMP/ex/importonly" +cat > mcpp.toml <<'EOF' +[package] +name = "importonly" +version = "0.1.0" + +[language] +standard = "c++23" + +[dependencies] +example.rules-toy = { path = "../rules-toy", features = ["rules-toy"] } + +[build] +sources = ["src/*.cpp"] +EOF +cat > src/main.cpp <<'EOF' +#include +int main() { std::printf("IMPORT_ONLY_OK\n"); } +EOF +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: leg 2 did not build"; exit 1; } +if grep -q 'Rules' b2.log; then + cat b2.log; echo "FAIL: leg 2 reported a rule that claims none of its sources"; exit 1 +fi +if find target -name build.mcpp | grep -q .; then + find target -name build.mcpp + echo "FAIL: leg 2 synthesised a build program with nothing to do"; exit 1 +fi +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "IMPORT_ONLY_OK" ]] || { echo "FAIL: leg 2 program: '$out'"; exit 1; } + +echo "OK" diff --git a/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh b/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh new file mode 100755 index 000000000..6f2d4958a --- /dev/null +++ b/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# 799_an_action_runs_with_its_env_and_cwd.sh — mcpp#708, protocol 13. +# +# An action's command is an argv with no shell assumed (SPEC-007 R3.1), so a +# generator configured through environment variables, or one that must run in +# a particular directory, had no portable way to be declared: `NAME=value cmd` +# and `cd dir &&` are shell syntax. `mcpp::action::env(name, value)` and +# `mcpp::action::cwd(dir)` state both, and the engine's wrapper applies them. +# +# Criteria: +# A. the command sees the variable, and runs in the package-relative +# directory `cwd` names; +# B. the declared output lands where it was declared, not under `cwd`; +# C. changing the variable's value re-runs the action. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +mkdir -p src tools + +cat > mcpp.toml <<'EOF' +[package] +name = "envcwd" +version = "0.1.0" +EOF +cat > src/main.cpp <<'EOF' +#include +int main() { std::printf("ENV_CWD_OK\n"); } +EOF + +write_program() { # $1 = the value of GREETING + cat > build.mcpp < +import mcpp; +int main() { + const std::string out = std::string(mcpp::out_dir()) + "/probe.txt"; + mcpp::action a; + a.id = "probe"; + a.role = mcpp::roles::check; + a.env("GREETING", "$1") + .cwd("tools") + .arg("sh").arg("-c") + .arg("printf '%s\\\\n' \"\$GREETING\" > \"\$1\"; pwd -P >> \"\$1\"") + .arg("sh").arg(out.c_str()) + .output(out.c_str()) + .submit(); +} +EOF +} + +probe() { find target -name probe.txt | head -1; } + +write_program hello +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } +p="$(probe)" +[[ -n "$p" ]] || { cat b1.log; echo "FAIL: B: the declared output was not written"; exit 1; } +[[ "$(sed -n 1p "$p")" == "hello" ]] || { + cat "$p"; echo "FAIL: A: the command did not see GREETING"; exit 1; } +want="$(cd tools && pwd -P)" +[[ "$(sed -n 2p "$p")" == "$want" ]] || { + cat "$p"; echo "FAIL: A: the command did not run in $want"; exit 1; } +[[ ! -e tools/probe.txt ]] || { echo "FAIL: B: the output landed under cwd"; exit 1; } +echo "ok: A, B" + +write_program goodbye +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: rebuild failed"; exit 1; } +[[ "$(sed -n 1p "$(probe)")" == "goodbye" ]] || { + cat "$(probe)"; echo "FAIL: C: a changed value did not re-run the action"; exit 1; } +echo "ok: C" + +echo "PASS: 799_an_action_runs_with_its_env_and_cwd" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index 616d8e67e..be6d0c5d6 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -852,6 +852,47 @@ TEST(BuildDirectives, DecodeActionRoundTripsOutputDir) { EXPECT_EQ(a->outputDir, "out/prefix"); } +// mcpp#708, protocol 13: `env` and `cwd` round-trip like `output_dir` -- +// present when the typed builder set them, absent otherwise, and an action +// without them decodes exactly as it did under protocol 12. +TEST(BuildDirectives, DecodeActionRoundTripsEnvAndCwd) { + auto d = parse( + "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," + "\"description\":\"\",\"blocking\":false," + "\"env\":[\"GEN_MODE=fast\",\"EMPTY=\"],\"cwd\":\"third_party/gen\"," + "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," + "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); + auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); + ASSERT_TRUE(a.has_value()); + ASSERT_EQ(a->env.size(), 2u); + EXPECT_EQ(a->env[0], "GEN_MODE=fast"); + EXPECT_EQ(a->env[1], "EMPTY="); // an empty value is a value + EXPECT_EQ(a->cwd, "third_party/gen"); + + auto plain = parse( + "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," + "\"description\":\"\",\"blocking\":false," + "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," + "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); + auto b = dirs::decode_action(plain.at(dirs::Slot::Actions).front()); + ASSERT_TRUE(b.has_value()); + EXPECT_TRUE(b->env.empty()); + EXPECT_TRUE(b->cwd.empty()); +} + +// An `env` entry with no name is refused, and the refusal names the entry. +TEST(BuildDirectives, AnEnvEntryWithoutANameIsRefused) { + auto d = parse( + "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," + "\"description\":\"\",\"blocking\":false,\"env\":[\"=oops\"]," + "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," + "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); + EXPECT_FALSE(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value()); + const auto why = dirs::action_error(d); + EXPECT_NE(why.find("=oops"), std::string::npos) << why; + EXPECT_NE(why.find("NAME=value"), std::string::npos) << why; +} + // A `prepare` action with no `output_dir` is refused: it would be a stamp // and nothing else, indistinguishable from a `check` that forgot // `blocking = true`. @@ -1021,13 +1062,13 @@ TEST(BuildDirectives, DeployRowIsProtocolElevenWithLinkGlobalScopeAndATag) { EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); EXPECT_EQ(def->sinceProtocol, 11); EXPECT_FALSE(def->tag.empty()); - EXPECT_EQ(dirs::kProtocolVersion, 12); + EXPECT_EQ(dirs::kProtocolVersion, 13); } -TEST(BuildDirectives, ProtocolTwelveIsAcceptedAndThirteenIsNot) { - auto ok = parse("mcpp:protocol=12\n"); +TEST(BuildDirectives, ProtocolThirteenIsAcceptedAndFourteenIsNot) { + auto ok = parse("mcpp:protocol=13\n"); EXPECT_FALSE(dirs::protocol_error(ok).has_value()); - auto no = parse("mcpp:protocol=13\n"); + auto no = parse("mcpp:protocol=14\n"); EXPECT_TRUE(dirs::protocol_error(no).has_value()); } diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index fc2d80f62..0cccbc781 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -239,8 +239,9 @@ TEST(XpkgPayload, AConstrainedRefTakesTheHighestInstalledVersionSatisfyingIt) { // Bounded on both sides, so the highest is NOT the answer -- a test that // only used a lower bound would pass on an implementation that ignored the - // requirement and took the newest. - auto ranged = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=8.0.0, <8.7.0")); + // requirement and took the newest. Spelled the way xlings reads a + // conjunction (space-separated), because xlings is what resolved it. + auto ranged = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=8.0.0 <8.7.0")); ASSERT_TRUE(ranged.has_value()); EXPECT_EQ(ranged->filename(), "8.5.0"); @@ -258,7 +259,7 @@ TEST(XpkgPayload, AConstrainedRefTakesTheHighestInstalledVersionSatisfyingIt) { } // An installed version whose directory name is not a SemVer is addressable by -// its exact spelling and by nothing else. `8.0.RC1` is a real CANN version. +// its exact spelling. `8.0.RC1` is a real CANN version. TEST(XpkgPayload, AnUnparseableVersionIsStillAddressableExactly) { namespace xp = mcpp::xlings::paths; auto base = std::filesystem::temp_directory_path() @@ -270,13 +271,48 @@ TEST(XpkgPayload, AnUnparseableVersionIsStillAddressableExactly) { ASSERT_TRUE(exact.has_value()); EXPECT_EQ(exact->filename(), "8.0.RC1"); - // It cannot be TESTED against a requirement, so it does not answer one. - EXPECT_FALSE(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=1.0")) + // xlings' grammar reads it as 8, 0, RC, 1, so a range can test it, and the + // lookup answers what xlings selected. A key with no numeric segment at + // all is a name, and a name never answers a range. + auto ranged = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=1.0")); + ASSERT_TRUE(ranged.has_value()); + EXPECT_EQ(ranged->filename(), "8.0.RC1"); + std::filesystem::create_directories(base / "xim-x-named" / "nightly"); + EXPECT_FALSE(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:named@>=1.0")) .has_value()); std::filesystem::remove_all(base); } +// A BARE VERSION MEANS WHAT XLINGS MEANT BY IT (#712). The address +// `libglvnd@1.7` installed `1.7.0.1`: one or two segments are a prefix range, +// three or more are written-prefix equality, and a fourth segment is an +// ordinary version. The lookup answered "" for every one of these before. +TEST(XpkgPayload, ABareVersionSelectsWhatXlingsSelected) { + namespace xp = mcpp::xlings::paths; + auto base = std::filesystem::temp_directory_path() + / std::format("mcpp-xpkg-test5-{}", ::getpid()); + std::filesystem::remove_all(base); + for (auto v : { "1.7.0.1", "1.2.0", "1.2.5", "1.3.0", "12.9.1.4", "12.9.10" }) + std::filesystem::create_directories(base / "xim-x-demo" / v); + + auto pick = [&](std::string_view ver) -> std::string { + auto p = xp::xpkg_payload_at(base, + xp::parse_xpkg_ref(std::format("xim:demo@{}", ver))); + return p ? p->filename().string() : std::string{}; + }; + EXPECT_EQ(pick("1.7"), "1.7.0.1"); // prefix range [1.7, 1.8) + EXPECT_EQ(pick("1.7.0"), "1.7.0.1"); // written prefix 1.7.0 + EXPECT_EQ(pick("1.2"), "1.2.5"); // the highest 1.2.x, never 1.3 + EXPECT_EQ(pick("1.2.0"), "1.2.0"); // three segments: 1.2.0 exactly + EXPECT_EQ(pick("12.9.1"), "12.9.1.4"); // not 12.9.10 + EXPECT_EQ(pick("1"), "1.7.0.1"); // [1, 2) + EXPECT_EQ(pick("1.2.4"), ""); // absent is absent + EXPECT_EQ(pick("1.8.12"), ""); // never slides to a later minor + + std::filesystem::remove_all(base); +} + TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { using mcpp::build::xpkg_env_var; // The two sides of the channel must agree; drifting apart would make the diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 21021d267..21bfa3237 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -5778,6 +5778,41 @@ kind = "shared" EXPECT_EQ(m.targets[0].linkageDefault, "shared"); } +// #714: `sources = []` states that the default build compiles nothing, so a +// module interface under `src/` is not a library of that build. A build-logic +// package keeps its module behind a feature that host-module consumers +// request; inferring a library for it made every build of the package link an +// archive with no inputs. A glob that happens to match nothing is a different +// statement and keeps the inferred library (and #533's empty-link refusal). +TEST(Manifest, AnExplicitlyEmptySourceListInfersNoLibrary) { + auto dir = std::filesystem::temp_directory_path() + / std::format("mcpp_empty_sources_{}", std::random_device{}()); + std::filesystem::create_directories(dir / "src"); + std::ofstream(dir / "src" / "buildlib.cppm") << "export module buildlib;\n"; + { + std::ofstream(dir / "mcpp.toml") + << "[package]\nname = \"buildlib\"\nversion = \"0.1.0\"\n\n" + "[build]\nsources = []\n\n" + "[features.host]\nsources = [\"src/buildlib.cppm\"]\n"; + } + auto empty = mcpp::manifest::load(dir / "mcpp.toml"); + ASSERT_TRUE(empty) << (empty ? "" : empty.error().message); + EXPECT_TRUE(empty->targets.empty()); + + { + std::ofstream(dir / "mcpp.toml") + << "[package]\nname = \"buildlib\"\nversion = \"0.1.0\"\n\n" + "[build]\nsources = [\"srcs/**/*.cppm\"]\n"; + } + auto typo = mcpp::manifest::load(dir / "mcpp.toml"); + ASSERT_TRUE(typo) << (typo ? "" : typo.error().message); + ASSERT_EQ(typo->targets.size(), 1u); + EXPECT_EQ(typo->targets[0].kind, mcpp::manifest::Target::Library); + + std::error_code ec; + std::filesystem::remove_all(dir, ec); +} + // #649 E6: a consumer reads "every declared target is a program" as "a tool // provider that contributes nothing to my graph". A target list the loader // INFERRED from the tree is not that statement, so the loader says which of diff --git a/tests/unit/test_tool_store.cpp b/tests/unit/test_tool_store.cpp index 3020ec329..55c9f2564 100644 --- a/tests/unit/test_tool_store.cpp +++ b/tests/unit/test_tool_store.cpp @@ -74,6 +74,25 @@ TEST(ToolStoreStamp, BuildProductsAndTheVersionControlDirectoryDoNotCount) { EXPECT_EQ(before, after) << "build products, .git, .mcpp and the compile database are excluded"; } +// #705: a consumer nested in the tool's tree -- a fixture, an example -- wrote +// its own sources and outputs under the tool's root, so every build of the +// consumer moved the tool's key and rebuilt the tool. A directory with its own +// mcpp.toml is another package and is not this tool's input. A directory +// without one is still an ordinary part of the tree. +TEST(ToolStoreStamp, ANestedPackageIsNotPartOfTheTree) { + Tree t("mcpp_tool_store_stamp_nested"); + fs::create_directories(t.root / "tests" / "consumer" / "src"); + t.write("tests/consumer/mcpp.toml", "[package]\nname = \"consumer\"\n"); + const auto before = mcpp::build::tool_store::tree_stamp(t.root); + t.write("tests/consumer/src/main.cpp", "int main() { return 0; }\n"); + t.write("tests/consumer/build.mcpp", "// generated by the consumer's prepare\n"); + EXPECT_EQ(before, mcpp::build::tool_store::tree_stamp(t.root)); + + fs::create_directories(t.root / "tests" / "data"); + t.write("tests/data/input.txt", "an ordinary input of the tool"); + EXPECT_NE(before, mcpp::build::tool_store::tree_stamp(t.root)); +} + // The sub-build scratch (mcpp#641, item 3). Every object path the sub-build // writes is appended to it, so it spends no length on names the entry already // records; it still separates two consumers of one entry, and a re-run by the From aac072c7f8d7afe53dfcb4a9b4ba60c6f100df28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 00:07:28 +0000 Subject: [PATCH 2/4] Add feature tools (#709) and target artifact dependencies (#711) #709 `[features.] tools = [""]` names the package's own programs a feature needs on the build machine. On every edge into the package, the active features' tools are requested as if the edge had written `tools = [...]`: built once for the host, reachable through `mcpp::dep_bin`. A name that is not a `kind = "bin"` target of the package is refused at load, naming the bin targets. #711 `x = { ..., artifacts = [""] }` asks for the dependency's program built for the consumer's target and profile, as a link unit of the consumer's own plan, written to `bin/` beside its programs. The unit links the dependency's objects and those of every package it reaches; a package reached only through artifact edges is kept out of the consumer's images, since the edge takes the program and not the code. A package asked for artifacts is scanned and configured here even when all its targets are programs. `${mcpp.artifact:/}` names the output in actions, `mcpp pack` stages it beside the program, and `mcpp run` and the program-selection sites ignore it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5 --- modules/manifest/src/dep_spec.cppm | 9 + modules/manifest/src/toml.cppm | 39 +++- modules/manifest/src/types.cppm | 8 + src/build/execute.cppm | 4 + src/build/plan.cppm | 199 ++++++++++++++++++ src/build/prepare.cppm | 96 ++++++++- src/pack/pipeline.cppm | 10 +- .../800_a_feature_provides_its_host_tools.sh | 131 ++++++++++++ ...cy_program_is_shipped_with_the_consumer.sh | 122 +++++++++++ tests/unit/test_manifest.cpp | 33 ++- 10 files changed, 645 insertions(+), 6 deletions(-) create mode 100755 tests/e2e/800_a_feature_provides_its_host_tools.sh create mode 100755 tests/e2e/801_a_dependency_program_is_shipped_with_the_consumer.sh diff --git a/modules/manifest/src/dep_spec.cppm b/modules/manifest/src/dep_spec.cppm index e7137b31c..6a768ccc1 100644 --- a/modules/manifest/src/dep_spec.cppm +++ b/modules/manifest/src/dep_spec.cppm @@ -67,6 +67,15 @@ struct DependencySpec { // Empty by default: the cost (e.g. protobuf's libprotoc is ~157 extra TUs) // is paid by the consumer, so nothing is built unless someone asks. std::vector tools; + // mcpp#711: programs of the dependency that this consumer SHIPS -- the + // names of its `kind = "bin"` targets, built for the consumer's TARGET and + // profile as link units of the consumer's own plan, beside its programs in + // `bin/`. The counterpart of `tools` (built for the build machine, in a + // nested sub-build): a GUI that carries its updater executable wants the + // updater for the machine the GUI runs on. An edge that names artifacts + // takes the dependency's programs and not its code: nothing of the + // package is linked into the consumer through this edge. + std::vector artifacts; // #355 step 5: compile this dependency's lib-root module interface FOR THE // HOST and make it importable from the consumer's build.mcpp — the // mechanism behind reusable build rules distributed as ordinary packages diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 44d86b1c0..8d298d10b 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1014,6 +1014,14 @@ std::expected parse_string(std::string_view content, if (!devExts.empty()) m.featureDeviceExtensions[fname] = std::move(devExts); } + // The host tools this feature makes available (mcpp#709). Which + // targets exist is known only after target inference, so the + // names are checked in `load`, where the list is complete. + { + std::vector tools; + read_str_array(ft, "tools", tools); + if (!tools.empty()) m.featureTools[fname] = std::move(tools); + } // The module a consumer's build program imports for this rule. if (auto it = ft.find("rule_module"); it != ft.end() && it->second.is_string()) @@ -1101,6 +1109,8 @@ std::expected parse_string(std::string_view content, // and `tools = [...]` made ordinary builds of rule packages // routine. "device_extensions", "rule_module", + // mcpp#709: the host tools a feature makes available. + "tools", }; for (auto& [fkey, fignored] : fval.as_table()) { (void)fignored; @@ -1842,7 +1852,7 @@ std::expected parse_string(std::string_view content, || k == "rev" || k == "tag" || k == "branch" || k == "features" || k == "default-features" || k == "workspace" || k == "visibility" - || k == "backend" || k == "tools" + || k == "backend" || k == "tools" || k == "artifacts" || k == "host-module" || k == "reexport" || k == "linkage"; }; @@ -2018,6 +2028,12 @@ std::expected parse_string(std::string_view content, for (auto& tv : it->second.as_array()) if (tv.is_string()) spec.tools.push_back(tv.as_string()); } + // mcpp#711: `artifacts = ["updater"]` -- the dependency's programs, + // built for this package's target and shipped beside its own. + if (auto it = sub.find("artifacts"); it != sub.end() && it->second.is_array()) { + for (auto& av : it->second.as_array()) + if (av.is_string()) spec.artifacts.push_back(av.as_string()); + } // #355 step 5: `host-module = true` — make this dependency's lib-root // module importable from build.mcpp (reusable rules as packages). if (auto it = sub.find("host-module"); it != sub.end() && it->second.is_bool()) { @@ -4329,6 +4345,27 @@ std::expected load(const std::filesystem::path& path, // M5.0: defaults + target inference (uses filesystem context relative to mcpp.toml). apply_defaults_and_infer(*m, path.parent_path()); + // `[features]..tools` names this package's own programs (mcpp#709), and + // an inferred target exists only after the inference above. + for (auto it = m->featureTools.begin(); it != m->featureTools.end(); ++it) { + for (auto const& name : it->second) { + const Target* target = nullptr; + for (std::size_t i = 0; i < m->targets.size(); ++i) + if (m->targets[i].name == name) { target = &m->targets[i]; break; } + if (target != nullptr && target->kind == Target::Binary) continue; + std::string bins; + for (std::size_t i = 0; i < m->targets.size(); ++i) + if (m->targets[i].kind == Target::Binary) + bins += (bins.empty() ? "" : ", ") + m->targets[i].name; + return std::unexpected(ManifestError{std::format( + "[features.{}] tools names '{}', which is not a `kind = \"bin\"` " + "target of this package (its bin targets: {}). A feature makes " + "the package's own programs available as host tools.", + it->first, name, bins.empty() ? std::string("none") : bins), + path, 0, 0}); + } + } + // A `[target..targets.]` row names a target, and an undeclared // library target exists only after the inference above, so the name is // checked here rather than where the row is parsed. diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index b8045c92a..4e9f94a82 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -2070,6 +2070,14 @@ struct Manifest { // feature is a build rule, here is what it compiles and here is how to // reach it". A feature with one and not the other is refused at parse time. std::map featureRuleModule; + // `[features]..tools` -- this package's own `kind = "bin"` targets that + // the feature makes available as host tools (mcpp#709). A consumer on whose + // behalf the feature is active receives each one as if its dependency edge + // had written `tools = [...]`: built once for the build machine, reachable + // from its build program through `mcpp::dep_bin`. The key moves a request a + // package already knows it needs into the package, so a consumer that + // enables a rule does not also have to name the rule's tools. + std::map> featureTools; // Feature System v2 Stage 2a — dependencies activated by a feature. A dep // declared ONLY here is optional: pulled into the resolution worklist only // when its feature is active (root --features or a dep spec's features=[...]). diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 24ff538be..fd55db407 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -562,6 +562,9 @@ compute_run_targets(const mcpp::build::BuildPlan& plan) { std::vector> out; for (auto& lu : plan.linkUnits) { if (lu.kind != mcpp::build::LinkUnit::Binary) continue; + // A dependency's program shipped with this one (mcpp#711) is not a + // program of this package, and `mcpp run` does not choose it. + if (!lu.artifactOf.empty()) continue; out.emplace_back(lu.targetName, lu.output.generic_string()); } return out; @@ -2219,6 +2222,7 @@ export int build_run_target(const std::optional& targetName, const mcpp::build::LinkUnit* chosen = nullptr; for (auto& lu : ctx->plan.linkUnits) { if (lu.kind != mcpp::build::LinkUnit::Binary) continue; + if (!lu.artifactOf.empty()) continue; // mcpp#711; see compute_run_targets if (targetName && lu.targetName != *targetName) continue; chosen = &lu; if (targetName) break; diff --git a/src/build/plan.cppm b/src/build/plan.cppm index f9f0f68bc..a6cc965cc 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -102,6 +102,11 @@ struct LinkUnit { // `sycl::` instantiations, and the process then had two copies of the // island. Latent until a SYCL project first had a shared dependency. bool dependencyOwned = false; + // mcpp#711: the dependency whose program this is, when a consumer's edge + // asked for it with `artifacts = [...]` (qualified package name). Empty for + // every other unit. A dependency-owned `Binary` is always one of these; the + // name is what `${mcpp.artifact:/}` is resolved against. + std::string artifactOf; // Normally relative to plan.outputDir. A `role = "object"` action's outputs // land here ABSOLUTE, on purpose: ninja identifies a file by the string an // edge declares, and the action edge declares whatever prepare_actions @@ -2231,6 +2236,96 @@ make_plan(const mcpp::manifest::Manifest& manifest, plan.linkUnits.push_back(std::move(lu)); } + // THE PROGRAMS A CONSUMER SHIPS FROM ITS DEPENDENCIES (mcpp#711). + // + // An edge `x = { ..., artifacts = ["updater"] }` asks for the dependency's + // `bin` target built for THIS plan's target and profile, as a link unit of + // this plan -- not a host tool, which is built for the build machine by a + // nested sub-build. Collected here, before the root's link units, because + // the same edges also decide what the root must NOT link: an artifact edge + // takes the dependency's program and none of its code, so a package the + // root reaches only through artifact edges stays out of the root's images. + struct ArtifactRequest { + std::size_t packageIndex = 0; + mcpp::manifest::Target target; + }; + std::vector artifactRequests; + std::set> artifactEdges; // (consumer, dependency) + for (std::size_t i = 0; i < packages.size(); ++i) { + auto const& deps = i == 0 ? manifest.dependencies : packages[i].manifest.dependencies; + for (auto const& [depName, spec] : deps) { + if (spec.artifacts.empty()) continue; + std::optional j; + for (auto const& candidate : dependency_name_candidates(depName, spec)) + if (auto it = packageIndexByName.find(candidate); + it != packageIndexByName.end() && it->second != i) { j = it->second; break; } + if (!j) continue; + artifactEdges.insert({i, *j}); + auto const& dm = packages[*j].manifest; + for (auto const& name : spec.artifacts) { + auto t = std::ranges::find_if(dm.targets, [&](const mcpp::manifest::Target& x) { + return x.name == name; + }); + if (t == dm.targets.end() || t->kind != mcpp::manifest::Target::Binary) { + std::string bins; + for (auto const& x : dm.targets) + if (x.kind == mcpp::manifest::Target::Binary) + bins += (bins.empty() ? "" : ", ") + x.name; + return std::unexpected(std::format( + "dependency '{}' names the artifact '{}', which is not a " + "`kind = \"bin\"` target of '{}' (its bin targets: {})", + depName, name, qualified_package_name(dm), + bins.empty() ? std::string("none") : bins)); + } + const bool seen = std::ranges::any_of(artifactRequests, + [&](const ArtifactRequest& r) { + return r.packageIndex == *j && r.target.name == name; + }); + if (!seen) artifactRequests.push_back({*j, *t}); + } + } + } + // Reached through a non-artifact edge from the root (its dependencies, + // dev- and build-dependencies included), versus reached only through an + // artifact edge. Only the second set is withheld from the root's images, + // so every package this plan linked before keeps being linked. + std::set artifactOnlyPackages; + if (!artifactRequests.empty()) { + std::set viaCode{0}, viaArtifact; + std::vector work{0}; + auto seed = [&](const auto& m) { + for (auto const& [depName, spec] : m) + for (auto const& candidate : dependency_name_candidates(depName, spec)) + if (auto it = packageIndexByName.find(candidate); + it != packageIndexByName.end() && it->second != 0) { + if (!spec.artifacts.empty()) break; + if (viaCode.insert(it->second).second) work.push_back(it->second); + break; + } + }; + seed(manifest.devDependencies); + seed(manifest.buildDependencies); + while (!work.empty()) { + const auto i = work.back(); work.pop_back(); + if (auto it = directPackageDeps.find(i); it != directPackageDeps.end()) + for (auto j : it->second) { + if (artifactEdges.contains({i, j})) continue; + if (viaCode.insert(j).second) work.push_back(j); + } + } + for (auto const& r : artifactRequests) + if (viaArtifact.insert(r.packageIndex).second) work.push_back(r.packageIndex); + while (!work.empty()) { + const auto i = work.back(); work.pop_back(); + if (auto it = directPackageDeps.find(i); it != directPackageDeps.end()) + for (auto j : it->second) + if (viaArtifact.insert(j).second) work.push_back(j); + } + for (auto i : viaArtifact) + if (i != 0 && !viaCode.contains(i)) + artifactOnlyPackages.insert(qualified_package_name(packages[i].manifest)); + } + // 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 @@ -2295,6 +2390,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto& cu : plan.compileUnits) { if (sharedDepPackages.contains(cu.packageName)) continue; if (placedInImage.contains(cu.packageName)) continue; + if (artifactOnlyPackages.contains(cu.packageName)) continue; if (mcpp::links_unconditionally(cu.kind)) { lu.objects.push_back(cu.object); } @@ -2412,6 +2508,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto& cu : plan.compileUnits) { if (sharedDepPackages.contains(cu.packageName)) continue; if (placedInImage.contains(cu.packageName)) continue; + if (artifactOnlyPackages.contains(cu.packageName)) continue; if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; // own entry: already added above if (entryFilesAcrossTargets.contains(cu.source)) continue; // foreign entry: skip @@ -2432,6 +2529,108 @@ make_plan(const mcpp::manifest::Manifest& manifest, plan.linkUnits.push_back(std::move(lu)); } + // 5. The dependency programs requested with `artifacts` (mcpp#711). + // + // Each is the dependency's own `bin` target, linked the way the package's + // own build links it: its objects and those of every package it reaches, + // and its entry. Built in THIS plan, so it follows the consumer's target, + // profile and toolchain -- a cross build ships a program for the machine + // the consumer runs on, and nothing is built twice. Its output is + // `bin/`, beside the consumer's programs, which is where a program + // that launches it looks for it and what `mcpp pack` stages with it. + for (auto const& r : artifactRequests) { + auto const& pkg = packages[r.packageIndex]; + const auto owner = qualified_package_name(pkg.manifest); + LinkUnit lu; + lu.targetName = r.target.name; + lu.kind = LinkUnit::Binary; + lu.dependencyOwned = true; + lu.artifactOf = owner; + lu.output = target_output(r.target, naming); + lu.windowsSubsystem = r.target.windowsSubsystem; + lu.windowsEntry = r.target.windowsEntry; + lu.loaderTagFlag = loader_tag_flag(lu.kind); + for (auto const& other : plan.linkUnits) + if (other.output == lu.output) + return std::unexpected(std::format( + "the artifact '{}' of '{}' would be written to '{}', which " + "target '{}' of this build also produces", + r.target.name, owner, lu.output.generic_string(), other.targetName)); + + std::set closure{owner}; + { + std::vector work{r.packageIndex}; + std::set seen{r.packageIndex}; + while (!work.empty()) { + const auto i = work.back(); work.pop_back(); + if (auto it = directPackageDeps.find(i); it != directPackageDeps.end()) + for (auto j : it->second) + if (seen.insert(j).second) { + work.push_back(j); + closure.insert(qualified_package_name(packages[j].manifest)); + } + } + } + for (auto const& cu : plan.compileUnits) { + if (!closure.contains(cu.packageName)) continue; + if (sharedDepPackages.contains(cu.packageName)) continue; + if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object); + } + if (!r.target.main.empty()) { + const auto entry = pkg.root / r.target.main; + lu.entryMain = entry; + // The package's `sources` glob normally scanned its entry already + // (every other image leaves it out as a foreign entry); when it did + // not, the entry is compiled here with the package's own flags. + std::optional entryObject; + for (auto const& cu : plan.compileUnits) + if (cu.source == entry) { entryObject = cu.object; break; } + if (!entryObject) { + const auto depExtTable = mcpp::extension_table_for( + pkg.manifest.buildConfig.moduleExtensions, + pkg.manifest.buildConfig.deviceExtensions); + CompileUnit main_cu; + main_cu.source = entry; + main_cu.packageName = owner; + main_cu.kind = mcpp::classify(entry, depExtTable); + 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; + } else { + main_cu.localIncludeDirs = local_include_dirs_for_manifest(pkg.root, pkg.manifest); + main_cu.localIncludeDirsAfter = + local_include_dirs_after_for_manifest(pkg.root, pkg.manifest); + main_cu.packageCflags = pkg.manifest.buildConfig.cflags; + main_cu.packageCxxflags = pkg.manifest.buildConfig.cxxflags; + } + mcpp::modgraph::normalize_include_flags(pkg.root, main_cu.packageCflags); + mcpp::modgraph::normalize_include_flags(pkg.root, main_cu.packageCxxflags); + apply_c_standard(main_cu); + const auto scanned = mcpp::modgraph::scan_entry_file(entry, owner, depExtTable); + for (auto const& req : scanned.requires_) main_cu.imports.push_back(req.logicalName); + main_cu.declaration = scanned.provides + ? mcpp::modgraph::ModuleDeclaration::Unknown : scanned.declaration; + main_cu.object = object_for(entry, owner, + std::filesystem::relative(entry, pkg.root), r.packageIndex).object; + plan.compileUnits.push_back(main_cu); + entryObject = main_cu.object; + } + lu.objects.push_back(*entryObject); + } + for (auto const& cu : plan.compileUnits) { + if (!closure.contains(cu.packageName)) continue; + if (sharedDepPackages.contains(cu.packageName)) continue; + if (!is_implementation_source(cu.kind)) continue; + if (lu.entryMain && cu.source == *lu.entryMain) continue; + if (entryFilesAcrossTargets.contains(cu.source)) continue; + lu.objects.push_back(cu.object); + } + append_shared_deps_for_linked_objects(lu); + plan.linkUnits.push_back(std::move(lu)); + } + // The single derivation. Deliberately at the END of make_plan, after every // producer of a link unit has run: a dependency resolved to the shared // form arrives as an ordinary SharedLibrary unit, so this one predicate diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b32a0e99e..14bb41f96 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -6557,6 +6557,10 @@ prepare_build(bool print_fingerprint, // consumer's request must not be silently dropped, which is the // #242/#243 failure shape. std::vector requestedTools; + // mcpp#711: the dependency's programs this consumer ships, built in + // THIS plan for its target. A package asked for them is scanned and + // configured here even when every target it declares is a program. + std::vector requestedArtifacts; // #355 step 5 / #359: does this edge ask for the dependency's lib-root // interface as a HOST module, and does it hand its build-time // provisions on to this consumer's own consumers? @@ -6687,6 +6691,19 @@ prepare_build(bool print_fingerprint, || t.kind == mcpp::manifest::Target::SharedLibrary; }); }; + // A package some edge asked for programs to SHIP (mcpp#711). Its programs + // are linked in this plan, so it is scanned and configured here like any + // library dependency, even when every target it declares is a program. + auto isArtifactPackage = [&](std::size_t i) { + return std::ranges::any_of(dependencyEdges, [&](const DependencyEdge& e) { + return e.dependencyPackageIndex == i && !e.requestedArtifacts.empty(); + }); + }; + // Compiled in this plan: not a package of programs, or one whose programs + // this plan ships. + auto compilesHere = [&](std::size_t i) { + return i == 0 || !isProgramOnlyPackage(packages[i].manifest) || isArtifactPackage(i); + }; auto parseVisibility = [](std::string_view visibility) { if (visibility == "private") return mcpp::modgraph::DependencyVisibility::Private; @@ -7143,6 +7160,10 @@ prepare_build(bool print_fingerprint, for (auto const& t : spec.tools) if (std::ranges::find(it->requestedTools, t) == it->requestedTools.end()) it->requestedTools.push_back(t); + for (auto const& a : spec.artifacts) + if (std::ranges::find(it->requestedArtifacts, a) + == it->requestedArtifacts.end()) + it->requestedArtifacts.push_back(a); for (auto const& f : spec.features) if (std::ranges::find(it->requestedFeatures, f) == it->requestedFeatures.end()) @@ -7179,6 +7200,7 @@ prepare_build(bool print_fingerprint, .requestedFeatures = spec.features, .defaultFeatures = spec.defaultFeatures, .requestedTools = spec.tools, + .requestedArtifacts = spec.artifacts, .hostModule = hostModule, .reexport = spec.reexport, .buildOnly = buildOnly, @@ -8667,7 +8689,8 @@ prepare_build(bool print_fingerprint, // application `z.o`), compiled its sources in the consumer's build, and // made a tool that depends on the package declaring it a cycle of the // consumer's graph although the two builds never meet. - const bool depProgramOnly = isProgramOnlyPackage(*dep_manifest); + const bool depProgramOnly = isProgramOnlyPackage(*dep_manifest) + && spec.artifacts.empty(); auto linkFlagsAdded = depProgramOnly ? std::vector{} : propagateLinkFlags(dep_root, *dep_manifest); @@ -10035,6 +10058,26 @@ prepare_build(bool print_fingerprint, // Aggregate off the authoritative edge graph, exactly like feature // activation — a transitive consumer's request must not be // silently dropped (#242/#243). + // A FEATURE'S TOOLS ARE REQUESTED ON EVERY EDGE INTO ITS PACKAGE + // (#709). `[features.] tools` states that enabling `f` needs + // those programs, so a consumer enabling it receives them exactly + // as if its edge had written `tools = [...]`. Features are unified + // per package, so the set is the package's active features, the + // same set `[feature-xlings]` is answered from. Added before the + // aggregation below, so building, visibility (`dep_bin`) and the + // store key are the edge-requested tool's in every respect. + for (auto& edge : dependencyEdges) { + const auto d = edge.dependencyPackageIndex; + if (d >= packages.size() || d >= activeFeaturesByPackage.size()) continue; + auto const& ft = packages[d].manifest.featureTools; + if (ft.empty()) continue; + for (auto const& f : activeFeaturesByPackage[d]) + if (auto it = ft.find(f); it != ft.end()) + for (auto const& t : it->second) + if (std::ranges::find(edge.requestedTools, t) + == edge.requestedTools.end()) + edge.requestedTools.push_back(t); + } std::map> toolRequests; for (auto const& edge : dependencyEdges) for (auto const& t : edge.requestedTools) @@ -10808,7 +10851,7 @@ prepare_build(bool print_fingerprint, auto& pkg = packages[i]; // A package of programs runs its build program in its own tool // sub-build, where its sources are compiled (#649 E6). - if (isProgramOnlyPackage(pkg.manifest)) continue; + if (!compilesHere(i)) continue; std::error_code bpEc; if (!std::filesystem::exists(pkg.root / "build.mcpp", bpEc) && pkg.manifest.buildConfig.ruleModules.empty()) continue; @@ -13023,7 +13066,7 @@ prepare_build(bool print_fingerprint, std::vector scannedPackages; scannedPackages.reserve(packages.size()); for (std::size_t i = 0; i < packages.size(); ++i) - if (i == 0 || !isProgramOnlyPackage(packages[i].manifest)) + if (compilesHere(i)) scannedPackages.push_back(packages[i]); auto scan = [&] { const char* sel = std::getenv("MCPP_SCANNER"); @@ -14429,6 +14472,7 @@ prepare_build(bool print_fingerprint, // become an edge with a blank path, and ninja reports that far away // from the typo that caused it. std::set unresolvedTargets; + std::set unresolvedArtifacts; // `${mcpp.stage_dir}` used where there is no staged tree, and used by an // action whose role runs before the link. Both are refusals rather than // empty expansions: an empty path is a token the command still accepts, @@ -14509,6 +14553,33 @@ prepare_build(bool print_fingerprint, if (resolved.empty()) unresolvedTargets.insert(name); s.replace(p, close - p + 1, resolved); } + // `${mcpp.artifact:/}` (mcpp#711): a dependency's + // program that an edge requested with `artifacts = [...]`, spelled + // like `${mcpp.target_file:}` -- the link unit's build-dir-relative + // output -- for the same reason. `` is the dependency's + // name with or without its namespace. + constexpr std::string_view kArt = "${mcpp.artifact:"; + for (std::size_t p; (p = s.find(kArt)) != std::string::npos; ) { + auto close = s.find('}', p); + if (close == std::string::npos) break; + const auto ref = s.substr(p + kArt.size(), close - p - kArt.size()); + const auto slash = ref.rfind('/'); + std::string resolved; + if (slash != std::string::npos) { + const auto pkgName = ref.substr(0, slash); + const auto target = ref.substr(slash + 1); + for (auto const& lu : ctx.plan.linkUnits) { + if (lu.artifactOf.empty() || lu.targetName != target) continue; + const auto dot = lu.artifactOf.rfind('.'); + const auto shortName = dot == std::string::npos + ? lu.artifactOf : lu.artifactOf.substr(dot + 1); + if (lu.artifactOf == pkgName || shortName == pkgName) + resolved = lu.output.generic_string(); + } + } + if (resolved.empty()) unresolvedArtifacts.insert(ref); + s.replace(p, close - p + 1, resolved); + } return s; }; auto collect = [&](const mcpp::manifest::Manifest& mm) { @@ -14617,6 +14688,21 @@ prepare_build(bool print_fingerprint, bad, known.empty() ? std::string("none") : known)); } + if (!unresolvedArtifacts.empty()) { + std::string bad, known; + for (auto const& n : unresolvedArtifacts) bad += (bad.empty() ? "" : ", ") + n; + for (auto const& lu : ctx.plan.linkUnits) + if (!lu.artifactOf.empty()) + known += (known.empty() ? "" : ", ") + lu.artifactOf + "/" + lu.targetName; + return std::unexpected(std::format( + "build.mcpp action references unknown artifact(s) via " + "${{mcpp.artifact:/}}: {}\n" + " artifacts in this build: [{}]\n" + " (an artifact exists when a dependency edge requests it with " + "`artifacts = [\"\"]`)", + bad, known.empty() ? std::string("none") : known)); + } + // role = "object": the outputs are LINK inputs, so attach them to the // link units that should receive them. // @@ -14925,6 +15011,9 @@ prepare_build(bool print_fingerprint, std::vector peUnits; for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { auto k = ctx.plan.linkUnits[i].kind; + // A dependency's program (mcpp#711) carries its own package's + // identity, not this one's. + if (!ctx.plan.linkUnits[i].artifactOf.empty()) continue; if (k == mcpp::build::LinkUnit::Binary || k == mcpp::build::LinkUnit::SharedLibrary) peUnits.push_back(i); @@ -15026,6 +15115,7 @@ prepare_build(bool print_fingerprint, const bool synthVersion = R.declared() && R.synthesize_version_info(); auto wantsUtf8 = [&](const mcpp::build::LinkUnit& lu) { if (lu.kind != mcpp::build::LinkUnit::Binary) return false; + if (!lu.artifactOf.empty()) return false; for (auto const& t : m->targets) if (t.name == lu.targetName) return t.is_program() && codePageOf(t) == "utf-8"; diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index 140519b12..4f850fcb0 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -384,7 +384,9 @@ export PackOutcome build_and_pack(Options opts, bool modeFromUser, // excluded: a dependency's own `shared` target contributes a link unit to // this plan too, and it is never the package being packed. auto is_program_link_unit = [&](const mcpp::build::LinkUnit& lu) { - if (lu.kind == mcpp::build::LinkUnit::Binary) return true; + // A dependency's program shipped beside this one (mcpp#711) is staged + // as a file, never packed as the program. + if (lu.kind == mcpp::build::LinkUnit::Binary) return lu.artifactOf.empty(); if (lu.kind != mcpp::build::LinkUnit::SharedLibrary || lu.dependencyOwned) return false; for (auto const& t : ctx->manifest.targets) @@ -467,6 +469,12 @@ export PackOutcome build_and_pack(Options opts, bool modeFromUser, // destinations are `bin//`, and the executable is in `bin/`. for (auto const& d : ctx->plan.runtimeDeployFiles) opts.runtimeFiles.push_back(d.dest.lexically_relative("bin")); + // A dependency's program the manifest ships with this one (mcpp#711, + // `artifacts = [...]`) is linked into `bin/` beside the executable, so + // it is staged the way a deployed file is. + for (auto const& u : ctx->plan.linkUnits) + if (!u.artifactOf.empty()) + opts.runtimeFiles.push_back(u.output.lexically_relative("bin")); // #634 A3: the Android row reads its closure against the directories // its link declared -- a prebuilt library named through `[runtime] // link_library_dirs` is a file the link used and the device does not diff --git a/tests/e2e/800_a_feature_provides_its_host_tools.sh b/tests/e2e/800_a_feature_provides_its_host_tools.sh new file mode 100755 index 000000000..3b8d9bd0e --- /dev/null +++ b/tests/e2e/800_a_feature_provides_its_host_tools.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# requires: gcc +# 800_a_feature_provides_its_host_tools.sh — mcpp#709. +# +# `[features.] tools = [""]` states that enabling `f` needs the +# package's own program `` on the build machine. A consumer that enables +# the feature receives the tool exactly as if its dependency edge had written +# `tools = [...]` (e2e 187): built once for the host, reachable through +# `mcpp::dep_bin()`. Before this, every consumer of a rule package had to name +# the rule's tools on its edge as well as the feature. +# +# Criteria: +# 1. a consumer that writes only `features = ["codegen"]` gets the tool, and +# the source it generates is compiled and linked; +# 2. a consumer that does not enable the feature gets nothing built; +# 3. a `tools` entry that names no bin target of the package is refused at +# load, naming the package's bin targets. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +export MCPP_HOME="$TMP/mcpphome" +mkdir -p "$MCPP_HOME" +if [ -d "$HOME/.mcpp/registry" ]; then + ln -s "$HOME/.mcpp/registry" "$MCPP_HOME/registry" +fi + +# ── the tool package ──────────────────────────────────────────────────────── +mkdir -p toolpkg/src +cat > toolpkg/mcpp.toml <<'EOF' +[package] +name = "toolpkg" +version = "0.1.0" + +[build] +sources = ["src/lib.cpp"] + +[features.codegen] +tools = ["codegen"] + +[targets.codegen] +kind = "bin" +main = "src/codegen.cpp" + +[targets.toolpkg] +kind = "lib" +EOF +echo 'int toolpkg_lib() { return 1; }' > toolpkg/src/lib.cpp +cat > toolpkg/src/codegen.cpp <<'EOF' +#include +int main(int argc, char** argv) { + if (argc < 2) return 2; + FILE* f = std::fopen(argv[1], "w"); + if (!f) return 3; + std::fprintf(f, "int generated_answer() { return 42; }\n"); + std::fclose(f); + return 0; +} +EOF + +# ── 1. the consumer names the feature, not the tool ───────────────────────── +mkdir -p app/src +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +toolpkg = { path = "../toolpkg", features = ["codegen"] } +EOF +cat > app/src/main.cpp <<'EOF' +#include +int generated_answer(); +int main() { std::printf("ANSWER=%d\n", generated_answer()); } +EOF +cat > app/build.mcpp <<'EOF' +#include +#include +#include +import mcpp; +int main() { + const char* tool = mcpp::dep_bin("toolpkg", "codegen"); + if (!tool || !*tool) { std::fprintf(stderr, "no tool path\n"); return 1; } + std::string out = std::string(mcpp::out_dir()) + "/gen.cpp"; + std::string cmd = std::string("\"") + tool + "\" \"" + out + "\""; + if (std::system(cmd.c_str()) != 0) { std::fprintf(stderr, "tool failed\n"); return 1; } + mcpp::generated(out.c_str()); +} +EOF +cd app +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: 1: build failed"; exit 1; } +grep -q "host tool toolpkg:codegen" b1.log || { + cat b1.log; echo "FAIL: 1: the feature's tool was not built"; exit 1; } +out="$("$MCPP" run 2>&1 | grep '^ANSWER=' | tail -1)" +[[ "$out" == "ANSWER=42" ]] || { echo "FAIL: 1: generated source not linked: $out"; exit 1; } +echo "ok: 1" + +# ── 2. without the feature, nothing is built ──────────────────────────────── +cd "$TMP" +mkdir -p plain/src +cat > plain/mcpp.toml <<'EOF' +[package] +name = "plain" +version = "0.1.0" + +[dependencies] +toolpkg = { path = "../toolpkg" } +EOF +echo 'int main() {}' > plain/src/main.cpp +cd plain +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: 2: build failed"; exit 1; } +if grep -q "host tool" b2.log; then + cat b2.log; echo "FAIL: 2: a tool was built for a consumer that did not enable the feature"; exit 1 +fi +echo "ok: 2" + +# ── 3. a tools entry that names no bin target is refused ──────────────────── +cd "$TMP" +sed 's/tools = \["codegen"\]/tools = ["nosuch"]/' toolpkg/mcpp.toml > toolpkg/mcpp.toml.new +mv toolpkg/mcpp.toml.new toolpkg/mcpp.toml +cd app && rm -rf target +if "$MCPP" build > b3.log 2>&1; then + cat b3.log; echo "FAIL: 3: an unknown tool name was accepted"; exit 1 +fi +grep -q "nosuch" b3.log && grep -q "codegen" b3.log || { + cat b3.log; echo "FAIL: 3: the refusal does not name the entry and the bin targets"; exit 1; } +echo "ok: 3" + +echo "PASS: 800_a_feature_provides_its_host_tools" diff --git a/tests/e2e/801_a_dependency_program_is_shipped_with_the_consumer.sh b/tests/e2e/801_a_dependency_program_is_shipped_with_the_consumer.sh new file mode 100755 index 000000000..7c07eac25 --- /dev/null +++ b/tests/e2e/801_a_dependency_program_is_shipped_with_the_consumer.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# requires: gcc elf +# 801_a_dependency_program_is_shipped_with_the_consumer.sh — mcpp#711. +# +# `x = { path = "...", artifacts = ["updater"] }` asks for the dependency's +# `bin` target built for the CONSUMER's target and profile, as a link unit of +# the consumer's own plan, beside the consumer's programs in `bin/`. The only +# edge that exposed another package's executable before was `tools`, which +# builds it for the build machine in a nested sub-build: a cross build shipped +# a program for the wrong architecture, built a second time. +# +# Criteria: +# 1. the dependency's program is built into `bin/` and runs; +# 2. none of its code is linked into the consumer (an artifact edge takes the +# program, not the package's code); +# 3. `${mcpp.artifact:updater/updater}` names it in an action; +# 4. `mcpp pack` stages it beside the program; +# 5. under `--target x86_64-linux-musl` it is built for that target (static, +# no PT_INTERP) -- run where a musl toolchain is available. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p updater/src app/src +cat > updater/mcpp.toml <<'EOF' +[package] +name = "updater" +version = "0.1.0" + +[targets.updater] +kind = "bin" +main = "src/main.cpp" +EOF +cat > updater/src/helper.cpp <<'EOF' +int updater_only_helper() { return 7; } +EOF +cat > updater/src/main.cpp <<'EOF' +#include +int updater_only_helper(); +int main() { std::printf("UPDATER=%d\n", updater_only_helper()); } +EOF + +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +updater = { path = "../updater", artifacts = ["updater"] } + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF +cat > app/src/main.cpp <<'EOF' +#include +int main() { std::printf("APP_OK\n"); } +EOF +cat > app/build.mcpp <<'EOF' +#include +import mcpp; +int main() { + const std::string out = std::string(mcpp::out_dir()) + "/updater.copy"; + mcpp::action a; + a.id = "copy-updater"; + a.role = mcpp::roles::artifact; + a.arg("cp").arg("${mcpp.artifact:updater/updater}").arg(out.c_str()) + .input("${mcpp.artifact:updater/updater}") + .output(out.c_str()) + .submit(); +} +EOF + +cd app +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } + +# ── 1 ── +upd="$(find target -path '*/bin/updater' -type f | head -1)" +[[ -n "$upd" ]] || { cat b1.log; find target -name 'updater*'; echo "FAIL: 1: no bin/updater"; exit 1; } +[[ "$("$upd")" == "UPDATER=7" ]] || { echo "FAIL: 1: the artifact does not run"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "APP_OK" ]] || { echo "FAIL: 1: mcpp run chose '$out', not the package's program"; exit 1; } +echo "ok: 1" + +# ── 2 ── +app="$(dirname "$upd")/app" +if nm "$app" 2>/dev/null | grep -q updater_only_helper; then + echo "FAIL: 2: the dependency's code was linked into the consumer"; exit 1 +fi +echo "ok: 2" + +# ── 3 ── +copy="$(find target -name updater.copy -type f | head -1)" +[[ -n "$copy" ]] && cmp -s "$copy" "$upd" || { + echo "FAIL: 3: \${mcpp.artifact:} did not reach the action"; exit 1; } +echo "ok: 3" + +# ── 4 ── +"$MCPP" pack > p.log 2>&1 || { cat p.log; echo "FAIL: 4: pack failed"; exit 1; } +tarball="$(find target/dist -name '*.tar.gz' | head -1)" +[[ -n "$tarball" ]] || { cat p.log; echo "FAIL: 4: no archive"; exit 1; } +tar -tzf "$tarball" | grep -q '/updater$' || { + tar -tzf "$tarball"; echo "FAIL: 4: the artifact is not in the archive"; exit 1; } +echo "ok: 4" + +# ── 5 ── +if [[ "$(uname -m)" == x86_64 ]] && { command -v x86_64-linux-musl-g++ >/dev/null 2>&1 \ + || ls "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-musl-gcc/*/bin/x86_64-linux-musl-g++ >/dev/null 2>&1; }; then + "$MCPP" build --target x86_64-linux-musl > b5.log 2>&1 || { cat b5.log; echo "FAIL: 5: cross build failed"; exit 1; } + cu="$(find target/x86_64-linux-musl -path '*/bin/updater' -type f | head -1)" + [[ -n "$cu" ]] || { echo "FAIL: 5: no cross-built updater"; exit 1; } + if readelf -l "$cu" | grep -q INTERP; then + echo "FAIL: 5: the artifact was not built for the musl target"; exit 1 + fi + echo "ok: 5" +else + echo "skip: 5 (no musl toolchain on this machine)" +fi + +echo "PASS: 801_a_dependency_program_is_shipped_with_the_consumer" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 21bfa3237..51bbb78d1 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -3782,7 +3782,7 @@ name = "depspeckeys" version = "0.1.0" [dependencies.compat] -everything = { version = "1.0.0", features = ["x"], default-features = false, visibility = "private", backend = "openblas", tools = ["t"], host-module = true, reexport = true, linkage = "shared" } +everything = { version = "1.0.0", features = ["x"], default-features = false, visibility = "private", backend = "openblas", tools = ["t"], artifacts = ["a"], host-module = true, reexport = true, linkage = "shared" } bygit = { git = "https://example.invalid/x.git", tag = "v1", visibility = "interface" } bypath = { path = "../sibling" } )"; @@ -3801,6 +3801,8 @@ bypath = { path = "../sibling" } EXPECT_TRUE(all->reexport); ASSERT_EQ(all->tools.size(), 1u); EXPECT_EQ(all->tools[0], "t"); + ASSERT_EQ(all->artifacts.size(), 1u); + EXPECT_EQ(all->artifacts[0], "a"); // `backend = "openblas"` is sugar for requesting the backend- feature. EXPECT_NE(std::find(all->features.begin(), all->features.end(), "backend-openblas"), all->features.end()); @@ -5778,6 +5780,35 @@ kind = "shared" EXPECT_EQ(m.targets[0].linkageDefault, "shared"); } +// #709: `[features.] tools` names the package's own bin targets, and is +// checked once inference has produced the target list. +TEST(Manifest, AFeatureToolMustNameABinTargetOfThePackage) { + auto dir = std::filesystem::temp_directory_path() + / std::format("mcpp_feature_tools_{}", std::random_device{}()); + std::filesystem::create_directories(dir / "src"); + std::ofstream(dir / "src" / "gen.cpp") << "int main() {}\n"; + auto write = [&](std::string_view tool) { + std::ofstream(dir / "mcpp.toml") + << "[package]\nname = \"gen\"\nversion = \"0.1.0\"\n\n" + << "[features.codegen]\ntools = [\"" << tool << "\"]\n\n" + << "[targets.gen-tool]\nkind = \"bin\"\nmain = \"src/gen.cpp\"\n"; + }; + write("gen-tool"); + auto ok = mcpp::manifest::load(dir / "mcpp.toml"); + ASSERT_TRUE(ok) << (ok ? "" : ok.error().message); + ASSERT_EQ(ok->featureTools.at("codegen").size(), 1u); + EXPECT_EQ(ok->featureTools.at("codegen")[0], "gen-tool"); + + write("nosuch"); + auto bad = mcpp::manifest::load(dir / "mcpp.toml"); + ASSERT_FALSE(bad); + EXPECT_NE(bad.error().message.find("nosuch"), std::string::npos) << bad.error().message; + EXPECT_NE(bad.error().message.find("gen-tool"), std::string::npos) << bad.error().message; + + std::error_code ec; + std::filesystem::remove_all(dir, ec); +} + // #714: `sources = []` states that the default build compiles nothing, so a // module interface under `src/` is not a library of that build. A build-logic // package keeps its module behind a feature that host-module consumers From c136000cf20ba02ff8b2b11c7d746aefbc7f7712 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 00:16:01 +0000 Subject: [PATCH 3/4] Consume the xlings revision and glibc runtime data The packaging revision xlings records for a payload (`.xpkg-install.json`, openxlings/xlings#620) is read in one place, `mcpp::xlings::paths::installed_revision`, and used twice. The runtime contract (fingerprint field 11) includes the revision of each runtime payload whose revision is 1 or more, so a C library reinstalled under the same version with changed content selects a new output directory. Revision 0 contributes nothing, so no existing key changes. An index dependency whose recorded revision differs from its descriptor's entry is not installed (#524 A): mcpp's descriptor reader now reads `revision` the way libxpkg does, and a stale payload goes back through xlings, which reinstalls it. A payload with no xlings record is judged by the marker, as before. `mcpp pack --mode self-contained` copies the bundled C library's `lib/locale` and `lib/gconv` from its payload when present and exports LOCPATH and GCONV_PATH in the launcher, keeping a value the user set (openxlings/xlings#621). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5 --- modules/manifest/src/xpkg.cppm | 40 ++++++++++++++++++++++++++ src/build/prepare.cppm | 24 ++++++++++++++++ src/pack/pack.cppm | 48 ++++++++++++++++++++++++++++++-- src/runtime/binding.cppm | 10 +++++++ src/xlings/xlings.cppm | 16 +++++++++++ tests/unit/test_freestanding.cpp | 17 +++++++++++ tests/unit/test_manifest.cpp | 31 +++++++++++++++++++++ 7 files changed, 183 insertions(+), 3 deletions(-) diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 9c5057f2d..777af98ec 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -38,6 +38,10 @@ struct XpkgVersionEntry { std::string version; // the literal key, as written bool alias = false; // entry carries `ref = "..."` std::string sha256; // payload digest when declared at entry level + // The packaging revision of this version's payload (xpkg V2 `revision`), + // 0 when the entry states none or states something that is not a + // non-negative integer -- the reference implementation's reading. + int revision = 0; }; // Extract the version entries for `platform` (e.g. "linux", "macosx", @@ -574,6 +578,40 @@ std::string top_level_table_body_for_key(std::string_view body, std::string_view return {}; } +// The non-negative integer bound to `wantedKey` at the top level of `body`, +// or 0: `revision = 1` reads 1, and a string, a negative or a fractional value +// reads as absent. +int top_level_nonneg_int_for_key(std::string_view body, std::string_view wantedKey) { + LuaCursor cur { body }; + cur.skip_ws_and_comments(); + while (!cur.eof()) { + auto key = cur.read_key(); + if (key.empty()) { + cur.skip_ws_and_comments(); + if (cur.eof()) break; + ++cur.pos; + continue; + } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) { + cur.skip_ws_and_comments(); + continue; + } + cur.skip_ws_and_comments(); + if (cur.peek() == '{') { cur.skip_table(); cur.skip_ws_and_comments(); continue; } + if (cur.at_string_start()) { (void)cur.read_string(); cur.skip_ws_and_comments(); continue; } + const auto word = cur.read_bareword(); + if (key == wantedKey) { + if (word.empty() || word.size() > 9 + || !std::ranges::all_of(word, [](char c) { return c >= '0' && c <= '9'; })) + return 0; + return std::stoi(std::string(word)); + } + cur.skip_ws_and_comments(); + } + return 0; +} + std::string top_level_string_value_for_key(std::string_view body, std::string_view wantedKey) { LuaCursor cur { body }; cur.skip_ws_and_comments(); @@ -1072,6 +1110,8 @@ list_xpkg_version_entries(std::string_view luaContent, e.alias = entry_is_alias(v, entry_end); e.sha256 = top_level_string_value_for_key( luaContent.substr(v + 1, entry_end - v - 1), "sha256"); + e.revision = top_level_nonneg_int_for_key( + luaContent.substr(v + 1, entry_end - v - 1), "revision"); versions.push_back(std::move(e)); q = entry_end + 1; continue; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 14bb41f96..37c1a77ab 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -6130,9 +6130,33 @@ prepare_build(bool print_fingerprint, return false; }; + // THE DESCRIPTOR'S REVISION IS PART OF WHAT IS INSTALLED (#524 A, + // openxlings/xlings#620). A descriptor that changes what it installs + // keeps its version and raises the entry's `revision`; a payload whose + // recorded revision differs is not this version any more, however + // complete it is, and goes back through xlings, which reinstalls it + // and records the new revision. A payload with no xlings record at + // all is judged by the marker alone, as before. + const int recipeRevision = [&] { + if (!luaContent) return 0; + for (auto const& e : mcpp::manifest::list_xpkg_version_entries( + *luaContent, targetPlatform)) + if (e.version == version) return e.revision; + return 0; + }(); + auto revisionIsCurrent = [&](const std::filesystem::path& p) { + const auto installed = mcpp::xlings::paths::installed_revision(p); + if (!installed || *installed == recipeRevision) return true; + mcpp::log::verbose("fetcher", std::format( + "{}@{}: installed revision {}, descriptor revision {}; reinstalling", + depName, version, *installed, recipeRevision)); + return false; + }; + auto findCompleteInstalled = [&]() -> std::optional { auto p = findRawInstalled(); if (!p) return std::nullopt; + if (!revisionIsCurrent(*p)) return std::nullopt; if (mcpp::fallback::is_install_complete(*p)) return p; if (installedLayoutMatchesIndex(*p)) { mcpp::fallback::mark_install_complete(*p); diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 1a3a35d8f..1cc46363e 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -1041,8 +1041,21 @@ write_executable_script(const std::filesystem::path& path, std::expected write_bundle_all_wrappers(const std::filesystem::path& stagingRoot, std::string_view binaryName, - std::string_view loaderName) + std::string_view loaderName, + bool localeData = false, + bool gconvData = false) { + // glibc's compiled locales and its character-set converters, when the + // bundle carries them (openxlings/xlings#621). The C library that runs here + // is the bundled one, and it reads both from paths compiled into it, which + // name the machine the payload was installed on. This script is the only + // entry of the bundle, so exporting the variables here reaches every run; + // a value the user already set is kept. + std::string runtimeData; + if (localeData) + runtimeData += "LOCPATH=\"${LOCPATH:-$here/lib/locale}\"\nexport LOCPATH\n"; + if (gconvData) + runtimeData += "GCONV_PATH=\"${GCONV_PATH:-$here/lib/gconv}\"\nexport GCONV_PATH\n"; auto body = std::format( "#!/bin/sh\n" "# Auto-generated by `mcpp pack --mode self-contained`. Launches the\n" @@ -1066,8 +1079,9 @@ write_bundle_all_wrappers(const std::filesystem::path& stagingRoot, "here=$(cd \"$(dirname \"$0\")\" && pwd)\n" "MCPP_BUNDLE_DIR=\"$here\"\n" "export MCPP_BUNDLE_DIR\n" + "{}" "exec \"$here/lib/{}\" --library-path \"$here/lib\" \"$here/bin/{}\" \"$@\"\n", - loaderName, binaryName); + runtimeData, loaderName, binaryName); if (auto r = write_executable_script(stagingRoot / "run.sh", body); !r) return r; if (auto r = write_executable_script(stagingRoot / std::string(binaryName), body); !r) return r; return {}; @@ -2312,11 +2326,39 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) // which the user typically wouldn't combine with --mode // bundle-all. Skip wrapper, ship as-is. } else { + // The runtime data of the C library the bundle carries + // (openxlings/xlings#621): its compiled locales and its + // character-set converters, from the payload the bundled + // loader came from (`/lib64/ld-linux-*` -> + // `/lib/{locale,gconv}`). A payload without them + // contributes nothing, and the program runs as before. + bool localeData = false, gconvData = false; + for (auto const& d : toBundle) { + if (d.soname != loader) continue; + std::error_code dec; + const auto payload = + std::filesystem::canonical(d.path, dec).parent_path().parent_path(); + if (dec) break; + auto copy_dir = [&](std::string_view sub) { + const auto from = payload / "lib" / std::string(sub); + std::error_code cec; + if (!std::filesystem::is_directory(from, cec)) return false; + const auto to = plan.stagingRoot / "lib" / std::string(sub); + std::filesystem::create_directories(to, cec); + std::filesystem::copy(from, to, + std::filesystem::copy_options::recursive + | std::filesystem::copy_options::overwrite_existing, cec); + return !cec; + }; + localeData = copy_dir("locale"); + gconvData = copy_dir("gconv"); + break; + } // Mode B writes BOTH `run.sh` and `` at the // bundle root — same content, different names — so users // can pick whichever entry point they prefer. if (auto r = write_bundle_all_wrappers(plan.stagingRoot, - plan.binaryName, loader); !r) + plan.binaryName, loader, localeData, gconvData); !r) return std::unexpected(Error{r.error()}); collect_licenses(toBundle, plan.stagingRoot); } diff --git a/src/runtime/binding.cppm b/src/runtime/binding.cppm index b4b5e3032..01e965b8b 100644 --- a/src/runtime/binding.cppm +++ b/src/runtime/binding.cppm @@ -16,6 +16,7 @@ import mcpp.libs.json; import mcpp.platform; import mcpp.xlings.runtime_selection; import mcpp.xlings.subos_info; +import mcpp.xlings; export namespace mcpp::platform::runtime { @@ -178,6 +179,15 @@ std::string canonical_contract(const RuntimeBinding& binding) { append_field(out, binding.hostLibc); for (auto const& p : binding.libraryDirs) append_field(out, p.generic_string()); + // A payload reinstalled under the same version with changed content is a + // different runtime (openxlings/xlings#620), so its packaging revision is + // part of the contract. Revision 0 contributes nothing: a payload installed + // before revisions existed keeps the contract, and the output directory, + // it had. + for (auto const& p : binding.libraryDirs) + if (const int r = mcpp::xlings::paths::installed_revision(p.parent_path()) + .value_or(0); r > 0) + append_field(out, std::format("revision={}", r)); // The farm participates in the hash because it participates in the // artifact: it lands in DT_RPATH, so a build made against one farm is not // interchangeable with a build made against another. `declared` is in for diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm index 489dcc102..21cb5aed6 100644 --- a/src/xlings/xlings.cppm +++ b/src/xlings/xlings.cppm @@ -210,6 +210,12 @@ namespace paths { std::optional recorded_payload(const Env& env, const XpkgRef& ref); + // The packaging revision xlings recorded for the payload in `payloadDir` + // (`.xpkg-install.json`, xlings 2026.9.27.1+; openxlings/xlings#620), and + // 0 when it recorded none -- the reading xlings itself gives a record that + // predates the field. `nullopt` when there is no record at all. + std::optional installed_revision(const std::filesystem::path& payloadDir); + // From compiler binary, climb parent dirs to find "xpkgs" directory. // Replaces 3 duplicate implementations in flags.cppm, ninja_backend.cppm, // stdmod.cppm. @@ -1066,6 +1072,16 @@ void record_resolutions(const Env& env, std::span targets) } } +std::optional installed_revision(const std::filesystem::path& payloadDir) { + std::ifstream in{payloadDir / ".xpkg-install.json", std::ios::binary}; + if (!in) return std::nullopt; + std::string text{std::istreambuf_iterator(in), {}}; + auto j = nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false); + if (!j.is_object()) return std::nullopt; + if (!j.contains("revision") || !j["revision"].is_number_integer()) return 0; + return j["revision"].get(); +} + std::optional recorded_payload(const Env& env, const XpkgRef& ref) { if (ref.name.empty()) return std::nullopt; diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 0cccbc781..df2f898c7 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -313,6 +313,23 @@ TEST(XpkgPayload, ABareVersionSelectsWhatXlingsSelected) { std::filesystem::remove_all(base); } +// The revision xlings recorded for a payload (openxlings/xlings#620): the +// field when present, 0 for a record that predates it, and nothing at all when +// there is no record -- which is the case mcpp judges by its marker alone. +TEST(XpkgPayload, InstalledRevisionReadsTheXlingsRecord) { + namespace xp = mcpp::xlings::paths; + auto dir = std::filesystem::temp_directory_path() + / std::format("mcpp-xpkg-rev-{}", ::getpid()); + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + EXPECT_FALSE(xp::installed_revision(dir).has_value()); + std::ofstream(dir / ".xpkg-install.json") << R"({"os":"linux","version":"2.44.3"})"; + EXPECT_EQ(xp::installed_revision(dir), 0); + std::ofstream(dir / ".xpkg-install.json") << R"({"version":"2.44.3","revision":1})"; + EXPECT_EQ(xp::installed_revision(dir), 1); + std::filesystem::remove_all(dir); +} + TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { using mcpp::build::xpkg_env_var; // The two sides of the channel must agree; drifting apart would make the diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 51bbb78d1..c468418eb 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -504,6 +504,37 @@ package = { EXPECT_EQ(keys[2], "25.0.4.7.1"); } +// openxlings/xlings#620, mcpp#524 A: a version entry's `revision` is read the +// way the reference implementation reads it -- a non-negative integer, and 0 +// for anything else or for its absence -- including the per-arch shape, whose +// arch tables must not be mistaken for the revision. +TEST(ListXpkgVersions, RevisionIsReadPerEntry) { + constexpr auto src = R"( +package = { + name = "rev", + xpm = { + linux = { + ["latest"] = { ref = "1.2.0" }, + ["1.0.0"] = { url = "u", sha256 = "a" }, + ["1.1.0"] = { url = "u", sha256 = "b", revision = 2 }, + ["1.2.0"] = { x86_64 = { url = "u", sha256 = "c" }, revision = 1 }, + ["1.3.0"] = { url = "u", sha256 = "d", revision = "3" }, + ["1.4.0"] = { url = "u", sha256 = "e", revision = -1 }, + }, + }, +} +)"; + auto e = mcpp::manifest::list_xpkg_version_entries( + src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + ASSERT_EQ(e.size(), 6u); + EXPECT_EQ(e[0].revision, 0); // an alias carries none + EXPECT_EQ(e[1].revision, 0); + EXPECT_EQ(e[2].revision, 2); + EXPECT_EQ(e[3].revision, 1); + EXPECT_EQ(e[4].revision, 0); // a string is not a revision + EXPECT_EQ(e[5].revision, 0); // nor is a negative number +} + // The scanner used to walk the platform table character by character, so a // bracket key nested inside a version's own body (mirror tables write // `["GLOBAL"] = "https://..."`) counted as a published version. From bba733ac89bef68e79a799017e3d21421856ccc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:27:23 +0000 Subject: [PATCH 4/4] 2026.9.27.1: tests, specs and docs for #704-#716; xlings version vectors; faster ELF reads Tests. e2e 802 (#704: a host build applies its host row, under either spelling) and 803 (#716: a recorded payload that is gone is refused offline, naming the address and the record). Unit tests for #713 (a member inherits the root's [xlings.workspace] entries and rows; its own wins), #714 (an unresolved `workspace = true` is named in every dependency table) and #710 (a member tool reads its workspace's toolchain; its own wins), for which prepare exports host_tool_declared_toolchain. Version selection. mcpp.xpkg_version gains select_installed, the one rule `mcpp::xpkg_dir` answers with (a literal name, then the xlings grammar, then the highest), and runs the conformance vectors xlings publishes, vendored as modules/versioning/tests/data/semver-vectors.tsv. ELF inspection reads a file with one sized read; filling the vector through an istreambuf_iterator made the post-link check of `mcpp test` take tens of minutes. Specs: SPEC-001 v1.5 (version answers follow xlings; packaging revision), SPEC-004 v1.8 (host row, workspace clauses 8-10, a dependency's programs), SPEC-007 v0.3 (action env/cwd, planning defers host tools, feature tools, artifacts). Docs 04, 05, 06, 07 and 30 in English and Chinese. CHANGELOG and version 2026.9.27.1. Not built or run in this session; the handover lists the verification that remains. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5 --- CHANGELOG.md | 55 +++++++++ docs/04-mcpp-toml.md | 1 + docs/05-dependencies.md | 61 ++++++++++ docs/06-features-and-capabilities.md | 7 +- docs/07-workspace.md | 23 ++++ docs/30-build-mcpp.md | 51 +++++++++ docs/specs/build-plugins.md | 32 ++++-- docs/specs/manifest-semantics.md | 92 ++++++++++++++- docs/specs/package-identity.md | 36 +++++- docs/zh/04-mcpp-toml.md | 1 + docs/zh/05-dependencies.md | 49 ++++++++ docs/zh/06-features-and-capabilities.md | 5 +- docs/zh/07-workspace.md | 18 +++ docs/zh/30-build-mcpp.md | 41 +++++++ mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- modules/versioning/src/xpkg_version.cppm | 22 ++++ .../versioning/tests/data/semver-vectors.tsv | 108 ++++++++++++++++++ .../versioning/tests/test_xpkg_version.cpp | 53 +++++++++ src/build/prepare.cppm | 41 +++---- src/runtime/elf.cppm | 27 +++-- src/xlings/xlings.cppm | 24 +--- .../802_a_host_build_applies_its_host_row.sh | 84 ++++++++++++++ ...payload_that_is_gone_is_refused_offline.sh | 73 ++++++++++++ tests/unit/test_workspace_inheritance.cpp | 86 ++++++++++++++ 25 files changed, 929 insertions(+), 65 deletions(-) create mode 100644 modules/versioning/tests/data/semver-vectors.tsv create mode 100755 tests/e2e/802_a_host_build_applies_its_host_row.sh create mode 100755 tests/e2e/803_a_recorded_payload_that_is_gone_is_refused_offline.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d402cc6..ef1a34664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,61 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.9.27.1] - 2026-09-27 + +### 缺陷修复(#704、#705、#710、#712 至 #716) + +- **宿主构建读取宿主三元组的行(#704)。** 不带 `--target` 的构建此前不读取 + `[target.<宿主三元组>]`,其中的 `cxx_runtime` 没有效果。现在宿主构建应用这一行,与 + `--target <宿主三元组>` 相同,行的查找与拼写无关;`--toolchain` 仍优先于行的 `toolchain` + (SPEC-004 §4.6)。 +- **`xpkg_dir` 回答 xlings 装下的载荷(#712、#716)。** 版本位按 xlings 的版本文法求值, + `libglvnd@1.7` 回答 `1.7.0.1`。xlings 在 `install_targets` 事件中报告每个请求解析到的载荷, + mcpp 按地址记录并优先读取。安装记录仍在而载荷已被删除时,联网构建重新安装,离线构建拒绝并 + 点名缺失的地址;构建缓存记录读取过的载荷目录,任一目录缺失时快路径不复用缓存。mcpp 以 + xlings 发布的版本选择向量测试同一规则(SPEC-001 §10.1)。 +- **宿主工具的工具链由请求方决定一次(#710)。** 顺序为 `--toolchain`、工具包自己的声明(工作 + 空间成员先继承根位置的键)、请求方的宿主工具链;结果传给子构建并写入工具库的键。工具包的源树 + 摘要跳过带自己 `mcpp.toml` 的子目录,并以 UTF-8 计算路径(#705)。 +- **工作空间(#713、#714)。** 成员隐式继承根的 `[xlings.workspace]` 条目与条件行,自己声明的 + 同一个包优先。未解析的 `workspace = true` 在根包、`-p` 成员与各类依赖处都被点名拒绝;带 + `[package]` 的工作空间根解析自己的条目。`[build] sources = []` 不再推断库目标。 +- **规则只经其声明的扩展名到达(#715)。** 一个规则只在包含它声明的设备源扩展名时进入包的合成 + 构建程序;状态行只列出实际生效的规则。 + +### 规划不构建宿主工具(#707) + +`mcpp emit build-database` 不再构建依赖提供的宿主工具。工具库中已有的工具照常使用;没有的工具被 +推迟,报告为 note `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`,请求它的构建程序收到该工具将被发布的 +路径。它取代 2026.9.26.2 的警告 `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT`(SPEC-005 v1.4 R2.5)。 +`mcpp build` 不受影响。 + +### 构建程序与依赖边(#708、#709、#711) + +- **action 的 `env` 与 `cwd`(协议 13,#708)。** `mcpp::action::env(name, value)` 与 + `cwd(dir)` 由引擎的 action 包装器应用;`cwd` 按包根解析,声明的输入、输出与 stamp 不随它 + 移动;变量值改变时 action 重新运行。两者都未声明的 action 命令行与协议 12 逐字节相同。 +- **特性的 `tools`(#709)。** `[features.] tools = [""]` 陈述特性需要本包的程序在 + 构建机器上运行;启用该特性的消费方得到该工具,与边上写 `tools` 相同。 +- **`artifacts` 依赖边(#711)。** `x = { path = "...", artifacts = [""] }` 以消费方的 + 目标与 profile 构建依赖的程序,输出到消费方的 `bin/`,不链接依赖的代码;action 以 + `${mcpp.artifact:/}` 引用它;`mcpp pack` 把它放在程序旁(SPEC-004 §10、 + SPEC-007 R6.4)。 + +### 载荷的打包修订与自包含的 locale(xlings#620、#621) + +- 依赖的索引条目的 `revision` 进入依赖完整性判断:修订号不同的已安装依赖按未安装处理。修订号 + 大于 0 的运行时载荷进入运行时契约(`revision=`),修订号变化使依赖它的产物重新链接;修订号 + 为 0 时契约文本不变(SPEC-001 §10.2)。 +- `mcpp pack` 的 bundle-all 形态随 glibc 载荷复制 `lib/locale` 与 `lib/gconv`,启动脚本设置 + `LOCPATH` 与 `GCONV_PATH`(用户已设置时保留用户的值)。 + +### 其他 + +- ELF 检查以一次读取载入文件。此前逐字节读入,`mcpp test` 的链接后检查在测试程序较多时耗时 + 数十分钟。 +- 规范:SPEC-001 v1.5、SPEC-004 v1.8、SPEC-005 v1.4、SPEC-007 v0.3。 + ## [2026.9.26.2] - 2026-09-26 ### 编译数据库:一个配置一个数据库(#699 的报告;#397 C-1、#677 B1) diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 91ad3c597..6a7c249e7 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -1780,6 +1780,7 @@ kind = "bin" |---|---|---| | Source files | `src/**/*.{cppm,cpp,cc,c,S,s,asm}` | Scanned recursively and automatically | | Entry point | `src/main.cpp` | If this file exists, a `bin` target is inferred | +| Library target | a module interface under `src/` | Inferred when no `[targets]` table and no `src/main.cpp` exist; `[build] sources = []` states that the default build compiles nothing and infers no library (2026.9.27.1+) | | Library root | `src/.cppm` | Override with `[lib].path` | | C++ standard | `c++23` | Configure with `[package].standard`; supports `c++20` / `c++26` / `c++2a` / `c++2c` / `gnu++NN` / `c++latest` / `c++fly` (experimental playground) | | C standard | `c11` | `.c` files go through the C compiler automatically | diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index f015be61d..b54a01f31 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -448,6 +448,67 @@ and refused, and the refusal says to restate the source. `tools`, `features`, effect on the row. A restatement that names another source is refused, naming both sources (mcpp 2026.9.16.1+); before that release it was ignored. +**Which compiler builds a tool (mcpp 2026.9.27.1+).** The build that requests +a tool decides its toolchain once: `--toolchain` when given; otherwise the tool +package's own declaration, read as its own build reads it (for a workspace +member, after the workspace root's `[toolchain]`, `[target.]` and +`[indices]`), its host row's `toolchain` before `[toolchain]`; otherwise the +host toolchain the requesting build compiles its build programs with. The +choice is passed to the tool's sub-build and recorded in the tool store key, so +`mcpp build -p ` in the workspace and the same tool built for a consumer +use the same compiler. The source digest of a tool package skips directories +that hold their own `mcpp.toml`, so editing a workspace member does not rebuild +a tool the workspace root provides. + +### A feature that provides the package's tools (mcpp 2026.9.27.1+) + +A package whose feature needs one of its own programs on the build machine +states it on the feature, and a consumer names only the feature: + +```toml +# the tool package +[features.codegen] +tools = ["codegen"] + +[targets.codegen] +kind = "bin" +main = "src/codegen.cpp" +``` + +```toml +# the consumer +[dependencies] +toolpkg = { path = "../toolpkg", features = ["codegen"] } +``` + +Enabling the feature has the effect of `tools = ["codegen"]` on the edge: the +program is built for the host and `mcpp::dep_bin("toolpkg", "codegen")` names +it. A consumer that does not enable the feature builds nothing. An entry that +names no `bin` target of the package is refused when the manifest loads, and the +message lists the package's `bin` targets. + +### A dependency's program shipped with the consumer: `artifacts` (mcpp 2026.9.27.1+) + +`tools` builds a program for the machine that runs the build. A program that +ships with the consumer and runs on its target (an updater, a helper process) +is requested with `artifacts`: + +```toml +[dependencies] +updater = { path = "../updater", artifacts = ["updater"] } +``` + +- The dependency's `bin` target is built for the consumer's target and profile, + as a link unit of the consumer's plan, into the consumer's `bin/`. Under + `--target x86_64-linux-musl` it is a musl program. +- None of the dependency's code is linked into the consumer through this edge. + A package also reached through an ordinary edge is linked as usual. +- An action of the consumer's build program names the program with + `${mcpp.artifact:updater/updater}`, in its arguments and its inputs; a name + that matches no `artifacts` entry fails planning and names the placeholder. +- `mcpp run` does not choose it, and `mcpp pack` stages it beside the + consumer's program. + > The section has been parsed since early versions and, until 2026.8.29.1, read > by nothing that made a decision: writing it produced a manifest that loaded, > no diagnostic, and no effect. diff --git a/docs/06-features-and-capabilities.md b/docs/06-features-and-capabilities.md index ee081a997..8a86f2e83 100644 --- a/docs/06-features-and-capabilities.md +++ b/docs/06-features-and-capabilities.md @@ -94,7 +94,7 @@ simd = { sources = ["src/simd/**"], flags = [ ``` - **The table form accepts exactly** `implies`, `forward`, `defines`, `sources`, - `flags`, `requires`, `provides`. Anything else is reported as a schema warning + `flags`, `requires`, `provides`, `tools`. Anything else is reported as a schema warning and ignored (mcpp 2026.9.1.1+); `deps` is reported separately as reserved and points at `[feature-deps.]`. Before that release `[features]` was the one structured section with no schema check at all, so a misplaced @@ -120,6 +120,11 @@ simd = { sources = ["src/simd/**"], flags = [ feature `flags` are **private per-TU build flags** — they never propagate to consumers (same contract as `[build].flags`), so they stay inside the additive model: scoped by glob, deterministic order, no cross-package effect. +- `tools` (mcpp 2026.9.27.1+) names `bin` targets of this package that the + feature needs on the build machine. A consumer that enables the feature + receives them as if its dependency edge had written `tools = [...]` + ([05, build-time dependencies](05-dependencies.md)); a name that is not a + `bin` target of the package is refused at load. ### A feature that is a build rule (mcpp 2026.9.7.1+) diff --git a/docs/07-workspace.md b/docs/07-workspace.md index de122f9bf..1e9179d10 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -133,6 +133,13 @@ A member can override an inherited version: mbedtls = "4.0.0" # override; does not use the workspace version ``` +An entry that says `.workspace = true` and that no workspace resolves is +refused wherever the package enters a build (the root, a member selected with +`-p`, a `path`, `git` or index dependency), naming the table and the entry +(mcpp 2026.9.27.1+). It is resolved against the `[workspace.dependencies]` of +the workspace whose `members` list the package; a workspace root that carries +its own `[package]` resolves its own entries the same way. + ## 4. Inheriting Toolchain and Build Configuration The workspace root's `[toolchain]` and `[target.]` settings are automatically inherited by all members. A member can override them in its own project file. @@ -161,6 +168,22 @@ linkage = "static" default = "llvm@20.1.7" ``` +`[toolchain]`, `[target.]` and `[indices]` choose the compiler, the +target rows and the indices for a whole graph, so a member takes them from the +workspace root only where it is the root of a build: built from the workspace, +with `-p`, or as a host tool of another package (mcpp 2026.9.27.1+ for the +last). A member reached as a dependency takes them from that build's root. + +A build without `--target` targets the host, and `[target.]` +applies to it as `--target ` would (mcpp 2026.9.27.1+). + +The root's `[xlings.workspace]` entries, including its +`[target..xlings.workspace]` rows, are inherited implicitly as well +(mcpp 2026.9.27.1+): a payload describes the environment a build runs in, like +`[toolchain]`, so no opt-in is needed. A member's own declaration of the same +package wins. `[feature-xlings.]` entries are not inherited, because a +feature belongs to the package that declares it. + ### 4.1 `[workspace.package]` and `[workspace.build]` Package metadata and build flags shared by every member are declared once at the diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index c3236847a..49baf5b09 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -426,6 +426,16 @@ position was compared against a directory name, so a range installed a payload and then answered that nothing was installed — which is why a rule package could not state a floor and every project repeated its rule's package list. +**The answer is the payload xlings installed** *(2026.9.27.1+)*. xlings reports +what each address resolved to, and `xpkg_dir` answers from that record first. +Without one, it selects among the installed version directories by the xlings +version grammar: a bare version of one or two segments is a prefix range, three +or more segments must match as written, so `libglvnd@1.7` answers `1.7.0.1`. +Before this release the Cargo reading of `1.7` could not see a four-segment +directory, and the answer was `""` for a payload on disk. A declared payload +that was removed after it was installed is installed again, or refused +offline, instead of answering `""` (SPEC-001 §10.1). + **A package a DEPENDENCY declared is answered too** *(2026.9.6.6+)*, at the version this build actually installed rather than the one the local manifest wrote. One package means one version: where a project and a rule both name it, @@ -685,6 +695,47 @@ scan agrees with what the generator will emit — the same assertion-plus- verification trade `[modules].scan_overrides` makes, and the compiler's own P1689 output checks it at build time. +#### Environment and working directory: `env` / `cwd` (protocol 13) + +An action's command is an argv, not a shell line, so `NAME=value cmd` and +`cd dir && cmd` are not available to it. A generator configured through +environment variables, or one that must run in a particular directory, states +both on the action: + +```cpp +mcpp::action a; +a.id = "gen"; +a.role = mcpp::roles::source; +a.env("GEN_MODE", "release") + .cwd("tools") // relative to the package root + .arg("./gen").arg(out.c_str()) + .output(out.c_str()) + .submit(); +``` + +The engine's action wrapper sets them before it runs the command. Declared +inputs, outputs and the stamp are resolved when the plan is made and do not +move with `cwd`; the command's own arguments are passed unchanged, so a +relative path among them is relative to `cwd`. A variable's value is part of +the edge's command line, and changing it re-runs the action. An action that +declares neither keeps the protocol 12 command line byte for byte. + +#### A dependency's program in an action: `${mcpp.artifact:}` (2026.9.27.1+) + +A dependency edge with `artifacts = [""]` builds that program for the +consumer's target ([05](05-dependencies.md)). An action names it with +`${mcpp.artifact:/}`, in its arguments and its inputs: + +```cpp +a.arg("cp").arg("${mcpp.artifact:updater/updater}").arg(out.c_str()) + .input("${mcpp.artifact:updater/updater}") + .output(out.c_str()) + .submit(); +``` + +A placeholder that names no `artifacts` entry fails planning and is named in the +error. + ### Deploying what the program generated: `deploy` (2026.9.12.3+, protocol 11) `[runtime] deploy` (docs/04 §2.11) places a file that already exists in the diff --git a/docs/specs/build-plugins.md b/docs/specs/build-plugins.md index 8bd9ed7f2..fead3f5ab 100644 --- a/docs/specs/build-plugins.md +++ b/docs/specs/build-plugins.md @@ -4,12 +4,12 @@ |---|---| | 规范编号 | SPEC-007 | | 标题 | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | -| 状态 | 草案 v0.2 | -| 版本 | 0.2 | -| 最后修改 | 2026-09-26 | -| 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2 | +| 状态 | 草案 v0.3 | +| 版本 | 0.3 | +| 最后修改 | 2026-09-27 | +| 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2;注明 mcpp#707、#708、#709、#711 的条款对应 mcpp >= 2026.9.27.1 | | 相关设计文档 | `.agents/docs/2026-09-26-compile-database-and-issue-699-design.md`(§5) | -| 相关 issue | mcpp#699、mcpp#701、mcpp#702、mcpp#703 | +| 相关 issue | mcpp#699、mcpp#701、mcpp#702、mcpp#703、mcpp#707、mcpp#708、mcpp#709、mcpp#711 | | 使用文档 | [docs/30 - build.mcpp](../30-build-mcpp.md)、[docs/31 - 编写规则包](../31-authoring-a-rule-package.md) | 本规范规定构建插件对引擎和对消费方承担的义务,以及引擎为此提供的机制。docs/31 说明怎样编写 @@ -126,6 +126,12 @@ 下载的 action(例如由包管理器取得源码)**必须**在其说明中写明,并在离线构建中 (`--offline` 或 `MCPP_OFFLINE=1`;前者在进程环境中设置后者,action 继承之)不访问网络: 从缓存完成,或以指出缺失内容的消息失败。(作者义务;环境传递 **已实现**) +- **R3.8** action 需要的环境变量与工作目录**必须**用 `env(name, value)` 与 `cwd(dir)` 声明 + (协议 13),**禁止**写成命令中的 shell 语法(`NAME=value cmd`、`cd dir &&`),因为 R3.1 + 不假定 shell。引擎的 action 包装器在运行命令前设置它们:`cwd` 按声明包的根目录解析;声明的 + 输入、输出与 stamp 在规划时解析为绝对路径,不受 `cwd` 影响;命令参数原样传给命令,其中的 + 相对路径相对于 `cwd`。变量的值属于这条边的命令行,值改变时该 action 重新运行。两者都未 + 声明的 action,其命令行与协议 12 逐字节相同。(**已实现**,mcpp#708) ## 4. 运行时:程序依赖的共享库的查找 @@ -149,9 +155,11 @@ 的配置与构建相同。(**已实现**) - **R5.2** 一个包的构建程序在规划中失败时,该包只按其清单描述,并得到一条错误诊断;成员的 其余部分照常描述。插件遵守 R1.2 时,环境不完整不会使构建程序失败。(**已实现**,mcpp#702) -- **R5.3** 插件所需的宿主工具在规划中构建失败时,规划继续,构建程序收到该工具将被发布的路径, - 并产生一条警告。插件**应当**在 action 中运行宿主工具,而不是在构建程序中运行,使规划不依赖 - 工具能否构建。(引擎部分 **已实现**,mcpp#702;「应当」为作者义务) +- **R5.3** 规划不构建宿主工具(SPEC-005 R2.5)。全局工具库中已有的工具照常使用;没有的工具 + 被推迟,规划产生一条 note `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`,点名工具与其所属包, + 请求它的构建程序收到该工具将被发布的路径。插件**应当**在 action 中运行宿主工具,而不是在 + 构建程序中运行,使规划不依赖工具是否已经构建。(引擎部分 **已实现**,mcpp#707;此前规划 + 构建宿主工具,构建失败时降级为警告,mcpp#702;「应当」为作者义务) ## 6. 环境与载荷 @@ -160,6 +168,13 @@ 的包上:`xpkg_dir` 为正在构建的包回答;`host-module` 的声明对编入它的每个构建程序可见 (docs/31)。(**已实现**) - **R6.2** 插件**禁止**探测宿主路径来寻找工具或 SDK;未声明的依赖不可复现。(作者义务) +- **R6.3** 插件的某个特性需要本包的程序在构建机器上运行时,**应当**在该特性上声明 + `[features.] tools = [""]`,而不是要求每个消费方在依赖边上重复写 `tools`。启用该 + 特性的消费方得到该工具,与边上写了 `tools` 相同(SPEC-004 §10.2)。(**已实现**,mcpp#709) +- **R6.4** 一个需要随消费方发布、在消费方的目标上运行的程序(更新器、辅助进程)**必须**以依赖 + 边的 `artifacts` 取得(SPEC-004 §10.3),**禁止**以 `tools` 取得:`tools` 为构建机器构建, + 交叉构建中得到错误架构的程序。action 以 `${mcpp.artifact:<依赖>/<目标>}` 引用它的路径。 + (**已实现**,mcpp#711) ## 7. 版本与兼容 @@ -182,4 +197,5 @@ | 版本 | 日期 | 变更 | |---|---|---| | 0.1 | 2026-09-26 | 首版草案(mcpp#699、#701、#702、#703)。 | +| 0.3 | 2026-09-27 | 随 mcpp 2026.9.27.1:新增 R3.8(action 的 `env` 与 `cwd`,协议 13,mcpp#708);R5.3 改为规划不构建宿主工具、缺失的工具以 note 推迟(mcpp#707);新增 R6.3(特性的 `tools`,mcpp#709)与 R6.4(`artifacts` 与 `${mcpp.artifact:}`,mcpp#711)。 | | 0.2 | 2026-09-26 | 随 mcpp 2026.9.26.2 落地:R1.3 的警告、R2.1 的 `runtime_search_dir`、R2.4、R3.3 的 `prepare`(目录须含文件;链接边等待所有 `prepare`)、R3.5、R3.6、R4.1、R4.3、R5.2、R5.3 标为已实现。 | diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index ae6d04e2a..91da1bd6d 100644 --- a/docs/specs/manifest-semantics.md +++ b/docs/specs/manifest-semantics.md @@ -5,8 +5,8 @@ | **规范编号** | SPEC-004 | | **标题** | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | | **状态** | **草案(Draft)** | -| **版本** | 1.7 | -| **最后修改** | 2026-09-26 | +| **版本** | 1.8 | +| **最后修改** | 2026-09-27 | | **最低实现版本** | 条件化形状:mcpp **2026.8.29.1**(`[target..build-dependencies]` 起齐备);目标轴:mcpp **2026.9.6.4** | | **作者/维护** | mcpp-community | | **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md` | @@ -263,7 +263,21 @@ feature-deps feature-xlings ← 限定词是门 版本,`>=2099.1` 被拒绝。实现**必须**让 `mcpp::xpkg_dir` 回答范围——安装了却答「不 存在」,是让规则包无法声明下界的那个缺口。 -**状态:已实现**(2026.9.6.6)。 +`xpkg_dir` 对一条地址的回答**必须**是 xlings 为它安装的那个载荷:先取 xlings 报告的 +解析结果,没有时按 xlings 的版本文法在已安装的版本目录中选择(SPEC-001 §10.1)。 +`libglvnd@1.7` 因此回答 `1.7.0.1`。 + +**状态:已实现**(2026.9.6.6;按 xlings 文法回答自 2026.9.27.1,mcpp#712)。 + +### 4.6 宿主构建读取宿主三元组的行 + +不带 `--target` 的构建以宿主为目标。`[target.<宿主三元组>]` 对它的描述与对任何其他目标 +的描述相同,**必须**被应用:`toolchain`、`linkage`、`cxx_runtime` 等键的效果与 +`--target <宿主三元组>` 相同。行的查找与 `--target` 使用同一个与拼写无关的比较, +`x86_64-unknown-linux-gnu` 找到 `[target.x86_64-linux-gnu]`。命令行的 `--toolchain` +(`MCPP_TOOLCHAIN`)仍优先于行的 `toolchain`。 + +**状态:已实现**(mcpp 2026.9.27.1,mcpp#704)。 ## 5. 命名规约 @@ -357,6 +371,22 @@ feature-deps feature-xlings ← 限定词是门 `-Wl,-rpath,$ORIGIN/../lib` 原样到达程序的运行路径,不出现 `/../lib`;依赖传播的同一 元素同样原样到达;含空格的 `link_search` 目录是一个参数 (`tests/e2e/795_a_link_flag_reaches_the_linker_as_written.sh`)。 +16. §4.5 按 xlings 文法回答的判据:xlings 发布的版本选择向量在 mcpp 的实现上逐条得到相同 + 结果(`modules/versioning/tests/data/semver-vectors.tsv`, + `modules/versioning/tests/test_xpkg_version.cpp`);`libglvnd@1.7` 在只装有 `1.7.0.1` + 时回答该目录(`tests/unit/test_freestanding.cpp`)。 +17. §4.6 的判据**必须**带对照腿:没有行时默认构建自包含,写了宿主行 + `cxx_runtime = "toolchain-coupled"` 后普通构建需要 `libstdc++.so.6`,行以另一种拼写 + 书写时同样生效(`tests/e2e/802_a_host_build_applies_its_host_row.sh`)。 +18. §9 第 8 至 10 条的判据:成员得到根的条目与条件行,自己声明的同一个包保留自己的地址; + 未解析的 `workspace = true` 在三张依赖表中都被点名拒绝;成员工具的工具链取工作空间的 + `[toolchain]`,自己声明时取自己的(`tests/unit/test_workspace_inheritance.cpp`)。 +19. §10.2 的判据**必须**两个方向都跑:只写 `features = ["codegen"]` 的消费方得到工具并编译 + 它生成的源,不启用该特性的消费方什么都不构建;`tools` 指名非 `bin` 目标时加载被拒绝 + (`tests/e2e/800_a_feature_provides_its_host_tools.sh`)。 +20. §10.3 的判据:程序构建到 `bin/` 并可运行,依赖的代码不在消费方中,占位符到达 action, + `mcpp pack` 的归档含该程序;有 musl 工具链时,`--target x86_64-linux-musl` 下它为目标构建 + (`tests/e2e/801_a_dependency_program_is_shipped_with_the_consumer.sh`)。 ## 8. flag 列表的元素 @@ -421,8 +451,61 @@ mcpp 2026.9.26.2,#703)。** 全部输入。 7. 工作空间成员的发布形态**必须**自包含:发布的清单写出继承来的值,兄弟成员之间的 `path` 边以版本边发布,无法以版本表达的 `path` 边**必须**被拒绝发布。 +8. 成员**必须**继承工作空间根的 `[xlings.workspace]` 条目,包括 + `[target..xlings.workspace]` 的条件行(按行继承,合并时由选择器决定)。继承是 + 隐式的,与 `[toolchain]` 相同,因为载荷描述的是构建运行的环境,不是依赖图的边。成员自己 + 声明的同一个包(身份为 `(namespace, name)`)优先。`[feature-xlings.]` 不被继承:特性 + 属于声明它的包。 +9. 一条 `x.workspace = true` 条目在继承之后仍未解析时,实现**必须**在它进入构建的每个位置 + (根包、`-p` 选中的成员、`path` 与 `git` 依赖、索引依赖)拒绝它,并点名条目所在的表与 + 名称。带 `[package]` 的工作空间根按它自己的 `[workspace.dependencies]` 解析自己的 + `workspace = true` 条目。 +10. `[toolchain]`、`[target.]` 与 `[indices]` 是根位置的键:它们为整个依赖图选择 + 编译器、目标行与索引,因此只在成员作为一次构建的根时继承。作为宿主工具构建的成员是其 + 子构建的根,同样继承这三项(§10.1)。 + +**状态:已实现(第 1 至 7 条 mcpp 2026.9.25.1;第 8 至 10 条 mcpp 2026.9.27.1,mcpp#713、 +#714、#710)。** + +## 10. 依赖的程序 + +一条依赖边可以取得依赖包的 `bin` 目标,而不链接它的代码。取得的方式由边决定,因为程序 +在哪台机器上运行决定了它为哪个目标构建。 + +### 10.1 `tools`:在构建机器上运行的程序 + +`x = { ..., tools = [""] }` 取得依赖为构建机器构建的程序:它在一次嵌套的子构建中构建, +发布到全局工具库,构建程序以 `mcpp::dep_bin("", "")` 取得路径。 + +- 子构建的工具链由请求它的构建决定一次:`--toolchain`(`MCPP_TOOLCHAIN`)优先;否则取 + 工具包自己的声明——应用它所在工作空间的根位置键(§9 第 10 条)之后,先宿主行的 + `toolchain`,再 `[toolchain]`;都没有时取请求方为构建程序使用的宿主工具链。决定的结果 + 传给子构建,并写入工具库的键,因此键与产物不会不一致。 +- 工具库键中的源树摘要不包含带有自己 `mcpp.toml` 的子目录:工作空间根作为工具包时,其成员 + 的改动不使工具重建。 + +**状态:已实现**(mcpp#355;工具链的决定与源树摘要自 mcpp 2026.9.27.1,mcpp#710、#705)。 + +### 10.2 特性的 `tools` + +`[features.] tools = [""]` 陈述启用特性 `f` 需要本包的程序 `` 在构建机器上 +运行。启用该特性的依赖边,等同于在边上写了 `tools = [""]`;不启用时不构建。条目 +**必须**指名本包的一个 `bin` 目标,否则清单在加载时被拒绝,消息列出本包的 `bin` 目标。 + +**状态:已实现**(mcpp 2026.9.27.1,mcpp#709)。 + +### 10.3 `artifacts`:随消费方发布的程序 + +`x = { ..., artifacts = [""] }` 取得依赖的 `bin` 目标,以**消费方**的目标与 profile +构建,作为消费方计划中的一个链接单元,输出到消费方的 `bin/`。 + +- 只经 `artifacts` 边到达的包的代码不链接进消费方;同一个包另经普通边到达时照常链接。 +- 构建程序的 action 以 `${mcpp.artifact:/}` 引用该程序的路径,可用于命令与输入; + 名称不对应一个 `artifacts` 条目时,规划失败并点名该占位符。 +- `mcpp run` 不选择该程序;`mcpp pack` 把它放在消费方程序旁。 +- 交叉构建(`--target`)中该程序为目标构建,与 `tools` 为构建机器构建相对。 -**状态:已实现(mcpp 2026.9.25.1)。** +**状态:已实现**(mcpp 2026.9.27.1,mcpp#711)。 ## 变更记录 @@ -436,3 +519,4 @@ mcpp 2026.9.26.2,#703)。** | 1.5 | 2026-09-17 | 编译 flag 列表元素的读法(mcpp 2026.9.17.1,#655):新增 §8 与 §7 第 10 条判据。 | | 1.6 | 2026-09-25 | 工作空间继承与构建需求的作用域(mcpp 2026.9.25.1,#690):§8 补 `defines` 的集合语义;新增 §9 与 §7 第 11 至 14 条判据。 | | 1.7 | 2026-09-26 | §8 的读法扩展到 `ldflags` 与构建程序的链接指令(mcpp 2026.9.26.2,#703):`$ORIGIN` 原样到达链接器;§7 补第 15 条判据。 | +| 1.8 | 2026-09-27 | mcpp 2026.9.27.1:§4.5 的版本位按 xlings 文法回答(#712);新增 §4.6 宿主构建读取宿主三元组的行(#704);§9 补第 8 至 10 条(#713、#714、#710);新增 §10 依赖的程序:`tools`、特性的 `tools`、`artifacts`(#709、#711);§7 补第 16 至 20 条判据。 | diff --git a/docs/specs/package-identity.md b/docs/specs/package-identity.md index 30b013ab7..35c4130a0 100644 --- a/docs/specs/package-identity.md +++ b/docs/specs/package-identity.md @@ -5,8 +5,8 @@ | **规范编号** | SPEC-001 | | **标题** | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | | **状态** | **评审中(Review)** —— 已实现 | -| **版本** | 1.4 | -| **最后修改** | 2026-09-14 | +| **版本** | 1.5 | +| **最后修改** | 2026-09-27 | | **最低实现版本** | 描述符身份:mcpp **0.0.106**;精确 selector:mcpp **2026.8.10.1**(xlings >= 0.4.69) | | **作者/维护** | mcpp-community | | **相关设计文档** | `.agents/docs/2026-06-20-package-resolution-architecture.md` §4
`.agents/docs/2026-06-26-identity-first-resolution-no-filename.md`
`.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md`
`.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md`
`.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md` | @@ -469,12 +469,44 @@ lua = "0.0.3" 已安装的版本目录回答查询。mcpp 这边新增的只有「哪一条声明胜出」与「胜出的那条是否 满足其余要求」,两者都不需要知道索引里有哪些版本。 +### 10.1 版本位的回答与 xlings 一致(2026.9.27.1+) + +一条地址的版本位在 mcpp 侧的含义**必须**与 xlings 选择载荷时的含义相同,否则 xlings 装下的 +载荷在 mcpp 查询时不存在(mcpp#712:`libglvnd@1.7` 装下 `1.7.0.1`,`xpkg_dir` 回答空)。 + +1. xlings 每次安装在 `install_targets` 事件中报告每个请求解析到的载荷(接口协议 1.1)。mcpp + 按地址记录这一结果,之后的查询(根包、成员、依赖的构建程序)先读该记录,记录指向的目录 + 存在且位于该包的载荷目录之下时,以它为答案。 +2. 没有记录时,mcpp 在已安装的版本目录中按 xlings 的版本文法选择:先按字面匹配目录名;三段 + 及以上的版本要求书写部分逐段相等,一至两段的版本表示前缀范围;预发布版本只精确匹配; + `>=`、`^`、`~`、`*` 与以空格连接的多个运算符按 xlings 的语义求值;`latest` 与名称不参与 + 比较。不带版本的地址取最高版本。 +3. 一个声明过的地址在其安装记录仍在、载荷目录已不存在时,不算已安装:联网时重新安装,离线 + 或关闭自动安装时拒绝,并点名缺失的地址(mcpp#716)。构建缓存记录一次构建读取的载荷目录, + 其中任一目录不存在时,快路径不复用该缓存。 + +两者的一致性由共享的测试向量判定:xlings 仓库的 `tests/data/semver-vectors.tsv` 列出 +「请求、可用版本、当前版本、期望结果」,mcpp 的 `mcpp.xpkg_version` 以同一组向量测试。 + +### 10.2 打包修订 `revision`(2026.9.27.1+) + +索引条目的 `revision` 是同一上游版本的打包修订号(非负整数,缺省为 0),见 xim-pkgindex +V2 规范。版本回答「装的是哪一个上游版本」,修订号回答「装的是该版本的哪一次打包」;修订号 +不进入地址,也不参与 §10.1 的选择。 + +- xlings 把安装时的修订号写入载荷的安装记录;已安装载荷的修订号低于索引条目时,xlings 重新 + 安装该载荷。 +- mcpp 以依赖的索引条目的修订号判断一个已安装的依赖是否完整;修订号不同的载荷按未安装处理。 +- 修订号大于 0 的运行时载荷进入运行时契约(`revision=`),因此修订号变化使依赖该载荷的 + 产物重新链接;修订号为 0 时契约的文本不变。 + --- ## 11. 变更记录 | 版本 | 日期 | 变更 | |---|---|---| +| 1.5 | 2026-09-27 | 新增 §10.1:版本位的回答与 xlings 一致(安装记录优先,其次按 xlings 版本文法选择;已记录而缺失的载荷不算已安装),mcpp#712、#716;新增 §10.2:打包修订 `revision` 的含义与 mcpp 侧的三处使用(mcpp 2026.9.27.1,xlings 2026.9.27.1) | | 1.4 | 2026-09-14 | 新增 §5.4:`path` 与 `git` 依赖的身份取自其清单,键规范化到另一身份时采用清单声明并告警,同一来源上的两个身份在扫描前拒绝(mcpp 2026.9.14.2) | | 1.3 | 2026-09-07 | 新增 §10:同一条身份规则扩展到 xlings 工具地址(mcpp 2026.9.6.6)。此前工具侧有两套定义,同一个包被两处以不同版本声明时两份都装、只用一份 | | 1.2 | 2026-08-09 | selector 收敛为唯一精确 PackageId:裸名只表示默认 mcpplibs,dotted 以最后一段为 name;移除 compat/空 namespace 隐式候选,加入 lock 保持与一个 release train 的双 selector 迁移 warning | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index e45e29733..035279b4c 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -1692,6 +1692,7 @@ kind = "bin" |---|---|---| | 源文件 | `src/**/*.{cppm,cpp,cc,c,S,s,asm}` | 自动递归扫描 | | 入口点 | `src/main.cpp` | 这个文件存在时,会推断出一个 `bin` 目标 | +| 库目标 | `src/` 下的模块接口 | 没有 `[targets]` 表且没有 `src/main.cpp` 时推断;`[build] sources = []` 陈述默认构建不编译任何源,因此不推断库目标(2026.9.27.1+) | | 库根 | `src/<包名的最后一段>.cppm` | 用 `[lib].path` 覆盖 | | C++ 标准 | `c++23` | 用 `[package].standard` 配置;支持 `c++20` / `c++26` / `c++2a` / `c++2c` / `gnu++NN` / `c++latest` / `c++fly`(实验性试验场) | | C 标准 | `c11` | `.c` 文件自动经由 C 编译器处理 | diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 654923815..0c0020094 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -415,6 +415,55 @@ spike.installer = { path = "../installer", tools = ["installer"] } 会加到这一行当前生效的声明上。重述若写了另一个源,会被拒绝,并列出两个源 (mcpp 2026.9.16.1+);该版本之前它会被忽略。 +**哪个编译器构建工具(mcpp 2026.9.27.1+)。** 请求工具的构建只决定一次它的工具链: +给出 `--toolchain` 时用它;否则取工具包自己的声明,按它自己的构建读取的方式读取(工作空间 +成员先应用工作空间根的 `[toolchain]`、`[target.]` 与 `[indices]`),宿主行的 +`toolchain` 先于 `[toolchain]`;都没有时取请求方编译构建程序所用的宿主工具链。这个决定 +传给工具的子构建,并写入工具库的键,因此在工作空间中 `mcpp build -p ` 与为消费方 +构建同一个工具使用同一个编译器。工具包的源树摘要跳过带有自己 `mcpp.toml` 的目录,修改 +工作空间的成员不会使工作空间根提供的工具重建。 + +### 特性提供本包的工具(mcpp 2026.9.27.1+) + +一个包的某个特性需要本包的程序在构建机器上运行时,在特性上陈述,消费方只写特性: + +```toml +# 工具包 +[features.codegen] +tools = ["codegen"] + +[targets.codegen] +kind = "bin" +main = "src/codegen.cpp" +``` + +```toml +# 消费方 +[dependencies] +toolpkg = { path = "../toolpkg", features = ["codegen"] } +``` + +启用该特性等同于在边上写 `tools = ["codegen"]`:程序为宿主构建, +`mcpp::dep_bin("toolpkg", "codegen")` 给出它的路径。不启用该特性的消费方什么都不构建。 +条目指名的不是本包的 `bin` 目标时,清单在加载时被拒绝,消息列出本包的 `bin` 目标。 + +### 随消费方发布的依赖程序:`artifacts`(mcpp 2026.9.27.1+) + +`tools` 为运行构建的机器构建程序。随消费方发布、在消费方的目标上运行的程序(更新器、 +辅助进程)用 `artifacts` 请求: + +```toml +[dependencies] +updater = { path = "../updater", artifacts = ["updater"] } +``` + +- 依赖的 `bin` 目标以消费方的目标与 profile 构建,作为消费方计划中的一个链接单元,输出到 + 消费方的 `bin/`。在 `--target x86_64-linux-musl` 下它是 musl 程序。 +- 这条边不把依赖的任何代码链接进消费方。同一个包另经普通边到达时照常链接。 +- 消费方构建程序的 action 以 `${mcpp.artifact:updater/updater}` 在参数与输入中引用该程序; + 名称不对应任何 `artifacts` 条目时,规划失败并点名该占位符。 +- `mcpp run` 不选择它,`mcpp pack` 把它放在消费方程序旁。 + > 这个段很早就能被解析,而直到 2026.8.29.1,没有任何做决定的代码读过它: > 写下它得到的是一份能加载的 manifest、零诊断、零效果。 diff --git a/docs/zh/06-features-and-capabilities.md b/docs/zh/06-features-and-capabilities.md index 53aca4014..57bf6f25a 100644 --- a/docs/zh/06-features-and-capabilities.md +++ b/docs/zh/06-features-and-capabilities.md @@ -85,7 +85,7 @@ simd = { sources = ["src/simd/**"], flags = [ ``` - **表形式恰好接受** `implies`、`forward`、`defines`、`sources`、`flags`、 - `requires`、`provides`。其余键会被报为一条 schema warning 并忽略 + `requires`、`provides`、`tools`。其余键会被报为一条 schema warning 并忽略 (mcpp 2026.9.1.1+);`deps` 单独报为"保留",并指向 `[feature-deps.]`。 该版本之前,`[features]` 是唯一一个完全没有 schema 检查的结构化段落 —— 把 `include_dirs` 误写进 feature 里会零诊断地构建成功,而同样的错误写在 @@ -108,6 +108,9 @@ simd = { sources = ["src/simd/**"], flags = [ 与 `defines` 不同,feature 的 `flags` 是**私有的、per-TU 的构建旗标**——它们 从不传播给消费方(与 `[build].flags` 同一契约),因此不破坏可加模型:由 glob 限定作用面,顺序确定,没有跨包效应。 +- `tools`(mcpp 2026.9.27.1+)指名该 feature 需要在构建机器上运行的本包 `bin` 目标。 + 启用该 feature 的消费方得到这些工具,与其依赖边上写了 `tools = [...]` 相同 + ([05,构建期依赖](05-dependencies.md));指名的不是本包的 `bin` 目标时,加载被拒绝。 ### 作为构建规则的 feature(mcpp 2026.9.7.1+) diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 63d15cb49..18a0fb0d9 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -136,6 +136,11 @@ gtest.workspace = true # 继承版本 → "1.15.2" mbedtls = "4.0.0" # override; does not use the workspace version ``` +写了 `.workspace = true` 而没有工作空间解析它的条目,在该包进入构建的每个位置(根包、 +`-p` 选中的成员、`path`、`git` 与索引依赖)都被拒绝,消息点名所在的表与条目 +(mcpp 2026.9.27.1+)。它按 `members` 列出该包的工作空间的 `[workspace.dependencies]` +解析;带自己 `[package]` 的工作空间根以同样方式解析自己的条目。 + ## 4. 工具链与构建配置的继承 工作空间根的 `[toolchain]` 与 `[target.]` 配置由全体成员自动继承。成员 @@ -165,6 +170,19 @@ linkage = "static" default = "llvm@20.1.7" ``` +`[toolchain]`、`[target.]` 与 `[indices]` 为整个依赖图选择编译器、目标行与索引, +因此成员只在作为一次构建的根时从工作空间根继承它们:从工作空间构建、以 `-p` 选中,或作为 +另一个包的宿主工具构建(最后一种自 mcpp 2026.9.27.1)。作为依赖到达的成员从该次构建的根 +取得它们。 + +不带 `--target` 的构建以宿主为目标,`[target.<宿主三元组>]` 对它生效,与 +`--target <宿主三元组>` 相同(mcpp 2026.9.27.1+)。 + +根的 `[xlings.workspace]` 条目,包括 `[target..xlings.workspace]` 行,同样隐式 +继承(mcpp 2026.9.27.1+):载荷描述的是构建运行的环境,与 `[toolchain]` 相同,不需要 +显式声明。成员自己声明的同一个包优先。`[feature-xlings.]` 不被继承,因为特性属于声明 +它的包。 + ### 4.1 `[workspace.package]` 与 `[workspace.build]` 全体成员共享的包元信息与构建标志,在工作空间根声明一次: diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 08bf2a872..6a2a5be2a 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -368,6 +368,12 @@ store 内部结构 —— 与 `dep_dir` 存在的理由相同。 (2026.9.6.6+)。在那之前整个版本位是拿去与目录名比对的,于是一条范围装上了载荷,然后 回答「没装」—— 这正是规则包无法声明下界、而每个工程都要把规则的包列表重写一遍的原因。 +**答案是 xlings 装下的那个载荷**(2026.9.27.1+)。xlings 报告每条地址解析到的载荷, +`xpkg_dir` 先读这份记录。没有记录时,按 xlings 的版本文法在已安装的版本目录中选择:一至两段 +的裸版本是前缀范围,三段及以上须按书写部分逐段相等,因此 `libglvnd@1.7` 回答 `1.7.0.1`。 +此前 `1.7` 按 Cargo 文法读取,看不到四段的目录,对磁盘上的载荷回答 `""`。一个声明过、安装 +之后又被删除的载荷会被重新安装,离线时被拒绝,而不是回答 `""`(SPEC-001 §10.1)。 + **依赖声明的包同样被作答**(2026.9.6.6+),而且答的是这次构建**真正装上**的版本,不是 本地 manifest 写下的那个。一个包只有一个版本:工程与规则都命名它时,离产物更近的声明赢, 而两侧被告知同一个答案。见 [23 — The Project Environment](23-the-project-environment.md) 的「一个包一个版本」。 @@ -577,6 +583,41 @@ mcpp 会播下一个带着该声明的占位文件,使 prepare 期的扫描与 内容一致 —— 与 `[modules].scan_overrides` 同一条「声明 + 验证」的取舍,build 期由 编译器自己的 P1689 输出复核。 +#### 环境变量与工作目录:`env` / `cwd`(protocol 13) + +action 的命令是 argv,不是 shell 命令行,因此 `NAME=value cmd` 与 `cd dir && cmd` 对它 +不可用。一个由环境变量配置、或必须在某个目录中运行的生成器,在 action 上陈述两者: + +```cpp +mcpp::action a; +a.id = "gen"; +a.role = mcpp::roles::source; +a.env("GEN_MODE", "release") + .cwd("tools") // 相对于包根 + .arg("./gen").arg(out.c_str()) + .output(out.c_str()) + .submit(); +``` + +引擎的 action 包装器在运行命令之前设置它们。声明的输入、输出与 stamp 在生成计划时解析, +不随 `cwd` 移动;命令自己的参数原样传递,其中的相对路径相对于 `cwd`。变量的值属于这条边的 +命令行,值改变时该 action 重新运行。两者都未声明的 action,其命令行与 protocol 12 逐字节 +相同。 + +#### action 中的依赖程序:`${mcpp.artifact:}`(2026.9.27.1+) + +带 `artifacts = [""]` 的依赖边为消费方的目标构建该程序([05](05-dependencies.md))。 +action 以 `${mcpp.artifact:<依赖>/}` 在参数与输入中引用它: + +```cpp +a.arg("cp").arg("${mcpp.artifact:updater/updater}").arg(out.c_str()) + .input("${mcpp.artifact:updater/updater}") + .output(out.c_str()) + .submit(); +``` + +占位符不对应任何 `artifacts` 条目时,规划失败,错误中点名该占位符。 + ### 部署程序生成的东西:`deploy`(2026.9.12.3+,protocol 11) `[runtime] deploy`(docs/04 §2.11)把包里已经存在的一个文件,放到相对可执行 diff --git a/mcpp.toml b/mcpp.toml index afdd732c2..e70ad04ef 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.26.2" +version = "2026.9.27.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 60c3bcdd0..c07002110 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.9.26.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.27.1"; } // namespace mcpp diff --git a/modules/versioning/src/xpkg_version.cppm b/modules/versioning/src/xpkg_version.cppm index 74733484b..51a162519 100644 --- a/modules/versioning/src/xpkg_version.cppm +++ b/modules/versioning/src/xpkg_version.cppm @@ -85,6 +85,15 @@ int compare_keys(std::string_view a, std::string_view b); std::optional select_best(std::span available, std::string_view request); +// The installed version an address resolves to, by the rule xlings selects +// with: a key equal to `request` first (a version whose name does not parse, +// `8.0.RC1`, is addressable only that way), then `select_best`; an empty +// request takes the highest key by `compare_keys`. nullopt when none is +// selected. The conformance vectors xlings publishes are stated against this +// function (tests/data/semver-vectors.tsv). +std::optional +select_installed(std::span installed, std::string_view request); + } // namespace mcpp::xpkg_version namespace mcpp::xpkg_version { @@ -322,4 +331,17 @@ select_best(std::span available, std::string_view request) { return best; } +std::optional +select_installed(std::span installed, std::string_view request) { + if (!request.empty()) { + if (std::ranges::find(installed, request) != installed.end()) + return std::string(request); + return select_best(installed, request); + } + std::optional best; + for (auto const& key : installed) + if (!best || compare_keys(key, *best) > 0) best = key; + return best; +} + } // namespace mcpp::xpkg_version diff --git a/modules/versioning/tests/data/semver-vectors.tsv b/modules/versioning/tests/data/semver-vectors.tsv new file mode 100644 index 000000000..d792589b3 --- /dev/null +++ b/modules/versioning/tests/data/semver-vectors.tsv @@ -0,0 +1,108 @@ +# Vendored from openxlings/xlings tests/data/semver-vectors.tsv at xlings 2026.9.27.1. +# Refresh it when mcpp moves its pinned xlings version (kXlingsVersion). +# +# xlings version-resolution conformance vectors +# +# Which version an install request selects from the versions a recipe +# declares. The unit test tests/unit/test_semver_vectors.cpp drives the +# resolver xlings installs with (pin_target_to_subos, then the catalog's +# version selection) with every vector below; another implementation of the +# same grammar can vendor this file and run it unchanged. +# +# FORMAT +# +# One vector per line, four columns separated by a single TAB: +# +# request the version part of a request, as written after `@` +# (`1.7`, `>=1.2 <2`, `^1.2.3`); `-` for a bare name. +# May contain spaces, never a tab. +# available the version keys the recipe declares for the platform, +# comma-separated, no spaces. Every key names a concrete +# version (no aliases, no `latest`). +# active the version active in the workspace, `-` for none. +# expected the version selected, or `none` when nothing is. +# +# Lines starting with `#` and blank lines carry no vector. +# +# RULES THE VECTORS EXERCISE +# +# 1. A request that equals a declared key selects that key. +# 2. Otherwise the highest declared version satisfying the request wins. +# 3. A bare version of three or more segments is written-prefix equality, +# floored at three segments: 1.7.0 matches 1.7.0 and 1.7.0.1, never +# 1.7.1; 1.8.12 never selects 1.9.0. +# 4. A bare version of one or two segments is the prefix range [v, next): +# 1.2 is [1.2, 1.3), 1 is [1, 2). 1.1 never matches 1.10. +# 5. A prerelease is exact; a release request never selects a prerelease. +# 6. Operators: >=, >, <=, < (a space-separated list is a conjunction), +# ^ (up to the next release of the first nonzero segment), ~ (up to the +# next second segment), and the wildcards 1.2.* and 1.*. Ordering +# compares segment by segment, numerically, a missing segment being 0. +# 7. A bare name selects the highest declared version. +# 8. An active version that satisfies the request, and that the recipe +# declares, is selected; otherwise resolution proceeds as if nothing +# were active. +# +# request available active expected + +# rule 1: a declared key is selected as written +1.2 1.2,1.2.0,1.2.5 - 1.2 +1.7.0 1.7.0,1.7.0.1 - 1.7.0 +2.15.0.1 2.15.0.1,2.15.0.2 - 2.15.0.1 +2026.7.31.2 2026.7.31.1,2026.7.31.2,2026.8.1.1 - 2026.7.31.2 +25.0.4+7 25.0.3+9,25.0.4+7 - 25.0.4+7 + +# rule 3: three or more segments are written-prefix equality +1.7.0 1.7.0.1,1.7.1 - 1.7.0.1 +1.7.0 1.7.0.1,1.7.0.2,1.7.1 - 1.7.0.2 +12.9.1 12.9.1.2,12.9.1.4,12.9.2.0 - 12.9.1.4 +2.15.0 2.15.0.1,2.15.0.2,2.15.1.0 - 2.15.0.2 +1.8.12 1.8.11,1.9.0 - none +1.8.12 1.8.12.4,1.8.13,1.9.0 - 1.8.12.4 +15.1.0 15.1.1,16.1.0 - none + +# rule 4: one or two segments are a prefix range +1.7 1.7.0.1,1.8.0.0 - 1.7.0.1 +1.7 1.6.9.9,1.7.0.1,1.7.2.3,1.8.0.0 - 1.7.2.3 +1.2 1.1.9,1.2.0,1.2.9,1.3.0 - 1.2.9 +1.2 1.3.0,1.10.0 - none +1.1 1.1.0,1.10.0 - 1.1.0 +1 0.9.0,1.0.0,1.9.9,2.0.0 - 1.9.9 +15 14.2.0,15.1.0,15.2.0,16.1.0 - 15.2.0 +2.39 2.38,2.40 - none + +# rule 5: a prerelease is exact +1.0.0-rc1 1.0.0-rc1,1.0.0-rc2,1.0.0 - 1.0.0-rc1 +1.0.0-rc1 1.0.0-rc2,1.0.0 - none +1.0.0 1.0.0-rc1,1.0.0-rc2 - none + +# rule 6: operators +>=1.2 1.1.0,1.2.0,2.0.0 - 2.0.0 +>=1.2 <2 1.1.0,1.2.0,1.9.3,2.0.0 - 1.9.3 +>1.2.0 1.2.0,1.2.1 - 1.2.1 +<=1.5 1.4.0,1.5.0,1.5.1 - 1.5.0 +<2 1.9.9,2.0.0 - 1.9.9 +>=2.38 2.37,2.39,2.40 - 2.40 +>=2.15.0.1 2.15.0.0,2.15.0.1,2.15.0.3 - 2.15.0.3 +>=3 1.0.0,2.9.9 - none +^1.2.3 1.2.2,1.2.3,1.9.0,2.0.0 - 1.9.0 +^0.2.3 0.2.3,0.2.9,0.3.0 - 0.2.9 +~1.2.3 1.2.3,1.2.8,1.3.0 - 1.2.8 +1.2.* 1.2.0,1.2.7,1.3.0 - 1.2.7 +1.* 1.0.0,1.9.0,2.0.0 - 1.9.0 + +# rule 7: a bare name selects the highest declared version +- 1.0.0,2.0.0,1.10.0 - 2.0.0 +- 0.0.9,0.0.11,0.0.100 - 0.0.100 +- 2026.7.31.2,2026.7.31.10 - 2026.7.31.10 + +# rule 8: an active version that satisfies the request is selected +- 1.0.0,2.0.0 1.0.0 1.0.0 +1 1.5.0,1.9.0 1.5.0 1.5.0 +^1.2 1.2.0,1.10.0,1.11.0 1.10.0 1.10.0 +>=2.38 2.39,2.40 2.39 2.39 +12.9.1 12.9.1.2,12.9.1.4 12.9.1.2 12.9.1.2 +1.7 1.7.0.1,1.7.2.3 1.7.0.1 1.7.0.1 +1.1 1.1.0,1.10.0 1.10.0 1.1.0 +2 1.5.0,2.1.0 1.5.0 2.1.0 +- 1.0.0,2.0.0 0.9.0 2.0.0 diff --git a/modules/versioning/tests/test_xpkg_version.cpp b/modules/versioning/tests/test_xpkg_version.cpp index 3fca9fe39..80ad988a4 100644 --- a/modules/versioning/tests/test_xpkg_version.cpp +++ b/modules/versioning/tests/test_xpkg_version.cpp @@ -57,3 +57,56 @@ TEST(XpkgVersion, NamesNeverWinAndBuildMetadataIsIgnored) { EXPECT_GT(xv::compare_keys("1.0.0", "nightly"), 0); EXPECT_EQ(best({"25.0.4+7"}, "25.0.4"), "25.0.4+7"); } + +// THE SHARED STATEMENT. xlings publishes which version a request selects as +// data (tests/data/semver-vectors.tsv in its repository), and this package +// vendors the file at the xlings version mcpp pins. Every vector is run +// through `select_installed`, the function `mcpp::xpkg_dir` answers with. +// A vector whose `active` column names a version is skipped: the active +// version is state xlings keeps, and mcpp selects among installed payload +// directories, none of which is active. +namespace { +std::filesystem::path vectors_file() { + namespace fs = std::filesystem; + for (auto dir = fs::current_path();; dir = dir.parent_path()) { + for (auto rel : {"tests/data/semver-vectors.tsv", + "modules/versioning/tests/data/semver-vectors.tsv"}) { + std::error_code ec; + if (fs::is_regular_file(dir / rel, ec)) return dir / rel; + } + if (dir == dir.parent_path()) return {}; + } +} + +std::vector split(std::string_view s, char sep) { + std::vector out; + for (std::size_t p = 0;;) { + const auto e = s.find(sep, p); + out.emplace_back(s.substr(p, e == std::string_view::npos ? e : e - p)); + if (e == std::string_view::npos) return out; + p = e + 1; + } +} +} + +TEST(XpkgVersion, TheXlingsConformanceVectorsSelectTheSameVersion) { + const auto file = vectors_file(); + ASSERT_FALSE(file.empty()) << "semver-vectors.tsv not found above " + << std::filesystem::current_path(); + std::ifstream in(file); + std::string line; + int lineNo = 0, checked = 0; + while (std::getline(in, line)) { + ++lineNo; + if (line.empty() || line.front() == '#') continue; + const auto col = split(line, '\t'); + ASSERT_EQ(col.size(), 4u) << file << ":" << lineNo; + if (col[2] != "-") continue; + const auto available = split(col[1], ','); + const auto request = col[0] == "-" ? std::string{} : col[0]; + EXPECT_EQ(xv::select_installed(available, request).value_or("none"), col[3]) + << file << ":" << lineNo << ": request '" << col[0] << "' among " << col[1]; + ++checked; + } + EXPECT_GT(checked, 0); +} diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 37c1a77ab..ea0615dde 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1563,26 +1563,6 @@ sysroot_override(const mcpp::manifest::Manifest& m, return (e && e->sysrootDeclared) ? &e->sysroot : nullptr; } -// The toolchain a host tool's package chose for itself, read the way its own -// build reads it (#710): the package's manifest with the root-position keys of -// the workspace that lists it (`inherit_workspace_root_position`), then its -// host row's `[target.] toolchain`, then `[toolchain]`. nullopt when none -// names one. -std::optional -host_tool_declared_toolchain(const mcpp::manifest::Manifest& tool, - const std::filesystem::path& toolRoot, - std::string_view platform) { - auto effective = tool; - if (const auto wsRoot = mcpp::project::find_workspace_root(toolRoot); !wsRoot.empty()) - if (auto ws = mcpp::manifest::load(wsRoot / "mcpp.toml"); - ws && mcpp::project::is_workspace_member(*ws, wsRoot, toolRoot)) - mcpp::project::inherit_workspace_root_position(effective, *ws, wsRoot); - if (auto* row = find_target_entry(effective, mcpp::toolchain::triple::host_triple()); - row && !row->toolchain.empty()) - return row->toolchain; - return effective.toolchain.for_platform(platform); -} - // THE MSVC TOOLSET A CLANG `*-windows-msvc` BUILD COMPILES AGAINST. // // On an MSVC-ABI row the compiler is the toolchain and the MSVC toolset -- its @@ -2356,6 +2336,27 @@ std::string with_index_cause(std::string msg) { } } // namespace +// The toolchain a host tool's package chose for itself, read the way its own +// build reads it (#710): the package's manifest with the root-position keys of +// the workspace that lists it (`inherit_workspace_root_position`), then its +// host row's `[target.] toolchain`, then `[toolchain]`. nullopt when none +// names one. Exported for its unit test +// (tests/unit/test_workspace_inheritance.cpp). +export std::optional +host_tool_declared_toolchain(const mcpp::manifest::Manifest& tool, + const std::filesystem::path& toolRoot, + std::string_view platform) { + auto effective = tool; + if (const auto wsRoot = mcpp::project::find_workspace_root(toolRoot); !wsRoot.empty()) + if (auto ws = mcpp::manifest::load(wsRoot / "mcpp.toml"); + ws && mcpp::project::is_workspace_member(*ws, wsRoot, toolRoot)) + mcpp::project::inherit_workspace_root_position(effective, *ws, wsRoot); + if (auto* row = find_target_entry(effective, mcpp::toolchain::triple::host_triple()); + row && !row->toolchain.empty()) + return row->toolchain; + return effective.toolchain.for_platform(platform); +} + export std::expected prepare_build(bool print_fingerprint, diff --git a/src/runtime/elf.cppm b/src/runtime/elf.cppm index e8b4a0640..0ce3f04da 100644 --- a/src/runtime/elf.cppm +++ b/src/runtime/elf.cppm @@ -334,6 +334,21 @@ std::optional copy_relocation_type(std::uint16_t machine) { struct Reader { std::vector bytes; + // Reads the whole file with one sized read. Filling the vector through + // an istreambuf_iterator grows it a byte at a time, which made the + // post-link loader check of a test run cost seconds per program. + bool load(const std::filesystem::path& file) { + std::ifstream input(file, std::ios::binary | std::ios::ate); + if (!input) return false; + const auto size = input.tellg(); + if (size < 0) return false; + bytes.resize(static_cast(size)); + input.seekg(0); + if (size == 0) return true; + return static_cast(input.read( + reinterpret_cast(bytes.data()), static_cast(size))); + } + bool range(std::uint64_t off, std::uint64_t size) const { return off <= bytes.size() && size <= bytes.size() - off; } @@ -633,10 +648,8 @@ std::optional dynsym_count_from_gnu_hash( std::expected inspect_dynamic_symbols(const std::filesystem::path& object) { detail::Reader reader; - std::ifstream input(object, std::ios::binary); - if (!input) return std::unexpected(std::format( + if (!reader.load(object)) return std::unexpected(std::format( "cannot open ELF object '{}'", object.string())); - reader.bytes.assign(std::istreambuf_iterator(input), {}); if (reader.bytes.size() < 0x40 || reader.bytes[0] != 0x7f || reader.bytes[1] != 'E' @@ -795,10 +808,8 @@ inspect_dynamic_symbols(const std::filesystem::path& object) { std::expected, std::string> defined_object_symbols(const std::filesystem::path& object) { detail::Reader reader; - std::ifstream input(object, std::ios::binary); - if (!input) return std::unexpected(std::format( + if (!reader.load(object)) return std::unexpected(std::format( "cannot open ELF object '{}'", object.string())); - reader.bytes.assign(std::istreambuf_iterator(input), {}); if (reader.bytes.size() < 0x40 || reader.bytes[0] != 0x7f || reader.bytes[1] != 'E' @@ -862,10 +873,8 @@ defined_object_symbols(const std::filesystem::path& object) { std::expected inspect_elf_runtime(const std::filesystem::path& artifact) { detail::Reader reader; - std::ifstream input(artifact, std::ios::binary); - if (!input) return std::unexpected(std::format( + if (!reader.load(artifact)) return std::unexpected(std::format( "cannot open ELF artifact '{}'", artifact.string())); - reader.bytes.assign(std::istreambuf_iterator(input), {}); if (reader.bytes.size() < 0x40 || reader.bytes[0] != 0x7f || reader.bytes[1] != 'E' diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm index 21cb5aed6..2cd7d1ea4 100644 --- a/src/xlings/xlings.cppm +++ b/src/xlings/xlings.cppm @@ -966,13 +966,11 @@ XpkgRef parse_xpkg_ref(std::string_view spec) { // reading of `1.7` could not see a four-segment directory at all, so // `mcpp::xpkg_dir` answered "" for a payload that was on disk. // -// A literal directory is tried first for every spelling: an installed version -// whose name does not parse -- `8.0.RC1` is a real one -- is addressable only -// that way. After that the request selects among the installed directories -// exactly as xlings would among index keys: a bare version of three or more -// segments is written-prefix equality (1.8.12 matches 1.8.12.x and never -// 1.9.0), one or two segments are a prefix range, operators are ranges. With -// no version the highest installed one is taken, ordered by the same grammar. +// The request selects among the installed directories exactly as xlings would +// among index keys (`select_installed`): a literal name first, then a bare +// version of three or more segments as written-prefix equality (1.8.12 matches +// 1.8.12.x and never 1.9.0), one or two segments as a prefix range, operators +// as ranges; with no version, the highest installed one. std::optional xpkg_payload(const Env& env, const XpkgRef& ref) { if (auto recorded = recorded_payload(env, ref)) return recorded; @@ -984,21 +982,11 @@ xpkg_payload_at(const std::filesystem::path& xpkgsBase, const XpkgRef& ref) { if (ref.name.empty()) return std::nullopt; const auto root = xpkgsBase / std::format("{}-x-{}", ref.ns, ref.name); std::error_code ec; - if (!ref.version.empty()) { - auto p = root / ref.version; - if (std::filesystem::is_directory(p, ec)) return p; - } if (!std::filesystem::is_directory(root, ec)) return std::nullopt; std::vector installed; for (auto const& e : std::filesystem::directory_iterator(root, ec)) if (e.is_directory(ec)) installed.push_back(e.path().filename().string()); - std::optional pick; - if (!ref.version.empty()) { - pick = mcpp::xpkg_version::select_best(installed, ref.version); - } else { - for (auto const& k : installed) - if (!pick || mcpp::xpkg_version::compare_keys(k, *pick) > 0) pick = k; - } + auto pick = mcpp::xpkg_version::select_installed(installed, ref.version); if (!pick) return std::nullopt; return root / *pick; } diff --git a/tests/e2e/802_a_host_build_applies_its_host_row.sh b/tests/e2e/802_a_host_build_applies_its_host_row.sh new file mode 100755 index 000000000..c47281d45 --- /dev/null +++ b/tests/e2e/802_a_host_build_applies_its_host_row.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# requires: gcc elf +# 802_a_host_build_applies_its_host_row.sh — mcpp#704. +# +# A build without `--target` targets the host, and `[target.]` +# describes that target as it describes any other. The row used to be read +# only when a target was named, so its `cxx_runtime` had no effect on a plain +# `mcpp build`, while `--target ` honoured it. A program linking Qt's +# libraries (which require libstdc++.so.6 and carry RUNPATH $ORIGIN) then +# embedded its own libstdc++ and failed at start. +# +# Criteria: +# 1. without the row, a plain build is self-contained (no NEEDED +# libstdc++.so.6), the engine default; +# 2. with `[target.] cxx_runtime = "toolchain-coupled"`, a plain build +# needs libstdc++.so.6, exactly as `--target ` does; +# 3. the row is found under another spelling of the host triple. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +host="$(uname -m)-linux-gnu" + +write_manifest() { # $1 = the row's selector, empty for no row + cat > mcpp.toml <> mcpp.toml + fi +} + +mkdir -p src +cat > src/main.cpp <<'EOF' +#include +#include +int main() { std::string s = "RT_OK"; std::printf("%s\n", s.c_str()); } +EOF + +needs_libstdcxx() { + local bin + bin="$(find target -path '*/bin/cxxrt' -type f | head -1)" + [[ -n "$bin" ]] || { echo "FAIL: no program built"; exit 1; } + readelf -d "$bin" | grep -q 'NEEDED.*libstdc++\.so' +} + +# ── 1 ── +write_manifest "" +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: 1: build failed"; exit 1; } +if needs_libstdcxx; then + echo "FAIL: 1: the default build is not self-contained"; exit 1 +fi +echo "ok: 1" + +# ── 2 ── +rm -rf target +write_manifest "$host" +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: 2: build failed"; exit 1; } +needs_libstdcxx || { + cat b2.log; echo "FAIL: 2: [target.$host] cxx_runtime was not applied to a host build"; exit 1; } +[[ "$("$MCPP" run 2>&1 | tail -1)" == "RT_OK" ]] || { echo "FAIL: 2: the program does not run"; exit 1; } +echo "ok: 2" + +# ── 3 ── +rm -rf target +write_manifest "$(uname -m)-unknown-linux-gnu" +"$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: 3: build failed"; exit 1; } +needs_libstdcxx || { + cat b3.log; echo "FAIL: 3: the row was not matched under another spelling"; exit 1; } +echo "ok: 3" + +echo "PASS: 802_a_host_build_applies_its_host_row" diff --git a/tests/e2e/803_a_recorded_payload_that_is_gone_is_refused_offline.sh b/tests/e2e/803_a_recorded_payload_that_is_gone_is_refused_offline.sh new file mode 100755 index 000000000..8678df946 --- /dev/null +++ b/tests/e2e/803_a_recorded_payload_that_is_gone_is_refused_offline.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# requires: gcc +# 803_a_recorded_payload_that_is_gone_is_refused_offline.sh — mcpp#716. +# +# The provisioning stamp records that a list of `[xlings.workspace]` entries +# was installed once. A payload removed afterwards (`xlings remove`, a pruned +# cache) left the stamp claiming it, so the build skipped provisioning and +# succeeded while `mcpp::xpkg_dir` answered "". The stamp now counts only while +# every address still resolves to a payload; offline, a missing one is refused +# by name. +# +# Criteria: +# 1. with no stamp, an offline build refuses the undeclared-and-uninstalled +# entry (the behaviour before this change, kept); +# 2. with a stamp for the same list and no payload on disk, the offline build +# is refused, naming the address and the record, where it used to pass. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +export MCPP_HOME="$TMP/mcpphome" +mkdir -p "$MCPP_HOME" +if [ -d "$HOME/.mcpp/registry" ]; then + ln -s "$HOME/.mcpp/registry" "$MCPP_HOME/registry" +fi + +mkdir -p app/src +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[xlings.workspace] +mcpp-e2e-payload-that-is-gone = "1.0.0" +EOF +echo 'int main() {}' > app/src/main.cpp +cd app + +# ── 1 ── +if "$MCPP" build --offline > b1.log 2>&1; then + cat b1.log; echo "FAIL: 1: an uninstalled entry was accepted offline"; exit 1 +fi +address="$(sed -n 's/^ *declared: //p' b1.log | head -1)" +[[ "$address" == *mcpp-e2e-payload-that-is-gone* ]] || { + cat b1.log; echo "FAIL: 1: the refusal does not name the declared entry"; exit 1; } +echo "ok: 1" + +# ── 2 ── +# The stamp a successful provisioning of this list writes: FNV-1a of the list, +# one address per line, as `provision_xlings_addresses` computes it. +stamp="$(python3 - "$address" <<'PY' +import sys +h = 1469598103934665603 +for ch in (sys.argv[1] + "\n").encode(): + h ^= ch + h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF +print(f"xlings-deps-{h:016x}") +PY +)" +mkdir -p "$MCPP_HOME/provisioned" +printf '%s\n' "$address" > "$MCPP_HOME/provisioned/$stamp" + +if "$MCPP" build --offline > b2.log 2>&1; then + cat b2.log; echo "FAIL: 2: a recorded payload that is not installed was accepted"; exit 1 +fi +grep -q "recorded as provisioned" b2.log && grep -q "$address" b2.log \ + && grep -q "$stamp" b2.log || { + cat b2.log; echo "FAIL: 2: the refusal does not name the address and the record"; exit 1; } +echo "ok: 2" + +echo "PASS: 803_a_recorded_payload_that_is_gone_is_refused_offline" diff --git a/tests/unit/test_workspace_inheritance.cpp b/tests/unit/test_workspace_inheritance.cpp index 8c1e2b493..496fcba1c 100644 --- a/tests/unit/test_workspace_inheritance.cpp +++ b/tests/unit/test_workspace_inheritance.cpp @@ -165,3 +165,89 @@ TEST(SnapshotPostcondition, UnfoldedDefinesAreAnInternalError) { mcpp::build::fold_build_defines_into_flags(m.buildConfig); EXPECT_FALSE(mcpp::build::unfolded_defines_error(m).has_value()); } + +// #713. A member inherits the workspace root's `[xlings.workspace]` entries, +// conditional rows included; a package the member declares itself keeps the +// member's address, because the nearer declaration wins (SPEC-004 §4.5). +TEST(WorkspaceXlings, AMemberInheritsTheRootsEntriesAndItsOwnWins) { + auto ws = mcpp::manifest::parse_string( + "[workspace]\nmembers = [\"m\"]\n\n" + "[xlings.workspace]\nninja = \"1.12.1\"\ncmake = \"3.30.0\"\n\n" + "[target.'cfg(os = \"linux\")'.xlings.workspace]\npatchelf = \"0.18.0\"\n"); + ASSERT_TRUE(ws.has_value()) << ws.error().format(); + auto member = mcpp::manifest::parse_string( + "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n" + "[xlings.workspace]\ncmake = \"3.31.0\"\n", + "m/mcpp.toml", {.insideWorkspace = true}); + ASSERT_TRUE(member.has_value()) << member.error().format(); + + mcpp::project::inherit_workspace_xlings(*member, *ws); + + auto has = [&](std::string_view needle) { + return std::ranges::any_of(member->xlings.deps, [&](const std::string& a) { + return a.find(needle) != std::string::npos; + }); + }; + EXPECT_TRUE(has("ninja@1.12.1")); + EXPECT_TRUE(has("cmake@3.31.0")); + EXPECT_FALSE(has("cmake@3.30.0")); + // The conditional row travels as a row, decided by its selector at merge time. + bool rowCarried = false; + for (auto const& cc : member->conditionalConfigs) + for (auto const& a : cc.xlings.deps) + rowCarried = rowCarried || a.find("patchelf@0.18.0") != std::string::npos; + EXPECT_TRUE(rowCarried); +} + +// #714. An entry that says `workspace = true` and that no workspace resolved is +// refused by name, in every dependency table. +TEST(WorkspaceDependency, AnUnresolvedWorkspaceEntryIsNamed) { + for (std::string_view table : {"dependencies", "dev-dependencies", "build-dependencies"}) { + SCOPED_TRACE(std::string(table)); + auto m = mcpp::manifest::parse_string(std::format( + "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n[{}]\nfmt = {{ workspace = true }}\n", + table), "m/mcpp.toml", {.insideWorkspace = true}); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto err = mcpp::project::unresolved_workspace_dependency_error(*m, "/p/m"); + ASSERT_TRUE(err.has_value()); + EXPECT_NE(err->find(std::format("[{}] fmt", table)), std::string::npos) << *err; + EXPECT_NE(err->find("members"), std::string::npos) << *err; + } + auto resolved = mcpp::manifest::parse_string( + "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n[dependencies]\nfmt = \"11.0.0\"\n"); + ASSERT_TRUE(resolved.has_value()); + EXPECT_FALSE(mcpp::project::unresolved_workspace_dependency_error(*resolved, "/p/m")); +} + +// #710. A host tool's toolchain is the one its own build would use: its host +// row, then `[toolchain]`, each after the root-position keys of the workspace +// that lists it. A member tool that declares nothing takes the workspace's. +TEST(HostToolToolchain, AMemberToolReadsItsWorkspaceToolchain) { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() + / std::format("mcpp-710-{:x}", std::random_device{}()); + fs::create_directories(root / "tool"); + auto write = [](const fs::path& p, std::string_view text) { + std::ofstream(p) << text; + }; + write(root / "mcpp.toml", + "[workspace]\nmembers = [\"tool\"]\n\n[toolchain]\ndefault = \"gcc@15.1.0\"\n"); + write(root / "tool" / "mcpp.toml", + "[package]\nname = \"tool\"\nversion = \"0.1.0\"\n"); + auto tool = mcpp::manifest::load(root / "tool" / "mcpp.toml"); + ASSERT_TRUE(tool.has_value()); + EXPECT_EQ(mcpp::build::host_tool_declared_toolchain(*tool, root / "tool", "linux"), + std::optional("gcc@15.1.0")); + + // The tool's own declaration wins over the workspace's. + write(root / "tool" / "mcpp.toml", + "[package]\nname = \"tool\"\nversion = \"0.1.0\"\n\n" + "[toolchain]\ndefault = \"gcc@16.1.0\"\n"); + tool = mcpp::manifest::load(root / "tool" / "mcpp.toml"); + ASSERT_TRUE(tool.has_value()); + EXPECT_EQ(mcpp::build::host_tool_declared_toolchain(*tool, root / "tool", "linux"), + std::optional("gcc@16.1.0")); + + std::error_code ec; + fs::remove_all(root, ec); +}